@jackchuka/gql-ingest 1.2.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.
@@ -141,11 +141,11 @@ describe("DataMapper", () => {
141
141
  expect(mockClient.executeMutation).toHaveBeenCalledWith(mockMutation, {
142
142
  name: "John",
143
143
  email: "john@example.com",
144
- });
144
+ }, undefined);
145
145
  expect(mockClient.executeMutation).toHaveBeenCalledWith(mockMutation, {
146
146
  name: "Jane",
147
147
  email: "jane@example.com",
148
- });
148
+ }, undefined);
149
149
 
150
150
  consoleSpy.mockRestore();
151
151
  });
@@ -223,7 +223,7 @@ describe("DataMapper", () => {
223
223
  name: "Widget",
224
224
  price: "19.99",
225
225
  sku: "W001",
226
- });
226
+ }, undefined);
227
227
  });
228
228
 
229
229
  it("should handle missing CSV columns gracefully", async () => {
@@ -260,7 +260,7 @@ describe("DataMapper", () => {
260
260
  expect(mockClient.executeMutation).toHaveBeenCalledWith(mockMutation, {
261
261
  name: "John",
262
262
  email: "john@example.com",
263
- });
263
+ }, undefined);
264
264
  });
265
265
 
266
266
  it("should call metrics methods during successful processing", async () => {
@@ -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,9 +1,10 @@
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";
6
- import { ParallelProcessingConfig } from "./config";
7
+ import { ParallelProcessingConfig, RetryConfig } from "./config";
7
8
 
8
9
  export interface MappingConfig {
9
10
  csvFile: string;
@@ -48,7 +49,8 @@ export class DataMapper {
48
49
 
49
50
  async processEntity(
50
51
  configPath: string,
51
- parallelConfig?: ParallelProcessingConfig
52
+ parallelConfig?: ParallelProcessingConfig,
53
+ retryConfig?: RetryConfig
52
54
  ): Promise<void> {
53
55
  const entityName = path.basename(configPath, ".json");
54
56
  console.log(`Processing entity: ${configPath}`);
@@ -79,14 +81,16 @@ export class DataMapper {
79
81
  mutation,
80
82
  config.mapping,
81
83
  entityName,
82
- parallelConfig
84
+ parallelConfig,
85
+ retryConfig
83
86
  );
84
87
  } else {
85
88
  await this.processRowsSequentially(
86
89
  csvData,
87
90
  mutation,
88
91
  config.mapping,
89
- entityName
92
+ entityName,
93
+ retryConfig
90
94
  );
91
95
  }
92
96
 
@@ -97,27 +101,37 @@ export class DataMapper {
97
101
  csvData: CsvRow[],
98
102
  mutation: string,
99
103
  mapping: Record<string, string>,
100
- entityName: string
104
+ entityName: string,
105
+ retryConfig?: RetryConfig
101
106
  ): Promise<void> {
102
107
  const totalRows = csvData.length;
103
-
108
+ const variableTypes = this.extractVariableTypes(mutation);
109
+
104
110
  for (let i = 0; i < csvData.length; i++) {
105
111
  const row = csvData[i];
106
- const variables = this.mapCsvRowToVariables(row, mapping);
112
+ const variables = this.mapCsvRowToVariables(row, mapping, variableTypes);
107
113
 
108
114
  try {
109
- await this.client.executeMutation(mutation, variables);
115
+ await this.client.executeMutation(mutation, variables, retryConfig);
110
116
  this.metrics.recordSuccess(entityName);
111
-
117
+
112
118
  // Show progress every 10% or at the end (only in non-verbose mode)
113
- 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
+ ) {
114
124
  const progress = (((i + 1) / totalRows) * 100).toFixed(1);
115
125
  console.log(`šŸ“Š Progress: ${i + 1}/${totalRows} (${progress}%) āœ“`);
116
126
  }
117
127
  } catch (error) {
118
128
  this.metrics.recordFailure(entityName);
119
129
  if (!this.verbose) {
120
- 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
+ );
121
135
  }
122
136
  }
123
137
  }
@@ -128,13 +142,17 @@ export class DataMapper {
128
142
  mutation: string,
129
143
  mapping: Record<string, string>,
130
144
  entityName: string,
131
- parallelConfig: ParallelProcessingConfig
145
+ parallelConfig: ParallelProcessingConfig,
146
+ retryConfig?: RetryConfig
132
147
  ): Promise<void> {
133
148
  const concurrency = parallelConfig.concurrency;
134
149
  console.log(
135
150
  `Processing ${csvData.length} rows with concurrency: ${concurrency}`
136
151
  );
137
152
 
153
+ // Extract variable types once for all rows
154
+ const variableTypes = this.extractVariableTypes(mutation);
155
+
138
156
  // Split data into chunks for concurrent processing
139
157
  const chunks = this.chunkArray(csvData, concurrency);
140
158
  let processedCount = 0;
@@ -143,10 +161,14 @@ export class DataMapper {
143
161
  for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
144
162
  const chunk = chunks[chunkIndex];
145
163
  const promises = chunk.map(async (row) => {
146
- const variables = this.mapCsvRowToVariables(row, mapping);
164
+ const variables = this.mapCsvRowToVariables(row, mapping, variableTypes);
147
165
 
148
166
  try {
149
- const result = await this.client.executeMutation(mutation, variables);
167
+ const result = await this.client.executeMutation(
168
+ mutation,
169
+ variables,
170
+ retryConfig
171
+ );
150
172
  this.metrics.recordSuccess(entityName);
151
173
  return { success: true, result, row };
152
174
  } catch (error) {
@@ -161,7 +183,7 @@ export class DataMapper {
161
183
  // Count successes and failures in this chunk
162
184
  let chunkSuccesses = 0;
163
185
  let chunkFailures = 0;
164
-
186
+
165
187
  results.forEach((result) => {
166
188
  if (result.status === "fulfilled") {
167
189
  const { success, error, row } = result.value;
@@ -184,7 +206,11 @@ export class DataMapper {
184
206
  // Show progress update (only in non-verbose mode)
185
207
  if (!this.verbose) {
186
208
  const progress = ((processedCount / totalRows) * 100).toFixed(1);
187
- 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
+ );
188
214
  }
189
215
  }
190
216
  }
@@ -199,19 +225,120 @@ export class DataMapper {
199
225
 
200
226
  private mapCsvRowToVariables(
201
227
  row: CsvRow,
202
- mapping: Record<string, string>
228
+ mapping: Record<string, string>,
229
+ variableTypes: Record<string, string>
203
230
  ): Record<string, any> {
204
231
  const variables: Record<string, any> = {};
205
232
 
206
233
  for (const [graphqlVar, csvColumn] of Object.entries(mapping)) {
207
234
  if (row[csvColumn] !== undefined) {
208
- variables[graphqlVar] = row[csvColumn];
235
+ const rawValue = row[csvColumn];
236
+ const type = variableTypes[graphqlVar];
237
+ variables[graphqlVar] = this.convertValue(rawValue, type, graphqlVar);
209
238
  }
210
239
  }
211
240
 
212
241
  return variables;
213
242
  }
214
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
+
215
342
  getMetrics(): MetricsCollector {
216
343
  return this.metrics;
217
344
  }
package/src/metrics.ts CHANGED
@@ -12,6 +12,9 @@ export interface ProcessingMetrics {
12
12
  totalFailures: number;
13
13
  entityMetrics: Map<string, EntityMetrics>;
14
14
  requestDurations: number[];
15
+ retryAttempts: number;
16
+ retrySuccesses: number;
17
+ retryFailures: number;
15
18
  startTime: number;
16
19
  endTime?: number;
17
20
  }
@@ -26,6 +29,9 @@ export class MetricsCollector {
26
29
  totalFailures: 0,
27
30
  entityMetrics: new Map(),
28
31
  requestDurations: [],
32
+ retryAttempts: 0,
33
+ retrySuccesses: 0,
34
+ retryFailures: 0,
29
35
  startTime: Date.now(),
30
36
  };
31
37
  }
@@ -88,6 +94,16 @@ export class MetricsCollector {
88
94
  this.metrics.requestDurations.push(duration);
89
95
  }
90
96
 
97
+ recordRetrySuccess(attempts: number): void {
98
+ this.metrics.retryAttempts += attempts;
99
+ this.metrics.retrySuccesses++;
100
+ }
101
+
102
+ recordRetryFailure(attempts: number): void {
103
+ this.metrics.retryAttempts += attempts;
104
+ this.metrics.retryFailures++;
105
+ }
106
+
91
107
  getAverageRequestDuration(): number {
92
108
  if (this.metrics.requestDurations.length === 0) return 0;
93
109
  const sum = this.metrics.requestDurations.reduce((a, b) => a + b, 0);
@@ -114,6 +130,12 @@ export class MetricsCollector {
114
130
  if (this.metrics.requestDurations.length > 0) {
115
131
  summary += ` Avg Request Time: ${avgRequestDuration.toFixed(0)}ms\n`;
116
132
  }
133
+
134
+ if (this.metrics.retryAttempts > 0) {
135
+ summary += ` Retry Attempts: ${this.metrics.retryAttempts}\n`;
136
+ summary += ` Retry Successes: ${this.metrics.retrySuccesses}\n`;
137
+ summary += ` Retry Failures: ${this.metrics.retryFailures}\n`;
138
+ }
117
139
 
118
140
  if (this.metrics.entityMetrics.size > 1) {
119
141
  summary += `\nšŸ“‹ Per-Entity Breakdown:\n`;