diff --git a/docs/runtime/sql.mdx b/docs/runtime/sql.mdx index cdd16725b61a..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. @@ -437,6 +439,13 @@ 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. 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 }); +``` + ### 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..f0761a239f7e 100644 --- a/packages/bun-types/sql.d.ts +++ b/packages/bun-types/sql.d.ts @@ -935,22 +935,30 @@ 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). 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 }) * ``` */ - 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); keys keep the + * prefix unless the connection sets `strict: true`. * @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..187738b0a6f1 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,17 @@ class SQLiteQueryHandle implements BaseQueryHandle { const stmt = db.prepare(sql); let result: unknown[] | undefined; - if (mode === SQLQueryResultMode.values) { - result = stmt.values.$apply(stmt, values); - } else if (mode === SQLQueryResultMode.raw) { - result = stmt.raw.$apply(stmt, values); - } else { - result = stmt.all.$apply(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]); @@ -258,11 +263,10 @@ 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 - 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 +354,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..b86a49018104 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"; @@ -1356,6 +1356,120 @@ 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" }); + 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 byColon = await defaultSql.unsafe("SELECT * FROM default_named_test WHERE id = :id_param", { + ":id_param": 1, + }); + 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 () => { + 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); + 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 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" }]); + }); + + 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)`; @@ -1507,6 +1621,27 @@ describe("SQL helpers", () => { expect(result[0].param1).toBe("value1"); expect(result[0].param2).toBe("value2"); }); + + test("file with named parameters", async () => { + 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" }]); + }); }); describe("Helper argument validation", () => { @@ -2014,7 +2149,9 @@ describe("Memory and resource management", () => { await sql`CREATE TABLE stmt_test (id INTEGER PRIMARY KEY, value TEXT)`; - const iterations = 10000; + // 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})`;