Skip to content

Commit f941663

Browse files
committed
Refactor CSV class and improve array mapping features
- Updated import statements for lodash and headers. - Removed redundant retryOperation method from CSV class. - Enhanced validation logic for empty values in CSV data. - Improved header mapping generation for array-to-columns mapping. - Refactored tests to use fs.readFileSync instead of createReadStream. - Updated test cases to reflect changes in header mapping structure. - Simplified error handling in CSV methods. - Enhanced mocking setup for fs and path in tests. - Added integration tests for CSV functionality with temporary files.
1 parent 9b18bf3 commit f941663

11 files changed

Lines changed: 325 additions & 289 deletions

.claude/settings.local.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(npm test)"
5+
],
6+
"deny": [],
7+
"ask": []
8+
}
9+
}

fzf

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
test\
2+
test\array-mapping.test.ts
3+
test\custom-casting.test.ts
4+
test\index.test.ts
5+
test\schema-validation.test.ts
6+
test-temp\
7+
vitest.config.ts

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
"watch": "pridepack watch",
4848
"start": "pridepack start",
4949
"dev": "pridepack dev",
50-
"test": "vitest"
50+
"test": "vitest --run"
5151
},
5252
"private": false,
5353
"description": "Utilities for csv files / arrays of objects",

src/headers.ts

Lines changed: 69 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22
* @fileoverview Utilities for data transformation, including header mapping and array processing
33
*/
44

5-
import { get as getPath, set as setPath } from 'lodash';
5+
import getPath from 'lodash/get';
6+
import setPath from 'lodash/set';
67
import { CSVError } from './index';
78

89
/**
910
* A path string using dot notation to access nested properties
10-
* @template T - The target object type
1111
*/
12-
export type Path<T = any> = string;
12+
export type Path = string;
1313

1414
/**
1515
* A function used for custom header mapping operations
@@ -24,14 +24,13 @@ export type HeaderMapFn<T = any> = (
2424

2525
/**
2626
* Configuration for mapping multiple CSV columns to a single array property
27-
* @template T - The target object type
2827
*/
29-
export interface CsvToArrayConfig<T = any> {
28+
export interface CsvToArrayConfig {
3029
/** Type identifier for the mapping configuration */
3130
_type: 'csvToTargetArray';
3231

3332
/** The target array property path in dot notation */
34-
targetPath: Path<T>;
33+
targetPath: Path;
3534

3635
/** Option A: Explicit list of CSV column names in order */
3736
sourceCsvColumns?: string[];
@@ -74,9 +73,9 @@ export interface ObjectArrayToCsvConfig {
7473
* @template T - The target object type
7574
*/
7675
export type HeaderMapValue<T = any> =
77-
| Path<T> // Direct path like 'profile.name'
76+
| Path // Direct path like 'profile.name'
7877
| HeaderMapFn<T> // Custom mapping function
79-
| CsvToArrayConfig<T> // CSV columns -> object array property configuration
78+
| CsvToArrayConfig // CSV columns -> object array property configuration
8079
| ObjectArrayToCsvConfig; // Object array property -> CSV columns configuration
8180

8281
/**
@@ -162,25 +161,33 @@ export function createHeaderMapFns<To, RowArr extends any[] = any[]>(
162161
if (!headerMap || typeof headerMap !== 'object') {
163162
throw new CSVError('Header map must be a non-null object');
164163
}
165-
164+
166165
if (Object.keys(headerMap).length === 0) {
167166
throw new CSVError('Header map cannot be empty');
168167
}
169168
};
170-
169+
170+
// Create inverse map for toRowArr
171+
const inverseMap: HeaderMap<any> = {};
172+
for (const [key, value] of Object.entries(headerMap)) {
173+
if (typeof value === 'string') {
174+
inverseMap[value] = isNaN(Number(key)) ? key : Number(key);
175+
}
176+
}
177+
171178
// Helper function to ensure array exists at path
172179
const ensureArrayAtPath = (obj: any, path: string): any[] => {
173180
let current = obj;
174181
const parts = path.split('.');
175-
182+
176183
// Navigate to the parent object
177184
for (let i = 0; i < parts.length - 1; i++) {
178185
if (!current[parts[i]]) {
179186
current[parts[i]] = {};
180187
}
181188
current = current[parts[i]];
182189
}
183-
190+
184191
// Create array if it doesn't exist
185192
const lastPart = parts[parts.length - 1];
186193
if (!current[lastPart]) {
@@ -189,10 +196,10 @@ export function createHeaderMapFns<To, RowArr extends any[] = any[]>(
189196
// Convert to array if not already one
190197
current[lastPart] = [current[lastPart]];
191198
}
192-
199+
193200
return current[lastPart] as any[];
194201
};
195-
202+
196203
// Validate once during creation
197204
validateHeaderMap();
198205

@@ -211,14 +218,15 @@ export function createHeaderMapFns<To, RowArr extends any[] = any[]>(
211218
fromRowArr: (rowArr: RowArr | Record<string, any>, allHeaders?: string[]): To & Record<string, any> => {
212219
const to = {} as To & Record<string, any>;
213220
const handledCsvHeaders = new Set<string | number>();
214-
221+
222+
// Determine if header map uses string keys
223+
const hasStringKeys = Object.keys(headerMap).some(k => isNaN(Number(k)));
224+
215225
// Convert array to object if needed and provide headers
216226
let rowObj: Record<string, any>;
217227
if (Array.isArray(rowArr)) {
218228
// If we're dealing with a header-based mapping but have array data,
219229
// convert the array to an object using header names
220-
const hasStringKeys = Object.keys(headerMap).some(k => isNaN(Number(k)));
221-
222230
if (hasStringKeys && allHeaders) {
223231
rowObj = {};
224232
for (let i = 0; i < rowArr.length && i < allHeaders.length; i++) {
@@ -240,7 +248,7 @@ export function createHeaderMapFns<To, RowArr extends any[] = any[]>(
240248

241249
// Check if rule is a CsvToArrayConfig
242250
if (rule && typeof rule === 'object' && (rule as any)._type === 'csvToTargetArray') {
243-
const arrayRule = rule as CsvToArrayConfig<To>;
251+
const arrayRule = rule as CsvToArrayConfig;
244252
const collectedItems: { value: any; sortKey?: string | number; sourceHeader: string | number }[] = [];
245253

246254
// Determine which headers to scan for matches
@@ -351,11 +359,13 @@ export function createHeaderMapFns<To, RowArr extends any[] = any[]>(
351359
};
352360

353361
// Process all source keys
354-
if (Array.isArray(rowArr)) {
362+
if (Array.isArray(rowArr) && !(hasStringKeys && allHeaders)) {
363+
// Use array indices only when we didn't convert to object
355364
for (let i = 0; i < rowArr.length; i++) {
356365
processHeaderMapping(i, rowArr[i]);
357366
}
358367
} else {
368+
// Use object keys when we have an object (either input was object or converted from array)
359369
for (const [key, value] of Object.entries(rowObj)) {
360370
processHeaderMapping(key, value);
361371
}
@@ -394,23 +404,23 @@ export function createHeaderMapFns<To, RowArr extends any[] = any[]>(
394404
// Determine if mapping is index-based
395405
const isIndexBased = Object.keys(headerMap).every(k => !isNaN(Number(k)));
396406

397-
// Process array-to-csv mappings first
407+
// Process array-to-csv mappings first (using original headerMap)
398408
for (const objectPath in headerMap) {
399409
const mappingRule = headerMap[objectPath];
400-
410+
401411
// Handle array-to-csv mappings
402412
if (typeof mappingRule === 'object' && mappingRule !== null && (mappingRule as any)._type === 'targetArrayToCsv') {
403413
const arrayRule = mappingRule as ObjectArrayToCsvConfig;
404414
const sourceArray = getPath(objAfterMapWasApplied, objectPath);
405-
415+
406416
if (Array.isArray(sourceArray)) {
407417
if (arrayRule.targetCsvColumns) {
408418
// Fixed column names
409419
for (let i = 0; i < arrayRule.targetCsvColumns.length; i++) {
410420
const csvColName = arrayRule.targetCsvColumns[i];
411421
const value = i < sourceArray.length ? sourceArray[i] : null;
412422
const outputValue = value ?? arrayRule.emptyCellOutput ?? '';
413-
423+
414424
if (isIndexBased) {
415425
// For index-based mapping, find the index of this column name
416426
for (const [idx, headerName] of Object.entries(headers)) {
@@ -425,15 +435,15 @@ export function createHeaderMapFns<To, RowArr extends any[] = any[]>(
425435
}
426436
} else if (arrayRule.targetCsvColumnPrefix) {
427437
// Dynamic column names with prefix
428-
const limit = arrayRule.maxColumns !== undefined
429-
? Math.min(arrayRule.maxColumns, sourceArray.length)
438+
const limit = arrayRule.maxColumns !== undefined
439+
? Math.min(arrayRule.maxColumns, sourceArray.length)
430440
: sourceArray.length;
431-
441+
432442
for (let i = 0; i < limit; i++) {
433443
const csvColName = `${arrayRule.targetCsvColumnPrefix}${i + 1}`;
434444
const value = sourceArray[i];
435445
const outputValue = value ?? arrayRule.emptyCellOutput ?? '';
436-
446+
437447
if (isIndexBased) {
438448
// For index-based mapping, find the index of this column name
439449
for (const [idx, headerName] of Object.entries(headers)) {
@@ -447,40 +457,52 @@ export function createHeaderMapFns<To, RowArr extends any[] = any[]>(
447457
}
448458
}
449459
}
450-
460+
451461
handledPaths.add(objectPath);
452462
}
453-
463+
454464
continue;
455465
}
456-
457-
// Skip non-array special rules during toRowArr
466+
}
467+
468+
// Process standard mappings
469+
for (const objectPath in headerMap) {
470+
const mappingRule = headerMap[objectPath];
471+
472+
// Skip array mappings (already handled above)
473+
if (typeof mappingRule === 'object' && mappingRule !== null && (mappingRule as any)._type === 'targetArrayToCsv') {
474+
continue;
475+
}
476+
477+
// Skip csvToTargetArray mappings
458478
if (typeof mappingRule === 'object' && mappingRule !== null && (mappingRule as any)._type === 'csvToTargetArray') {
459479
continue;
460480
}
461481

462482
// Handle standard direct mappings and function mappings
463483
if (typeof mappingRule === 'string') {
464-
// Direct path mapping: Field path -> CSV header name
465-
const csvHeaderName = mappingRule;
466-
let value = getPath(objAfterMapWasApplied, objectPath);
467-
484+
let value = getPath(objAfterMapWasApplied, mappingRule);
485+
468486
if (value !== undefined) {
469487
value = processValueForOutput(value, objectPath, transformFn);
470-
488+
471489
if (isIndexBased) {
472-
// For index-based mapping, find the numeric index of this column name
473-
for (const [idx, headerName] of Object.entries(headers)) {
474-
if (headerName === csvHeaderName) {
475-
row[Number(idx)] = value;
476-
break;
477-
}
478-
}
490+
// For index-based mapping, objectPath is the index
491+
row[Number(objectPath)] = value;
479492
} else {
480-
rowObj[csvHeaderName] = value;
493+
// For header-based mapping, objectPath is the CSV header name
494+
rowObj[objectPath] = value;
481495
}
482496
}
483-
497+
498+
handledPaths.add(objectPath);
499+
} else if (typeof mappingRule === 'number') {
500+
// This shouldn't happen for standard mappings, but handle just in case
501+
let value = getPath(objAfterMapWasApplied, objectPath);
502+
if (value !== undefined) {
503+
value = processValueForOutput(value, objectPath, transformFn);
504+
row[mappingRule] = value;
505+
}
484506
handledPaths.add(objectPath);
485507
} else if (typeof mappingRule === 'function') {
486508
// Function mapping
@@ -499,9 +521,9 @@ export function createHeaderMapFns<To, RowArr extends any[] = any[]>(
499521
// For header-based output, convert the object to an array
500522
if (!isIndexBased) {
501523
if (!headers || headers.length === 0) {
502-
throw new CSVError('Headers array is required for header-based mapping');
524+
headers = Object.keys(headerMap);
503525
}
504-
526+
505527
for (let i = 0; i < headers.length; i++) {
506528
const headerName = headers[i];
507529
row[i] = rowObj[headerName] !== undefined ? rowObj[headerName] : '';

0 commit comments

Comments
 (0)