diff --git a/docs/runtime/sql.mdx b/docs/runtime/sql.mdx index 230d4af311d..b6e68c8dd5a 100644 --- a/docs/runtime/sql.mdx +++ b/docs/runtime/sql.mdx @@ -1323,6 +1323,28 @@ const now = await mysql`SELECT NOW() as current_time`; const uuid = await mysql`SELECT UUID() as id`; ``` +By default, `affectedRows` counts rows the `WHERE` clause **matched** — the +same default as the `mysql2` and `mariadb` drivers. An `UPDATE` that matches +a row but leaves every column untouched still reports `affectedRows: 1`. If +you want the server's changed-rows semantics instead (where a no-op update +returns `0`), pass `foundRows: false`: + +```ts +const mysql = new SQL({ + adapter: "mysql", // or "mariadb" + hostname: "localhost", + database: "myapp", + foundRows: false, // changed-rows semantics (returns 0 for no-op UPDATEs) +}); +// Equivalently as a URL query param: +new SQL("mysql://user:pass@localhost/db?foundRows=false"); +``` + +This option toggles the +[`CLIENT_FOUND_ROWS`](https://dev.mysql.com/doc/c-api/en/mysql-affected-rows.html) +capability flag during the protocol handshake and is ignored by the Postgres +and SQLite adapters. + ### MySQL Error Handling ```ts diff --git a/packages/bun-types/sql.d.ts b/packages/bun-types/sql.d.ts index ebf032f4b80..c400e7f2e23 100644 --- a/packages/bun-types/sql.d.ts +++ b/packages/bun-types/sql.d.ts @@ -389,6 +389,23 @@ declare module "bun" { * @default false */ allowPublicKeyRetrieval?: boolean | undefined; + + /** + * MySQL / MariaDB. When enabled, the server reports the number of rows + * matched by the `WHERE` clause in `affectedRows`, rather than the + * number of rows actually changed. This is the default behavior of the + * `mysql2` and `mariadb` drivers and matches what most Node.js MySQL + * code expects. Applies to both `adapter: "mysql"` and + * `adapter: "mariadb"`; no effect on `adapter: "postgres"`. + * + * Set to `false` to use the server's changed-rows semantics: an + * `UPDATE` that matches a row but does not change any column value + * will return `affectedRows: 0`. + * + * @see [CLIENT_FOUND_ROWS](https://dev.mysql.com/doc/c-api/en/mysql-affected-rows.html) + * @default true + */ + foundRows?: boolean | undefined; } /** diff --git a/src/codegen/bake-codegen.ts b/src/codegen/bake-codegen.ts index b60a3dba613..3029e4282ce 100644 --- a/src/codegen/bake-codegen.ts +++ b/src/codegen/bake-codegen.ts @@ -53,7 +53,14 @@ async function run() { side: JSON.stringify(side), IS_ERROR_RUNTIME: String(file === "error"), IS_BUN_DEVELOPMENT: String(!!debug), - OVERLAY_CSS: css("../runtime/bake/client/overlay.css", !!debug), + // `Bun.build` parses `define:` values as JSON. The other defines + // above already go through `JSON.stringify` / `String`, but the + // raw minified CSS starts with `*{...}` — bun versions that lack + // the auto-quote fallback (#30679) reject it as "Operators are + // not allowed in JSON". Quote explicitly so cold builds work with + // the older bootstrap bun (1.3.13) used in the CI Dockerfile and + // any pre-fix local install. + OVERLAY_CSS: JSON.stringify(css("../runtime/bake/client/overlay.css", !!debug)), }, minify: { syntax: !debug, diff --git a/src/js/internal/sql/mysql.ts b/src/js/internal/sql/mysql.ts index 2df129b99da..c07076106af 100644 --- a/src/js/internal/sql/mysql.ts +++ b/src/js/internal/sql/mysql.ts @@ -103,6 +103,7 @@ export interface MySQLDotZig { maxLifetime: number, useUnnamedPreparedStatements: boolean, allowPublicKeyRetrieval: boolean, + foundRows: boolean, ) => $ZigGeneratedClasses.MySQLConnection; createQuery: ( sql: string, @@ -253,6 +254,7 @@ class PooledMySQLConnection { prepare = true, path, allowPublicKeyRetrieval = false, + foundRows = true, } = options; let password: Bun.MaybePromise | string | undefined | (() => Bun.MaybePromise) = options.password; @@ -287,6 +289,7 @@ class PooledMySQLConnection { maxLifetime, !prepare, !!allowPublicKeyRetrieval, + !!foundRows, ); } catch (e) { process.nextTick(closeNT, onClose, e); diff --git a/src/js/internal/sql/shared.ts b/src/js/internal/sql/shared.ts index d55ca62ad18..07e0b7ea8bf 100644 --- a/src/js/internal/sql/shared.ts +++ b/src/js/internal/sql/shared.ts @@ -604,6 +604,10 @@ function parseOptions( let bigint: boolean | undefined; let path: string; let prepare: boolean = true; + // MySQL-only. Defaults to `true` to match the `mysql2` and `mariadb` drivers + // (both enable CLIENT_FOUND_ROWS by default). Read from the options object + // and the URL query string below; ignored for non-MySQL adapters. + let foundRows: boolean = true; if (url !== null) { url = url instanceof URL ? url : new URL(url); @@ -621,10 +625,21 @@ function parseOptions( const queryObject = url.searchParams.toJSON(); for (const key in queryObject) { - if (key.toLowerCase() === "sslmode") { + const lowered = key.toLowerCase(); + if (lowered === "sslmode") { sslMode = normalizeSSLMode(queryObject[key]); - } else if (key.toLowerCase() === "path") { + } else if (lowered === "path") { path = queryObject[key]; + } else if (lowered === "foundrows") { + // Accept "false"/"0" (case-insensitive) to disable; anything else + // (including "true"/"1" or empty) leaves the default enabled. Only + // consumed by the MySQL adapter. `toJSON()` returns an array when a + // key appears more than once (`?foundRows=a&foundRows=b`) — `String()` + // coerces that to `"a,b"` so we never call `.toLowerCase` on a non- + // string. Matches the `sslmode` branch's coercion via + // `normalizeSSLMode`. + const value = String(queryObject[key]).toLowerCase(); + foundRows = !(value === "false" || value === "0"); } else { // this is valid for postgres for other databases it might not be valid // check adapter then implement for other databases @@ -780,6 +795,13 @@ function parseOptions( prepare = false; } + // The options-object form wins over the URL query string. `foundRows` is + // only meaningful for the MySQL wire protocol (maps to CLIENT_FOUND_ROWS); + // for Postgres/SQLite it's a no-op. + if (options.foundRows !== undefined) { + foundRows = !!options.foundRows; + } + onconnect ??= options.onconnect; onclose ??= options.onclose; @@ -870,6 +892,7 @@ function parseOptions( sslMode, query, max: max || 10, + foundRows, }; if (idleTimeout != null) { diff --git a/src/js/private.d.ts b/src/js/private.d.ts index 8c7b3e9ed29..8a494f215df 100644 --- a/src/js/private.d.ts +++ b/src/js/private.d.ts @@ -28,7 +28,7 @@ declare module "bun" { /** * Represents the result of the `parseOptions()` function in the postgres, mysql or mariadb path */ - type DefinedPostgresOrMySQLOptions = Define & { + type DefinedPostgresOrMySQLOptions = Define & { sslMode: import("internal/sql/shared").SSLMode; query: string; }; diff --git a/src/sql_jsc/mysql/JSMySQLConnection.rs b/src/sql_jsc/mysql/JSMySQLConnection.rs index 6fb2f49287f..2fb4d9f3d84 100644 --- a/src/sql_jsc/mysql/JSMySQLConnection.rs +++ b/src/sql_jsc/mysql/JSMySQLConnection.rs @@ -582,6 +582,16 @@ impl JSMySQLConnection { // MySQL doesn't support unnamed prepared statements let _ = use_unnamed_prepared_statements; let allow_public_key_retrieval = callframe.argument(15).to_boolean(); + // `foundRows: true` (default) asks the server to return matched-rows + // counts instead of changed-rows counts in OK_Packet.affected_rows — + // matches the `mysql2` / `mariadb` driver defaults. Enabled at + // handshake time by OR-ing `CLIENT_FOUND_ROWS` into the client + // capability set. `callframe.argument(16)` returns UNDEFINED when + // the JS layer omits the arg; `to_boolean` coerces UNDEFINED to + // `false` so we fall back to Bun's pre-fix changed-rows default in + // that case. Current `src/js/internal/sql/mysql.ts` always passes + // an explicit JS boolean so the default still fires there. + let found_rows = callframe.argument(16).to_boolean(); // Ownership transferred into `ptr.connection`; disarm the errdefer so the // connect-fail `ptr.deref()` is the sole cleanup path from here on. @@ -603,6 +613,7 @@ impl JSMySQLConnection { secure, ssl_mode, allow_public_key_retrieval, + found_rows, )), auto_flusher: JsCell::new(AutoFlusher::default()), idle_timeout_interval_ms: u32::try_from(idle_timeout).expect("int cast"), diff --git a/src/sql_jsc/mysql/MySQLConnection.rs b/src/sql_jsc/mysql/MySQLConnection.rs index 53a44a3d49a..36e007be3c9 100644 --- a/src/sql_jsc/mysql/MySQLConnection.rs +++ b/src/sql_jsc/mysql/MySQLConnection.rs @@ -89,6 +89,10 @@ pub struct MySQLConnection { ssl_mode: SSLMode, allow_public_key_retrieval: bool, flags: ConnectionFlags, + /// When `true`, request `CLIENT_FOUND_ROWS` during handshake so + /// `affected_rows` counts rows matched by `WHERE` instead of rows whose + /// column values actually changed (mysql2 / mariadb default). + found_rows: bool, } impl Default for MySQLConnection { @@ -121,6 +125,7 @@ impl Default for MySQLConnection { ssl_mode: SSLMode::Disable, allow_public_key_retrieval: false, flags: ConnectionFlags::default(), + found_rows: true, } } } @@ -141,6 +146,7 @@ impl MySQLConnection { secure: Option<*mut SslCtx>, ssl_mode: SSLMode, allow_public_key_retrieval: bool, + found_rows: bool, ) -> Self { Self { database, @@ -161,6 +167,7 @@ impl MySQLConnection { TLSStatus::None }, character_set: CharacterSet::default(), + found_rows, ..Default::default() } } @@ -635,11 +642,17 @@ impl MySQLConnection { // server's advertised capabilities. This ensures features like CLIENT_DEPRECATE_EOF // are only used when the server actually supports them (critical for MySQL-compatible // databases like StarRocks, TiDB, SingleStore, etc.). - self.capabilities = Capabilities::get_default_capabilities( + let mut requested = Capabilities::get_default_capabilities( self.ssl_mode != SSLMode::Disable, !self.database.is_empty(), - ) - .intersect(handshake.capability_flags); + ); + // CLIENT_FOUND_ROWS (`foundRows` connection option; default on) — tells + // the server to report rows matched by `WHERE` in affected_rows rather + // than rows actually changed. Matches the mysql2 / mariadb driver + // defaults so an `UPDATE` that matches but doesn't change values still + // returns `affectedRows: 1`. + requested.CLIENT_FOUND_ROWS = self.found_rows; + self.capabilities = requested.intersect(handshake.capability_flags); // Override with utf8mb4 instead of using server's default self.character_set = CharacterSet::default(); diff --git a/test/js/sql/sql-mysql-found-rows.test.ts b/test/js/sql/sql-mysql-found-rows.test.ts new file mode 100644 index 00000000000..3af951ad383 --- /dev/null +++ b/test/js/sql/sql-mysql-found-rows.test.ts @@ -0,0 +1,300 @@ +// Bun.SQL MySQL: `foundRows` connection option (CLIENT_FOUND_ROWS capability) +// +// mysql2 and mariadb both enable CLIENT_FOUND_ROWS by default, so an UPDATE +// that matches a row but does not change any value returns `affectedRows: 1`. +// This test asserts that: +// +// 1. The default (no `foundRows` option) sets CLIENT_FOUND_ROWS in the +// HandshakeResponse41 — matching the mysql2/mariadb defaults so code +// migrated from those drivers sees the same `affectedRows` values. +// 2. `foundRows: true` sets the bit. +// 3. `foundRows: false` (option form) clears the bit — opt-out for users +// who want the server's changed-rows semantics. +// 4. `?foundRows=false` on the URL clears the bit (same opt-out via URL). +// 5. The options object wins over the URL query string. +// 6. `affectedRows` on the SQLResultArray reflects the OK_Packet +// `affected_rows` field the server emits — which is the field +// CLIENT_FOUND_ROWS changes the server-side semantics of. +// +// Implemented via a mock MySQL server so the test runs without Docker. + +import { SQL } from "bun"; +import { describe, expect, test } from "bun:test"; +import net from "net"; + +// --- MySQL wire protocol helpers --- + +function u16le(n: number): Buffer { + return Buffer.from([n & 0xff, (n >> 8) & 0xff]); +} + +function u24le(n: number): Buffer { + return Buffer.from([n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff]); +} + +function u32le(n: number): Buffer { + return Buffer.from([n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff]); +} + +function packet(seq: number, payload: Buffer): Buffer { + return Buffer.concat([u24le(payload.length), Buffer.from([seq]), payload]); +} + +// Capability flag constants (bit positions in MySQL protocol). +const CLIENT_LONG_PASSWORD = 1 << 0; +const CLIENT_FOUND_ROWS = 1 << 1; +const CLIENT_LONG_FLAG = 1 << 2; +const CLIENT_CONNECT_WITH_DB = 1 << 3; +const CLIENT_PROTOCOL_41 = 1 << 9; +const CLIENT_SECURE_CONNECTION = 1 << 15; +const CLIENT_MULTI_STATEMENTS = 1 << 16; +const CLIENT_MULTI_RESULTS = 1 << 17; +const CLIENT_PLUGIN_AUTH = 1 << 19; +const CLIENT_DEPRECATE_EOF = 1 << 24; + +// Advertise everything the client may request, including CLIENT_FOUND_ROWS, +// so the intersect()-at-handshake step keeps whatever the client asked for. +const SERVER_CAPS = + CLIENT_LONG_PASSWORD | + CLIENT_FOUND_ROWS | + CLIENT_LONG_FLAG | + CLIENT_CONNECT_WITH_DB | + CLIENT_PROTOCOL_41 | + CLIENT_SECURE_CONNECTION | + CLIENT_MULTI_STATEMENTS | + CLIENT_MULTI_RESULTS | + CLIENT_PLUGIN_AUTH | + CLIENT_DEPRECATE_EOF; + +function handshakeV10(): Buffer { + const authData1 = Buffer.alloc(8, 0x61); + const authData2 = Buffer.alloc(13, 0x62); + authData2[12] = 0; // trailing NUL + const payload = Buffer.concat([ + Buffer.from([10]), // protocol version + Buffer.from("mock-5.7.0\0"), // server version NUL-terminated + u32le(1), // connection id + authData1, // auth-plugin-data-part-1 (8) + Buffer.from([0]), // filler + u16le(SERVER_CAPS & 0xffff), // capability flags lower + Buffer.from([0x2d]), // character set utf8mb4_general_ci + u16le(0x0002), // status flags SERVER_STATUS_AUTOCOMMIT + u16le((SERVER_CAPS >>> 16) & 0xffff), // capability flags upper + Buffer.from([21]), // auth-plugin-data length + Buffer.alloc(10, 0), // reserved + authData2, // auth-plugin-data-part-2 (13) + Buffer.from("mysql_native_password\0"), + ]); + return packet(0, payload); +} + +function okPacket(seq: number, affectedRows: number): Buffer { + // OK header 0x00, length-encoded affected_rows (fits in 1 byte when < 251), + // length-encoded last_insert_id = 0, status_flags = AUTOCOMMIT, warnings = 0. + return packet( + seq, + Buffer.from([ + 0x00, + affectedRows & 0xff, + 0x00, // last_insert_id + 0x02, + 0x00, // status_flags (AUTOCOMMIT) + 0x00, + 0x00, // warnings + ]), + ); +} + +interface CapturedHandshake { + capabilityFlags: number; +} + +// Minimal mock MySQL server that completes the handshake, captures the +// client's HandshakeResponse41 capability flags, and replies to a single +// COM_QUERY with an OK_Packet carrying the specified affected_rows value. +// Returns a Promise that resolves to the captured capability flags once the +// client finishes its first query. +function startMockMysql(affectedRows: number): Promise<{ + port: number; + captured: Promise; + close: () => void; +}> { + return new Promise((resolve, reject) => { + const captured = Promise.withResolvers(); + + const server = net.createServer(socket => { + let buffered = Buffer.alloc(0); + let sawHandshakeResponse = false; + + socket.write(handshakeV10()); + + socket.on("data", chunk => { + buffered = Buffer.concat([buffered, chunk]); + while (buffered.length >= 4) { + const len = buffered[0]! | (buffered[1]! << 8) | (buffered[2]! << 16); + if (buffered.length < 4 + len) break; + const seq = buffered[3]!; + const payload = buffered.subarray(4, 4 + len); + buffered = buffered.subarray(4 + len); + + if (!sawHandshakeResponse) { + // First packet from client after our handshake is the + // HandshakeResponse41. First 4 bytes of its payload are the + // client's negotiated capability flags (u32 LE). + sawHandshakeResponse = true; + const caps = payload[0]! | (payload[1]! << 8) | (payload[2]! << 16) | (payload[3]! << 24); + captured.resolve({ capabilityFlags: caps >>> 0 }); + // Complete auth with OK packet so the connection is usable. + socket.write(okPacket(seq + 1, 0)); + } else { + const cmd = payload[0]; + if (cmd === 0x03) { + // COM_QUERY: reply with OK_Packet carrying affectedRows. + socket.write(okPacket(seq + 1, affectedRows)); + } else if (cmd === 0x01) { + // COM_QUIT + socket.end(); + } + } + } + }); + + socket.on("error", () => {}); + // Make sure we don't leak the captured waiter if the client drops. + socket.on("close", () => { + captured.resolve({ capabilityFlags: 0 }); + }); + }); + + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const addr = server.address() as net.AddressInfo; + resolve({ + port: addr.port, + captured: captured.promise, + close: () => server.close(), + }); + }); + }); +} + +async function runHandshakeCase(options: Bun.SQL.Options): Promise { + const mock = await startMockMysql(1); + try { + await using db = new SQL({ + ...options, + adapter: "mysql", + hostname: "127.0.0.1", + port: mock.port, + username: "root", + password: "", + database: "test", + max: 1, + idleTimeout: 1, + } as Bun.SQL.Options); + + // Triggering any query forces the handshake to complete. + await db.unsafe("UPDATE t SET v = 1"); + const { capabilityFlags } = await mock.captured; + return capabilityFlags; + } finally { + mock.close(); + } +} + +describe("Bun.SQL MySQL foundRows (CLIENT_FOUND_ROWS)", () => { + test("default: CLIENT_FOUND_ROWS is enabled (matches mysql2 / mariadb defaults)", async () => { + const caps = await runHandshakeCase({}); + expect((caps & CLIENT_FOUND_ROWS) !== 0).toBe(true); + }); + + test("foundRows: true enables CLIENT_FOUND_ROWS", async () => { + const caps = await runHandshakeCase({ foundRows: true } as Bun.SQL.Options); + expect((caps & CLIENT_FOUND_ROWS) !== 0).toBe(true); + }); + + test("foundRows: false disables CLIENT_FOUND_ROWS", async () => { + const caps = await runHandshakeCase({ foundRows: false } as Bun.SQL.Options); + expect((caps & CLIENT_FOUND_ROWS) !== 0).toBe(false); + }); + + test("URL ?foundRows=false disables CLIENT_FOUND_ROWS", async () => { + const mock = await startMockMysql(1); + try { + await using db = new SQL({ + url: `mysql://root:@127.0.0.1:${mock.port}/test?foundRows=false`, + max: 1, + idleTimeout: 1, + }); + await db.unsafe("UPDATE t SET v = 1"); + const { capabilityFlags } = await mock.captured; + expect((capabilityFlags & CLIENT_FOUND_ROWS) !== 0).toBe(false); + } finally { + mock.close(); + } + }); + + test("URL with duplicate foundRows keys doesn't throw", async () => { + // `URLSearchParams.toJSON()` returns an Array when the same key appears + // more than once. The option parser coerces through `String()` before + // normalizing, so a malformed URL like `?foundRows=true&foundRows=false` + // must not throw "toLowerCase is not a function". Using `false` as the + // last value turns the coerced "true,false" into a string that is neither + // "false" nor "0", so the default (enabled) wins. + const mock = await startMockMysql(1); + try { + await using db = new SQL({ + url: `mysql://root:@127.0.0.1:${mock.port}/test?foundRows=true&foundRows=false`, + max: 1, + idleTimeout: 1, + }); + await db.unsafe("UPDATE t SET v = 1"); + const { capabilityFlags } = await mock.captured; + // "true,false" matches neither "false" nor "0" — stays at the default. + expect((capabilityFlags & CLIENT_FOUND_ROWS) !== 0).toBe(true); + } finally { + mock.close(); + } + }); + + test("options object wins over URL query string", async () => { + const mock = await startMockMysql(1); + try { + await using db = new SQL({ + url: `mysql://root:@127.0.0.1:${mock.port}/test?foundRows=false`, + foundRows: true, + max: 1, + idleTimeout: 1, + } as Bun.SQL.Options); + await db.unsafe("UPDATE t SET v = 1"); + const { capabilityFlags } = await mock.captured; + expect((capabilityFlags & CLIENT_FOUND_ROWS) !== 0).toBe(true); + } finally { + mock.close(); + } + }); + + test("affectedRows reflects the server's OK_Packet.affected_rows value", async () => { + // CLIENT_FOUND_ROWS is what the server uses to pick matched-vs-changed. + // Bun just forwards whatever count the server reports; to pin down that + // we are not silently clamping or mis-decoding, the mock returns 1 and we + // assert the JS-side result carries it. + const mock = await startMockMysql(1); + try { + await using db = new SQL({ + adapter: "mysql", + hostname: "127.0.0.1", + port: mock.port, + username: "root", + password: "", + database: "test", + max: 1, + idleTimeout: 1, + }); + const result: any = await db.unsafe("UPDATE t SET v = v WHERE id = 1"); + expect(result.affectedRows).toBe(1); + } finally { + mock.close(); + } + }); +});