diff --git a/.changeset/database-client-sql-template.md b/.changeset/database-client-sql-template.md new file mode 100644 index 00000000..f9cc9507 --- /dev/null +++ b/.changeset/database-client-sql-template.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/database-client": patch +--- + +Add `db.sql` for building statements from template literals diff --git a/packages/database-client/README.md b/packages/database-client/README.md index 919657b3..d97c20a0 100644 --- a/packages/database-client/README.md +++ b/packages/database-client/README.md @@ -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`. diff --git a/packages/database-client/src/client.test.ts b/packages/database-client/src/client.test.ts index 645b0879..c8b9f4a4 100644 --- a/packages/database-client/src/client.test.ts +++ b/packages/database-client/src/client.test.ts @@ -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 = { diff --git a/packages/database-client/src/client.ts b/packages/database-client/src/client.ts index 4444defa..f882ace1 100644 --- a/packages/database-client/src/client.ts +++ b/packages/database-client/src/client.ts @@ -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( statements: Statement[],