From ebc1c765276e83c9e8f073efdf41b27d4e5e7d7b Mon Sep 17 00:00:00 2001 From: Warren James <28974128+W-A-James@users.noreply.github.com> Date: Mon, 9 Jan 2023 15:46:22 -0500 Subject: [PATCH 1/2] fix(NODE-4887): serializeInto does not check for the presence of a toBSON method for values in Map entries (#555) Co-authored-by: Bailey Pearson --- src/parser/serializer.ts | 6 +++++- test/node/to_bson_test.js | 9 +++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/parser/serializer.ts b/src/parser/serializer.ts index baf3fe9b..076bd3eb 100644 --- a/src/parser/serializer.ts +++ b/src/parser/serializer.ts @@ -757,7 +757,11 @@ export function serializeInto( // Get the entry values const key = entry.value[0]; - const value = entry.value[1]; + let value = entry.value[1]; + + if (typeof value?.toBSON === 'function') { + value = value.toBSON(); + } // Check the type of the value const type = typeof value; diff --git a/test/node/to_bson_test.js b/test/node/to_bson_test.js index 1178cda3..0c78ab90 100644 --- a/test/node/to_bson_test.js +++ b/test/node/to_bson_test.js @@ -183,6 +183,15 @@ describe('toBSON', function () { const sizeNestedToBSON = BSON.calculateObjectSize({ a: [0] }); expect(sizeNestedToBSON).to.equal(33); }); + + it('uses toBSON on values contained in a map', () => { + const map = new Map(); + map.set('a', 100); + + const serializedData = BSON.serialize(map); + const deserializedData = BSON.deserialize(serializedData); + expect(deserializedData).to.have.property('a', 'hello number'); + }); }); it('should use toBSON in calculateObjectSize', () => { From 946866d1a1b265f13c0e6c542f54b9786726fa85 Mon Sep 17 00:00:00 2001 From: Neal Beeken Date: Tue, 10 Jan 2023 16:06:33 -0500 Subject: [PATCH 2/2] fix(NODE-4932): remove .0 suffix from double extended json values (#554) --- src/double.ts | 8 +- test/node/bson_corpus.spec.test.js | 43 ++++++-- test/node/double.test.ts | 165 ++++++++++++++++++++++++++--- 3 files changed, 186 insertions(+), 30 deletions(-) diff --git a/src/double.ts b/src/double.ts index 5e790017..43c86be0 100644 --- a/src/double.ts +++ b/src/double.ts @@ -58,11 +58,9 @@ export class Double { return { $numberDouble: '-0.0' }; } - if (Number.isInteger(this.value)) { - return { $numberDouble: `${this.value}.0` }; - } else { - return { $numberDouble: `${this.value}` }; - } + return { + $numberDouble: Number.isInteger(this.value) ? this.value.toFixed(1) : this.value.toString() + }; } /** @internal */ diff --git a/test/node/bson_corpus.spec.test.js b/test/node/bson_corpus.spec.test.js index 36828bef..a60c738f 100644 --- a/test/node/bson_corpus.spec.test.js +++ b/test/node/bson_corpus.spec.test.js @@ -184,22 +184,28 @@ describe('BSON Corpus', function () { // convert inputs to native Javascript objects const nativeFromCB = bsonToNative(cB); - if (cEJ.includes('1.2345678921232E+18')) { + if (description === 'Double type') { // The following is special test logic for a "Double type" bson corpus test that uses a different // string format for the resulting double value // The test does not have a loss in precision, just different exponential output // We want to ensure that the stringified value when interpreted as a double is equal // as opposed to the string being precisely the same - if (description !== 'Double type') { - throw new Error('Unexpected test using 1.2345678921232E+18'); - } const eJSONParsedAsJSON = JSON.parse(cEJ); const eJSONParsed = EJSON.parse(cEJ, { relaxed: false }); expect(eJSONParsedAsJSON).to.have.nested.property('d.$numberDouble'); expect(eJSONParsed).to.have.nested.property('d._bsontype', 'Double'); const testInputAsFloat = Number.parseFloat(eJSONParsedAsJSON.d.$numberDouble); + const testInputAsNumber = Number(eJSONParsedAsJSON.d.$numberDouble); const ejsonOutputAsFloat = eJSONParsed.d.valueOf(); - expect(ejsonOutputAsFloat).to.equal(testInputAsFloat); + if (eJSONParsedAsJSON.d.$numberDouble === 'NaN') { + expect(ejsonOutputAsFloat).to.be.NaN; + } else { + if (eJSONParsedAsJSON.d.$numberDouble === '-0.0') { + expect(Object.is(ejsonOutputAsFloat, -0)).to.be.true; + } + expect(ejsonOutputAsFloat).to.equal(testInputAsFloat); + expect(ejsonOutputAsFloat).to.equal(testInputAsNumber); + } } else { // round tripped EJSON should match the original expect(nativeToCEJSON(jsonToNative(cEJ))).to.equal(cEJ); @@ -223,18 +229,37 @@ describe('BSON Corpus', function () { expect(nativeToBson(jsonToNative(cEJ))).to.deep.equal(cB); } - if (cEJ.includes('1.2345678921232E+18')) { + if (description === 'Double type') { // The round tripped value should be equal in interpreted value, not in exact character match const eJSONFromBSONAsJSON = JSON.parse( EJSON.stringify(BSON.deserialize(cB), { relaxed: false }) ); const eJSONParsed = EJSON.parse(cEJ, { relaxed: false }); + const stringValueKey = Object.keys(eJSONFromBSONAsJSON.d)[0]; + const testInputAsFloat = Number.parseFloat(eJSONFromBSONAsJSON.d[stringValueKey]); + const testInputAsNumber = Number(eJSONFromBSONAsJSON.d[stringValueKey]); + // TODO(NODE-4377): EJSON transforms large doubles into longs - expect(eJSONFromBSONAsJSON).to.have.nested.property('d.$numberLong'); + expect(eJSONFromBSONAsJSON).to.have.nested.property( + Number.isFinite(testInputAsFloat) && + Number.isInteger(testInputAsFloat) && + !Object.is(testInputAsFloat, -0) + ? testInputAsFloat <= 0x7fffffff && testInputAsFloat >= -0x80000000 + ? 'd.$numberInt' + : 'd.$numberLong' + : 'd.$numberDouble' + ); expect(eJSONParsed).to.have.nested.property('d._bsontype', 'Double'); - const testInputAsFloat = Number.parseFloat(eJSONFromBSONAsJSON.d.$numberLong); const ejsonOutputAsFloat = eJSONParsed.d.valueOf(); - expect(ejsonOutputAsFloat).to.equal(testInputAsFloat); + if (eJSONFromBSONAsJSON.d.$numberDouble === 'NaN') { + expect(ejsonOutputAsFloat).to.be.NaN; + } else { + if (eJSONFromBSONAsJSON.d.$numberDouble === '-0.0') { + expect(Object.is(ejsonOutputAsFloat, -0)).to.be.true; + } + expect(ejsonOutputAsFloat).to.equal(testInputAsFloat); + expect(ejsonOutputAsFloat).to.equal(testInputAsNumber); + } } else { // the reverse direction, BSON -> native -> EJSON, should match canonical EJSON. expect(nativeToCEJSON(nativeFromCB)).to.equal(cEJ); diff --git a/test/node/double.test.ts b/test/node/double.test.ts index 450017a4..bde5240d 100644 --- a/test/node/double.test.ts +++ b/test/node/double.test.ts @@ -2,7 +2,6 @@ import { expect } from 'chai'; import { BSON, Double } from '../register-bson'; import { BSON_DATA_NUMBER, BSON_DATA_INT } from '../../src/constants'; -import { inspect } from 'node:util'; describe('BSON Double Precision', function () { context('class Double', function () { @@ -40,24 +39,158 @@ describe('BSON Double Precision', function () { describe('.toExtendedJSON()', () => { const tests = [ - { input: new Double(0), output: { $numberDouble: '0.0' } }, - { input: new Double(-0), output: { $numberDouble: '-0.0' } }, - { input: new Double(3), output: { $numberDouble: '3.0' } }, - { input: new Double(-3), output: { $numberDouble: '-3.0' } }, - { input: new Double(3.4), output: { $numberDouble: '3.4' } }, - { input: new Double(Number.EPSILON), output: { $numberDouble: '2.220446049250313e-16' } }, - { input: new Double(12345e7), output: { $numberDouble: '123450000000.0' } }, - { input: new Double(12345e-1), output: { $numberDouble: '1234.5' } }, - { input: new Double(-12345e-1), output: { $numberDouble: '-1234.5' } }, - { input: new Double(Infinity), output: { $numberDouble: 'Infinity' } }, - { input: new Double(-Infinity), output: { $numberDouble: '-Infinity' } }, - { input: new Double(NaN), output: { $numberDouble: 'NaN' } } + { + title: 'returns "0.0" when input is a number 0', + input: 0, + output: { $numberDouble: '0.0' } + }, + { + title: 'returns "-0.0" when input is a number -0', + input: -0, + output: { $numberDouble: '-0.0' } + }, + { + title: 'returns "0.0" when input is a string "-0.0"', + input: '-0.0', + output: { $numberDouble: '-0.0' } + }, + { + title: 'returns "3.0" when input is a number 3', + input: 3, + output: { $numberDouble: '3.0' } + }, + { + title: 'returns "-3.0" when input is a number -3', + input: -3, + output: { $numberDouble: '-3.0' } + }, + { + title: 'returns "3.4" when input is a number 3.4', + input: 3.4, + output: { $numberDouble: '3.4' } + }, + { + title: 'returns "2.220446049250313e-16" when input is Number.EPSILON', + input: Number.EPSILON, + output: { $numberDouble: '2.220446049250313e-16' } + }, + { + title: 'returns "123450000000.0" when input is a number 12345e7', + input: 12345e7, + output: { $numberDouble: '123450000000.0' } + }, + { + title: 'returns "1234.5" when input is a number 12345e-1', + input: 12345e-1, + output: { $numberDouble: '1234.5' } + }, + { + title: 'returns "-1234.5" when input is a number -12345e-1', + input: -12345e-1, + output: { $numberDouble: '-1234.5' } + }, + { + title: 'returns "Infinity" when input is a number Infinity', + input: Infinity, + output: { $numberDouble: 'Infinity' } + }, + { + title: 'returns "-Infinity" when input is a number -Infinity', + input: -Infinity, + output: { $numberDouble: '-Infinity' } + }, + { + title: 'returns "NaN" when input is a number NaN', + input: NaN, + output: { $numberDouble: 'NaN' } + }, + { + title: 'returns "1.7976931348623157e+308" when input is a number Number.MAX_VALUE', + input: Number.MAX_VALUE, + output: { $numberDouble: '1.7976931348623157e+308' } + }, + { + title: 'returns "5e-324" when input is a number Number.MIN_VALUE', + input: Number.MIN_VALUE, + output: { $numberDouble: '5e-324' } + }, + { + title: 'returns "-1.7976931348623157e+308" when input is a number -Number.MAX_VALUE', + input: -Number.MAX_VALUE, + output: { $numberDouble: '-1.7976931348623157e+308' } + }, + { + title: 'returns "-5e-324" when input is a number -Number.MIN_VALUE', + input: -Number.MIN_VALUE, + output: { $numberDouble: '-5e-324' } + }, + { + // Reference: https://docs.oracle.com/cd/E19957-01/806-3568/ncg_math.html + // min positive normal number + title: + 'returns "2.2250738585072014e-308" when input is a number the minimum positive normal value', + input: '2.2250738585072014e-308', + output: { $numberDouble: '2.2250738585072014e-308' } + }, + { + // max subnormal number + title: + 'returns "2.225073858507201e-308" when input is a number the maximum positive subnormal value', + input: '2.225073858507201e-308', + output: { $numberDouble: '2.225073858507201e-308' } + }, + { + // min positive subnormal number (NOTE: JS does not output same input string, but numeric values are equal) + title: 'returns "5e-324" when input is a number the minimum positive subnormal value', + input: '4.9406564584124654e-324', + output: { $numberDouble: '5e-324' } + }, + { + // https://262.ecma-international.org/13.0/#sec-number.prototype.tofixed + // Note: calling toString on this integer returns 1000000000000000100, so toFixed is more precise + // This test asserts we do not change _current_ behavior, however preserving this value is not + // something that is possible in BSON, if a future version of this library were to emit + // "1000000000000000100.0" instead, it would not be incorrect from a BSON/MongoDB/Double precision perspective, + // it would just constrain the string output to what is possible with 8 bytes of floating point precision + title: + 'returns "1000000000000000128.0" when input is an int-like number beyond 8-byte double precision', + input: '1000000000000000128', + output: { $numberDouble: '1000000000000000128.0' } + } ]; - for (const { input, output } of tests) { - const title = `returns ${inspect(output)} when Double is ${input}`; + for (const test of tests) { + const input = test.input; + const output = test.output; + const title = test.title; it(title, () => { - expect(output).to.deep.equal(input.toExtendedJSON({ relaxed: false })); + const inputAsDouble = new Double(input); + expect(inputAsDouble.toExtendedJSON({ relaxed: false })).to.deep.equal(output); + if (!Number.isNaN(inputAsDouble.value)) { + expect(Number(inputAsDouble.toExtendedJSON({ relaxed: false }).$numberDouble)).to.equal( + inputAsDouble.value + ); + } + }); + + it(`preserves the byte wise value of ${input} (${typeof input}) after stringification`, () => { + // Asserts the same bytes can be reconstructed from the generated string, + // sometimes the string changes "4.9406564584124654e-324" -> "5e-324" + // but both represent the same ieee754 double bytes + const ejsonDoubleString = new Double(input).toExtendedJSON().$numberDouble; + const bytesFromInput = (() => { + const b = Buffer.alloc(8); + b.writeDoubleBE(Number(input)); + return b.toString('hex'); + })(); + + const bytesFromOutput = (() => { + const b = Buffer.alloc(8); + b.writeDoubleBE(Number(ejsonDoubleString)); + return b.toString('hex'); + })(); + + expect(bytesFromOutput).to.equal(bytesFromInput); }); } });