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()), }; }