rowpipe 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -32
- package/dist/cli/index.js +4 -4
- package/dist/cli/index.js.map +1 -1
- package/dist/core/types.d.ts +2 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/readers/csv.d.ts.map +1 -1
- package/dist/readers/csv.js +28 -16
- package/dist/readers/csv.js.map +1 -1
- package/dist/readers/index.d.ts +1 -0
- package/dist/readers/index.d.ts.map +1 -1
- package/dist/readers/index.js +29 -3
- package/dist/readers/index.js.map +1 -1
- package/dist/readers/json.d.ts.map +1 -1
- package/dist/readers/json.js +6 -8
- package/dist/readers/json.js.map +1 -1
- package/dist/readers/jsonl.d.ts.map +1 -1
- package/dist/readers/jsonl.js +5 -7
- package/dist/readers/jsonl.js.map +1 -1
- package/dist/readers/parquet.d.ts +17 -0
- package/dist/readers/parquet.d.ts.map +1 -0
- package/dist/readers/parquet.js +146 -0
- package/dist/readers/parquet.js.map +1 -0
- package/dist/utils/compression.d.ts +30 -6
- package/dist/utils/compression.d.ts.map +1 -1
- package/dist/utils/compression.js +95 -18
- package/dist/utils/compression.js.map +1 -1
- package/dist/writers/csv.d.ts.map +1 -1
- package/dist/writers/csv.js +9 -11
- package/dist/writers/csv.js.map +1 -1
- package/dist/writers/index.d.ts +2 -0
- package/dist/writers/index.d.ts.map +1 -1
- package/dist/writers/index.js +49 -4
- package/dist/writers/index.js.map +1 -1
- package/dist/writers/json.d.ts +1 -1
- package/dist/writers/json.d.ts.map +1 -1
- package/dist/writers/json.js +9 -11
- package/dist/writers/json.js.map +1 -1
- package/dist/writers/jsonl.d.ts +1 -1
- package/dist/writers/jsonl.d.ts.map +1 -1
- package/dist/writers/jsonl.js +9 -11
- package/dist/writers/jsonl.js.map +1 -1
- package/dist/writers/markdown.d.ts +21 -0
- package/dist/writers/markdown.d.ts.map +1 -0
- package/dist/writers/markdown.js +82 -0
- package/dist/writers/markdown.js.map +1 -0
- package/dist/writers/parquet.d.ts +19 -0
- package/dist/writers/parquet.d.ts.map +1 -0
- package/dist/writers/parquet.js +129 -0
- package/dist/writers/parquet.js.map +1 -0
- package/package.json +13 -2
- package/skills/rowpipe/SKILL.md +181 -0
- package/benchmarks/memory-bench.ts +0 -109
- package/src/analytics/reduce.ts +0 -388
- package/src/analytics/schema-inference.ts +0 -217
- package/src/analytics/semantic-types.ts +0 -36
- package/src/analytics/stats.ts +0 -383
- package/src/analytics/validator.ts +0 -134
- package/src/cli/commands/cast.ts +0 -60
- package/src/cli/commands/convert.ts +0 -128
- package/src/cli/commands/filter.ts +0 -58
- package/src/cli/commands/inspect.ts +0 -193
- package/src/cli/commands/map.ts +0 -66
- package/src/cli/commands/reduce.ts +0 -74
- package/src/cli/commands/rename.ts +0 -59
- package/src/cli/commands/sample.ts +0 -61
- package/src/cli/commands/schema.ts +0 -86
- package/src/cli/commands/select.ts +0 -59
- package/src/cli/commands/stats.ts +0 -100
- package/src/cli/commands/validate.ts +0 -123
- package/src/cli/index.ts +0 -285
- package/src/core/batch.ts +0 -81
- package/src/core/errors.ts +0 -81
- package/src/core/pipeline.ts +0 -150
- package/src/core/types.ts +0 -110
- package/src/index.ts +0 -52
- package/src/readers/csv.ts +0 -302
- package/src/readers/index.ts +0 -88
- package/src/readers/json.ts +0 -256
- package/src/readers/jsonl.ts +0 -126
- package/src/readers/xlsx.ts +0 -182
- package/src/transforms/cast.ts +0 -212
- package/src/transforms/expression.ts +0 -1114
- package/src/transforms/filter.ts +0 -38
- package/src/transforms/map.ts +0 -99
- package/src/transforms/rename.ts +0 -47
- package/src/transforms/sample.ts +0 -57
- package/src/transforms/select.ts +0 -64
- package/src/utils/compression.ts +0 -43
- package/src/utils/formatting.ts +0 -75
- package/src/utils/progress.ts +0 -37
- package/src/writers/csv.ts +0 -106
- package/src/writers/index.ts +0 -87
- package/src/writers/json.ts +0 -52
- package/src/writers/jsonl.ts +0 -49
- package/src/writers/xlsx.ts +0 -87
- package/tests/analytics.test.ts +0 -119
- package/tests/cli-integration.test.ts +0 -187
- package/tests/map-reduce.test.ts +0 -223
- package/tests/readers-writers.test.ts +0 -189
- package/tests/transforms.test.ts +0 -199
- package/tsconfig.json +0 -21
- package/vitest.config.ts +0 -9
package/src/readers/csv.ts
DELETED
|
@@ -1,302 +0,0 @@
|
|
|
1
|
-
import { createReadStream } from "node:fs";
|
|
2
|
-
import type { Readable } from "node:stream";
|
|
3
|
-
import { ParseError } from "../core/errors.js";
|
|
4
|
-
import type {
|
|
5
|
-
DataBatch,
|
|
6
|
-
DataStream,
|
|
7
|
-
InspectionMetadata,
|
|
8
|
-
ReaderOptions,
|
|
9
|
-
Row,
|
|
10
|
-
TabularReader,
|
|
11
|
-
} from "../core/types.js";
|
|
12
|
-
|
|
13
|
-
export interface CSVReaderOptions extends ReaderOptions {
|
|
14
|
-
delimiter?: string;
|
|
15
|
-
header?: boolean;
|
|
16
|
-
filePath?: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Streaming CSV Reader supporting RFC 4180 quotes, multiline cells,
|
|
21
|
-
* custom & auto-detected delimiters, BOM removal, and exact error reporting.
|
|
22
|
-
*/
|
|
23
|
-
export class CSVReader implements TabularReader {
|
|
24
|
-
private input: Readable | string;
|
|
25
|
-
private options: CSVReaderOptions;
|
|
26
|
-
|
|
27
|
-
constructor(input: Readable | string, options: CSVReaderOptions = {}) {
|
|
28
|
-
this.input = input;
|
|
29
|
-
this.options = { ...options };
|
|
30
|
-
if (typeof input === "string") {
|
|
31
|
-
this.options.filePath = input;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
private getInputStream(): Readable {
|
|
36
|
-
if (typeof this.input === "string") {
|
|
37
|
-
return createReadStream(this.input);
|
|
38
|
-
}
|
|
39
|
-
return this.input;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Auto-detects delimiter by scanning candidate delimiters in a sample string.
|
|
44
|
-
*/
|
|
45
|
-
public static detectDelimiter(sample: string): string {
|
|
46
|
-
const candidates = [",", "\t", ";", "|"];
|
|
47
|
-
const firstLine = sample.split(/\r?\n/)[0] || "";
|
|
48
|
-
|
|
49
|
-
let bestDelimiter = ",";
|
|
50
|
-
let maxCount = -1;
|
|
51
|
-
|
|
52
|
-
for (const cand of candidates) {
|
|
53
|
-
let count = 0;
|
|
54
|
-
let inQuotes = false;
|
|
55
|
-
for (let i = 0; i < firstLine.length; i++) {
|
|
56
|
-
const ch = firstLine[i];
|
|
57
|
-
if (ch === '"') {
|
|
58
|
-
inQuotes = !inQuotes;
|
|
59
|
-
} else if (ch === cand && !inQuotes) {
|
|
60
|
-
count++;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
if (count > maxCount) {
|
|
64
|
-
maxCount = count;
|
|
65
|
-
bestDelimiter = cand;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return maxCount > 0 ? bestDelimiter : ",";
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async *read(options?: ReaderOptions): DataStream {
|
|
73
|
-
const mergedOptions: CSVReaderOptions = {
|
|
74
|
-
...this.options,
|
|
75
|
-
...options,
|
|
76
|
-
};
|
|
77
|
-
const batchSize = Math.max(1, mergedOptions.batchSize ?? 1000);
|
|
78
|
-
const hasHeader = mergedOptions.header !== false;
|
|
79
|
-
let delimiter = mergedOptions.delimiter;
|
|
80
|
-
|
|
81
|
-
const stream = this.getInputStream();
|
|
82
|
-
|
|
83
|
-
let buffer = "";
|
|
84
|
-
let headers: string[] | null = null;
|
|
85
|
-
let isFirstChunk = true;
|
|
86
|
-
let rowsInCurrentBatch: Row[] = [];
|
|
87
|
-
let globalRowOffset = 0;
|
|
88
|
-
let rowNumber = 0;
|
|
89
|
-
let byteOffset = 0;
|
|
90
|
-
let currentField = "";
|
|
91
|
-
let currentRowFields: string[] = [];
|
|
92
|
-
let inQuotes = false;
|
|
93
|
-
let cursor = 0;
|
|
94
|
-
|
|
95
|
-
for await (const chunk of stream) {
|
|
96
|
-
const chunkStr: string =
|
|
97
|
-
typeof chunk === "string" ? chunk : (chunk as Buffer).toString("utf8");
|
|
98
|
-
|
|
99
|
-
if (isFirstChunk) {
|
|
100
|
-
isFirstChunk = false;
|
|
101
|
-
// Remove UTF-8 BOM if present
|
|
102
|
-
if (chunkStr.charCodeAt(0) === 0xfeff) {
|
|
103
|
-
buffer = chunkStr.slice(1);
|
|
104
|
-
} else {
|
|
105
|
-
buffer = chunkStr;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Auto-detect delimiter if not specified
|
|
109
|
-
if (!delimiter) {
|
|
110
|
-
delimiter = CSVReader.detectDelimiter(buffer);
|
|
111
|
-
}
|
|
112
|
-
} else {
|
|
113
|
-
buffer += chunkStr;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const activeDelimiter = delimiter ?? ",";
|
|
117
|
-
const delimLen = activeDelimiter.length;
|
|
118
|
-
|
|
119
|
-
while (cursor < buffer.length) {
|
|
120
|
-
const char = buffer[cursor];
|
|
121
|
-
|
|
122
|
-
if (inQuotes) {
|
|
123
|
-
if (char === '"') {
|
|
124
|
-
if (cursor + 1 < buffer.length && buffer[cursor + 1] === '"') {
|
|
125
|
-
currentField += '"';
|
|
126
|
-
cursor += 2;
|
|
127
|
-
byteOffset += 2;
|
|
128
|
-
continue;
|
|
129
|
-
} else {
|
|
130
|
-
inQuotes = false;
|
|
131
|
-
cursor++;
|
|
132
|
-
byteOffset++;
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
} else {
|
|
136
|
-
currentField += char;
|
|
137
|
-
cursor++;
|
|
138
|
-
byteOffset++;
|
|
139
|
-
continue;
|
|
140
|
-
}
|
|
141
|
-
} else {
|
|
142
|
-
if (char === '"') {
|
|
143
|
-
if (currentField.length === 0) {
|
|
144
|
-
inQuotes = true;
|
|
145
|
-
cursor++;
|
|
146
|
-
byteOffset++;
|
|
147
|
-
continue;
|
|
148
|
-
} else {
|
|
149
|
-
throw new ParseError(
|
|
150
|
-
`Unexpected quote inside unquoted field at byte offset ${byteOffset}`,
|
|
151
|
-
{
|
|
152
|
-
file: mergedOptions.filePath,
|
|
153
|
-
row: rowNumber + 1,
|
|
154
|
-
column: currentRowFields.length + 1,
|
|
155
|
-
byteOffset,
|
|
156
|
-
}
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Check for delimiter
|
|
162
|
-
if (buffer.startsWith(activeDelimiter, cursor)) {
|
|
163
|
-
currentRowFields.push(currentField);
|
|
164
|
-
currentField = "";
|
|
165
|
-
cursor += delimLen;
|
|
166
|
-
byteOffset += delimLen;
|
|
167
|
-
continue;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// Check for CRLF / LF newline
|
|
171
|
-
if (char === "\r" || char === "\n") {
|
|
172
|
-
currentRowFields.push(currentField);
|
|
173
|
-
currentField = "";
|
|
174
|
-
|
|
175
|
-
if (char === "\r" && cursor + 1 < buffer.length && buffer[cursor + 1] === "\n") {
|
|
176
|
-
cursor += 2;
|
|
177
|
-
byteOffset += 2;
|
|
178
|
-
} else {
|
|
179
|
-
cursor++;
|
|
180
|
-
byteOffset++;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Skip entirely empty row if it's trailing whitespace
|
|
184
|
-
if (
|
|
185
|
-
currentRowFields.length === 1 &&
|
|
186
|
-
currentRowFields[0]?.trim() === "" &&
|
|
187
|
-
cursor >= buffer.length
|
|
188
|
-
) {
|
|
189
|
-
currentRowFields = [];
|
|
190
|
-
continue;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
if (hasHeader && headers === null) {
|
|
194
|
-
headers = currentRowFields.map((h, i) =>
|
|
195
|
-
h.trim().length > 0 ? h.trim() : `col_${i + 1}`
|
|
196
|
-
);
|
|
197
|
-
currentRowFields = [];
|
|
198
|
-
continue;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
if (headers === null) {
|
|
202
|
-
headers = currentRowFields.map((_, i) => `col_${i + 1}`);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// Construct Row object
|
|
206
|
-
const rowObj: Row = {};
|
|
207
|
-
for (let i = 0; i < headers.length; i++) {
|
|
208
|
-
const key = headers[i] || `col_${i + 1}`;
|
|
209
|
-
const val = currentRowFields[i] ?? "";
|
|
210
|
-
rowObj[key] = val;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
rowNumber++;
|
|
214
|
-
rowsInCurrentBatch.push(rowObj);
|
|
215
|
-
currentRowFields = [];
|
|
216
|
-
|
|
217
|
-
if (rowsInCurrentBatch.length >= batchSize) {
|
|
218
|
-
yield {
|
|
219
|
-
rows: rowsInCurrentBatch,
|
|
220
|
-
offset: globalRowOffset,
|
|
221
|
-
};
|
|
222
|
-
globalRowOffset += rowsInCurrentBatch.length;
|
|
223
|
-
rowsInCurrentBatch = [];
|
|
224
|
-
}
|
|
225
|
-
continue;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
currentField += char;
|
|
229
|
-
cursor++;
|
|
230
|
-
byteOffset++;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// Compact processed buffer to keep memory bounded
|
|
235
|
-
buffer = buffer.slice(cursor);
|
|
236
|
-
cursor = 0;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Process remainder if any
|
|
240
|
-
if (inQuotes) {
|
|
241
|
-
throw new ParseError("Unclosed quote at end of CSV stream", {
|
|
242
|
-
file: mergedOptions.filePath,
|
|
243
|
-
row: rowNumber + 1,
|
|
244
|
-
byteOffset,
|
|
245
|
-
});
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
if (currentField.length > 0 || currentRowFields.length > 0) {
|
|
249
|
-
currentRowFields.push(currentField);
|
|
250
|
-
|
|
251
|
-
if (hasHeader && headers === null) {
|
|
252
|
-
headers = currentRowFields.map((h, i) =>
|
|
253
|
-
h.trim().length > 0 ? h.trim() : `col_${i + 1}`
|
|
254
|
-
);
|
|
255
|
-
} else {
|
|
256
|
-
if (headers === null) {
|
|
257
|
-
headers = currentRowFields.map((_, i) => `col_${i + 1}`);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
const rowObj: Row = {};
|
|
261
|
-
for (let i = 0; i < headers.length; i++) {
|
|
262
|
-
const key = headers[i] || `col_${i + 1}`;
|
|
263
|
-
const val = currentRowFields[i] ?? "";
|
|
264
|
-
rowObj[key] = val;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
rowNumber++;
|
|
268
|
-
rowsInCurrentBatch.push(rowObj);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
if (rowsInCurrentBatch.length > 0) {
|
|
273
|
-
yield {
|
|
274
|
-
rows: rowsInCurrentBatch,
|
|
275
|
-
offset: globalRowOffset,
|
|
276
|
-
};
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
async inspect(options?: ReaderOptions): Promise<InspectionMetadata> {
|
|
281
|
-
let rowCount = 0;
|
|
282
|
-
let columnNames: string[] = [];
|
|
283
|
-
|
|
284
|
-
for await (const batch of this.read({ ...options, batchSize: 5000 })) {
|
|
285
|
-
if (columnNames.length === 0 && batch.rows.length > 0 && batch.rows[0]) {
|
|
286
|
-
columnNames = Object.keys(batch.rows[0]);
|
|
287
|
-
}
|
|
288
|
-
rowCount += batch.rows.length;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
return {
|
|
292
|
-
format: "CSV",
|
|
293
|
-
rowCount,
|
|
294
|
-
columnCount: columnNames.length,
|
|
295
|
-
columns: columnNames.map((name) => ({
|
|
296
|
-
name,
|
|
297
|
-
type: "string",
|
|
298
|
-
nullPercentage: 0,
|
|
299
|
-
})),
|
|
300
|
-
};
|
|
301
|
-
}
|
|
302
|
-
}
|
package/src/readers/index.ts
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import type { Readable } from "node:stream";
|
|
2
|
-
import { InvalidArgumentError } from "../core/errors.js";
|
|
3
|
-
import type { FormatAdapter, ReaderOptions, TabularReader } from "../core/types.js";
|
|
4
|
-
import { CSVReader } from "./csv.js";
|
|
5
|
-
import { JSONReader } from "./json.js";
|
|
6
|
-
import { JSONLReader } from "./jsonl.js";
|
|
7
|
-
import { XLSXReader } from "./xlsx.js";
|
|
8
|
-
|
|
9
|
-
export * from "./csv.js";
|
|
10
|
-
export * from "./json.js";
|
|
11
|
-
export * from "./jsonl.js";
|
|
12
|
-
export * from "./xlsx.js";
|
|
13
|
-
|
|
14
|
-
const adapters: Record<string, FormatAdapter> = {
|
|
15
|
-
csv: {
|
|
16
|
-
name: "CSV",
|
|
17
|
-
extensions: [".csv", ".tsv", ".txt"],
|
|
18
|
-
createReader: (input, options) => new CSVReader(input, options),
|
|
19
|
-
createWriter: () => {
|
|
20
|
-
throw new Error("Writer adapter called from reader");
|
|
21
|
-
},
|
|
22
|
-
},
|
|
23
|
-
json: {
|
|
24
|
-
name: "JSON",
|
|
25
|
-
extensions: [".json"],
|
|
26
|
-
createReader: (input, options) => new JSONReader(input, options),
|
|
27
|
-
createWriter: () => {
|
|
28
|
-
throw new Error("Writer adapter called from reader");
|
|
29
|
-
},
|
|
30
|
-
},
|
|
31
|
-
jsonl: {
|
|
32
|
-
name: "JSONL",
|
|
33
|
-
extensions: [".jsonl", ".ndjson", ".ldjson"],
|
|
34
|
-
createReader: (input, options) => new JSONLReader(input, options),
|
|
35
|
-
createWriter: () => {
|
|
36
|
-
throw new Error("Writer adapter called from reader");
|
|
37
|
-
},
|
|
38
|
-
},
|
|
39
|
-
xlsx: {
|
|
40
|
-
name: "XLSX",
|
|
41
|
-
extensions: [".xlsx", ".xlsm"],
|
|
42
|
-
createReader: (input, options) => new XLSXReader(input, options),
|
|
43
|
-
createWriter: () => {
|
|
44
|
-
throw new Error("Writer adapter called from reader");
|
|
45
|
-
},
|
|
46
|
-
},
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Infers format name from file path or extension.
|
|
51
|
-
*/
|
|
52
|
-
export function inferFormatFromPath(filePath: string): string | null {
|
|
53
|
-
const cleanPath = filePath.toLowerCase().replace(/\.gz$/, "");
|
|
54
|
-
for (const [format, adapter] of Object.entries(adapters)) {
|
|
55
|
-
if (adapter.extensions.some((ext) => cleanPath.endsWith(ext))) {
|
|
56
|
-
return format;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Creates an appropriate TabularReader for input and format.
|
|
64
|
-
*/
|
|
65
|
-
export function createReader(
|
|
66
|
-
input: Readable | string,
|
|
67
|
-
options: ReaderOptions & { format?: string } = {}
|
|
68
|
-
): TabularReader {
|
|
69
|
-
let format = options.format?.toLowerCase();
|
|
70
|
-
|
|
71
|
-
if (!format && typeof input === "string" && input !== "-") {
|
|
72
|
-
format = inferFormatFromPath(input) ?? undefined;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
if (!format) {
|
|
76
|
-
// Default to CSV if cannot infer
|
|
77
|
-
format = "csv";
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const adapter = adapters[format];
|
|
81
|
-
if (!adapter) {
|
|
82
|
-
throw new InvalidArgumentError(
|
|
83
|
-
`Unsupported input format: "${format}". Supported formats are: ${Object.keys(adapters).join(", ")}`
|
|
84
|
-
);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
return adapter.createReader(input, options);
|
|
88
|
-
}
|
package/src/readers/json.ts
DELETED
|
@@ -1,256 +0,0 @@
|
|
|
1
|
-
import { createReadStream } from "node:fs";
|
|
2
|
-
import type { Readable } from "node:stream";
|
|
3
|
-
import { ParseError } from "../core/errors.js";
|
|
4
|
-
import type {
|
|
5
|
-
DataBatch,
|
|
6
|
-
DataStream,
|
|
7
|
-
InspectionMetadata,
|
|
8
|
-
ReaderOptions,
|
|
9
|
-
Row,
|
|
10
|
-
TabularReader,
|
|
11
|
-
} from "../core/types.js";
|
|
12
|
-
|
|
13
|
-
export interface JSONReaderOptions extends ReaderOptions {
|
|
14
|
-
path?: string;
|
|
15
|
-
filePath?: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Incremental streaming JSON parser for top-level arrays [ {...}, ... ]
|
|
20
|
-
* and nested array paths (e.g. data.results).
|
|
21
|
-
* Never loads the full JSON file into memory.
|
|
22
|
-
*/
|
|
23
|
-
export class JSONReader implements TabularReader {
|
|
24
|
-
private input: Readable | string;
|
|
25
|
-
private options: JSONReaderOptions;
|
|
26
|
-
|
|
27
|
-
constructor(input: Readable | string, options: JSONReaderOptions = {}) {
|
|
28
|
-
this.input = input;
|
|
29
|
-
this.options = { ...options };
|
|
30
|
-
if (typeof input === "string") {
|
|
31
|
-
this.options.filePath = input;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
private getInputStream(): Readable {
|
|
36
|
-
if (typeof this.input === "string") {
|
|
37
|
-
return createReadStream(this.input);
|
|
38
|
-
}
|
|
39
|
-
return this.input;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async *read(options?: ReaderOptions): DataStream {
|
|
43
|
-
const mergedOptions: JSONReaderOptions = {
|
|
44
|
-
...this.options,
|
|
45
|
-
...options,
|
|
46
|
-
};
|
|
47
|
-
const batchSize = Math.max(1, mergedOptions.batchSize ?? 1000);
|
|
48
|
-
const targetPath = mergedOptions.path; // e.g. "data.results"
|
|
49
|
-
const stream = this.getInputStream();
|
|
50
|
-
|
|
51
|
-
let buffer = "";
|
|
52
|
-
let cursor = 0;
|
|
53
|
-
let byteOffset = 0;
|
|
54
|
-
let foundTargetArray = !targetPath; // if no path, we look for first '['
|
|
55
|
-
let currentPath: string[] = [];
|
|
56
|
-
|
|
57
|
-
// State machine for array item scanning
|
|
58
|
-
let inArray = false;
|
|
59
|
-
let inString = false;
|
|
60
|
-
let isEscaped = false;
|
|
61
|
-
let objectDepth = 0;
|
|
62
|
-
let objectStartIndex = -1;
|
|
63
|
-
|
|
64
|
-
let rowsInCurrentBatch: Row[] = [];
|
|
65
|
-
let globalOffset = 0;
|
|
66
|
-
let rowIndex = 0;
|
|
67
|
-
|
|
68
|
-
// Helper for path navigation if targetPath is set
|
|
69
|
-
const targetPathParts = targetPath ? targetPath.split(".") : [];
|
|
70
|
-
|
|
71
|
-
for await (const chunk of stream) {
|
|
72
|
-
const chunkStr =
|
|
73
|
-
typeof chunk === "string" ? chunk : (chunk as Buffer).toString("utf8");
|
|
74
|
-
buffer += chunkStr;
|
|
75
|
-
|
|
76
|
-
while (cursor < buffer.length) {
|
|
77
|
-
const char = buffer[cursor];
|
|
78
|
-
|
|
79
|
-
if (isEscaped) {
|
|
80
|
-
isEscaped = false;
|
|
81
|
-
cursor++;
|
|
82
|
-
byteOffset++;
|
|
83
|
-
continue;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
if (char === "\\") {
|
|
87
|
-
isEscaped = true;
|
|
88
|
-
cursor++;
|
|
89
|
-
byteOffset++;
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (char === '"') {
|
|
94
|
-
inString = !inString;
|
|
95
|
-
cursor++;
|
|
96
|
-
byteOffset++;
|
|
97
|
-
continue;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
if (inString) {
|
|
101
|
-
cursor++;
|
|
102
|
-
byteOffset++;
|
|
103
|
-
continue;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// We are outside of any string literal
|
|
107
|
-
if (!foundTargetArray) {
|
|
108
|
-
// If we need to find target path (e.g. data.results)
|
|
109
|
-
// Look for keys and match path parts
|
|
110
|
-
if (char === "{") {
|
|
111
|
-
cursor++;
|
|
112
|
-
byteOffset++;
|
|
113
|
-
continue;
|
|
114
|
-
} else if (char === "[") {
|
|
115
|
-
// Check if current path matches targetPath
|
|
116
|
-
// If path matched or we hit array at root
|
|
117
|
-
foundTargetArray = true;
|
|
118
|
-
inArray = true;
|
|
119
|
-
cursor++;
|
|
120
|
-
byteOffset++;
|
|
121
|
-
continue;
|
|
122
|
-
} else if (char === ":") {
|
|
123
|
-
// Scan backwards from ':' to find previous string key
|
|
124
|
-
let keyEnd = cursor - 1;
|
|
125
|
-
while (keyEnd >= 0 && /\s/.test(buffer[keyEnd]!)) keyEnd--;
|
|
126
|
-
if (keyEnd >= 0 && buffer[keyEnd] === '"') {
|
|
127
|
-
let keyStart = keyEnd - 1;
|
|
128
|
-
while (keyStart >= 0 && buffer[keyStart] !== '"') keyStart--;
|
|
129
|
-
if (keyStart >= 0) {
|
|
130
|
-
const keyName = buffer.slice(keyStart + 1, keyEnd);
|
|
131
|
-
currentPath.push(keyName);
|
|
132
|
-
if (
|
|
133
|
-
currentPath.join(".") === targetPath ||
|
|
134
|
-
keyName === targetPathParts[targetPathParts.length - 1]
|
|
135
|
-
) {
|
|
136
|
-
// Next non-whitespace should be '['
|
|
137
|
-
// Let it continue to find '['
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
cursor++;
|
|
142
|
-
byteOffset++;
|
|
143
|
-
continue;
|
|
144
|
-
}
|
|
145
|
-
cursor++;
|
|
146
|
-
byteOffset++;
|
|
147
|
-
continue;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Now we are streaming inside the target array
|
|
151
|
-
if (!inArray) {
|
|
152
|
-
if (char === "[") {
|
|
153
|
-
inArray = true;
|
|
154
|
-
cursor++;
|
|
155
|
-
byteOffset++;
|
|
156
|
-
continue;
|
|
157
|
-
}
|
|
158
|
-
} else {
|
|
159
|
-
// Inside array: looking for objects '{ ... }'
|
|
160
|
-
if (char === "{") {
|
|
161
|
-
if (objectDepth === 0) {
|
|
162
|
-
objectStartIndex = cursor;
|
|
163
|
-
}
|
|
164
|
-
objectDepth++;
|
|
165
|
-
} else if (char === "}") {
|
|
166
|
-
objectDepth--;
|
|
167
|
-
if (objectDepth === 0 && objectStartIndex !== -1) {
|
|
168
|
-
// We have captured a complete JSON object!
|
|
169
|
-
const objectStr = buffer.slice(objectStartIndex, cursor + 1);
|
|
170
|
-
let parsedRow: unknown;
|
|
171
|
-
try {
|
|
172
|
-
parsedRow = JSON.parse(objectStr);
|
|
173
|
-
} catch (err) {
|
|
174
|
-
throw new ParseError(
|
|
175
|
-
`Malformed JSON object in array: ${(err as Error).message}`,
|
|
176
|
-
{
|
|
177
|
-
file: mergedOptions.filePath,
|
|
178
|
-
row: rowIndex + 1,
|
|
179
|
-
byteOffset,
|
|
180
|
-
}
|
|
181
|
-
);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
if (
|
|
185
|
-
typeof parsedRow === "object" &&
|
|
186
|
-
parsedRow !== null &&
|
|
187
|
-
!Array.isArray(parsedRow)
|
|
188
|
-
) {
|
|
189
|
-
rowsInCurrentBatch.push(parsedRow as Row);
|
|
190
|
-
rowIndex++;
|
|
191
|
-
|
|
192
|
-
if (rowsInCurrentBatch.length >= batchSize) {
|
|
193
|
-
yield {
|
|
194
|
-
rows: rowsInCurrentBatch,
|
|
195
|
-
offset: globalOffset,
|
|
196
|
-
};
|
|
197
|
-
globalOffset += rowsInCurrentBatch.length;
|
|
198
|
-
rowsInCurrentBatch = [];
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
objectStartIndex = -1;
|
|
203
|
-
}
|
|
204
|
-
} else if (char === "]" && objectDepth === 0) {
|
|
205
|
-
// End of array reached
|
|
206
|
-
inArray = false;
|
|
207
|
-
break;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
cursor++;
|
|
211
|
-
byteOffset++;
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// Compact buffer while preserving active object if in the middle of parsing
|
|
216
|
-
if (objectStartIndex !== -1) {
|
|
217
|
-
buffer = buffer.slice(objectStartIndex);
|
|
218
|
-
cursor = cursor - objectStartIndex;
|
|
219
|
-
objectStartIndex = 0;
|
|
220
|
-
} else {
|
|
221
|
-
buffer = buffer.slice(cursor);
|
|
222
|
-
cursor = 0;
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
if (rowsInCurrentBatch.length > 0) {
|
|
227
|
-
yield {
|
|
228
|
-
rows: rowsInCurrentBatch,
|
|
229
|
-
offset: globalOffset,
|
|
230
|
-
};
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
async inspect(options?: ReaderOptions): Promise<InspectionMetadata> {
|
|
235
|
-
let rowCount = 0;
|
|
236
|
-
let columnNames: string[] = [];
|
|
237
|
-
|
|
238
|
-
for await (const batch of this.read({ ...options, batchSize: 5000 })) {
|
|
239
|
-
if (columnNames.length === 0 && batch.rows.length > 0 && batch.rows[0]) {
|
|
240
|
-
columnNames = Object.keys(batch.rows[0]);
|
|
241
|
-
}
|
|
242
|
-
rowCount += batch.rows.length;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
return {
|
|
246
|
-
format: "JSON",
|
|
247
|
-
rowCount,
|
|
248
|
-
columnCount: columnNames.length,
|
|
249
|
-
columns: columnNames.map((name) => ({
|
|
250
|
-
name,
|
|
251
|
-
type: "string",
|
|
252
|
-
nullPercentage: 0,
|
|
253
|
-
})),
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
|
-
}
|