feat: writes, procedures, Fastify and Express adapters, documentation
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
# Adapters
|
||||
|
||||
The core answers a plain request object, so it fits any framework:
|
||||
|
||||
```ts
|
||||
const response = await rest.handle({ method, path, query, headers, body, context });
|
||||
// { status, headers, body }
|
||||
```
|
||||
|
||||
`handle` never throws: every failure is a response. `path` is the path below where the API is mounted (`/assets/4`), `query` a `URLSearchParams`, `body` the parsed JSON.
|
||||
|
||||
## Node
|
||||
|
||||
`nodeListener` from the core serves `node:http` with no other dependency. It reads JSON bodies up to 1 MiB (`maxBodyBytes`).
|
||||
|
||||
```ts
|
||||
http.createServer(nodeListener(rest, { prefix: '/api', context: (req) => userFrom(req) })).listen(3000);
|
||||
```
|
||||
|
||||
## Fastify
|
||||
|
||||
```sh
|
||||
npm install @crudy/restloom-fastify
|
||||
```
|
||||
|
||||
```ts
|
||||
import { restloom } from '@crudy/restloom-fastify';
|
||||
|
||||
await app.register(restloom(rest, { context: (req) => req.user }), { prefix: '/api' });
|
||||
```
|
||||
|
||||
Fastify's own hooks, rate limits and logging run as for any route.
|
||||
|
||||
## Express
|
||||
|
||||
```sh
|
||||
npm install @crudy/restloom-express
|
||||
```
|
||||
|
||||
```ts
|
||||
import { restloom } from '@crudy/restloom-express';
|
||||
|
||||
app.use('/api', express.json(), restloom(rest, { context: (req) => req.user }));
|
||||
```
|
||||
|
||||
## Your own routes
|
||||
|
||||
A host that registers every route itself, to attach its own permissions or documentation to each, calls `rest.handle` from those routes. That is how crudy mounts restloom: each declared route checks its permission, then hands the request to restloom.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Dialects
|
||||
|
||||
A dialect is the package that knows one database: how to read its catalog, how to write its SQL, how to run a transaction and what its errors mean.
|
||||
|
||||
| Package | Database | Embeds | Writes return rows | Estimated count | Row security |
|
||||
|---|---|---|---|---|---|
|
||||
| `@crudy/restloom-postgres` | PostgreSQL 13+ | `json_build_object`, `json_agg` | `RETURNING` | planner estimate | yes |
|
||||
|
||||
## Postgres
|
||||
|
||||
```ts
|
||||
import { postgres } from '@crudy/restloom-postgres';
|
||||
|
||||
const db = postgres(pool);
|
||||
```
|
||||
|
||||
- **Transactions:** every request is one transaction, `READ ONLY` for reads, with `SET LOCAL statement_timeout` from `limits.timeoutMs`.
|
||||
- **Types:** enums read as text. A domain reads as its base type.
|
||||
- **Timestamps:** they are written in the session's time zone, as Postgres writes them in JSON.
|
||||
- **Procedures:** calls use named arguments, so the order in the body does not matter.
|
||||
|
||||
## Changing how SQL is written
|
||||
|
||||
Every piece of SQL comes from one overridable method of the dialect's renderer. To change one, subclass it and pass it in:
|
||||
|
||||
```ts
|
||||
import { PostgresRenderer, postgres } from '@crudy/restloom-postgres';
|
||||
|
||||
class Renderer extends PostgresRenderer {
|
||||
like(expr: string, pattern: string, insensitive: boolean) {
|
||||
return insensitive ? `unaccent(${expr}) ILIKE unaccent(${pattern})` : super.like(expr, pattern, false);
|
||||
}
|
||||
}
|
||||
|
||||
const db = postgres(pool, { renderer: new Renderer() });
|
||||
```
|
||||
|
||||
The pieces are listed in [writing a dialect](./writing-a-dialect.md).
|
||||
@@ -0,0 +1,65 @@
|
||||
# Discovery and overrides
|
||||
|
||||
restloom reads the database's catalog once, on the first request, and builds the API from it. `rest.reload()` reads it again on the next request, after a migration for example.
|
||||
|
||||
## What becomes a resource
|
||||
|
||||
`expose` names what is served. Nothing else is.
|
||||
|
||||
```ts
|
||||
expose: { schemas: ['app'] } // every table and view of app
|
||||
expose: { tables: ['app.assets', 'app.users'] } // only these
|
||||
```
|
||||
|
||||
A resource is named after its table. When two exposed schemas hold a table of the same name, both are named `schema_table`. `resources.<name>.expose: false` removes one, for example a junction table you only want used as a relation:
|
||||
|
||||
```ts
|
||||
resources: { asset_tags: { expose: false } }
|
||||
```
|
||||
|
||||
A resource's key is its primary key. A view has none, so it uses a column named `id` when it has one, or the columns you give it: `resources: { asset_rows: { key: ['asset_id'] } }`. A resource without a key can still be listed, but has no `/{id}` path and no stable page order.
|
||||
|
||||
## Columns
|
||||
|
||||
Every column is visible except:
|
||||
|
||||
- **Sensitive names:** columns whose name contains one of these words: `password`, `passwd`, `pwd`, `hash`, `hashed`, `salt`, `secret`, `token`, `api_key`, `private_key`, `otp`, `totp`, `mfa`, `recovery`. A word is a whole part of the name between underscores: `password_hash` and `reset_token` are hidden, `tokenizer` is not.
|
||||
- **Columns you hide:** `resources: { users: { hide: ['phone'] } }`.
|
||||
|
||||
`show` makes a sensitive-looking column visible on purpose: `resources: { files: { show: ['sha256_hash'] } }`. `sensitive` replaces the whole list with your own pattern.
|
||||
|
||||
A hidden column cannot be read, filtered, sorted or written, and naming it answers exactly as naming a column that does not exist. It can still join a relation.
|
||||
|
||||
## Relations
|
||||
|
||||
Relations come from foreign keys between exposed resources:
|
||||
|
||||
| In the catalog | Gives |
|
||||
|---|---|
|
||||
| `assets.owner_id` references `users.id` | `assets.owner`, to-one; `users.assets`, to-many |
|
||||
| `asset_tags` has a primary key made of a key to `assets` and a key to `tags` | `assets.tags` and `tags.assets`, many-to-many |
|
||||
|
||||
Names:
|
||||
|
||||
- **To-one:** the foreign key column without `_id` (`owner_id` gives `owner`). Otherwise the target table's name.
|
||||
- **To-many:** the source table's name.
|
||||
- **Collisions:** when a name is taken by a column or another relation, the next candidate is used. For to-one, that is the target table's name, then `<column>_<table>`. For to-many, it is `<table>_by_<column>`, and for many-to-many `<table>_via_<junction>`.
|
||||
|
||||
A relation to a table that is not exposed does not exist. A foreign key to a hidden table exposes nothing.
|
||||
|
||||
`relations` overrides, adds or removes them:
|
||||
|
||||
```ts
|
||||
resources: {
|
||||
users: {
|
||||
relations: {
|
||||
users: false, // remove the discovered one
|
||||
reports: { many: 'users', via: 'manager_id' }, // to-many: users whose manager_id is my key
|
||||
team: { one: 'teams', via: 'team_id' }, // to-one, e.g. for a view without foreign keys
|
||||
badges: { many: 'badges', through: 'user_badges' },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
An override with the name of a column is refused when the catalog is read, and so is any option naming a resource, column or table that does not exist. Call `await rest.resources()` at start so a typo stops your server from booting instead of failing its first request.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Errors
|
||||
|
||||
Every error is a JSON object with a stable code, and a message naming the part of the request at fault when there is one:
|
||||
|
||||
```json
|
||||
{ "error": "unknown_column", "message": "password_hash" }
|
||||
```
|
||||
|
||||
A message never contains SQL or database text.
|
||||
|
||||
## The request
|
||||
|
||||
| Code | Status | Means |
|
||||
|---|---|---|
|
||||
| `not_found` | 404 | no such resource, or no such row, or a row you may not see |
|
||||
| `method_not_allowed` | 405 | the resource does not allow this method, or a procedure was not called with POST |
|
||||
| `unknown_column` | 400 | the column does not exist or is hidden |
|
||||
| `unknown_relation` | 400 | the relation does not exist |
|
||||
| `unknown_operator` | 400 | the operator is not one of `eq neq gt gte lt lte in like ilike is` |
|
||||
| `bad_operator` | 400 | the operator does not apply to the column's type |
|
||||
| `invalid_value` | 400 | the value does not fit the column's type |
|
||||
| `bad_filter` | 400 | a filter is not `operator.value` |
|
||||
| `bad_logic` | 400 | an `or` or `and` is not `(term,term)` |
|
||||
| `bad_list` | 400 | an `in` list is malformed, empty, or too long |
|
||||
| `bad_name` | 400 | a dotted or quoted name is malformed |
|
||||
| `bad_syntax` | 400 | unbalanced quotes or parentheses |
|
||||
| `bad_select` | 400 | a select item is malformed |
|
||||
| `duplicate_select` | 400 | a name is selected twice |
|
||||
| `unknown_aggregate` | 400 | not one of `count sum avg min max` |
|
||||
| `bad_aggregate` | 400 | the aggregate does not apply to the column's type |
|
||||
| `aggregate_with_embed` | 400 | a grouped read cannot embed relations |
|
||||
| `aggregate_in_embed` | 400 | an embed cannot hold aggregates |
|
||||
| `bad_order` | 400 | an order term names a to-many column, an unorderable type, or, in a grouped read, an unselected column |
|
||||
| `not_embedded` | 400 | `relation.order`, `.limit` or `.offset` names a relation that is not embedded |
|
||||
| `too_deep` | 400 | a path or an embed walks more relations than `limits.depth` |
|
||||
| `too_many_filters` | 400 | more filters than `limits.filters` |
|
||||
| `bad_limit`, `bad_offset` | 400 | not a whole number |
|
||||
| `limit_exceeded` | 400 | a limit over `limits.maxPage` |
|
||||
| `bad_count` | 400 | `count` is not `exact` or `estimated` |
|
||||
|
||||
## Writes and procedures
|
||||
|
||||
| Code | Status | Means |
|
||||
|---|---|---|
|
||||
| `bad_body` | 400 | the body is not an object or a list of objects within `limits.bodyRows` |
|
||||
| `body_too_large` | 413 | the body is over the adapter's size limit |
|
||||
| `mixed_columns` | 400 | the rows of a body name different columns |
|
||||
| `not_writable` | 400 | the column cannot be written by this method |
|
||||
| `filter_required` | 400 | a bulk update or delete has no filter |
|
||||
| `out_of_scope` | 403 | a value contradicts the host's scope |
|
||||
| `unknown_argument` | 400 | the procedure's function takes no argument of that name |
|
||||
| `missing_argument` | 400 | an argument without a default is missing |
|
||||
|
||||
## The database
|
||||
|
||||
| Code | Status | Means |
|
||||
|---|---|---|
|
||||
| `duplicate` | 409 | a unique constraint refused the row |
|
||||
| `reference_missing` | 409 | a foreign key points to a row that does not exist |
|
||||
| `still_referenced` | 409 | the row is still referenced by another |
|
||||
| `value_required` | 400 | a required column has no value |
|
||||
| `value_rejected` | 400 | a check constraint refused the value |
|
||||
| `forbidden` | 403 | the database refused the session's role |
|
||||
| `timeout` | 503 | the statement ran past `limits.timeoutMs` |
|
||||
| `internal` | 500 | anything else; your `onError` hook receives it |
|
||||
@@ -0,0 +1,43 @@
|
||||
# Getting started
|
||||
|
||||
## Install
|
||||
|
||||
Install the core, the package for your database, and its driver:
|
||||
|
||||
```sh
|
||||
npm install @crudy/restloom @crudy/restloom-postgres pg
|
||||
```
|
||||
|
||||
| Database | Package | Driver |
|
||||
|---|---|---|
|
||||
| PostgreSQL 13+ | `@crudy/restloom-postgres` | `pg` |
|
||||
|
||||
## Serve a schema
|
||||
|
||||
```ts
|
||||
import http from 'node:http';
|
||||
import pg from 'pg';
|
||||
import { createREST, nodeListener } from '@crudy/restloom';
|
||||
import { postgres } from '@crudy/restloom-postgres';
|
||||
|
||||
const rest = createREST({
|
||||
db: postgres(new pg.Pool({ connectionString: process.env.DATABASE_URL })),
|
||||
expose: { schemas: ['app'] },
|
||||
});
|
||||
|
||||
http.createServer(nodeListener(rest, { prefix: '/api' })).listen(3000);
|
||||
```
|
||||
|
||||
Every table and view of `app` is now a resource, read-only:
|
||||
|
||||
```sh
|
||||
curl -g 'http://localhost:3000/api/assets?select=id,name,owner(name)&status=eq.open'
|
||||
```
|
||||
|
||||
## Next
|
||||
|
||||
- [Discovery and overrides](./discovery.md): what becomes a resource, which columns are hidden, how relations are named.
|
||||
- [Query grammar](./query-grammar.md): everything a GET can ask.
|
||||
- [Writes and procedures](./writes.md): turning writes on, calling functions.
|
||||
- [Security and hooks](./security.md): authorising requests, row security, scoping.
|
||||
- [Adapters](./adapters.md): Fastify, Express, or any framework.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Compared with PostgREST
|
||||
|
||||
restloom keeps what makes PostgREST pleasant, the catalog as the source and a query string anyone can type, and changes where control sits.
|
||||
|
||||
| | PostgREST | restloom |
|
||||
|---|---|---|
|
||||
| Runs as | a separate server | a library inside your backend |
|
||||
| Databases | PostgreSQL | PostgreSQL; more through dialects |
|
||||
| What is served | everything the role can reach in the schema | what you name in `expose`, minus hidden columns |
|
||||
| Sensitive columns | served unless the role cannot read them | hidden by name unless shown |
|
||||
| Writes | everything the role may write | off until a resource enables them, by column |
|
||||
| Authorisation | database roles and row security | your hooks, plus row security where the database has it |
|
||||
| Relations | inferred from foreign keys | inferred from foreign keys, overridable, removable |
|
||||
| Filtering through a to-many relation | an embed with `!inner` | `tags.name=eq.pci`: at least one; `not.`: none |
|
||||
| Ordering by a related column | on embedded resources | `order=owner.name`, `order=incidents.count` |
|
||||
| A name that is a keyword | not addressable as a filter | quote it: `"limit"=eq.5` |
|
||||
| Over-sized limit | capped | refused with `limit_exceeded` |
|
||||
| Errors | database messages | stable codes, no database text |
|
||||
|
||||
The grammar is close enough that most PostgREST reads work unchanged: `select`, `eq`, `in`, `is`, `like`, `order`, `limit`, `offset`, `or`, embeds, and `Prefer: return=representation`.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Security and hooks
|
||||
|
||||
restloom decides what can be asked. Your backend decides who may ask it, through hooks.
|
||||
|
||||
## What restloom enforces
|
||||
|
||||
- **Exposure:** only the schemas and tables you name are served, and only their visible columns.
|
||||
- **Bound values:** every value is a bound parameter, checked against its column's type before any SQL runs. Names come from the catalog and are quoted; nothing from a request is written into SQL.
|
||||
- **Bounded requests:** every request is limited:
|
||||
|
||||
| Limit | Default | Option |
|
||||
|---|---|---|
|
||||
| Rows per page | 50, at most 1000 | `limits.page`, `limits.maxPage` |
|
||||
| Relations walked by a path or an embed | 2 | `limits.depth` |
|
||||
| Filters, every or/and term included | 50 | `limits.filters` |
|
||||
| Items in one `in` list | 500 | `limits.inList` |
|
||||
| Rows in one write body | 1000 | `limits.bodyRows` |
|
||||
| Statement time | 5 s | `limits.timeoutMs` |
|
||||
|
||||
A request over a limit is refused, never quietly cut down.
|
||||
|
||||
- **Errors:** they answer stable codes. A database message never reaches the client. Unexpected errors answer `internal` and reach your `onError` hook.
|
||||
|
||||
## Hooks
|
||||
|
||||
```ts
|
||||
createREST({
|
||||
db,
|
||||
expose: { schemas: ['app'] },
|
||||
hooks: {
|
||||
authorize(request, target) { /* throw a RestError to refuse */ },
|
||||
async transaction(tx, context) { /* session settings, role */ },
|
||||
scope(resource, context) { return { tenant_id: context.tenantId }; },
|
||||
onError(error, request) { log.error(error); },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
| Hook | Runs | Use it to |
|
||||
|---|---|---|
|
||||
| `authorize(request, target)` | before any SQL, with `{ resource, action }` or `{ procedure, action: 'call' }` | refuse a request: `throw new RestError(403, 'forbidden')` |
|
||||
| `transaction(tx, context)` | first, in every transaction | set the role and session settings row security reads |
|
||||
| `scope(resource, context)` | for every resource a query touches, embeds and relation filters included | add equality filters, e.g. a tenant column, on databases without row security |
|
||||
| `onError(error, request)` | for every answer of `internal` | log it |
|
||||
|
||||
`context` is what your adapter builds from the request, usually the authenticated user. restloom never reads it.
|
||||
|
||||
## Row security
|
||||
|
||||
With Postgres, row security is the strongest perimeter: the database itself hides the rows a session may not see. Connect as a role without `BYPASSRLS`, and set what your policies read in the `transaction` hook:
|
||||
|
||||
```ts
|
||||
async transaction(tx, context) {
|
||||
await tx.query("SELECT set_config('app.user_id', $1, true)", [context.userId]);
|
||||
}
|
||||
```
|
||||
|
||||
Every read and write of the request then runs under those policies, embeds and relation filters included.
|
||||
|
||||
## Scope
|
||||
|
||||
On a database without row security, `scope` keeps a request inside its perimeter. It returns column values a row must have, and restloom applies them:
|
||||
|
||||
- **Reads:** every query on the resource, and every relation reaching it, only matches rows with those values.
|
||||
- **Inserts:** the scoped columns are filled in. A body giving another value is refused with `out_of_scope`.
|
||||
- **Updates and deletes:** only rows with those values are reached. An update moving a row out of scope is refused.
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
# Writes and procedures
|
||||
|
||||
Writes are off until a resource enables them. The examples on this page run with these options, against the [demo schema](../conformance/fixtures/postgres.sql):
|
||||
|
||||
```ts
|
||||
resources: {
|
||||
tags: { write: true },
|
||||
assets: { write: { create: ['name', 'status', 'owner_id', 'cost'], update: ['name', 'status', 'owner_id'], delete: false } },
|
||||
incidents: { write: { create: true, update: true, delete: true, bulk: true } },
|
||||
},
|
||||
procedures: {
|
||||
'assets/{asset}/retire': { function: 'demo.retire_asset' },
|
||||
'tags/{tag}/assets': { function: 'demo.assets_tagged' },
|
||||
},
|
||||
```
|
||||
|
||||
`write: true` allows create, update and delete of every visible column. A list allows only the columns named. Generated and identity columns are never writable, and neither is a key on update.
|
||||
|
||||
| Method | Path | Does | Answers |
|
||||
|---|---|---|---|
|
||||
| POST | `/tags` | creates one row, or an array of rows | 201 |
|
||||
| PATCH | `/tags/4` | updates one row | 204, or 200 with the row |
|
||||
| DELETE | `/tags/4` | deletes one row | 204, or 200 with the row |
|
||||
| PATCH, DELETE | `/incidents?severity=lt.3` | by filter, only where `bulk: true` | 204, or 200 with the rows |
|
||||
|
||||
## Getting the rows back
|
||||
|
||||
Without a `Prefer` header a write answers with no body. `Prefer: return=representation` answers the written rows, read back with the request's `select`, so embeds work too.
|
||||
|
||||
```http
|
||||
POST /tags?select=id,name
|
||||
Prefer: return=representation
|
||||
|
||||
{ "id": 4, "name": "critical" }
|
||||
```
|
||||
|
||||
Answer: 201
|
||||
|
||||
```json
|
||||
[{ "id": 4, "name": "critical" }]
|
||||
```
|
||||
|
||||
```http
|
||||
POST /tags
|
||||
|
||||
[{ "id": 5, "name": "legacy" }, { "id": 6, "name": "edge" }]
|
||||
```
|
||||
|
||||
Answer: 201
|
||||
|
||||
Every row of a body names the same columns, so one statement writes them all.
|
||||
|
||||
```http
|
||||
POST /tags
|
||||
|
||||
[{ "id": 7, "name": "a" }, { "name": "b" }]
|
||||
```
|
||||
|
||||
```json
|
||||
{ "error": "mixed_columns" }
|
||||
```
|
||||
|
||||
A column outside the resource's writable list is refused, whatever its value:
|
||||
|
||||
```http
|
||||
POST /assets
|
||||
|
||||
{ "name": "new laptop", "status": "open", "active": false }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "error": "not_writable", "message": "active" }
|
||||
```
|
||||
|
||||
## Updating and deleting
|
||||
|
||||
PATCH and DELETE act on one row, by its key. A row that does not exist, or is not visible, answers 404.
|
||||
|
||||
```http
|
||||
PATCH /assets/2?select=id,name,owner(name)
|
||||
Prefer: return=representation
|
||||
|
||||
{ "owner_id": 1 }
|
||||
```
|
||||
|
||||
Answer: 200
|
||||
|
||||
```json
|
||||
{ "id": 2, "name": "mail relay", "owner": { "name": "Ada" } }
|
||||
```
|
||||
|
||||
```http
|
||||
DELETE /tags/4
|
||||
```
|
||||
|
||||
Answer: 204
|
||||
|
||||
A row still referenced by another is not deleted:
|
||||
|
||||
```http
|
||||
DELETE /tags/3
|
||||
```
|
||||
|
||||
```json
|
||||
{ "error": "still_referenced" }
|
||||
```
|
||||
|
||||
```http
|
||||
DELETE /assets/1
|
||||
```
|
||||
|
||||
```json
|
||||
{ "error": "method_not_allowed" }
|
||||
```
|
||||
|
||||
A resource with `bulk: true` also takes PATCH and DELETE on the collection, with at least one filter. Without a filter they are refused, so no request can empty a table by accident.
|
||||
|
||||
```http
|
||||
DELETE /incidents?severity=lt.3&select=id,title
|
||||
Prefer: return=representation
|
||||
```
|
||||
|
||||
Answer: 200
|
||||
|
||||
```json
|
||||
[{ "id": 2, "title": "Spoofed mail" }]
|
||||
```
|
||||
|
||||
```http
|
||||
DELETE /incidents
|
||||
```
|
||||
|
||||
```json
|
||||
{ "error": "filter_required" }
|
||||
```
|
||||
|
||||
## Values
|
||||
|
||||
Body values are checked against each column's type, as query values are: a number for a number column, `true` or `false` for a boolean, a string in ISO 8601 for a date or a time. The database's own constraints answer with stable codes:
|
||||
|
||||
```http
|
||||
POST /tags
|
||||
|
||||
{ "id": 1, "name": "duplicate key" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "error": "duplicate" }
|
||||
```
|
||||
|
||||
```http
|
||||
POST /incidents
|
||||
|
||||
{ "id": 9, "title": "Lost badge", "asset_id": 99, "severity": 1, "opened_on": "2026-04-01" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "error": "reference_missing" }
|
||||
```
|
||||
|
||||
## Procedures
|
||||
|
||||
A procedure calls a database function at a path you choose. A `{name}` in the path passes that segment as the argument of the same name; the body's keys pass the others. Arguments are checked by name and type, and one with a default may be left out. A procedure is always a POST.
|
||||
|
||||
```http
|
||||
POST /assets/3/retire
|
||||
|
||||
{ "reason": "end of life" }
|
||||
```
|
||||
|
||||
```json
|
||||
3
|
||||
```
|
||||
|
||||
A function returning a set answers an array of its rows. Mail relay belongs to Ada since the update above.
|
||||
|
||||
```http
|
||||
POST /tags/gdpr/assets
|
||||
```
|
||||
|
||||
```json
|
||||
[
|
||||
{ "id": 2, "name": "mail relay", "status": "open", "owner_id": 1, "cost": 300, "active": true, "created_at": "2026-01-05T09:30:00+00:00" },
|
||||
{ "id": 4, "name": "payroll db", "status": "open", "owner_id": 3, "cost": 4000, "active": true, "created_at": "2026-02-11T16:45:00+00:00" }
|
||||
]
|
||||
```
|
||||
|
||||
```http
|
||||
POST /assets/3/retire
|
||||
|
||||
{ "why": "no reason" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "error": "unknown_argument", "message": "why" }
|
||||
```
|
||||
@@ -0,0 +1,54 @@
|
||||
# Writing a dialect
|
||||
|
||||
A dialect implements one interface:
|
||||
|
||||
```ts
|
||||
interface Dialect {
|
||||
name: string;
|
||||
capabilities: { returning: boolean; estimatedCount: boolean; rowSecurity: boolean };
|
||||
renderer: SqlRenderer;
|
||||
introspect(scope: { schemas: string[]; tables: string[] }): Promise<Catalog>;
|
||||
transaction<T>(fn: (tx: Tx) => Promise<T>, options: { timeoutMs: number; readOnly: boolean }): Promise<T>;
|
||||
mapError(err: unknown): RestError | null;
|
||||
estimate?(tx: Tx, sql: string, params: unknown[]): Promise<number | null>;
|
||||
}
|
||||
```
|
||||
|
||||
| Member | Does |
|
||||
|---|---|
|
||||
| `introspect` | reads tables, views, columns, primary keys, foreign keys and functions, and maps each type to a logical one: `text`, `integer`, `number`, `boolean`, `uuid`, `date`, `timestamp`, `time`, `json`, `binary`, `array` or `other` |
|
||||
| `transaction` | runs a request's statements in one transaction with a statement timeout |
|
||||
| `mapError` | turns an error the client caused into a stable code; anything else returns `null` and answers `internal` |
|
||||
| `renderer` | writes the SQL |
|
||||
|
||||
The core decides everything else from the logical types: which operators a column accepts, which values are valid, which columns can be summed.
|
||||
|
||||
## The renderer
|
||||
|
||||
`SqlRenderer` writes Postgres SQL. A dialect subclasses it and overrides what its database writes differently. Statements are built only from these pieces, so an override reaches every statement that uses it:
|
||||
|
||||
| Method | Postgres writes |
|
||||
|---|---|
|
||||
| `quote(name)` | `"name"` |
|
||||
| `placeholder(n)` | `$n` |
|
||||
| `jsonObject(pairs)` | `json_build_object('a', x, ...)` |
|
||||
| `jsonArray(row, order)` | `COALESCE(json_agg(row ORDER BY order), '[]'::json)` |
|
||||
| `jsonValue(expr, column)` | the column, unchanged |
|
||||
| `nested(expr)` | a nested JSON subquery, unchanged |
|
||||
| `like(expr, pattern, insensitive)` | `LIKE` or `ILIKE ... ESCAPE '\'` |
|
||||
| `isBoolean(expr, value)` | `IS TRUE`, `IS FALSE` |
|
||||
| `orderBy(expr, desc, nulls)` | `ASC`/`DESC NULLS FIRST`/`LAST` |
|
||||
| `page(limit, offset)` | `LIMIT n OFFSET m` |
|
||||
| `first(select)` | `... LIMIT 1` |
|
||||
| `rowNumber(order)` | `ROW_NUMBER() OVER (ORDER BY ...)` |
|
||||
| `returning(resource)` | `RETURNING key` |
|
||||
| `writeTarget(resource, alias)` | `table AS alias` |
|
||||
| `call(fn, args)` | `SELECT to_json(fn(arg => $1))` |
|
||||
|
||||
Larger units can be overridden too: `relationSource`, `exists`, `filter`, `embed`, `read`, `count`, `insert`, `update`, `delete`.
|
||||
|
||||
Placeholders are written last: fragments carry internal tokens, and the final text gets the database's own placeholders in text order. A dialect with positional placeholders (`?`) gets its values in the right order whatever order the fragments were built in.
|
||||
|
||||
## Proving it
|
||||
|
||||
A dialect passes when the conformance suite does. Add a fixture with the demo schema and data in `conformance/fixtures/<dialect>.sql`, and a runner next to `postgres.test.mjs`. Every example of the docs pages then runs against your database and must answer exactly what the page shows.
|
||||
Reference in New Issue
Block a user