@jackchuka/gql-ingest 2.0.2 → 2.2.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/src/mapper.ts DELETED
@@ -1,489 +0,0 @@
1
- import fs from "fs";
2
- import path from "path";
3
- import { parse, DocumentNode, VariableDefinitionNode } from "graphql";
4
- import { DataReaderFactory, DataRow } from "./readers";
5
- import { GraphQLClientWrapper } from "./graphql-client";
6
- import { MetricsCollector } from "./metrics";
7
- import { ParallelProcessingConfig, RetryConfig } from "./config";
8
-
9
- export interface MappingConfig {
10
- // Legacy CSV support
11
- csvFile?: string;
12
- // New flexible data file support
13
- dataFile?: string;
14
- dataFormat?: string;
15
- graphqlFile: string;
16
- mapping: Record<string, string | any>;
17
- }
18
-
19
- export class DataMapper {
20
- private client: GraphQLClientWrapper;
21
- private basePath: string;
22
- private metrics: MetricsCollector;
23
- private verbose: boolean;
24
- private formatOverride?: string;
25
-
26
- constructor(
27
- client: GraphQLClientWrapper,
28
- basePath: string = process.cwd(),
29
- metrics?: MetricsCollector,
30
- verbose: boolean = false,
31
- formatOverride?: string
32
- ) {
33
- this.client = client;
34
- this.basePath = basePath;
35
- this.metrics = metrics || new MetricsCollector();
36
- this.verbose = verbose;
37
- this.formatOverride = formatOverride;
38
- }
39
-
40
- discoverMappings(configDir: string, entityFilter?: string[]): string[] {
41
- const mappingsPath = path.resolve(this.basePath, configDir, "mappings");
42
-
43
- try {
44
- const files = fs.readdirSync(mappingsPath);
45
- let jsonFiles = files.filter((file) => file.endsWith(".json"));
46
-
47
- // Apply entity filter if provided
48
- if (entityFilter && entityFilter.length > 0) {
49
- const requestedEntities = new Set(entityFilter);
50
- const foundEntities = new Set<string>();
51
-
52
- jsonFiles = jsonFiles.filter((file) => {
53
- const entityName = path.basename(file, ".json");
54
- if (requestedEntities.has(entityName)) {
55
- foundEntities.add(entityName);
56
- return true;
57
- }
58
- return false;
59
- });
60
-
61
- // Check for requested entities that were not found
62
- const notFound = entityFilter.filter((e) => !foundEntities.has(e));
63
- if (notFound.length > 0) {
64
- console.warn(
65
- `Warning: The following entities were not found in mappings: ${notFound.join(
66
- ", "
67
- )}`
68
- );
69
- }
70
- }
71
-
72
- jsonFiles.sort(); // Alphabetical order for consistent processing
73
-
74
- console.log(
75
- `Discovered ${jsonFiles.length} mapping files: ${jsonFiles.join(", ")}`
76
- );
77
- return jsonFiles.map((file) => path.join(configDir, "mappings", file));
78
- } catch (error) {
79
- console.error(`Error reading mappings directory ${mappingsPath}:`, error);
80
- return [];
81
- }
82
- }
83
-
84
- async processEntity(
85
- configPath: string,
86
- parallelConfig?: ParallelProcessingConfig,
87
- retryConfig?: RetryConfig
88
- ): Promise<void> {
89
- const entityName = path.basename(configPath, ".json");
90
- console.log(`Processing entity: ${configPath}`);
91
-
92
- this.metrics.startEntityProcessing(entityName);
93
-
94
- // Read mapping configuration
95
- const configFullPath = path.resolve(this.basePath, configPath);
96
- const config: MappingConfig = JSON.parse(
97
- fs.readFileSync(configFullPath, "utf8")
98
- );
99
-
100
- // Extract config directory (parent of mappings directory)
101
- const configDir = path.dirname(path.dirname(configFullPath));
102
-
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);
117
-
118
- // Read GraphQL mutation (relative to config directory)
119
- const graphqlPath = path.resolve(configDir, config.graphqlFile);
120
- const mutation = fs.readFileSync(graphqlPath, "utf8");
121
-
122
- // Process rows with optional parallelization
123
- if (parallelConfig && parallelConfig.concurrency > 1) {
124
- await this.processRowsConcurrently(
125
- data,
126
- mutation,
127
- config.mapping,
128
- entityName,
129
- parallelConfig,
130
- retryConfig
131
- );
132
- } else {
133
- await this.processRowsSequentially(
134
- data,
135
- mutation,
136
- config.mapping,
137
- entityName,
138
- retryConfig
139
- );
140
- }
141
-
142
- this.metrics.finishEntityProcessing(entityName);
143
- }
144
-
145
- private async processRowsSequentially(
146
- data: DataRow[],
147
- mutation: string,
148
- mapping: Record<string, string | any>,
149
- entityName: string,
150
- retryConfig?: RetryConfig
151
- ): Promise<void> {
152
- const totalRows = data.length;
153
- const variableTypes = this.extractVariableTypes(mutation);
154
-
155
- for (let i = 0; i < data.length; i++) {
156
- const row = data[i];
157
- const variables = this.mapRowToVariables(row, mapping, variableTypes);
158
-
159
- try {
160
- await this.client.executeMutation(mutation, variables, retryConfig);
161
- this.metrics.recordSuccess(entityName);
162
-
163
- // Show progress every 10% or at the end (only in non-verbose mode)
164
- if (
165
- !this.verbose &&
166
- ((i + 1) % Math.max(1, Math.floor(totalRows / 10)) === 0 ||
167
- i === totalRows - 1)
168
- ) {
169
- const progress = (((i + 1) / totalRows) * 100).toFixed(1);
170
- console.log(`📊 Progress: ${i + 1}/${totalRows} (${progress}%) ✓`);
171
- }
172
- } catch (error) {
173
- this.metrics.recordFailure(entityName);
174
- if (!this.verbose) {
175
- console.error(
176
- `✗ Failed to create entity for row ${i + 1}:`,
177
- row,
178
- error
179
- );
180
- }
181
- }
182
- }
183
- }
184
-
185
- private async processRowsConcurrently(
186
- data: DataRow[],
187
- mutation: string,
188
- mapping: Record<string, string | any>,
189
- entityName: string,
190
- parallelConfig: ParallelProcessingConfig,
191
- retryConfig?: RetryConfig
192
- ): Promise<void> {
193
- const concurrency = parallelConfig.concurrency;
194
- console.log(
195
- `Processing ${data.length} rows with concurrency: ${concurrency}`
196
- );
197
-
198
- // Extract variable types once for all rows
199
- const variableTypes = this.extractVariableTypes(mutation);
200
-
201
- // Split data into chunks for concurrent processing
202
- const chunks = this.chunkArray(data, concurrency);
203
- let processedCount = 0;
204
- const totalRows = data.length;
205
-
206
- for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
207
- const chunk = chunks[chunkIndex];
208
- const promises = chunk.map(async (row) => {
209
- const variables = this.mapRowToVariables(row, mapping, variableTypes);
210
-
211
- try {
212
- const result = await this.client.executeMutation(
213
- mutation,
214
- variables,
215
- retryConfig
216
- );
217
- this.metrics.recordSuccess(entityName);
218
- return { success: true, result, row };
219
- } catch (error) {
220
- this.metrics.recordFailure(entityName);
221
- return { success: false, error, row };
222
- }
223
- });
224
-
225
- const results = await Promise.allSettled(promises);
226
- processedCount += chunk.length;
227
-
228
- // Count successes and failures in this chunk
229
- let chunkSuccesses = 0;
230
- let chunkFailures = 0;
231
-
232
- results.forEach((result) => {
233
- if (result.status === "fulfilled") {
234
- const { success, error, row } = result.value;
235
- if (success) {
236
- chunkSuccesses++;
237
- } else {
238
- chunkFailures++;
239
- if (!this.verbose) {
240
- console.error(`✗ Failed to create entity for row:`, row, error);
241
- }
242
- }
243
- } else {
244
- chunkFailures++;
245
- if (!this.verbose) {
246
- console.error(`✗ Promise rejected:`, result.reason);
247
- }
248
- }
249
- });
250
-
251
- // Show progress update (only in non-verbose mode)
252
- if (!this.verbose) {
253
- const progress = ((processedCount / totalRows) * 100).toFixed(1);
254
- console.log(
255
- `📊 Progress: ${processedCount}/${totalRows} (${progress}%) - Chunk ${
256
- chunkIndex + 1
257
- }: ${chunkSuccesses} ✓, ${chunkFailures} ✗`
258
- );
259
- }
260
- }
261
- }
262
-
263
- private chunkArray<T>(array: T[], chunkSize: number): T[][] {
264
- const chunks: T[][] = [];
265
- for (let i = 0; i < array.length; i += chunkSize) {
266
- chunks.push(array.slice(i, i + chunkSize));
267
- }
268
- return chunks;
269
- }
270
-
271
- private mapRowToVariables(
272
- row: DataRow,
273
- mapping: Record<string, string | any>,
274
- variableTypes: Record<string, string>
275
- ): Record<string, any> {
276
- const variables: Record<string, any> = {};
277
-
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];
302
- const type = variableTypes[graphqlVar];
303
- variables[graphqlVar] = this.convertValue(rawValue, type, graphqlVar);
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
- }
313
- }
314
-
315
- return variables;
316
- }
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
-
375
- private extractVariableTypes(mutation: string): Record<string, string> {
376
- const types: Record<string, string> = {};
377
-
378
- try {
379
- const document: DocumentNode = parse(mutation);
380
-
381
- // Find the operation (mutation/query) and extract variable definitions
382
- for (const definition of document.definitions) {
383
- if (
384
- definition.kind === "OperationDefinition" &&
385
- definition.variableDefinitions
386
- ) {
387
- for (const variableDef of definition.variableDefinitions) {
388
- const varName = variableDef.variable.name.value;
389
- const typeName = this.extractTypeName(variableDef);
390
- if (typeName) {
391
- types[varName] = typeName;
392
- }
393
- }
394
- }
395
- }
396
- } catch (error) {
397
- console.error("Error parsing GraphQL mutation:", error);
398
- }
399
-
400
- return types;
401
- }
402
-
403
- private extractTypeName(variableDef: VariableDefinitionNode): string | null {
404
- const type = variableDef.type;
405
-
406
- if (type.kind === "NonNullType") {
407
- // Handle non-null types like String!
408
- if (type.type.kind === "NamedType") {
409
- return type.type.name.value;
410
- }
411
- } else if (type.kind === "NamedType") {
412
- // Handle nullable types like String
413
- return type.name.value;
414
- }
415
-
416
- return null;
417
- }
418
-
419
- private convertValue(
420
- value: any,
421
- type: string | undefined,
422
- varName: string
423
- ): any {
424
- if (!type) {
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") {
431
- return value;
432
- }
433
-
434
- const trimmedValue = value.trim();
435
-
436
- switch (type) {
437
- case "Int":
438
- const intValue = Number(trimmedValue);
439
- // Validate that it's a valid integer (no decimals, NaN, or Infinity)
440
- if (
441
- isNaN(intValue) ||
442
- !isFinite(intValue) ||
443
- !Number.isInteger(intValue)
444
- ) {
445
- console.warn(
446
- `Warning: Cannot convert "${value}" to Int for variable $${varName}. Expected a valid integer. Using original value.`
447
- );
448
- return value;
449
- }
450
- return intValue;
451
-
452
- case "Float":
453
- const floatValue = Number(trimmedValue);
454
- // Number() is more strict than parseFloat() - it requires the entire string to be valid
455
- if (isNaN(floatValue) || !isFinite(floatValue)) {
456
- console.warn(
457
- `Warning: Cannot convert "${value}" to Float for variable $${varName}. Expected a valid number. Using original value.`
458
- );
459
- return value;
460
- }
461
- return floatValue;
462
-
463
- case "Boolean":
464
- const lowerValue = trimmedValue.toLowerCase();
465
- if (lowerValue === "true" || lowerValue === "1") return true;
466
- if (lowerValue === "false" || lowerValue === "0") return false;
467
- console.warn(
468
- `Warning: Cannot convert "${value}" to Boolean for variable $${varName}. Expected "true", "false", "1", or "0". Using original value.`
469
- );
470
- return value;
471
-
472
- case "String":
473
- return value;
474
-
475
- default:
476
- // Unknown scalar type - keep as string for safety
477
- if (this.verbose) {
478
- console.log(
479
- `Unknown GraphQL type "${type}" for variable $${varName}. Keeping value as string.`
480
- );
481
- }
482
- return value;
483
- }
484
- }
485
-
486
- getMetrics(): MetricsCollector {
487
- return this.metrics;
488
- }
489
- }
@@ -1,207 +0,0 @@
1
- import { MetricsCollector } from './metrics';
2
-
3
- describe('MetricsCollector', () => {
4
- let metrics: MetricsCollector;
5
-
6
- beforeEach(() => {
7
- metrics = new MetricsCollector();
8
- });
9
-
10
- describe('initialization', () => {
11
- it('should initialize with zero counts', () => {
12
- expect(metrics.getTotalProcessed()).toBe(0);
13
- expect(metrics.getSuccessRate()).toBe(0);
14
- });
15
-
16
- it('should have a start time', () => {
17
- expect(metrics.getDurationMs()).toBeGreaterThanOrEqual(0);
18
- });
19
- });
20
-
21
- describe('entity processing', () => {
22
- it('should start entity processing', () => {
23
- metrics.startEntityProcessing('testEntity');
24
- const entityMetric = metrics.getEntityMetrics('testEntity');
25
-
26
- expect(entityMetric).toBeDefined();
27
- expect(entityMetric?.entityName).toBe('testEntity');
28
- expect(entityMetric?.successCount).toBe(0);
29
- expect(entityMetric?.failureCount).toBe(0);
30
- expect(entityMetric?.startTime).toBeGreaterThan(0);
31
- expect(entityMetric?.endTime).toBeUndefined();
32
- });
33
-
34
- it('should not create duplicate entity metrics', () => {
35
- metrics.startEntityProcessing('testEntity');
36
- metrics.startEntityProcessing('testEntity');
37
-
38
- const entityMetric = metrics.getEntityMetrics('testEntity');
39
- expect(entityMetric).toBeDefined();
40
- });
41
-
42
- it('should finish entity processing', () => {
43
- metrics.startEntityProcessing('testEntity');
44
- metrics.finishEntityProcessing('testEntity');
45
-
46
- const entityMetric = metrics.getEntityMetrics('testEntity');
47
- expect(entityMetric?.endTime).toBeGreaterThan(0);
48
- });
49
- });
50
-
51
- describe('success and failure recording', () => {
52
- beforeEach(() => {
53
- metrics.startEntityProcessing('testEntity');
54
- });
55
-
56
- it('should record successes', () => {
57
- metrics.recordSuccess('testEntity');
58
- metrics.recordSuccess('testEntity');
59
-
60
- const entityMetric = metrics.getEntityMetrics('testEntity');
61
- expect(entityMetric?.successCount).toBe(2);
62
- expect(entityMetric?.failureCount).toBe(0);
63
- expect(metrics.getTotalProcessed()).toBe(2);
64
- });
65
-
66
- it('should record failures', () => {
67
- metrics.recordFailure('testEntity');
68
- metrics.recordFailure('testEntity');
69
-
70
- const entityMetric = metrics.getEntityMetrics('testEntity');
71
- expect(entityMetric?.successCount).toBe(0);
72
- expect(entityMetric?.failureCount).toBe(2);
73
- expect(metrics.getTotalProcessed()).toBe(2);
74
- });
75
-
76
- it('should record mixed results', () => {
77
- metrics.recordSuccess('testEntity');
78
- metrics.recordFailure('testEntity');
79
- metrics.recordSuccess('testEntity');
80
-
81
- const entityMetric = metrics.getEntityMetrics('testEntity');
82
- expect(entityMetric?.successCount).toBe(2);
83
- expect(entityMetric?.failureCount).toBe(1);
84
- expect(metrics.getTotalProcessed()).toBe(3);
85
- });
86
-
87
- it('should handle unknown entity gracefully', () => {
88
- expect(() => {
89
- metrics.recordSuccess('unknownEntity');
90
- }).not.toThrow();
91
-
92
- expect(metrics.getTotalProcessed()).toBe(0);
93
- });
94
- });
95
-
96
- describe('multiple entities', () => {
97
- beforeEach(() => {
98
- metrics.startEntityProcessing('entity1');
99
- metrics.startEntityProcessing('entity2');
100
- });
101
-
102
- it('should track multiple entities separately', () => {
103
- metrics.recordSuccess('entity1');
104
- metrics.recordSuccess('entity1');
105
- metrics.recordFailure('entity2');
106
-
107
- const entity1Metrics = metrics.getEntityMetrics('entity1');
108
- const entity2Metrics = metrics.getEntityMetrics('entity2');
109
-
110
- expect(entity1Metrics?.successCount).toBe(2);
111
- expect(entity1Metrics?.failureCount).toBe(0);
112
- expect(entity2Metrics?.successCount).toBe(0);
113
- expect(entity2Metrics?.failureCount).toBe(1);
114
- expect(metrics.getTotalProcessed()).toBe(3);
115
- });
116
- });
117
-
118
- describe('success rate calculation', () => {
119
- beforeEach(() => {
120
- metrics.startEntityProcessing('testEntity');
121
- });
122
-
123
- it('should calculate 100% success rate', () => {
124
- metrics.recordSuccess('testEntity');
125
- metrics.recordSuccess('testEntity');
126
-
127
- expect(metrics.getSuccessRate()).toBe(100);
128
- });
129
-
130
- it('should calculate 0% success rate', () => {
131
- metrics.recordFailure('testEntity');
132
- metrics.recordFailure('testEntity');
133
-
134
- expect(metrics.getSuccessRate()).toBe(0);
135
- });
136
-
137
- it('should calculate partial success rate', () => {
138
- metrics.recordSuccess('testEntity');
139
- metrics.recordFailure('testEntity');
140
- metrics.recordFailure('testEntity');
141
-
142
- expect(metrics.getSuccessRate()).toBeCloseTo(33.3, 1);
143
- });
144
-
145
- it('should return 0% for no processed items', () => {
146
- expect(metrics.getSuccessRate()).toBe(0);
147
- });
148
- });
149
-
150
- describe('summary generation', () => {
151
- it('should generate summary for empty metrics', () => {
152
- const summary = metrics.generateSummary();
153
-
154
- expect(summary).toContain('Total Processed: 0');
155
- expect(summary).toContain('Successes: 0');
156
- expect(summary).toContain('Failures: 0');
157
- expect(summary).toContain('Success Rate: 0.0%');
158
- expect(summary).toContain('Duration:');
159
- });
160
-
161
- it('should generate summary with single entity', () => {
162
- metrics.startEntityProcessing('testEntity');
163
- metrics.recordSuccess('testEntity');
164
- metrics.recordFailure('testEntity');
165
- metrics.finishEntityProcessing('testEntity');
166
-
167
- const summary = metrics.generateSummary();
168
-
169
- expect(summary).toContain('Total Processed: 2');
170
- expect(summary).toContain('Successes: 1');
171
- expect(summary).toContain('Failures: 1');
172
- expect(summary).toContain('Success Rate: 50.0%');
173
- });
174
-
175
- it('should generate summary with multiple entities', () => {
176
- metrics.startEntityProcessing('entity1');
177
- metrics.recordSuccess('entity1');
178
- metrics.finishEntityProcessing('entity1');
179
-
180
- metrics.startEntityProcessing('entity2');
181
- metrics.recordFailure('entity2');
182
- metrics.finishEntityProcessing('entity2');
183
-
184
- const summary = metrics.generateSummary();
185
-
186
- expect(summary).toContain('Total Processed: 2');
187
- expect(summary).toContain('Per-Entity Breakdown');
188
- expect(summary).toContain('entity1:');
189
- expect(summary).toContain('entity2:');
190
- });
191
- });
192
-
193
- describe('finish processing', () => {
194
- it('should set end time and return metrics', () => {
195
- metrics.startEntityProcessing('testEntity');
196
- metrics.recordSuccess('testEntity');
197
-
198
- const finalMetrics = metrics.finishProcessing();
199
-
200
- expect(finalMetrics.endTime).toBeGreaterThan(0);
201
- expect(finalMetrics.totalEntities).toBe(1);
202
- expect(finalMetrics.totalSuccesses).toBe(1);
203
- expect(finalMetrics.totalFailures).toBe(0);
204
- expect(finalMetrics.entityMetrics.size).toBe(1);
205
- });
206
- });
207
- });