From 97b15ab6ace70b2141a8084279532955636eff04 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:56:48 +0000 Subject: [PATCH 01/29] sql: report affectedRows on PostgreSQL and SQLite query results MySQL reports the affected-row count in affectedRows (from the OK packet), while PostgreSQL and SQLite reported it only in count and left affectedRows null. No single property worked on every adapter, and the portable fallback 'count ?? affectedRows' silently returned 0 on MySQL. Populate affectedRows on PostgreSQL (from the command tag, for INSERT, UPDATE, DELETE and MERGE) and SQLite (from sqlite3_changes, or the returned row count for writes with RETURNING). Non-write statements report 0, matching MySQL. The meaning of count does not change on any adapter. Also add the result metadata properties to the published types (Bun.SQL.ResultMetadata, intersected into the Query resolution type) and document them for all adapters. Fixes #40432 --- docs/runtime/sql.mdx | 16 ++++++++++ packages/bun-types/sql.d.ts | 35 ++++++++++++++++++-- src/js/internal/sql/postgres.ts | 14 ++++++++ src/js/internal/sql/sqlite.ts | 23 +++++++++++-- test/integration/bun-types/fixture/sql.ts | 20 ++++++------ test/js/sql/sql-affected-rows.test.ts | 39 +++++++++++++++++++++++ test/js/sql/sqlite-sql.test.ts | 25 +++++++++++++++ 7 files changed, 158 insertions(+), 14 deletions(-) create mode 100644 test/js/sql/sql-affected-rows.test.ts diff --git a/docs/runtime/sql.mdx b/docs/runtime/sql.mdx index 1d3b727daa22..43f27026f256 100644 --- a/docs/runtime/sql.mdx +++ b/docs/runtime/sql.mdx @@ -1398,6 +1398,22 @@ 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`, or `DELETE` changed. `0` for statements that do not write. +- `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. 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. + ### Working with MySQL Result Sets ```ts diff --git a/packages/bun-types/sql.d.ts b/packages/bun-types/sql.d.ts index 86c7ec2fb7fa..0f91ed2090c8 100644 --- a/packages/bun-types/sql.d.ts +++ b/packages/bun-types/sql.d.ts @@ -448,11 +448,42 @@ declare module "bun" { unlisten(): Promise; } + /** + * Metadata properties present on the array a query resolves to. They are + * non-enumerable, so `console.log` does not print them. + */ + 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`. + */ + count: number | null; + /** + * 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`, or `DELETE` changed. `0` + * for statements that do not write. This is the portable way to read + * the affected-row count on every adapter. + */ + 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 extends Promise { + interface Query extends Promise { /** * True while the query is executing */ diff --git a/src/js/internal/sql/postgres.ts b/src/js/internal/sql/postgres.ts index c8907501063a..f10f8954503a 100644 --- a/src/js/internal/sql/postgres.ts +++ b/src/js/internal/sql/postgres.ts @@ -274,6 +274,20 @@ initPostgres( } result.count = count || 0; + // For write commands the tag count is the number of rows the statement + // changed. Mirror it into affectedRows so the same property carries the + // affected-row count on every adapter (MySQL reports it only there). + switch (result.command) { + case "INSERT": + case "UPDATE": + case "DELETE": + case "MERGE": + result.affectedRows = result.count; + break; + default: + result.affectedRows = 0; + break; + } const last_result = query[_results]; if (!last_result) { diff --git a/src/js/internal/sql/sqlite.ts b/src/js/internal/sql/sqlite.ts index 6ab1c8fa156d..5ebb79ddf728 100644 --- a/src/js/internal/sql/sqlite.ts +++ b/src/js/internal/sql/sqlite.ts @@ -53,6 +53,20 @@ function commandToString(command: SQLCommand, lastToken?: string): string { } } +function affectedRowsForCommand(commandString: string, count: number | bigint): number | bigint { + switch (commandString) { + case "INSERT": + case "UPDATE": + case "DELETE": + case "REPLACE": + return count; + default: + // Keep non-write statements at 0 instead of sqlite3_changes(), which + // still reports the previous write after e.g. CREATE TABLE. + return 0; + } +} + /** * Parse the SQL query and return the command and the last token * @param query - The SQL query to parse @@ -260,8 +274,11 @@ class SQLiteQueryHandle implements BaseQueryHandle { const sqlResult = $isArray(result) ? new SQLResultArray(result) : new SQLResultArray([result]); + const count = $isArray(result) ? result.length : 1; sqlResult.command = commandToString(command, parsedInfo.lastToken); - sqlResult.count = $isArray(result) ? result.length : 1; + sqlResult.count = count; + // A write with RETURNING emits one row per affected row. + sqlResult.affectedRows = affectedRowsForCommand(sqlResult.command, count); query.resolve(sqlResult); } else { @@ -269,9 +286,11 @@ class SQLiteQueryHandle implements BaseQueryHandle { const changes = db.run.$call(db, sql, values); const sqlResult = new SQLResultArray(); - sqlResult.command = commandToString(command, parsedInfo.lastToken); + const commandString = commandToString(command, parsedInfo.lastToken); + sqlResult.command = commandString; sqlResult.count = changes.changes; sqlResult.lastInsertRowid = changes.lastInsertRowid; + sqlResult.affectedRows = affectedRowsForCommand(commandString, changes.changes); query.resolve(sqlResult); } diff --git a/test/integration/bun-types/fixture/sql.ts b/test/integration/bun-types/fixture/sql.ts index 592ec6a71091..84d3236b227a 100644 --- a/test/integration/bun-types/fixture/sql.ts +++ b/test/integration/bun-types/fixture/sql.ts @@ -92,7 +92,7 @@ expectType( return [await txn<[9]>`SELECT 9`, await txn<[10]>`SELECT 10`]; }); - expectType(tx).is(); + expectType(tx).is(); } { @@ -100,7 +100,7 @@ expectType( return [await txn<[9]>`SELECT 9`, await txn<[10]>`SELECT 10`]; }); - expectType(tx).is(); + expectType(tx).is(); } { @@ -108,7 +108,7 @@ expectType( return [await txn<[9]>`SELECT 9`, await txn<[10]>`SELECT 10`]; }); - expectType(tx).is(); + expectType(tx).is(); } expectType(sql1.unsafe("SELECT * FROM users")).is>(); @@ -178,8 +178,8 @@ expectType(opts2).extends(); const txCb = (async sql => [sql<[1]>`SELECT 1`]) satisfies Bun.SQL.TransactionContextCallback; const spCb = (async sql => [sql<[2]>`SELECT 2`]) satisfies Bun.SQL.SavepointContextCallback; -expectType(await sql.begin(txCb)).is<[1][]>(); -expectType(await sql.begin(spCb)).is<[2][]>(); +expectType(await sql.begin(txCb)).is<([1] & Bun.SQL.ResultMetadata)[]>(); +expectType(await sql.begin(spCb)).is<([2] & Bun.SQL.ResultMetadata)[]>(); expectType(queryA.cancel()).is>(); expectType(queryA.simple()).is>(); @@ -194,11 +194,11 @@ expectType(queryNum.execute()).is>(); expectType(queryNum.raw()).is>(); expectType(queryNum.values()).is>(); -expectType(await queryNum.cancel()).is(); -expectType(await queryNum.simple()).is(); -expectType(await queryNum.execute()).is(); -expectType(await queryNum.raw()).is(); -expectType(await queryNum.values()).is(); +expectType(await queryNum.cancel()).is(); +expectType(await queryNum.simple()).is(); +expectType(await queryNum.execute()).is(); +expectType(await queryNum.raw()).is(); +expectType(await queryNum.values()).is(); expectType({ password: () => "hey", diff --git a/test/js/sql/sql-affected-rows.test.ts b/test/js/sql/sql-affected-rows.test.ts new file mode 100644 index 000000000000..b9e9f73e89f5 --- /dev/null +++ b/test/js/sql/sql-affected-rows.test.ts @@ -0,0 +1,39 @@ +// `affectedRows` is the portable affected-row count for writes. MySQL has +// always reported it (from the OK packet, covered in sql-mysql.test.ts). +// PostgreSQL and SQLite used to leave it null and report the count only in +// `count`, so there was no single property that worked on every adapter +// (issue #40432). The SQLite coverage lives in sqlite-sql.test.ts. +import { randomUUIDv7, SQL } from "bun"; +import { expect, test } from "bun:test"; +import { describeWithContainer } from "harness"; + +describeWithContainer("postgres", { image: "postgres_plain" }, container => { + const url = () => `postgres://bun_sql_test@${container.host}:${container.port}/bun_sql_test`; + + test("affectedRows reports rows changed by writes", async () => { + await container.ready; + await using sql = new SQL({ url: url(), max: 1, idleTimeout: 5, connectionTimeout: 5 }); + const table = sql("t_" + randomUUIDv7("hex").replaceAll("-", "")); + + const created = await sql`CREATE TEMPORARY TABLE ${table} (id int, price int)`; + expect(created.affectedRows).toBe(0); + + const inserted = await sql`INSERT INTO ${table} VALUES (1, 10), (2, 20), (3, 30)`; + expect(inserted.affectedRows).toBe(3); + expect(inserted.count).toBe(3); + + const updated = await sql`UPDATE ${table} SET price = price + 1 WHERE price < 25`; + expect(updated.affectedRows).toBe(2); + + const returning = await sql`UPDATE ${table} SET price = 0 WHERE id IN (1, 2) RETURNING id`; + expect(returning).toHaveLength(2); + expect(returning.affectedRows).toBe(2); + + const selected = await sql`SELECT * FROM ${table}`; + expect(selected.affectedRows).toBe(0); + expect(selected.count).toBe(3); + + const deleted = await sql`DELETE FROM ${table} WHERE id > 1`; + expect(deleted.affectedRows).toBe(2); + }); +}); diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index 6e345cadae84..6e2beab0226b 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -872,6 +872,31 @@ describe("Query Execution", () => { expect(result.command).toBe("DELETE"); }); + test("affectedRows reports rows changed by writes", async () => { + const result = await sql`CREATE TABLE gadgets (id INTEGER PRIMARY KEY, price REAL)`; + expect(result.affectedRows).toBe(0); + + const inserted = await sql`INSERT INTO gadgets VALUES (1, 10.0), (2, 20.0), (3, 30.0)`; + expect(inserted.affectedRows).toBe(3); + + const updated = await sql`UPDATE gadgets SET price = price * 1.1 WHERE price < 25`; + expect(updated.affectedRows).toBe(2); + + const returning = await sql`UPDATE gadgets SET price = 0 WHERE id IN (1, 2) RETURNING id`; + expect(returning).toHaveLength(2); + expect(returning.affectedRows).toBe(2); + + const selected = await sql`SELECT * FROM gadgets`; + expect(selected.affectedRows).toBe(0); + + const deleted = await sql`DELETE FROM gadgets WHERE id > 1`; + expect(deleted.affectedRows).toBe(2); + + // CREATE does not inherit the previous write's sqlite3_changes() + const create = await sql`CREATE TABLE gadgets2 (id INTEGER)`; + expect(create.affectedRows).toBe(0); + }); + test("SELECT with various clauses", async () => { await sql`CREATE TABLE scores (id INTEGER, player TEXT, score INTEGER, team TEXT)`; await sql`INSERT INTO scores VALUES From 891a9ffd3abbdb34720be7f99791eb148ce32683 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:05:48 +0000 Subject: [PATCH 02/29] sql(sqlite): report 0 affectedRows for EXPLAIN of a write EXPLAIN UPDATE classifies as the inner write command and returns plan rows, so the plan row count leaked into affectedRows. Gate on the statement's first token. Also document that SQLite count can carry the previous write's sqlite3_changes() value for non-row, non-write statements. --- docs/runtime/sql.mdx | 2 +- packages/bun-types/sql.d.ts | 4 +++- src/js/internal/sql/sqlite.ts | 7 +++++-- test/js/sql/sqlite-sql.test.ts | 5 +++++ 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/runtime/sql.mdx b/docs/runtime/sql.mdx index 43f27026f256..eb6feea51762 100644 --- a/docs/runtime/sql.mdx +++ b/docs/runtime/sql.mdx @@ -1410,7 +1410,7 @@ console.log(result.affectedRows); // rows updated, on every adapter ``` - `affectedRows`: the number of rows an `INSERT`, `UPDATE`, or `DELETE` changed. `0` for statements that do not write. -- `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. Prefer `affectedRows` for portable code. +- `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 statement that neither returns rows nor writes (for example `CREATE TABLE`) can report the previous write's count 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. diff --git a/packages/bun-types/sql.d.ts b/packages/bun-types/sql.d.ts index 0f91ed2090c8..ce0fb6c8ce0c 100644 --- a/packages/bun-types/sql.d.ts +++ b/packages/bun-types/sql.d.ts @@ -457,7 +457,9 @@ declare module "bun" { * 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`. + * `0` for a plain `INSERT`, `UPDATE`, or `DELETE`. On SQLite, a + * statement that neither returns rows nor writes (for example + * `CREATE TABLE`) can report the previous write's count here. */ count: number | null; /** diff --git a/src/js/internal/sql/sqlite.ts b/src/js/internal/sql/sqlite.ts index 5ebb79ddf728..d5b1896095a0 100644 --- a/src/js/internal/sql/sqlite.ts +++ b/src/js/internal/sql/sqlite.ts @@ -277,8 +277,11 @@ class SQLiteQueryHandle implements BaseQueryHandle { const count = $isArray(result) ? result.length : 1; sqlResult.command = commandToString(command, parsedInfo.lastToken); sqlResult.count = count; - // A write with RETURNING emits one row per affected row. - sqlResult.affectedRows = affectedRowsForCommand(sqlResult.command, count); + // A write with RETURNING emits one row per affected row. lastToken is + // the first token of the statement: EXPLAIN classifies as the + // inner write command but returns plan rows and changes nothing. + sqlResult.affectedRows = + parsedInfo.lastToken === "EXPLAIN" ? 0 : affectedRowsForCommand(sqlResult.command, count); query.resolve(sqlResult); } else { diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index 6e2beab0226b..8c386281a8c3 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -895,6 +895,11 @@ describe("Query Execution", () => { // CREATE does not inherit the previous write's sqlite3_changes() const create = await sql`CREATE TABLE gadgets2 (id INTEGER)`; expect(create.affectedRows).toBe(0); + + // EXPLAIN of a write returns plan rows and changes nothing + const explained = await sql`EXPLAIN UPDATE gadgets SET price = 1`; + expect(explained.length).toBeGreaterThan(0); + expect(explained.affectedRows).toBe(0); }); test("SELECT with various clauses", async () => { From a03af16ee3c7c7e80d72d0cda18cf8d8f2130409 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:09:37 +0000 Subject: [PATCH 03/29] sql: shorten result metadata comments --- src/js/internal/sql/postgres.ts | 4 +--- src/js/internal/sql/sqlite.ts | 8 +++----- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/js/internal/sql/postgres.ts b/src/js/internal/sql/postgres.ts index f10f8954503a..bb7c95ab5259 100644 --- a/src/js/internal/sql/postgres.ts +++ b/src/js/internal/sql/postgres.ts @@ -274,9 +274,7 @@ initPostgres( } result.count = count || 0; - // For write commands the tag count is the number of rows the statement - // changed. Mirror it into affectedRows so the same property carries the - // affected-row count on every adapter (MySQL reports it only there). + // For writes the tag count is rows changed; affectedRows is the adapter-portable field. switch (result.command) { case "INSERT": case "UPDATE": diff --git a/src/js/internal/sql/sqlite.ts b/src/js/internal/sql/sqlite.ts index d5b1896095a0..349f4efcc71a 100644 --- a/src/js/internal/sql/sqlite.ts +++ b/src/js/internal/sql/sqlite.ts @@ -61,8 +61,7 @@ function affectedRowsForCommand(commandString: string, count: number | bigint): case "REPLACE": return count; default: - // Keep non-write statements at 0 instead of sqlite3_changes(), which - // still reports the previous write after e.g. CREATE TABLE. + // sqlite3_changes() still reports the previous write after e.g. CREATE TABLE. return 0; } } @@ -277,9 +276,8 @@ class SQLiteQueryHandle implements BaseQueryHandle { const count = $isArray(result) ? result.length : 1; sqlResult.command = commandToString(command, parsedInfo.lastToken); sqlResult.count = count; - // A write with RETURNING emits one row per affected row. lastToken is - // the first token of the statement: EXPLAIN classifies as the - // inner write command but returns plan rows and changes nothing. + // A write with RETURNING emits one row per affected row. EXPLAIN + // (lastToken is the statement's first token) changes nothing. sqlResult.affectedRows = parsedInfo.lastToken === "EXPLAIN" ? 0 : affectedRowsForCommand(sqlResult.command, count); From ee5f4b271dadc4da700fdadfe69b3c058cacb24c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:14:26 +0000 Subject: [PATCH 04/29] sql(sqlite): skip leading comments when classifying the statement A leading -- or /* */ comment made the first token the comment marker, so command reported "--" and a real DELETE reported affectedRows 0. --- src/js/internal/sql/sqlite.ts | 44 +++++++++++++++++++++++++++++++--- test/js/sql/sqlite-sql.test.ts | 11 +++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/js/internal/sql/sqlite.ts b/src/js/internal/sql/sqlite.ts index 349f4efcc71a..84ad8deafcc5 100644 --- a/src/js/internal/sql/sqlite.ts +++ b/src/js/internal/sql/sqlite.ts @@ -66,6 +66,45 @@ function affectedRowsForCommand(commandString: string, count: number | bigint): } } +/** Skips leading whitespace and SQL comments so the scan sees the statement's first keyword. */ +function skipLeadingComments(text: string): string { + let i = 0; + const len = text.length; + while (i < len) { + switch (text[i]) { + case " ": + case "\n": + case "\t": + case "\r": + case "\f": + case "\v": + i++; + continue; + case "-": { + if (text[i + 1] !== "-") { + return text.slice(i); + } + const newline = text.indexOf("\n", i + 2); + if (newline === -1) return ""; + i = newline + 1; + continue; + } + case "/": { + if (text[i + 1] !== "*") { + return text.slice(i); + } + const end = text.indexOf("*/", i + 2); + if (end === -1) return ""; + i = end + 2; + continue; + } + default: + return i === 0 ? text : text.slice(i); + } + } + return ""; +} + /** * Parse the SQL query and return the command and the last token * @param query - The SQL query to parse @@ -73,7 +112,7 @@ function affectedRowsForCommand(commandString: string, count: number | bigint): * @returns The command, the last token, and whether it can return rows */ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { - const text = query.toUpperCase().trim(); + const text = skipLeadingComments(query.toUpperCase().trim()); const text_len = text.length; let token = ""; @@ -276,8 +315,7 @@ class SQLiteQueryHandle implements BaseQueryHandle { const count = $isArray(result) ? result.length : 1; sqlResult.command = commandToString(command, parsedInfo.lastToken); sqlResult.count = count; - // A write with RETURNING emits one row per affected row. EXPLAIN - // (lastToken is the statement's first token) changes nothing. + // RETURNING emits one row per affected row; EXPLAIN (lastToken = first token) changes nothing. sqlResult.affectedRows = parsedInfo.lastToken === "EXPLAIN" ? 0 : affectedRowsForCommand(sqlResult.command, count); diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index 8c386281a8c3..38a60be72d07 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -900,6 +900,17 @@ describe("Query Execution", () => { const explained = await sql`EXPLAIN UPDATE gadgets SET price = 1`; expect(explained.length).toBeGreaterThan(0); expect(explained.affectedRows).toBe(0); + + // leading comments do not hide the write command + const lineComment = await sql` + -- remove the remaining row + DELETE FROM gadgets WHERE id = 1`; + expect(lineComment.command).toBe("DELETE"); + expect(lineComment.affectedRows).toBe(1); + + const blockComment = await sql`/* seed */ INSERT INTO gadgets2 VALUES (1), (2)`; + expect(blockComment.command).toBe("INSERT"); + expect(blockComment.affectedRows).toBe(2); }); test("SELECT with various clauses", async () => { From f0b96a5dcf6d79ff9c1709fd6302e0b7f0ecaf63 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:28:28 +0000 Subject: [PATCH 05/29] sql(sqlite): report null affectedRows for a CTE write without RETURNING The WITH token routes the statement through stmt.all(), which returns no rows for a write without RETURNING, so the row count said 0 while rows changed. Track RETURNING explicitly and report null (unknown) instead of a wrong 0. --- docs/runtime/sql.mdx | 2 +- packages/bun-types/sql.d.ts | 3 ++- src/js/internal/sql/sqlite.ts | 49 +++++++++++++++++++++------------- test/js/sql/sqlite-sql.test.ts | 9 +++++++ 4 files changed, 42 insertions(+), 21 deletions(-) diff --git a/docs/runtime/sql.mdx b/docs/runtime/sql.mdx index eb6feea51762..83cec99c913c 100644 --- a/docs/runtime/sql.mdx +++ b/docs/runtime/sql.mdx @@ -1409,7 +1409,7 @@ 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`, or `DELETE` changed. `0` for statements that do not write. +- `affectedRows`: the number of rows an `INSERT`, `UPDATE`, or `DELETE` changed. `0` for statements that do not write. On SQLite, `null` when the count is unknown: a write wrapped in a CTE 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 statement that neither returns rows nor writes (for example `CREATE TABLE`) can report the previous write's count 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. diff --git a/packages/bun-types/sql.d.ts b/packages/bun-types/sql.d.ts index ce0fb6c8ce0c..26bba2fb1671 100644 --- a/packages/bun-types/sql.d.ts +++ b/packages/bun-types/sql.d.ts @@ -470,7 +470,8 @@ declare module "bun" { /** * The number of rows an `INSERT`, `UPDATE`, or `DELETE` changed. `0` * for statements that do not write. This is the portable way to read - * the affected-row count on every adapter. + * the affected-row count on every adapter. On SQLite, `null` when the + * count is unknown: a write wrapped in a CTE without `RETURNING`. */ affectedRows: number | bigint | null; /** diff --git a/src/js/internal/sql/sqlite.ts b/src/js/internal/sql/sqlite.ts index 84ad8deafcc5..7db812419e6d 100644 --- a/src/js/internal/sql/sqlite.ts +++ b/src/js/internal/sql/sqlite.ts @@ -34,6 +34,7 @@ interface SQLParsedInfo { command: SQLCommand; lastToken?: string; canReturnRows: boolean; + hasReturning: boolean; } function commandToString(command: SQLCommand, lastToken?: string): string { @@ -53,16 +54,15 @@ function commandToString(command: SQLCommand, lastToken?: string): string { } } -function affectedRowsForCommand(commandString: string, count: number | bigint): number | bigint { +function isWriteCommand(commandString: string): boolean { switch (commandString) { case "INSERT": case "UPDATE": case "DELETE": case "REPLACE": - return count; + return true; default: - // sqlite3_changes() still reports the previous write after e.g. CREATE TABLE. - return 0; + return false; } } @@ -119,6 +119,7 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { let command = SQLCommand.none; let lastToken = ""; let canReturnRows = false; + let hasReturning = false; let quoted: false | "'" | '"' = false; // we need to reverse search so we find the closest command to the parameter for (let i = text_len - 1; i >= 0; i--) { @@ -138,7 +139,7 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { lastToken = token; token = ""; if (partial) { - return { command: SQLCommand.insert, lastToken, canReturnRows }; + return { command: SQLCommand.insert, lastToken, canReturnRows, hasReturning }; } continue; } @@ -149,7 +150,7 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { lastToken = token; token = ""; if (partial) { - return { command: SQLCommand.update, lastToken, canReturnRows }; + return { command: SQLCommand.update, lastToken, canReturnRows, hasReturning }; } continue; } @@ -160,7 +161,7 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { lastToken = token; token = ""; if (partial) { - return { command: SQLCommand.where, lastToken, canReturnRows }; + return { command: SQLCommand.where, lastToken, canReturnRows, hasReturning }; } continue; } @@ -171,7 +172,7 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { lastToken = token; token = ""; if (partial) { - return { command: SQLCommand.updateSet, lastToken, canReturnRows }; + return { command: SQLCommand.updateSet, lastToken, canReturnRows, hasReturning }; } continue; } @@ -182,15 +183,17 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { lastToken = token; token = ""; if (partial) { - return { command: SQLCommand.in, lastToken, canReturnRows }; + return { command: SQLCommand.in, lastToken, canReturnRows, hasReturning }; } continue; } + case "RETURNING": + hasReturning = true; + // fallthrough case "SELECT": case "PRAGMA": case "WITH": - case "EXPLAIN": - case "RETURNING": { + case "EXPLAIN": { lastToken = token; canReturnRows = true; token = ""; @@ -245,11 +248,13 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { command = SQLCommand.in; } break; + case "RETURNING": + hasReturning = true; + // fallthrough case "SELECT": case "PRAGMA": case "WITH": - case "EXPLAIN": - case "RETURNING": { + case "EXPLAIN": { canReturnRows = true; break; } @@ -258,7 +263,7 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { break; } } - return { command, lastToken, canReturnRows }; + return { command, lastToken, canReturnRows, hasReturning }; } class SQLiteQueryHandle implements BaseQueryHandle { @@ -313,11 +318,16 @@ class SQLiteQueryHandle implements BaseQueryHandle { const sqlResult = $isArray(result) ? new SQLResultArray(result) : new SQLResultArray([result]); const count = $isArray(result) ? result.length : 1; - sqlResult.command = commandToString(command, parsedInfo.lastToken); + const commandString = commandToString(command, parsedInfo.lastToken); + sqlResult.command = commandString; sqlResult.count = count; - // RETURNING emits one row per affected row; EXPLAIN (lastToken = first token) changes nothing. - sqlResult.affectedRows = - parsedInfo.lastToken === "EXPLAIN" ? 0 : affectedRowsForCommand(sqlResult.command, count); + if (isWriteCommand(commandString) && parsedInfo.lastToken !== "EXPLAIN") { + // RETURNING emits one row per affected row; a CTE-wrapped write without it returns no rows (count unknown). + sqlResult.affectedRows = parsedInfo.hasReturning ? count : null; + } else { + // reads and EXPLAIN (lastToken = first token) change nothing + sqlResult.affectedRows = 0; + } query.resolve(sqlResult); } else { @@ -329,7 +339,8 @@ class SQLiteQueryHandle implements BaseQueryHandle { sqlResult.command = commandString; sqlResult.count = changes.changes; sqlResult.lastInsertRowid = changes.lastInsertRowid; - sqlResult.affectedRows = affectedRowsForCommand(commandString, changes.changes); + // non-writes keep 0: sqlite3_changes() still reports the previous write after e.g. CREATE TABLE + sqlResult.affectedRows = isWriteCommand(commandString) ? changes.changes : 0; query.resolve(sqlResult); } diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index 38a60be72d07..2d3d1579b23a 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -911,6 +911,15 @@ describe("Query Execution", () => { const blockComment = await sql`/* seed */ INSERT INTO gadgets2 VALUES (1), (2)`; expect(blockComment.command).toBe("INSERT"); expect(blockComment.affectedRows).toBe(2); + + // a CTE-wrapped write without RETURNING returns no rows, so the count is unknown + const cteWrite = await sql`WITH bump AS (SELECT 1) UPDATE gadgets2 SET id = id + 10`; + expect(cteWrite.command).toBe("UPDATE"); + expect(cteWrite.affectedRows).toBeNull(); + + const cteReturning = await sql`WITH bump AS (SELECT 1) UPDATE gadgets2 SET id = id - 10 RETURNING id`; + expect(cteReturning).toHaveLength(2); + expect(cteReturning.affectedRows).toBe(2); }); test("SELECT with various clauses", async () => { From 0dd58e5c4c763c117f95bbe10dc3b2fd828f0b80 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:42:37 +0000 Subject: [PATCH 06/29] sql(sqlite): classify the write verb independently of the first token Strip -- and /* */ comments (outside string literals) before the token scan, and derive the statement's write verb during the scan. A CTE DELETE or REPLACE now reports command and affectedRows like the other writes, and a RETURNING keyword inside a comment no longer counts as a RETURNING clause. Also mention PostgreSQL MERGE in the affectedRows docs. --- docs/runtime/sql.mdx | 2 +- packages/bun-types/sql.d.ts | 9 +-- src/js/internal/sql/sqlite.ts | 115 +++++++++++++++++++-------------- test/js/sql/sqlite-sql.test.ts | 14 ++++ 4 files changed, 88 insertions(+), 52 deletions(-) diff --git a/docs/runtime/sql.mdx b/docs/runtime/sql.mdx index 83cec99c913c..f96b0c1c109e 100644 --- a/docs/runtime/sql.mdx +++ b/docs/runtime/sql.mdx @@ -1409,7 +1409,7 @@ 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`, or `DELETE` changed. `0` for statements that do not write. On SQLite, `null` when the count is unknown: a write wrapped in a CTE without `RETURNING`. +- `affectedRows`: the number of rows an `INSERT`, `UPDATE`, `DELETE`, or `MERGE` (PostgreSQL) changed. `0` for statements that do not write. On SQLite, `null` when the count is unknown: a write wrapped in a CTE 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 statement that neither returns rows nor writes (for example `CREATE TABLE`) can report the previous write's count 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. diff --git a/packages/bun-types/sql.d.ts b/packages/bun-types/sql.d.ts index 26bba2fb1671..56a549727237 100644 --- a/packages/bun-types/sql.d.ts +++ b/packages/bun-types/sql.d.ts @@ -468,10 +468,11 @@ declare module "bun" { */ command: string | null; /** - * The number of rows an `INSERT`, `UPDATE`, or `DELETE` 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: a write wrapped in a CTE without `RETURNING`. + * The number of rows an `INSERT`, `UPDATE`, `DELETE`, 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: a write wrapped in a CTE + * without `RETURNING`. */ affectedRows: number | bigint | null; /** diff --git a/src/js/internal/sql/sqlite.ts b/src/js/internal/sql/sqlite.ts index 7db812419e6d..95bce1cc9ee4 100644 --- a/src/js/internal/sql/sqlite.ts +++ b/src/js/internal/sql/sqlite.ts @@ -35,6 +35,8 @@ interface SQLParsedInfo { lastToken?: string; canReturnRows: boolean; hasReturning: boolean; + /** The statement's write verb (INSERT/UPDATE/DELETE/REPLACE), or null for non-writes. */ + writeCommand: string | null; } function commandToString(command: SQLCommand, lastToken?: string): string { @@ -66,43 +68,45 @@ function isWriteCommand(commandString: string): boolean { } } -/** Skips leading whitespace and SQL comments so the scan sees the statement's first keyword. */ -function skipLeadingComments(text: string): string { - let i = 0; +/** Removes -- and slash-star comments (outside string literals) so the token scan sees only real SQL. */ +function stripComments(text: string): string { + if (!text.includes("--") && !text.includes("/*")) { + return text; + } + let out = ""; + let quote: false | "'" | '"' = false; const len = text.length; - while (i < len) { - switch (text[i]) { - case " ": - case "\n": - case "\t": - case "\r": - case "\f": - case "\v": - i++; - continue; - case "-": { - if (text[i + 1] !== "-") { - return text.slice(i); - } - const newline = text.indexOf("\n", i + 2); - if (newline === -1) return ""; - i = newline + 1; - continue; - } - case "/": { - if (text[i + 1] !== "*") { - return text.slice(i); - } - const end = text.indexOf("*/", i + 2); - if (end === -1) return ""; - i = end + 2; - continue; + for (let i = 0; i < len; i++) { + const char = text[i]; + if (quote) { + if (char === quote) { + quote = false; } - default: - return i === 0 ? text : text.slice(i); + out += char; + continue; + } + if (char === "'" || char === '"') { + quote = char; + out += char; + continue; + } + if (char === "-" && text[i + 1] === "-") { + out += " "; + const newline = text.indexOf("\n", i + 2); + if (newline === -1) break; + i = newline; + continue; } + if (char === "/" && text[i + 1] === "*") { + out += " "; + const end = text.indexOf("*/", i + 2); + if (end === -1) break; + i = end + 1; + continue; + } + out += char; } - return ""; + return out; } /** @@ -112,7 +116,8 @@ function skipLeadingComments(text: string): string { * @returns The command, the last token, and whether it can return rows */ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { - const text = skipLeadingComments(query.toUpperCase().trim()); + // the scan relies on a trimmed input: a leading comment leaves whitespace behind + const text = stripComments(query.toUpperCase().trim()).trim(); const text_len = text.length; let token = ""; @@ -120,6 +125,7 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { let lastToken = ""; let canReturnRows = false; let hasReturning = false; + let writeVerb: string | null = null; let quoted: false | "'" | '"' = false; // we need to reverse search so we find the closest command to the parameter for (let i = text_len - 1; i >= 0; i--) { @@ -136,10 +142,11 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { if (command === SQLCommand.none) { command = SQLCommand.insert; } + writeVerb = token; lastToken = token; token = ""; if (partial) { - return { command: SQLCommand.insert, lastToken, canReturnRows, hasReturning }; + return { command: SQLCommand.insert, lastToken, canReturnRows, hasReturning, writeCommand: null }; } continue; } @@ -147,13 +154,21 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { if (command === SQLCommand.none) { command = SQLCommand.update; } + writeVerb = token; lastToken = token; token = ""; if (partial) { - return { command: SQLCommand.update, lastToken, canReturnRows, hasReturning }; + return { command: SQLCommand.update, lastToken, canReturnRows, hasReturning, writeCommand: null }; } continue; } + case "DELETE": + case "REPLACE": { + writeVerb = token; + lastToken = token; + token = ""; + continue; + } case "WHERE": { if (command === SQLCommand.none) { command = SQLCommand.where; @@ -161,7 +176,7 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { lastToken = token; token = ""; if (partial) { - return { command: SQLCommand.where, lastToken, canReturnRows, hasReturning }; + return { command: SQLCommand.where, lastToken, canReturnRows, hasReturning, writeCommand: null }; } continue; } @@ -172,7 +187,7 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { lastToken = token; token = ""; if (partial) { - return { command: SQLCommand.updateSet, lastToken, canReturnRows, hasReturning }; + return { command: SQLCommand.updateSet, lastToken, canReturnRows, hasReturning, writeCommand: null }; } continue; } @@ -183,7 +198,7 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { lastToken = token; token = ""; if (partial) { - return { command: SQLCommand.in, lastToken, canReturnRows, hasReturning }; + return { command: SQLCommand.in, lastToken, canReturnRows, hasReturning, writeCommand: null }; } continue; } @@ -263,7 +278,15 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { break; } } - return { command, lastToken, canReturnRows, hasReturning }; + // lastToken ends as the statement's first keyword. A CTE body can only + // contain SELECTs in SQLite, so any write verb seen belongs to the statement. + let writeCommand: string | null = null; + if (isWriteCommand(lastToken)) { + writeCommand = lastToken; + } else if (lastToken === "WITH") { + writeCommand = writeVerb; + } + return { command, lastToken, canReturnRows, hasReturning, writeCommand }; } class SQLiteQueryHandle implements BaseQueryHandle { @@ -318,14 +341,13 @@ class SQLiteQueryHandle implements BaseQueryHandle { const sqlResult = $isArray(result) ? new SQLResultArray(result) : new SQLResultArray([result]); const count = $isArray(result) ? result.length : 1; - const commandString = commandToString(command, parsedInfo.lastToken); - sqlResult.command = commandString; + sqlResult.command = parsedInfo.writeCommand ?? commandToString(command, parsedInfo.lastToken); sqlResult.count = count; - if (isWriteCommand(commandString) && parsedInfo.lastToken !== "EXPLAIN") { + if (parsedInfo.writeCommand) { // RETURNING emits one row per affected row; a CTE-wrapped write without it returns no rows (count unknown). sqlResult.affectedRows = parsedInfo.hasReturning ? count : null; } else { - // reads and EXPLAIN (lastToken = first token) change nothing + // reads and EXPLAIN change nothing sqlResult.affectedRows = 0; } @@ -335,12 +357,11 @@ class SQLiteQueryHandle implements BaseQueryHandle { const changes = db.run.$call(db, sql, values); const sqlResult = new SQLResultArray(); - const commandString = commandToString(command, parsedInfo.lastToken); - sqlResult.command = commandString; + sqlResult.command = parsedInfo.writeCommand ?? commandToString(command, parsedInfo.lastToken); sqlResult.count = changes.changes; sqlResult.lastInsertRowid = changes.lastInsertRowid; // non-writes keep 0: sqlite3_changes() still reports the previous write after e.g. CREATE TABLE - sqlResult.affectedRows = isWriteCommand(commandString) ? changes.changes : 0; + sqlResult.affectedRows = parsedInfo.writeCommand ? changes.changes : 0; query.resolve(sqlResult); } diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index 2d3d1579b23a..f7a423f74e67 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -920,6 +920,20 @@ describe("Query Execution", () => { const cteReturning = await sql`WITH bump AS (SELECT 1) UPDATE gadgets2 SET id = id - 10 RETURNING id`; expect(cteReturning).toHaveLength(2); expect(cteReturning.affectedRows).toBe(2); + + // a CTE DELETE: the write verb is not the statement's first token + const cteDelete = await sql`WITH pick AS (SELECT 1 AS id) DELETE FROM gadgets2 WHERE id IN (SELECT id FROM pick)`; + expect(cteDelete.command).toBe("DELETE"); + expect(cteDelete.affectedRows).toBeNull(); + + const cteDeleteReturning = await sql`WITH pick AS (SELECT 2 AS id) DELETE FROM gadgets2 WHERE id IN (SELECT id FROM pick) RETURNING id`; + expect(cteDeleteReturning).toHaveLength(1); + expect(cteDeleteReturning.affectedRows).toBe(1); + + // a RETURNING keyword inside a comment is not a RETURNING clause + const commentedReturning = await sql`INSERT INTO gadgets2 VALUES (3) /* no RETURNING here */`; + expect(commentedReturning.command).toBe("INSERT"); + expect(commentedReturning.affectedRows).toBe(1); }); test("SELECT with various clauses", async () => { From 4a73dc5570ad67f3f6aaec5fb0b2a0f04804f965 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:43:26 +0000 Subject: [PATCH 07/29] sql: shorten a parser comment --- src/js/internal/sql/sqlite.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/js/internal/sql/sqlite.ts b/src/js/internal/sql/sqlite.ts index 95bce1cc9ee4..5436b5240f9e 100644 --- a/src/js/internal/sql/sqlite.ts +++ b/src/js/internal/sql/sqlite.ts @@ -278,8 +278,7 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { break; } } - // lastToken ends as the statement's first keyword. A CTE body can only - // contain SELECTs in SQLite, so any write verb seen belongs to the statement. + // lastToken = first keyword; SQLite CTE bodies are SELECT-only, so any write verb seen is the statement's let writeCommand: string | null = null; if (isWriteCommand(lastToken)) { writeCommand = lastToken; From 1f411104eb7777f8cbccdf71e36bcaf6a9859206 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:46:03 +0000 Subject: [PATCH 08/29] [autofix.ci] apply automated fixes --- test/js/sql/sqlite-sql.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index f7a423f74e67..658bd52f9534 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -926,7 +926,8 @@ describe("Query Execution", () => { expect(cteDelete.command).toBe("DELETE"); expect(cteDelete.affectedRows).toBeNull(); - const cteDeleteReturning = await sql`WITH pick AS (SELECT 2 AS id) DELETE FROM gadgets2 WHERE id IN (SELECT id FROM pick) RETURNING id`; + const cteDeleteReturning = + await sql`WITH pick AS (SELECT 2 AS id) DELETE FROM gadgets2 WHERE id IN (SELECT id FROM pick) RETURNING id`; expect(cteDeleteReturning).toHaveLength(1); expect(cteDeleteReturning.affectedRows).toBe(1); From 287545feb7217ffe59a23db4357b4fc9980fee61 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:55:31 +0000 Subject: [PATCH 09/29] sql(sqlite): only accept a write verb at parenthesis depth zero REPLACE is both a command and a builtin function. A REPLACE function call written with a space before its argument list, inside a CTE body, classified the statement as a write. Track parenthesis depth in the scan and record the write verb only at depth zero. Also add REPLACE to the affectedRows docs. --- docs/runtime/sql.mdx | 2 +- packages/bun-types/sql.d.ts | 10 +++++----- src/js/internal/sql/sqlite.ts | 19 ++++++++++++++++--- test/js/sql/sqlite-sql.test.ts | 5 +++++ 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/docs/runtime/sql.mdx b/docs/runtime/sql.mdx index f96b0c1c109e..7abfda9dff0c 100644 --- a/docs/runtime/sql.mdx +++ b/docs/runtime/sql.mdx @@ -1409,7 +1409,7 @@ 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`, or `MERGE` (PostgreSQL) changed. `0` for statements that do not write. On SQLite, `null` when the count is unknown: a write wrapped in a CTE without `RETURNING`. +- `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: a write wrapped in a CTE 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 statement that neither returns rows nor writes (for example `CREATE TABLE`) can report the previous write's count 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. diff --git a/packages/bun-types/sql.d.ts b/packages/bun-types/sql.d.ts index 56a549727237..64079cf19dbe 100644 --- a/packages/bun-types/sql.d.ts +++ b/packages/bun-types/sql.d.ts @@ -468,11 +468,11 @@ declare module "bun" { */ command: string | null; /** - * The number of rows an `INSERT`, `UPDATE`, `DELETE`, 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: a write wrapped in a CTE - * without `RETURNING`. + * 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: a write wrapped in a CTE without `RETURNING`. */ affectedRows: number | bigint | null; /** diff --git a/src/js/internal/sql/sqlite.ts b/src/js/internal/sql/sqlite.ts index 5436b5240f9e..35760dcd0a35 100644 --- a/src/js/internal/sql/sqlite.ts +++ b/src/js/internal/sql/sqlite.ts @@ -126,6 +126,8 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { let canReturnRows = false; let hasReturning = false; let writeVerb: string | null = null; + // parentheses seen so far in the reverse scan; a statement-level write verb sits at depth 0 + let parenDepth = 0; let quoted: false | "'" | '"' = false; // we need to reverse search so we find the closest command to the parameter for (let i = text_len - 1; i >= 0; i--) { @@ -142,7 +144,9 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { if (command === SQLCommand.none) { command = SQLCommand.insert; } - writeVerb = token; + if (parenDepth === 0) { + writeVerb = token; + } lastToken = token; token = ""; if (partial) { @@ -154,7 +158,9 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { if (command === SQLCommand.none) { command = SQLCommand.update; } - writeVerb = token; + if (parenDepth === 0) { + writeVerb = token; + } lastToken = token; token = ""; if (partial) { @@ -164,7 +170,9 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { } case "DELETE": case "REPLACE": { - writeVerb = token; + if (parenDepth === 0) { + writeVerb = token; + } lastToken = token; token = ""; continue; @@ -232,6 +240,11 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { continue; } if (!quoted) { + if (char === ")") { + parenDepth++; + } else if (char === "(" && parenDepth > 0) { + parenDepth--; + } token = char + token; } } diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index 658bd52f9534..e606fd5bf00c 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -935,6 +935,11 @@ describe("Query Execution", () => { const commentedReturning = await sql`INSERT INTO gadgets2 VALUES (3) /* no RETURNING here */`; expect(commentedReturning.command).toBe("INSERT"); expect(commentedReturning.affectedRows).toBe(1); + + // REPLACE as a function call inside a CTE body is not the REPLACE command + const replaceFn = await sql`WITH names AS (SELECT REPLACE ('aaa', 'a', 'b') AS n) SELECT * FROM names`; + expect(replaceFn).toEqual([{ n: "bbb" }]); + expect(replaceFn.affectedRows).toBe(0); }); test("SELECT with various clauses", async () => { From 5c01aac3170f6e239f24253eda88d7b6295a555d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:58:38 +0000 Subject: [PATCH 10/29] sql(sqlite): pin CTE UPDATE tests with WHERE, soften affectedRows docs A CTE update usually carries a WHERE clause, so the tests use that shape. The null case in the docs is an example list, not exhaustive: INSERT ... SELECT also routes through the row-returning path. --- docs/runtime/sql.mdx | 2 +- packages/bun-types/sql.d.ts | 3 ++- test/js/sql/sqlite-sql.test.ts | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/runtime/sql.mdx b/docs/runtime/sql.mdx index 7abfda9dff0c..0d2873c5ff85 100644 --- a/docs/runtime/sql.mdx +++ b/docs/runtime/sql.mdx @@ -1409,7 +1409,7 @@ 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: a write wrapped in a CTE without `RETURNING`. +- `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 statement that neither returns rows nor writes (for example `CREATE TABLE`) can report the previous write's count 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. diff --git a/packages/bun-types/sql.d.ts b/packages/bun-types/sql.d.ts index 64079cf19dbe..498e2a6854d0 100644 --- a/packages/bun-types/sql.d.ts +++ b/packages/bun-types/sql.d.ts @@ -472,7 +472,8 @@ declare module "bun" { * (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: a write wrapped in a CTE without `RETURNING`. + * count is unknown, for example a write wrapped in a CTE or an + * `INSERT ... SELECT` without `RETURNING`. */ affectedRows: number | bigint | null; /** diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index e606fd5bf00c..4d740a651846 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -913,11 +913,11 @@ describe("Query Execution", () => { expect(blockComment.affectedRows).toBe(2); // a CTE-wrapped write without RETURNING returns no rows, so the count is unknown - const cteWrite = await sql`WITH bump AS (SELECT 1) UPDATE gadgets2 SET id = id + 10`; + const cteWrite = await sql`WITH bump AS (SELECT 1) UPDATE gadgets2 SET id = id + 10 WHERE id > 0`; expect(cteWrite.command).toBe("UPDATE"); expect(cteWrite.affectedRows).toBeNull(); - const cteReturning = await sql`WITH bump AS (SELECT 1) UPDATE gadgets2 SET id = id - 10 RETURNING id`; + const cteReturning = await sql`WITH bump AS (SELECT 1) UPDATE gadgets2 SET id = id - 10 WHERE id > 10 RETURNING id`; expect(cteReturning).toHaveLength(2); expect(cteReturning.affectedRows).toBe(2); From da7328a1ac15f51abbf6b22ebbfcc6d8b69d5f22 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:08:09 +0000 Subject: [PATCH 11/29] sql(sqlite): handle quoted identifiers in comment stripping, pin multi-statement metadata stripComments now treats backtick and bracket identifiers as quoted, so a comment marker inside one does not truncate the scan. Document and test that a multi-statement string reports the first statement's command and the batch's total changes, and that a write with null affectedRows reports count 0. --- docs/runtime/sql.mdx | 4 +++- packages/bun-types/sql.d.ts | 3 ++- src/js/internal/sql/sqlite.ts | 12 +++++++++--- test/js/sql/sqlite-sql.test.ts | 7 +++++++ 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/docs/runtime/sql.mdx b/docs/runtime/sql.mdx index 0d2873c5ff85..c543d98f29a1 100644 --- a/docs/runtime/sql.mdx +++ b/docs/runtime/sql.mdx @@ -1410,10 +1410,12 @@ 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 statement that neither returns rows nor writes (for example `CREATE TABLE`) can report the previous write's count here. Prefer `affectedRows` for portable code. +- `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 statement that neither returns rows nor writes (for example `CREATE TABLE`) can report the previous write's count here, and 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. + ### Working with MySQL Result Sets ```ts diff --git a/packages/bun-types/sql.d.ts b/packages/bun-types/sql.d.ts index 498e2a6854d0..7bbaa738b070 100644 --- a/packages/bun-types/sql.d.ts +++ b/packages/bun-types/sql.d.ts @@ -459,7 +459,8 @@ declare module "bun" { * 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 * statement that neither returns rows nor writes (for example - * `CREATE TABLE`) can report the previous write's count here. + * `CREATE TABLE`) can report the previous write's count here, and a + * write whose `affectedRows` is `null` reports `0` here. */ count: number | null; /** diff --git a/src/js/internal/sql/sqlite.ts b/src/js/internal/sql/sqlite.ts index 35760dcd0a35..3d4020bacc31 100644 --- a/src/js/internal/sql/sqlite.ts +++ b/src/js/internal/sql/sqlite.ts @@ -68,13 +68,13 @@ function isWriteCommand(commandString: string): boolean { } } -/** Removes -- and slash-star comments (outside string literals) so the token scan sees only real SQL. */ +/** Removes -- and slash-star comments (outside strings and quoted identifiers) so the token scan sees only real SQL. */ function stripComments(text: string): string { if (!text.includes("--") && !text.includes("/*")) { return text; } let out = ""; - let quote: false | "'" | '"' = false; + let quote: false | "'" | '"' | "`" | "]" = false; const len = text.length; for (let i = 0; i < len; i++) { const char = text[i]; @@ -85,11 +85,17 @@ function stripComments(text: string): string { out += char; continue; } - if (char === "'" || char === '"') { + if (char === "'" || char === '"' || char === "`") { quote = char; out += char; continue; } + if (char === "[") { + // bracket-quoted identifier, closed by "]" + quote = "]"; + out += char; + continue; + } if (char === "-" && text[i + 1] === "-") { out += " "; const newline = text.indexOf("\n", i + 2); diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index 4d740a651846..cb088fff26fd 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -940,6 +940,13 @@ describe("Query Execution", () => { const replaceFn = await sql`WITH names AS (SELECT REPLACE ('aaa', 'a', 'b') AS n) SELECT * FROM names`; expect(replaceFn).toEqual([{ n: "bbb" }]); expect(replaceFn.affectedRows).toBe(0); + + // a multi-statement string reports the first command and the batch's total changes + const multi = await sql.unsafe( + "INSERT INTO gadgets2 VALUES (100), (101); UPDATE gadgets2 SET id = id + 1 WHERE id >= 100", + ); + expect(multi.command).toBe("INSERT"); + expect(multi.affectedRows).toBe(4); }); test("SELECT with various clauses", async () => { From 3b5543a05bf4e953943a812208fd1d454ce332f3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:12:54 +0000 Subject: [PATCH 12/29] sql: document that multi-result queries carry metadata on the inner arrays --- docs/runtime/sql.mdx | 2 +- packages/bun-types/sql.d.ts | 41 +------ src/js/builtins.d.ts | 6 +- src/js/internal/sql/postgres.ts | 12 --- src/js/internal/sql/sqlite.ts | 136 +++--------------------- src/js/node/_http_server.ts | 20 ++-- src/js/node/dgram.ts | 18 ++-- src/js/node/events.ts | 8 +- src/js/node/util.ts | 22 ++-- src/jsc/bindings/EncodeURIComponent.cpp | 2 + 10 files changed, 60 insertions(+), 207 deletions(-) diff --git a/docs/runtime/sql.mdx b/docs/runtime/sql.mdx index c543d98f29a1..4c29d4220b58 100644 --- a/docs/runtime/sql.mdx +++ b/docs/runtime/sql.mdx @@ -1414,7 +1414,7 @@ console.log(result.affectedRows); // rows updated, on every adapter - `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 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 diff --git a/packages/bun-types/sql.d.ts b/packages/bun-types/sql.d.ts index 7bbaa738b070..86c7ec2fb7fa 100644 --- a/packages/bun-types/sql.d.ts +++ b/packages/bun-types/sql.d.ts @@ -448,48 +448,11 @@ declare module "bun" { unlisten(): Promise; } - /** - * Metadata properties present on the array a query resolves to. They are - * non-enumerable, so `console.log` does not print them. - */ - 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 - * statement that neither returns rows nor writes (for example - * `CREATE TABLE`) can report the previous write's count here, and a - * write whose `affectedRows` is `null` reports `0` here. - */ - count: number | null; - /** - * 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. The resolved array also carries - * {@link ResultMetadata}. + * methods to control how it runs. */ - interface Query extends Promise { + interface Query extends Promise { /** * True while the query is executing */ diff --git a/src/js/builtins.d.ts b/src/js/builtins.d.ts index 6a79984312ae..2868ed044e67 100644 --- a/src/js/builtins.d.ts +++ b/src/js/builtins.d.ts @@ -123,6 +123,11 @@ declare function $resolvePromise(promise: Promise, value: NoInfer): voi * Reject a promise with a value */ declare function $rejectPromise(promise: Promise, value: unknown): void; +/** A new pending promise. `$resolvePromise` / `$rejectPromise` require it to still be pending. */ +declare function $newPromise(): Promise; +/** Like the resolving functions of a Promise executor: calls after the first one are ignored. */ +declare function $resolvePromiseWithFirstResolvingFunctionCallCheck(promise: Promise, value: NoInfer): void; +declare function $rejectPromiseWithFirstResolvingFunctionCallCheck(promise: Promise, value: unknown): void; declare function $loadEsmIntoCjs(...args: any[]): TODO; declare function $getProxyInternalField(): TODO; @@ -296,7 +301,6 @@ declare function $overridableRequire(this: JSCommonJSModule, id: string): any; declare function $toLength(length: number): number; declare function $isTypedArrayView(obj: unknown): obj is ArrayBufferView | DataView | Uint8Array; declare function $setStateToMax(target: any, state: number): void; -declare function $newPromiseCapability(C: PromiseConstructor): TODO; /** @deprecated, use new TypeError instead */ declare function $makeTypeError(message: string): TypeError; diff --git a/src/js/internal/sql/postgres.ts b/src/js/internal/sql/postgres.ts index bb7c95ab5259..c8907501063a 100644 --- a/src/js/internal/sql/postgres.ts +++ b/src/js/internal/sql/postgres.ts @@ -274,18 +274,6 @@ initPostgres( } 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; - } const last_result = query[_results]; if (!last_result) { diff --git a/src/js/internal/sql/sqlite.ts b/src/js/internal/sql/sqlite.ts index 3d4020bacc31..6ab1c8fa156d 100644 --- a/src/js/internal/sql/sqlite.ts +++ b/src/js/internal/sql/sqlite.ts @@ -34,9 +34,6 @@ interface SQLParsedInfo { command: SQLCommand; lastToken?: string; canReturnRows: boolean; - hasReturning: boolean; - /** The statement's write verb (INSERT/UPDATE/DELETE/REPLACE), or null for non-writes. */ - writeCommand: string | null; } function commandToString(command: SQLCommand, lastToken?: string): string { @@ -56,65 +53,6 @@ function commandToString(command: SQLCommand, lastToken?: string): string { } } -function isWriteCommand(commandString: string): boolean { - switch (commandString) { - case "INSERT": - case "UPDATE": - case "DELETE": - case "REPLACE": - return true; - default: - return false; - } -} - -/** Removes -- and slash-star comments (outside strings and quoted identifiers) so the token scan sees only real SQL. */ -function stripComments(text: string): string { - if (!text.includes("--") && !text.includes("/*")) { - return text; - } - let out = ""; - let quote: false | "'" | '"' | "`" | "]" = false; - const len = text.length; - for (let i = 0; i < len; i++) { - const char = text[i]; - if (quote) { - if (char === quote) { - quote = false; - } - out += char; - continue; - } - if (char === "'" || char === '"' || char === "`") { - quote = char; - out += char; - continue; - } - if (char === "[") { - // bracket-quoted identifier, closed by "]" - quote = "]"; - out += char; - continue; - } - if (char === "-" && text[i + 1] === "-") { - out += " "; - const newline = text.indexOf("\n", i + 2); - if (newline === -1) break; - i = newline; - continue; - } - if (char === "/" && text[i + 1] === "*") { - out += " "; - const end = text.indexOf("*/", i + 2); - if (end === -1) break; - i = end + 1; - continue; - } - out += char; - } - return out; -} - /** * Parse the SQL query and return the command and the last token * @param query - The SQL query to parse @@ -122,18 +60,13 @@ function stripComments(text: string): string { * @returns The command, the last token, and whether it can return rows */ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { - // the scan relies on a trimmed input: a leading comment leaves whitespace behind - const text = stripComments(query.toUpperCase().trim()).trim(); + const text = query.toUpperCase().trim(); const text_len = text.length; let token = ""; let command = SQLCommand.none; let lastToken = ""; let canReturnRows = false; - let hasReturning = false; - let writeVerb: string | null = null; - // parentheses seen so far in the reverse scan; a statement-level write verb sits at depth 0 - let parenDepth = 0; let quoted: false | "'" | '"' = false; // we need to reverse search so we find the closest command to the parameter for (let i = text_len - 1; i >= 0; i--) { @@ -150,13 +83,10 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { if (command === SQLCommand.none) { command = SQLCommand.insert; } - if (parenDepth === 0) { - writeVerb = token; - } lastToken = token; token = ""; if (partial) { - return { command: SQLCommand.insert, lastToken, canReturnRows, hasReturning, writeCommand: null }; + return { command: SQLCommand.insert, lastToken, canReturnRows }; } continue; } @@ -164,23 +94,11 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { if (command === SQLCommand.none) { command = SQLCommand.update; } - if (parenDepth === 0) { - writeVerb = token; - } lastToken = token; token = ""; if (partial) { - return { command: SQLCommand.update, lastToken, canReturnRows, hasReturning, writeCommand: null }; - } - continue; - } - case "DELETE": - case "REPLACE": { - if (parenDepth === 0) { - writeVerb = token; + return { command: SQLCommand.update, lastToken, canReturnRows }; } - lastToken = token; - token = ""; continue; } case "WHERE": { @@ -190,7 +108,7 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { lastToken = token; token = ""; if (partial) { - return { command: SQLCommand.where, lastToken, canReturnRows, hasReturning, writeCommand: null }; + return { command: SQLCommand.where, lastToken, canReturnRows }; } continue; } @@ -201,7 +119,7 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { lastToken = token; token = ""; if (partial) { - return { command: SQLCommand.updateSet, lastToken, canReturnRows, hasReturning, writeCommand: null }; + return { command: SQLCommand.updateSet, lastToken, canReturnRows }; } continue; } @@ -212,17 +130,15 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { lastToken = token; token = ""; if (partial) { - return { command: SQLCommand.in, lastToken, canReturnRows, hasReturning, writeCommand: null }; + return { command: SQLCommand.in, lastToken, canReturnRows }; } continue; } - case "RETURNING": - hasReturning = true; - // fallthrough case "SELECT": case "PRAGMA": case "WITH": - case "EXPLAIN": { + case "EXPLAIN": + case "RETURNING": { lastToken = token; canReturnRows = true; token = ""; @@ -246,11 +162,6 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { continue; } if (!quoted) { - if (char === ")") { - parenDepth++; - } else if (char === "(" && parenDepth > 0) { - parenDepth--; - } token = char + token; } } @@ -282,13 +193,11 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { command = SQLCommand.in; } break; - case "RETURNING": - hasReturning = true; - // fallthrough case "SELECT": case "PRAGMA": case "WITH": - case "EXPLAIN": { + case "EXPLAIN": + case "RETURNING": { canReturnRows = true; break; } @@ -297,14 +206,7 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { break; } } - // lastToken = first keyword; SQLite CTE bodies are SELECT-only, so any write verb seen is the statement's - let writeCommand: string | null = null; - if (isWriteCommand(lastToken)) { - writeCommand = lastToken; - } else if (lastToken === "WITH") { - writeCommand = writeVerb; - } - return { command, lastToken, canReturnRows, hasReturning, writeCommand }; + return { command, lastToken, canReturnRows }; } class SQLiteQueryHandle implements BaseQueryHandle { @@ -358,16 +260,8 @@ class SQLiteQueryHandle implements BaseQueryHandle { const sqlResult = $isArray(result) ? new SQLResultArray(result) : new SQLResultArray([result]); - const count = $isArray(result) ? result.length : 1; - sqlResult.command = parsedInfo.writeCommand ?? commandToString(command, parsedInfo.lastToken); - sqlResult.count = count; - if (parsedInfo.writeCommand) { - // RETURNING emits one row per affected row; a CTE-wrapped write without it returns no rows (count unknown). - sqlResult.affectedRows = parsedInfo.hasReturning ? count : null; - } else { - // reads and EXPLAIN change nothing - sqlResult.affectedRows = 0; - } + sqlResult.command = commandToString(command, parsedInfo.lastToken); + sqlResult.count = $isArray(result) ? result.length : 1; query.resolve(sqlResult); } else { @@ -375,11 +269,9 @@ class SQLiteQueryHandle implements BaseQueryHandle { const changes = db.run.$call(db, sql, values); const sqlResult = new SQLResultArray(); - sqlResult.command = parsedInfo.writeCommand ?? commandToString(command, parsedInfo.lastToken); + sqlResult.command = commandToString(command, parsedInfo.lastToken); sqlResult.count = changes.changes; sqlResult.lastInsertRowid = changes.lastInsertRowid; - // non-writes keep 0: sqlite3_changes() still reports the previous write after e.g. CREATE TABLE - sqlResult.affectedRows = parsedInfo.writeCommand ? changes.changes : 0; query.resolve(sqlResult); } diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 0448fb0f3836..45fb072718c6 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -740,7 +740,7 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort // The parser is detached: the socket is handed over with only // net.Socket's 'end' listener left, like Node.js. detachSocketListenersForHandoff(socket); - const { promise, resolve } = $newPromiseCapability(Promise); + const promise = $newPromise(); // Pass the pipelined data (head buffer) if any was received with the CONNECT request const head = connectHead ? connectHead : kEmptyBuffer; // Node.js's parserOnIncoming: req.upgrade is true for CONNECT @@ -752,7 +752,7 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort // Attach the internal close listener after the user's "connect" // handler ran: Node.js hands the socket over with no listeners and // tests assert socket.listenerCount("close") === 0 there. - socket.once("close", resolve); + socket.once("close", resolveHandoffPromise.bind(undefined, promise)); return promise; } else { // Node.js will close the socket and will NOT respond with 400 Bad Request @@ -831,7 +831,7 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort } drainMicrotasks(); - let resolveFunction; + let pendingPromise: Promise | undefined; let didFinish = false; const isRequestsLimitSet = typeof server.maxRequestsPerSocket === "number" && server.maxRequestsPerSocket > 0; @@ -921,7 +921,7 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort } function onClose() { didFinish = true; - if (resolveFunction) resolveFunction(); + if (pendingPromise) $resolvePromiseWithFirstResolvingFunctionCallCheck(pendingPromise, undefined); } if (!isPipelined) { @@ -991,8 +991,8 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort // Like CONNECT: the connection is detached from the HTTP request // machinery; hold the native callback open until the raw socket // closes. - const { promise: upgradePromise, resolve: resolveUpgrade } = $newPromiseCapability(Promise); - socket.once("close", resolveUpgrade); + const upgradePromise = $newPromise(); + socket.once("close", resolveHandoffPromise.bind(undefined, upgradePromise)); return upgradePromise; } else if ( server.requireHostHeader && @@ -1051,10 +1051,9 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort return; } - const { resolve, promise } = $newPromiseCapability(Promise); - resolveFunction = resolve; + pendingPromise = $newPromise(); - return promise; + return pendingPromise; }, }); @@ -1329,6 +1328,9 @@ function detachSocketListenersForHandoff(socket) { socket.removeListener("timeout", onNodeHTTPServerSocketTimeout); socket.on("end", onReadableStreamEnd); } +function resolveHandoffPromise(promise) { + $resolvePromise(promise, undefined); +} const kSocketTimeoutTimer = Symbol("socketTimeoutTimer"); const kStreamingEnabled = Symbol("kStreamingEnabled"); // Scratch options object for the builtin ServerResponse (see the dispatcher). diff --git a/src/js/node/dgram.ts b/src/js/node/dgram.ts index 2324ae3ef9bc..9d35d32dd644 100644 --- a/src/js/node/dgram.ts +++ b/src/js/node/dgram.ts @@ -1160,18 +1160,20 @@ Socket.prototype[SymbolAsyncDispose] = async function () { if (!this[kStateSymbol].handle) { return; } - const { promise, resolve, reject } = $newPromiseCapability(Promise); - this.close(err => { - if (err) { - reject(err); - } else { - resolve(); - } - }); + const promise = $newPromise(); + this.close(FunctionPrototypeBind.$call(onAsyncDisposeClosed, undefined, promise)); return promise; }; +function onAsyncDisposeClosed(promise, err) { + if (err) { + $rejectPromiseWithFirstResolvingFunctionCallCheck(promise, err); + } else { + $resolvePromiseWithFirstResolvingFunctionCallCheck(promise, undefined); + } +} + function socketCloseNT(self) { self.emit("close"); } diff --git a/src/js/node/events.ts b/src/js/node/events.ts index e8550e1077be..81419aef5baf 100644 --- a/src/js/node/events.ts +++ b/src/js/node/events.ts @@ -564,13 +564,13 @@ async function once(emitter, type, options = kEmptyObject) { if (signal?.aborted) { throw $makeAbortError(undefined, { cause: signal?.reason }); } - const { resolve, reject, promise } = $newPromiseCapability(Promise); + const promise = $newPromise(); const errorListener = err => { emitter.removeListener(type, resolver); if (signal != null) { eventTargetAgnosticRemoveListener(signal, "abort", abortListener); } - reject(err); + $rejectPromiseWithFirstResolvingFunctionCallCheck(promise, err); }; const resolver = (...args) => { if (typeof emitter.removeListener === "function") { @@ -579,7 +579,7 @@ async function once(emitter, type, options = kEmptyObject) { if (signal != null) { eventTargetAgnosticRemoveListener(signal, "abort", abortListener); } - resolve(args); + $resolvePromiseWithFirstResolvingFunctionCallCheck(promise, args); }; const opts = resistStopPropagation({ __proto__: null, once: true }); eventTargetAgnosticAddListener(emitter, type, resolver, opts); @@ -591,7 +591,7 @@ async function once(emitter, type, options = kEmptyObject) { function abortListener() { eventTargetAgnosticRemoveListener(emitter, type, resolver); eventTargetAgnosticRemoveListener(emitter, "error", errorListener); - reject($makeAbortError(undefined, { cause: signal?.reason })); + $rejectPromiseWithFirstResolvingFunctionCallCheck(promise, $makeAbortError(undefined, { cause: signal?.reason })); } if (signal != null) { eventTargetAgnosticAddListener(signal, "abort", abortListener, opts); diff --git a/src/js/node/util.ts b/src/js/node/util.ts index 8a998a3a8ec5..5b545172dbe9 100644 --- a/src/js/node/util.ts +++ b/src/js/node/util.ts @@ -620,12 +620,12 @@ function convertProcessSignalToExitCode(signalCode) { let lazyAbortedRegistry: FinalizationRegistry<{ ref: WeakRef; - unregisterToken: (...args: any[]) => void; + listener: (...args: any[]) => void; }>; -function onAbortedCallback(resolveFn: Function) { - lazyAbortedRegistry.unregister(resolveFn); +function onAbortedCallback(promise: Promise) { + lazyAbortedRegistry.unregister(promise); - resolveFn(); + $resolvePromiseWithFirstResolvingFunctionCallCheck(promise, undefined); } function aborted(signal: AbortSignal, resource: object) { @@ -641,20 +641,20 @@ function aborted(signal: AbortSignal, resource: object) { return Promise.$resolve(); } - const { promise, resolve } = $newPromiseCapability(Promise); - const unregisterToken = onAbortedCallback.bind(undefined, resolve); + const promise = $newPromise(); + const listener = onAbortedCallback.bind(undefined, promise); signal.addEventListener( "abort", // Do not leak the current scope into the listener. // Instead, create a new function. - unregisterToken, + listener, resistStopPropagation({ __proto__: null, once: true }), ); if (!lazyAbortedRegistry) { - lazyAbortedRegistry = new FinalizationRegistry(({ ref, unregisterToken }) => { + lazyAbortedRegistry = new FinalizationRegistry(({ ref, listener }) => { const signal = ref.deref(); - if (signal) signal.removeEventListener("abort", unregisterToken); + if (signal) signal.removeEventListener("abort", listener); }); } @@ -665,9 +665,9 @@ function aborted(signal: AbortSignal, resource: object) { resource, { ref: new WeakRef(signal), - unregisterToken, + listener, }, - unregisterToken, + promise, ); return promise; diff --git a/src/jsc/bindings/EncodeURIComponent.cpp b/src/jsc/bindings/EncodeURIComponent.cpp index 705521774430..e8316d20664f 100644 --- a/src/jsc/bindings/EncodeURIComponent.cpp +++ b/src/jsc/bindings/EncodeURIComponent.cpp @@ -1,5 +1,7 @@ #include "EncodeURIComponent.h" +#include + // from JSGlobalObjectFunctions.cpp namespace JSC { From 8573e09cc5ca6de0f69b467df6e4d7173b4b8f86 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:25:55 +0000 Subject: [PATCH 13/29] Restore the adapter changes lost in the previous commit The previous commit unintentionally reverted the affectedRows changes in postgres.ts and sqlite.ts, the ResultMetadata type, and several unrelated files. Restore them, and harden two classifier edges: a write verb flush against the CTE's closing paren, and a REPLACE function call in the outer SELECT. --- packages/bun-types/sql.d.ts | 46 ++++++- src/js/builtins.d.ts | 6 +- src/js/internal/sql/postgres.ts | 12 ++ src/js/internal/sql/sqlite.ts | 158 +++++++++++++++++++++--- src/js/node/_http_server.ts | 20 ++- src/js/node/dgram.ts | 18 ++- src/js/node/events.ts | 8 +- src/js/node/util.ts | 22 ++-- src/jsc/bindings/EncodeURIComponent.cpp | 2 - test/js/sql/sqlite-sql.test.ts | 10 ++ 10 files changed, 243 insertions(+), 59 deletions(-) diff --git a/packages/bun-types/sql.d.ts b/packages/bun-types/sql.d.ts index 86c7ec2fb7fa..eb7e36118fa5 100644 --- a/packages/bun-types/sql.d.ts +++ b/packages/bun-types/sql.d.ts @@ -448,11 +448,53 @@ declare module "bun" { unlisten(): Promise; } + /** + * 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 + * statement that neither returns rows nor writes (for example + * `CREATE TABLE`) can report the previous write's count here, and a + * write whose `affectedRows` is `null` reports `0` here. + */ + count: number | null; + /** + * 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 extends Promise { + interface Query extends Promise { /** * True while the query is executing */ diff --git a/src/js/builtins.d.ts b/src/js/builtins.d.ts index 2868ed044e67..6a79984312ae 100644 --- a/src/js/builtins.d.ts +++ b/src/js/builtins.d.ts @@ -123,11 +123,6 @@ declare function $resolvePromise(promise: Promise, value: NoInfer): voi * Reject a promise with a value */ declare function $rejectPromise(promise: Promise, value: unknown): void; -/** A new pending promise. `$resolvePromise` / `$rejectPromise` require it to still be pending. */ -declare function $newPromise(): Promise; -/** Like the resolving functions of a Promise executor: calls after the first one are ignored. */ -declare function $resolvePromiseWithFirstResolvingFunctionCallCheck(promise: Promise, value: NoInfer): void; -declare function $rejectPromiseWithFirstResolvingFunctionCallCheck(promise: Promise, value: unknown): void; declare function $loadEsmIntoCjs(...args: any[]): TODO; declare function $getProxyInternalField(): TODO; @@ -301,6 +296,7 @@ declare function $overridableRequire(this: JSCommonJSModule, id: string): any; declare function $toLength(length: number): number; declare function $isTypedArrayView(obj: unknown): obj is ArrayBufferView | DataView | Uint8Array; declare function $setStateToMax(target: any, state: number): void; +declare function $newPromiseCapability(C: PromiseConstructor): TODO; /** @deprecated, use new TypeError instead */ declare function $makeTypeError(message: string): TypeError; diff --git a/src/js/internal/sql/postgres.ts b/src/js/internal/sql/postgres.ts index c8907501063a..bb7c95ab5259 100644 --- a/src/js/internal/sql/postgres.ts +++ b/src/js/internal/sql/postgres.ts @@ -274,6 +274,18 @@ initPostgres( } 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; + } const last_result = query[_results]; if (!last_result) { diff --git a/src/js/internal/sql/sqlite.ts b/src/js/internal/sql/sqlite.ts index 6ab1c8fa156d..babd331af4f6 100644 --- a/src/js/internal/sql/sqlite.ts +++ b/src/js/internal/sql/sqlite.ts @@ -34,6 +34,9 @@ interface SQLParsedInfo { command: SQLCommand; lastToken?: string; canReturnRows: boolean; + hasReturning: boolean; + /** The statement's write verb (INSERT/UPDATE/DELETE/REPLACE), or null for non-writes. */ + writeCommand: string | null; } function commandToString(command: SQLCommand, lastToken?: string): string { @@ -53,6 +56,82 @@ function commandToString(command: SQLCommand, lastToken?: string): string { } } +function nextTokenIsOpenParen(text: string, index: number): boolean { + for (let i = index; i < text.length; i++) { + switch (text[i]) { + case " ": + case "\n": + case "\t": + case "\r": + case "\f": + case "\v": + continue; + default: + return text[i] === "("; + } + } + return false; +} + +function isWriteCommand(commandString: string): boolean { + switch (commandString) { + case "INSERT": + case "UPDATE": + case "DELETE": + case "REPLACE": + return true; + default: + return false; + } +} + +/** Removes -- and slash-star comments (outside strings and quoted identifiers) so the token scan sees only real SQL. */ +function stripComments(text: string): string { + if (!text.includes("--") && !text.includes("/*")) { + return text; + } + let out = ""; + let quote: false | "'" | '"' | "`" | "]" = false; + const len = text.length; + for (let i = 0; i < len; i++) { + const char = text[i]; + if (quote) { + if (char === quote) { + quote = false; + } + out += char; + continue; + } + if (char === "'" || char === '"' || char === "`") { + quote = char; + out += char; + continue; + } + if (char === "[") { + // bracket-quoted identifier, closed by "]" + quote = "]"; + out += char; + continue; + } + if (char === "-" && text[i + 1] === "-") { + out += " "; + const newline = text.indexOf("\n", i + 2); + if (newline === -1) break; + i = newline; + continue; + } + if (char === "/" && text[i + 1] === "*") { + out += " "; + const end = text.indexOf("*/", i + 2); + if (end === -1) break; + i = end + 1; + continue; + } + out += char; + } + return out; +} + /** * Parse the SQL query and return the command and the last token * @param query - The SQL query to parse @@ -60,13 +139,18 @@ function commandToString(command: SQLCommand, lastToken?: string): string { * @returns The command, the last token, and whether it can return rows */ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { - const text = query.toUpperCase().trim(); + // the scan relies on a trimmed input: a leading comment leaves whitespace behind + const text = stripComments(query.toUpperCase().trim()).trim(); const text_len = text.length; let token = ""; let command = SQLCommand.none; let lastToken = ""; let canReturnRows = false; + let hasReturning = false; + let writeVerb: string | null = null; + // parentheses seen so far in the reverse scan; a statement-level write verb sits at depth 0 + let parenDepth = 0; let quoted: false | "'" | '"' = false; // we need to reverse search so we find the closest command to the parameter for (let i = text_len - 1; i >= 0; i--) { @@ -83,10 +167,13 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { if (command === SQLCommand.none) { command = SQLCommand.insert; } + if (parenDepth === 0) { + writeVerb = token; + } lastToken = token; token = ""; if (partial) { - return { command: SQLCommand.insert, lastToken, canReturnRows }; + return { command: SQLCommand.insert, lastToken, canReturnRows, hasReturning, writeCommand: null }; } continue; } @@ -94,13 +181,26 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { if (command === SQLCommand.none) { command = SQLCommand.update; } + if (parenDepth === 0) { + writeVerb = token; + } lastToken = token; token = ""; if (partial) { - return { command: SQLCommand.update, lastToken, canReturnRows }; + return { command: SQLCommand.update, lastToken, canReturnRows, hasReturning, writeCommand: null }; } continue; } + case "DELETE": + case "REPLACE": { + // REPLACE is also a scalar function; a call is followed by "(" + if (parenDepth === 0 && !(token === "REPLACE" && nextTokenIsOpenParen(text, i + 1 + token.length))) { + writeVerb = token; + } + lastToken = token; + token = ""; + continue; + } case "WHERE": { if (command === SQLCommand.none) { command = SQLCommand.where; @@ -108,7 +208,7 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { lastToken = token; token = ""; if (partial) { - return { command: SQLCommand.where, lastToken, canReturnRows }; + return { command: SQLCommand.where, lastToken, canReturnRows, hasReturning, writeCommand: null }; } continue; } @@ -119,7 +219,7 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { lastToken = token; token = ""; if (partial) { - return { command: SQLCommand.updateSet, lastToken, canReturnRows }; + return { command: SQLCommand.updateSet, lastToken, canReturnRows, hasReturning, writeCommand: null }; } continue; } @@ -130,15 +230,17 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { lastToken = token; token = ""; if (partial) { - return { command: SQLCommand.in, lastToken, canReturnRows }; + return { command: SQLCommand.in, lastToken, canReturnRows, hasReturning, writeCommand: null }; } continue; } + case "RETURNING": + hasReturning = true; + // fallthrough case "SELECT": case "PRAGMA": case "WITH": - case "EXPLAIN": - case "RETURNING": { + case "EXPLAIN": { lastToken = token; canReturnRows = true; token = ""; @@ -162,6 +264,15 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { continue; } if (!quoted) { + if (char === ")") { + // a write verb can sit flush against the CTE's closing paren: "(SELECT 1)DELETE" + if (parenDepth === 0 && isWriteCommand(token)) { + writeVerb = token; + } + parenDepth++; + } else if (char === "(" && parenDepth > 0) { + parenDepth--; + } token = char + token; } } @@ -193,11 +304,13 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { command = SQLCommand.in; } break; + case "RETURNING": + hasReturning = true; + // fallthrough case "SELECT": case "PRAGMA": case "WITH": - case "EXPLAIN": - case "RETURNING": { + case "EXPLAIN": { canReturnRows = true; break; } @@ -206,7 +319,14 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { break; } } - return { command, lastToken, canReturnRows }; + // lastToken = first keyword; SQLite CTE bodies are SELECT-only, so any write verb seen is the statement's + let writeCommand: string | null = null; + if (isWriteCommand(lastToken)) { + writeCommand = lastToken; + } else if (lastToken === "WITH") { + writeCommand = writeVerb; + } + return { command, lastToken, canReturnRows, hasReturning, writeCommand }; } class SQLiteQueryHandle implements BaseQueryHandle { @@ -260,8 +380,16 @@ class SQLiteQueryHandle implements BaseQueryHandle { const sqlResult = $isArray(result) ? new SQLResultArray(result) : new SQLResultArray([result]); - sqlResult.command = commandToString(command, parsedInfo.lastToken); - sqlResult.count = $isArray(result) ? result.length : 1; + const count = $isArray(result) ? result.length : 1; + sqlResult.command = parsedInfo.writeCommand ?? commandToString(command, parsedInfo.lastToken); + sqlResult.count = count; + if (parsedInfo.writeCommand) { + // RETURNING emits one row per affected row; a CTE-wrapped write without it returns no rows (count unknown). + sqlResult.affectedRows = parsedInfo.hasReturning ? count : null; + } else { + // reads and EXPLAIN change nothing + sqlResult.affectedRows = 0; + } query.resolve(sqlResult); } else { @@ -269,9 +397,11 @@ class SQLiteQueryHandle implements BaseQueryHandle { const changes = db.run.$call(db, sql, values); const sqlResult = new SQLResultArray(); - sqlResult.command = commandToString(command, parsedInfo.lastToken); + sqlResult.command = parsedInfo.writeCommand ?? commandToString(command, parsedInfo.lastToken); sqlResult.count = changes.changes; sqlResult.lastInsertRowid = changes.lastInsertRowid; + // non-writes keep 0: sqlite3_changes() still reports the previous write after e.g. CREATE TABLE + sqlResult.affectedRows = parsedInfo.writeCommand ? changes.changes : 0; query.resolve(sqlResult); } diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 45fb072718c6..0448fb0f3836 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -740,7 +740,7 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort // The parser is detached: the socket is handed over with only // net.Socket's 'end' listener left, like Node.js. detachSocketListenersForHandoff(socket); - const promise = $newPromise(); + const { promise, resolve } = $newPromiseCapability(Promise); // Pass the pipelined data (head buffer) if any was received with the CONNECT request const head = connectHead ? connectHead : kEmptyBuffer; // Node.js's parserOnIncoming: req.upgrade is true for CONNECT @@ -752,7 +752,7 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort // Attach the internal close listener after the user's "connect" // handler ran: Node.js hands the socket over with no listeners and // tests assert socket.listenerCount("close") === 0 there. - socket.once("close", resolveHandoffPromise.bind(undefined, promise)); + socket.once("close", resolve); return promise; } else { // Node.js will close the socket and will NOT respond with 400 Bad Request @@ -831,7 +831,7 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort } drainMicrotasks(); - let pendingPromise: Promise | undefined; + let resolveFunction; let didFinish = false; const isRequestsLimitSet = typeof server.maxRequestsPerSocket === "number" && server.maxRequestsPerSocket > 0; @@ -921,7 +921,7 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort } function onClose() { didFinish = true; - if (pendingPromise) $resolvePromiseWithFirstResolvingFunctionCallCheck(pendingPromise, undefined); + if (resolveFunction) resolveFunction(); } if (!isPipelined) { @@ -991,8 +991,8 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort // Like CONNECT: the connection is detached from the HTTP request // machinery; hold the native callback open until the raw socket // closes. - const upgradePromise = $newPromise(); - socket.once("close", resolveHandoffPromise.bind(undefined, upgradePromise)); + const { promise: upgradePromise, resolve: resolveUpgrade } = $newPromiseCapability(Promise); + socket.once("close", resolveUpgrade); return upgradePromise; } else if ( server.requireHostHeader && @@ -1051,9 +1051,10 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort return; } - pendingPromise = $newPromise(); + const { resolve, promise } = $newPromiseCapability(Promise); + resolveFunction = resolve; - return pendingPromise; + return promise; }, }); @@ -1328,9 +1329,6 @@ function detachSocketListenersForHandoff(socket) { socket.removeListener("timeout", onNodeHTTPServerSocketTimeout); socket.on("end", onReadableStreamEnd); } -function resolveHandoffPromise(promise) { - $resolvePromise(promise, undefined); -} const kSocketTimeoutTimer = Symbol("socketTimeoutTimer"); const kStreamingEnabled = Symbol("kStreamingEnabled"); // Scratch options object for the builtin ServerResponse (see the dispatcher). diff --git a/src/js/node/dgram.ts b/src/js/node/dgram.ts index 9d35d32dd644..2324ae3ef9bc 100644 --- a/src/js/node/dgram.ts +++ b/src/js/node/dgram.ts @@ -1160,20 +1160,18 @@ Socket.prototype[SymbolAsyncDispose] = async function () { if (!this[kStateSymbol].handle) { return; } - const promise = $newPromise(); - this.close(FunctionPrototypeBind.$call(onAsyncDisposeClosed, undefined, promise)); + const { promise, resolve, reject } = $newPromiseCapability(Promise); + this.close(err => { + if (err) { + reject(err); + } else { + resolve(); + } + }); return promise; }; -function onAsyncDisposeClosed(promise, err) { - if (err) { - $rejectPromiseWithFirstResolvingFunctionCallCheck(promise, err); - } else { - $resolvePromiseWithFirstResolvingFunctionCallCheck(promise, undefined); - } -} - function socketCloseNT(self) { self.emit("close"); } diff --git a/src/js/node/events.ts b/src/js/node/events.ts index 81419aef5baf..e8550e1077be 100644 --- a/src/js/node/events.ts +++ b/src/js/node/events.ts @@ -564,13 +564,13 @@ async function once(emitter, type, options = kEmptyObject) { if (signal?.aborted) { throw $makeAbortError(undefined, { cause: signal?.reason }); } - const promise = $newPromise(); + const { resolve, reject, promise } = $newPromiseCapability(Promise); const errorListener = err => { emitter.removeListener(type, resolver); if (signal != null) { eventTargetAgnosticRemoveListener(signal, "abort", abortListener); } - $rejectPromiseWithFirstResolvingFunctionCallCheck(promise, err); + reject(err); }; const resolver = (...args) => { if (typeof emitter.removeListener === "function") { @@ -579,7 +579,7 @@ async function once(emitter, type, options = kEmptyObject) { if (signal != null) { eventTargetAgnosticRemoveListener(signal, "abort", abortListener); } - $resolvePromiseWithFirstResolvingFunctionCallCheck(promise, args); + resolve(args); }; const opts = resistStopPropagation({ __proto__: null, once: true }); eventTargetAgnosticAddListener(emitter, type, resolver, opts); @@ -591,7 +591,7 @@ async function once(emitter, type, options = kEmptyObject) { function abortListener() { eventTargetAgnosticRemoveListener(emitter, type, resolver); eventTargetAgnosticRemoveListener(emitter, "error", errorListener); - $rejectPromiseWithFirstResolvingFunctionCallCheck(promise, $makeAbortError(undefined, { cause: signal?.reason })); + reject($makeAbortError(undefined, { cause: signal?.reason })); } if (signal != null) { eventTargetAgnosticAddListener(signal, "abort", abortListener, opts); diff --git a/src/js/node/util.ts b/src/js/node/util.ts index 5b545172dbe9..8a998a3a8ec5 100644 --- a/src/js/node/util.ts +++ b/src/js/node/util.ts @@ -620,12 +620,12 @@ function convertProcessSignalToExitCode(signalCode) { let lazyAbortedRegistry: FinalizationRegistry<{ ref: WeakRef; - listener: (...args: any[]) => void; + unregisterToken: (...args: any[]) => void; }>; -function onAbortedCallback(promise: Promise) { - lazyAbortedRegistry.unregister(promise); +function onAbortedCallback(resolveFn: Function) { + lazyAbortedRegistry.unregister(resolveFn); - $resolvePromiseWithFirstResolvingFunctionCallCheck(promise, undefined); + resolveFn(); } function aborted(signal: AbortSignal, resource: object) { @@ -641,20 +641,20 @@ function aborted(signal: AbortSignal, resource: object) { return Promise.$resolve(); } - const promise = $newPromise(); - const listener = onAbortedCallback.bind(undefined, promise); + const { promise, resolve } = $newPromiseCapability(Promise); + const unregisterToken = onAbortedCallback.bind(undefined, resolve); signal.addEventListener( "abort", // Do not leak the current scope into the listener. // Instead, create a new function. - listener, + unregisterToken, resistStopPropagation({ __proto__: null, once: true }), ); if (!lazyAbortedRegistry) { - lazyAbortedRegistry = new FinalizationRegistry(({ ref, listener }) => { + lazyAbortedRegistry = new FinalizationRegistry(({ ref, unregisterToken }) => { const signal = ref.deref(); - if (signal) signal.removeEventListener("abort", listener); + if (signal) signal.removeEventListener("abort", unregisterToken); }); } @@ -665,9 +665,9 @@ function aborted(signal: AbortSignal, resource: object) { resource, { ref: new WeakRef(signal), - listener, + unregisterToken, }, - promise, + unregisterToken, ); return promise; diff --git a/src/jsc/bindings/EncodeURIComponent.cpp b/src/jsc/bindings/EncodeURIComponent.cpp index e8316d20664f..705521774430 100644 --- a/src/jsc/bindings/EncodeURIComponent.cpp +++ b/src/jsc/bindings/EncodeURIComponent.cpp @@ -1,7 +1,5 @@ #include "EncodeURIComponent.h" -#include - // from JSGlobalObjectFunctions.cpp namespace JSC { diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index cb088fff26fd..65cc949472a6 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -947,6 +947,16 @@ describe("Query Execution", () => { ); expect(multi.command).toBe("INSERT"); expect(multi.affectedRows).toBe(4); + + // the write verb can sit flush against the CTE's closing paren + const glued = await sql`WITH c AS (SELECT 1)DELETE FROM gadgets2 WHERE id = 102`; + expect(glued.command).toBe("DELETE"); + expect(glued.affectedRows).toBeNull(); + + // REPLACE as a function in the outer SELECT is not the REPLACE command + const outerReplaceFn = await sql`WITH c AS (SELECT 'aaa' AS n) SELECT REPLACE (n, 'a', 'b') AS r FROM c`; + expect(outerReplaceFn).toEqual([{ r: "bbb" }]); + expect(outerReplaceFn.affectedRows).toBe(0); }); test("SELECT with various clauses", async () => { From 9d8d4289f644204e4aa62a5c23e3107a06e7c0ff Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:07:04 +0000 Subject: [PATCH 14/29] sql: correct the SQLite count docs bun:sqlite's db.run() reports a sqlite3_total_changes() delta, so a non-write like CREATE TABLE always reports count 0, not the previous write's value. Drop the wrong claim and fix the code comment. --- docs/runtime/sql.mdx | 2 +- packages/bun-types/sql.d.ts | 2 -- src/js/internal/sql/sqlite.ts | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/runtime/sql.mdx b/docs/runtime/sql.mdx index 4c29d4220b58..4f8cc5a441f1 100644 --- a/docs/runtime/sql.mdx +++ b/docs/runtime/sql.mdx @@ -1410,7 +1410,7 @@ 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 statement that neither returns rows nor writes (for example `CREATE TABLE`) can report the previous write's count here, and a write whose `affectedRows` is `null` reports `0` here. Prefer `affectedRows` for portable code. +- `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. diff --git a/packages/bun-types/sql.d.ts b/packages/bun-types/sql.d.ts index eb7e36118fa5..715acde82e2c 100644 --- a/packages/bun-types/sql.d.ts +++ b/packages/bun-types/sql.d.ts @@ -463,8 +463,6 @@ declare module "bun" { * 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 - * statement that neither returns rows nor writes (for example - * `CREATE TABLE`) can report the previous write's count here, and a * write whose `affectedRows` is `null` reports `0` here. */ count: number | null; diff --git a/src/js/internal/sql/sqlite.ts b/src/js/internal/sql/sqlite.ts index babd331af4f6..3583a0d7a9e0 100644 --- a/src/js/internal/sql/sqlite.ts +++ b/src/js/internal/sql/sqlite.ts @@ -400,7 +400,7 @@ class SQLiteQueryHandle implements BaseQueryHandle { sqlResult.command = parsedInfo.writeCommand ?? commandToString(command, parsedInfo.lastToken); sqlResult.count = changes.changes; sqlResult.lastInsertRowid = changes.lastInsertRowid; - // non-writes keep 0: sqlite3_changes() still reports the previous write after e.g. CREATE TABLE + // a batch whose first statement is a non-write reports 0 even when a later statement wrote sqlResult.affectedRows = parsedInfo.writeCommand ? changes.changes : 0; query.resolve(sqlResult); From f6c2ed922b796c609ce0f601d0f189fe2539b0a8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:22:51 +0000 Subject: [PATCH 15/29] ci: retrigger From 611b33b49373fec7f60e640ec65b1cfa1b61c46c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:44:13 +0000 Subject: [PATCH 16/29] sql(sqlite): pin the upsert command value INSERT ... ON CONFLICT DO UPDATE reports command INSERT, the statement's verb, matching the PostgreSQL command tag. The old scanner accidentally reported UPDATE. --- test/js/sql/sqlite-sql.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index 65cc949472a6..2156ca09f74f 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -957,6 +957,12 @@ describe("Query Execution", () => { const outerReplaceFn = await sql`WITH c AS (SELECT 'aaa' AS n) SELECT REPLACE (n, 'a', 'b') AS r FROM c`; expect(outerReplaceFn).toEqual([{ r: "bbb" }]); expect(outerReplaceFn.affectedRows).toBe(0); + + // upsert reports the statement's verb, matching PostgreSQL's command tag + await sql`INSERT INTO gadgets VALUES (7, 1)`; + const upsert = await sql`INSERT INTO gadgets VALUES (7, 2) ON CONFLICT (id) DO UPDATE SET price = 2`; + expect(upsert.command).toBe("INSERT"); + expect(upsert.affectedRows).toBe(1); }); test("SELECT with various clauses", async () => { From 3977ddc162ba67b668074c8cd3514935f16c0885 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:25:13 +0000 Subject: [PATCH 17/29] sql(sqlite): require INTO after REPLACE, reset scan state at statement boundaries REPLACE is a non-reserved keyword, so a bare replace identifier or a REPLACE() call must not classify as the REPLACE command; the statement form is always REPLACE INTO. Also reset the write verb and RETURNING flags at an unquoted top-level semicolon so a later statement in a batch does not classify the first one. --- src/js/internal/sql/sqlite.ts | 49 ++++++++++++++++++++++++++++------ test/js/sql/sqlite-sql.test.ts | 10 +++++++ 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/src/js/internal/sql/sqlite.ts b/src/js/internal/sql/sqlite.ts index 3583a0d7a9e0..1b28ea61e798 100644 --- a/src/js/internal/sql/sqlite.ts +++ b/src/js/internal/sql/sqlite.ts @@ -56,8 +56,11 @@ function commandToString(command: SQLCommand, lastToken?: string): string { } } -function nextTokenIsOpenParen(text: string, index: number): boolean { - for (let i = index; i < text.length; i++) { +/** True when the next whitespace-delimited token at or after index is exactly `word`. */ +function nextTokenIs(text: string, index: number, word: string): boolean { + let i = index; + const len = text.length; + while (i < len) { switch (text[i]) { case " ": case "\n": @@ -65,12 +68,34 @@ function nextTokenIsOpenParen(text: string, index: number): boolean { case "\r": case "\f": case "\v": + i++; continue; - default: - return text[i] === "("; } + break; + } + const end = i + word.length; + if (end > len) { + return false; + } + for (let j = 0; j < word.length; j++) { + if (text[i + j] !== word[j]) { + return false; + } + } + if (end === len) { + return true; + } + switch (text[end]) { + case " ": + case "\n": + case "\t": + case "\r": + case "\f": + case "\v": + return true; + default: + return false; } - return false; } function isWriteCommand(commandString: string): boolean { @@ -193,8 +218,8 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { } case "DELETE": case "REPLACE": { - // REPLACE is also a scalar function; a call is followed by "(" - if (parenDepth === 0 && !(token === "REPLACE" && nextTokenIsOpenParen(text, i + 1 + token.length))) { + // REPLACE is also a function and a legal bare identifier; the statement form is REPLACE INTO + if (parenDepth === 0 && (token !== "REPLACE" || nextTokenIs(text, i + 1 + token.length, "INTO"))) { writeVerb = token; } lastToken = token; @@ -266,12 +291,20 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { if (!quoted) { if (char === ")") { // a write verb can sit flush against the CTE's closing paren: "(SELECT 1)DELETE" - if (parenDepth === 0 && isWriteCommand(token)) { + if ( + parenDepth === 0 && + isWriteCommand(token) && + (token !== "REPLACE" || nextTokenIs(text, i + 1 + token.length, "INTO")) + ) { writeVerb = token; } parenDepth++; } else if (char === "(" && parenDepth > 0) { parenDepth--; + } else if (char === ";" && parenDepth === 0) { + // the reverse scan crosses statement boundaries; later statements' state must not leak into the first + writeVerb = null; + hasReturning = false; } token = char + token; } diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index 2156ca09f74f..e0adeef82376 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -963,6 +963,16 @@ describe("Query Execution", () => { const upsert = await sql`INSERT INTO gadgets VALUES (7, 2) ON CONFLICT (id) DO UPDATE SET price = 2`; expect(upsert.command).toBe("INSERT"); expect(upsert.affectedRows).toBe(1); + + // REPLACE as a bare identifier (a non-reserved keyword) is not the REPLACE command + const replaceAlias = await sql`WITH c AS (SELECT 1 AS x) SELECT x AS replace FROM c`; + expect(replaceAlias).toEqual([{ replace: 1 }]); + expect(replaceAlias.affectedRows).toBe(0); + + // a later statement's RETURNING does not leak across a statement boundary + const firstOfBatch = await sql.unsafe("WITH c AS (SELECT 1 AS x) SELECT x FROM c; DELETE FROM gadgets RETURNING id"); + expect(firstOfBatch.command).toBe("WITH"); + expect(firstOfBatch.affectedRows).toBe(0); }); test("SELECT with various clauses", async () => { From 57e6ecdc55982846d720d03a794c7b0f8ea20a9f Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:29:29 +0000 Subject: [PATCH 18/29] [autofix.ci] apply automated fixes --- test/js/sql/sqlite-sql.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index e0adeef82376..71905dc7fb36 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -970,7 +970,9 @@ describe("Query Execution", () => { expect(replaceAlias.affectedRows).toBe(0); // a later statement's RETURNING does not leak across a statement boundary - const firstOfBatch = await sql.unsafe("WITH c AS (SELECT 1 AS x) SELECT x FROM c; DELETE FROM gadgets RETURNING id"); + const firstOfBatch = await sql.unsafe( + "WITH c AS (SELECT 1 AS x) SELECT x FROM c; DELETE FROM gadgets RETURNING id", + ); expect(firstOfBatch.command).toBe("WITH"); expect(firstOfBatch.affectedRows).toBe(0); }); From 77e421385ee3d48b206dbd44a5bde3147e79fc49 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:02:47 +0000 Subject: [PATCH 19/29] sql: reset command at statement boundaries, accept quoted identifiers after INTO Also document that a PostgreSQL write inside a CTE under a top-level SELECT reports affectedRows 0 (the server tags the statement SELECT), and that a SQLite batch reports the total only when the first statement is a write. --- docs/runtime/sql.mdx | 4 ++-- packages/bun-types/sql.d.ts | 4 +++- src/js/internal/sql/sqlite.ts | 5 +++++ test/js/sql/sql-affected-rows.test.ts | 6 ++++++ test/js/sql/sqlite-sql.test.ts | 5 +++++ 5 files changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/runtime/sql.mdx b/docs/runtime/sql.mdx index 4f8cc5a441f1..2e22d41652c4 100644 --- a/docs/runtime/sql.mdx +++ b/docs/runtime/sql.mdx @@ -1409,12 +1409,12 @@ 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`. +- `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`. On PostgreSQL, a write inside a CTE under a top-level `SELECT` reports `0`: the server tags the whole statement `SELECT`. - `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. +On SQLite, a multi-statement string reports the first statement's command, and `affectedRows` reports the batch's total changes when that first statement is a write (otherwise `0`, with the total in `count`). 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 diff --git a/packages/bun-types/sql.d.ts b/packages/bun-types/sql.d.ts index 715acde82e2c..2361769d91c6 100644 --- a/packages/bun-types/sql.d.ts +++ b/packages/bun-types/sql.d.ts @@ -477,7 +477,9 @@ declare module "bun" { * 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`. + * `INSERT ... SELECT` without `RETURNING`. On PostgreSQL, a write + * inside a CTE under a top-level `SELECT` reports `0`: the server + * tags the whole statement `SELECT`. */ affectedRows: number | bigint | null; /** diff --git a/src/js/internal/sql/sqlite.ts b/src/js/internal/sql/sqlite.ts index 1b28ea61e798..264aaba56f21 100644 --- a/src/js/internal/sql/sqlite.ts +++ b/src/js/internal/sql/sqlite.ts @@ -92,6 +92,10 @@ function nextTokenIs(text: string, index: number, word: string): boolean { case "\r": case "\f": case "\v": + // a quoted identifier can follow with no space: INTO"t", INTO`t`, INTO[t] + case '"': + case "`": + case "[": return true; default: return false; @@ -305,6 +309,7 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { // the reverse scan crosses statement boundaries; later statements' state must not leak into the first writeVerb = null; hasReturning = false; + command = SQLCommand.none; } token = char + token; } diff --git a/test/js/sql/sql-affected-rows.test.ts b/test/js/sql/sql-affected-rows.test.ts index b9e9f73e89f5..ba61e8473322 100644 --- a/test/js/sql/sql-affected-rows.test.ts +++ b/test/js/sql/sql-affected-rows.test.ts @@ -35,5 +35,11 @@ describeWithContainer("postgres", { image: "postgres_plain" }, container => { const deleted = await sql`DELETE FROM ${table} WHERE id > 1`; expect(deleted.affectedRows).toBe(2); + + // a write inside a CTE under a top-level SELECT is tagged SELECT by the server + const cteDelete = await sql`WITH gone AS (DELETE FROM ${table} RETURNING id) SELECT * FROM gone`; + expect(cteDelete).toHaveLength(1); + expect(cteDelete.command).toBe("SELECT"); + expect(cteDelete.affectedRows).toBe(0); }); }); diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index 71905dc7fb36..4b18c966ab6d 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -975,6 +975,11 @@ describe("Query Execution", () => { ); expect(firstOfBatch.command).toBe("WITH"); expect(firstOfBatch.affectedRows).toBe(0); + + // a later statement's command does not leak across a statement boundary + const readFirst = await sql.unsafe("SELECT 1 AS x; INSERT INTO gadgets2 VALUES (9)"); + expect(readFirst.command).toBe("SELECT"); + expect(readFirst.affectedRows).toBe(0); }); test("SELECT with various clauses", async () => { From f0797e6cb084969bd189a9ce153de80500f2e948 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:25:29 +0000 Subject: [PATCH 20/29] sql(sqlite): track backtick and bracket identifier quotes in the token scan A semicolon or paren inside a backtick or bracket identifier no longer resets the scan state or skews the paren depth. A quote character of another kind inside a quoted span no longer flips the quote state. --- src/js/internal/sql/sqlite.ts | 19 +++++++++++++++---- test/js/sql/sqlite-sql.test.ts | 7 +++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/js/internal/sql/sqlite.ts b/src/js/internal/sql/sqlite.ts index 264aaba56f21..3e6224e09f77 100644 --- a/src/js/internal/sql/sqlite.ts +++ b/src/js/internal/sql/sqlite.ts @@ -180,7 +180,7 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { let writeVerb: string | null = null; // parentheses seen so far in the reverse scan; a statement-level write verb sits at depth 0 let parenDepth = 0; - let quoted: false | "'" | '"' = false; + let quoted: false | "'" | '"' | "`" | "]" = false; // we need to reverse search so we find the closest command to the parameter for (let i = text_len - 1; i >= 0; i--) { const char = text[i]; @@ -283,16 +283,27 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { } } default: { - // skip quoted commands - if (char === '"' || char === "'") { + // skip quoted spans: strings and quoted identifiers + if (char === '"' || char === "'" || char === "`") { if (quoted === char) { quoted = false; - } else { + } else if (!quoted) { quoted = char; } continue; } + if (quoted === "]") { + if (char === "[") { + quoted = false; + } + continue; + } if (!quoted) { + if (char === "]") { + // the reverse scan sees "]" first: it opens a bracket-quoted identifier, "[" closes it + quoted = "]"; + continue; + } if (char === ")") { // a write verb can sit flush against the CTE's closing paren: "(SELECT 1)DELETE" if ( diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index 4b18c966ab6d..d87275b56782 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -980,6 +980,13 @@ describe("Query Execution", () => { const readFirst = await sql.unsafe("SELECT 1 AS x; INSERT INTO gadgets2 VALUES (9)"); expect(readFirst.command).toBe("SELECT"); expect(readFirst.affectedRows).toBe(0); + + // a semicolon inside a bracket-quoted identifier is not a statement boundary + await sql.unsafe("CREATE TABLE weird ([a;b] INTEGER)"); + await sql.unsafe("INSERT INTO weird VALUES (1)"); + const quotedIdent = await sql.unsafe("UPDATE weird SET [a;b] = 2 WHERE [a;b] = 1 RETURNING [a;b] AS v"); + expect(quotedIdent).toEqual([{ v: 2 }]); + expect(quotedIdent.affectedRows).toBe(1); }); test("SELECT with various clauses", async () => { From eb8455fcdbd6c674ba75695b06941ae9f8cd1a8e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:54:01 +0000 Subject: [PATCH 21/29] sql(sqlite): reset canReturnRows at statement boundaries A write-first batch with a later row-returning statement routed through db.prepare(), which compiles only the first statement, and reported affectedRows null. Resetting canReturnRows at the boundary routes it through db.run(), so the whole batch executes and affectedRows carries the total. --- src/js/internal/sql/sqlite.ts | 1 + test/js/sql/sqlite-sql.test.ts | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/src/js/internal/sql/sqlite.ts b/src/js/internal/sql/sqlite.ts index 3e6224e09f77..689d879b0f6f 100644 --- a/src/js/internal/sql/sqlite.ts +++ b/src/js/internal/sql/sqlite.ts @@ -321,6 +321,7 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { writeVerb = null; hasReturning = false; command = SQLCommand.none; + canReturnRows = false; } token = char + token; } diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index d87275b56782..98266e19faa4 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -987,6 +987,11 @@ describe("Query Execution", () => { const quotedIdent = await sql.unsafe("UPDATE weird SET [a;b] = 2 WHERE [a;b] = 1 RETURNING [a;b] AS v"); expect(quotedIdent).toEqual([{ v: 2 }]); expect(quotedIdent.affectedRows).toBe(1); + + // a write-first batch runs through db.run even when a later statement reads + const writeFirst = await sql.unsafe("INSERT INTO weird VALUES (3); SELECT * FROM weird"); + expect(writeFirst.command).toBe("INSERT"); + expect(writeFirst.affectedRows).toBe(1); }); test("SELECT with various clauses", async () => { From d30940b460287860b7ad75be6e3e3759c13175bc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:33:00 +0000 Subject: [PATCH 22/29] sql(sqlite): treat a quote opener as a token boundary SQLite tokenizes UPDATE"t"SET as three tokens, but the reverse scan preserved the pending token across a quoted span, gluing the tokens on both sides together and hiding the write verb. --- src/js/internal/sql/sqlite.ts | 11 ++++++++++- test/js/sql/sqlite-sql.test.ts | 5 +++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/js/internal/sql/sqlite.ts b/src/js/internal/sql/sqlite.ts index 689d879b0f6f..2d99745ffbd9 100644 --- a/src/js/internal/sql/sqlite.ts +++ b/src/js/internal/sql/sqlite.ts @@ -283,11 +283,16 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { } } default: { - // skip quoted spans: strings and quoted identifiers + // skip quoted spans: strings and quoted identifiers. A quote is also a + // token boundary: UPDATE"t"SET is three tokens. if (char === '"' || char === "'" || char === "`") { if (quoted === char) { quoted = false; } else if (!quoted) { + if (token) { + lastToken = token; + token = ""; + } quoted = char; } continue; @@ -301,6 +306,10 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { if (!quoted) { if (char === "]") { // the reverse scan sees "]" first: it opens a bracket-quoted identifier, "[" closes it + if (token) { + lastToken = token; + token = ""; + } quoted = "]"; continue; } diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index 98266e19faa4..5326a55d8db4 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -992,6 +992,11 @@ describe("Query Execution", () => { const writeFirst = await sql.unsafe("INSERT INTO weird VALUES (3); SELECT * FROM weird"); expect(writeFirst.command).toBe("INSERT"); expect(writeFirst.affectedRows).toBe(1); + + // a quoted identifier is a token boundary even with no surrounding spaces + const noSpaces = await sql.unsafe('UPDATE"weird"SET [a;b] = 4 WHERE [a;b] = 2'); + expect(noSpaces.command).toBe("UPDATE"); + expect(noSpaces.affectedRows).toBe(1); }); test("SELECT with various clauses", async () => { From 3734e2bb222cfac189f2ef111ca96184a39b2643 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:34:43 +0000 Subject: [PATCH 23/29] sql: shorten a scanner comment --- src/js/internal/sql/sqlite.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/js/internal/sql/sqlite.ts b/src/js/internal/sql/sqlite.ts index 2d99745ffbd9..ed5f5eb37fe3 100644 --- a/src/js/internal/sql/sqlite.ts +++ b/src/js/internal/sql/sqlite.ts @@ -283,8 +283,7 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { } } default: { - // skip quoted spans: strings and quoted identifiers. A quote is also a - // token boundary: UPDATE"t"SET is three tokens. + // a quoted span is skipped and its opener is a token boundary: UPDATE"t"SET is three tokens if (char === '"' || char === "'" || char === "`") { if (quoted === char) { quoted = false; From 069157a771b18b45e05280a79e3b42558e1dcd58 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:11:30 +0000 Subject: [PATCH 24/29] sql(sqlite): classify tokens flushed at quote boundaries The quote-boundary flush only assigned lastToken, so a keyword flush against a quoted span (for example 'v'RETURNING) lost its meaning and the query routed through db.run(), dropping the returned rows. Extract the keyword switch into classifyToken and run it at every boundary. --- src/js/internal/sql/sqlite.ts | 200 ++++++++++++++++++--------------- test/js/sql/sqlite-sql.test.ts | 5 + 2 files changed, 112 insertions(+), 93 deletions(-) diff --git a/src/js/internal/sql/sqlite.ts b/src/js/internal/sql/sqlite.ts index ed5f5eb37fe3..a6c6437b5f6a 100644 --- a/src/js/internal/sql/sqlite.ts +++ b/src/js/internal/sql/sqlite.ts @@ -181,6 +181,101 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { // parentheses seen so far in the reverse scan; a statement-level write verb sits at depth 0 let parenDepth = 0; let quoted: false | "'" | '"' | "`" | "]" = false; + + /** Classifies and clears the pending token at a boundary (i = the boundary char's index). */ + function classifyToken(i: number): SQLParsedInfo | null { + switch (token) { + case "INSERT": { + if (command === SQLCommand.none) { + command = SQLCommand.insert; + } + if (parenDepth === 0) { + writeVerb = token; + } + lastToken = token; + token = ""; + if (partial) { + return { command: SQLCommand.insert, lastToken, canReturnRows, hasReturning, writeCommand: null }; + } + return null; + } + case "UPDATE": { + if (command === SQLCommand.none) { + command = SQLCommand.update; + } + if (parenDepth === 0) { + writeVerb = token; + } + lastToken = token; + token = ""; + if (partial) { + return { command: SQLCommand.update, lastToken, canReturnRows, hasReturning, writeCommand: null }; + } + return null; + } + case "DELETE": + case "REPLACE": { + // REPLACE is also a function and a legal bare identifier; the statement form is REPLACE INTO + if (parenDepth === 0 && (token !== "REPLACE" || nextTokenIs(text, i + 1 + token.length, "INTO"))) { + writeVerb = token; + } + lastToken = token; + token = ""; + return null; + } + case "WHERE": { + if (command === SQLCommand.none) { + command = SQLCommand.where; + } + lastToken = token; + token = ""; + if (partial) { + return { command: SQLCommand.where, lastToken, canReturnRows, hasReturning, writeCommand: null }; + } + return null; + } + case "SET": { + if (command === SQLCommand.none) { + command = SQLCommand.updateSet; + } + lastToken = token; + token = ""; + if (partial) { + return { command: SQLCommand.updateSet, lastToken, canReturnRows, hasReturning, writeCommand: null }; + } + return null; + } + case "IN": { + if (command === SQLCommand.none) { + command = SQLCommand.in; + } + lastToken = token; + token = ""; + if (partial) { + return { command: SQLCommand.in, lastToken, canReturnRows, hasReturning, writeCommand: null }; + } + return null; + } + case "RETURNING": + hasReturning = true; + // fallthrough + case "SELECT": + case "PRAGMA": + case "WITH": + case "EXPLAIN": { + lastToken = token; + canReturnRows = true; + token = ""; + return null; + } + default: { + lastToken = token; + token = ""; + return null; + } + } + } + // we need to reverse search so we find the closest command to the parameter for (let i = text_len - 1; i >= 0; i--) { const char = text[i]; @@ -191,96 +286,11 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { case "\r": case "\f": case "\v": { - switch (token) { - case "INSERT": { - if (command === SQLCommand.none) { - command = SQLCommand.insert; - } - if (parenDepth === 0) { - writeVerb = token; - } - lastToken = token; - token = ""; - if (partial) { - return { command: SQLCommand.insert, lastToken, canReturnRows, hasReturning, writeCommand: null }; - } - continue; - } - case "UPDATE": { - if (command === SQLCommand.none) { - command = SQLCommand.update; - } - if (parenDepth === 0) { - writeVerb = token; - } - lastToken = token; - token = ""; - if (partial) { - return { command: SQLCommand.update, lastToken, canReturnRows, hasReturning, writeCommand: null }; - } - continue; - } - case "DELETE": - case "REPLACE": { - // REPLACE is also a function and a legal bare identifier; the statement form is REPLACE INTO - if (parenDepth === 0 && (token !== "REPLACE" || nextTokenIs(text, i + 1 + token.length, "INTO"))) { - writeVerb = token; - } - lastToken = token; - token = ""; - continue; - } - case "WHERE": { - if (command === SQLCommand.none) { - command = SQLCommand.where; - } - lastToken = token; - token = ""; - if (partial) { - return { command: SQLCommand.where, lastToken, canReturnRows, hasReturning, writeCommand: null }; - } - continue; - } - case "SET": { - if (command === SQLCommand.none) { - command = SQLCommand.updateSet; - } - lastToken = token; - token = ""; - if (partial) { - return { command: SQLCommand.updateSet, lastToken, canReturnRows, hasReturning, writeCommand: null }; - } - continue; - } - case "IN": { - if (command === SQLCommand.none) { - command = SQLCommand.in; - } - lastToken = token; - token = ""; - if (partial) { - return { command: SQLCommand.in, lastToken, canReturnRows, hasReturning, writeCommand: null }; - } - continue; - } - case "RETURNING": - hasReturning = true; - // fallthrough - case "SELECT": - case "PRAGMA": - case "WITH": - case "EXPLAIN": { - lastToken = token; - canReturnRows = true; - token = ""; - continue; - } - default: { - lastToken = token; - token = ""; - continue; - } + const early = classifyToken(i); + if (early) { + return early; } + continue; } default: { // a quoted span is skipped and its opener is a token boundary: UPDATE"t"SET is three tokens @@ -289,8 +299,10 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { quoted = false; } else if (!quoted) { if (token) { - lastToken = token; - token = ""; + const early = classifyToken(i); + if (early) { + return early; + } } quoted = char; } @@ -306,8 +318,10 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { if (char === "]") { // the reverse scan sees "]" first: it opens a bracket-quoted identifier, "[" closes it if (token) { - lastToken = token; - token = ""; + const early = classifyToken(i); + if (early) { + return early; + } } quoted = "]"; continue; diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index 5326a55d8db4..b843913a8725 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -997,6 +997,11 @@ describe("Query Execution", () => { const noSpaces = await sql.unsafe('UPDATE"weird"SET [a;b] = 4 WHERE [a;b] = 2'); expect(noSpaces.command).toBe("UPDATE"); expect(noSpaces.affectedRows).toBe(1); + + // a keyword flush against a quoted span keeps its meaning + const afterQuote = await sql.unsafe("DELETE FROM weird WHERE [a;b] = 4 AND 'x' = 'x'RETURNING [a;b] AS v"); + expect(afterQuote).toEqual([{ v: 4 }]); + expect(afterQuote.affectedRows).toBe(1); }); test("SELECT with various clauses", async () => { From 95a439f49cc94dba7b07a97756ac1ae50dbfdab4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:48:21 +0000 Subject: [PATCH 25/29] sql(sqlite): classify tokens at the closing paren, accept a single-quoted name after INTO The closing paren is the last token boundary that did not classify its pending token, so VALUES(1)RETURNING dropped the returned rows. Route it through classifyToken, which also subsumes the ad-hoc write-verb check. SQLite also accepts a single-quoted table name, so INTO't' counts as the REPLACE statement form. --- src/js/internal/sql/sqlite.ts | 16 ++++++++-------- test/js/sql/sqlite-sql.test.ts | 10 ++++++++++ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/js/internal/sql/sqlite.ts b/src/js/internal/sql/sqlite.ts index a6c6437b5f6a..54b16775bbf9 100644 --- a/src/js/internal/sql/sqlite.ts +++ b/src/js/internal/sql/sqlite.ts @@ -92,10 +92,11 @@ function nextTokenIs(text: string, index: number, word: string): boolean { case "\r": case "\f": case "\v": - // a quoted identifier can follow with no space: INTO"t", INTO`t`, INTO[t] + // a quoted identifier can follow with no space: INTO"t", INTO`t`, INTO[t], INTO't' case '"': case "`": case "[": + case "'": return true; default: return false; @@ -327,13 +328,12 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { continue; } if (char === ")") { - // a write verb can sit flush against the CTE's closing paren: "(SELECT 1)DELETE" - if ( - parenDepth === 0 && - isWriteCommand(token) && - (token !== "REPLACE" || nextTokenIs(text, i + 1 + token.length, "INTO")) - ) { - writeVerb = token; + // a token can sit flush against the closing paren: "(SELECT 1)DELETE", "VALUES(1)RETURNING" + if (token) { + const early = classifyToken(i); + if (early) { + return early; + } } parenDepth++; } else if (char === "(" && parenDepth > 0) { diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index b843913a8725..0c56f15b93d1 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -1002,6 +1002,16 @@ describe("Query Execution", () => { const afterQuote = await sql.unsafe("DELETE FROM weird WHERE [a;b] = 4 AND 'x' = 'x'RETURNING [a;b] AS v"); expect(afterQuote).toEqual([{ v: 4 }]); expect(afterQuote.affectedRows).toBe(1); + + // a keyword flush against a closing paren keeps its meaning + const parenReturning = await sql.unsafe("INSERT INTO weird VALUES (7)RETURNING [a;b] AS v"); + expect(parenReturning).toEqual([{ v: 7 }]); + expect(parenReturning.affectedRows).toBe(1); + + // a single-quoted table name can follow INTO with no space + const quotedInto = await sql.unsafe("WITH c AS (SELECT 1) REPLACE INTO'weird' ([a;b]) VALUES (9)"); + expect(quotedInto.command).toBe("REPLACE"); + expect(quotedInto.affectedRows).toBeNull(); }); test("SELECT with various clauses", async () => { From 4cec6e2056f975bda3ed7dea4f0e7ddecce29f5a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:14:45 +0000 Subject: [PATCH 26/29] sql(sqlite): treat the opening paren as a token boundary RETURNING(id) glued the keyword to the paren content and dropped the returned rows. The opening paren now discards the accumulated paren content, so the keyword to its left classifies at its own boundary. The content stays unclassified, so a subquery's SELECT still does not change the routing. --- src/js/internal/sql/sqlite.ts | 4 ++++ test/js/sql/sqlite-sql.test.ts | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/src/js/internal/sql/sqlite.ts b/src/js/internal/sql/sqlite.ts index 54b16775bbf9..48ef8d59c9fb 100644 --- a/src/js/internal/sql/sqlite.ts +++ b/src/js/internal/sql/sqlite.ts @@ -337,7 +337,11 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { } parenDepth++; } else if (char === "(" && parenDepth > 0) { + // "(" is a token boundary: discard the paren content so "RETURNING(id)" classifies. + // The content is not classified, so a subquery's SELECT does not set canReturnRows. parenDepth--; + token = ""; + continue; } else if (char === ";" && parenDepth === 0) { // the reverse scan crosses statement boundaries; later statements' state must not leak into the first writeVerb = null; diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index 0c56f15b93d1..f8bf28c55eb5 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -1012,6 +1012,11 @@ describe("Query Execution", () => { const quotedInto = await sql.unsafe("WITH c AS (SELECT 1) REPLACE INTO'weird' ([a;b]) VALUES (9)"); expect(quotedInto.command).toBe("REPLACE"); expect(quotedInto.affectedRows).toBeNull(); + + // a keyword flush against an opening paren keeps its meaning + const parenLeft = await sql.unsafe("DELETE FROM weird WHERE [a;b] = 9 RETURNING([a;b]) AS v"); + expect(parenLeft).toEqual([{ v: 9 }]); + expect(parenLeft.affectedRows).toBe(1); }); test("SELECT with various clauses", async () => { From ed3c09a9987dfe5239aa3133bfc891c1c1a5f19e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:16:42 +0000 Subject: [PATCH 27/29] sql: shorten a scanner comment --- src/js/internal/sql/sqlite.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/js/internal/sql/sqlite.ts b/src/js/internal/sql/sqlite.ts index 48ef8d59c9fb..87269a949102 100644 --- a/src/js/internal/sql/sqlite.ts +++ b/src/js/internal/sql/sqlite.ts @@ -337,8 +337,7 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { } parenDepth++; } else if (char === "(" && parenDepth > 0) { - // "(" is a token boundary: discard the paren content so "RETURNING(id)" classifies. - // The content is not classified, so a subquery's SELECT does not set canReturnRows. + // discard unclassified paren content: "RETURNING(id)" classifies, a subquery's SELECT does not route parenDepth--; token = ""; continue; From 11b06ad073c3f0c89dc339e4f0d9fa1ace1b5cfa Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:38:54 +0000 Subject: [PATCH 28/29] sql: drop an over-general claim from the multi-statement docs count carries the batch total only when the first statement neither returns rows nor writes; a row-returning first statement reports its own row count. --- docs/runtime/sql.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/runtime/sql.mdx b/docs/runtime/sql.mdx index 2e22d41652c4..481c7e46ef89 100644 --- a/docs/runtime/sql.mdx +++ b/docs/runtime/sql.mdx @@ -1414,7 +1414,7 @@ console.log(result.affectedRows); // rows updated, on every adapter - `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 `affectedRows` reports the batch's total changes when that first statement is a write (otherwise `0`, with the total in `count`). 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. +On SQLite, a multi-statement string reports the first statement's command, and `affectedRows` reports the batch's total changes when that first statement is a write (otherwise `0`). 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 From f973e3391978c6d1cc5e6366217129bc89244f07 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:07:33 +0000 Subject: [PATCH 29/29] sql(sqlite): gate canReturnRows on parenthesis depth A spaced subquery ( SELECT ... ) inside a write routed the statement through the row-returning path, so UPDATE t SET x = ( SELECT 1 ) reported affectedRows null while the glued form reported the count. The SELECT arm of classifyToken now only routes at depth zero. RETURNING stays ungated: it cannot appear inside parens. --- src/js/internal/sql/sqlite.ts | 14 +++++++++++--- test/js/sql/sqlite-sql.test.ts | 9 +++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/js/internal/sql/sqlite.ts b/src/js/internal/sql/sqlite.ts index 87269a949102..62a40d500f39 100644 --- a/src/js/internal/sql/sqlite.ts +++ b/src/js/internal/sql/sqlite.ts @@ -257,15 +257,23 @@ function parseSQLQuery(query: string, partial: boolean = false): SQLParsedInfo { } return null; } - case "RETURNING": + case "RETURNING": { + // RETURNING cannot appear inside parens, so no depth gate hasReturning = true; - // fallthrough + lastToken = token; + canReturnRows = true; + token = ""; + return null; + } case "SELECT": case "PRAGMA": case "WITH": case "EXPLAIN": { lastToken = token; - canReturnRows = true; + // a subquery's SELECT must not route the outer write through the row-returning path + if (parenDepth === 0) { + canReturnRows = true; + } token = ""; return null; } diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index f8bf28c55eb5..9404cc10df0c 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -1017,6 +1017,15 @@ describe("Query Execution", () => { const parenLeft = await sql.unsafe("DELETE FROM weird WHERE [a;b] = 9 RETURNING([a;b]) AS v"); expect(parenLeft).toEqual([{ v: 9 }]); expect(parenLeft.affectedRows).toBe(1); + + // a spaced subquery does not route the write through the row-returning path + const spacedSubquery = await sql.unsafe("UPDATE weird SET [a;b] = ( SELECT 21 ) WHERE [a;b] = 7"); + expect(spacedSubquery.command).toBe("UPDATE"); + expect(spacedSubquery.affectedRows).toBe(1); + + const spacedIn = await sql.unsafe("DELETE FROM weird WHERE [a;b] IN ( SELECT 21 )"); + expect(spacedIn.command).toBe("DELETE"); + expect(spacedIn.affectedRows).toBe(1); }); test("SELECT with various clauses", async () => {