@jackchuka/gql-ingest 1.5.0 → 2.0.1
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 +138 -6
- package/bin/cli.js +53 -52
- package/dist/mapper.d.ts +9 -4
- package/dist/mapper.d.ts.map +1 -1
- package/dist/readers/csv.d.ts +10 -0
- package/dist/readers/csv.d.ts.map +1 -0
- package/dist/readers/csv.test.d.ts +2 -0
- package/dist/readers/csv.test.d.ts.map +1 -0
- package/dist/readers/data-reader.d.ts +21 -0
- package/dist/readers/data-reader.d.ts.map +1 -0
- package/dist/readers/data-reader.test.d.ts +2 -0
- package/dist/readers/data-reader.test.d.ts.map +1 -0
- package/dist/readers/index.d.ts +6 -0
- package/dist/readers/index.d.ts.map +1 -0
- package/dist/readers/json.d.ts +6 -0
- package/dist/readers/json.d.ts.map +1 -0
- package/dist/readers/json.test.d.ts +2 -0
- package/dist/readers/json.test.d.ts.map +1 -0
- package/dist/readers/jsonl.d.ts +6 -0
- package/dist/readers/jsonl.d.ts.map +1 -0
- package/dist/readers/jsonl.test.d.ts +2 -0
- package/dist/readers/jsonl.test.d.ts.map +1 -0
- package/dist/readers/yaml.d.ts +6 -0
- package/dist/readers/yaml.d.ts.map +1 -0
- package/dist/readers/yaml.test.d.ts +2 -0
- package/dist/readers/yaml.test.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/cli.ts +10 -25
- package/src/graphql-client.test.ts +19 -4
- package/src/mapper.test.ts +115 -64
- package/src/mapper.ts +138 -33
- package/src/{csv-reader.test.ts → readers/csv.test.ts} +1 -1
- package/src/readers/csv.ts +29 -0
- package/src/readers/data-reader.test.ts +104 -0
- package/src/readers/data-reader.ts +61 -0
- package/src/readers/index.ts +18 -0
- package/src/readers/json.test.ts +80 -0
- package/src/readers/json.ts +27 -0
- package/src/readers/jsonl.test.ts +96 -0
- package/src/readers/jsonl.ts +28 -0
- package/src/readers/yaml.test.ts +95 -0
- package/src/readers/yaml.ts +28 -0
- package/dist/csv-reader.d.ts +0 -5
- package/dist/csv-reader.d.ts.map +0 -1
- package/dist/csv-reader.test.d.ts +0 -2
- package/dist/csv-reader.test.d.ts.map +0 -1
- package/src/csv-reader.ts +0 -18
package/src/mapper.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { parse, DocumentNode, VariableDefinitionNode } from "graphql";
|
|
4
|
-
import {
|
|
4
|
+
import { DataReaderFactory, DataRow } from "./readers";
|
|
5
5
|
import { GraphQLClientWrapper } from "./graphql-client";
|
|
6
6
|
import { MetricsCollector } from "./metrics";
|
|
7
7
|
import { ParallelProcessingConfig, RetryConfig } from "./config";
|
|
8
8
|
|
|
9
9
|
export interface MappingConfig {
|
|
10
|
-
|
|
10
|
+
// Legacy CSV support
|
|
11
|
+
csvFile?: string;
|
|
12
|
+
// New flexible data file support
|
|
13
|
+
dataFile?: string;
|
|
14
|
+
dataFormat?: string;
|
|
11
15
|
graphqlFile: string;
|
|
12
|
-
mapping: Record<string, string>;
|
|
16
|
+
mapping: Record<string, string | any>;
|
|
13
17
|
}
|
|
14
18
|
|
|
15
19
|
export class DataMapper {
|
|
@@ -17,17 +21,20 @@ export class DataMapper {
|
|
|
17
21
|
private basePath: string;
|
|
18
22
|
private metrics: MetricsCollector;
|
|
19
23
|
private verbose: boolean;
|
|
24
|
+
private formatOverride?: string;
|
|
20
25
|
|
|
21
26
|
constructor(
|
|
22
27
|
client: GraphQLClientWrapper,
|
|
23
28
|
basePath: string = process.cwd(),
|
|
24
29
|
metrics?: MetricsCollector,
|
|
25
|
-
verbose: boolean = false
|
|
30
|
+
verbose: boolean = false,
|
|
31
|
+
formatOverride?: string
|
|
26
32
|
) {
|
|
27
33
|
this.client = client;
|
|
28
34
|
this.basePath = basePath;
|
|
29
35
|
this.metrics = metrics || new MetricsCollector();
|
|
30
36
|
this.verbose = verbose;
|
|
37
|
+
this.formatOverride = formatOverride;
|
|
31
38
|
}
|
|
32
39
|
|
|
33
40
|
discoverMappings(configDir: string, entityFilter?: string[]): string[] {
|
|
@@ -93,9 +100,20 @@ export class DataMapper {
|
|
|
93
100
|
// Extract config directory (parent of mappings directory)
|
|
94
101
|
const configDir = path.dirname(path.dirname(configFullPath));
|
|
95
102
|
|
|
96
|
-
//
|
|
97
|
-
const
|
|
98
|
-
|
|
103
|
+
// Determine data file path (support both legacy csvFile and new dataFile)
|
|
104
|
+
const dataFile = config.dataFile || config.csvFile;
|
|
105
|
+
if (!dataFile) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`No data file specified in mapping config: ${configPath}`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const dataPath = path.resolve(configDir, dataFile);
|
|
112
|
+
|
|
113
|
+
// Get appropriate reader (prioritize CLI format override, then config format)
|
|
114
|
+
const format = this.formatOverride || config.dataFormat;
|
|
115
|
+
const reader = DataReaderFactory.getReader(dataPath, format);
|
|
116
|
+
const data = await reader.readFile(dataPath);
|
|
99
117
|
|
|
100
118
|
// Read GraphQL mutation (relative to config directory)
|
|
101
119
|
const graphqlPath = path.resolve(configDir, config.graphqlFile);
|
|
@@ -104,7 +122,7 @@ export class DataMapper {
|
|
|
104
122
|
// Process rows with optional parallelization
|
|
105
123
|
if (parallelConfig && parallelConfig.concurrency > 1) {
|
|
106
124
|
await this.processRowsConcurrently(
|
|
107
|
-
|
|
125
|
+
data,
|
|
108
126
|
mutation,
|
|
109
127
|
config.mapping,
|
|
110
128
|
entityName,
|
|
@@ -113,7 +131,7 @@ export class DataMapper {
|
|
|
113
131
|
);
|
|
114
132
|
} else {
|
|
115
133
|
await this.processRowsSequentially(
|
|
116
|
-
|
|
134
|
+
data,
|
|
117
135
|
mutation,
|
|
118
136
|
config.mapping,
|
|
119
137
|
entityName,
|
|
@@ -125,18 +143,18 @@ export class DataMapper {
|
|
|
125
143
|
}
|
|
126
144
|
|
|
127
145
|
private async processRowsSequentially(
|
|
128
|
-
|
|
146
|
+
data: DataRow[],
|
|
129
147
|
mutation: string,
|
|
130
|
-
mapping: Record<string, string>,
|
|
148
|
+
mapping: Record<string, string | any>,
|
|
131
149
|
entityName: string,
|
|
132
150
|
retryConfig?: RetryConfig
|
|
133
151
|
): Promise<void> {
|
|
134
|
-
const totalRows =
|
|
152
|
+
const totalRows = data.length;
|
|
135
153
|
const variableTypes = this.extractVariableTypes(mutation);
|
|
136
154
|
|
|
137
|
-
for (let i = 0; i <
|
|
138
|
-
const row =
|
|
139
|
-
const variables = this.
|
|
155
|
+
for (let i = 0; i < data.length; i++) {
|
|
156
|
+
const row = data[i];
|
|
157
|
+
const variables = this.mapRowToVariables(row, mapping, variableTypes);
|
|
140
158
|
|
|
141
159
|
try {
|
|
142
160
|
await this.client.executeMutation(mutation, variables, retryConfig);
|
|
@@ -165,34 +183,30 @@ export class DataMapper {
|
|
|
165
183
|
}
|
|
166
184
|
|
|
167
185
|
private async processRowsConcurrently(
|
|
168
|
-
|
|
186
|
+
data: DataRow[],
|
|
169
187
|
mutation: string,
|
|
170
|
-
mapping: Record<string, string>,
|
|
188
|
+
mapping: Record<string, string | any>,
|
|
171
189
|
entityName: string,
|
|
172
190
|
parallelConfig: ParallelProcessingConfig,
|
|
173
191
|
retryConfig?: RetryConfig
|
|
174
192
|
): Promise<void> {
|
|
175
193
|
const concurrency = parallelConfig.concurrency;
|
|
176
194
|
console.log(
|
|
177
|
-
`Processing ${
|
|
195
|
+
`Processing ${data.length} rows with concurrency: ${concurrency}`
|
|
178
196
|
);
|
|
179
197
|
|
|
180
198
|
// Extract variable types once for all rows
|
|
181
199
|
const variableTypes = this.extractVariableTypes(mutation);
|
|
182
200
|
|
|
183
201
|
// Split data into chunks for concurrent processing
|
|
184
|
-
const chunks = this.chunkArray(
|
|
202
|
+
const chunks = this.chunkArray(data, concurrency);
|
|
185
203
|
let processedCount = 0;
|
|
186
|
-
const totalRows =
|
|
204
|
+
const totalRows = data.length;
|
|
187
205
|
|
|
188
206
|
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
|
189
207
|
const chunk = chunks[chunkIndex];
|
|
190
208
|
const promises = chunk.map(async (row) => {
|
|
191
|
-
const variables = this.
|
|
192
|
-
row,
|
|
193
|
-
mapping,
|
|
194
|
-
variableTypes
|
|
195
|
-
);
|
|
209
|
+
const variables = this.mapRowToVariables(row, mapping, variableTypes);
|
|
196
210
|
|
|
197
211
|
try {
|
|
198
212
|
const result = await this.client.executeMutation(
|
|
@@ -254,24 +268,110 @@ export class DataMapper {
|
|
|
254
268
|
return chunks;
|
|
255
269
|
}
|
|
256
270
|
|
|
257
|
-
private
|
|
258
|
-
row:
|
|
259
|
-
mapping: Record<string, string>,
|
|
271
|
+
private mapRowToVariables(
|
|
272
|
+
row: DataRow,
|
|
273
|
+
mapping: Record<string, string | any>,
|
|
260
274
|
variableTypes: Record<string, string>
|
|
261
275
|
): Record<string, any> {
|
|
262
276
|
const variables: Record<string, any> = {};
|
|
263
277
|
|
|
264
|
-
for (const [graphqlVar,
|
|
265
|
-
|
|
266
|
-
|
|
278
|
+
for (const [graphqlVar, mappingValue] of Object.entries(mapping)) {
|
|
279
|
+
// Handle direct mapping for nested data (e.g., "input": "$")
|
|
280
|
+
if (mappingValue === "$") {
|
|
281
|
+
// Use the entire row as the variable value
|
|
282
|
+
variables[graphqlVar] = row;
|
|
283
|
+
}
|
|
284
|
+
// Handle path-based mapping for nested data (e.g., "input.name": "$.product.name")
|
|
285
|
+
else if (
|
|
286
|
+
typeof mappingValue === "string" &&
|
|
287
|
+
mappingValue.startsWith("$.")
|
|
288
|
+
) {
|
|
289
|
+
const path = mappingValue.substring(2); // Remove '$.'
|
|
290
|
+
const value = this.getValueByPath(row, path);
|
|
291
|
+
if (value !== undefined) {
|
|
292
|
+
const type = variableTypes[graphqlVar];
|
|
293
|
+
variables[graphqlVar] = this.convertValue(value, type, graphqlVar);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// Handle traditional flat mapping (e.g., "name": "product_name")
|
|
297
|
+
else if (
|
|
298
|
+
typeof mappingValue === "string" &&
|
|
299
|
+
row[mappingValue] !== undefined
|
|
300
|
+
) {
|
|
301
|
+
const rawValue = row[mappingValue];
|
|
267
302
|
const type = variableTypes[graphqlVar];
|
|
268
303
|
variables[graphqlVar] = this.convertValue(rawValue, type, graphqlVar);
|
|
269
304
|
}
|
|
305
|
+
// Handle complex mapping object
|
|
306
|
+
else if (typeof mappingValue === "object" && mappingValue !== null) {
|
|
307
|
+
variables[graphqlVar] = this.mapNestedObject(
|
|
308
|
+
row,
|
|
309
|
+
mappingValue,
|
|
310
|
+
variableTypes
|
|
311
|
+
);
|
|
312
|
+
}
|
|
270
313
|
}
|
|
271
314
|
|
|
272
315
|
return variables;
|
|
273
316
|
}
|
|
274
317
|
|
|
318
|
+
private getValueByPath(obj: any, path: string): any {
|
|
319
|
+
const parts = path.split(".");
|
|
320
|
+
let current = obj;
|
|
321
|
+
|
|
322
|
+
for (const part of parts) {
|
|
323
|
+
if (current && typeof current === "object" && part in current) {
|
|
324
|
+
current = current[part];
|
|
325
|
+
} else {
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return current;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private mapNestedObject(
|
|
334
|
+
row: DataRow,
|
|
335
|
+
mappingObj: any,
|
|
336
|
+
variableTypes: Record<string, string>
|
|
337
|
+
): any {
|
|
338
|
+
if (Array.isArray(mappingObj)) {
|
|
339
|
+
return mappingObj.map((item) =>
|
|
340
|
+
this.mapNestedObject(row, item, variableTypes)
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (typeof mappingObj === "object" && mappingObj !== null) {
|
|
345
|
+
const result: any = {};
|
|
346
|
+
for (const [key, value] of Object.entries(mappingObj)) {
|
|
347
|
+
if (typeof value === "string" && value.startsWith("$.")) {
|
|
348
|
+
const path = value.substring(2);
|
|
349
|
+
let fieldValue = this.getValueByPath(row, path);
|
|
350
|
+
|
|
351
|
+
// Handle special case for array fields (e.g., comma-separated values)
|
|
352
|
+
if (
|
|
353
|
+
key === "values" &&
|
|
354
|
+
typeof fieldValue === "string" &&
|
|
355
|
+
fieldValue.includes(",")
|
|
356
|
+
) {
|
|
357
|
+
fieldValue = fieldValue.split(",").map((v) => v.trim());
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
result[key] = fieldValue;
|
|
361
|
+
} else if (typeof value === "string" && row[value] !== undefined) {
|
|
362
|
+
result[key] = row[value];
|
|
363
|
+
} else if (typeof value === "object") {
|
|
364
|
+
result[key] = this.mapNestedObject(row, value, variableTypes);
|
|
365
|
+
} else {
|
|
366
|
+
result[key] = value;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return result;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return mappingObj;
|
|
373
|
+
}
|
|
374
|
+
|
|
275
375
|
private extractVariableTypes(mutation: string): Record<string, string> {
|
|
276
376
|
const types: Record<string, string> = {};
|
|
277
377
|
|
|
@@ -317,12 +417,17 @@ export class DataMapper {
|
|
|
317
417
|
}
|
|
318
418
|
|
|
319
419
|
private convertValue(
|
|
320
|
-
value:
|
|
420
|
+
value: any,
|
|
321
421
|
type: string | undefined,
|
|
322
422
|
varName: string
|
|
323
423
|
): any {
|
|
324
424
|
if (!type) {
|
|
325
|
-
// No type information available, keep as
|
|
425
|
+
// No type information available, keep as is
|
|
426
|
+
return value;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// For non-string values (objects, arrays), return as is
|
|
430
|
+
if (typeof value !== "string") {
|
|
326
431
|
return value;
|
|
327
432
|
}
|
|
328
433
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import csv from "csv-parser";
|
|
3
|
+
import { DataReader, DataRow } from "./data-reader";
|
|
4
|
+
|
|
5
|
+
export interface CsvRow {
|
|
6
|
+
[key: string]: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function readCsvFile(filePath: string): Promise<CsvRow[]> {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const results: CsvRow[] = [];
|
|
12
|
+
|
|
13
|
+
fs.createReadStream(filePath)
|
|
14
|
+
.pipe(csv())
|
|
15
|
+
.on("data", (data) => results.push(data))
|
|
16
|
+
.on("end", () => resolve(results))
|
|
17
|
+
.on("error", (error) => reject(error));
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class CsvReader extends DataReader {
|
|
22
|
+
getSupportedExtensions(): string[] {
|
|
23
|
+
return ["csv"];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async readFile(filePath: string): Promise<DataRow[]> {
|
|
27
|
+
return readCsvFile(filePath);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { DataReader, DataReaderFactory } from "./data-reader";
|
|
2
|
+
|
|
3
|
+
class TestReader extends DataReader {
|
|
4
|
+
getSupportedExtensions(): string[] {
|
|
5
|
+
return ["test", "tst"];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
async readFile(filePath: string): Promise<any[]> {
|
|
9
|
+
return [{ test: true }];
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("DataReader", () => {
|
|
14
|
+
describe("canHandle", () => {
|
|
15
|
+
it("should check if reader can handle file based on extension", () => {
|
|
16
|
+
const reader = new TestReader();
|
|
17
|
+
|
|
18
|
+
expect(reader.canHandle("file.test")).toBe(true);
|
|
19
|
+
expect(reader.canHandle("file.tst")).toBe(true);
|
|
20
|
+
expect(reader.canHandle("path/to/file.test")).toBe(true);
|
|
21
|
+
expect(reader.canHandle("file.other")).toBe(false);
|
|
22
|
+
expect(reader.canHandle("file")).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("DataReaderFactory", () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
// Clear the readers array before each test
|
|
30
|
+
(DataReaderFactory as any).readers = [];
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("registerReader", () => {
|
|
34
|
+
it("should register a reader", () => {
|
|
35
|
+
const reader = new TestReader();
|
|
36
|
+
DataReaderFactory.registerReader(reader);
|
|
37
|
+
|
|
38
|
+
expect(DataReaderFactory.getSupportedFormats()).toContain("test");
|
|
39
|
+
expect(DataReaderFactory.getSupportedFormats()).toContain("tst");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("getReader", () => {
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
const reader = new TestReader();
|
|
46
|
+
DataReaderFactory.registerReader(reader);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should get reader by file extension", () => {
|
|
50
|
+
const reader = DataReaderFactory.getReader("file.test");
|
|
51
|
+
expect(reader).toBeInstanceOf(TestReader);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("should get reader by format override", () => {
|
|
55
|
+
const reader = DataReaderFactory.getReader("file.other", "test");
|
|
56
|
+
expect(reader).toBeInstanceOf(TestReader);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("should prioritize format override over file extension", () => {
|
|
60
|
+
const reader = DataReaderFactory.getReader("file.unknown", "tst");
|
|
61
|
+
expect(reader).toBeInstanceOf(TestReader);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("should throw error when no reader found", () => {
|
|
65
|
+
expect(() => DataReaderFactory.getReader("file.unknown")).toThrow(
|
|
66
|
+
"No reader found for file: file.unknown"
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should throw error when format specified but no reader found", () => {
|
|
71
|
+
expect(() => DataReaderFactory.getReader("file.txt", "unknown")).toThrow(
|
|
72
|
+
"No reader found for file: file.txt with format: unknown"
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("getSupportedFormats", () => {
|
|
78
|
+
it("should return empty array when no readers registered", () => {
|
|
79
|
+
expect(DataReaderFactory.getSupportedFormats()).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("should return all supported formats", () => {
|
|
83
|
+
const reader1 = new TestReader();
|
|
84
|
+
DataReaderFactory.registerReader(reader1);
|
|
85
|
+
|
|
86
|
+
const formats = DataReaderFactory.getSupportedFormats();
|
|
87
|
+
expect(formats).toContain("test");
|
|
88
|
+
expect(formats).toContain("tst");
|
|
89
|
+
expect(formats.length).toBe(2);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("should not duplicate formats", () => {
|
|
93
|
+
const reader1 = new TestReader();
|
|
94
|
+
const reader2 = new TestReader();
|
|
95
|
+
DataReaderFactory.registerReader(reader1);
|
|
96
|
+
DataReaderFactory.registerReader(reader2);
|
|
97
|
+
|
|
98
|
+
const formats = DataReaderFactory.getSupportedFormats();
|
|
99
|
+
expect(formats).toContain("test");
|
|
100
|
+
expect(formats).toContain("tst");
|
|
101
|
+
expect(formats.length).toBe(2);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export interface DataRow {
|
|
2
|
+
[key: string]: any;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export abstract class DataReader {
|
|
6
|
+
abstract readFile(filePath: string): Promise<DataRow[]>;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Get the supported file extensions for this reader
|
|
10
|
+
*/
|
|
11
|
+
abstract getSupportedExtensions(): string[];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Check if this reader can handle the given file
|
|
15
|
+
*/
|
|
16
|
+
canHandle(filePath: string): boolean {
|
|
17
|
+
const extension = filePath.split(".").pop()?.toLowerCase();
|
|
18
|
+
return extension
|
|
19
|
+
? this.getSupportedExtensions().includes(extension)
|
|
20
|
+
: false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class DataReaderFactory {
|
|
25
|
+
private static readers: DataReader[] = [];
|
|
26
|
+
|
|
27
|
+
static registerReader(reader: DataReader): void {
|
|
28
|
+
this.readers.push(reader);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static getReader(filePath: string, format?: string): DataReader {
|
|
32
|
+
// If format is specified, try to find reader by format
|
|
33
|
+
if (format) {
|
|
34
|
+
const reader = this.readers.find((r) =>
|
|
35
|
+
r.getSupportedExtensions().includes(format.toLowerCase())
|
|
36
|
+
);
|
|
37
|
+
if (reader) return reader;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Otherwise, try to find reader by file extension
|
|
41
|
+
const reader = this.readers.find((r) => r.canHandle(filePath));
|
|
42
|
+
|
|
43
|
+
if (!reader) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`No reader found for file: ${filePath}${
|
|
46
|
+
format ? ` with format: ${format}` : ""
|
|
47
|
+
}`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return reader;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
static getSupportedFormats(): string[] {
|
|
55
|
+
const formats = new Set<string>();
|
|
56
|
+
this.readers.forEach((reader) => {
|
|
57
|
+
reader.getSupportedExtensions().forEach((ext) => formats.add(ext));
|
|
58
|
+
});
|
|
59
|
+
return Array.from(formats);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export { DataReader, DataRow, DataReaderFactory } from "./data-reader";
|
|
2
|
+
export { CsvReader, readCsvFile, CsvRow } from "./csv";
|
|
3
|
+
export { JsonReader } from "./json";
|
|
4
|
+
export { YamlReader } from "./yaml";
|
|
5
|
+
export { JsonlReader } from "./jsonl";
|
|
6
|
+
|
|
7
|
+
// Register all readers
|
|
8
|
+
import { DataReaderFactory } from "./data-reader";
|
|
9
|
+
import { CsvReader } from "./csv";
|
|
10
|
+
import { JsonReader } from "./json";
|
|
11
|
+
import { YamlReader } from "./yaml";
|
|
12
|
+
import { JsonlReader } from "./jsonl";
|
|
13
|
+
|
|
14
|
+
// Register readers on module load
|
|
15
|
+
DataReaderFactory.registerReader(new CsvReader());
|
|
16
|
+
DataReaderFactory.registerReader(new JsonReader());
|
|
17
|
+
DataReaderFactory.registerReader(new YamlReader());
|
|
18
|
+
DataReaderFactory.registerReader(new JsonlReader());
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import { JsonReader } from "./json";
|
|
3
|
+
|
|
4
|
+
jest.mock("fs/promises");
|
|
5
|
+
|
|
6
|
+
describe("JsonReader", () => {
|
|
7
|
+
let reader: JsonReader;
|
|
8
|
+
const mockFs = fs as jest.Mocked<typeof fs>;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
reader = new JsonReader();
|
|
12
|
+
jest.clearAllMocks();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("getSupportedExtensions", () => {
|
|
16
|
+
it("should return json as supported extension", () => {
|
|
17
|
+
expect(reader.getSupportedExtensions()).toEqual(["json"]);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("canHandle", () => {
|
|
22
|
+
it("should return true for .json files", () => {
|
|
23
|
+
expect(reader.canHandle("data.json")).toBe(true);
|
|
24
|
+
expect(reader.canHandle("path/to/file.json")).toBe(true);
|
|
25
|
+
expect(reader.canHandle("file.JSON")).toBe(true); // case insensitive
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("should return false for non-json files", () => {
|
|
29
|
+
expect(reader.canHandle("data.csv")).toBe(false);
|
|
30
|
+
expect(reader.canHandle("data.yaml")).toBe(false);
|
|
31
|
+
expect(reader.canHandle("data")).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("readFile", () => {
|
|
36
|
+
it("should read and parse JSON array", async () => {
|
|
37
|
+
const mockData = [
|
|
38
|
+
{ id: 1, name: "Item 1" },
|
|
39
|
+
{ id: 2, name: "Item 2" },
|
|
40
|
+
];
|
|
41
|
+
mockFs.readFile.mockResolvedValue(JSON.stringify(mockData));
|
|
42
|
+
|
|
43
|
+
const result = await reader.readFile("data.json");
|
|
44
|
+
|
|
45
|
+
expect(mockFs.readFile).toHaveBeenCalledWith("data.json", "utf8");
|
|
46
|
+
expect(result).toEqual(mockData);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should wrap single object in array", async () => {
|
|
50
|
+
const mockData = { id: 1, name: "Item 1" };
|
|
51
|
+
mockFs.readFile.mockResolvedValue(JSON.stringify(mockData));
|
|
52
|
+
|
|
53
|
+
const result = await reader.readFile("data.json");
|
|
54
|
+
|
|
55
|
+
expect(result).toEqual([mockData]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("should throw error for invalid JSON", async () => {
|
|
59
|
+
mockFs.readFile.mockResolvedValue("invalid json");
|
|
60
|
+
|
|
61
|
+
await expect(reader.readFile("data.json")).rejects.toThrow();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("should throw error for null data", async () => {
|
|
65
|
+
mockFs.readFile.mockResolvedValue("null");
|
|
66
|
+
|
|
67
|
+
await expect(reader.readFile("data.json")).rejects.toThrow(
|
|
68
|
+
"Invalid JSON data structure in file: data.json. Expected array or object."
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("should throw error for primitive values", async () => {
|
|
73
|
+
mockFs.readFile.mockResolvedValue('"string value"');
|
|
74
|
+
|
|
75
|
+
await expect(reader.readFile("data.json")).rejects.toThrow(
|
|
76
|
+
"Invalid JSON data structure in file: data.json. Expected array or object."
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import { DataReader, DataRow } from "./data-reader";
|
|
3
|
+
|
|
4
|
+
export class JsonReader extends DataReader {
|
|
5
|
+
getSupportedExtensions(): string[] {
|
|
6
|
+
return ["json"];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async readFile(filePath: string): Promise<DataRow[]> {
|
|
10
|
+
const content = await fs.readFile(filePath, "utf8");
|
|
11
|
+
const data = JSON.parse(content);
|
|
12
|
+
|
|
13
|
+
// If the data is already an array, return it
|
|
14
|
+
if (Array.isArray(data)) {
|
|
15
|
+
return data;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// If it's a single object, wrap it in an array
|
|
19
|
+
if (typeof data === "object" && data !== null) {
|
|
20
|
+
return [data];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
throw new Error(
|
|
24
|
+
`Invalid JSON data structure in file: ${filePath}. Expected array or object.`
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
}
|