Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .changeset/database-client-sql-template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/database-client": patch
---

Add `db.sql` for building statements from template literals
14 changes: 14 additions & 0 deletions packages/database-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,20 @@ const alice = await byId.bind(1).first();
const bob = await byId.bind(2).first();
```

### ``db.sql`...` ``

A template literal that binds every interpolated value, so the shortest way to write a query is also the parameterized one:

```ts
const note = await db.sql`SELECT * FROM notes WHERE id = ${id}`.first();
```

Each `${...}` becomes a `?` placeholder and its value is bound, never spliced into the SQL string. It returns a `Statement`, so everything under [Executing](#executing) applies unchanged.

Values follow the same rules as `bind()`, with one difference: an interpolated object throws instead of being read as named parameters, since inside a template it is far more likely to be a mistake.

Only values can be parameterized, which is a SQLite limit rather than a client one. Build the statement with `prepare()` when a table or column name has to vary.

### `statement.bind(...values)`

Binds parameters and returns a new statement. Accepts `null`, `boolean`, `number`, `bigint`, `string`, and `Uint8Array`.
Expand Down
38 changes: 38 additions & 0 deletions packages/database-client/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,44 @@ describe("statement", () => {
});
});

describe("sql template", () => {
test("interpolations become positional placeholders", () => {
const stmt = connect({ url: URL_ })
.sql`SELECT * FROM t WHERE a = ${1} AND b = ${"x"}`;

expect(stmt.wire.sql).toBe("SELECT * FROM t WHERE a = ? AND b = ?");
expect(stmt.wire.args).toEqual([
{ type: "integer", value: "1" },
{ type: "text", value: "x" },
]);
expect(stmt.wire.named_args).toEqual([]);
});

test("a template with nothing interpolated is left alone", () => {
const stmt = connect({ url: URL_ }).sql`SELECT 1 AS a`;

expect(stmt.wire.sql).toBe("SELECT 1 AS a");
expect(stmt.wire.args).toEqual([]);
});

test("an interpolated object is rejected rather than read as named parameters", () => {
const db = connect({ url: URL_ });

expect(() => db.sql`SELECT ${{ a: 1 }}`).toThrow(/cannot bind value/);
});

test("executes like any other statement", async () => {
const fake = fakeFetch([okExecute(["id"], [[7]])]);
const db = connect({ url: URL_, fetch: fake.fetch });

expect(await db.sql`SELECT id FROM t WHERE id = ${7}`.first("id")).toBe(7);

const stmt = (fake.captures[0] as Capture).body.requests[0]?.stmt;
expect(stmt?.sql).toBe("SELECT id FROM t WHERE id = ?");
expect(stmt?.args).toEqual([{ type: "integer", value: "7" }]);
});
});

describe("batch", () => {
function okBatch(count: number) {
const step = {
Expand Down
12 changes: 12 additions & 0 deletions packages/database-client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,18 @@ export class Database {
});
}

/** Build a statement from a template literal, binding every interpolated value positionally. */
sql(strings: TemplateStringsArray, ...values: unknown[]): Statement {
// Binding here rather than through bind() keeps an interpolated object a rejected value instead of named parameters.
return new Statement({
sql: strings.join("?"),
args: values.map(encodeValue),
namedArgs: [],
transport: this.#transport,
signal: this.#signal,
});
}

/** Run every statement in one transaction. All succeed or none are applied. */
async batch<T = Row>(
statements: Statement[],
Expand Down