@jackchuka/gql-ingest 1.3.0 → 1.4.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/dist/mapper.d.ts CHANGED
@@ -18,6 +18,9 @@ export declare class DataMapper {
18
18
  private processRowsConcurrently;
19
19
  private chunkArray;
20
20
  private mapCsvRowToVariables;
21
+ private extractVariableTypes;
22
+ private extractTypeName;
23
+ private convertValue;
21
24
  getMetrics(): MetricsCollector;
22
25
  }
23
26
  //# sourceMappingURL=mapper.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"mapper.d.ts","sourceRoot":"","sources":["../src/mapper.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AACxD,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,EAAE,wBAAwB,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAEjE,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AAED,qBAAa,UAAU;IACrB,OAAO,CAAC,MAAM,CAAuB;IACrC,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,OAAO,CAAmB;IAClC,OAAO,CAAC,OAAO,CAAU;gBAGvB,MAAM,EAAE,oBAAoB,EAC5B,QAAQ,GAAE,MAAsB,EAChC,OAAO,CAAC,EAAE,gBAAgB,EAC1B,OAAO,GAAE,OAAe;IAQ1B,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,EAAE;IAiBvC,aAAa,CACjB,UAAU,EAAE,MAAM,EAClB,cAAc,CAAC,EAAE,wBAAwB,EACzC,WAAW,CAAC,EAAE,WAAW,GACxB,OAAO,CAAC,IAAI,CAAC;YA8CF,uBAAuB;YA+BvB,uBAAuB;IAmErC,OAAO,CAAC,UAAU;IAQlB,OAAO,CAAC,oBAAoB;IAe5B,UAAU,IAAI,gBAAgB;CAG/B"}
1
+ {"version":3,"file":"mapper.d.ts","sourceRoot":"","sources":["../src/mapper.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AACxD,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,EAAE,wBAAwB,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAEjE,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AAED,qBAAa,UAAU;IACrB,OAAO,CAAC,MAAM,CAAuB;IACrC,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,OAAO,CAAmB;IAClC,OAAO,CAAC,OAAO,CAAU;gBAGvB,MAAM,EAAE,oBAAoB,EAC5B,QAAQ,GAAE,MAAsB,EAChC,OAAO,CAAC,EAAE,gBAAgB,EAC1B,OAAO,GAAE,OAAe;IAQ1B,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,EAAE;IAiBvC,aAAa,CACjB,UAAU,EAAE,MAAM,EAClB,cAAc,CAAC,EAAE,wBAAwB,EACzC,WAAW,CAAC,EAAE,WAAW,GACxB,OAAO,CAAC,IAAI,CAAC;YA8CF,uBAAuB;YAwCvB,uBAAuB;IA8ErC,OAAO,CAAC,UAAU;IAQlB,OAAO,CAAC,oBAAoB;IAkB5B,OAAO,CAAC,oBAAoB;IA4B5B,OAAO,CAAC,eAAe;IAgBvB,OAAO,CAAC,YAAY;IAsDpB,UAAU,IAAI,gBAAgB;CAG/B"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackchuka/gql-ingest",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "A CLI tool for ingesting data from CSV files into a GraphQL API",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",
@@ -331,5 +331,226 @@ describe("DataMapper", () => {
331
331
  const metrics = dataMapper.getMetrics();
332
332
  expect(metrics).toBe(mockMetrics);
333
333
  });
334
+
335
+ it("should convert numeric types from CSV strings to proper GraphQL types", async () => {
336
+ const mockConfig = {
337
+ csvFile: "data/products.csv",
338
+ graphqlFile: "graphql/products.graphql",
339
+ mapping: {
340
+ name: "product_name",
341
+ price: "product_price",
342
+ quantity: "product_quantity",
343
+ active: "product_active",
344
+ },
345
+ };
346
+
347
+ const mockCsvData = [
348
+ {
349
+ product_name: "Widget",
350
+ product_price: "19.99",
351
+ product_quantity: "10",
352
+ product_active: "true",
353
+ },
354
+ ];
355
+
356
+ const mockMutation = `
357
+ mutation CreateProduct($name: String!, $price: Float!, $quantity: Int!, $active: Boolean!) {
358
+ createProduct(input: { name: $name, price: $price, quantity: $quantity, active: $active }) {
359
+ id
360
+ }
361
+ }
362
+ `;
363
+
364
+ mockFs.readFileSync
365
+ .mockReturnValueOnce(JSON.stringify(mockConfig))
366
+ .mockReturnValueOnce(mockMutation);
367
+
368
+ const { readCsvFile } = require("./csv-reader");
369
+ readCsvFile.mockResolvedValue(mockCsvData);
370
+
371
+ mockClient.executeMutation.mockResolvedValue({
372
+ createProduct: { id: "123" },
373
+ });
374
+
375
+ await dataMapper.processEntity("configs/test/mappings/products.json");
376
+
377
+ expect(mockClient.executeMutation).toHaveBeenCalledWith(mockMutation, {
378
+ name: "Widget",
379
+ price: 19.99,
380
+ quantity: 10,
381
+ active: true,
382
+ }, undefined);
383
+ });
384
+
385
+ it("should handle invalid numeric conversions gracefully", async () => {
386
+ const mockConfig = {
387
+ csvFile: "data/products.csv",
388
+ graphqlFile: "graphql/products.graphql",
389
+ mapping: {
390
+ name: "product_name",
391
+ price: "product_price",
392
+ quantity: "product_quantity",
393
+ },
394
+ };
395
+
396
+ const mockCsvData = [
397
+ {
398
+ product_name: "Widget",
399
+ product_price: "invalid_price",
400
+ product_quantity: "invalid_quantity",
401
+ },
402
+ ];
403
+
404
+ const mockMutation = `
405
+ mutation CreateProduct($name: String!, $price: Float!, $quantity: Int!) {
406
+ createProduct(input: { name: $name, price: $price, quantity: $quantity }) {
407
+ id
408
+ }
409
+ }
410
+ `;
411
+
412
+ mockFs.readFileSync
413
+ .mockReturnValueOnce(JSON.stringify(mockConfig))
414
+ .mockReturnValueOnce(mockMutation);
415
+
416
+ const { readCsvFile } = require("./csv-reader");
417
+ readCsvFile.mockResolvedValue(mockCsvData);
418
+
419
+ mockClient.executeMutation.mockResolvedValue({
420
+ createProduct: { id: "123" },
421
+ });
422
+
423
+ const consoleSpy = jest.spyOn(console, "warn").mockImplementation();
424
+
425
+ await dataMapper.processEntity("configs/test/mappings/products.json");
426
+
427
+ expect(mockClient.executeMutation).toHaveBeenCalledWith(mockMutation, {
428
+ name: "Widget",
429
+ price: "invalid_price",
430
+ quantity: "invalid_quantity",
431
+ }, undefined);
432
+
433
+ expect(consoleSpy).toHaveBeenCalledWith(
434
+ 'Warning: Cannot convert "invalid_price" to Float for variable $price. Expected a valid number. Using original value.'
435
+ );
436
+ expect(consoleSpy).toHaveBeenCalledWith(
437
+ 'Warning: Cannot convert "invalid_quantity" to Int for variable $quantity. Expected a valid integer. Using original value.'
438
+ );
439
+
440
+ consoleSpy.mockRestore();
441
+ });
442
+
443
+ it("should handle edge cases in numeric conversion safely", async () => {
444
+ const mockConfig = {
445
+ csvFile: "data/products.csv",
446
+ graphqlFile: "graphql/products.graphql",
447
+ mapping: {
448
+ int_field: "int_value",
449
+ float_field: "float_value",
450
+ },
451
+ };
452
+
453
+ const mockCsvData = [
454
+ {
455
+ int_value: "1.5", // Float in Int field - should remain string
456
+ float_value: "Infinity", // Invalid float - should remain string
457
+ },
458
+ {
459
+ int_value: "not_a_number", // Invalid int - should remain string
460
+ float_value: "1.2.3", // Invalid number format - should remain string
461
+ },
462
+ ];
463
+
464
+ const mockMutation = `
465
+ mutation CreateProduct($int_field: Int!, $float_field: Float!) {
466
+ createProduct(input: { int_field: $int_field, float_field: $float_field }) {
467
+ id
468
+ }
469
+ }
470
+ `;
471
+
472
+ mockFs.readFileSync
473
+ .mockReturnValueOnce(JSON.stringify(mockConfig))
474
+ .mockReturnValueOnce(mockMutation);
475
+
476
+ const { readCsvFile } = require("./csv-reader");
477
+ readCsvFile.mockResolvedValue(mockCsvData);
478
+
479
+ mockClient.executeMutation.mockResolvedValue({
480
+ createProduct: { id: "123" },
481
+ });
482
+
483
+ const consoleSpy = jest.spyOn(console, "warn").mockImplementation();
484
+
485
+ await dataMapper.processEntity("configs/test/mappings/products.json");
486
+
487
+ // Should keep invalid values as strings
488
+ expect(mockClient.executeMutation).toHaveBeenCalledWith(mockMutation, {
489
+ int_field: "1.5",
490
+ float_field: "Infinity",
491
+ }, undefined);
492
+
493
+ expect(mockClient.executeMutation).toHaveBeenCalledWith(mockMutation, {
494
+ int_field: "not_a_number",
495
+ float_field: "1.2.3",
496
+ }, undefined);
497
+
498
+ consoleSpy.mockRestore();
499
+ });
500
+
501
+ it("should keep unknown scalar types as strings", async () => {
502
+ const mockConfig = {
503
+ csvFile: "data/products.csv",
504
+ graphqlFile: "graphql/products.graphql",
505
+ mapping: {
506
+ name: "product_name",
507
+ custom_field: "custom_value",
508
+ },
509
+ };
510
+
511
+ const mockCsvData = [
512
+ {
513
+ product_name: "Widget",
514
+ custom_value: "123",
515
+ },
516
+ ];
517
+
518
+ const mockMutation = `
519
+ mutation CreateProduct($name: String!, $custom_field: CustomScalar!) {
520
+ createProduct(input: { name: $name, custom_field: $custom_field }) {
521
+ id
522
+ }
523
+ }
524
+ `;
525
+
526
+ mockFs.readFileSync
527
+ .mockReturnValueOnce(JSON.stringify(mockConfig))
528
+ .mockReturnValueOnce(mockMutation);
529
+
530
+ const { readCsvFile } = require("./csv-reader");
531
+ readCsvFile.mockResolvedValue(mockCsvData);
532
+
533
+ mockClient.executeMutation.mockResolvedValue({
534
+ createProduct: { id: "123" },
535
+ });
536
+
537
+ // Create verbose mapper to test the logging
538
+ const verboseMapper = new DataMapper(mockClient, testBasePath, mockMetrics, true);
539
+ const consoleSpy = jest.spyOn(console, "log").mockImplementation();
540
+
541
+ await verboseMapper.processEntity("configs/test/mappings/products.json");
542
+
543
+ // Should keep custom scalar as string
544
+ expect(mockClient.executeMutation).toHaveBeenCalledWith(mockMutation, {
545
+ name: "Widget",
546
+ custom_field: "123",
547
+ }, undefined);
548
+
549
+ expect(consoleSpy).toHaveBeenCalledWith(
550
+ 'Unknown GraphQL type "CustomScalar" for variable $custom_field. Keeping value as string.'
551
+ );
552
+
553
+ consoleSpy.mockRestore();
554
+ });
334
555
  });
335
556
  });
package/src/mapper.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
+ import { parse, DocumentNode, VariableDefinitionNode } from "graphql";
3
4
  import { readCsvFile, CsvRow } from "./csv-reader";
4
5
  import { GraphQLClientWrapper } from "./graphql-client";
5
6
  import { MetricsCollector } from "./metrics";
@@ -104,24 +105,33 @@ export class DataMapper {
104
105
  retryConfig?: RetryConfig
105
106
  ): Promise<void> {
106
107
  const totalRows = csvData.length;
107
-
108
+ const variableTypes = this.extractVariableTypes(mutation);
109
+
108
110
  for (let i = 0; i < csvData.length; i++) {
109
111
  const row = csvData[i];
110
- const variables = this.mapCsvRowToVariables(row, mapping);
112
+ const variables = this.mapCsvRowToVariables(row, mapping, variableTypes);
111
113
 
112
114
  try {
113
115
  await this.client.executeMutation(mutation, variables, retryConfig);
114
116
  this.metrics.recordSuccess(entityName);
115
-
117
+
116
118
  // Show progress every 10% or at the end (only in non-verbose mode)
117
- if (!this.verbose && ((i + 1) % Math.max(1, Math.floor(totalRows / 10)) === 0 || i === totalRows - 1)) {
119
+ if (
120
+ !this.verbose &&
121
+ ((i + 1) % Math.max(1, Math.floor(totalRows / 10)) === 0 ||
122
+ i === totalRows - 1)
123
+ ) {
118
124
  const progress = (((i + 1) / totalRows) * 100).toFixed(1);
119
125
  console.log(`📊 Progress: ${i + 1}/${totalRows} (${progress}%) ✓`);
120
126
  }
121
127
  } catch (error) {
122
128
  this.metrics.recordFailure(entityName);
123
129
  if (!this.verbose) {
124
- console.error(`✗ Failed to create entity for row ${i + 1}:`, row, error);
130
+ console.error(
131
+ `✗ Failed to create entity for row ${i + 1}:`,
132
+ row,
133
+ error
134
+ );
125
135
  }
126
136
  }
127
137
  }
@@ -140,6 +150,9 @@ export class DataMapper {
140
150
  `Processing ${csvData.length} rows with concurrency: ${concurrency}`
141
151
  );
142
152
 
153
+ // Extract variable types once for all rows
154
+ const variableTypes = this.extractVariableTypes(mutation);
155
+
143
156
  // Split data into chunks for concurrent processing
144
157
  const chunks = this.chunkArray(csvData, concurrency);
145
158
  let processedCount = 0;
@@ -148,10 +161,14 @@ export class DataMapper {
148
161
  for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
149
162
  const chunk = chunks[chunkIndex];
150
163
  const promises = chunk.map(async (row) => {
151
- const variables = this.mapCsvRowToVariables(row, mapping);
164
+ const variables = this.mapCsvRowToVariables(row, mapping, variableTypes);
152
165
 
153
166
  try {
154
- const result = await this.client.executeMutation(mutation, variables, retryConfig);
167
+ const result = await this.client.executeMutation(
168
+ mutation,
169
+ variables,
170
+ retryConfig
171
+ );
155
172
  this.metrics.recordSuccess(entityName);
156
173
  return { success: true, result, row };
157
174
  } catch (error) {
@@ -166,7 +183,7 @@ export class DataMapper {
166
183
  // Count successes and failures in this chunk
167
184
  let chunkSuccesses = 0;
168
185
  let chunkFailures = 0;
169
-
186
+
170
187
  results.forEach((result) => {
171
188
  if (result.status === "fulfilled") {
172
189
  const { success, error, row } = result.value;
@@ -189,7 +206,11 @@ export class DataMapper {
189
206
  // Show progress update (only in non-verbose mode)
190
207
  if (!this.verbose) {
191
208
  const progress = ((processedCount / totalRows) * 100).toFixed(1);
192
- console.log(`📊 Progress: ${processedCount}/${totalRows} (${progress}%) - Chunk ${chunkIndex + 1}: ${chunkSuccesses} ✓, ${chunkFailures} ✗`);
209
+ console.log(
210
+ `📊 Progress: ${processedCount}/${totalRows} (${progress}%) - Chunk ${
211
+ chunkIndex + 1
212
+ }: ${chunkSuccesses} ✓, ${chunkFailures} ✗`
213
+ );
193
214
  }
194
215
  }
195
216
  }
@@ -204,19 +225,120 @@ export class DataMapper {
204
225
 
205
226
  private mapCsvRowToVariables(
206
227
  row: CsvRow,
207
- mapping: Record<string, string>
228
+ mapping: Record<string, string>,
229
+ variableTypes: Record<string, string>
208
230
  ): Record<string, any> {
209
231
  const variables: Record<string, any> = {};
210
232
 
211
233
  for (const [graphqlVar, csvColumn] of Object.entries(mapping)) {
212
234
  if (row[csvColumn] !== undefined) {
213
- variables[graphqlVar] = row[csvColumn];
235
+ const rawValue = row[csvColumn];
236
+ const type = variableTypes[graphqlVar];
237
+ variables[graphqlVar] = this.convertValue(rawValue, type, graphqlVar);
214
238
  }
215
239
  }
216
240
 
217
241
  return variables;
218
242
  }
219
243
 
244
+ private extractVariableTypes(mutation: string): Record<string, string> {
245
+ const types: Record<string, string> = {};
246
+
247
+ try {
248
+ const document: DocumentNode = parse(mutation);
249
+
250
+ // Find the operation (mutation/query) and extract variable definitions
251
+ for (const definition of document.definitions) {
252
+ if (
253
+ definition.kind === "OperationDefinition" &&
254
+ definition.variableDefinitions
255
+ ) {
256
+ for (const variableDef of definition.variableDefinitions) {
257
+ const varName = variableDef.variable.name.value;
258
+ const typeName = this.extractTypeName(variableDef);
259
+ if (typeName) {
260
+ types[varName] = typeName;
261
+ }
262
+ }
263
+ }
264
+ }
265
+ } catch (error) {
266
+ console.error("Error parsing GraphQL mutation:", error);
267
+ }
268
+
269
+ return types;
270
+ }
271
+
272
+ private extractTypeName(variableDef: VariableDefinitionNode): string | null {
273
+ const type = variableDef.type;
274
+
275
+ if (type.kind === "NonNullType") {
276
+ // Handle non-null types like String!
277
+ if (type.type.kind === "NamedType") {
278
+ return type.type.name.value;
279
+ }
280
+ } else if (type.kind === "NamedType") {
281
+ // Handle nullable types like String
282
+ return type.name.value;
283
+ }
284
+
285
+ return null;
286
+ }
287
+
288
+ private convertValue(value: string, type: string | undefined, varName: string): any {
289
+ if (!type) {
290
+ // No type information available, keep as string
291
+ return value;
292
+ }
293
+
294
+ const trimmedValue = value.trim();
295
+
296
+ switch (type) {
297
+ case "Int":
298
+ const intValue = Number(trimmedValue);
299
+ // Validate that it's a valid integer (no decimals, NaN, or Infinity)
300
+ if (isNaN(intValue) || !isFinite(intValue) || !Number.isInteger(intValue)) {
301
+ console.warn(
302
+ `Warning: Cannot convert "${value}" to Int for variable $${varName}. Expected a valid integer. Using original value.`
303
+ );
304
+ return value;
305
+ }
306
+ return intValue;
307
+
308
+ case "Float":
309
+ const floatValue = Number(trimmedValue);
310
+ // Number() is more strict than parseFloat() - it requires the entire string to be valid
311
+ if (isNaN(floatValue) || !isFinite(floatValue)) {
312
+ console.warn(
313
+ `Warning: Cannot convert "${value}" to Float for variable $${varName}. Expected a valid number. Using original value.`
314
+ );
315
+ return value;
316
+ }
317
+ return floatValue;
318
+
319
+ case "Boolean":
320
+ const lowerValue = trimmedValue.toLowerCase();
321
+ if (lowerValue === "true" || lowerValue === "1") return true;
322
+ if (lowerValue === "false" || lowerValue === "0") return false;
323
+ console.warn(
324
+ `Warning: Cannot convert "${value}" to Boolean for variable $${varName}. Expected "true", "false", "1", or "0". Using original value.`
325
+ );
326
+ return value;
327
+
328
+ case "String":
329
+ return value;
330
+
331
+ default:
332
+ // Unknown scalar type - keep as string for safety
333
+ if (this.verbose) {
334
+ console.log(
335
+ `Unknown GraphQL type "${type}" for variable $${varName}. Keeping value as string.`
336
+ );
337
+ }
338
+ return value;
339
+ }
340
+ }
341
+
220
342
  getMetrics(): MetricsCollector {
221
343
  return this.metrics;
222
344
  }