Skip to content

feat: typed parameters, and adopt integer types the server reports - #4488

Merged
sidorares merged 5 commits into
masterfrom
feat/parameter-types
Aug 23, 2026
Merged

feat: typed parameters, and adopt integer types the server reports#4488
sidorares merged 5 commits into
masterfrom
feat/parameter-types

Conversation

@sidorares

@sidorares sidorares commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Adds TypedParameter for stating a bind parameter's MySQL type explicitly, and makes the driver adopt the integer types COM_STMT_PREPARE already reports.

The first commit is the red baseline: tests reproducing the current failures, passing on MariaDB and failing on MySQL 8.3 and 9.7. The second makes them pass.

Background

Bind parameter types are inferred from the JavaScript value in toParameter. That mapping arrived in #353 and #705, replacing an earlier design that sent everything as a string and let the server coerce.

MySQL 8.0.22 then changed the server: a statement is prepared once at PREPARE rather than re-prepared per execution, so the parameter types of the first execution stick. This is #1239, open since October 2020 and the most-reported issue in this area. #1407 was an attempt to fix it by following the prepare response for every parameter; it stalled because the hint is not always meaningful, and the follow-up plan discussed there and in #1483 was a hybrid — explicit containers plus a safer default.

Prior discussion: #1239, #1407, #353, #705, #548, #446, #1483, #1173, #1760, #348, and the closed duplicates #1789, #1623, #2793, #2302, #1521.

What is wrong today

LIMIT ? with a number is rejected. Every JS number is sent as DOUBLE, and MySQL requires an integer here.

await conn.execute('SELECT * FROM t LIMIT ?', [10]);
// Error: Incorrect arguments to mysqld_stmt_execute

This is the single most reported symptom. Measured across every context I could find, LIMIT/OFFSET is the only place a DOUBLE is refused outright — everywhere else the server coerces, which is why the remaining problems are silent.

A cached statement is fixed by its first execution. Bind a number once and the position stays DOUBLE, so an exact BIGINT bound later is read back through it:

await conn.execute('INSERT INTO t (big) VALUES (?)', [1]);
await conn.execute('INSERT INTO t (big) VALUES (?)', ['9007199254740993']);
// stored: 9007199254740992   — no error, no warning

Comparing a number to an indexed string column loses the index, permanently. The server compares the column numerically, and the cached statement keeps that shape. On a 100k-row table:

one prepared statement, bound with: str, num, str, bigint, str
before   3887 ops/s    100    100    103    107     <- never recovers

Deterministically, via Handler_read_next on a 2000-row table: 1 row read per lookup before the number, 2000 after — for every later execution, including the correctly-typed ones. MariaDB re-optimises each execution so it recovers; MySQL does not.

The unsigned flag is never sentit is hardcoded to 0, so the top half of BIGINT UNSIGNED is unreachable — and binary versus text cannot be expressed, which is #1760 and the MariaDB VECTOR case.

TypedParameter

const { TypedParameter: T } = require('mysql2');

conn.execute('SELECT * FROM t LIMIT ?', [T.BIGINT(10)]);
conn.execute('INSERT INTO t (big) VALUES (?)', [T.BIGINT('9007199254740993')]);
conn.execute('INSERT INTO t (big) VALUES (?)', [T.BIGINT.unsigned('18446744073709551615')]);
conn.execute('SELECT id FROM t WHERE code = ?', [T.VARCHAR(userInput)]);
conn.execute('INSERT INTO t (raw) VALUES (?)', [T.BLOB(buffer)]);

Named after both the protocol types and their SQL spellings (BIGINT/LONGLONG, INT/LONG, MEDIUMINT/INT24). Integer factories expose .unsigned. Values that do not fit are rejected at the call site:

T.TINYINT(300);    // RangeError: TINY parameter out of range: 300 is not within -128..127
T.BIGINT(9007199254740993);
// RangeError: ... exceeds Number.MAX_SAFE_INTEGER and has already lost precision;
// pass a string or BigInt instead

T.BIGINT(null) sends SQL NULL while keeping the declared type, so a position does not change type between null and non-null executions.

Neither server accepts every type as a bind type — MySQL refuses INT24 YEAR ENUM SET BIT GEOMETRY, MariaDB refuses JSON VECTOR — so declared types travel as the nearest type both accept (MEDIUMINTLONG, YEARSHORT, ENUM/SETSTRING, VECTORBLOB, JSONVAR_STRING on MariaDB). BIT and GEOMETRY get no factory, since neither has an unambiguous encoding; use T.BIGINT(mask) and T.BLOB(wkb).

Integer types adopted from the prepare response

For untyped values the driver now consults the reported parameter type, but adopts it only when both hold:

  1. the reported type is an integer type valid as a bind type on every server (TINY, SHORT, LONG, LONGLONG), and
  2. the value is already an integer — number that is a safe integer, bigint, or boolean — that fits it.

Anything else keeps today's inference. That is deliberately narrow, and each restriction is load-bearing:

  • A non-integer hint is never adopted. A parameter definition carries no provenance — SELECT ? and WHERE varchar_col = ? produce byte-identical hints (VAR_STRING, empty schema/table/orgTable, name ?). Adopting VAR_STRING would fix the index case but would also turn SELECT ? bound with 42 into the string "42", and the two are indistinguishable. That is the wall Change the way types for prepared statement parameters are calculated #1407 hit.
  • A non-integer value is never upgraded. Following the hint for 1.5 or '7abc' would turn a working call into a client-side error.
  • Nothing is adopted on servers that resolve no types, and no vendor or version check is needed to achieve it. MariaDB answers MYSQL_TYPE_NULL for every parameter (verified: 35/35 across 27 statements) and MySQL 5.7 answers VAR_STRING with a zero length for every parameter. Both fail gate 1 structurally, as would any other server or proxy that reports nothing. Neither needs the adoption anyway: both pre-date the 8.0.22 change and still re-prepare when a parameter type changes.

This fixes LIMIT ?/OFFSET ? with a number, and stops a cached statement being fixed as DOUBLE by an integer first execution — so the BIGINT rounding above no longer happens. It does not fix the index case, which still needs an explicit T.VARCHAR(...); that is a limit of what a provenance-free hint can safely support.

Compatibility

Only MySQL 8.0 and later resolve parameter types at all:

Server Reports parameter types
MySQL 8.0 and up Yes, including the unsigned flag
MySQL 5.7 No — VAR_STRING, length 0, charset 63 for every ?
MariaDB No — MYSQL_TYPE_NULL for every ?

What goes on the wire for each column type and value. LONGLONG marks where the hint is adopted; every other cell is unchanged from today.

MySQL 9.7.2 (8.3.0 identical apart from DATETIME charset)

column type PREPARE hint plain number plain string Date Buffer
TINYINT LONGLONG LONGLONG VAR_STRING DATETIME BLOB
SMALLINT LONGLONG LONGLONG VAR_STRING DATETIME BLOB
MEDIUMINT LONGLONG LONGLONG VAR_STRING DATETIME BLOB
INT LONGLONG LONGLONG VAR_STRING DATETIME BLOB
BIGINT LONGLONG LONGLONG VAR_STRING DATETIME BLOB
BIGINT UNSIGNED LONGLONG uns LONGLONG uns VAR_STRING DATETIME BLOB
YEAR YEAR uns DOUBLE VAR_STRING DATETIME BLOB
DECIMAL(20,2) NEWDECIMAL DOUBLE VAR_STRING DATETIME BLOB
FLOAT DOUBLE DOUBLE VAR_STRING DATETIME BLOB
DOUBLE DOUBLE DOUBLE VAR_STRING DATETIME BLOB
VARCHAR(32) VAR_STRING DOUBLE VAR_STRING DATETIME BLOB
CHAR(8) VAR_STRING DOUBLE VAR_STRING DATETIME BLOB
TEXT LONG_BLOB DOUBLE VAR_STRING DATETIME BLOB
VARBINARY(16) VAR_STRING bin DOUBLE VAR_STRING DATETIME BLOB
BLOB LONG_BLOB bin DOUBLE VAR_STRING DATETIME BLOB
DATE DATE DOUBLE VAR_STRING DATETIME BLOB
DATETIME(6) DATETIME DOUBLE VAR_STRING DATETIME BLOB
TIME(6) TIME DOUBLE VAR_STRING DATETIME BLOB
TIMESTAMP(6) DATETIME DOUBLE VAR_STRING DATETIME BLOB
JSON JSON DOUBLE VAR_STRING DATETIME BLOB
ENUM VAR_STRING DOUBLE VAR_STRING DATETIME BLOB
SET VAR_STRING DOUBLE VAR_STRING DATETIME BLOB
BIT(8) BIT uns DOUBLE VAR_STRING DATETIME BLOB
expression PREPARE hint plain number plain string
LIMIT ? LONGLONG uns LONGLONG uns VAR_STRING
OFFSET ? LONGLONG uns LONGLONG uns VAR_STRING
SELECT ? VAR_STRING DOUBLE VAR_STRING
SELECT ? + ? DOUBLE DOUBLE VAR_STRING
WHERE i IN (?, ?) LONGLONG LONGLONG VAR_STRING
DATE_ADD(?, ...) DATE DOUBLE VAR_STRING

Note YEAR and BIT: the server reports them but refuses them as bind types, so they are excluded from the adoptable set. Including YEAR initially caused INSERT INTO t (year_col) VALUES (?) to start failing with ER_WRONG_ARGUMENTS; the test suite caught it.

MariaDB 12.3.2 and MySQL 5.7.44 — no hint resolves, so every cell is inference, identical to today:

server column type PREPARE hint plain number plain string Date Buffer
MariaDB 12.3 (all 23, and every expression incl. LIMIT ?) NULL DOUBLE VAR_STRING DATETIME BLOB
MySQL 5.7 (all 23, and every expression incl. LIMIT ?) VAR_STRING len 0 DOUBLE VAR_STRING DATETIME BLOB

Tests

  • test/unit/packets/test-typed-parameter.test.mts — 71 assertions: wire encoding for every supported type, two's complement, the full unsigned 64-bit range, range and precision rejection, the wire-type mapping, typed null, and every gate of the hint policy including the placeholder types 5.7 and MariaDB report. 99% statement coverage of lib/packets/typed_parameter.js.
  • test/integration/connection/test-execute-integer-parameters.test.mts — the red baseline from the first commit.
  • test/integration/connection/test-typed-parameter.test.mts — round trips for exact/unsigned/negative 64-bit, binary, typed null, forced string.
  • test/integration/connection/test-typed-parameter-plan.test.mts — index retention via Handler_read_next, no timing.
  • test/integration/connection/test-prepare-hint-fidelity.test.mts — what each server reports, the missing provenance, and the cases the driver deliberately ignores.

Full suite: 231/231 on MySQL 5.7.44, 8.3.0 and MariaDB 12.3.2. On MySQL 9.7, 230/231 with one pre-existing failure (test-execute-nocolumndef, which asserts 8.x EXPLAIN metadata and fails on master too).

Notes for review

Every JS number is bound as MYSQL_TYPE_DOUBLE. MySQL rejects a DOUBLE where
the statement needs an integer, so `LIMIT ?` and `LIMIT ? OFFSET ?` fail
with ER_WRONG_ARGUMENTS for the most natural call a user can write.

Since MySQL 8.0.22 a prepared statement is no longer re-prepared when a
later execution changes a parameter type, so the first type a cached
statement sees decides how every later execution is read. Binding a number
once fixes the position as DOUBLE, and an exact BIGINT string bound
afterwards is silently rounded through it.

These tests fail on MySQL 8.3 and 9.7 and pass on MariaDB, which accepts a
DOUBLE for LIMIT and re-optimises each execution.

Refs #1239, #1407
Two changes to how a bind parameter picks its MySQL type.

TypedParameter carries the type alongside the value and is accepted
anywhere execute() takes one:

  conn.execute('SELECT * FROM t LIMIT ?', [mysql.TypedParameter.BIGINT(10)])

It expresses what a JavaScript value cannot: integer width, signedness, and
binary versus text. The declared type is written into COM_STMT_EXECUTE, so
the unsigned flag is now sent rather than hardcoded to zero, and a typed
null keeps its declared type instead of collapsing to MYSQL_TYPE_NULL.
Values that do not fit are rejected at the call site, including numbers that
already lost precision. Types no server accepts as a bind type (MEDIUMINT,
YEAR, ENUM, SET, and JSON on MariaDB) travel as the nearest accepted type.

For untyped values the driver now adopts the type COM_STMT_PREPARE reports,
but only when that type is an integer type and the value is already an
integer that fits it. That is enough to make 'LIMIT ?' accept a number, and
to stop a cached statement being fixed as DOUBLE by its first execution.
Every other combination keeps the type inferred from JavaScript, so a bare
'SELECT ?' still round-trips a number as a number and server-side string
coercions are left alone. MariaDB reports MYSQL_TYPE_NULL for every
parameter, which fails the same gate, so nothing changes there.

Closes #1239

Refs #1407, #548, #446, #1483, #1173
@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.92704% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.29%. Comparing base (5bf7bda) to head (e11cb4b).

Files with missing lines Patch % Lines
lib/packets/typed_parameter.js 98.68% 5 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #4488      +/-   ##
==========================================
+ Coverage   92.09%   92.29%   +0.19%     
==========================================
  Files          92       93       +1     
  Lines       15214    15671     +457     
  Branches     2086     2190     +104     
==========================================
+ Hits        14012    14464     +452     
- Misses       1202     1207       +5     
Flag Coverage Δ
compression-0 91.87% <98.92%> (+0.21%) ⬆️
compression-1 92.27% <98.92%> (+0.19%) ⬆️
static-parser-0 91.09% <98.92%> (+0.23%) ⬆️
static-parser-1 91.35% <98.92%> (+0.22%) ⬆️
tls-0 91.85% <98.92%> (+0.21%) ⬆️
tls-1 92.29% <98.92%> (+0.19%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…oders

MySQL 5.7 answers VAR_STRING with a zero length for every parameter, the
same non-answer MariaDB gives as MYSQL_TYPE_NULL. Neither reaches the
integer gate, so nothing is adopted on either and no version check is
needed, but the test asserting what the server reports assumed every MySQL
resolves types. It now detects that capability instead of naming versions.

Also formats the new docs page with the website's own prettier config, adds
the 5.7 and MariaDB rows to it, and covers the remaining parameter encoders
(FLOAT, DATE, TIMESTAMP, TIME in all four forms, length coded text, and the
unsupported-type path), taking lib/packets/typed_parameter.js from 92% to
99% statement coverage.
@sidorares

Copy link
Copy Markdown
Owner Author

Pushed 7e4fc7e to fix the red CI.

build — the new docs page was formatted with the root prettier config, not the website's own. Reformatted with website/'s config.

MySQL 5.7 — the interesting one. 5.7 resolves no parameter types at all: it answers VAR_STRING with length 0 and charset 63 for every ?, the same non-answer MariaDB gives as MYSQL_TYPE_NULL. I had only tested 8.3, 9.7 and MariaDB locally and generalised "MySQL reports real types" into an assertion.

No production code changed for this. The placeholder fails gate 1 structurally, so nothing is adopted on 5.7 exactly as intended, and every behavioural test already passed there — only the test that documents what the server reports was wrong. It now detects that capability by preparing a known-integer statement rather than naming versions, which also covers proxies and forks.

5.7 does not need the adoption anyway: it pre-dates the 8.0.22 change and still re-prepares when a parameter type changes, so LIMIT ? with a number works there already.

Also addressed the codecov report: added coverage for the remaining encoders (FLOAT, DATE, TIMESTAMP, TIME in all four input forms, length-coded text, and the unsupported-type path). lib/packets/typed_parameter.js goes from 92% to 99% statement coverage, 71 unit assertions.

Full suite now 231/231 on MySQL 5.7.44, 8.3.0 and MariaDB 12.3.2; 230/231 on 9.7 with the pre-existing test-execute-nocolumndef failure that also fails on master.

🤖 Addressed by Claude Code

@sidorares
sidorares requested a review from wellwelwel August 19, 2026 05:07
@sidorares

Copy link
Copy Markdown
Owner Author

The implementation is mostly CC, but I reviewed it and it looks good. Would be good to have another review @wellwelwel when you have a chance, both in terms of surface API and internals. Surface API is probably even more important, once its in there is no way back

@sidorares
sidorares merged commit 8ec20f1 into master Aug 23, 2026
105 checks passed
@sidorares
sidorares deleted the feat/parameter-types branch August 23, 2026 06:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant