Skip to content

Commit 854aa70

Browse files
authored
feat(NODE-4874): support EJSON parse for BigInt from $numberLong (#552)
1 parent 1c6be19 commit 854aa70

File tree

4 files changed

+304
-9
lines changed

4 files changed

+304
-9
lines changed

src/extended_json.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export type EJSONOptions = {
2828
legacy?: boolean;
2929
/** Enable Extended JSON's `relaxed` mode, which attempts to return native JS types where possible, rather than BSON types */
3030
relaxed?: boolean;
31+
/** Enable native bigint support */
32+
useBigInt64?: boolean;
3133
};
3234

3335
/** @internal */
@@ -76,17 +78,23 @@ const keysToCodecs = {
7678
// eslint-disable-next-line @typescript-eslint/no-explicit-any
7779
function deserializeValue(value: any, options: EJSONOptions = {}) {
7880
if (typeof value === 'number') {
81+
// TODO(NODE-4377): EJSON js number handling diverges from BSON
82+
const in32BitRange = value <= BSON_INT32_MAX && value >= BSON_INT32_MIN;
83+
const in64BitRange = value <= BSON_INT64_MAX && value >= BSON_INT64_MIN;
84+
7985
if (options.relaxed || options.legacy) {
8086
return value;
8187
}
8288

8389
if (Number.isInteger(value) && !Object.is(value, -0)) {
8490
// interpret as being of the smallest BSON integer type that can represent the number exactly
85-
if (value >= BSON_INT32_MIN && value <= BSON_INT32_MAX) {
91+
if (in32BitRange) {
8692
return new Int32(value);
8793
}
88-
if (value >= BSON_INT64_MIN && value <= BSON_INT64_MAX) {
89-
// TODO(NODE-4377): EJSON js number handling diverges from BSON
94+
if (in64BitRange) {
95+
if (options.useBigInt64) {
96+
return BigInt(value);
97+
}
9098
return Long.fromNumber(value);
9199
}
92100
}
@@ -378,13 +386,18 @@ function serializeDocument(doc: any, options: EJSONSerializeOptions) {
378386
*/
379387
// eslint-disable-next-line @typescript-eslint/no-explicit-any
380388
function parse(text: string, options?: EJSONOptions): any {
389+
const ejsonOptions = {
390+
useBigInt64: options?.useBigInt64 ?? false,
391+
relaxed: options?.relaxed ?? true,
392+
legacy: options?.legacy ?? false
393+
};
381394
return JSON.parse(text, (key, value) => {
382395
if (key.indexOf('\x00') !== -1) {
383396
throw new BSONError(
384397
`BSON Document field names cannot contain null bytes, found: ${JSON.stringify(key)}`
385398
);
386399
}
387-
return deserializeValue(value, { relaxed: true, legacy: false, ...options });
400+
return deserializeValue(value, ejsonOptions);
388401
});
389402
}
390403

src/long.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ const INT_CACHE: { [key: number]: Long } = {};
7676
/** A cache of the Long representations of small unsigned integer values. */
7777
const UINT_CACHE: { [key: number]: Long } = {};
7878

79+
const MAX_INT64_STRING_LENGTH = 20;
80+
81+
const DECIMAL_REG_EX = /^(\+?0|(\+|-)?[1-9][0-9]*)$/;
82+
7983
/** @public */
8084
export interface LongExtended {
8185
$numberLong: string;
@@ -1023,9 +1027,30 @@ export class Long extends BSONValue {
10231027
if (options && options.relaxed) return this.toNumber();
10241028
return { $numberLong: this.toString() };
10251029
}
1026-
static fromExtendedJSON(doc: { $numberLong: string }, options?: EJSONOptions): number | Long {
1027-
const result = Long.fromString(doc.$numberLong);
1028-
return options && options.relaxed ? result.toNumber() : result;
1030+
static fromExtendedJSON(
1031+
doc: { $numberLong: string },
1032+
options?: EJSONOptions
1033+
): number | Long | bigint {
1034+
const { useBigInt64 = false, relaxed = true } = { ...options };
1035+
1036+
if (doc.$numberLong.length > MAX_INT64_STRING_LENGTH) {
1037+
throw new BSONError('$numberLong string is too long');
1038+
}
1039+
1040+
if (!DECIMAL_REG_EX.test(doc.$numberLong)) {
1041+
throw new BSONError(`$numberLong string "${doc.$numberLong}" is in an invalid format`);
1042+
}
1043+
1044+
if (useBigInt64) {
1045+
const bigIntResult = BigInt(doc.$numberLong);
1046+
return BigInt.asIntN(64, bigIntResult);
1047+
}
1048+
1049+
const longResult = Long.fromString(doc.$numberLong);
1050+
if (relaxed) {
1051+
return longResult.toNumber();
1052+
}
1053+
return longResult;
10291054
}
10301055

10311056
/** @internal */

test/node/bigint.test.ts

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { BSON, EJSON, BSONError } from '../register-bson';
1+
import { BSON, BSONError, EJSON } from '../register-bson';
22
import { bufferFromHexArray } from './tools/utils';
33
import { expect } from 'chai';
44
import { BSON_DATA_LONG } from '../../src/constants';
@@ -264,6 +264,129 @@ describe('BSON BigInt support', function () {
264264
});
265265
});
266266

267+
describe('EJSON.parse()', function () {
268+
type ParseOptions = {
269+
useBigInt64: boolean | undefined;
270+
relaxed: boolean | undefined;
271+
};
272+
type TestTableEntry = {
273+
options: ParseOptions;
274+
expectedResult: BSON.Document;
275+
};
276+
277+
// NOTE: legacy is not changed here as it does not affect the output of parsing a Long
278+
const useBigInt64Values = [true, false, undefined];
279+
const relaxedValues = [true, false, undefined];
280+
const sampleCanonicalString = '{"a":{"$numberLong":"23"}}';
281+
const sampleRelaxedIntegerString = '{"a":4294967296}';
282+
const sampleRelaxedDoubleString = '{"a": 2147483647.9}';
283+
284+
function genTestTable(
285+
useBigInt64: boolean | undefined,
286+
relaxed: boolean | undefined,
287+
getExpectedResult: (boolean, boolean) => BSON.Document
288+
): [TestTableEntry] {
289+
const useBigInt64IsSet = useBigInt64 ?? false;
290+
const relaxedIsSet = relaxed ?? true;
291+
292+
const expectedResult = getExpectedResult(useBigInt64IsSet, relaxedIsSet);
293+
294+
return [{ options: { useBigInt64, relaxed }, expectedResult }];
295+
}
296+
297+
function generateBehaviourDescription(entry: TestTableEntry, inputString: string): string {
298+
return `parses field 'a' of '${inputString}' to '${entry.expectedResult.a.constructor.name}' `;
299+
}
300+
301+
function generateConditionDescription(entry: TestTableEntry): string {
302+
const options = entry.options;
303+
return `when useBigInt64 is ${options.useBigInt64} and relaxed is ${options.relaxed}`;
304+
}
305+
306+
function generateTest(entry: TestTableEntry, sampleString: string): () => void {
307+
const options = entry.options;
308+
309+
return () => {
310+
const parsed = EJSON.parse(sampleString, {
311+
useBigInt64: options.useBigInt64,
312+
relaxed: options.relaxed
313+
});
314+
expect(parsed).to.deep.equal(entry.expectedResult);
315+
};
316+
}
317+
318+
function createTestsFromTestTable(table: TestTableEntry[], sampleString: string) {
319+
for (const entry of table) {
320+
const test = generateTest(entry, sampleString);
321+
const condDescription = generateConditionDescription(entry);
322+
const behaviourDescription = generateBehaviourDescription(entry, sampleString);
323+
324+
describe(condDescription, function () {
325+
it(behaviourDescription, test);
326+
});
327+
}
328+
}
329+
330+
describe('canonical input', function () {
331+
const canonicalInputTestTable = useBigInt64Values.flatMap(useBigInt64 => {
332+
return relaxedValues.flatMap(relaxed => {
333+
return genTestTable(
334+
useBigInt64,
335+
relaxed,
336+
(useBigInt64IsSet: boolean, relaxedIsSet: boolean) =>
337+
useBigInt64IsSet
338+
? { a: 23n }
339+
: relaxedIsSet
340+
? { a: 23 }
341+
: { a: BSON.Long.fromNumber(23) }
342+
);
343+
});
344+
});
345+
346+
it('meta test: generates 9 tests', () => {
347+
expect(canonicalInputTestTable).to.have.lengthOf(9);
348+
});
349+
350+
createTestsFromTestTable(canonicalInputTestTable, sampleCanonicalString);
351+
});
352+
353+
describe('relaxed integer input', function () {
354+
const relaxedIntegerInputTestTable = useBigInt64Values.flatMap(useBigInt64 => {
355+
return relaxedValues.flatMap(relaxed => {
356+
return genTestTable(
357+
useBigInt64,
358+
relaxed,
359+
(useBigInt64IsSet: boolean, relaxedIsSet: boolean) =>
360+
relaxedIsSet
361+
? { a: 4294967296 }
362+
: useBigInt64IsSet
363+
? { a: 4294967296n }
364+
: { a: BSON.Long.fromNumber(4294967296) }
365+
);
366+
});
367+
});
368+
it('meta test: generates 9 tests', () => {
369+
expect(relaxedIntegerInputTestTable).to.have.lengthOf(9);
370+
});
371+
372+
createTestsFromTestTable(relaxedIntegerInputTestTable, sampleRelaxedIntegerString);
373+
});
374+
375+
describe('relaxed double input where double is outside of int32 range and useBigInt64 is true', function () {
376+
const relaxedDoubleInputTestTable = relaxedValues.flatMap(relaxed => {
377+
return genTestTable(true, relaxed, (_, relaxedIsSet: boolean) =>
378+
relaxedIsSet ? { a: 2147483647.9 } : { a: new BSON.Double(2147483647.9) }
379+
);
380+
});
381+
382+
it('meta test: generates 3 tests', () => {
383+
expect(relaxedDoubleInputTestTable).to.have.lengthOf(3);
384+
});
385+
386+
createTestsFromTestTable(relaxedDoubleInputTestTable, sampleRelaxedDoubleString);
387+
});
388+
});
389+
267390
describe('EJSON.stringify()', function () {
268391
context('canonical mode (relaxed=false)', function () {
269392
it('truncates bigint values when they are outside the range [BSON_INT64_MIN, BSON_INT64_MAX]', function () {

test/node/long.test.ts

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { Long } from '../register-bson';
1+
import { expect } from 'chai';
2+
import { Long, BSONError } from '../register-bson';
23

34
describe('Long', function () {
45
it('accepts strings in the constructor', function () {
@@ -21,4 +22,137 @@ describe('Long', function () {
2122
expect(new Long(13835058055282163712n).toString()).to.equal('-4611686018427387904');
2223
expect(new Long(13835058055282163712n, true).toString()).to.equal('13835058055282163712');
2324
});
25+
26+
describe('static fromExtendedJSON()', function () {
27+
it('is not affected by the legacy flag', function () {
28+
const ejsonDoc = { $numberLong: '123456789123456789' };
29+
const longRelaxedLegacy = Long.fromExtendedJSON(ejsonDoc, { legacy: true, relaxed: true });
30+
const longRelaxedNonLegacy = Long.fromExtendedJSON(ejsonDoc, {
31+
legacy: false,
32+
relaxed: true
33+
});
34+
const longCanonicalLegacy = Long.fromExtendedJSON(ejsonDoc, { legacy: true, relaxed: false });
35+
const longCanonicalNonLegacy = Long.fromExtendedJSON(ejsonDoc, {
36+
legacy: false,
37+
relaxed: false
38+
});
39+
40+
expect(longRelaxedLegacy).to.deep.equal(longRelaxedNonLegacy);
41+
expect(longCanonicalLegacy).to.deep.equal(longCanonicalNonLegacy);
42+
});
43+
44+
describe('accepts', function () {
45+
it('+0', function () {
46+
const ejsonDoc = { $numberLong: '+0' };
47+
expect(Long.fromExtendedJSON(ejsonDoc, { relaxed: false })).to.deep.equal(
48+
Long.fromNumber(0)
49+
);
50+
});
51+
52+
it('negative integers within int64 range', function () {
53+
const ejsonDoc = { $numberLong: '-1235498139' };
54+
expect(Long.fromExtendedJSON(ejsonDoc, { relaxed: false })).to.deep.equal(
55+
Long.fromNumber(-1235498139)
56+
);
57+
});
58+
59+
it('positive numbers within int64 range', function () {
60+
const ejsonDoc = { $numberLong: '1234567129' };
61+
expect(Long.fromExtendedJSON(ejsonDoc, { relaxed: false })).to.deep.equal(
62+
Long.fromNumber(1234567129)
63+
);
64+
});
65+
});
66+
67+
describe('rejects with BSONError', function () {
68+
it('hex strings', function () {
69+
const ejsonDoc = { $numberLong: '0xffffffff' };
70+
expect(() => Long.fromExtendedJSON(ejsonDoc)).to.throw(
71+
BSONError,
72+
/is in an invalid format/
73+
);
74+
});
75+
76+
it('octal strings', function () {
77+
const ejsonDoc = { $numberLong: '0o1234567' };
78+
expect(() => Long.fromExtendedJSON(ejsonDoc)).to.throw(
79+
BSONError,
80+
/is in an invalid format/
81+
);
82+
});
83+
84+
it('binary strings', function () {
85+
const ejsonDoc = { $numberLong: '0b010101101011' };
86+
expect(() => Long.fromExtendedJSON(ejsonDoc)).to.throw(
87+
BSONError,
88+
/is in an invalid format/
89+
);
90+
});
91+
92+
it('strings longer than 20 characters', function () {
93+
const ejsonDoc = { $numberLong: '99999999999999999999999' };
94+
expect(() => Long.fromExtendedJSON(ejsonDoc)).to.throw(BSONError, /is too long/);
95+
});
96+
97+
it('strings with leading zeros', function () {
98+
const ejsonDoc = { $numberLong: '000123456' };
99+
expect(() => Long.fromExtendedJSON(ejsonDoc)).to.throw(
100+
BSONError,
101+
/is in an invalid format/
102+
);
103+
});
104+
105+
it('non-numeric strings', function () {
106+
const ejsonDoc = { $numberLong: 'hello world' };
107+
expect(() => Long.fromExtendedJSON(ejsonDoc)).to.throw(
108+
BSONError,
109+
/is in an invalid format/
110+
);
111+
});
112+
113+
it('-0', function () {
114+
const ejsonDoc = { $numberLong: '-0' };
115+
expect(() => Long.fromExtendedJSON(ejsonDoc)).to.throw(
116+
BSONError,
117+
/is in an invalid format/
118+
);
119+
});
120+
});
121+
122+
describe('when useBigInt64=true', function () {
123+
describe('truncates', function () {
124+
it('positive numbers outside int64 range', function () {
125+
const ejsonDoc = { $numberLong: '9223372036854775808' }; // 2^63
126+
expect(Long.fromExtendedJSON(ejsonDoc, { useBigInt64: true })).to.deep.equal(
127+
-9223372036854775808n
128+
);
129+
});
130+
131+
it('negative numbers outside int64 range', function () {
132+
const ejsonDoc = { $numberLong: '-9223372036854775809' }; // -2^63 - 1
133+
expect(Long.fromExtendedJSON(ejsonDoc, { useBigInt64: true })).to.deep.equal(
134+
9223372036854775807n
135+
);
136+
});
137+
});
138+
});
139+
140+
describe('when useBigInt64=false', function () {
141+
describe('truncates', function () {
142+
it('positive numbers outside int64 range', function () {
143+
const ejsonDoc = { $numberLong: '9223372036854775808' }; // 2^63
144+
expect(
145+
Long.fromExtendedJSON(ejsonDoc, { useBigInt64: false, relaxed: false })
146+
).to.deep.equal(Long.fromBigInt(-9223372036854775808n));
147+
});
148+
149+
it('negative numbers outside int64 range', function () {
150+
const ejsonDoc = { $numberLong: '-9223372036854775809' }; // -2^63 - 1
151+
expect(
152+
Long.fromExtendedJSON(ejsonDoc, { useBigInt64: false, relaxed: false })
153+
).to.deep.equal(Long.fromBigInt(9223372036854775807n));
154+
});
155+
});
156+
});
157+
});
24158
});

0 commit comments

Comments
 (0)