Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/runtime/sql.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 });
```
Comment thread
robobun marked this conversation as resolved.

### Execute and Cancelling Queries

Queries are lazy: they only start executing when awaited or run with `.execute()`.
Expand Down
14 changes: 11 additions & 3 deletions packages/bun-types/sql.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T = any>(string: string, values?: any[]): SQL.Query<T>;
unsafe<T = any>(string: string, values?: any[] | Record<string, any>): SQL.Query<T>;

/**
* 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<T = any>(filename: string, values?: any[]): SQL.Query<T>;
file<T = any>(filename: string, values?: any[] | Record<string, any>): SQL.Query<T>;
Comment thread
robobun marked this conversation as resolved.
}

/**
Expand Down
29 changes: 18 additions & 11 deletions src/js/internal/sql/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,10 +213,10 @@ class SQLiteQueryHandle implements BaseQueryHandle<BunSQLiteModule.Database> {
private mode = SQLQueryResultMode.objects;

private readonly sql: string;
private readonly values: unknown[];
private readonly values: unknown[] | Record<string, unknown>;
private readonly parsedInfo: SQLParsedInfo;

public constructor(sql: string, values: unknown[]) {
public constructor(sql: string, values: unknown[] | Record<string, unknown>) {
this.sql = sql;
this.values = values;
// Parse the SQL query once when creating the handle
Expand Down Expand Up @@ -245,24 +245,28 @@ class SQLiteQueryHandle implements BaseQueryHandle<BunSQLiteModule.Database> {
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]);

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);
Expand Down Expand Up @@ -350,7 +354,10 @@ class SQLiteAdapter implements DatabaseAdapter<BunSQLiteModule.Database, BunSQLi
}
}

createQueryHandle(sql: string, values: unknown[] | undefined | null = []): SQLiteQueryHandle {
createQueryHandle(
sql: string,
values: unknown[] | Record<string, unknown> | undefined | null = [],
): SQLiteQueryHandle {
return new SQLiteQueryHandle(sql, values ?? []);
}
escapeIdentifier(str: string) {
Expand Down
141 changes: 139 additions & 2 deletions test/js/sql/sqlite-sql.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -1356,6 +1356,120 @@
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" }]);
});
Comment thread
robobun marked this conversation as resolved.

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);
Comment thread
robobun marked this conversation as resolved.
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" }]);
});

Check warning on line 1442 in test/js/sql/sqlite-sql.test.ts

View check run for this annotation

Claude / Claude Code Review

Missing-binding test does not verify finalization on error

The comment says "The statement from the failed query is finalized", but neither assertion depends on the `try/finally` from fa34508c: strict-mode `rebindObject` throws before `sqlite3_step`, so an unfinalized statement acquires no lock, and `db.prepare()` returns a fresh statement each call — reverting the `try/finally` leaves this test green. Trim the comment to what is actually asserted ("the connection stays usable after a rejected bind"), or add a `heapStats`/`objectTypeCounts` check across
Comment thread
robobun marked this conversation as resolved.

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)`;
Expand Down Expand Up @@ -1507,6 +1621,27 @@
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", () => {
Expand Down Expand Up @@ -2014,7 +2149,9 @@

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})`;
Expand Down