diff --git a/.changeset/database-client-prepare-generic.md b/.changeset/database-client-prepare-generic.md new file mode 100644 index 00000000..5346af59 --- /dev/null +++ b/.changeset/database-client-prepare-generic.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/database-client": patch +--- + +Let `prepare()` type every row the statement returns, including through `batch()` diff --git a/packages/database-client/README.md b/packages/database-client/README.md index a3b73730..db947d9a 100644 --- a/packages/database-client/README.md +++ b/packages/database-client/README.md @@ -68,6 +68,8 @@ const alice = await byId.bind(1).first(); const bob = await byId.bind(2).first(); ``` +Pass a row type to have it flow through every execution of that statement. See [Types](#types). + ### ``db.sql`...` `` A template literal that binds every interpolated value, so the shortest way to write a query is also the parameterized one: @@ -165,6 +167,8 @@ const [inserted, count] = await db.batch([ You get one `Result` per statement you passed, in order. `batchRaw()` is the same but with positional rows. If any statement fails the transaction rolls back and `batch()` throws that statement's error. +`batch()` infers each result's row type from its statement, so a `prepare(...)` statement comes back as `Result` even next to untyped ones. See [Types](#types). + `{ foreignKeys: false }` brackets the transaction with `PRAGMA foreign_keys=off` and `=on`. Schema changes need it: SQLite's table rebuild procedure and several `ALTER TABLE` forms require enforcement genuinely off, not merely deferred to commit. `bunny db migrations apply` runs this way. ```ts @@ -247,6 +251,17 @@ interface User { const users = await db.prepare("SELECT id, name FROM users").all(); ``` +Or type the statement once and let every execution of it inherit the shape: + +```ts +const byId = db.prepare("SELECT id, name FROM users WHERE id = ?"); + +const alice = await byId.bind(1).first(); // User | null +const both = await byId.bind(1).all(); // User[] +``` + +A type argument on the executor still wins over the statement's, so `all()` on a `Statement` gives you rows back untyped. + That type is an assertion. Nothing validates the rows against it at runtime, so it is only ever as accurate as your SQL. ## Edge Scripting diff --git a/packages/database-client/src/client.test.ts b/packages/database-client/src/client.test.ts index c8b9f4a4..2c778178 100644 --- a/packages/database-client/src/client.test.ts +++ b/packages/database-client/src/client.test.ts @@ -342,6 +342,107 @@ describe("statement", () => { }); }); +interface User { + id: number; + name: string; +} + +describe("typed statements", () => { + test("prepare carries the row type to every executor", async () => { + const fake = fakeFetch([okExecute(["id", "name"], [[1, "a"]])]); + const db = connect({ url: URL_, fetch: fake.fetch }); + const byId = db.prepare("SELECT id, name FROM users WHERE id = ?"); + + const users = await byId.bind(1).all(); + const user = await byId.bind(1).first(); + + // Typed as User[] and User | null rather than Row, so these read without a cast. + expect(users[0]?.name).toBe("a"); + expect(user?.id).toBe(1); + }); + + test("a call-site type argument still wins", async () => { + const fake = fakeFetch([okExecute(["id"], [[1]])]); + const db = connect({ url: URL_, fetch: fake.fetch }); + + const rows = await db.prepare("SELECT id FROM users").all<{ + id: number; + }>(); + + expect(rows).toEqual([{ id: 1 }]); + }); + + test("batch infers the row type from its statements", async () => { + const fake = fakeFetch([ + { + type: "ok", + response: { + type: "batch", + result: { + step_results: [ + null, + okExecute(["id", "name"], [[1, "a"]]).response.result, + null, + null, + ], + step_errors: [null, null, null, null], + }, + }, + }, + ]); + const db = connect({ url: URL_, fetch: fake.fetch }); + + const [users] = await db.batch([ + db.prepare("SELECT id, name FROM users"), + ]); + + // Typed as string | undefined rather than SqlValue, so batch inherited User. + const name: string | undefined = users.rows[0]?.name; + expect(name).toBe("a"); + }); + + test("a mixed batch keeps each statement's own row type", async () => { + const fake = fakeFetch([ + { + type: "ok", + response: { + type: "batch", + result: { + step_results: [ + null, + okExecute(["id", "name"], [[1, "a"]]).response.result, + okExecute(["c"], [[2]]).response.result, + null, + null, + ], + step_errors: [null, null, null, null, null], + }, + }, + }, + ]); + const db = connect({ url: URL_, fetch: fake.fetch }); + + const [users, counts] = await db.batch([ + db.prepare("SELECT id, name FROM users"), + db.prepare("SELECT COUNT(*) AS c FROM users"), + ]); + + // Result and Result respectively, so name reads as a string and c as a SqlValue. + const name: string | undefined = users.rows[0]?.name; + expect(name).toBe("a"); + expect(counts.rows[0]?.c).toBe(2); + }); + + test("column reads are unaffected by the row type", async () => { + const fake = fakeFetch([okExecute(["name"], [["a"]])]); + const db = connect({ url: URL_, fetch: fake.fetch }); + + const name = await db.prepare("SELECT name FROM users").first("name"); + + expect(name).toBe("a"); + }); +}); + describe("sql template", () => { test("interpolations become positional placeholders", () => { const stmt = connect({ url: URL_ }) diff --git a/packages/database-client/src/client.ts b/packages/database-client/src/client.ts index f882ace1..8c37087b 100644 --- a/packages/database-client/src/client.ts +++ b/packages/database-client/src/client.ts @@ -82,8 +82,8 @@ function toResult(wire: WireStmtResult): Result { return { ...raw, rows } as Result; } -/** A SQL statement plus its bound arguments. Immutable and reusable. */ -export class Statement { +/** A SQL statement plus its bound arguments. Immutable and reusable. `T` is the row shape its executors return. */ +export class Statement { readonly #internals: StatementInternals; constructor(internals: StatementInternals) { @@ -91,7 +91,7 @@ export class Statement { } /** Return a copy of this statement with `values` bound: positionally for `?`, or one object for `:name`, `@name`, and `$name`. */ - bind(...values: unknown[]): Statement { + bind(...values: unknown[]): Statement { const named = values.find(isNamedArgs); if (named && values.length > 1) { throw new DatabaseError( @@ -100,7 +100,7 @@ export class Statement { ); } if (named) { - return new Statement({ + return new Statement({ ...this.#internals, args: [], // The server resolves a bare name against :name, @name, and $name, so the sigil is optional. @@ -110,7 +110,7 @@ export class Statement { })), }); } - return new Statement({ + return new Statement({ ...this.#internals, args: values.map(encodeValue), namedArgs: [], @@ -118,18 +118,18 @@ export class Statement { } /** Execute and return every row as an object. */ - async all(): Promise { - return (await this.run()).rows; + async all(): Promise { + return (await this.run()).rows; } /** Execute and return the first row, or the value of one column of it. */ - async first(): Promise; + async first(): Promise; async first(column: string): Promise; - async first(column?: string): Promise { + async first(column?: string): Promise { const result = await this.run(); const row = result.rows[0]; if (!row) return null; - if (column === undefined) return row as T; + if (column === undefined) return row as R; if (!Object.hasOwn(row, column)) { throw new DatabaseError( `column "${column}" is not in the result; got ${result.columns.join(", ")}`, @@ -150,8 +150,8 @@ export class Statement { } /** Execute and return rows together with write metadata. */ - async run(): Promise> { - return toResult(await this.#execute()); + async run(): Promise> { + return toResult(await this.#execute()); } /** @internal exposed so `batch()` can read the wire form. */ @@ -174,6 +174,11 @@ export class Statement { } } +/** Maps each statement in a batch to the `Result` of its row type. */ +export type BatchResults[]> = { + -readonly [K in keyof T]: T[K] extends Statement ? Result : never; +}; + /** A connection to a bunny.net database. Stateless: each call is one HTTPS request. */ export class Database { readonly #transport: Transport; @@ -185,9 +190,9 @@ export class Database { this.#signal = config.signal; } - /** Create a statement from SQL. Bind arguments with `.bind()`. */ - prepare(sql: string): Statement { - return new Statement({ + /** Create a statement from SQL. Bind arguments with `.bind()`. Pass `T` to type every row it returns. */ + prepare(sql: string): Statement { + return new Statement({ sql, args: [], namedArgs: [], @@ -209,25 +214,25 @@ export class Database { } /** Run every statement in one transaction. All succeed or none are applied. */ - async batch( - statements: Statement[], + async batch[]>( + statements: [...T], options: BatchOptions = {}, - ): Promise[]> { + ): Promise> { return (await this.#batch(statements, options)).map((wire) => - toResult(wire), - ); + toResult(wire), + ) as BatchResults; } /** Like `batch()`, but each result has positional rows. Keeps duplicate column names distinct. */ async batchRaw( - statements: Statement[], + statements: readonly Statement[], options: BatchOptions = {}, ): Promise { return (await this.#batch(statements, options)).map(toRawResult); } async #batch( - statements: Statement[], + statements: readonly Statement[], options: BatchOptions, ): Promise { if (statements.length === 0) return []; diff --git a/packages/database-client/src/index.ts b/packages/database-client/src/index.ts index 14dffbcf..b3903014 100644 --- a/packages/database-client/src/index.ts +++ b/packages/database-client/src/index.ts @@ -1,5 +1,6 @@ export { type BatchOptions, + type BatchResults, type Config, connect, Database,