From acc6fba8fab88a81466b03835b93325e20ced3ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Halvard=20M=C3=B8rstad?= Date: Mon, 12 Oct 2026 17:57:52 +0200 Subject: [PATCH] feat(database): add firstValues and firstRecord, and driver typed parameters Replace first() with firstValues() and firstRecord(), mirroring toValues() and toRecords(), so the first row can be taken in either representation. Both consume the result, like the other row reads. Type the query parameters of the client level by driver, with the standard SqlClient generic over the driver's parameter types; the SQLite driver extends them with Dates, bound as ISO 8601 strings. Export the package root, re-exporting the SQL interface with the drivers as namespaces. --- README.md | 2 +- database/README.md | 74 +++----- database/RFC_SQL.md | 24 ++- database/deno.json | 1 + database/drivers/sqlite/client.ts | 8 +- database/drivers/sqlite/driver.test.ts | 2 +- database/drivers/sqlite/driver.ts | 53 ++++++ database/mod.ts | 41 +++++ database/sql/README.md | 20 ++- database/sql/client.test.ts | 27 +++ database/sql/client.ts | 233 ++++++++++++++++--------- database/sql/core.ts | 42 ++++- database/sql/testing.ts | 35 ++++ database/sql/utils.test.ts | 40 +++++ database/sql/utils.ts | 26 +++ 15 files changed, 479 insertions(+), 149 deletions(-) create mode 100644 database/mod.ts diff --git a/README.md b/README.md index bfa871a..a6c6f74 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ console.log(dump(buffer)); | [cli](https://jsr.io/@stdx/cli) | The cli package contains a command framework and helpers for building command line applications | | [collections](https://jsr.io/@stdx/collections) | The collections package contains commonly used utilities and structures | | [crypto](https://jsr.io/@stdx/crypto) | The crypto package contains utility for crypto, hashing, HMAC and SCRAM authentication | -| [database](https://jsr.io/@stdx/database) | The database package contains interfaces and helpers for interacting with databases, and SQLite and Postgres drivers | +| [database](https://jsr.io/@stdx/database) | The database package contains interfaces and helpers for interacting with databases, and a SQLite driver | | [encoding](https://jsr.io/@stdx/encoding) | The encoding package contains utility for text and binary encoding | | [event](https://jsr.io/@stdx/event) | The event package contains extensions for events | | [ffi](https://jsr.io/@stdx/ffi) | The FFI package contains helpers when using FFI | diff --git a/database/README.md b/database/README.md index 1f386d5..bb94124 100644 --- a/database/README.md +++ b/database/README.md @@ -6,7 +6,7 @@ [![Total downloads](https://jsr.io/badges/@stdx/database/total-downloads)](https://jsr.io/@stdx/database) The database package contains a standard interface for SQL databases, and -drivers implementing it for SQLite and Postgres. It draws inspiration from +drivers implementing it for SQLite. It draws inspiration from [go std/database](https://pkg.go.dev/database). Applications use one of the [drivers](#drivers), which all share the same @@ -15,15 +15,35 @@ interface: ```ts import { SqliteClient } from "@stdx/database/drivers/sqlite"; +interface User { + id: number; + name: string; +} + await using client = new SqliteClient(":memory:"); await client.execute("CREATE TABLE users (id INTEGER, name TEXT)"); await client.execute("INSERT INTO users VALUES (?, ?)", [1, "Alice"]); -console.log(await client.query("SELECT * FROM users").toRecords()); +// The record type of the result is given at the call site. +console.log(await client.query("SELECT * FROM users").toRecords()); // [{ id: 1, name: "Alice" }] +// The first row, or undefined when there are no rows. +console.log(await client.query("SELECT * FROM users").firstRecord()); +// { id: 1, name: "Alice" } ``` ## Entrypoints +### Root + +`@stdx/database` re-exports the SQL interface and its standard implementation, +with the drivers and the conformance test suite as namespaces: + +```ts +import { sql, SqlClient, sqlite, testing } from "@stdx/database"; + +await using client = new sqlite.SqliteClient(":memory:"); +``` + ### Sql The SQL entrypoint contains the standard interface for SQL databases, as @@ -64,6 +84,9 @@ The connection URL is a file path, a `file:` URL, or `:memory:`. Parameters use `connectionOptions.readBigInts`), `BLOB`s as `Uint8Array`. Booleans are bound as `1`/`0`, and, as an extension, `Date`s as ISO 8601 strings. `execute` reports the `rowid` of inserted rows as `lastInsertId`. +- The parameters of the query methods are typed as `SqliteQueryParameters`, + exported by the driver: the standard `QueryParameters`, extended with `Date`s, + which are bound as ISO 8601 strings. ```ts import { SqliteClient } from "@stdx/database/drivers/sqlite"; @@ -72,50 +95,5 @@ await using client = new SqliteClient(":memory:"); await client.execute("CREATE TABLE users (id INTEGER, name TEXT)"); await client.execute("INSERT INTO users VALUES (?, ?)", [1, "Alice"]); console.log(await client.query("SELECT * FROM users").toRecords()); -``` - -#### Postgres - -`@stdx/database/drivers/postgres`, implemented in TypeScript on top of the -Postgres frontend/backend protocol, and requires the `net` permission. It -supports SCRAM-SHA-256, MD5 and cleartext password authentication, TLS, -streaming results, prepared statements, and cancelling queries with an -`AbortSignal`. Parameters use `$1`, `$2`, ... placeholders. - -- The connection URL follows the libpq connection URI format - (`postgres://user:password@host:port/database`), and supports the `sslmode` - and `application_name` parameters. The connection options take precedence. -- TLS always verifies the server certificate, also for `sslmode=prefer` and - `sslmode=require`, as Deno can not encrypt without verifying. Self-signed - certificates can be trusted with `connectionOptions.tls.caCerts`. -- SQL templates are rendered with `$1`, `$2`, ... placeholders, and scripts run - with the simple query protocol. Postgres has no insert ids: use `RETURNING`. -- A query result holds its connection until it is fully read or disposed. Other - commands on the same connection are rejected meanwhile, rather than buffering - the rest of the result in memory. -- Values are parsed by type: `int8` as `bigint`, `timestamptz` and `timestamp` - (as UTC) as `Date`, `numeric` and `date` as strings, `json`/`jsonb` parsed, - and arrays of these. Override the parsers with `connectionOptions.parsers`. - -```ts ignore -import { PostgresClient } from "@stdx/database/drivers/postgres"; - -await using client = new PostgresClient("postgres://user@localhost:5432/db", { - connectionOptions: { password: "secret" }, - poolOptions: { maxSize: 4 }, -}); -await client.connect(); -await client.transaction(async (tx) => { - await tx.execute("INSERT INTO users (name) VALUES ($1)", ["Alice"]); -}); -console.log( - await client.query("SELECT * FROM users WHERE id = $1", [1]).toRecords(), -); -``` - -The Postgres tests run against a live server when `STDX_POSTGRES_URL` is set: - -```sh -docker run -d -p 54329:5432 -e POSTGRES_PASSWORD=postgres postgres:17 -STDX_POSTGRES_URL=postgres://postgres:postgres@localhost:54329/postgres deno task test +// [{ id: 1, name: "Alice" }] ``` diff --git a/database/RFC_SQL.md b/database/RFC_SQL.md index 8f76f8f..8b2b23c 100644 --- a/database/RFC_SQL.md +++ b/database/RFC_SQL.md @@ -309,7 +309,10 @@ type QueryParameters = ParameterType[] | Record; Drivers must at minimum support `string`, and should support the full recommended set, which are the types JavaScript runtimes bind natively. The -parameter type may be extended with database specific types, such as `Date`. +parameter type may be extended with database specific types, such as `Date`. A +driver that extends the parameter types exports them, and its preconfigured +client is typed with them, so that the extension reaches the whole client level: +the client, its connections, transactions and prepared statements. The placeholder style (for example `?`, `$1` or `:name`) and the mapping of values to and from database types are database specific, and are not @@ -634,18 +637,21 @@ interface Queryable { params?: QueryParameters, options?: QueryOptions, ): Promise; - query( + query>( sql: Statement, params?: QueryParameters, options?: QueryOptions, - ): ResultIterableContext; + ): ResultIterableContext; executeScript(sql: string, options?: QueryOptions): Promise; } ``` - `execute` and `query` accept SQL text or a [SQL template](#sql-templates), and run exactly one statement. -- `query` returns a lazy [result](#result-context). +- `query` returns a lazy [result](#result-context). Its `R` type parameter + determines the record type of the result, given at the call site, such as + `query(...)`: the records are of the given type, without validating the + values against it. - `executeScript` runs a script without parameters. #### Result Context @@ -663,6 +669,8 @@ interface ResultIterableContext< columns(): Promise; toValues(): Promise; toRecords(): Promise; + firstValues(): Promise; + firstRecord(): Promise; } ``` @@ -671,6 +679,9 @@ interface ResultIterableContext< as invalid SQL or a lost connection, are thrown by the consuming methods. - `columns()` resolves to the column names, without consuming the rows. - Iterating streams the rows, and `toValues` and `toRecords` collect them. +- `firstValues()` and `firstRecord()` resolve to the first row as values or as a + record respectively, or `undefined` when there are no rows, and consume the + result: the remaining rows are not read. - The rows are not kept in memory, so a result can be consumed once: either iterated or collected. Consuming it again rejects with a `QueryError`. This keeps the memory use of streaming a massive result constant. @@ -694,7 +705,10 @@ interface PreparedStatement extends AsyncDisposable { readonly deallocated: boolean; deallocate(): Promise; execute(params?, options?): Promise; - query(params?, options?): ResultIterableContext; + query>( + params?, + options?, + ): ResultIterableContext; } ``` diff --git a/database/deno.json b/database/deno.json index 8e51a07..ac1ca42 100644 --- a/database/deno.json +++ b/database/deno.json @@ -3,6 +3,7 @@ "name": "@stdx/database", "description": "A standard interface for SQL databases, with SQLite drivers.", "exports": { + ".": "./mod.ts", "./sql": "./sql/mod.ts", "./sql/testing": "./sql/testing.ts", "./drivers/sqlite": "./drivers/sqlite/mod.ts" diff --git a/database/drivers/sqlite/client.ts b/database/drivers/sqlite/client.ts index 92644ab..4818b0e 100644 --- a/database/drivers/sqlite/client.ts +++ b/database/drivers/sqlite/client.ts @@ -2,6 +2,7 @@ import { type ClientOptions, SqlClient } from "../../sql/mod.ts"; import { type SqliteConnectionOptions, SqliteDriver, + type SqliteQueryParameters, type SqliteTransactionOptions, } from "./driver.ts"; @@ -40,6 +41,10 @@ export interface SqliteClientOptions * connection is released. This keeps transactions isolated, and makes * `:memory:` databases behave like a single database. * + * The parameters of its query methods are typed as + * {@linkcode SqliteQueryParameters}: the standard parameter types, extended + * with `Date`s, which are bound as ISO 8601 strings. + * * @example * ```ts * import { SqliteClient } from "@stdx/database/drivers/sqlite"; @@ -52,7 +57,8 @@ export interface SqliteClientOptions * console.log(await client.query("SELECT * FROM users").toRecords()); * ``` */ -export class SqliteClient extends SqlClient { +export class SqliteClient + extends SqlClient { /** * Create a SQLite client. * diff --git a/database/drivers/sqlite/driver.test.ts b/database/drivers/sqlite/driver.test.ts index c04277b..757f4b4 100644 --- a/database/drivers/sqlite/driver.test.ts +++ b/database/drivers/sqlite/driver.test.ts @@ -200,7 +200,7 @@ Deno.test("SqliteClient", async (t) => { new Uint8Array([1, 2]), undefined, true, - date as never, + date, null, ], ), diff --git a/database/drivers/sqlite/driver.ts b/database/drivers/sqlite/driver.ts index 5de6049..abc8485 100644 --- a/database/drivers/sqlite/driver.ts +++ b/database/drivers/sqlite/driver.ts @@ -16,12 +16,65 @@ import { type DriverStatement, type DriverTransaction, type ExecuteResult, + type ParameterType, QueryError, type QueryParameters, TransactionError, type TransactionOptions, } from "../../sql/mod.ts"; +/** + * SqliteParameterType + * + * The parameter values that the SQLite driver binds: the standard + * {@linkcode ParameterType}, extended with `Date`s, which are bound as ISO 8601 + * strings. Booleans are bound as `1`/`0`, and binary data from any + * `ArrayBufferView` or `ArrayBuffer`. Any other value, such as a plain + * object, is bound as its JSON serialization; bind it with a cast, or + * serialize it yourself. + * + * @example + * ```ts + * import type { SqliteParameterType } from "@stdx/database/drivers/sqlite"; + * + * const values: SqliteParameterType[] = [ + * "Alice", + * 1, + * true, + * new Date("2024-01-02T03:04:05.000Z"), + * new Uint8Array([1, 2]), + * ]; + * console.log(values.length); // 5 + * ``` + */ +export type SqliteParameterType = ParameterType | Date; + +/** + * SqliteQueryParameters + * + * The parameters to bind to a SQLite statement, either as an ordered array + * with `?` placeholders, or as a record with `:name`, `@name` and `$name` + * placeholders. The values are {@linkcode SqliteParameterType}s. + * + * @example + * ```ts + * import type { SqliteQueryParameters } from "@stdx/database/drivers/sqlite"; + * import { SqliteClient } from "@stdx/database/drivers/sqlite"; + * + * await using client = new SqliteClient(":memory:"); + * await client.execute("CREATE TABLE users (id INTEGER, at TEXT)"); + * // Positional parameters, with a Date, bound as an ISO 8601 string. + * const positional: SqliteQueryParameters = [1, new Date()]; + * await client.execute("INSERT INTO users VALUES (?, ?)", positional); + * // Named parameters. + * const named: SqliteQueryParameters = { id: 2, at: new Date() }; + * await client.execute("INSERT INTO users VALUES (:id, :at)", named); + * ``` + */ +export type SqliteQueryParameters = + | SqliteParameterType[] + | Record; + /** * SqliteConnectionOptions * diff --git a/database/mod.ts b/database/mod.ts new file mode 100644 index 0000000..db61463 --- /dev/null +++ b/database/mod.ts @@ -0,0 +1,41 @@ +/** + * The standard interface for SQL databases, with SQLite drivers. + * + * The root module re-exports the SQL entrypoint: the standard interface and + * its implementation, the {@linkcode SqlClient}, the errors, the types and + * the tagged template query builder. The drivers are exported as namespaces, + * such as `sqlite`, and the conformance test suite as `testing`. + * + * @example + * ```ts + * import { sqlite } from "@stdx/database"; + * + * await using client = new sqlite.SqliteClient(":memory:"); + * await client.execute("CREATE TABLE users (id INTEGER, name TEXT)"); + * await client.execute("INSERT INTO users VALUES (1, 'Alice')"); + * console.log(await client.query("SELECT * FROM users").toRecords()); + * // [{ id: 1, name: "Alice" }] + * ``` + * + * @example + * ```ts + * import { sqlite } from "@stdx/database"; + * + * interface User { + * id: number; + * name: string; + * } + * + * await using client = new sqlite.SqliteClient(":memory:"); + * await client.execute("CREATE TABLE users (id INTEGER, name TEXT)"); + * await client.execute("INSERT INTO users VALUES (1, 'Alice')"); + * // The record type of the result is given at the call site. + * const user = await client.query("SELECT * FROM users").firstRecord(); + * console.log(user?.name.toUpperCase()); // "ALICE" + * ``` + * + * @module + */ +export * from "./sql/mod.ts"; +export * as sqlite from "./drivers/sqlite/mod.ts"; +export * as testing from "./sql/testing.ts"; diff --git a/database/sql/README.md b/database/sql/README.md index 0e08a0a..87da246 100644 --- a/database/sql/README.md +++ b/database/sql/README.md @@ -80,8 +80,11 @@ The query methods are defined in [core.ts](./core.ts) (`Queryable`): other statements) and the `lastInsertId`, if the database reports it - `query(sql, params?, options?)`: Returns a lazy `ResultIterableContext` for a single SQL statement, which is both an async iterable of rows and provides - `columns()`, `toValues()` and `toRecords()`. The query runs when the result is - consumed, and holds its connection until it is fully read or disposed + `columns()`, `toValues()`, `toRecords()`, `firstValues()` and `firstRecord()`. + The query runs when the result is consumed, and holds its connection until it + is fully read or disposed. The record type of the result is given at the call + site as a type parameter, and the driver's parameter types, which may extend + the standard ones, are given by its client - `executeScript(sql, options?)`: Executes a script of one or more statements without parameters, such as a migration @@ -92,6 +95,11 @@ mapping of values depend on the driver. import { sql } from "@stdx/database/sql"; import { SqliteClient } from "@stdx/database/drivers/sqlite"; +interface User { + id: number; + name: string; +} + await using client = new SqliteClient(":memory:"); await client.execute("CREATE TABLE users (id INTEGER, name TEXT)"); @@ -100,8 +108,12 @@ for await (const row of client.query("SELECT * FROM users")) { console.log(row.toRecord()); } -// Or collect -console.log(await client.query("SELECT * FROM users").toRecords()); +// Or collect, with the record type given at the call site +console.log(await client.query("SELECT * FROM users").toRecords()); + +// Or take the first row, which consumes the result +console.log(await client.query("SELECT * FROM users").firstRecord()); +// { id: 1, name: "Alice" } // SQL templates render the placeholders of the database const name = "Alice"; diff --git a/database/sql/client.test.ts b/database/sql/client.test.ts index 637c9bd..8690bba 100644 --- a/database/sql/client.test.ts +++ b/database/sql/client.test.ts @@ -116,6 +116,33 @@ Deno.test("SqlClient queries", async (t) => { assertEquals(log(driver).at(-1), "query SELECT ? AS value"); }); + await t.step("types the records at the call site", async () => { + interface User { + id: number; + name: string; + } + await using c = client().client; + const rows = await c.query(memorySql.query).toRecords(); + assertEquals(rows.length, memorySql.count); + assertEquals(rows.map((row) => row.name.toUpperCase()), [ + "ALICE", + "BOB", + "CHARLIE", + ]); + assertEquals(await c.query(memorySql.query).firstRecord(), { + id: 1, + name: "Alice", + }); + assertEquals( + await c.query(memorySql.query).firstValues(), + [1, "Alice"], + ); + assertEquals( + await c.query(memorySql.emptyQuery).firstRecord(), + undefined, + ); + }); + await t.step("caches prepared statements", async () => { const { client: c, driver } = client(); await using _ = c; diff --git a/database/sql/client.ts b/database/sql/client.ts index 777cb71..516301c 100644 --- a/database/sql/client.ts +++ b/database/sql/client.ts @@ -52,13 +52,17 @@ function wrapError(error: unknown, ErrorClass: ErrorClass): DatabaseError { } function transformParams( - params: QueryParameters | undefined, + params: unknown, transform: ((value: unknown) => unknown) | undefined, ): QueryParameters | undefined { - if (params === undefined || transform === undefined) return params; + if (params === undefined || transform === undefined) { + return params as QueryParameters | undefined; + } if (Array.isArray(params)) return params.map(transform) as QueryParameters; return Object.fromEntries( - Object.entries(params).map(([key, value]) => [key, transform(value)]), + (Object.entries(params as Record)).map(( + [key, value], + ) => [key, transform(value)]), ) as QueryParameters; } @@ -83,11 +87,12 @@ async function runTransaction( * Create a lazy result that delegates to another result, which is only * created when the result is started, so that checks, such as whether a * transaction is still active, run when the query runs. The `done` callback - * runs once the delegated result is read or disposed. + * runs once the delegated result is read or disposed. The record type is the + * given `R`: the delegated result produces records of its own record type. */ -function delegateResult( +function delegateResult( start: () => Promise<{ result: ResultIterableContext; done?: Callback }>, -): ResultIterableContext { +): ResultIterableContext { return createResultIterableContext(async () => { const { result, done } = await start(); const finish = async () => { @@ -109,13 +114,18 @@ function delegateResult( } } return { columns, rows: rows() }; - }); + }) as ResultIterableContext; } /** * The state of a driver connection of the pool, kept across acquires. + * + * @template IParameters the query parameter types */ -interface PooledConnection { +interface PooledConnection< + IParameters extends readonly unknown[] | Record = + QueryParameters, +> { readonly connection: DriverConnection; readonly openedAt: number; /** The prepared statement cache, in least recently used order */ @@ -127,7 +137,7 @@ interface PooledConnection { /** Whether a query result is being read */ streaming: boolean; /** The active root transaction */ - transaction?: SqlTransaction; + transaction?: SqlTransaction; } /** @@ -135,7 +145,11 @@ interface PooledConnection { * operations are rejected: the rest of the result is not buffered, as it may * not fit in memory. */ -async function lock(pooled: PooledConnection): Promise<() => void> { +async function lock< + IParameters extends readonly unknown[] | Record, +>( + pooled: PooledConnection, +): Promise<() => void> { if (pooled.streaming) throw new QueryError(STREAMING_MESSAGE); const controller = new AbortController(); pooled.waiting.add(controller); @@ -158,7 +172,11 @@ async function lock(pooled: PooledConnection): Promise<() => void> { * until the result is read, which may only happen after they finish, so they * are rejected. */ -function startStreaming(pooled: PooledConnection): void { +function startStreaming< + IParameters extends readonly unknown[] | Record, +>( + pooled: PooledConnection, +): void { pooled.streaming = true; for (const waiting of pooled.waiting) { waiting.abort(new QueryError(STREAMING_MESSAGE)); @@ -170,8 +188,10 @@ function startStreaming(pooled: PooledConnection): void { * cached. Resolves to `undefined` when the driver has no prepared statements * or the cache is disabled. */ -async function cachedStatement( - pooled: PooledConnection, +async function cachedStatement< + IParameters extends readonly unknown[] | Record, +>( + pooled: PooledConnection, sql: string, size: number, ): Promise { @@ -195,8 +215,10 @@ async function cachedStatement( } /** Remove a statement from the cache, such as after it failed */ -async function evictStatement( - pooled: PooledConnection, +async function evictStatement< + IParameters extends readonly unknown[] | Record, +>( + pooled: PooledConnection, sql: string, statement: DriverStatement, ): Promise { @@ -218,23 +240,39 @@ interface ConnectionContext { // Accessors to private members, assigned in static blocks of the classes, so // that the hooks of the client are not part of the public class surface. -let addDeallocateCallback: ( - stmt: SqlPreparedStatement, +let addDeallocateCallback: < + IParameters extends readonly unknown[] | Record, +>( + stmt: SqlPreparedStatement, callback: Callback, ) => void; -let addEndCallback: (tx: SqlTransaction, callback: Callback) => void; -let endTransaction: (tx: SqlTransaction) => Promise; +let addEndCallback: < + IParameters extends readonly unknown[] | Record, +>( + tx: SqlTransaction, + callback: Callback, +) => void; +let endTransaction: < + IParameters extends readonly unknown[] | Record, +>( + tx: SqlTransaction, +) => Promise; /** * The calls a {@linkcode SqlPreparedStatement} delegates to. + * + * @template IParameters the query parameter types */ -interface PreparedStatementCalls { +interface PreparedStatementCalls< + IParameters extends readonly unknown[] | Record = + QueryParameters, +> { execute( - params?: QueryParameters, + params?: IParameters | QueryParameters, options?: QueryOptions, ): Promise; query( - params?: QueryParameters, + params?: IParameters | QueryParameters, options?: QueryOptions, ): ResultIterableContext; deallocate(): Promise; @@ -257,10 +295,15 @@ interface PreparedStatementCalls { * console.log(await statement.query([1]).toRecords()); * // [{ name: "Alice" }] * ``` + * + * @template IParameters the query parameter types, extended by the driver */ -export class SqlPreparedStatement implements PreparedStatement { +export class SqlPreparedStatement< + IParameters extends readonly unknown[] | Record = + QueryParameters, +> implements PreparedStatement { readonly #sql: string; - readonly #calls: PreparedStatementCalls; + readonly #calls: PreparedStatementCalls; readonly #onDeallocate: Callback[] = []; #deallocated = false; @@ -269,7 +312,7 @@ export class SqlPreparedStatement implements PreparedStatement { * * @ignore */ - constructor(sql: string, calls: PreparedStatementCalls) { + constructor(sql: string, calls: PreparedStatementCalls) { this.#sql = sql; this.#calls = calls; } @@ -299,18 +342,18 @@ export class SqlPreparedStatement implements PreparedStatement { } async execute( - params?: QueryParameters, + params?: IParameters | QueryParameters, options?: QueryOptions, ): Promise { this.#assertUsable(); return await this.#calls.execute(params, options); } - query( - params?: QueryParameters, + query>( + params?: IParameters | QueryParameters, options?: QueryOptions, - ): ResultIterableContext { - return delegateResult(() => { + ): ResultIterableContext { + return delegateResult(() => { this.#assertUsable(); return Promise.resolve({ result: this.#calls.query(params, options) }); }); @@ -328,9 +371,14 @@ export class SqlPreparedStatement implements PreparedStatement { /** * What a {@linkcode SqlTransaction} needs from its connection. + * + * @template IParameters the query parameter types */ -interface TransactionContext { - readonly connection: SqlConnection; +interface TransactionContext< + IParameters extends readonly unknown[] | Record = + QueryParameters, +> { + readonly connection: SqlConnection; readonly transaction: DriverTransaction; /** Run a transaction control operation on the connection */ control(fn: () => Promise): Promise; @@ -360,11 +408,16 @@ interface TransactionContext { * console.log(transaction.inTransaction); // false * // Disposing an active transaction rolls it back instead. * ``` + * + * @template IParameters the query parameter types, extended by the driver */ -export class SqlTransaction implements Transaction { - readonly #context: TransactionContext; +export class SqlTransaction< + IParameters extends readonly unknown[] | Record = + QueryParameters, +> implements Transaction { + readonly #context: TransactionContext; readonly #savepoint?: DriverSavepoint; - readonly #children: SqlTransaction[] = []; + readonly #children: SqlTransaction[] = []; readonly #savepoints: { name: string; savepoint: DriverSavepoint }[] = []; readonly #onEnd: Callback[] = []; #active = true; @@ -374,7 +427,10 @@ export class SqlTransaction implements Transaction { * * @ignore */ - constructor(context: TransactionContext, savepoint?: DriverSavepoint) { + constructor( + context: TransactionContext, + savepoint?: DriverSavepoint, + ) { this.#context = context; this.#savepoint = savepoint; } @@ -409,19 +465,19 @@ export class SqlTransaction implements Transaction { async execute( sql: Statement, - params?: QueryParameters, + params?: IParameters | QueryParameters, options?: QueryOptions, ): Promise { this.#assertActive(); return await this.#context.connection.execute(sql, params, options); } - query( + query>( sql: Statement, - params?: QueryParameters, + params?: IParameters | QueryParameters, options?: QueryOptions, - ): ResultIterableContext { - return delegateResult(() => { + ): ResultIterableContext { + return delegateResult(() => { this.#assertActive(); return Promise.resolve({ result: this.#context.connection.query(sql, params, options), @@ -437,20 +493,20 @@ export class SqlTransaction implements Transaction { async prepare( sql: string, options?: QueryOptions, - ): Promise { + ): Promise> { this.#assertActive(); return await this.#context.connection.prepare(sql, options); } async beginTransaction( _options?: TransactionOptions, - ): Promise { + ): Promise> { this.#assertActive(); const name = this.#context.savepointName(); const savepoint = await this.#context.control(() => this.#context.transaction.savepoint(name) ); - const child = new SqlTransaction(this.#context, savepoint); + const child = new SqlTransaction(this.#context, savepoint); this.#children.push(child); return child; } @@ -553,10 +609,15 @@ export class SqlTransaction implements Transaction { * console.log(await connection.query("SELECT * FROM users").toRecords()); * // [{ id: 1, name: "Alice" }] * ``` + * + * @template IParameters the query parameter types, extended by the driver */ -export class SqlConnection implements Connection { +export class SqlConnection< + IParameters extends readonly unknown[] | Record = + QueryParameters, +> implements Connection { readonly #context: ConnectionContext; - readonly #pooled: PooledConnection; + readonly #pooled: PooledConnection; readonly #release: () => Promise; readonly #remove: () => Promise; #released = false; @@ -569,7 +630,7 @@ export class SqlConnection implements Connection { */ constructor( context: ConnectionContext, - pooled: PooledConnection, + pooled: PooledConnection, release: () => Promise, remove: () => Promise, ) { @@ -636,11 +697,14 @@ export class SqlConnection implements Connection { } } - /** Create a lazy result, holding the lock of the connection while read */ - #result( + /** + * Create a lazy result, holding the lock of the connection while read. The + * record type is the given `R`: the rows are mapped to records of it. + */ + #result>( options: QueryOptions, start: () => Promise, - ): ResultIterableContext { + ): ResultIterableContext { return createResultIterableContext(async () => { try { options.signal?.throwIfAborted(); @@ -664,7 +728,7 @@ export class SqlConnection implements Connection { } catch (error) { throw this.#context.error(error, QueryError, options.signal); } - }); + }) as ResultIterableContext; } async *#rows( @@ -692,12 +756,12 @@ export class SqlConnection implements Connection { #render( statement: Statement, - params: QueryParameters | undefined, + params: (IParameters | QueryParameters) | undefined, options: QueryOptions, ): { sql: string; params: QueryParameters | undefined } { const rendered = renderStatement( statement, - params, + params as QueryParameters | undefined, (index) => this.#context.dialect.placeholder(index), ); return { @@ -713,7 +777,7 @@ export class SqlConnection implements Connection { */ async #runCached( sql: Statement, - params: QueryParameters | undefined, + params: (IParameters | QueryParameters) | undefined, merged: QueryOptions, run: ( statement: DriverStatement | undefined, @@ -736,7 +800,7 @@ export class SqlConnection implements Connection { async execute( sql: Statement, - params?: QueryParameters, + params?: IParameters | QueryParameters, options?: QueryOptions, ): Promise { const merged = this.#merge(options); @@ -757,13 +821,13 @@ export class SqlConnection implements Connection { ); } - query( + query>( sql: Statement, - params?: QueryParameters, + params?: IParameters | QueryParameters, options?: QueryOptions, - ): ResultIterableContext { + ): ResultIterableContext { const merged = this.#merge(options); - return this.#result( + return this.#result( merged, () => this.#runCached(sql, params, merged, (statement, call) => { @@ -796,7 +860,7 @@ export class SqlConnection implements Connection { async prepare( sql: string, options?: QueryOptions, - ): Promise { + ): Promise> { const base = this.#merge(options); const { connection } = this.#pooled; const native = await this.#run( @@ -805,7 +869,7 @@ export class SqlConnection implements Connection { async () => await connection.prepare?.(sql), ); const args = ( - params: QueryParameters | undefined, + params: (IParameters | QueryParameters) | undefined, merged: QueryOptions, ) => [ @@ -849,7 +913,7 @@ export class SqlConnection implements Connection { async beginTransaction( options?: TransactionOptions, - ): Promise { + ): Promise> { try { this.#assertUsable(); } catch (error) { @@ -921,6 +985,7 @@ export class SqlConnection implements Connection { * * @template IDriver the driver * @template IOptions the client options + * @template IParameters the query parameter types, extended by the driver * * @example * ```ts @@ -935,6 +1000,8 @@ export class SqlConnection implements Connection { export class SqlClient< IDriver extends Driver = Driver, IOptions extends ClientOptions = ClientOptions, + IParameters extends readonly unknown[] | Record = + QueryParameters, > implements Client { readonly #driver: IDriver; readonly #connectionUrl: string | URL; @@ -942,11 +1009,11 @@ export class SqlClient< readonly eventTarget: ClientEventTarget = new ClientEventTarget(); readonly #dispatched = new WeakSet(); readonly #idleTimers = new Map< - PooledConnection, + PooledConnection, ReturnType >(); readonly #context: ConnectionContext; - #stack?: DeferredStack; + #stack?: DeferredStack>; #opening = 0; #connected = false; #closed = false; @@ -1038,19 +1105,19 @@ export class SqlClient< } /** Whether a connection has reached its maximum lifetime */ - #expired(pooled: PooledConnection): boolean { + #expired(pooled: PooledConnection): boolean { const { maxLifetime } = this.#poolOptions; return maxLifetime !== undefined && Date.now() - pooled.openedAt >= maxLifetime; } - #clearIdleTimer(pooled: PooledConnection): void { + #clearIdleTimer(pooled: PooledConnection): void { clearTimeout(this.#idleTimers.get(pooled)); this.#idleTimers.delete(pooled); } /** Close the connection once it has been idle for the idle timeout */ - #scheduleIdle(pooled: PooledConnection): void { + #scheduleIdle(pooled: PooledConnection): void { const { idleTimeout } = this.#poolOptions; const stack = this.#stack; if (idleTimeout === undefined || !stack) return; @@ -1117,7 +1184,7 @@ export class SqlClient< await connection.close(); throw new ConnectionError("Client is closed"); } - const pooled: PooledConnection = { + const pooled: PooledConnection = { connection, openedAt: Date.now(), statements: new Map(), @@ -1146,8 +1213,8 @@ export class SqlClient< } async #release( - element: DeferredStackElement, - pooled: PooledConnection, + element: DeferredStackElement>, + pooled: PooledConnection, ): Promise { this.#dispatch("release"); try { @@ -1168,7 +1235,7 @@ export class SqlClient< } async #remove( - element: DeferredStackElement, + element: DeferredStackElement>, ): Promise { await element.remove(); await this.#replenish(); @@ -1176,9 +1243,9 @@ export class SqlClient< /** Pop a connection, waiting at most until the deadline */ async #pop( - stack: DeferredStack, + stack: DeferredStack>, deadline: number | undefined, - ): Promise> { + ): Promise>> { if (deadline === undefined) return await stack.pop(); const controller = new AbortController(); const timer = setTimeout( @@ -1225,7 +1292,7 @@ export class SqlClient< } this.#connected = true; this.#closed = false; - this.#stack = new DeferredStack({ + this.#stack = new DeferredStack>({ maxSize: maxSize!, removeFn: (pooled) => { this.#clearIdleTimer(pooled); @@ -1251,7 +1318,7 @@ export class SqlClient< .catch(() => {}); } - async acquire(): Promise { + async acquire(): Promise> { const { acquireTimeout } = this.#poolOptions; const deadline = acquireTimeout === undefined ? undefined @@ -1288,21 +1355,21 @@ export class SqlClient< async execute( sql: Statement, - params?: QueryParameters, + params?: IParameters | QueryParameters, options?: QueryOptions, ): Promise { await using connection = await this.acquire(); return await connection.execute(sql, params, options); } - query( + query>( sql: Statement, - params?: QueryParameters, + params?: IParameters | QueryParameters, options?: QueryOptions, - ): ResultIterableContext { + ): ResultIterableContext { // The connection is acquired when the query runs, and held until the // result is fully read or disposed. - return delegateResult(async () => { + return delegateResult(async () => { const connection = await this.acquire(); return { result: connection.query(sql, params, options), @@ -1322,7 +1389,7 @@ export class SqlClient< * when it ends, and the connection is released when creating it fails. */ async #holding( - create: (connection: SqlConnection) => Promise, + create: (connection: SqlConnection) => Promise, onEnd: (held: T, release: () => Promise) => void, ): Promise { const connection = await this.acquire(); @@ -1339,7 +1406,7 @@ export class SqlClient< async prepare( sql: string, options?: QueryOptions, - ): Promise { + ): Promise> { // The connection is held until the statement is deallocated. return await this.#holding( (connection) => connection.prepare(sql, options), @@ -1349,7 +1416,7 @@ export class SqlClient< async beginTransaction( options?: TransactionOptions, - ): Promise { + ): Promise> { // The connection is held until the transaction has ended. return await this.#holding( (connection) => connection.beginTransaction(options), diff --git a/database/sql/core.ts b/database/sql/core.ts index f895844..23709d8 100644 --- a/database/sql/core.ts +++ b/database/sql/core.ts @@ -760,6 +760,16 @@ export interface ResultIterableContext< * Collect all rows as an array of records */ toRecords(): Promise; + /** + * The first row as an array of values, or `undefined` when there are no + * rows. Runs the query if it has not run yet, and consumes the result. + */ + firstValues(): Promise; + /** + * The first row as a record, or `undefined` when there are no rows. Runs + * the query if it has not run yet, and consumes the result. + */ + firstRecord(): Promise; } /** @@ -899,7 +909,25 @@ export interface Queryable { /** * Query exactly one SQL statement, returning a lazy * {@linkcode ResultIterableContext}. The query runs when the result is - * consumed. + * consumed. The `R` type parameter determines the record type of the + * result, such as `query(...)`: the records are of the given type, + * without validating the values against it. + * + * @example + * ```ts + * import { SqliteClient } from "@stdx/database/drivers/sqlite"; + * + * interface User { + * id: number; + * name: string; + * } + * + * await using client = new SqliteClient(":memory:"); + * await client.execute("CREATE TABLE users (id INTEGER, name TEXT)"); + * await client.execute("INSERT INTO users VALUES (1, 'Alice')"); + * const rows = await client.query("SELECT * FROM users").toRecords(); + * console.log(rows[0].name.toUpperCase()); // "ALICE" + * ``` * * @param sql the SQL statement, as text or a {@linkcode SqlTemplate} * @param params the parameters to bind. Not allowed with a @@ -907,11 +935,11 @@ export interface Queryable { * @param options the query options, merged with the client's * @returns the result of the query */ - query( + query>( sql: Statement, params?: QueryParameters, options?: QueryOptions, - ): ResultIterableContext; + ): ResultIterableContext; /** * Execute a script of one or more SQL statements without parameters, such * as a migration. @@ -1004,12 +1032,14 @@ export interface PreparedStatement extends AsyncDisposable { options?: QueryOptions, ): Promise; /** - * Query the statement, returning a lazy result + * Query the statement, returning a lazy result. The `R` type parameter + * determines the record type of the result, such as + * `stmt.query(...)`. */ - query( + query>( params?: QueryParameters, options?: QueryOptions, - ): ResultIterableContext; + ): ResultIterableContext; } /** diff --git a/database/sql/testing.ts b/database/sql/testing.ts index 75b034b..08403cd 100644 --- a/database/sql/testing.ts +++ b/database/sql/testing.ts @@ -372,6 +372,41 @@ export async function testQueryable( await queryable.close(); }); + await t.step( + "firstValues and firstRecord return the first row or undefined", + async () => { + const queryable = await create(); + await queryable.connect(); + const [expectedRecord] = await queryable.query(sql.query).toRecords(); + assertEquals( + await queryable.query(sql.query).firstRecord(), + expectedRecord, + ); + const [expectedValues] = await queryable.query(sql.query).toValues(); + assertEquals( + await queryable.query(sql.query).firstValues(), + expectedValues, + ); + // An empty result resolves to undefined. + assertEquals( + await queryable.query(sql.emptyQuery).firstRecord(), + undefined, + ); + assertEquals( + await queryable.query(sql.emptyQuery).firstValues(), + undefined, + ); + // Taking the first row consumes the result. + const result = queryable.query(sql.query); + await result.firstValues(); + await assertRejects(() => result.toValues(), QueryError, "consumed"); + const record = queryable.query(sql.query); + await record.firstRecord(); + await assertRejects(() => record.toValues(), QueryError, "consumed"); + await queryable.close(); + }, + ); + await t.step("binds parameters", async () => { const queryable = await create(); await queryable.connect(); diff --git a/database/sql/utils.test.ts b/database/sql/utils.test.ts index 3ef2aac..6976ee4 100644 --- a/database/sql/utils.test.ts +++ b/database/sql/utils.test.ts @@ -106,6 +106,46 @@ Deno.test("createResultIterableContext", async (t) => { assertEquals(await ctx.columns(), ["a"]); }); + await t.step("firstValues and firstRecord read the first row", async () => { + const { start, log } = source(["a"], [1], [2]); + const ctx = createResultIterableContext(start); + assertEquals(await ctx.firstValues(), [1]); + assertEquals(log, ["started", "read 1", "closed"]); + const { start: start2, log: log2 } = source(["a"], [1], [2]); + const ctx2 = createResultIterableContext(start2); + assertEquals(await ctx2.firstRecord(), { a: 1 }); + assertEquals(log2, ["started", "read 1", "closed"]); + }); + + await t.step( + "firstValues and firstRecord are undefined without rows", + async () => { + const { start, log } = source(["a"]); + const ctx = createResultIterableContext(start); + assertEquals(await ctx.firstValues(), undefined); + assertEquals(log, ["started", "closed"]); + const { start: start2, log: log2 } = source(["a"]); + const ctx2 = createResultIterableContext(start2); + assertEquals(await ctx2.firstRecord(), undefined); + assertEquals(log2, ["started", "closed"]); + }, + ); + + await t.step("firstValues and firstRecord consume the result", async () => { + const { start } = source(["a"], [1]); + const ctx = createResultIterableContext(start); + assertEquals(await ctx.firstValues(), [1]); + await assertRejects(() => ctx.firstValues(), QueryError, "consumed"); + await assertRejects(() => ctx.firstRecord(), QueryError, "consumed"); + await assertRejects(() => ctx.toValues(), QueryError, "consumed"); + // The columns are still available. + assertEquals(await ctx.columns(), ["a"]); + const { start: start2 } = source(["a"], [1]); + const ctx2 = createResultIterableContext(start2); + assertEquals(await ctx2.firstRecord(), { a: 1 }); + await assertRejects(() => ctx2.firstValues(), QueryError, "consumed"); + }); + await t.step("stops the source when the iteration ends early", async () => { const { start, log } = source(["a"], [1], [2], [3]); for await (const _row of createResultIterableContext(start)) break; diff --git a/database/sql/utils.ts b/database/sql/utils.ts index 8758d57..4489575 100644 --- a/database/sql/utils.ts +++ b/database/sql/utils.ts @@ -190,6 +190,30 @@ export function createResultIterableContext( return values; } + async function firstRow( + map: (row: ResultObject) => T, + ): Promise { + assertUsable(); + if (consumed) { + throw new QueryError( + "The result has already been consumed: a result can be iterated or collected once", + ); + } + consumed = true; + const { columns, iterator, first } = await begin(); + try { + if (first.done) return undefined; + const values = first.value; + return map({ + values, + toRecord: () => getObjectFromRow({ columns, values }), + }); + } finally { + // The remaining rows are not read, so the source is stopped. + if (!first.done) await iterator.return?.(); + } + } + return { [Symbol.asyncIterator]: rows, async [Symbol.asyncDispose](): Promise { @@ -208,5 +232,7 @@ export function createResultIterableContext( }, toValues: () => collect((row) => row.values), toRecords: () => collect((row) => row.toRecord()), + firstValues: () => firstRow((row) => row.values), + firstRecord: () => firstRow((row) => row.toRecord()), }; }