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
22 changes: 22 additions & 0 deletions docs/runtime/sql.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 @@ -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;
}

/**
Expand Down
9 changes: 8 additions & 1 deletion src/codegen/bake-codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/js/internal/sql/mysql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export interface MySQLDotZig {
maxLifetime: number,
useUnnamedPreparedStatements: boolean,
allowPublicKeyRetrieval: boolean,
foundRows: boolean,
) => $ZigGeneratedClasses.MySQLConnection;
createQuery: (
sql: string,
Expand Down Expand Up @@ -253,6 +254,7 @@ class PooledMySQLConnection {
prepare = true,
path,
allowPublicKeyRetrieval = false,
foundRows = true,
} = options;

let password: Bun.MaybePromise<string> | string | undefined | (() => Bun.MaybePromise<string>) = options.password;
Expand Down Expand Up @@ -287,6 +289,7 @@ class PooledMySQLConnection {
maxLifetime,
!prepare,
!!allowPublicKeyRetrieval,
!!foundRows,
);
} catch (e) {
process.nextTick(closeNT, onClose, e);
Expand Down
27 changes: 25 additions & 2 deletions src/js/internal/sql/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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");
Comment thread
robobun marked this conversation as resolved.
} 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 @@ -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;

Expand Down Expand Up @@ -870,6 +892,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
11 changes: 11 additions & 0 deletions src/sql_jsc/mysql/JSMySQLConnection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"),
Expand Down
19 changes: 16 additions & 3 deletions src/sql_jsc/mysql/MySQLConnection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -121,6 +125,7 @@ impl Default for MySQLConnection {
ssl_mode: SSLMode::Disable,
allow_public_key_retrieval: false,
flags: ConnectionFlags::default(),
found_rows: true,
}
}
}
Expand All @@ -141,6 +146,7 @@ impl MySQLConnection {
secure: Option<*mut SslCtx>,
ssl_mode: SSLMode,
allow_public_key_retrieval: bool,
found_rows: bool,
) -> Self {
Self {
database,
Expand All @@ -161,6 +167,7 @@ impl MySQLConnection {
TLSStatus::None
},
character_set: CharacterSet::default(),
found_rows,
..Default::default()
}
}
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading