diff --git a/docs/runtime/sql.mdx b/docs/runtime/sql.mdx index 1d3b727daa22..481c7e46ef89 100644 --- a/docs/runtime/sql.mdx +++ b/docs/runtime/sql.mdx @@ -1398,6 +1398,24 @@ We also haven't implemented some of the more uncommon features like: ## Common Patterns & Best Practices +### Result metadata + +Every query resolves to an array of rows with four extra non-enumerable properties: `command`, `count`, `affectedRows`, and `lastInsertRowid`. `console.log` does not print them, but they are there. + +To read how many rows a write changed, use `affectedRows`. It reports the same number on every adapter: + +```ts +const result = await sql`UPDATE users SET active = ${false} WHERE age < ${18}`; +console.log(result.affectedRows); // rows updated, on every adapter +``` + +- `affectedRows`: the number of rows an `INSERT`, `UPDATE`, `DELETE`, `REPLACE` (SQLite and MySQL), or `MERGE` (PostgreSQL) changed. `0` for statements that do not write. On SQLite, `null` when the count is unknown, for example a write wrapped in a CTE or an `INSERT ... SELECT` without `RETURNING`. 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 `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 ```ts diff --git a/packages/bun-types/sql.d.ts b/packages/bun-types/sql.d.ts index 86c7ec2fb7fa..2361769d91c6 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 + * 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`. 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; + /** + * 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..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..62a40d500f39 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,112 @@ function commandToString(command: SQLCommand, lastToken?: string): string { } } +/** 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": + case "\t": + case "\r": + case "\f": + case "\v": + i++; + continue; + } + 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": + // 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; + } +} + +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,14 +169,122 @@ 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 quoted: false | "'" | '"' = 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; + + /** 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": { + // RETURNING cannot appear inside parens, so no depth gate + hasReturning = true; + lastToken = token; + canReturnRows = true; + token = ""; + return null; + } + case "SELECT": + case "PRAGMA": + case "WITH": + case "EXPLAIN": { + lastToken = token; + // a subquery's SELECT must not route the outer write through the row-returning path + if (parenDepth === 0) { + 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]; @@ -78,90 +295,67 @@ 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; - } - lastToken = token; - token = ""; - if (partial) { - return { command: SQLCommand.insert, lastToken, canReturnRows }; - } - continue; - } - case "UPDATE": { - if (command === SQLCommand.none) { - command = SQLCommand.update; - } - lastToken = token; - token = ""; - if (partial) { - return { command: SQLCommand.update, lastToken, canReturnRows }; - } - continue; - } - case "WHERE": { - if (command === SQLCommand.none) { - command = SQLCommand.where; - } - lastToken = token; - token = ""; - if (partial) { - return { command: SQLCommand.where, lastToken, canReturnRows }; - } - continue; - } - case "SET": { - if (command === SQLCommand.none) { - command = SQLCommand.updateSet; - } - lastToken = token; - token = ""; - if (partial) { - return { command: SQLCommand.updateSet, lastToken, canReturnRows }; - } - continue; - } - case "IN": { - if (command === SQLCommand.none) { - command = SQLCommand.in; - } - lastToken = token; - token = ""; - if (partial) { - return { command: SQLCommand.in, lastToken, canReturnRows }; - } - continue; - } - case "SELECT": - case "PRAGMA": - case "WITH": - case "EXPLAIN": - case "RETURNING": { - lastToken = token; - canReturnRows = true; - token = ""; - continue; - } - default: { - lastToken = token; - token = ""; - continue; - } + const early = classifyToken(i); + if (early) { + return early; } + continue; } default: { - // skip quoted commands - if (char === '"' || char === "'") { + // 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; - } else { + } else if (!quoted) { + if (token) { + const early = classifyToken(i); + if (early) { + return early; + } + } 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 + if (token) { + const early = classifyToken(i); + if (early) { + return early; + } + } + quoted = "]"; + continue; + } + if (char === ")") { + // 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) { + // discard unclassified paren content: "RETURNING(id)" classifies, a subquery's SELECT does not route + 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; + hasReturning = false; + command = SQLCommand.none; + canReturnRows = false; + } token = char + token; } } @@ -193,11 +387,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 +402,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 +463,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 +480,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; + // 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); } 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..ba61e8473322 --- /dev/null +++ b/test/js/sql/sql-affected-rows.test.ts @@ -0,0 +1,45 @@ +// `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); + + // 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 6e345cadae84..9404cc10df0c 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -872,6 +872,162 @@ 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); + + // 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); + + // 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); + + // 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 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 WHERE 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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); + + // 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(); + + // 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); + + // 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 () => { await sql`CREATE TABLE scores (id INTEGER, player TEXT, score INTEGER, team TEXT)`; await sql`INSERT INTO scores VALUES