• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

visgl / loaders.gl / 24153816851

08 Apr 2026 07:17PM UTC coverage: 53.247% (-12.1%) from 65.319%
24153816851

push

github

web-flow
chore: Move from tape to vitest (#3351)

8651 of 17291 branches covered (50.03%)

Branch coverage included in aggregate %.

7 of 7 new or added lines in 1 file covered. (100.0%)

2031 existing lines in 296 files now uncovered.

17563 of 31940 relevant lines covered (54.99%)

5279.54 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

89.53
/modules/csv/src/csv-loader.ts
1
// loaders.gl
2
// SPDX-License-Identifier: MIT
3
// Copyright (c) vis.gl contributors
4

5
import type {LoaderWithParser, LoaderOptions} from '@loaders.gl/loader-utils';
6
import type {Schema, ArrayRowTable, ObjectRowTable, TableBatch} from '@loaders.gl/schema';
7

8
import {log, toArrayBufferIterator} from '@loaders.gl/loader-utils';
9
import {
10
  AsyncQueue,
11
  deduceTableSchema,
12
  TableBatchBuilder,
13
  convertToArrayRow,
14
  convertToObjectRow
15
} from '@loaders.gl/schema-utils';
16
import Papa from './papaparse/papaparse';
17
import AsyncIteratorStreamer from './papaparse/async-iterator-streamer';
18
import {CSVFormat} from './csv-format';
19

20
// __VERSION__ is injected by babel-plugin-version-inline
21
// @ts-ignore TS2304: Cannot find name '__VERSION__'.
22
const VERSION = typeof __VERSION__ !== 'undefined' ? __VERSION__ : 'latest';
7!
23

24
const DEFAULT_CSV_SHAPE = 'object-row-table';
7✔
25

26
export type CSVLoaderOptions = LoaderOptions & {
27
  csv?: {
28
    // loaders.gl options
29
    shape?: 'array-row-table' | 'object-row-table';
30
    /** optimizes memory usage but increases parsing time. */
31
    optimizeMemoryUsage?: boolean;
32
    columnPrefix?: string;
33
    header?: 'auto';
34

35
    // CSV options (papaparse)
36
    // delimiter: auto
37
    // newline: auto
38
    quoteChar?: string;
39
    escapeChar?: string;
40
    // Convert numbers and boolean values in rows from strings
41
    dynamicTyping?: boolean;
42
    comments?: boolean;
43
    skipEmptyLines?: boolean | 'greedy';
44
    // transform: null?
45
    delimitersToGuess?: string[];
46
    // fastMode: auto
47
  };
48
};
49

50
export const CSVLoader = {
7✔
51
  ...CSVFormat,
52

53
  dataType: null as unknown as ObjectRowTable | ArrayRowTable,
54
  batchType: null as unknown as TableBatch,
55
  version: VERSION,
56
  parse: async (arrayBuffer: ArrayBuffer, options?: CSVLoaderOptions) =>
57
    parseCSV(new TextDecoder().decode(arrayBuffer), options),
1✔
58
  parseText: (text: string, options?: CSVLoaderOptions) => parseCSV(text, options),
50✔
59
  parseInBatches: parseCSVInBatches,
60
  // @ts-ignore
61
  // testText: null,
62
  options: {
63
    csv: {
64
      shape: DEFAULT_CSV_SHAPE, // 'object-row-table'
65
      optimizeMemoryUsage: false,
66
      // CSV options
67
      header: 'auto',
68
      columnPrefix: 'column',
69
      // delimiter: auto
70
      // newline: auto
71
      quoteChar: '"',
72
      escapeChar: '"',
73
      dynamicTyping: true,
74
      comments: false,
75
      skipEmptyLines: true,
76
      // transform: null?
77
      delimitersToGuess: [',', '\t', '|', ';']
78
      // fastMode: auto
79
    }
80
  }
81
} as const satisfies LoaderWithParser<ObjectRowTable | ArrayRowTable, TableBatch, CSVLoaderOptions>;
82

83
async function parseCSV(
84
  csvText: string,
85
  options?: CSVLoaderOptions
86
): Promise<ObjectRowTable | ArrayRowTable> {
87
  // Apps can call the parse method directly, so we apply default options here
88
  const csvOptions = {...CSVLoader.options.csv, ...options?.csv};
51✔
89

90
  const firstRow = readFirstRow(csvText);
51✔
91
  const header: boolean =
92
    csvOptions.header === 'auto' ? isHeaderRow(firstRow) : Boolean(csvOptions.header);
51✔
93

94
  const parseWithHeader = header;
51✔
95

96
  const papaparseConfig = {
51✔
97
    // dynamicTyping: true,
98
    ...csvOptions,
99
    header: parseWithHeader,
100
    download: false, // We handle loading, no need for papaparse to do it for us
101
    transformHeader: parseWithHeader ? duplicateColumnTransformer() : undefined,
51✔
102
    error: e => {
103
      throw new Error(e);
×
104
    }
105
  };
106

107
  const result = Papa.parse(csvText, papaparseConfig);
51✔
108
  const rows = result.data as any[];
51✔
109

110
  const headerRow = result.meta.fields || generateHeader(csvOptions.columnPrefix, firstRow.length);
51✔
111

112
  const shape = csvOptions.shape || DEFAULT_CSV_SHAPE;
51!
113
  let table: ArrayRowTable | ObjectRowTable;
114
  switch (shape) {
51!
115
    case 'object-row-table':
116
      table = {
46✔
117
        shape: 'object-row-table',
118
        data: rows.map(row => (Array.isArray(row) ? convertToObjectRow(row, headerRow) : row))
89,315✔
119
      };
120
      break;
46✔
121
    case 'array-row-table':
122
      table = {
4✔
123
        shape: 'array-row-table',
124
        data: rows.map(row => (Array.isArray(row) ? row : convertToArrayRow(row, headerRow)))
10!
125
      };
126
      break;
4✔
127
    default:
UNCOV
128
      throw new Error(shape);
×
129
  }
130
  table.schema = deduceTableSchema(table!);
50✔
131
  return table;
50✔
132
}
133

134
// TODO - support batch size 0 = no batching/single batch?
135
function parseCSVInBatches(
136
  asyncIterator:
137
    | AsyncIterable<ArrayBufferLike | ArrayBufferView>
138
    | Iterable<ArrayBufferLike | ArrayBufferView>,
139
  options?: CSVLoaderOptions
140
): AsyncIterable<TableBatch> {
141
  // Papaparse does not support standard batch size handling
142
  // TODO - investigate papaparse chunks mode
143
  options = {...options};
27✔
144
  if (options?.core?.batchSize === 'auto') {
27✔
145
    options.core.batchSize = 4000;
24✔
146
  }
147

148
  // Apps can call the parse method directly, we so apply default options here
149
  const csvOptions = {...CSVLoader.options.csv, ...options?.csv};
27✔
150

151
  const asyncQueue = new AsyncQueue<TableBatch>();
27✔
152

153
  let isFirstRow: boolean = true;
27✔
154
  let headerRow: string[] | null = null;
27✔
155
  let tableBatchBuilder: TableBatchBuilder | null = null;
27✔
156
  let schema: Schema | null = null;
27✔
157

158
  const config = {
27✔
159
    // dynamicTyping: true, // Convert numbers and boolean values in rows from strings,
160
    ...csvOptions,
161
    header: false, // Unfortunately, header detection is not automatic and does not infer shapes
162
    download: false, // We handle loading, no need for papaparse to do it for us
163
    // chunkSize is set to 5MB explicitly (same as Papaparse default) due to a bug where the
164
    // streaming parser gets stuck if skipEmptyLines and a step callback are both supplied.
165
    // See https://github.com/mholt/PapaParse/issues/465
166
    chunkSize: 1024 * 1024 * 5,
167
    // skipEmptyLines is set to a boolean value if supplied. Greedy is set to true
168
    // skipEmptyLines is handled manually given two bugs where the streaming parser gets stuck if
169
    // both of the skipEmptyLines and step callback options are provided:
170
    // - true doesn't work unless chunkSize is set: https://github.com/mholt/PapaParse/issues/465
171
    // - greedy doesn't work: https://github.com/mholt/PapaParse/issues/825
172
    skipEmptyLines: false,
173

174
    // step is called on every row
175
    // eslint-disable-next-line complexity, max-statements
176
    step(results) {
177
      let row = results.data;
7,867✔
178

179
      if (csvOptions.skipEmptyLines === 'greedy') {
7,867✔
180
        // Manually reject lines that are empty
181
        const collapsedRow = row.flat().join('').trim();
8✔
182
        if (collapsedRow === '') {
8✔
183
          return;
5✔
184
        }
185
      } else if (csvOptions.skipEmptyLines === true) {
7,859!
186
        row = normalizePapaStreamingRow(row);
7,859✔
187
        if (row.length === 1 && row[0] === null) {
7,859✔
188
          return;
6✔
189
        }
190
      }
191
      const bytesUsed = results.meta.cursor;
7,856✔
192

193
      // Check if we need to save a header row
194
      if (isFirstRow && !headerRow) {
7,856✔
195
        // Auto detects or can be forced with csvOptions.header
196
        const header = csvOptions.header === 'auto' ? isHeaderRow(row) : Boolean(csvOptions.header);
27✔
197
        if (header) {
27✔
198
          headerRow = row.map(duplicateColumnTransformer());
15✔
199
          return;
15✔
200
        }
201
      }
202

203
      // If first data row, we can deduce the schema
204
      if (isFirstRow) {
7,841✔
205
        isFirstRow = false;
27✔
206
        if (!headerRow) {
27✔
207
          headerRow = generateHeader(csvOptions.columnPrefix, row.length);
12✔
208
        }
209
        schema = deduceCSVSchema(row, headerRow);
27✔
210
      }
211

212
      if (csvOptions.optimizeMemoryUsage) {
7,841!
213
        // A workaround to allocate new strings and don't retain pointers to original strings.
214
        // https://bugs.chromium.org/p/v8/issues/detail?id=2869
UNCOV
215
        row = JSON.parse(JSON.stringify(row));
×
216
      }
217

218
      const shape = (options as any)?.shape || csvOptions.shape || DEFAULT_CSV_SHAPE;
7,841!
219
      if (shape === 'object-row-table' && headerRow && row.length > headerRow.length) {
7,867✔
220
        row = convertToPapaObjectRow(row, headerRow);
4✔
221
      }
222

223
      // Add the row
224
      tableBatchBuilder =
7,841✔
225
        tableBatchBuilder ||
7,868✔
226
        new TableBatchBuilder(
227
          // @ts-expect-error TODO this is not a proper schema
228
          schema,
229
          {
230
            shape,
231
            ...(options?.core || {})
28✔
232
          }
233
        );
234

235
      try {
7,867✔
236
        tableBatchBuilder.addRow(row);
7,867✔
237
        // If a batch has been completed, emit it
238
        const batch = tableBatchBuilder && tableBatchBuilder.getFullBatch({bytesUsed});
7,867✔
239
        if (batch) {
7,867✔
240
          asyncQueue.enqueue(batch);
84✔
241
        }
242
      } catch (error) {
UNCOV
243
        asyncQueue.enqueue(error as Error);
×
244
      }
245
    },
246

247
    // complete is called when all rows have been read
248
    complete(results) {
249
      try {
27✔
250
        const bytesUsed = results.meta.cursor;
27✔
251
        // Ensure any final (partial) batch gets emitted
252
        const batch = tableBatchBuilder && tableBatchBuilder.getFinalBatch({bytesUsed});
27✔
253
        if (batch) {
27✔
254
          asyncQueue.enqueue(batch);
25✔
255
        }
256
      } catch (error) {
257
        asyncQueue.enqueue(error as Error);
×
258
      }
259

260
      asyncQueue.close();
27✔
261
    }
262
  };
263

264
  Papa.parse(toArrayBufferIterator(asyncIterator), config, AsyncIteratorStreamer);
27✔
265

266
  // TODO - Does it matter if we return asyncIterable or asyncIterator
267
  // return asyncQueue[Symbol.asyncIterator]();
268
  return asyncQueue;
27✔
269
}
270

271
/**
272
 * Checks if a certain row is a header row
273
 * @param row the row to check
274
 * @returns true if the row looks like a header
275
 */
276
function isHeaderRow(row: string[]): boolean {
277
  return row && row.every(value => typeof value === 'string');
254✔
278
}
279

280
/**
281
 * Reads, parses, and returns the first row of a CSV text
282
 * @param csvText the csv text to parse
283
 * @returns the first row
284
 */
285
function readFirstRow(csvText: string): any[] {
286
  const result = Papa.parse(csvText, {
51✔
287
    dynamicTyping: true,
288
    preview: 1
289
  });
290
  return result.data[0];
51✔
291
}
292

293
/**
294
 * Creates a transformer that renames duplicate columns. This is needed as Papaparse doesn't handle
295
 * duplicate header columns and would use the latest occurrence by default.
296
 * See the header option in https://www.papaparse.com/docs#config
297
 * @returns a transform function that returns sanitized names for duplicate fields
298
 */
299
function duplicateColumnTransformer(): (column: string) => string {
300
  const observedColumns = new Set<string>();
46✔
301
  return col => {
46✔
302
    let colName = col;
277✔
303
    let counter = 1;
277✔
304
    while (observedColumns.has(colName)) {
277✔
305
      colName = `${col}.${counter}`;
45✔
306
      counter++;
45✔
307
    }
308
    observedColumns.add(colName);
277✔
309
    return colName;
277✔
310
  };
311
}
312

313
/**
314
 * Generates the header of a CSV given a prefix and a column count
315
 * @param columnPrefix the columnPrefix to use
316
 * @param count the count of column names to generate
317
 * @returns an array of column names
318
 */
319
function generateHeader(columnPrefix: string, count: number = 0): string[] {
31✔
320
  const headers: string[] = [];
31✔
321
  for (let i = 0; i < count; i++) {
31✔
322
    headers.push(`${columnPrefix}${i + 1}`);
96✔
323
  }
324
  return headers;
31✔
325
}
326

327
function normalizePapaStreamingRow(row: unknown[]): unknown[] {
328
  return row.map(value => (Array.isArray(value) && value.length === 0 ? null : value));
34,099✔
329
}
330

331
function convertToPapaObjectRow(
332
  row: unknown[],
333
  headerRow: string[]
334
): {[columnName: string]: unknown} {
335
  const objectRow = convertToObjectRow(row, headerRow);
4✔
336
  const parsedExtra = row.slice(headerRow.length);
4✔
337
  if (parsedExtra.length > 0) {
4!
338
    objectRow.__parsed_extra = parsedExtra;
4✔
339
  }
340
  return objectRow;
4✔
341
}
342

343
function deduceCSVSchema(row, headerRow): Schema {
344
  const fields: Schema['fields'] = [];
27✔
345
  for (let i = 0; i < row.length; i++) {
27✔
346
    const columnName = (headerRow && headerRow[i]) || i;
112!
347
    const value = row[i];
112✔
348
    switch (typeof value) {
112!
349
      case 'number':
350
        fields.push({name: String(columnName), type: 'float64', nullable: true});
38✔
351
        break;
38✔
352
      case 'boolean':
353
        fields.push({name: String(columnName), type: 'bool', nullable: true});
×
354
        break;
×
355
      case 'string':
356
        fields.push({name: String(columnName), type: 'utf8', nullable: true});
74✔
357
        break;
74✔
358
      default:
359
        log.warn(`CSV: Unknown column type: ${typeof value}`)();
×
360
        fields.push({name: String(columnName), type: 'utf8', nullable: true});
×
361
    }
362
  }
363
  return {
27✔
364
    fields,
365
    metadata: {
366
      'loaders.gl#format': 'csv',
367
      'loaders.gl#loader': 'CSVLoader'
368
    }
369
  };
370
}
371

372
// TODO - remove
373
// type ObjectField = {name: string; index: number; type: any};
374
// type ObjectSchema = {[key: string]: ObjectField} | ObjectField[];
375

376
// function deduceObjectSchema(row, headerRow): ObjectSchema {
377
//   const schema: ObjectSchema = headerRow ? {} : [];
378
//   for (let i = 0; i < row.length; i++) {
379
//     const columnName = (headerRow && headerRow[i]) || i;
380
//     const value = row[i];
381
//     switch (typeof value) {
382
//       case 'number':
383
//       case 'boolean':
384
//         // TODO - booleans could be handled differently...
385
//         schema[columnName] = {name: String(columnName), index: i, type: Float32Array};
386
//         break;
387
//       case 'string':
388
//       default:
389
//         schema[columnName] = {name: String(columnName), index: i, type: Array};
390
//       // We currently only handle numeric rows
391
//       // TODO we could offer a function to map strings to numbers?
392
//     }
393
//   }
394
//   return schema;
395
// }
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc