Skip to content
Closed
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
97b15ab
sql: report affectedRows on PostgreSQL and SQLite query results
robobun Aug 25, 2026
891a9ff
sql(sqlite): report 0 affectedRows for EXPLAIN of a write
robobun Aug 25, 2026
a03af16
sql: shorten result metadata comments
robobun Aug 25, 2026
ee5f4b2
sql(sqlite): skip leading comments when classifying the statement
robobun Aug 25, 2026
f0b96a5
sql(sqlite): report null affectedRows for a CTE write without RETURNING
robobun Aug 25, 2026
0dd58e5
sql(sqlite): classify the write verb independently of the first token
robobun Aug 25, 2026
4a73dc5
sql: shorten a parser comment
robobun Aug 25, 2026
1f41110
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 25, 2026
287545f
sql(sqlite): only accept a write verb at parenthesis depth zero
robobun Aug 25, 2026
5c01aac
sql(sqlite): pin CTE UPDATE tests with WHERE, soften affectedRows docs
robobun Aug 25, 2026
da7328a
sql(sqlite): handle quoted identifiers in comment stripping, pin mult…
robobun Aug 25, 2026
3b5543a
sql: document that multi-result queries carry metadata on the inner a…
robobun Aug 25, 2026
8573e09
Restore the adapter changes lost in the previous commit
robobun Aug 25, 2026
9d8d428
sql: correct the SQLite count docs
robobun Aug 25, 2026
f6c2ed9
ci: retrigger
robobun Aug 25, 2026
611b33b
sql(sqlite): pin the upsert command value
robobun Aug 25, 2026
3977ddc
sql(sqlite): require INTO after REPLACE, reset scan state at statemen…
robobun Aug 25, 2026
57e6ecd
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 25, 2026
77e4213
sql: reset command at statement boundaries, accept quoted identifiers…
robobun Aug 25, 2026
f0797e6
sql(sqlite): track backtick and bracket identifier quotes in the toke…
robobun Aug 25, 2026
eb8455f
sql(sqlite): reset canReturnRows at statement boundaries
robobun Aug 25, 2026
d30940b
sql(sqlite): treat a quote opener as a token boundary
robobun Aug 25, 2026
3734e2b
sql: shorten a scanner comment
robobun Aug 25, 2026
069157a
sql(sqlite): classify tokens flushed at quote boundaries
robobun Aug 25, 2026
95a439f
sql(sqlite): classify tokens at the closing paren, accept a single-qu…
robobun Aug 25, 2026
4cec6e2
sql(sqlite): treat the opening paren as a token boundary
robobun Aug 25, 2026
ed3c09a
sql: shorten a scanner comment
robobun Aug 25, 2026
11b06ad
sql: drop an over-general claim from the multi-statement docs
robobun Aug 25, 2026
f973e33
sql(sqlite): gate canReturnRows on parenthesis depth
robobun Aug 25, 2026
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
18 changes: 18 additions & 0 deletions docs/runtime/sql.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1398,6 +1398,24 @@ We also haven't implemented some of the more uncommon features like:

## Common Patterns & Best Practices

### Result metadata

Every query resolves to an array of rows with four extra non-enumerable properties: `command`, `count`, `affectedRows`, and `lastInsertRowid`. `console.log` does not print them, but they are there.

To read how many rows a write changed, use `affectedRows`. It reports the same number on every adapter:

```ts
const result = await sql`UPDATE users SET active = ${false} WHERE age < ${18}`;
console.log(result.affectedRows); // rows updated, on every adapter
```

- `affectedRows`: the number of rows an `INSERT`, `UPDATE`, `DELETE`, `REPLACE` (SQLite and MySQL), or `MERGE` (PostgreSQL) changed. `0` for statements that do not write. On SQLite, `null` when the count is unknown, for example a write wrapped in a CTE or an `INSERT ... SELECT` without `RETURNING`.
- `count`: the number of rows in the result set. On PostgreSQL and SQLite, a write without `RETURNING` reports the affected-row count here instead. On MySQL it is always the number of rows returned, so it is `0` for a plain write. On SQLite, a write whose `affectedRows` is `null` reports `0` here. Prefer `affectedRows` for portable code.
- `command`: the SQL command that produced the result, such as `"SELECT"` or `"UPDATE"`. `null` on MySQL, whose protocol does not report it.
- `lastInsertRowid`: the row id of the last inserted row on MySQL and SQLite. `null` on PostgreSQL; use `RETURNING` there.

On SQLite, a multi-statement string reports the first statement's command and the number of rows the whole batch changed. On PostgreSQL and MySQL, a query that produces several result sets (for example `.simple()` with several statements) resolves to a plain array of result arrays, and the metadata is on each inner array.

### Working with MySQL Result Sets

```ts
Expand Down
44 changes: 42 additions & 2 deletions packages/bun-types/sql.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,11 +448,51 @@ declare module "bun" {
unlisten(): Promise<void>;
}

/**
* Metadata properties present on the array a query resolves to. They are
* non-enumerable, so `console.log` does not print them.
*
* A query that produces several result sets (for example `.simple()`
* with several statements, or a MySQL `CALL`) resolves to a plain array
* of result arrays instead. The metadata is on each inner array, not on
* the outer one.
*/
interface ResultMetadata {
/**
* The number of rows in the result set. On PostgreSQL and SQLite, a
* write without `RETURNING` reports the number of affected rows here
* instead. On MySQL it is always the number of rows returned, so it is
* `0` for a plain `INSERT`, `UPDATE`, or `DELETE`. On SQLite, a
* write whose `affectedRows` is `null` reports `0` here.
Comment thread
robobun marked this conversation as resolved.
Resolved
*/
count: number | null;
Comment thread
robobun marked this conversation as resolved.
Resolved
/**
* The SQL command that produced this result, such as `"SELECT"` or
* `"UPDATE"`. `null` on MySQL, whose protocol does not report it.
*/
command: string | null;
/**
* The number of rows an `INSERT`, `UPDATE`, `DELETE`, `REPLACE`
* (SQLite and MySQL), or `MERGE` (PostgreSQL) changed. `0` for
* statements that do not write. This is the portable way to read the
* affected-row count on every adapter. On SQLite, `null` when the
* count is unknown, for example a write wrapped in a CTE or an
* `INSERT ... SELECT` without `RETURNING`.
*/
affectedRows: number | bigint | null;
/**
* The row id of the last inserted row on MySQL and SQLite. `null` on
* PostgreSQL; use `RETURNING` there.
*/
lastInsertRowid: number | bigint | null;
}

/**
* A pending SQL query. Extends `Promise`, so it can be awaited, and adds
* methods to control how it runs.
* methods to control how it runs. The resolved array also carries
* {@link ResultMetadata}.
*/
interface Query<T> extends Promise<T> {
interface Query<T> extends Promise<T & ResultMetadata> {
/**
* True while the query is executing
*/
Expand Down
12 changes: 12 additions & 0 deletions src/js/internal/sql/postgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,18 @@
}

result.count = count || 0;
// For writes the tag count is rows changed; affectedRows is the adapter-portable field.
switch (result.command) {
case "INSERT":
case "UPDATE":
case "DELETE":
case "MERGE":
result.affectedRows = result.count;
break;
default:
result.affectedRows = 0;
break;
}

Check warning on line 288 in src/js/internal/sql/postgres.ts

View check run for this annotation

Claude / Claude Code Review

PostgreSQL data-modifying CTE with top-level SELECT reports affectedRows: 0

The `default:` branch reports `affectedRows: 0` for any tag other than INSERT/UPDATE/DELETE/MERGE, but PostgreSQL tags a data-modifying CTE with a top-level `SELECT` (and `CREATE TABLE AS`, per CommandTag.rs:20) as `SELECT N` — so `WITH del AS (DELETE FROM t ... RETURNING *) SELECT * FROM del` now reports `affectedRows: 0` for a statement that wrote, contradicting the new docs' "`0` for statements that do not write". The protocol doesn't report the inner count here, so consider qualifying the
Comment thread
robobun marked this conversation as resolved.
Resolved
const last_result = query[_results];

if (!last_result) {
Expand Down
Loading
Loading