diff --git a/src/extended_json.ts b/src/extended_json.ts index 4490c958..8d79e355 100644 --- a/src/extended_json.ts +++ b/src/extended_json.ts @@ -28,6 +28,8 @@ export type EJSONOptions = { legacy?: boolean; /** Enable Extended JSON's `relaxed` mode, which attempts to return native JS types where possible, rather than BSON types */ relaxed?: boolean; + /** Enable native bigint support */ + useBigInt64?: boolean; }; /** @internal */ @@ -76,17 +78,23 @@ const keysToCodecs = { // eslint-disable-next-line @typescript-eslint/no-explicit-any function deserializeValue(value: any, options: EJSONOptions = {}) { if (typeof value === 'number') { + // TODO(NODE-4377): EJSON js number handling diverges from BSON + const in32BitRange = value <= BSON_INT32_MAX && value >= BSON_INT32_MIN; + const in64BitRange = value <= BSON_INT64_MAX && value >= BSON_INT64_MIN; + if (options.relaxed || options.legacy) { return value; } if (Number.isInteger(value) && !Object.is(value, -0)) { // interpret as being of the smallest BSON integer type that can represent the number exactly - if (value >= BSON_INT32_MIN && value <= BSON_INT32_MAX) { + if (in32BitRange) { return new Int32(value); } - if (value >= BSON_INT64_MIN && value <= BSON_INT64_MAX) { - // TODO(NODE-4377): EJSON js number handling diverges from BSON + if (in64BitRange) { + if (options.useBigInt64) { + return BigInt(value); + } return Long.fromNumber(value); } } @@ -378,13 +386,18 @@ function serializeDocument(doc: any, options: EJSONSerializeOptions) { */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function parse(text: string, options?: EJSONOptions): any { + const ejsonOptions = { + useBigInt64: options?.useBigInt64 ?? false, + relaxed: options?.relaxed ?? true, + legacy: options?.legacy ?? false + }; return JSON.parse(text, (key, value) => { if (key.indexOf('\x00') !== -1) { throw new BSONError( `BSON Document field names cannot contain null bytes, found: ${JSON.stringify(key)}` ); } - return deserializeValue(value, { relaxed: true, legacy: false, ...options }); + return deserializeValue(value, ejsonOptions); }); } diff --git a/src/long.ts b/src/long.ts index 0e6911e1..609f9392 100644 --- a/src/long.ts +++ b/src/long.ts @@ -76,6 +76,10 @@ const INT_CACHE: { [key: number]: Long } = {}; /** A cache of the Long representations of small unsigned integer values. */ const UINT_CACHE: { [key: number]: Long } = {}; +const MAX_INT64_STRING_LENGTH = 20; + +const DECIMAL_REG_EX = /^(\+?0|(\+|-)?[1-9][0-9]*)$/; + /** @public */ export interface LongExtended { $numberLong: string; @@ -1023,9 +1027,30 @@ export class Long extends BSONValue { if (options && options.relaxed) return this.toNumber(); return { $numberLong: this.toString() }; } - static fromExtendedJSON(doc: { $numberLong: string }, options?: EJSONOptions): number | Long { - const result = Long.fromString(doc.$numberLong); - return options && options.relaxed ? result.toNumber() : result; + static fromExtendedJSON( + doc: { $numberLong: string }, + options?: EJSONOptions + ): number | Long | bigint { + const { useBigInt64 = false, relaxed = true } = { ...options }; + + if (doc.$numberLong.length > MAX_INT64_STRING_LENGTH) { + throw new BSONError('$numberLong string is too long'); + } + + if (!DECIMAL_REG_EX.test(doc.$numberLong)) { + throw new BSONError(`$numberLong string "${doc.$numberLong}" is in an invalid format`); + } + + if (useBigInt64) { + const bigIntResult = BigInt(doc.$numberLong); + return BigInt.asIntN(64, bigIntResult); + } + + const longResult = Long.fromString(doc.$numberLong); + if (relaxed) { + return longResult.toNumber(); + } + return longResult; } /** @internal */ diff --git a/test/node/bigint.test.ts b/test/node/bigint.test.ts index a1a071b6..9c962eef 100644 --- a/test/node/bigint.test.ts +++ b/test/node/bigint.test.ts @@ -1,4 +1,4 @@ -import { BSON, EJSON, BSONError } from '../register-bson'; +import { BSON, BSONError, EJSON } from '../register-bson'; import { bufferFromHexArray } from './tools/utils'; import { expect } from 'chai'; import { BSON_DATA_LONG } from '../../src/constants'; @@ -264,6 +264,129 @@ describe('BSON BigInt support', function () { }); }); + describe('EJSON.parse()', function () { + type ParseOptions = { + useBigInt64: boolean | undefined; + relaxed: boolean | undefined; + }; + type TestTableEntry = { + options: ParseOptions; + expectedResult: BSON.Document; + }; + + // NOTE: legacy is not changed here as it does not affect the output of parsing a Long + const useBigInt64Values = [true, false, undefined]; + const relaxedValues = [true, false, undefined]; + const sampleCanonicalString = '{"a":{"$numberLong":"23"}}'; + const sampleRelaxedIntegerString = '{"a":4294967296}'; + const sampleRelaxedDoubleString = '{"a": 2147483647.9}'; + + function genTestTable( + useBigInt64: boolean | undefined, + relaxed: boolean | undefined, + getExpectedResult: (boolean, boolean) => BSON.Document + ): [TestTableEntry] { + const useBigInt64IsSet = useBigInt64 ?? false; + const relaxedIsSet = relaxed ?? true; + + const expectedResult = getExpectedResult(useBigInt64IsSet, relaxedIsSet); + + return [{ options: { useBigInt64, relaxed }, expectedResult }]; + } + + function generateBehaviourDescription(entry: TestTableEntry, inputString: string): string { + return `parses field 'a' of '${inputString}' to '${entry.expectedResult.a.constructor.name}' `; + } + + function generateConditionDescription(entry: TestTableEntry): string { + const options = entry.options; + return `when useBigInt64 is ${options.useBigInt64} and relaxed is ${options.relaxed}`; + } + + function generateTest(entry: TestTableEntry, sampleString: string): () => void { + const options = entry.options; + + return () => { + const parsed = EJSON.parse(sampleString, { + useBigInt64: options.useBigInt64, + relaxed: options.relaxed + }); + expect(parsed).to.deep.equal(entry.expectedResult); + }; + } + + function createTestsFromTestTable(table: TestTableEntry[], sampleString: string) { + for (const entry of table) { + const test = generateTest(entry, sampleString); + const condDescription = generateConditionDescription(entry); + const behaviourDescription = generateBehaviourDescription(entry, sampleString); + + describe(condDescription, function () { + it(behaviourDescription, test); + }); + } + } + + describe('canonical input', function () { + const canonicalInputTestTable = useBigInt64Values.flatMap(useBigInt64 => { + return relaxedValues.flatMap(relaxed => { + return genTestTable( + useBigInt64, + relaxed, + (useBigInt64IsSet: boolean, relaxedIsSet: boolean) => + useBigInt64IsSet + ? { a: 23n } + : relaxedIsSet + ? { a: 23 } + : { a: BSON.Long.fromNumber(23) } + ); + }); + }); + + it('meta test: generates 9 tests', () => { + expect(canonicalInputTestTable).to.have.lengthOf(9); + }); + + createTestsFromTestTable(canonicalInputTestTable, sampleCanonicalString); + }); + + describe('relaxed integer input', function () { + const relaxedIntegerInputTestTable = useBigInt64Values.flatMap(useBigInt64 => { + return relaxedValues.flatMap(relaxed => { + return genTestTable( + useBigInt64, + relaxed, + (useBigInt64IsSet: boolean, relaxedIsSet: boolean) => + relaxedIsSet + ? { a: 4294967296 } + : useBigInt64IsSet + ? { a: 4294967296n } + : { a: BSON.Long.fromNumber(4294967296) } + ); + }); + }); + it('meta test: generates 9 tests', () => { + expect(relaxedIntegerInputTestTable).to.have.lengthOf(9); + }); + + createTestsFromTestTable(relaxedIntegerInputTestTable, sampleRelaxedIntegerString); + }); + + describe('relaxed double input where double is outside of int32 range and useBigInt64 is true', function () { + const relaxedDoubleInputTestTable = relaxedValues.flatMap(relaxed => { + return genTestTable(true, relaxed, (_, relaxedIsSet: boolean) => + relaxedIsSet ? { a: 2147483647.9 } : { a: new BSON.Double(2147483647.9) } + ); + }); + + it('meta test: generates 3 tests', () => { + expect(relaxedDoubleInputTestTable).to.have.lengthOf(3); + }); + + createTestsFromTestTable(relaxedDoubleInputTestTable, sampleRelaxedDoubleString); + }); + }); + describe('EJSON.stringify()', function () { context('canonical mode (relaxed=false)', function () { it('truncates bigint values when they are outside the range [BSON_INT64_MIN, BSON_INT64_MAX]', function () { diff --git a/test/node/long.test.ts b/test/node/long.test.ts index 9a73aedc..66f3fd2f 100644 --- a/test/node/long.test.ts +++ b/test/node/long.test.ts @@ -1,4 +1,5 @@ -import { Long } from '../register-bson'; +import { expect } from 'chai'; +import { Long, BSONError } from '../register-bson'; describe('Long', function () { it('accepts strings in the constructor', function () { @@ -21,4 +22,137 @@ describe('Long', function () { expect(new Long(13835058055282163712n).toString()).to.equal('-4611686018427387904'); expect(new Long(13835058055282163712n, true).toString()).to.equal('13835058055282163712'); }); + + describe('static fromExtendedJSON()', function () { + it('is not affected by the legacy flag', function () { + const ejsonDoc = { $numberLong: '123456789123456789' }; + const longRelaxedLegacy = Long.fromExtendedJSON(ejsonDoc, { legacy: true, relaxed: true }); + const longRelaxedNonLegacy = Long.fromExtendedJSON(ejsonDoc, { + legacy: false, + relaxed: true + }); + const longCanonicalLegacy = Long.fromExtendedJSON(ejsonDoc, { legacy: true, relaxed: false }); + const longCanonicalNonLegacy = Long.fromExtendedJSON(ejsonDoc, { + legacy: false, + relaxed: false + }); + + expect(longRelaxedLegacy).to.deep.equal(longRelaxedNonLegacy); + expect(longCanonicalLegacy).to.deep.equal(longCanonicalNonLegacy); + }); + + describe('accepts', function () { + it('+0', function () { + const ejsonDoc = { $numberLong: '+0' }; + expect(Long.fromExtendedJSON(ejsonDoc, { relaxed: false })).to.deep.equal( + Long.fromNumber(0) + ); + }); + + it('negative integers within int64 range', function () { + const ejsonDoc = { $numberLong: '-1235498139' }; + expect(Long.fromExtendedJSON(ejsonDoc, { relaxed: false })).to.deep.equal( + Long.fromNumber(-1235498139) + ); + }); + + it('positive numbers within int64 range', function () { + const ejsonDoc = { $numberLong: '1234567129' }; + expect(Long.fromExtendedJSON(ejsonDoc, { relaxed: false })).to.deep.equal( + Long.fromNumber(1234567129) + ); + }); + }); + + describe('rejects with BSONError', function () { + it('hex strings', function () { + const ejsonDoc = { $numberLong: '0xffffffff' }; + expect(() => Long.fromExtendedJSON(ejsonDoc)).to.throw( + BSONError, + /is in an invalid format/ + ); + }); + + it('octal strings', function () { + const ejsonDoc = { $numberLong: '0o1234567' }; + expect(() => Long.fromExtendedJSON(ejsonDoc)).to.throw( + BSONError, + /is in an invalid format/ + ); + }); + + it('binary strings', function () { + const ejsonDoc = { $numberLong: '0b010101101011' }; + expect(() => Long.fromExtendedJSON(ejsonDoc)).to.throw( + BSONError, + /is in an invalid format/ + ); + }); + + it('strings longer than 20 characters', function () { + const ejsonDoc = { $numberLong: '99999999999999999999999' }; + expect(() => Long.fromExtendedJSON(ejsonDoc)).to.throw(BSONError, /is too long/); + }); + + it('strings with leading zeros', function () { + const ejsonDoc = { $numberLong: '000123456' }; + expect(() => Long.fromExtendedJSON(ejsonDoc)).to.throw( + BSONError, + /is in an invalid format/ + ); + }); + + it('non-numeric strings', function () { + const ejsonDoc = { $numberLong: 'hello world' }; + expect(() => Long.fromExtendedJSON(ejsonDoc)).to.throw( + BSONError, + /is in an invalid format/ + ); + }); + + it('-0', function () { + const ejsonDoc = { $numberLong: '-0' }; + expect(() => Long.fromExtendedJSON(ejsonDoc)).to.throw( + BSONError, + /is in an invalid format/ + ); + }); + }); + + describe('when useBigInt64=true', function () { + describe('truncates', function () { + it('positive numbers outside int64 range', function () { + const ejsonDoc = { $numberLong: '9223372036854775808' }; // 2^63 + expect(Long.fromExtendedJSON(ejsonDoc, { useBigInt64: true })).to.deep.equal( + -9223372036854775808n + ); + }); + + it('negative numbers outside int64 range', function () { + const ejsonDoc = { $numberLong: '-9223372036854775809' }; // -2^63 - 1 + expect(Long.fromExtendedJSON(ejsonDoc, { useBigInt64: true })).to.deep.equal( + 9223372036854775807n + ); + }); + }); + }); + + describe('when useBigInt64=false', function () { + describe('truncates', function () { + it('positive numbers outside int64 range', function () { + const ejsonDoc = { $numberLong: '9223372036854775808' }; // 2^63 + expect( + Long.fromExtendedJSON(ejsonDoc, { useBigInt64: false, relaxed: false }) + ).to.deep.equal(Long.fromBigInt(-9223372036854775808n)); + }); + + it('negative numbers outside int64 range', function () { + const ejsonDoc = { $numberLong: '-9223372036854775809' }; // -2^63 - 1 + expect( + Long.fromExtendedJSON(ejsonDoc, { useBigInt64: false, relaxed: false }) + ).to.deep.equal(Long.fromBigInt(9223372036854775807n)); + }); + }); + }); + }); });