Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions lib/packets/packet.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,33 @@ function leftPad(num, value) {
const minus = '-'.charCodeAt(0);
const plus = '+'.charCodeAt(0);

// JSON.parse source access: proposal-json-parse-with-source,
// Node.js 22+ / V8 12.2
const jsonSourceAccessSupported = (() => {
let supported = false;
JSON.parse('0', (key, value, context) => {
supported = context !== undefined && typeof context.source === 'string';
return value;
});
return supported;
})();

// 16 digits is the shortest numeral that can exceed Number.MAX_SAFE_INTEGER
const jsonBigNumeral = /\d{16}/;
const jsonIntegerSource = /^-?\d+$/;

function jsonBigNumberReviver(key, value, context) {
if (
typeof value === 'number' &&
!Number.isSafeInteger(value) &&
context !== undefined &&
jsonIntegerSource.test(context.source)
) {
return context.source;
}
return value;
}

// TODO: handle E notation
const dot = '.'.charCodeAt(0);
const exponent = 'e'.charCodeAt(0);
Expand Down Expand Up @@ -654,6 +681,21 @@ class Packet {
return result;
}

// With supportBigNumbers, unsafe integers become exact strings,
// mirroring the option's behaviour for BIGINT columns
parseJson(encoding, supportBigNumbers) {
const str = this.readLengthCodedString(encoding);
if (
supportBigNumbers &&
jsonSourceAccessSupported &&
str !== null &&
jsonBigNumeral.test(str)
) {
return JSON.parse(str, jsonBigNumberReviver);
}
return JSON.parse(str);
}

parseDate(timezone) {
const strLen = this.readLengthCodedNumber();
if (strLen === null) {
Expand Down
4 changes: 2 additions & 2 deletions lib/parsers/binary_parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ function readCodeFor(field, config, options, fieldNum) {
if (field.extendedFormat === 'json') {
return config.jsonStrings
? `packet.readLengthCodedString(fields[${fieldNum}].encoding)`
: `JSON.parse(packet.readLengthCodedString(fields[${fieldNum}].encoding));`;
: `packet.parseJson(fields[${fieldNum}].encoding, ${supportBigNumbers});`;
}
switch (field.columnType) {
case Types.TINY:
Expand Down Expand Up @@ -70,7 +70,7 @@ function readCodeFor(field, config, options, fieldNum) {
// see https://github.com/sidorares/node-mysql2/issues/409
return config.jsonStrings
? 'packet.readLengthCodedString("utf8")'
: 'JSON.parse(packet.readLengthCodedString("utf8"));';
: `packet.parseJson("utf8", ${supportBigNumbers});`;
case Types.LONGLONG:
if (!supportBigNumbers) {
return unsigned
Expand Down
4 changes: 2 additions & 2 deletions lib/parsers/static_binary_parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ function getBinaryParser(fields, _options, config) {
if (field.extendedFormat === 'json') {
return config.jsonStrings
? packet.readLengthCodedString(field.encoding)
: JSON.parse(packet.readLengthCodedString(field.encoding));
: packet.parseJson(field.encoding, supportBigNumbers);
}

switch (field.columnType) {
Expand Down Expand Up @@ -74,7 +74,7 @@ function getBinaryParser(fields, _options, config) {
// see https://github.com/sidorares/node-mysql2/issues/409
return config.jsonStrings
? packet.readLengthCodedString('utf8')
: JSON.parse(packet.readLengthCodedString('utf8'));
: packet.parseJson('utf8', supportBigNumbers);
case Types.LONGLONG:
if (!supportBigNumbers)
return unsigned
Expand Down
4 changes: 2 additions & 2 deletions lib/parsers/static_text_parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ function readField({
if (field.extendedFormat === 'json') {
return config.jsonStrings
? packet.readLengthCodedString(encoding)
: JSON.parse(packet.readLengthCodedString(encoding));
: packet.parseJson(encoding, supportBigNumbers);
}

switch (type) {
Expand Down Expand Up @@ -80,7 +80,7 @@ function readField({
// see https://github.com/sidorares/node-mysql2/issues/409
return config.jsonStrings
? packet.readLengthCodedString('utf8')
: JSON.parse(packet.readLengthCodedString('utf8'));
: packet.parseJson('utf8', supportBigNumbers);
default:
if (charset === Charsets.BINARY) {
return packet.readLengthCodedBuffer();
Expand Down
4 changes: 2 additions & 2 deletions lib/parsers/text_parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ function readCodeFor(field, encodingExpr, config, options) {
if (field.extendedFormat === 'json') {
return config.jsonStrings
? `packet.readLengthCodedString(${encodingExpr})`
: `JSON.parse(packet.readLengthCodedString(${encodingExpr}))`;
: `packet.parseJson(${encodingExpr}, ${supportBigNumbers})`;
}

switch (type) {
Expand Down Expand Up @@ -77,7 +77,7 @@ function readCodeFor(field, encodingExpr, config, options) {
// see https://github.com/sidorares/node-mysql2/issues/409
return config.jsonStrings
? 'packet.readLengthCodedString("utf8")'
: 'JSON.parse(packet.readLengthCodedString("utf8"))';
: `packet.parseJson("utf8", ${supportBigNumbers})`;
default:
if (charset === Charsets.BINARY) {
return 'packet.readLengthCodedBuffer()';
Expand Down
204 changes: 204 additions & 0 deletions test/unit/parsers/support-big-numbers-json.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { Buffer } from 'node:buffer';
import { describe, it, strict } from 'poku';
import Packet from '../../../lib/packets/packet.js';
import getBinaryParser from '../../../lib/parsers/binary_parser.js';
import getStaticBinaryParser from '../../../lib/parsers/static_binary_parser.js';
import getStaticTextParser from '../../../lib/parsers/static_text_parser.js';
import getTextParser from '../../../lib/parsers/text_parser.js';

// With supportBigNumbers, integers inside JSON documents that cannot be
// accurately represented as JavaScript Numbers must arrive as exact
// String objects (mirroring BIGINT column behaviour) instead of being
// silently rounded by JSON.parse. Exactness requires JSON.parse source
// access (Node.js 22+); older runtimes keep plain JSON.parse behaviour.
const sourceAccessSupported = (() => {
let supported = false;
JSON.parse('0', (_key: unknown, value: unknown, context?: unknown) => {
supported =
context !== undefined &&
typeof (context as { source?: unknown }).source === 'string';
return value;
});
return supported;
})();

// 9007199254740993 exceeds Number.MAX_SAFE_INTEGER; JSON.parse rounds it
// to 9007199254740992. Safe integers, floats (including unsafe-magnitude
// decimals and exponent notation, which stay lossy doubles), numeric
// strings and nulls must pass through untouched.
const raw = Buffer.from(
'{"big": 9007199254740993, "neg": -9007199254740993, ' +
'"safe": 42, "float": 1.5, "dec": 9007199254740993.5, ' +
'"exp": 9e30, "str": "9007199254740993", "nil": null}',
'utf8'
);
strict.ok(raw.length < 251);

const exactValue = {
big: '9007199254740993',
neg: '-9007199254740993',
safe: 42,
float: 1.5,
dec: 9007199254740994, // 9007199254740993.5 rounds to this double in both modes
exp: 9e30,
str: '9007199254740993',
nil: null,
};

const roundedValue = {
big: 9007199254740992,
neg: -9007199254740992,
safe: 42,
float: 1.5,
dec: 9007199254740994, // 9007199254740993.5 rounds to this double in both modes
exp: 9e30,
str: '9007199254740993',
nil: null,
};

const expected = sourceAccessSupported ? exactValue : roundedValue;

// JSON columns can hold bare scalars too; the reviver must handle the
// root value (key '') like any other
const rawRootScalar = Buffer.from('9007199254740993', 'utf8');
const expectedRootScalar = sourceAccessSupported
? '9007199254740993'
: 9007199254740992;
const rawRootNull = Buffer.from('null', 'utf8');

// MySQL JSON column (charset 63/BINARY, decoded as utf8)
const mysqlJsonField = {
name: 'j',
columnType: 0xf5, // JSON
characterSet: 63,
encoding: 'binary',
flags: 144,
decimals: 0,
columnLength: 4294967295,
schema: 'test',
table: 't',
orgName: 'j',
orgTable: 't',
};

// MariaDB JSON column: LONG_BLOB identified via extended metadata
const mariadbJsonField = {
name: 'j',
columnType: 0xfb, // LONG_BLOB
characterSet: 224,
encoding: 'utf8',
flags: 144,
decimals: 39,
columnLength: 4294967295,
schema: 'test',
table: 't',
orgName: 'j',
orgTable: 't',
extendedTypeName: undefined,
extendedFormat: 'json',
};

const textRowPacket = (payload = raw) => {
const buf = Buffer.concat([
Buffer.alloc(4),
Buffer.from([payload.length]),
payload,
]);
return new Packet(0, buf, 0, buf.length);
};

const binaryRowPacket = (payload = raw) => {
const buf = Buffer.concat([
Buffer.alloc(4),
Buffer.from([0]), // status byte
Buffer.from([0]), // null bitmap
Buffer.from([payload.length]),
payload,
]);
return new Packet(0, buf, 0, buf.length);
};

const options = {};

// the compiled row classes are untyped internals
const jsonOf = (row: object): unknown => (row as { j: unknown }).j;

for (const [label, field] of [
['MySQL JSON', mysqlJsonField],
['MariaDB extended-format JSON', mariadbJsonField],
] as const) {
const fields = [field];

describe(`supportBigNumbers inside ${label} values`, () => {
it('text parser returns unsafe integers exactly', () => {
const Parser = getTextParser(fields, options, {
supportBigNumbers: true,
});
const row = new Parser(fields).next(textRowPacket(), fields, options);
strict.deepEqual(jsonOf(row), expected);
});

it('binary parser returns unsafe integers exactly', () => {
const Parser = getBinaryParser(fields, options, {
supportBigNumbers: true,
});
const row = new Parser().next(binaryRowPacket(), fields, options);
strict.deepEqual(jsonOf(row), expected);
});

it('static text parser returns unsafe integers exactly', () => {
const parser = getStaticTextParser(fields, options, {
supportBigNumbers: true,
});
const row = parser.next(textRowPacket(), fields, options);
strict.deepEqual(jsonOf(row), expected);
});

it('static binary parser returns unsafe integers exactly', () => {
const Parser = getStaticBinaryParser(fields, options, {
supportBigNumbers: true,
});
const row = new Parser().next(binaryRowPacket(), fields, options);
strict.deepEqual(jsonOf(row), expected);
});

it('without supportBigNumbers keeps plain JSON.parse behaviour', () => {
const Parser = getTextParser(fields, options, {});
const row = new Parser(fields).next(textRowPacket(), fields, options);
strict.deepEqual(jsonOf(row), roundedValue);
});

it('jsonStrings still returns the raw (always exact) text', () => {
const Parser = getTextParser(fields, options, {
supportBigNumbers: true,
jsonStrings: true,
});
const row = new Parser(fields).next(textRowPacket(), fields, options);
strict.equal(jsonOf(row), raw.toString('utf8'));
});

it('handles a bare unsafe integer as the root value', () => {
const Parser = getTextParser(fields, options, {
supportBigNumbers: true,
});
const row = new Parser(fields).next(
textRowPacket(rawRootScalar),
fields,
options
);
strict.equal(jsonOf(row), expectedRootScalar);
});

it('handles a JSON null root value', () => {
const Parser = getTextParser(fields, options, {
supportBigNumbers: true,
});
const row = new Parser(fields).next(
textRowPacket(rawRootNull),
fields,
options
);
strict.equal(jsonOf(row), null);
});
});
}
8 changes: 7 additions & 1 deletion typings/mysql/lib/Connection.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,13 @@ export interface ConnectionOptions {

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

Expand Down
8 changes: 7 additions & 1 deletion typings/mysql/lib/protocol/sequences/Query.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,13 @@ export interface QueryOptions {

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

Expand Down
Loading