Skip to content

Commit

Permalink
feat(NODE-4874): support EJSON parse for BigInt from $numberLong (#552)
Browse files Browse the repository at this point in the history
  • Loading branch information
W-A-James committed Jan 19, 2023
1 parent 1c6be19 commit 854aa70
Show file tree
Hide file tree
Showing 4 changed files with 304 additions and 9 deletions.
21 changes: 17 additions & 4 deletions src/extended_json.ts
Expand Up @@ -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 */
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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);
});
}

Expand Down
31 changes: 28 additions & 3 deletions src/long.ts
Expand Up @@ -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;
Expand Down Expand Up @@ -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 */
Expand Down
125 changes: 124 additions & 1 deletion 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';
Expand Down Expand Up @@ -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 () {
Expand Down
136 changes: 135 additions & 1 deletion 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 () {
Expand All @@ -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));
});
});
});
});
});

0 comments on commit 854aa70

Please sign in to comment.