Skip to content
22 changes: 22 additions & 0 deletions docs/runtime/sql.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1407,6 +1407,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
Expand Down
17 changes: 17 additions & 0 deletions packages/bun-types/sql.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,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;
}

/**
Expand Down
3 changes: 2 additions & 1 deletion src/codegen/bake-codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ 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),
// quoted because bootstrap bun predating #30679 rejects raw CSS as a define value
OVERLAY_CSS: JSON.stringify(css("../runtime/bake/client/overlay.css", !!debug)),
},
minify: {
syntax: !debug,
Expand Down
1 change: 1 addition & 0 deletions src/js/internal/sql/mysql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export interface MySQLDotZig {
maxLifetime: number,
useUnnamedPreparedStatements: boolean,
allowPublicKeyRetrieval: boolean,
foundRows: boolean,
) => $ZigGeneratedClasses.MySQLConnection;
createQuery: (
sql: string,
Expand Down
18 changes: 17 additions & 1 deletion src/js/internal/sql/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -848,7 +848,8 @@ function closeNT(onClose: (err: Error) => void, err: Error | null) {
* Resolves the password (which may be a function and/or a promise) and calls
* the driver's native createConnection with the normalized pool options.
* Extra trailing arguments past `useUnnamedPreparedStatements` (MySQL's
* `allowPublicKeyRetrieval`) are ignored by drivers that don't take them.
* `allowPublicKeyRetrieval` and `foundRows`) are ignored by drivers that
* don't take them.
Comment thread
robobun marked this conversation as resolved.
*/
async function createPooledConnectionHandle<ConnectionHandle>(
nativeCreateConnection: (...args: any[]) => ConnectionHandle,
Expand All @@ -870,6 +871,7 @@ async function createPooledConnectionHandle<ConnectionHandle>(
prepare = true,
path,
allowPublicKeyRetrieval = false,
foundRows = true,
} = options;

let password: Bun.MaybePromise<string> | string | undefined | (() => Bun.MaybePromise<string>) = options.password;
Expand Down Expand Up @@ -904,6 +906,7 @@ async function createPooledConnectionHandle<ConnectionHandle>(
maxLifetime,
!prepare,
!!allowPublicKeyRetrieval,
!!foundRows,
);
} catch (e) {
// defer so the callback never runs while the adapter is still filling
Expand Down Expand Up @@ -1796,6 +1799,8 @@ function parseOptions(
let bigint: boolean | undefined;
let path: string;
let prepare: boolean = true;
// MySQL-only; defaults to `true` to match mysql2 / mariadb.
let foundRows: boolean = true;

if (url !== null) {
url = url instanceof URL ? url : new URL(url);
Expand Down Expand Up @@ -1828,6 +1833,10 @@ function parseOptions(
}
} else if (lowerKey === "path") {
path = queryObject[key];
} else if (lowerKey === "foundrows") {
// "false"/"0" disables; coerce first since toJSON() returns an array for duplicate keys
const value = `${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
Expand Down Expand Up @@ -2001,6 +2010,12 @@ function parseOptions(
prepare = false;
}

// Options object wins over the URL query string.
const foundRowsOption = options.foundRows;
if (foundRowsOption !== undefined) {
foundRows = !!foundRowsOption;
}

onconnect ??= options.onconnect;
onclose ??= options.onclose;

Expand Down Expand Up @@ -2101,6 +2116,7 @@ function parseOptions(
sslMode,
query,
max: max || 10,
foundRows,
};

if (idleTimeout != null) {
Expand Down
2 changes: 1 addition & 1 deletion src/js/private.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ declare module "bun" {
/**
* Represents the result of the `parseOptions()` function in the postgres, mysql or mariadb path
*/
type DefinedPostgresOrMySQLOptions = Define<Bun.SQL.PostgresOrMySQLOptions, "max" | "prepare" | "max"> & {
type DefinedPostgresOrMySQLOptions = Define<Bun.SQL.PostgresOrMySQLOptions, "max" | "prepare" | "foundRows"> & {
sslMode: import("internal/sql/shared").SSLMode;
query: string;
};
Expand Down
2 changes: 2 additions & 0 deletions src/sql_jsc/mysql/JSMySQLConnection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,7 @@ impl JSMySQLConnection {
// MySQL doesn't support unnamed prepared statements
let _ = use_unnamed_prepared_statements;
let allow_public_key_retrieval = callframe.argument(15).to_boolean();
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.
Expand All @@ -523,6 +524,7 @@ impl JSMySQLConnection {
secure,
args.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"),
Expand Down
12 changes: 9 additions & 3 deletions src/sql_jsc/mysql/MySQLConnection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ pub struct MySQLConnection {
ssl_mode: SSLMode,
allow_public_key_retrieval: bool,
flags: ConnectionFlags,
/// Request `CLIENT_FOUND_ROWS` during handshake (mysql2 / mariadb default).
found_rows: bool,
}

impl Default for MySQLConnection {
Expand Down Expand Up @@ -125,6 +127,7 @@ impl Default for MySQLConnection {
ssl_mode: SSLMode::Disable,
allow_public_key_retrieval: false,
flags: ConnectionFlags::default(),
found_rows: true,
}
}
}
Expand All @@ -144,6 +147,7 @@ impl MySQLConnection {
secure: Option<*mut SslCtx>,
ssl_mode: SSLMode,
allow_public_key_retrieval: bool,
found_rows: bool,
) -> Self {
Self {
database,
Expand All @@ -164,6 +168,7 @@ impl MySQLConnection {
TLSStatus::None
},
character_set: CharacterSet::default(),
found_rows,
..Default::default()
}
}
Expand Down Expand Up @@ -664,11 +669,12 @@ 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);
);
requested.CLIENT_FOUND_ROWS = self.found_rows;
self.capabilities = requested.intersect(handshake.capability_flags);
self.mariadb_capabilities = MariaDBCapabilities::get_default_capabilities()
.intersect(handshake.mariadb_capability_flags);

Expand Down
169 changes: 169 additions & 0 deletions test/js/sql/sql-mysql-found-rows.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
// Bun.SQL MySQL `foundRows` option: toggles CLIENT_FOUND_ROWS in the
// HandshakeResponse41 (default on, matching mysql2 / mariadb), which makes
// the server report WHERE-matched rows instead of changed rows in affectedRows.

import { SQL } from "bun";
import { describe, expect, test } from "bun:test";
import {
listeningServer,
MYSQL_CLIENT_FOUND_ROWS,
MYSQL_DEFAULT_CAPABILITIES,
mysqlHandshakeV10,
mysqlOkPacket,
mysqlParseHandshakeResponse41ClientFlags,
mysqlReadPackets,
} from "./wire-frames";

interface CapturedHandshake {
capabilityFlags: number;
}

// Completes the handshake, captures the client's HandshakeResponse41 capability
// flags, and answers each COM_QUERY with an OK_Packet carrying `affectedRows`.
async function startMockMysql(affectedRows: number): Promise<{
port: number;
captured: Promise<CapturedHandshake>;
close: () => void;
}> {
const captured = Promise.withResolvers<CapturedHandshake>();
const { port, server } = await listeningServer(socket => {
let buffered = Buffer.alloc(0);
let sawHandshakeResponse = false;

// Advertise CLIENT_FOUND_ROWS so the client's intersect keeps it when requested.
socket.write(mysqlHandshakeV10({ capabilities: MYSQL_DEFAULT_CAPABILITIES | MYSQL_CLIENT_FOUND_ROWS }));

socket.on("data", chunk => {
buffered = mysqlReadPackets(Buffer.concat([buffered, chunk]), (seq, payload) => {
if (!sawHandshakeResponse) {
sawHandshakeResponse = true;
captured.resolve({ capabilityFlags: mysqlParseHandshakeResponse41ClientFlags(payload) });
socket.write(mysqlOkPacket(seq + 1));
} else if (payload[0] === 0x03) {
// COM_QUERY
socket.write(mysqlOkPacket(seq + 1, 0x00, affectedRows));
} else if (payload[0] === 0x01) {
// COM_QUIT
socket.end();
}
});
});

socket.on("error", () => {});
// Don't leak the captured waiter if the client drops early.
socket.on("close", () => captured.resolve({ capabilityFlags: 0 }));
});
return { port, captured: captured.promise, close: () => server.close() };
}

async function runHandshakeCase(options: Bun.SQL.Options): Promise<number> {
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 & MYSQL_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 & MYSQL_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 & MYSQL_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 & MYSQL_CLIENT_FOUND_ROWS) !== 0).toBe(false);
} finally {
mock.close();
}
});

test("URL with duplicate foundRows keys doesn't throw", async () => {
// toJSON() returns an array for duplicate keys; the coerced "true,false"
// 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;
expect((capabilityFlags & MYSQL_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 & MYSQL_CLIENT_FOUND_ROWS) !== 0).toBe(true);
} finally {
mock.close();
}
});

test("affectedRows reflects the server's OK_Packet.affected_rows value", async () => {
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();
}
});
});
Loading
Loading