Skip to content

Commit a298d22

Browse files
authored
fix(NODE-4932): remove .0 suffix from double extended json values (#553)
1 parent 5465c33 commit a298d22

File tree

3 files changed

+184
-30
lines changed

3 files changed

+184
-30
lines changed

src/double.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,12 @@ export class Double {
5555
if (Object.is(Math.sign(this.value), -0)) {
5656
// NOTE: JavaScript has +0 and -0, apparently to model limit calculations. If a user
5757
// explicitly provided `-0` then we need to ensure the sign makes it into the output
58-
return { $numberDouble: '-0.0' };
58+
return { $numberDouble: `-${this.value.toFixed(1)}` };
5959
}
6060

61-
if (Number.isInteger(this.value)) {
62-
return { $numberDouble: `${this.value}.0` };
63-
} else {
64-
return { $numberDouble: `${this.value}` };
65-
}
61+
return {
62+
$numberDouble: Number.isInteger(this.value) ? this.value.toFixed(1) : this.value.toString()
63+
};
6664
}
6765

6866
/** @internal */

test/node/bson_corpus.spec.test.js

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -181,22 +181,28 @@ describe('BSON Corpus', function () {
181181
// convert inputs to native Javascript objects
182182
const nativeFromCB = bsonToNative(cB);
183183

184-
if (cEJ.includes('1.2345678921232E+18')) {
184+
if (description === 'Double type') {
185185
// The following is special test logic for a "Double type" bson corpus test that uses a different
186186
// string format for the resulting double value
187187
// The test does not have a loss in precision, just different exponential output
188188
// We want to ensure that the stringified value when interpreted as a double is equal
189189
// as opposed to the string being precisely the same
190-
if (description !== 'Double type') {
191-
throw new Error('Unexpected test using 1.2345678921232E+18');
192-
}
193190
const eJSONParsedAsJSON = JSON.parse(cEJ);
194191
const eJSONParsed = EJSON.parse(cEJ, { relaxed: false });
195192
expect(eJSONParsedAsJSON).to.have.nested.property('d.$numberDouble');
196193
expect(eJSONParsed).to.have.nested.property('d._bsontype', 'Double');
197194
const testInputAsFloat = Number.parseFloat(eJSONParsedAsJSON.d.$numberDouble);
195+
const testInputAsNumber = Number(eJSONParsedAsJSON.d.$numberDouble);
198196
const ejsonOutputAsFloat = eJSONParsed.d.valueOf();
199-
expect(ejsonOutputAsFloat).to.equal(testInputAsFloat);
197+
if (eJSONParsedAsJSON.d.$numberDouble === 'NaN') {
198+
expect(ejsonOutputAsFloat).to.be.NaN;
199+
} else {
200+
if (eJSONParsedAsJSON.d.$numberDouble === '-0.0') {
201+
expect(Object.is(ejsonOutputAsFloat, -0)).to.be.true;
202+
}
203+
expect(ejsonOutputAsFloat).to.equal(testInputAsFloat);
204+
expect(ejsonOutputAsFloat).to.equal(testInputAsNumber);
205+
}
200206
} else {
201207
// round tripped EJSON should match the original
202208
expect(nativeToCEJSON(jsonToNative(cEJ))).to.equal(cEJ);
@@ -220,18 +226,37 @@ describe('BSON Corpus', function () {
220226
expect(nativeToBson(jsonToNative(cEJ))).to.deep.equal(cB);
221227
}
222228

223-
if (cEJ.includes('1.2345678921232E+18')) {
229+
if (description === 'Double type') {
224230
// The round tripped value should be equal in interpreted value, not in exact character match
225231
const eJSONFromBSONAsJSON = JSON.parse(
226232
EJSON.stringify(BSON.deserialize(cB), { relaxed: false })
227233
);
228234
const eJSONParsed = EJSON.parse(cEJ, { relaxed: false });
235+
const stringValueKey = Object.keys(eJSONFromBSONAsJSON.d)[0];
236+
const testInputAsFloat = Number.parseFloat(eJSONFromBSONAsJSON.d[stringValueKey]);
237+
const testInputAsNumber = Number(eJSONFromBSONAsJSON.d[stringValueKey]);
238+
229239
// TODO(NODE-4377): EJSON transforms large doubles into longs
230-
expect(eJSONFromBSONAsJSON).to.have.nested.property('d.$numberLong');
240+
expect(eJSONFromBSONAsJSON).to.have.nested.property(
241+
Number.isFinite(testInputAsFloat) && Number.isInteger(testInputAsFloat)
242+
? testInputAsFloat <= 0x7fffffff &&
243+
testInputAsFloat >= -0x80000000 &&
244+
!Object.is(testInputAsFloat, -0)
245+
? 'd.$numberInt'
246+
: 'd.$numberLong'
247+
: 'd.$numberDouble'
248+
);
231249
expect(eJSONParsed).to.have.nested.property('d._bsontype', 'Double');
232-
const testInputAsFloat = Number.parseFloat(eJSONFromBSONAsJSON.d.$numberLong);
233250
const ejsonOutputAsFloat = eJSONParsed.d.valueOf();
234-
expect(ejsonOutputAsFloat).to.equal(testInputAsFloat);
251+
if (eJSONFromBSONAsJSON.d.$numberDouble === 'NaN') {
252+
expect(ejsonOutputAsFloat).to.be.NaN;
253+
} else {
254+
if (eJSONFromBSONAsJSON.d.$numberDouble === '-0.0') {
255+
expect(Object.is(ejsonOutputAsFloat, -0)).to.be.true;
256+
}
257+
expect(ejsonOutputAsFloat).to.equal(testInputAsFloat);
258+
expect(ejsonOutputAsFloat).to.equal(testInputAsNumber);
259+
}
235260
} else {
236261
// the reverse direction, BSON -> native -> EJSON, should match canonical EJSON.
237262
expect(nativeToCEJSON(nativeFromCB)).to.equal(cEJ);

test/node/double_tests.js

Lines changed: 146 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
const BSON = require('../register-bson');
44
const Double = BSON.Double;
5-
const inspect = require('util').inspect;
65

76
describe('BSON Double Precision', function () {
87
context('class Double', function () {
@@ -38,26 +37,158 @@ describe('BSON Double Precision', function () {
3837

3938
describe('.toExtendedJSON()', () => {
4039
const tests = [
41-
{ input: new Double(0), output: { $numberDouble: '0.0' } },
42-
{ input: new Double(-0), output: { $numberDouble: '-0.0' } },
43-
{ input: new Double(3), output: { $numberDouble: '3.0' } },
44-
{ input: new Double(-3), output: { $numberDouble: '-3.0' } },
45-
{ input: new Double(3.4), output: { $numberDouble: '3.4' } },
46-
{ input: new Double(Number.EPSILON), output: { $numberDouble: '2.220446049250313e-16' } },
47-
{ input: new Double(12345e7), output: { $numberDouble: '123450000000.0' } },
48-
{ input: new Double(12345e-1), output: { $numberDouble: '1234.5' } },
49-
{ input: new Double(-12345e-1), output: { $numberDouble: '-1234.5' } },
50-
{ input: new Double(Infinity), output: { $numberDouble: 'Infinity' } },
51-
{ input: new Double(-Infinity), output: { $numberDouble: '-Infinity' } },
52-
{ input: new Double(NaN), output: { $numberDouble: 'NaN' } }
40+
{
41+
title: 'returns "0.0" when input is a number 0',
42+
input: 0,
43+
output: { $numberDouble: '0.0' }
44+
},
45+
{
46+
title: 'returns "-0.0" when input is a number -0',
47+
input: -0,
48+
output: { $numberDouble: '-0.0' }
49+
},
50+
{
51+
title: 'returns "0.0" when input is a string "-0.0"',
52+
input: '-0.0',
53+
output: { $numberDouble: '-0.0' }
54+
},
55+
{
56+
title: 'returns "3.0" when input is a number 3',
57+
input: 3,
58+
output: { $numberDouble: '3.0' }
59+
},
60+
{
61+
title: 'returns "-3.0" when input is a number -3',
62+
input: -3,
63+
output: { $numberDouble: '-3.0' }
64+
},
65+
{
66+
title: 'returns "3.4" when input is a number 3.4',
67+
input: 3.4,
68+
output: { $numberDouble: '3.4' }
69+
},
70+
{
71+
title: 'returns "2.220446049250313e-16" when input is Number.EPSILON',
72+
input: Number.EPSILON,
73+
output: { $numberDouble: '2.220446049250313e-16' }
74+
},
75+
{
76+
title: 'returns "123450000000.0" when input is a number 12345e7',
77+
input: 12345e7,
78+
output: { $numberDouble: '123450000000.0' }
79+
},
80+
{
81+
title: 'returns "1234.5" when input is a number 12345e-1',
82+
input: 12345e-1,
83+
output: { $numberDouble: '1234.5' }
84+
},
85+
{
86+
title: 'returns "-1234.5" when input is a number -12345e-1',
87+
input: -12345e-1,
88+
output: { $numberDouble: '-1234.5' }
89+
},
90+
{
91+
title: 'returns "Infinity" when input is a number Infinity',
92+
input: Infinity,
93+
output: { $numberDouble: 'Infinity' }
94+
},
95+
{
96+
title: 'returns "-Infinity" when input is a number -Infinity',
97+
input: -Infinity,
98+
output: { $numberDouble: '-Infinity' }
99+
},
100+
{
101+
title: 'returns "NaN" when input is a number NaN',
102+
input: NaN,
103+
output: { $numberDouble: 'NaN' }
104+
},
105+
{
106+
title: 'returns "1.7976931348623157e+308" when input is a number Number.MAX_VALUE',
107+
input: Number.MAX_VALUE,
108+
output: { $numberDouble: '1.7976931348623157e+308' }
109+
},
110+
{
111+
title: 'returns "5e-324" when input is a number Number.MIN_VALUE',
112+
input: Number.MIN_VALUE,
113+
output: { $numberDouble: '5e-324' }
114+
},
115+
{
116+
title: 'returns "-1.7976931348623157e+308" when input is a number -Number.MAX_VALUE',
117+
input: -Number.MAX_VALUE,
118+
output: { $numberDouble: '-1.7976931348623157e+308' }
119+
},
120+
{
121+
title: 'returns "-5e-324" when input is a number -Number.MIN_VALUE',
122+
input: -Number.MIN_VALUE,
123+
output: { $numberDouble: '-5e-324' }
124+
},
125+
{
126+
// Reference: https://docs.oracle.com/cd/E19957-01/806-3568/ncg_math.html
127+
// min positive normal number
128+
title:
129+
'returns "2.2250738585072014e-308" when input is a number the minimum positive normal value',
130+
input: '2.2250738585072014e-308',
131+
output: { $numberDouble: '2.2250738585072014e-308' }
132+
},
133+
{
134+
// max subnormal number
135+
title:
136+
'returns "2.225073858507201e-308" when input is a number the maximum positive subnormal value',
137+
input: '2.225073858507201e-308',
138+
output: { $numberDouble: '2.225073858507201e-308' }
139+
},
140+
{
141+
// min positive subnormal number (NOTE: JS does not output same input string, but numeric values are equal)
142+
title: 'returns "5e-324" when input is a number the minimum positive subnormal value',
143+
input: '4.9406564584124654e-324',
144+
output: { $numberDouble: '5e-324' }
145+
},
146+
{
147+
// https://262.ecma-international.org/13.0/#sec-number.prototype.tofixed
148+
// Note: calling toString on this integer returns 1000000000000000100, so toFixed is more precise
149+
// This test asserts we do not change _current_ behavior, however preserving this value is not
150+
// something that is possible in BSON, if a future version of this library were to emit
151+
// "1000000000000000100.0" instead, it would not be incorrect from a BSON/MongoDB/Double precision perspective,
152+
// it would just constrain the string output to what is possible with 8 bytes of floating point precision
153+
title:
154+
'returns "1000000000000000128.0" when input is an int-like number beyond 8-byte double precision',
155+
input: '1000000000000000128',
156+
output: { $numberDouble: '1000000000000000128.0' }
157+
}
53158
];
54159

55160
for (const test of tests) {
56161
const input = test.input;
57162
const output = test.output;
58-
const title = `returns ${inspect(output)} when Double is ${input}`;
163+
const title = test.title;
59164
it(title, () => {
60-
expect(output).to.deep.equal(input.toExtendedJSON({ relaxed: false }));
165+
const inputAsDouble = new Double(input);
166+
expect(inputAsDouble.toExtendedJSON({ relaxed: false })).to.deep.equal(output);
167+
if (!Number.isNaN(inputAsDouble.value)) {
168+
expect(Number(inputAsDouble.toExtendedJSON({ relaxed: false }).$numberDouble)).to.equal(
169+
inputAsDouble.value
170+
);
171+
}
172+
});
173+
174+
it(`preserves the byte wise value of ${input} (${typeof input}) after stringification`, () => {
175+
// Asserts the same bytes can be reconstructed from the generated string,
176+
// sometimes the string changes "4.9406564584124654e-324" -> "5e-324"
177+
// but both represent the same ieee754 double bytes
178+
const ejsonDoubleString = new Double(input).toExtendedJSON().$numberDouble;
179+
const bytesFromInput = (() => {
180+
const b = Buffer.alloc(8);
181+
b.writeDoubleBE(Number(input));
182+
return b.toString('hex');
183+
})();
184+
185+
const bytesFromOutput = (() => {
186+
const b = Buffer.alloc(8);
187+
b.writeDoubleBE(Number(ejsonDoubleString));
188+
return b.toString('hex');
189+
})();
190+
191+
expect(bytesFromOutput).to.equal(bytesFromInput);
61192
});
62193
}
63194
});

0 commit comments

Comments
 (0)