@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.
Files changed (47) hide show
  1. package/README.md +138 -6
  2. package/bin/cli.js +53 -52
  3. package/dist/mapper.d.ts +9 -4
  4. package/dist/mapper.d.ts.map +1 -1
  5. package/dist/readers/csv.d.ts +10 -0
  6. package/dist/readers/csv.d.ts.map +1 -0
  7. package/dist/readers/csv.test.d.ts +2 -0
  8. package/dist/readers/csv.test.d.ts.map +1 -0
  9. package/dist/readers/data-reader.d.ts +21 -0
  10. package/dist/readers/data-reader.d.ts.map +1 -0
  11. package/dist/readers/data-reader.test.d.ts +2 -0
  12. package/dist/readers/data-reader.test.d.ts.map +1 -0
  13. package/dist/readers/index.d.ts +6 -0
  14. package/dist/readers/index.d.ts.map +1 -0
  15. package/dist/readers/json.d.ts +6 -0
  16. package/dist/readers/json.d.ts.map +1 -0
  17. package/dist/readers/json.test.d.ts +2 -0
  18. package/dist/readers/json.test.d.ts.map +1 -0
  19. package/dist/readers/jsonl.d.ts +6 -0
  20. package/dist/readers/jsonl.d.ts.map +1 -0
  21. package/dist/readers/jsonl.test.d.ts +2 -0
  22. package/dist/readers/jsonl.test.d.ts.map +1 -0
  23. package/dist/readers/yaml.d.ts +6 -0
  24. package/dist/readers/yaml.d.ts.map +1 -0
  25. package/dist/readers/yaml.test.d.ts +2 -0
  26. package/dist/readers/yaml.test.d.ts.map +1 -0
  27. package/package.json +1 -1
  28. package/src/cli.ts +10 -25
  29. package/src/graphql-client.test.ts +19 -4
  30. package/src/mapper.test.ts +115 -64
  31. package/src/mapper.ts +138 -33
  32. package/src/{csv-reader.test.ts → readers/csv.test.ts} +1 -1
  33. package/src/readers/csv.ts +29 -0
  34. package/src/readers/data-reader.test.ts +104 -0
  35. package/src/readers/data-reader.ts +61 -0
  36. package/src/readers/index.ts +18 -0
  37. package/src/readers/json.test.ts +80 -0
  38. package/src/readers/json.ts +27 -0
  39. package/src/readers/jsonl.test.ts +96 -0
  40. package/src/readers/jsonl.ts +28 -0
  41. package/src/readers/yaml.test.ts +95 -0
  42. package/src/readers/yaml.ts +28 -0
  43. package/dist/csv-reader.d.ts +0 -5
  44. package/dist/csv-reader.d.ts.map +0 -1
  45. package/dist/csv-reader.test.d.ts +0 -2
  46. package/dist/csv-reader.test.d.ts.map +0 -1
  47. 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 { readCsvFile, CsvRow } from "./csv-reader";
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
- csvFile: string;
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
- // Read CSV data (relative to config directory)
97
- const csvPath = path.resolve(configDir, config.csvFile);
98
- const csvData = await readCsvFile(csvPath);
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
- csvData,
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
- csvData,
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
- csvData: CsvRow[],
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 = csvData.length;
152
+ const totalRows = data.length;
135
153
  const variableTypes = this.extractVariableTypes(mutation);
136
154
 
137
- for (let i = 0; i < csvData.length; i++) {
138
- const row = csvData[i];
139
- const variables = this.mapCsvRowToVariables(row, mapping, variableTypes);
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
- csvData: CsvRow[],
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 ${csvData.length} rows with concurrency: ${concurrency}`
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(csvData, concurrency);
202
+ const chunks = this.chunkArray(data, concurrency);
185
203
  let processedCount = 0;
186
- const totalRows = csvData.length;
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.mapCsvRowToVariables(
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 mapCsvRowToVariables(
258
- row: CsvRow,
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, csvColumn] of Object.entries(mapping)) {
265
- if (row[csvColumn] !== undefined) {
266
- const rawValue = row[csvColumn];
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: string,
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 string
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
 
@@ -1,6 +1,6 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
- import { readCsvFile } from "./csv-reader";
3
+ import { readCsvFile } from "./csv";
4
4
 
5
5
  describe("CSV Reader", () => {
6
6
  const testDataDir = path.join(__dirname, "test-data");
@@ -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
+ }