From fbecbc212bf68c851dd088abbd9a6b7d6a193bf4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:28:31 +0000 Subject: [PATCH 01/11] sql: support named parameters in SQLite unsafe() queries Bun.SQL's unsafe() only bound array values. Passing an object of named parameters to a SELECT silently returned no rows: $apply treats the object as an empty array-like, so the named placeholders stayed unbound and SQLite compared against NULL. Detect object bindings in SQLiteQueryHandle and pass them as a single argument to the underlying bun:sqlite statement methods (stmt.all, stmt.values, stmt.raw, db.run), which already support :name, $name, and @name placeholders. Array values spread into positional arguments as before. Fixes #22799 Co-authored-by: Savan Nahar --- docs/runtime/sql.mdx | 6 +++ packages/bun-types/sql.d.ts | 12 +++-- src/js/internal/sql/sqlite.ts | 19 +++++--- test/js/sql/sqlite-sql.test.ts | 84 ++++++++++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 10 deletions(-) diff --git a/docs/runtime/sql.mdx b/docs/runtime/sql.mdx index cdd16725b61a..d9585f503054 100644 --- a/docs/runtime/sql.mdx +++ b/docs/runtime/sql.mdx @@ -437,6 +437,12 @@ const result = await sql.unsafe(` const result = await sql.unsafe("SELECT " + dangerous + " FROM users WHERE id = $1", [id]); ``` +With the SQLite adapter, parameters can also be an object of named parameters using `:name`, `$name`, or `@name` placeholders: + +```ts +const result = await sql.unsafe("SELECT * FROM users WHERE id = :id", { id: 1 }); +``` + ### Execute and Cancelling Queries Queries are lazy: they only start executing when awaited or run with `.execute()`. diff --git a/packages/bun-types/sql.d.ts b/packages/bun-types/sql.d.ts index 3e52469654a9..bba00d00e213 100644 --- a/packages/bun-types/sql.d.ts +++ b/packages/bun-types/sql.d.ts @@ -935,22 +935,28 @@ declare module "bun" { * * `sql.unsafe` can be nested inside a safe `sql` expression, for example * when only part of the query is unsafe. + * + * With the SQLite adapter, `values` may also be an object of named + * parameters (`:name`, `$name`, or `@name` placeholders). * @example * ```ts * const result = await sql.unsafe(`select ${danger} from users where id = ${dragons}`) + * const row = await sql.unsafe("select * from users where id = :id", { id: 1 }) * ``` */ - unsafe(string: string, values?: any[]): SQL.Query; + unsafe(string: string, values?: any[] | Record): SQL.Query; /** * Reads a file and runs its contents as a query. - * Pass `values` if the file uses positional parameters (`$1`, `$2`, ...) + * Pass `values` if the file uses positional parameters (`$1`, `$2`, ...). + * With the SQLite adapter, `values` may also be an object of named + * parameters (`:name`, `$name`, or `@name` placeholders). * @example * ```ts * const result = await sql.file("query.sql", [1, 2, 3]); * ``` */ - file(filename: string, values?: any[]): SQL.Query; + file(filename: string, values?: any[] | Record): SQL.Query; } /** diff --git a/src/js/internal/sql/sqlite.ts b/src/js/internal/sql/sqlite.ts index 236b2bce4e23..def8b11220db 100644 --- a/src/js/internal/sql/sqlite.ts +++ b/src/js/internal/sql/sqlite.ts @@ -213,10 +213,10 @@ class SQLiteQueryHandle implements BaseQueryHandle { private mode = SQLQueryResultMode.objects; private readonly sql: string; - private readonly values: unknown[]; + private readonly values: unknown[] | Record; private readonly parsedInfo: SQLParsedInfo; - public constructor(sql: string, values: unknown[]) { + public constructor(sql: string, values: unknown[] | Record) { this.sql = sql; this.values = values; // Parse the SQL query once when creating the handle @@ -245,12 +245,14 @@ class SQLiteQueryHandle implements BaseQueryHandle { const stmt = db.prepare(sql); let result: unknown[] | undefined; + // Arrays are positional parameters and spread into arguments; a plain + // object is a single named-parameters binding (:name, $name, @name). if (mode === SQLQueryResultMode.values) { - result = stmt.values.$apply(stmt, values); + result = $isArray(values) ? stmt.values.$apply(stmt, values) : stmt.values.$call(stmt, values); } else if (mode === SQLQueryResultMode.raw) { - result = stmt.raw.$apply(stmt, values); + result = $isArray(values) ? stmt.raw.$apply(stmt, values) : stmt.raw.$call(stmt, values); } else { - result = stmt.all.$apply(stmt, values); + result = $isArray(values) ? stmt.all.$apply(stmt, values) : stmt.all.$call(stmt, values); } const sqlResult = $isArray(result) ? new SQLResultArray(result) : new SQLResultArray([result]); @@ -262,7 +264,7 @@ class SQLiteQueryHandle implements BaseQueryHandle { query.resolve(sqlResult); } else { // For INSERT/UPDATE/DELETE/CREATE etc., use db.run() which handles multiple statements natively - const changes = db.run.$apply(db, [sql].concat(values)); + const changes = $isArray(values) ? db.run.$apply(db, [sql].concat(values)) : db.run.$call(db, sql, values); const sqlResult = new SQLResultArray(); sqlResult.command = commandToString(command, parsedInfo.lastToken); @@ -350,7 +352,10 @@ class SQLiteAdapter implements DatabaseAdapter | undefined | null = [], + ): SQLiteQueryHandle { return new SQLiteQueryHandle(sql, values ?? []); } escapeIdentifier(str: string) { diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index 1a34b8264d61..054f1f31d18d 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -1356,6 +1356,90 @@ describe("SQL helpers", () => { expect(results[0].value).toBe("test"); }); + test("unsafe with named parameters (strict mode)", async () => { + await using strictSql = new SQL({ adapter: "sqlite", filename: ":memory:", strict: true }); + await strictSql`CREATE TABLE named_test (id INTEGER, name TEXT, age INTEGER)`; + + const insert = await strictSql.unsafe("INSERT INTO named_test VALUES (:id, :name, :age)", { + id: 1, + name: "Alice", + age: 30, + }); + expect(insert.count).toBe(1); + + const results = await strictSql.unsafe("SELECT * FROM named_test WHERE name = :name", { name: "Alice" }); + expect(results).toEqual([{ id: 1, name: "Alice", age: 30 }]); + }); + + test("unsafe with named parameters using different prefixes", async () => { + await using strictSql = new SQL({ adapter: "sqlite", filename: ":memory:", strict: true }); + await strictSql`CREATE TABLE prefix_test (col TEXT)`; + + await strictSql.unsafe("INSERT INTO prefix_test VALUES (:value)", { value: "colon" }); + await strictSql.unsafe("INSERT INTO prefix_test VALUES ($value)", { value: "dollar" }); + await strictSql.unsafe("INSERT INTO prefix_test VALUES (@value)", { value: "at" }); + + const results = await strictSql.unsafe("SELECT * FROM prefix_test WHERE col = :wanted", { wanted: "dollar" }); + expect(results).toEqual([{ col: "dollar" }]); + + const all = await strictSql.unsafe("SELECT * FROM prefix_test"); + expect(all.map(r => r.col).sort()).toEqual(["at", "colon", "dollar"]); + }); + + test("unsafe with named parameters without strict mode (requires prefix in keys)", async () => { + await using defaultSql = new SQL({ adapter: "sqlite", filename: ":memory:" }); + await defaultSql`CREATE TABLE default_named_test (id INTEGER, name TEXT)`; + + await defaultSql.unsafe("INSERT INTO default_named_test VALUES (:id, :name)", { ":id": 1, ":name": "Bob" }); + + const results = await defaultSql.unsafe("SELECT * FROM default_named_test WHERE id = :id_param", { + ":id_param": 1, + }); + expect(results).toEqual([{ id: 1, name: "Bob" }]); + }); + + test("unsafe with named parameters supports values() and raw() modes", async () => { + await using strictSql = new SQL({ adapter: "sqlite", filename: ":memory:", strict: true }); + await strictSql`CREATE TABLE named_modes_test (id INTEGER, name TEXT)`; + await strictSql.unsafe("INSERT INTO named_modes_test VALUES (:id, :name)", { id: 7, name: "Carol" }); + + const values = await strictSql.unsafe("SELECT id, name FROM named_modes_test WHERE id = :id", { id: 7 }).values(); + expect(values).toEqual([[7, "Carol"]]); + + const raw = await strictSql.unsafe("SELECT id FROM named_modes_test WHERE id = :id", { id: 7 }).raw(); + expect(raw).toHaveLength(1); + expect(raw[0]).toHaveLength(1); + }); + + test("unsafe with named parameters for UPDATE and DELETE", async () => { + await using strictSql = new SQL({ adapter: "sqlite", filename: ":memory:", strict: true }); + await strictSql`CREATE TABLE named_update_test (id INTEGER, name TEXT)`; + await strictSql.unsafe("INSERT INTO named_update_test VALUES (:id, :name)", { id: 1, name: "before" }); + + const update = await strictSql.unsafe("UPDATE named_update_test SET name = :name WHERE id = :id", { + id: 1, + name: "after", + }); + expect(update.count).toBe(1); + + const results = await strictSql.unsafe("SELECT name FROM named_update_test WHERE id = :id", { id: 1 }); + expect(results).toEqual([{ name: "after" }]); + + const del = await strictSql.unsafe("DELETE FROM named_update_test WHERE id = :id", { id: 1 }); + expect(del.count).toBe(1); + }); + + test("unsafe with named parameters inside a transaction", async () => { + await using strictSql = new SQL({ adapter: "sqlite", filename: ":memory:", strict: true }); + await strictSql`CREATE TABLE named_tx_test (id INTEGER, name TEXT)`; + + const rows = await strictSql.begin(async tx => { + await tx.unsafe("INSERT INTO named_tx_test VALUES (:id, :name)", { id: 1, name: "tx" }); + return await tx.unsafe("SELECT * FROM named_tx_test WHERE id = :id", { id: 1 }); + }); + expect(rows).toEqual([{ id: 1, name: "tx" }]); + }); + test("insert into with select helper using where IN", async () => { const random_name = "test_" + randomUUIDv7("hex").replaceAll("-", ""); await sql`CREATE TEMPORARY TABLE ${sql(random_name)} (id int, name text, age int)`; From e1be3eecf88b8a01f5199ba88f1cefbd6d1e0d6f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:38:50 +0000 Subject: [PATCH 02/11] test: allow 60s for the 10k-insert statement finalization test 10,000 awaited inserts take 13-17s under a debug ASAN build, past the 5s default per-test timeout. The test is unchanged otherwise. --- 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 054f1f31d18d..8a0e9065cc91 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -2119,7 +2119,8 @@ describe("Memory and resource management", () => { expect(finalCount[0].count).toBe(iterations - 300); await sql.close(); - }); + // 10k awaited inserts need more than the 5s default under debug + ASAN + }, 60_000); test("handles many concurrent prepared statements", async () => { const sql = new SQL("sqlite://:memory:"); From b4f21c5a7a8caccfcd86792dbe983708bb97e00f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:41:19 +0000 Subject: [PATCH 03/11] Tighten comment on named-parameter binding --- 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 def8b11220db..5a9133bdfb1a 100644 --- a/src/js/internal/sql/sqlite.ts +++ b/src/js/internal/sql/sqlite.ts @@ -245,8 +245,7 @@ class SQLiteQueryHandle implements BaseQueryHandle { const stmt = db.prepare(sql); let result: unknown[] | undefined; - // Arrays are positional parameters and spread into arguments; a plain - // object is a single named-parameters binding (:name, $name, @name). + // Named-parameter objects need $call: $apply would treat them as an empty array-like. if (mode === SQLQueryResultMode.values) { result = $isArray(values) ? stmt.values.$apply(stmt, values) : stmt.values.$call(stmt, values); } else if (mode === SQLQueryResultMode.raw) { From 8d4d4ea1c5b2a3ba3bb99fcda26f0d5040cf831b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:47:04 +0000 Subject: [PATCH 04/11] Document strict-mode key prefixes and cover sql.file() with named parameters The docs and JSDoc examples used bare object keys, which only bind under strict: true; the default connection needs prefixed keys like ":id". Also adds a test for sql.file() with a named-parameters object since its signature was widened too. --- docs/runtime/sql.mdx | 3 ++- packages/bun-types/sql.d.ts | 8 +++++--- test/js/sql/sqlite-sql.test.ts | 13 +++++++++++++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/docs/runtime/sql.mdx b/docs/runtime/sql.mdx index d9585f503054..3ea03e8b6e8b 100644 --- a/docs/runtime/sql.mdx +++ b/docs/runtime/sql.mdx @@ -437,9 +437,10 @@ const result = await sql.unsafe(` const result = await sql.unsafe("SELECT " + dangerous + " FROM users WHERE id = $1", [id]); ``` -With the SQLite adapter, parameters can also be an object of named parameters using `:name`, `$name`, or `@name` placeholders: +With the SQLite adapter, parameters can also be an object of named parameters using `:name`, `$name`, or `@name` placeholders. Object keys keep the prefix (`{ ":id": 1 }`) unless the connection sets `strict: true`, which allows bare keys: ```ts +const sql = new SQL({ adapter: "sqlite", filename: "myapp.db", strict: true }); const result = await sql.unsafe("SELECT * FROM users WHERE id = :id", { id: 1 }); ``` diff --git a/packages/bun-types/sql.d.ts b/packages/bun-types/sql.d.ts index bba00d00e213..f0761a239f7e 100644 --- a/packages/bun-types/sql.d.ts +++ b/packages/bun-types/sql.d.ts @@ -937,11 +937,12 @@ declare module "bun" { * when only part of the query is unsafe. * * With the SQLite adapter, `values` may also be an object of named - * parameters (`:name`, `$name`, or `@name` placeholders). + * parameters (`:name`, `$name`, or `@name` placeholders). Object keys + * keep the prefix unless the connection sets `strict: true`. * @example * ```ts * const result = await sql.unsafe(`select ${danger} from users where id = ${dragons}`) - * const row = await sql.unsafe("select * from users where id = :id", { id: 1 }) + * const row = await sql.unsafe("select * from users where id = :id", { ":id": 1 }) * ``` */ unsafe(string: string, values?: any[] | Record): SQL.Query; @@ -950,7 +951,8 @@ declare module "bun" { * Reads a file and runs its contents as a query. * Pass `values` if the file uses positional parameters (`$1`, `$2`, ...). * With the SQLite adapter, `values` may also be an object of named - * parameters (`:name`, `$name`, or `@name` placeholders). + * parameters (`:name`, `$name`, or `@name` placeholders); keys keep the + * prefix unless the connection sets `strict: true`. * @example * ```ts * const result = await sql.file("query.sql", [1, 2, 3]); diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index 8a0e9065cc91..b3c0768df2d3 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -1591,6 +1591,19 @@ describe("SQL helpers", () => { expect(result[0].param1).toBe("value1"); expect(result[0].param2).toBe("value2"); }); + + test("file with named parameters", async () => { + await using strictSql = new SQL({ adapter: "sqlite", filename: ":memory:", strict: true }); + await strictSql`CREATE TABLE file_named_test (id INTEGER, name TEXT)`; + await strictSql.unsafe("INSERT INTO file_named_test VALUES (:id, :name)", { id: 1, name: "Alice" }); + + await using dir = tempDir("sql-file-named", { + "query.sql": `SELECT * FROM file_named_test WHERE name = :name`, + }); + + const result = await strictSql.file(path.join(dir, "query.sql"), { name: "Alice" }); + expect(result).toEqual([{ id: 1, name: "Alice" }]); + }); }); describe("Helper argument validation", () => { From fa9f9bd785b5851117467d1799ca0640d2ddf774 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:58:43 +0000 Subject: [PATCH 05/11] test: shrink statement finalization loop instead of raising its timeout Per-test timeouts are disallowed in test/CLAUDE.md; 1000 serial awaited inserts still exercise statement finalization and fit the default timeout on a debug ASAN build. --- test/js/sql/sqlite-sql.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index b3c0768df2d3..b9b923bbd5ab 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -2111,7 +2111,9 @@ describe("Memory and resource management", () => { await sql`CREATE TABLE stmt_test (id INTEGER PRIMARY KEY, value TEXT)`; - const iterations = 10000; + // Small enough that the serial awaited inserts stay under the default + // timeout on a debug + ASAN build. + const iterations = 1000; for (let i = 0; i < iterations; i++) { await sql`INSERT INTO stmt_test (id, value) VALUES (${i}, ${"test" + i})`; @@ -2132,8 +2134,7 @@ describe("Memory and resource management", () => { expect(finalCount[0].count).toBe(iterations - 300); await sql.close(); - // 10k awaited inserts need more than the 5s default under debug + ASAN - }, 60_000); + }); test("handles many concurrent prepared statements", async () => { const sql = new SQL("sqlite://:memory:"); From 668adf1fe6c1e657c2d999767190502d96d22699 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:52:19 +0000 Subject: [PATCH 06/11] ci: retrigger From 9496f0af5554abf7f99ca3fd4cf98d5f93cd8b2f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:47:57 +0000 Subject: [PATCH 07/11] test: keep the 10k finalization workload on release builds Shrink to 1000 iterations only when isDebug, per review. --- test/js/sql/sqlite-sql.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index b9b923bbd5ab..88267bae8338 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -1,6 +1,6 @@ import { randomUUIDv7, SQL } from "bun"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"; -import { tempDir } from "harness"; +import { isDebug, tempDir } from "harness"; import { existsSync } from "node:fs"; import { rm, stat } from "node:fs/promises"; import { join } from "node:path"; @@ -2111,9 +2111,9 @@ describe("Memory and resource management", () => { await sql`CREATE TABLE stmt_test (id INTEGER PRIMARY KEY, value TEXT)`; - // Small enough that the serial awaited inserts stay under the default - // timeout on a debug + ASAN build. - const iterations = 1000; + // Debug builds run the serial awaited inserts 10-100x slower, so use a + // smaller workload there to stay under the default timeout. + const iterations = isDebug ? 1000 : 10000; for (let i = 0; i < iterations; i++) { await sql`INSERT INTO stmt_test (id, value) VALUES (${i}, ${"test" + i})`; From 69ce6814549e6752c7d6507d631c51c3d5c1c38e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:52:33 +0000 Subject: [PATCH 08/11] Cover all prefix forms in the non-strict test and mention named parameters in the sql.file docs --- docs/runtime/sql.mdx | 2 ++ test/js/sql/sqlite-sql.test.ts | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/runtime/sql.mdx b/docs/runtime/sql.mdx index 3ea03e8b6e8b..ddef2b99e6f5 100644 --- a/docs/runtime/sql.mdx +++ b/docs/runtime/sql.mdx @@ -422,6 +422,8 @@ Simple queries cannot use parameters (`${value}`). If you need parameters, split const result = await sql.file("query.sql", [1, 2, 3]); ``` +With the SQLite adapter, parameters can also be an object of named parameters (`:name`, `$name`, or `@name` placeholders), the same form `sql.unsafe` accepts below. + ### Unsafe Queries `sql.unsafe` executes raw SQL strings. Use it with caution: it does not escape user input. Without parameters, the string can contain more than one command. diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index 88267bae8338..d8fc0913efad 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -1391,11 +1391,23 @@ describe("SQL helpers", () => { await defaultSql`CREATE TABLE default_named_test (id INTEGER, name TEXT)`; await defaultSql.unsafe("INSERT INTO default_named_test VALUES (:id, :name)", { ":id": 1, ":name": "Bob" }); + await defaultSql.unsafe("INSERT INTO default_named_test VALUES ($id, $name)", { $id: 2, $name: "Dan" }); + await defaultSql.unsafe("INSERT INTO default_named_test VALUES (@id, @name)", { "@id": 3, "@name": "Eve" }); - const results = await defaultSql.unsafe("SELECT * FROM default_named_test WHERE id = :id_param", { + const byColon = await defaultSql.unsafe("SELECT * FROM default_named_test WHERE id = :id_param", { ":id_param": 1, }); - expect(results).toEqual([{ id: 1, name: "Bob" }]); + expect(byColon).toEqual([{ id: 1, name: "Bob" }]); + + const byDollar = await defaultSql.unsafe("SELECT name FROM default_named_test WHERE id = $wanted", { + $wanted: 2, + }); + expect(byDollar).toEqual([{ name: "Dan" }]); + + const byAt = await defaultSql.unsafe("SELECT name FROM default_named_test WHERE id = @wanted", { + "@wanted": 3, + }); + expect(byAt).toEqual([{ name: "Eve" }]); }); test("unsafe with named parameters supports values() and raw() modes", async () => { From ca791a2e906ff3a60c41736ffcdd9eae5c10f2ff Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:59:17 +0000 Subject: [PATCH 09/11] Cover sql.file() named parameters on a non-strict connection --- test/js/sql/sqlite-sql.test.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index d8fc0913efad..6433c423c460 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -1605,16 +1605,24 @@ describe("SQL helpers", () => { }); test("file with named parameters", async () => { - await using strictSql = new SQL({ adapter: "sqlite", filename: ":memory:", strict: true }); - await strictSql`CREATE TABLE file_named_test (id INTEGER, name TEXT)`; - await strictSql.unsafe("INSERT INTO file_named_test VALUES (:id, :name)", { id: 1, name: "Alice" }); - await using dir = tempDir("sql-file-named", { "query.sql": `SELECT * FROM file_named_test WHERE name = :name`, }); + await using strictSql = new SQL({ adapter: "sqlite", filename: ":memory:", strict: true }); + await strictSql`CREATE TABLE file_named_test (id INTEGER, name TEXT)`; + await strictSql.unsafe("INSERT INTO file_named_test VALUES (:id, :name)", { id: 1, name: "Alice" }); + const result = await strictSql.file(path.join(dir, "query.sql"), { name: "Alice" }); expect(result).toEqual([{ id: 1, name: "Alice" }]); + + // Non-strict connections use the same file with prefixed keys. + await using defaultSql = new SQL({ adapter: "sqlite", filename: ":memory:" }); + await defaultSql`CREATE TABLE file_named_test (id INTEGER, name TEXT)`; + await defaultSql.unsafe("INSERT INTO file_named_test VALUES (:id, :name)", { ":id": 2, ":name": "Bob" }); + + const defaultResult = await defaultSql.file(path.join(dir, "query.sql"), { ":name": "Bob" }); + expect(defaultResult).toEqual([{ id: 2, name: "Bob" }]); }); }); From fa34508ccd3bd1c26fa9564730898975f425e4fd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:43:42 +0000 Subject: [PATCH 10/11] sql: finalize the SQLite statement when execution throws A missing named binding in strict mode throws from stmt.all/values/raw, which previously skipped stmt.finalize() and left the native statement alive until GC. Move finalize into a finally block and cover the error path. Also assert the raw() result bytes instead of only the lengths. --- src/js/internal/sql/sqlite.ts | 19 +++++++++++-------- test/js/sql/sqlite-sql.test.ts | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/js/internal/sql/sqlite.ts b/src/js/internal/sql/sqlite.ts index 5a9133bdfb1a..187738b0a6f1 100644 --- a/src/js/internal/sql/sqlite.ts +++ b/src/js/internal/sql/sqlite.ts @@ -245,13 +245,17 @@ class SQLiteQueryHandle implements BaseQueryHandle { const stmt = db.prepare(sql); let result: unknown[] | undefined; - // Named-parameter objects need $call: $apply would treat them as an empty array-like. - if (mode === SQLQueryResultMode.values) { - result = $isArray(values) ? stmt.values.$apply(stmt, values) : stmt.values.$call(stmt, values); - } else if (mode === SQLQueryResultMode.raw) { - result = $isArray(values) ? stmt.raw.$apply(stmt, values) : stmt.raw.$call(stmt, values); - } else { - result = $isArray(values) ? stmt.all.$apply(stmt, values) : stmt.all.$call(stmt, values); + try { + // Named-parameter objects need $call: $apply would treat them as an empty array-like. + if (mode === SQLQueryResultMode.values) { + result = $isArray(values) ? stmt.values.$apply(stmt, values) : stmt.values.$call(stmt, values); + } else if (mode === SQLQueryResultMode.raw) { + result = $isArray(values) ? stmt.raw.$apply(stmt, values) : stmt.raw.$call(stmt, values); + } else { + result = $isArray(values) ? stmt.all.$apply(stmt, values) : stmt.all.$call(stmt, values); + } + } finally { + stmt.finalize(); } const sqlResult = $isArray(result) ? new SQLResultArray(result) : new SQLResultArray([result]); @@ -259,7 +263,6 @@ class SQLiteQueryHandle implements BaseQueryHandle { sqlResult.command = commandToString(command, parsedInfo.lastToken); sqlResult.count = $isArray(result) ? result.length : 1; - stmt.finalize(); query.resolve(sqlResult); } else { // For INSERT/UPDATE/DELETE/CREATE etc., use db.run() which handles multiple statements natively diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index 6433c423c460..889e2274d755 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -1421,6 +1421,24 @@ describe("SQL helpers", () => { const raw = await strictSql.unsafe("SELECT id FROM named_modes_test WHERE id = :id", { id: 7 }).raw(); expect(raw).toHaveLength(1); expect(raw[0]).toHaveLength(1); + const idBuf = raw[0][0] as Uint8Array; + expect(idBuf).toBeInstanceOf(Uint8Array); + expect(new DataView(idBuf.buffer, idBuf.byteOffset, idBuf.byteLength).getBigInt64(0, true)).toBe(7n); + }); + + test("unsafe with named parameters rejects on missing bindings in strict mode", async () => { + await using strictSql = new SQL({ adapter: "sqlite", filename: ":memory:", strict: true }); + await strictSql`CREATE TABLE missing_binding_test (id INTEGER, name TEXT)`; + await strictSql.unsafe("INSERT INTO missing_binding_test VALUES (:id, :name)", { id: 1, name: "Alice" }); + + await expect( + async () => + await strictSql.unsafe("SELECT * FROM missing_binding_test WHERE id = :id AND name = :name", { id: 1 }), + ).toThrow('Missing parameter "name"'); + + // The statement from the failed query is finalized and the connection stays usable. + const rows = await strictSql.unsafe("SELECT * FROM missing_binding_test WHERE id = :id", { id: 1 }); + expect(rows).toEqual([{ id: 1, name: "Alice" }]); }); test("unsafe with named parameters for UPDATE and DELETE", async () => { From c2d927253c5860bc5538124cb4e0e1a0f39386d4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:25:20 +0000 Subject: [PATCH 11/11] test: describe what the missing-binding test actually asserts --- test/js/sql/sqlite-sql.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index 889e2274d755..b86a49018104 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -1436,7 +1436,7 @@ describe("SQL helpers", () => { await strictSql.unsafe("SELECT * FROM missing_binding_test WHERE id = :id AND name = :name", { id: 1 }), ).toThrow('Missing parameter "name"'); - // The statement from the failed query is finalized and the connection stays usable. + // The connection stays usable after a rejected bind. const rows = await strictSql.unsafe("SELECT * FROM missing_binding_test WHERE id = :id", { id: 1 }); expect(rows).toEqual([{ id: 1, name: "Alice" }]); });