Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/sweet-candles-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@cerios/csv-nested-json": patch
---

Add a new `preserveUnsafeIntegersAsString` option for number auto-parsing.

When enabled together with `autoParseNumbers`, integer strings outside JavaScript's safe integer range are preserved as strings instead of being converted to imprecise numbers.

This keeps existing behavior as the default and provides an opt-in path to prevent precision loss for large integer values in both regular and streaming parsers.
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -903,6 +903,7 @@ interface CsvParserOptions {

// Value transformations
autoParseNumbers?: boolean; // Default: false
preserveUnsafeIntegersAsString?: boolean; // Default: false
autoParseBooleans?: boolean; // Default: false
autoParseDates?: boolean; // Default: false
valueTransformer?: (value, header) => any; // Custom value transformer
Expand Down Expand Up @@ -978,6 +979,22 @@ Automatically remove BOM (Byte Order Mark) from the beginning of content. Defaul

Automatically convert numeric strings to numbers. Strings with leading zeros (like `"007"`) are preserved.

Note: JavaScript numbers lose integer precision above `Number.MAX_SAFE_INTEGER` (`9007199254740991`).
If you want to prevent precision loss for large integers, enable `preserveUnsafeIntegersAsString`.

#### `preserveUnsafeIntegersAsString`

When used with `autoParseNumbers`, keeps integers outside JavaScript's safe integer range as strings.

```typescript
const result = CsvParser.parseString(csv, {
autoParseNumbers: true,
preserveUnsafeIntegersAsString: true
});

// "9007199254740993" stays a string to avoid precision loss
```

#### `autoParseBooleans`

Automatically convert `'true'`/`'false'` strings to booleans (case-insensitive).
Expand Down
18 changes: 14 additions & 4 deletions src/csv-stream-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,8 +557,15 @@ export class CsvStreamParser extends Transform {
private applyTransformations(
record: Record<string, string>
): Record<string, string | number | boolean | Date | null | undefined> {
const { autoParseNumbers, autoParseBooleans, autoParseDates, valueTransformer, nullValues, nullRepresentation } =
this.options;
const {
autoParseNumbers,
preserveUnsafeIntegersAsString,
autoParseBooleans,
autoParseDates,
valueTransformer,
nullValues,
nullRepresentation,
} = this.options;

if (!autoParseNumbers && !autoParseBooleans && !autoParseDates && !valueTransformer && nullValues === undefined) {
return record;
Expand Down Expand Up @@ -594,7 +601,7 @@ export class CsvStreamParser extends Transform {

// Auto-parse numbers
if (autoParseNumbers) {
const parsed = this.tryParseNumber(value);
const parsed = this.tryParseNumber(value, preserveUnsafeIntegersAsString);
if (parsed !== null) {
transformedValue = parsed;
}
Expand Down Expand Up @@ -652,12 +659,15 @@ export class CsvStreamParser extends Transform {
/**
* Try to parse a string as a number.
*/
private tryParseNumber(value: string): number | null {
private tryParseNumber(value: string, preserveUnsafeIntegersAsString?: boolean): number | string | null {
if (value.trim() === "") return null;
if (/^0\d+$/.test(value)) return null;

const parsed = Number(value);
if (!Number.isNaN(parsed) && Number.isFinite(parsed)) {
if (preserveUnsafeIntegersAsString && /^-?\d+$/.test(value) && !Number.isSafeInteger(parsed)) {
return value;
}
return parsed;
}
return null;
Expand Down
18 changes: 14 additions & 4 deletions src/nested-json-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,15 @@ export class NestedJsonConverter {
records: CsvRecord[],
options: CsvParserOptions
): Record<string, string | number | boolean | Date | null | undefined>[] {
const { autoParseNumbers, autoParseBooleans, autoParseDates, valueTransformer, nullValues, nullRepresentation } =
options;
const {
autoParseNumbers,
preserveUnsafeIntegersAsString,
autoParseBooleans,
autoParseDates,
valueTransformer,
nullValues,
nullRepresentation,
} = options;

// Default null values
const nullSet = new Set((nullValues ?? ["null", "NULL", "nil", "NIL"]).map(v => v.toLowerCase()));
Expand Down Expand Up @@ -175,7 +182,7 @@ export class NestedJsonConverter {

// Step 1: Auto-parse numbers
if (autoParseNumbers) {
const parsed = this.tryParseNumber(value);
const parsed = this.tryParseNumber(value, preserveUnsafeIntegersAsString);
if (parsed !== null) {
transformedValue = parsed;
}
Expand Down Expand Up @@ -235,7 +242,7 @@ export class NestedJsonConverter {
* Try to parse a string as a number.
* Returns null if the string is not a valid number.
*/
private static tryParseNumber(value: string): number | null {
private static tryParseNumber(value: string, preserveUnsafeIntegersAsString?: boolean): number | string | null {
// Don't parse empty strings or whitespace-only
if (value.trim() === "") return null;

Expand All @@ -247,6 +254,9 @@ export class NestedJsonConverter {

// Check if it's a valid finite number
if (!Number.isNaN(parsed) && Number.isFinite(parsed)) {
if (preserveUnsafeIntegersAsString && /^-?\d+$/.test(value) && !Number.isSafeInteger(parsed)) {
return value;
}
return parsed;
}

Expand Down
10 changes: 10 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,16 @@ export interface CsvParserOptions {
*/
autoParseNumbers?: boolean;

/**
* Preserve integer values outside JavaScript safe integer range as strings.
* Helps prevent precision loss when autoParseNumbers is enabled.
* @default false
*
* @remarks
* Only applies to whole numbers where `Math.abs(Number(value)) > Number.MAX_SAFE_INTEGER`.
*/
preserveUnsafeIntegersAsString?: boolean;

/**
* Automatically convert 'true'/'false' strings to booleans.
* Case-insensitive matching.
Expand Down
32 changes: 32 additions & 0 deletions tests/csv-stream-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,38 @@ id,name,age
expect(records).toEqual([{ id: 1, code: "007" }]);
});

it("should preserve unsafe integers as strings when configured", async () => {
const csvContent = `id,big
1,9007199254740993`;
const stream = Readable.from([csvContent]);
const parser = new CsvStreamParser({
autoParseNumbers: true,
preserveUnsafeIntegersAsString: true,
});

const records: NestedObject[] = [];
for await (const record of stream.pipe(parser)) {
records.push(record as NestedObject);
}

expect(records).toEqual([{ id: 1, big: "9007199254740993" }]);
});

it("should keep current behavior for unsafe integers by default", async () => {
const csvContent = `id,big
1,9007199254740993`;
const stream = Readable.from([csvContent]);
const parser = new CsvStreamParser({ autoParseNumbers: true });

const records: NestedObject[] = [];
for await (const record of stream.pipe(parser)) {
records.push(record as NestedObject);
}

expect(typeof records[0].big).toBe("number");
expect(String(records[0].big)).toBe("9007199254740992");
});

it("should auto-parse booleans when enabled", async () => {
const csvContent = `id,active,verified
1,true,FALSE`;
Expand Down
30 changes: 30 additions & 0 deletions tests/edge-cases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,36 @@ describe("Edge Cases", () => {
expect(result[1].code).toBe("00123");
});

it("should preserve unsafe integers as strings when configured", () => {
const csv = "id,big\n1,9007199254740993";
const result = CsvParser.parseString(csv, {
autoParseNumbers: true,
preserveUnsafeIntegersAsString: true,
});

expect(result[0].big).toBe("9007199254740993");
expect(typeof result[0].big).toBe("string");
});

it("should keep current behavior for unsafe integers by default", () => {
const csv = "id,big\n1,9007199254740993";
const result = CsvParser.parseString(csv, { autoParseNumbers: true });

expect(typeof result[0].big).toBe("number");
expect(String(result[0].big)).toBe("9007199254740992");
});

it("should still parse safe integers as numbers with unsafe-integer preservation enabled", () => {
const csv = "id,count\n1,9007199254740991";
const result = CsvParser.parseString(csv, {
autoParseNumbers: true,
preserveUnsafeIntegersAsString: true,
});

expect(result[0].count).toBe(9007199254740991);
expect(typeof result[0].count).toBe("number");
});

it("should auto-parse booleans (case-insensitive)", () => {
const csv = "id,active,verified\n1,true,FALSE\n2,True,false";
const result = CsvParser.parseString(csv, { autoParseBooleans: true });
Expand Down
Loading