Skip to content
Open
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
40 changes: 35 additions & 5 deletions lib/parsers/binary_parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ function compile(fields, options, config) {
const parserFn = genFunc();
const nullBitmapLength = Math.floor((fields.length + 7 + 2) / 8);

function wrap(field, packet) {
function fieldMetadata(field) {
return {
type: typeNames[field.columnType],
extendedTypeName: field.extendedTypeName,
Expand All @@ -105,6 +105,12 @@ function compile(fields, options, config) {
db: field.schema,
table: field.table,
name: field.name,
};
}

function wrap(field, packet) {
return {
...fieldMetadata(field),
string: function (encoding = field.encoding) {
if (field.columnType === Types.JSON && encoding === field.encoding) {
// Since for JSON columns mysql always returns charset 63 (BINARY),
Expand Down Expand Up @@ -148,6 +154,22 @@ function compile(fields, options, config) {
};
}

/** A NULL value carries no bytes in the binary row packet, so the accessors return null without reading from it. */
function wrapNull(field) {
return {
...fieldMetadata(field),
string: function () {
return null;
},
buffer: function () {
return null;
},
geometry: function () {
return null;
},
};
}

parserFn('(function(){');
parserFn('return class BinaryRow {');
parserFn('constructor() {');
Expand Down Expand Up @@ -196,9 +218,17 @@ function compile(fields, options, config) {
lvalue = `result[${fieldName}]`;
}

parserFn(`if (nullBitmaskByte${nullByteIndex} & ${currentFieldNullBit}) `);
parserFn(`${lvalue} = null;`);
parserFn('else {');
parserFn(`if (nullBitmaskByte${nullByteIndex} & ${currentFieldNullBit}) {`);
if (typeof options.typeCast === 'function') {
const nullWrapperVar = `nullWrapper${i}`;
parserFn(`const ${nullWrapperVar} = wrapNull(fields[${i}]);`);
parserFn(
`${lvalue} = options.typeCast(${nullWrapperVar}, function() { return null; });`
);
} else {
parserFn(`${lvalue} = null;`);
}
parserFn('} else {');

if (options.typeCast === false) {
parserFn(`${lvalue} = packet.readLengthCodedBuffer();`);
Expand Down Expand Up @@ -234,7 +264,7 @@ function compile(fields, options, config) {
parserFn.toString()
);
}
return parserFn.toFunction({ wrap });
return parserFn.toFunction({ wrap, wrapNull });
}

function getBinaryParser(fields, options, config) {
Expand Down
27 changes: 26 additions & 1 deletion lib/parsers/static_binary_parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,28 @@ function getBinaryParser(fields, _options, config) {
}
}

/** A NULL value carries no bytes in the binary row packet, so the accessors return null without reading from it. */
function wrapNull(field) {
return {
type: typeNames[field.columnType],
extendedTypeName: field.extendedTypeName,
extendedFormat: field.extendedFormat,
length: field.columnLength,
db: field.schema,
table: field.table,
name: field.name,
string: function () {
return null;
},
buffer: function () {
return null;
},
geometry: function () {
return null;
},
};
}

return class BinaryRow {
constructor() {}

Expand All @@ -118,7 +140,10 @@ function getBinaryParser(fields, _options, config) {

let value;
if (nullBitmaskBytes[nullByteIndex] & currentFieldNullBit) {
value = null;
value =
typeof typeCast === 'function'
? typeCast(wrapNull(field), () => null)
: null;
} else if (options.typeCast === false) {
value = packet.readLengthCodedBuffer();
} else {
Expand Down
60 changes: 60 additions & 0 deletions test/integration/connection/test-typecast-null-execute.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { RowDataPacket } from '../../../index.js';
import { describe, it, strict } from 'poku';
import { createConnection } from '../../common.test.mjs';

// Regression for #3368: in the binary protocol (execute), NULL columns were
// short-circuited to `null` before the user typeCast ran, so typeCast was
// never called for them -- unlike the text protocol (query). These tests
// assert typeCast now sees NULL columns and can override them, without
// desyncing the packet reader for the surrounding columns.
await describe('Typecast NULL Execute (#3368)', async () => {
const connection = createConnection();

await it('calls typeCast for a NULL column and lets it override the value', async () => {
const seen: Array<string | null> = [];

const res = await new Promise<RowDataPacket[]>((resolve, reject) => {
connection.execute<RowDataPacket[]>(
{
sql: 'SELECT NULL AS foo',
typeCast(field, next) {
const value = field.string();
seen.push(value);
if (value === null) {
return '<was-null>';
}
return next();
},
},
(err, rows) => (err ? reject(err) : resolve(rows))
);
});

strict.deepEqual(seen, [null], 'typeCast ran for the NULL column');
strict.equal(res[0].foo, '<was-null>', 'and its return value was used');
});

await it('a passthrough typeCast still yields null and preserves column alignment', async () => {
const names: string[] = [];

const res = await new Promise<RowDataPacket[]>((resolve, reject) => {
connection.execute<RowDataPacket[]>(
{
sql: "SELECT 1 AS a, NULL AS b, 'z' AS c",
typeCast(field, next) {
names.push(field.name);
return next();
},
},
(err, rows) => (err ? reject(err) : resolve(rows))
);
});

strict.deepEqual(names, ['a', 'b', 'c'], 'every column, NULL included');
strict.equal(res[0].a, 1);
strict.equal(res[0].b, null, 'passthrough of NULL stays null');
strict.equal(res[0].c, 'z', 'column after the NULL is still aligned');
});

connection.end();
});