Skip to content

Commit a26ff14

Browse files
authored
feat: return unsafe integers inside JSON columns as exact strings with supportBigNumbers (#4388)
* feat: return unsafe integers inside JSON columns as exact strings with supportBigNumbers JSON column values (MySQL JSON, and MariaDB JSON identified via extended metadata) are parsed with plain JSON.parse, which silently rounds integers beyond Number.MAX_SAFE_INTEGER to the nearest double: a stored {"id": 9007199254740993} comes back as 9007199254740992 with no error. When the existing supportBigNumbers option is enabled, such integers are now returned as exact String objects, mirroring the option's documented behaviour for BIGINT and DECIMAL columns; safe integers, floats and numeric strings are unaffected, and the default behaviour without the option is unchanged. jsonStrings continues to return the raw (always exact) document text. Implemented as a packet.parseJson() method shared by all four row parsers. Exactness uses JSON.parse source access (proposal-json-parse- with-source, Node.js 22+); older runtimes fall back to plain JSON.parse. A 16-digit pre-scan keeps the reviver off the hot path for documents that cannot contain unsafe integers. supportBigNumbers is already part of the parser cache key, so no cache changes are needed. * chore: keep comments to rationale only --------- Co-authored-by: Vlad Lasky <vlasky@users.noreply.github.com>
1 parent 5034e57 commit a26ff14

8 files changed

Lines changed: 268 additions & 10 deletions

File tree

lib/packets/packet.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,33 @@ function leftPad(num, value) {
3939
const minus = '-'.charCodeAt(0);
4040
const plus = '+'.charCodeAt(0);
4141

42+
// JSON.parse source access: proposal-json-parse-with-source,
43+
// Node.js 22+ / V8 12.2
44+
const jsonSourceAccessSupported = (() => {
45+
let supported = false;
46+
JSON.parse('0', (key, value, context) => {
47+
supported = context !== undefined && typeof context.source === 'string';
48+
return value;
49+
});
50+
return supported;
51+
})();
52+
53+
// 16 digits is the shortest numeral that can exceed Number.MAX_SAFE_INTEGER
54+
const jsonBigNumeral = /\d{16}/;
55+
const jsonIntegerSource = /^-?\d+$/;
56+
57+
function jsonBigNumberReviver(key, value, context) {
58+
if (
59+
typeof value === 'number' &&
60+
!Number.isSafeInteger(value) &&
61+
context !== undefined &&
62+
jsonIntegerSource.test(context.source)
63+
) {
64+
return context.source;
65+
}
66+
return value;
67+
}
68+
4269
// TODO: handle E notation
4370
const dot = '.'.charCodeAt(0);
4471
const exponent = 'e'.charCodeAt(0);
@@ -654,6 +681,21 @@ class Packet {
654681
return result;
655682
}
656683

684+
// With supportBigNumbers, unsafe integers become exact strings,
685+
// mirroring the option's behaviour for BIGINT columns
686+
parseJson(encoding, supportBigNumbers) {
687+
const str = this.readLengthCodedString(encoding);
688+
if (
689+
supportBigNumbers &&
690+
jsonSourceAccessSupported &&
691+
str !== null &&
692+
jsonBigNumeral.test(str)
693+
) {
694+
return JSON.parse(str, jsonBigNumberReviver);
695+
}
696+
return JSON.parse(str);
697+
}
698+
657699
parseDate(timezone) {
658700
const strLen = this.readLengthCodedNumber();
659701
if (strLen === null) {

lib/parsers/binary_parser.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ function readCodeFor(field, config, options, fieldNum) {
2626
if (field.extendedFormat === 'json') {
2727
return config.jsonStrings
2828
? `packet.readLengthCodedString(fields[${fieldNum}].encoding)`
29-
: `JSON.parse(packet.readLengthCodedString(fields[${fieldNum}].encoding));`;
29+
: `packet.parseJson(fields[${fieldNum}].encoding, ${supportBigNumbers});`;
3030
}
3131
switch (field.columnType) {
3232
case Types.TINY:
@@ -70,7 +70,7 @@ function readCodeFor(field, config, options, fieldNum) {
7070
// see https://github.com/sidorares/node-mysql2/issues/409
7171
return config.jsonStrings
7272
? 'packet.readLengthCodedString("utf8")'
73-
: 'JSON.parse(packet.readLengthCodedString("utf8"));';
73+
: `packet.parseJson("utf8", ${supportBigNumbers});`;
7474
case Types.LONGLONG:
7575
if (!supportBigNumbers) {
7676
return unsigned

lib/parsers/static_binary_parser.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ function getBinaryParser(fields, _options, config) {
2727
if (field.extendedFormat === 'json') {
2828
return config.jsonStrings
2929
? packet.readLengthCodedString(field.encoding)
30-
: JSON.parse(packet.readLengthCodedString(field.encoding));
30+
: packet.parseJson(field.encoding, supportBigNumbers);
3131
}
3232

3333
switch (field.columnType) {
@@ -74,7 +74,7 @@ function getBinaryParser(fields, _options, config) {
7474
// see https://github.com/sidorares/node-mysql2/issues/409
7575
return config.jsonStrings
7676
? packet.readLengthCodedString('utf8')
77-
: JSON.parse(packet.readLengthCodedString('utf8'));
77+
: packet.parseJson('utf8', supportBigNumbers);
7878
case Types.LONGLONG:
7979
if (!supportBigNumbers)
8080
return unsigned

lib/parsers/static_text_parser.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ function readField({
3232
if (field.extendedFormat === 'json') {
3333
return config.jsonStrings
3434
? packet.readLengthCodedString(encoding)
35-
: JSON.parse(packet.readLengthCodedString(encoding));
35+
: packet.parseJson(encoding, supportBigNumbers);
3636
}
3737

3838
switch (type) {
@@ -80,7 +80,7 @@ function readField({
8080
// see https://github.com/sidorares/node-mysql2/issues/409
8181
return config.jsonStrings
8282
? packet.readLengthCodedString('utf8')
83-
: JSON.parse(packet.readLengthCodedString('utf8'));
83+
: packet.parseJson('utf8', supportBigNumbers);
8484
default:
8585
if (charset === Charsets.BINARY) {
8686
return packet.readLengthCodedBuffer();

lib/parsers/text_parser.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ function readCodeFor(field, encodingExpr, config, options) {
2828
if (field.extendedFormat === 'json') {
2929
return config.jsonStrings
3030
? `packet.readLengthCodedString(${encodingExpr})`
31-
: `JSON.parse(packet.readLengthCodedString(${encodingExpr}))`;
31+
: `packet.parseJson(${encodingExpr}, ${supportBigNumbers})`;
3232
}
3333

3434
switch (type) {
@@ -77,7 +77,7 @@ function readCodeFor(field, encodingExpr, config, options) {
7777
// see https://github.com/sidorares/node-mysql2/issues/409
7878
return config.jsonStrings
7979
? 'packet.readLengthCodedString("utf8")'
80-
: 'JSON.parse(packet.readLengthCodedString("utf8"))';
80+
: `packet.parseJson("utf8", ${supportBigNumbers})`;
8181
default:
8282
if (charset === Charsets.BINARY) {
8383
return 'packet.readLengthCodedBuffer()';
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import { Buffer } from 'node:buffer';
2+
import { describe, it, strict } from 'poku';
3+
import Packet from '../../../lib/packets/packet.js';
4+
import getBinaryParser from '../../../lib/parsers/binary_parser.js';
5+
import getStaticBinaryParser from '../../../lib/parsers/static_binary_parser.js';
6+
import getStaticTextParser from '../../../lib/parsers/static_text_parser.js';
7+
import getTextParser from '../../../lib/parsers/text_parser.js';
8+
9+
// With supportBigNumbers, integers inside JSON documents that cannot be
10+
// accurately represented as JavaScript Numbers must arrive as exact
11+
// String objects (mirroring BIGINT column behaviour) instead of being
12+
// silently rounded by JSON.parse. Exactness requires JSON.parse source
13+
// access (Node.js 22+); older runtimes keep plain JSON.parse behaviour.
14+
const sourceAccessSupported = (() => {
15+
let supported = false;
16+
JSON.parse('0', (_key: unknown, value: unknown, context?: unknown) => {
17+
supported =
18+
context !== undefined &&
19+
typeof (context as { source?: unknown }).source === 'string';
20+
return value;
21+
});
22+
return supported;
23+
})();
24+
25+
// 9007199254740993 exceeds Number.MAX_SAFE_INTEGER; JSON.parse rounds it
26+
// to 9007199254740992. Safe integers, floats (including unsafe-magnitude
27+
// decimals and exponent notation, which stay lossy doubles), numeric
28+
// strings and nulls must pass through untouched.
29+
const raw = Buffer.from(
30+
'{"big": 9007199254740993, "neg": -9007199254740993, ' +
31+
'"safe": 42, "float": 1.5, "dec": 9007199254740993.5, ' +
32+
'"exp": 9e30, "str": "9007199254740993", "nil": null}',
33+
'utf8'
34+
);
35+
strict.ok(raw.length < 251);
36+
37+
const exactValue = {
38+
big: '9007199254740993',
39+
neg: '-9007199254740993',
40+
safe: 42,
41+
float: 1.5,
42+
dec: 9007199254740994, // 9007199254740993.5 rounds to this double in both modes
43+
exp: 9e30,
44+
str: '9007199254740993',
45+
nil: null,
46+
};
47+
48+
const roundedValue = {
49+
big: 9007199254740992,
50+
neg: -9007199254740992,
51+
safe: 42,
52+
float: 1.5,
53+
dec: 9007199254740994, // 9007199254740993.5 rounds to this double in both modes
54+
exp: 9e30,
55+
str: '9007199254740993',
56+
nil: null,
57+
};
58+
59+
const expected = sourceAccessSupported ? exactValue : roundedValue;
60+
61+
// JSON columns can hold bare scalars too; the reviver must handle the
62+
// root value (key '') like any other
63+
const rawRootScalar = Buffer.from('9007199254740993', 'utf8');
64+
const expectedRootScalar = sourceAccessSupported
65+
? '9007199254740993'
66+
: 9007199254740992;
67+
const rawRootNull = Buffer.from('null', 'utf8');
68+
69+
// MySQL JSON column (charset 63/BINARY, decoded as utf8)
70+
const mysqlJsonField = {
71+
name: 'j',
72+
columnType: 0xf5, // JSON
73+
characterSet: 63,
74+
encoding: 'binary',
75+
flags: 144,
76+
decimals: 0,
77+
columnLength: 4294967295,
78+
schema: 'test',
79+
table: 't',
80+
orgName: 'j',
81+
orgTable: 't',
82+
};
83+
84+
// MariaDB JSON column: LONG_BLOB identified via extended metadata
85+
const mariadbJsonField = {
86+
name: 'j',
87+
columnType: 0xfb, // LONG_BLOB
88+
characterSet: 224,
89+
encoding: 'utf8',
90+
flags: 144,
91+
decimals: 39,
92+
columnLength: 4294967295,
93+
schema: 'test',
94+
table: 't',
95+
orgName: 'j',
96+
orgTable: 't',
97+
extendedTypeName: undefined,
98+
extendedFormat: 'json',
99+
};
100+
101+
const textRowPacket = (payload = raw) => {
102+
const buf = Buffer.concat([
103+
Buffer.alloc(4),
104+
Buffer.from([payload.length]),
105+
payload,
106+
]);
107+
return new Packet(0, buf, 0, buf.length);
108+
};
109+
110+
const binaryRowPacket = (payload = raw) => {
111+
const buf = Buffer.concat([
112+
Buffer.alloc(4),
113+
Buffer.from([0]), // status byte
114+
Buffer.from([0]), // null bitmap
115+
Buffer.from([payload.length]),
116+
payload,
117+
]);
118+
return new Packet(0, buf, 0, buf.length);
119+
};
120+
121+
const options = {};
122+
123+
// the compiled row classes are untyped internals
124+
const jsonOf = (row: object): unknown => (row as { j: unknown }).j;
125+
126+
for (const [label, field] of [
127+
['MySQL JSON', mysqlJsonField],
128+
['MariaDB extended-format JSON', mariadbJsonField],
129+
] as const) {
130+
const fields = [field];
131+
132+
describe(`supportBigNumbers inside ${label} values`, () => {
133+
it('text parser returns unsafe integers exactly', () => {
134+
const Parser = getTextParser(fields, options, {
135+
supportBigNumbers: true,
136+
});
137+
const row = new Parser(fields).next(textRowPacket(), fields, options);
138+
strict.deepEqual(jsonOf(row), expected);
139+
});
140+
141+
it('binary parser returns unsafe integers exactly', () => {
142+
const Parser = getBinaryParser(fields, options, {
143+
supportBigNumbers: true,
144+
});
145+
const row = new Parser().next(binaryRowPacket(), fields, options);
146+
strict.deepEqual(jsonOf(row), expected);
147+
});
148+
149+
it('static text parser returns unsafe integers exactly', () => {
150+
const parser = getStaticTextParser(fields, options, {
151+
supportBigNumbers: true,
152+
});
153+
const row = parser.next(textRowPacket(), fields, options);
154+
strict.deepEqual(jsonOf(row), expected);
155+
});
156+
157+
it('static binary parser returns unsafe integers exactly', () => {
158+
const Parser = getStaticBinaryParser(fields, options, {
159+
supportBigNumbers: true,
160+
});
161+
const row = new Parser().next(binaryRowPacket(), fields, options);
162+
strict.deepEqual(jsonOf(row), expected);
163+
});
164+
165+
it('without supportBigNumbers keeps plain JSON.parse behaviour', () => {
166+
const Parser = getTextParser(fields, options, {});
167+
const row = new Parser(fields).next(textRowPacket(), fields, options);
168+
strict.deepEqual(jsonOf(row), roundedValue);
169+
});
170+
171+
it('jsonStrings still returns the raw (always exact) text', () => {
172+
const Parser = getTextParser(fields, options, {
173+
supportBigNumbers: true,
174+
jsonStrings: true,
175+
});
176+
const row = new Parser(fields).next(textRowPacket(), fields, options);
177+
strict.equal(jsonOf(row), raw.toString('utf8'));
178+
});
179+
180+
it('handles a bare unsafe integer as the root value', () => {
181+
const Parser = getTextParser(fields, options, {
182+
supportBigNumbers: true,
183+
});
184+
const row = new Parser(fields).next(
185+
textRowPacket(rawRootScalar),
186+
fields,
187+
options
188+
);
189+
strict.equal(jsonOf(row), expectedRootScalar);
190+
});
191+
192+
it('handles a JSON null root value', () => {
193+
const Parser = getTextParser(fields, options, {
194+
supportBigNumbers: true,
195+
});
196+
const row = new Parser(fields).next(
197+
textRowPacket(rawRootNull),
198+
fields,
199+
options
200+
);
201+
strict.equal(jsonOf(row), null);
202+
});
203+
});
204+
}

typings/mysql/lib/Connection.d.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,13 @@ export interface ConnectionOptions {
220220

221221
/**
222222
* When dealing with big numbers (BIGINT and DECIMAL columns) in the database, you should enable this option
223-
* (Default: false)
223+
* (Default: false).
224+
*
225+
* Also applies inside parsed JSON columns: integers that cannot be accurately represented with JavaScript
226+
* Number objects (beyond the [-2^53, +2^53] range) are returned as exact String objects instead of being
227+
* silently rounded by JSON.parse. Exactness inside JSON requires Node.js 22+ (JSON.parse source access);
228+
* older runtimes fall back to plain JSON.parse behaviour. Has no effect when jsonStrings is enabled, where
229+
* the raw JSON text (always exact) is returned instead.
224230
*/
225231
supportBigNumbers?: boolean;
226232

typings/mysql/lib/protocol/sequences/Query.d.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,13 @@ export interface QueryOptions {
122122

123123
/**
124124
* When dealing with big numbers (BIGINT and DECIMAL columns) in the database, you should enable this option
125-
* (Default: false)
125+
* (Default: false).
126+
*
127+
* Also applies inside parsed JSON columns: integers that cannot be accurately represented with JavaScript
128+
* Number objects (beyond the [-2^53, +2^53] range) are returned as exact String objects instead of being
129+
* silently rounded by JSON.parse. Exactness inside JSON requires Node.js 22+ (JSON.parse source access);
130+
* older runtimes fall back to plain JSON.parse behaviour. Has no effect when jsonStrings is enabled, where
131+
* the raw JSON text (always exact) is returned instead.
126132
*/
127133
supportBigNumbers?: boolean;
128134

0 commit comments

Comments
 (0)