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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
74 changes: 26 additions & 48 deletions database/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<User>("SELECT * FROM users").toRecords());
// [{ id: 1, name: "Alice" }]
// The first row, or undefined when there are no rows.
console.log(await client.query<User>("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
Expand Down Expand Up @@ -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";
Expand All @@ -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" }]
```
24 changes: 19 additions & 5 deletions database/RFC_SQL.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,10 @@ type QueryParameters = ParameterType[] | Record<string, ParameterType>;

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
Expand Down Expand Up @@ -634,18 +637,21 @@ interface Queryable {
params?: QueryParameters,
options?: QueryOptions,
): Promise<ExecuteResult>;
query(
query<R = Record<string, unknown>>(
sql: Statement,
params?: QueryParameters,
options?: QueryOptions,
): ResultIterableContext;
): ResultIterableContext<unknown[], R>;
executeScript(sql: string, options?: QueryOptions): Promise<void>;
}
```

- `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<User>(...)`: the records are of the given type, without validating the
values against it.
- `executeScript` runs a script without parameters.

#### Result Context
Expand All @@ -663,6 +669,8 @@ interface ResultIterableContext<
columns(): Promise<string[]>;
toValues(): Promise<V[]>;
toRecords(): Promise<R[]>;
firstValues(): Promise<V | undefined>;
firstRecord(): Promise<R | undefined>;
}
```

Expand All @@ -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.
Expand All @@ -694,7 +705,10 @@ interface PreparedStatement extends AsyncDisposable {
readonly deallocated: boolean;
deallocate(): Promise<void>;
execute(params?, options?): Promise<ExecuteResult>;
query(params?, options?): ResultIterableContext;
query<R = Record<string, unknown>>(
params?,
options?,
): ResultIterableContext<unknown[], R>;
}
```

Expand Down
1 change: 1 addition & 0 deletions database/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 7 additions & 1 deletion database/drivers/sqlite/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type ClientOptions, SqlClient } from "../../sql/mod.ts";
import {
type SqliteConnectionOptions,
SqliteDriver,
type SqliteQueryParameters,
type SqliteTransactionOptions,
} from "./driver.ts";

Expand Down Expand Up @@ -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";
Expand All @@ -52,7 +57,8 @@ export interface SqliteClientOptions
* console.log(await client.query("SELECT * FROM users").toRecords());
* ```
*/
export class SqliteClient extends SqlClient<SqliteDriver, SqliteClientOptions> {
export class SqliteClient
extends SqlClient<SqliteDriver, SqliteClientOptions, SqliteQueryParameters> {
/**
* Create a SQLite client.
*
Expand Down
2 changes: 1 addition & 1 deletion database/drivers/sqlite/driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ Deno.test("SqliteClient", async (t) => {
new Uint8Array([1, 2]),
undefined,
true,
date as never,
date,
null,
],
),
Expand Down
53 changes: 53 additions & 0 deletions database/drivers/sqlite/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, SqliteParameterType>;

/**
* SqliteConnectionOptions
*
Expand Down
41 changes: 41 additions & 0 deletions database/mod.ts
Original file line number Diff line number Diff line change
@@ -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<User>("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";
20 changes: 16 additions & 4 deletions database/sql/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)");

Expand All @@ -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<User>("SELECT * FROM users").toRecords());

// Or take the first row, which consumes the result
console.log(await client.query<User>("SELECT * FROM users").firstRecord());
// { id: 1, name: "Alice" }

// SQL templates render the placeholders of the database
const name = "Alice";
Expand Down
Loading
Loading