Skip to content

Commit 946866d

Browse files
authored
fix(NODE-4932): remove .0 suffix from double extended json values (#554)
1 parent ebc1c76 commit 946866d

File tree

3 files changed

+186
-30
lines changed

3 files changed

+186
-30
lines changed

src/double.ts

+3-5
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,9 @@ export class Double {
5858
return { $numberDouble: '-0.0' };
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

+34-9
Original file line numberDiff line numberDiff line change
@@ -184,22 +184,28 @@ describe('BSON Corpus', function () {
184184
// convert inputs to native Javascript objects
185185
const nativeFromCB = bsonToNative(cB);
186186

187-
if (cEJ.includes('1.2345678921232E+18')) {
187+
if (description === 'Double type') {
188188
// The following is special test logic for a "Double type" bson corpus test that uses a different
189189
// string format for the resulting double value
190190
// The test does not have a loss in precision, just different exponential output
191191
// We want to ensure that the stringified value when interpreted as a double is equal
192192
// as opposed to the string being precisely the same
193-
if (description !== 'Double type') {
194-
throw new Error('Unexpected test using 1.2345678921232E+18');
195-
}
196193
const eJSONParsedAsJSON = JSON.parse(cEJ);
197194
const eJSONParsed = EJSON.parse(cEJ, { relaxed: false });
198195
expect(eJSONParsedAsJSON).to.have.nested.property('d.$numberDouble');
199196
expect(eJSONParsed).to.have.nested.property('d._bsontype', 'Double');
200197
const testInputAsFloat = Number.parseFloat(eJSONParsedAsJSON.d.$numberDouble);
198+
const testInputAsNumber = Number(eJSONParsedAsJSON.d.$numberDouble);
201199
const ejsonOutputAsFloat = eJSONParsed.d.valueOf();
202-
expect(ejsonOutputAsFloat).to.equal(testInputAsFloat);
200+
if (eJSONParsedAsJSON.d.$numberDouble === 'NaN') {
201+
expect(ejsonOutputAsFloat).to.be.NaN;
202+
} else {
203+
if (eJSONParsedAsJSON.d.$numberDouble === '-0.0') {
204+
expect(Object.is(ejsonOutputAsFloat, -0)).to.be.true;
205+
}
206+
expect(ejsonOutputAsFloat).to.equal(testInputAsFloat);
207+
expect(ejsonOutputAsFloat).to.equal(testInputAsNumber);
208+
}
203209
} else {
204210
// round tripped EJSON should match the original
205211
expect(nativeToCEJSON(jsonToNative(cEJ))).to.equal(cEJ);
@@ -223,18 +229,37 @@ describe('BSON Corpus', function () {
223229
expect(nativeToBson(jsonToNative(cEJ))).to.deep.equal(cB);
224230
}
225231

226-
if (cEJ.includes('1.2345678921232E+18')) {
232+
if (description === 'Double type') {
227233
// The round tripped value should be equal in interpreted value, not in exact character match
228234
const eJSONFromBSONAsJSON = JSON.parse(
229235
EJSON.stringify(BSON.deserialize(cB), { relaxed: false })
230236
);
231237
const eJSONParsed = EJSON.parse(cEJ, { relaxed: false });
238+
const stringValueKey = Object.keys(eJSONFromBSONAsJSON.d)[0];
239+
const testInputAsFloat = Number.parseFloat(eJSONFromBSONAsJSON.d[stringValueKey]);
240+
const testInputAsNumber = Number(eJSONFromBSONAsJSON.d[stringValueKey]);
241+
232242
// TODO(NODE-4377): EJSON transforms large doubles into longs
233-
expect(eJSONFromBSONAsJSON).to.have.nested.property('d.$numberLong');
243+
expect(eJSONFromBSONAsJSON).to.have.nested.property(
244+
Number.isFinite(testInputAsFloat) &&
245+
Number.isInteger(testInputAsFloat) &&
246+
!Object.is(testInputAsFloat, -0)
247+
? testInputAsFloat <= 0x7fffffff && testInputAsFloat >= -0x80000000
248+
? 'd.$numberInt'
249+
: 'd.$numberLong'
250+
: 'd.$numberDouble'
251+
);
234252
expect(eJSONParsed).to.have.nested.property('d._bsontype', 'Double');
235-
const testInputAsFloat = Number.parseFloat(eJSONFromBSONAsJSON.d.$numberLong);
236253
const ejsonOutputAsFloat = eJSONParsed.d.valueOf();
237-
expect(ejsonOutputAsFloat).to.equal(testInputAsFloat);
254+
if (eJSONFromBSONAsJSON.d.$numberDouble === 'NaN') {
255+
expect(ejsonOutputAsFloat).to.be.NaN;
256+
} else {
257+
if (eJSONFromBSONAsJSON.d.$numberDouble === '-0.0') {
258+
expect(Object.is(ejsonOutputAsFloat, -0)).to.be.true;
259+
}
260+
expect(ejsonOutputAsFloat).to.equal(testInputAsFloat);
261+
expect(ejsonOutputAsFloat).to.equal(testInputAsNumber);
262+
}
238263
} else {
239264
// the reverse direction, BSON -> native -> EJSON, should match canonical EJSON.
240265
expect(nativeToCEJSON(nativeFromCB)).to.equal(cEJ);

test/node/double.test.ts

+149-16
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { expect } from 'chai';
22
import { BSON, Double } from '../register-bson';
33

44
import { BSON_DATA_NUMBER, BSON_DATA_INT } from '../../src/constants';
5-
import { inspect } from 'node:util';
65

76
describe('BSON Double Precision', function () {
87
context('class Double', function () {
@@ -40,24 +39,158 @@ describe('BSON Double Precision', function () {
4039

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

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

0 commit comments

Comments
 (0)