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-prepare-generic.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/database-client": patch
---

Let `prepare<T>()` type every row the statement returns, including through `batch()`
15 changes: 15 additions & 0 deletions packages/database-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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<User>(...)` statement comes back as `Result<User>` 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
Expand Down Expand Up @@ -247,6 +251,17 @@ interface User {
const users = await db.prepare("SELECT id, name FROM users").all<User>();
```

Or type the statement once and let every execution of it inherit the shape:

```ts
const byId = db.prepare<User>("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<Row>()` on a `Statement<User>` 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
Expand Down
101 changes: 101 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,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<User>("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<User>("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<User>("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<User>("SELECT id, name FROM users"),
db.prepare("SELECT COUNT(*) AS c FROM users"),
]);

// Result<User> and Result<Row> 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<User>("SELECT name FROM users").first("name");

expect(name).toBe("a");
});
});

describe("sql template", () => {
test("interpolations become positional placeholders", () => {
const stmt = connect({ url: URL_ })
Expand Down
49 changes: 27 additions & 22 deletions packages/database-client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,16 +82,16 @@ function toResult<T>(wire: WireStmtResult): Result<T> {
return { ...raw, rows } as Result<T>;
}

/** 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<T = Row> {
readonly #internals: StatementInternals;

constructor(internals: StatementInternals) {
this.#internals = internals;
}

/** 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<T> {
const named = values.find(isNamedArgs);
if (named && values.length > 1) {
throw new DatabaseError(
Expand All @@ -100,7 +100,7 @@ export class Statement {
);
}
if (named) {
return new Statement({
return new Statement<T>({
...this.#internals,
args: [],
// The server resolves a bare name against :name, @name, and $name, so the sigil is optional.
Expand All @@ -110,26 +110,26 @@ export class Statement {
})),
});
}
return new Statement({
return new Statement<T>({
...this.#internals,
args: values.map(encodeValue),
namedArgs: [],
});
}

/** Execute and return every row as an object. */
async all<T = Row>(): Promise<T[]> {
return (await this.run<T>()).rows;
async all<R = T>(): Promise<R[]> {
return (await this.run<R>()).rows;
}

/** Execute and return the first row, or the value of one column of it. */
async first<T = Row>(): Promise<T | null>;
async first<R = T>(): Promise<R | null>;
async first(column: string): Promise<SqlValue | null>;
async first<T = Row>(column?: string): Promise<T | SqlValue | null> {
async first<R = T>(column?: string): Promise<R | SqlValue | null> {
const result = await this.run<Row>();
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(", ")}`,
Expand All @@ -150,8 +150,8 @@ export class Statement {
}

/** Execute and return rows together with write metadata. */
async run<T = Row>(): Promise<Result<T>> {
return toResult<T>(await this.#execute());
async run<R = T>(): Promise<Result<R>> {
return toResult<R>(await this.#execute());
}

/** @internal exposed so `batch()` can read the wire form. */
Expand All @@ -174,6 +174,11 @@ export class Statement {
}
}

/** Maps each statement in a batch to the `Result` of its row type. */
export type BatchResults<T extends readonly Statement<unknown>[]> = {
-readonly [K in keyof T]: T[K] extends Statement<infer R> ? Result<R> : never;
};

/** A connection to a bunny.net database. Stateless: each call is one HTTPS request. */
export class Database {
readonly #transport: Transport;
Expand All @@ -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<T = Row>(sql: string): Statement<T> {
return new Statement<T>({
sql,
args: [],
namedArgs: [],
Expand All @@ -209,25 +214,25 @@ export class Database {
}

/** Run every statement in one transaction. All succeed or none are applied. */
async batch<T = Row>(
statements: Statement[],
async batch<T extends readonly Statement<unknown>[]>(
statements: [...T],
options: BatchOptions = {},
): Promise<Result<T>[]> {
): Promise<BatchResults<T>> {
return (await this.#batch(statements, options)).map((wire) =>
toResult<T>(wire),
);
toResult<Row>(wire),
) as BatchResults<T>;
}

/** Like `batch()`, but each result has positional rows. Keeps duplicate column names distinct. */
async batchRaw(
statements: Statement[],
statements: readonly Statement<unknown>[],
options: BatchOptions = {},
): Promise<RawResult[]> {
return (await this.#batch(statements, options)).map(toRawResult);
}

async #batch(
statements: Statement[],
statements: readonly Statement<unknown>[],
options: BatchOptions,
): Promise<WireStmtResult[]> {
if (statements.length === 0) return [];
Expand Down
1 change: 1 addition & 0 deletions packages/database-client/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export {
type BatchOptions,
type BatchResults,
type Config,
connect,
Database,
Expand Down