Reylo
Import .sql files into your TypeScript code with full type inference
Reylo lets you import .sql files with full type safety. It's powered by TypeScript content mappers, introduced in TypeScript 7.1 (currently in beta), an official mechanism for integrating external file types directly into TypeScript compilation.
import postgres from "postgres";
import from "./get_author.sql";
const db = postgres(process.env.DATABASE_URL!);
// hover over author to see its type
const = await .run(db, { id: 1 });View the schema
create type user_role as enum ('admin', 'member'); -- role: "admin" | "member"
create table users (
id serial primary key, -- at most one row per id
name text not null, -- name: string
email text unique, -- email: string | null
role user_role not null default 'member',
tags text[] not null default '{}', -- tags: string[]
created_at timestamptz not null default now() -- created_at: Date
);
create table posts (
id serial primary key,
user_id integer not null references users (id),
title text not null,
body text,
published_at timestamptz
);
create table events (
id serial primary key,
kind text not null,
payload jsonb not null
);- 💪 The full power of SQL. Any statement your database runs, in its own dialect.
- 🌵 DRY. The types come from the SQL itself: no hand-written row types, no schema file to keep in sync.
- 🎯 Checked against your database. A misspelled column, a wrong parameter or a dropped table is a type error, in
tscand in your editor. - 🤖 Agent-friendly. Each fact lives in one place, so an agent cannot leave a stale type behind, and the type check finds every caller it missed.
- 🧩 Postgres, MySQL and SQLite. Bring your own driver:
pg, postgres.js, mysql2, better-sqlite3, Bun, Cloudflare D1 and more. - âš¡ No codegen. The type checker asks Reylo directly: no generated files, no watcher.
Setup
Set up Reylo with an agent
Paste these instructions into Claude Code, Codex, Cursor or any coding agent.
Set up Reylo in this project. Reylo lets TypeScript import .sql files, with types
inferred from the live database. Its docs are one page, https://reylo.dev: follow the
section linked at each step.
1. Check the requirements and install Reylo with TypeScript 7.1 beta or later:
https://reylo.dev/#install
2. Add Reylo to tsconfig.json as a content mapper, and add a typecheck script that runs
tsc --noEmit --runExternalCode: https://reylo.dev/#typescript
3. Point Reylo at the development database with DATABASE_URL: https://reylo.dev/#url
4. Make .sql imports load wherever the project runs code, with Node's --import
(https://reylo.dev/#node) or the plugin for its runtime, bundler or framework
(https://reylo.dev/#plugins).
5. Set up the editor the project uses: https://reylo.dev/#editors
6. Write a first query and run it with the project's own driver:
https://reylo.dev/#usage and https://reylo.dev/#supported-drivers
7. Run the typecheck script. The query's types come from the database, and an error in
a .sql file shows at its line and column.Install Reylo and point it at your database, then add it to each tool that loads your code.
Install
Reylo needs:
- TypeScript 7.1 beta or later. The first release with content mappers.
- Node 24 or later, Bun or nub.
- A running dev database. Reylo's type inference engine needs to know your current database schema. It does this by introspecting your live database: Postgres, MySQL 8.0.22 or later, or a SQLite file.
npm install reylo
npm install -D typescript@betaThen set DATABASE_URL to your database, in the environment or in .env: see url.
TypeScript
Add Reylo to tsconfig.json:
{
"compilerOptions": { /* ... */ },
"contentMappers": [
{ "package": "reylo", "extensions": [".sql"] }
]
}Type-check with --runExternalCode, which lets tsc run content mappers. Without it, tsc reports TS18068 and finds no .sql module (TS2307).
{
"scripts": {
"typecheck": "tsc --noEmit --runExternalCode"
}
}A .sql file holds one select, insert, update, delete or values; merge on Postgres; replace on MySQL and SQLite; or table on Postgres and MySQL. The type check reports every other .sql file it reads, imported or not, such as a migration or a schema, so keep those out of tsconfig.json:
{
"compilerOptions": { /* ... */ },
"contentMappers": [ /* ... */ ],
"exclude": ["db/**"]
}reylo generate skips such files, and only the type check reports them: the plugins and the preload build whatever you import.
Starting from an empty project
A new project also needs Node's types and ES modules:
npm install -D @types/node{
"compilerOptions": {
"module": "nodenext",
"strict": true,
"types": ["node"]
},
"contentMappers": [
{ "package": "reylo", "extensions": [".sql"] }
]
}{
"type": "module"
}TypeScript 7 loads @types/node only when types names it, and "type": "module" lets main.ts use a top-level await.
Editors
The editor needs the TypeScript 7.1 language server, with content mappers on.
-
Install the TypeScript 7 extension (
TypeScriptTeam.native-preview). -
Point it at the project's TypeScript: the extension's own copy is TypeScript 7.0, which has no content mappers.
.vscode/settings.json { "js/ts.experimental.useTsgo": true, "js/ts.tsdk.path": "./node_modules/typescript" } -
Trust the workspace, and click Allow when the extension asks to use the workspace's TypeScript. Content mappers run in a trusted workspace only.
The editor's environment is not your shell's, so keep DATABASE_URL in .env or .env.local, where Reylo finds it. To suggest the extension to everyone who opens the project:
{ "recommendations": ["TypeScriptTeam.native-preview"] }Node
Node loads Reylo with --import, which turns each .sql import into a module as it loads, and --env-file loads your .env for the app:
node --env-file=.env --import reylo/register main.tsPlugins
Everywhere else, a plugin turns each .sql import into a module: Bun and nub load Reylo's preload from their config files, and a bundler builds each module at build time, so a bundled app needs no --import and no preload.
Usage
Write a statement in a .sql file, with a :name for each parameter, and import it. A select by primary key resolves to the row or null:
select id, name, email from users where id = :idimport from "postgres";
import from "./get_user.sql";
const = (..!);
const user = await .(, { : 1 });An insert with returning resolves to the row it wrote:
insert into users (name, email) values (:name, :email)
returning idimport from "./create_user.sql";
const created = await .(, { : "Ada", : "ada@example.com" });An update by primary key with returning resolves to the row or null:
update users set name = :name where id = :id
returning id, nameimport from "./rename_user.sql";
const renamed = await .(, { : 1, : "Ada Lovelace" });The module also exports the statement's types:
import , { type , type Row } from "./get_user.sql";run
run(client, params) runs the statement and resolves to its result, shaped by how many rows Reylo proves the statement can return:
| The statement | cardinality | run resolves to |
|---|---|---|
A match on a primary or unique key, or limit 1 | maybeOne | Row | null |
An aggregate without group by, a one-row insert … returning | one | Row |
An insert, update or delete without returning | none | void |
| Anything else | many | Row[] |
At run time, a one query throws unless it gets exactly one row, and a maybeOne query throws when it gets more than one. The client is one from any of the supported drivers, or a transaction, savepoint or connection taken from it, and run always returns a promise, which rejects with the driver's own error. The parameters may also be an array, in the order of params:
import from "./get_user.sql";
const = await .(, [1]);runSync
On better-sqlite3, node:sqlite and bun:sqlite, runSync runs the statement synchronously and returns the result itself, so it works inside better-sqlite3's and bun:sqlite's db.transaction(fn), whose function cannot await:
import from "better-sqlite3";
import from "./create_user.sql";
import from "./rename_user.sql";
const = new ("app.db");
.(() => {
const { } = .(, { : "Ada", : null });
.(, { , : "Ada Lovelace" });
})();node:sqlite has no db.transaction: run the statements between db.exec("begin") and db.exec("commit").
with
with(client) binds the statement to a client once, such as a transaction, and returns a function that takes the parameters:
import from "./get_user.sql";
const = .();
const = await ({ : 1 });sql
The statement as Reylo sends it to the database, typed as its string literal:
import from "./get_user.sql";
.sql;raw
The file's contents, as written:
import from "./get_user.sql";
.raw;params
The parameters' names, in the order an array of values follows. A query also carries its row count, cardinality, and its dialect:
import from "./get_user.sql";
.; // ["id"]
.; // "maybeOne"inputSchema
The JSON Schema (2020-12) of the parameters, as a plain object, for anything that takes JSON Schema: an OpenAPI document, a form, an agent's tool definition.
import from "./get_user.sql";
.;
// { type: "object", properties: { id: { type: "integer", … } }, required: ["id"], … }outputSchema
The JSON Schema of the result, as JSON.stringify writes it:
import from "./get_user.sql";
.;
// { anyOf: [{ type: "object", properties: { id, name, email }, … }, { type: "null" }] }A query is also a Standard JSON Schema, so a library that reads one gets both schemas from getUser["~standard"].jsonSchema.
Parameters
Each :name in a statement is a parameter, and run takes them as an object. Reylo types each one from where the statement uses it: the column it is compared with or written to, or the function it is passed to.
Casts
Most parameters need no cast. Where nothing types one, cast it, with ::type on every dialect or with the dialect's own cast(… as …). Here only Postgres needs one:
select id, name from users
where name like concat(:prefix::text, '%')import from "./users_named.sql";
const = await .(, { prefix: "Ad" });concat takes any type, so the cast types :prefix.
On MySQL and SQLite, Reylo writes ::type as the dialect's own cast: :n::int is cast(? as signed) on MySQL and cast(? as integer) on SQLite. Their casts turn a value they cannot read into 0 or null, so Reylo checks the value against the type before the statement runs.
null
A parameter takes null where the statement lets it be null: a nullable column it is written to, as email in create_user.sql above, or a coalesce, as in Filter on an optional value.
Lists
A Postgres array parameter takes a JavaScript array, as in where id = any(:ids) (Filter by a list).
JSON parameters
A JSON parameter carries a JSON Schema in its cast. The schema types it in TypeScript, and Reylo checks every value against it before the statement runs, so a list, a batch of rows or a nested object is one parameter:
with input as (
select :names::jsonb('{ "type": "array", "items": { "type": "string" } }') as names
)
insert into users (name)
select jsonb_array_elements_text(input.names) from inputimport from "./add_users.sql";
await .(, { names: ["Ada", "Grace"] });- The schema is JSON Schema 2020-12:
string,number,integer,boolean,null,objectandarray, a key optional unlessrequiredlists it, and{ "type": ["string", "null"] }for a value that may benull. - Its checks, such as
format,pattern,minimum,maxItemsandenum, run before the statement does, and a value that fails makesrunreject with every issue listed.enumandconstalso narrow the type, andoneOf,anyOfandallOfmake a union. - The schema stays in Reylo: the database receives the value as JSON text,
$1::jsonbon Postgres,cast(? as json)on MySQL and?on SQLite.
The examples use JSON parameters to insert many rows, insert related rows and record events of several kinds.
Positional parameters
A statement may use its database's own positional form instead of :name: $1 on Postgres, ? on MySQL, ?1 on SQLite. The key is then the number, as in { 1: 42 }.
Configuration
Reylo's settings go in .reylo.yaml, beside package.json, or in the file the REYLO_CONFIG variable names; tsconfig.json only names the content mapper.
Every key is optional: a project without the file behaves as if it held url: ${DATABASE_URL}. A key Reylo does not know is an error at its line and column, reported on every .sql import; Reylo ignores a top-level $schema and the api section, which is Reylo Framework's.
url
The database Reylo analyzes each statement against. The default is ${DATABASE_URL}, and the scheme names the dialect:
# or postgresql://; the type check uses your pg or postgres.js
DATABASE_URL=postgres://localhost:5432/appA ${VAR} comes from the environment, then from the env files. One that nothing sets is an error, which every .sql import reports. Write the URL itself, or another variable:
url: ${APP_DATABASE_URL}direct_url
A URL that reaches the database past a pooler. Describing a statement through a pooler in transaction mode, such as PgBouncer's, can fail, so when direct_url is set, Reylo's analysis (the type check, the editor and reylo generate) connects through it instead of url, and keys .reylo/ by it. It has no default, and your app's client is not affected.
url: ${DATABASE_URL}
direct_url: ${DIRECT_URL}env
The .env files Reylo reads, relative to .reylo.yaml. The default is [.env.local, .env]: the first file that sets a variable wins, and the environment wins over every file.
env: [.env.development, .env]Reylo reads them for its own analysis only. Your app loads .env itself: Node takes --env-file (Node), and Bun and nub read .env on their own.
strict
With strict: true, an untyped parameter is an error instead of unknown. The default is false.
strict: truecli
The .sql files reylo generate analyzes, the command that fills .reylo/ ahead of a build without a database (In CI). The globs are relative to .reylo.yaml: include replaces the default, every .sql file, and exclude adds to what is always left out: node_modules, build output, directories whose names start with a dot, and what git ignores. The type check reads the files tsconfig.json includes instead.
cli:
include: ["src/**/*.sql"]
exclude: ["src/legacy/**"]How it works
Reylo is a content mapper, the kind of plugin TypeScript 7.1 introduced for files that are not TypeScript. Your tsconfig.json names Reylo for .sql files, so whenever the type checker meets a .sql import, in tsc or in your editor, it hands Reylo the file and takes back the module's types. It is TypeScript's own mechanism, so the types are the same in CI and as you type.
To type a statement, Reylo asks your live database. It has the database prepare the statement, without running it (Postgres and MySQL on the server, SQLite in Reylo's own copy of SQLite, from the database file's schema), and reads the catalog. From the answer, Reylo learns:
- the parameter types
- the column types
- nullability, from
NOT NULLand outer joins - the row count, from keys, aggregates,
limit 1andreturning
At run time, Node's --import, Bun's or nub's preload, or a bundler plugin turns the same file into a module that runs the statement through your driver.
Reylo keeps each answer in .reylo/, keyed by a fingerprint of the schema, so it asks the database again only for a schema it has not seen. After a migration, from any migration tool, the next type check sees the new schema: a statement that names a dropped or renamed column fails in its .sql file, and a caller that reads a column the statement no longer returns fails in TypeScript.
What an error looks like
An error in a .sql file is a type error at its line and column, in the type check and in the editor:
$ tsc --noEmit --runExternalCode
get_user.sql:1:12 - error reylo6: column "nmae" does not exist (42703)
1 select id, nmae, email
~~~~
Found 1 error in get_user.sql:1A wrong argument is an error at the call site:
import from "./get_user.sql";
const = await .(, { id: "1" });While a .sql file has an error, tsc holds back the TypeScript errors, so fix the SQL first. At run time, run rejects with the driver's own error, and with Reylo's when the rows do not fit the row count (run).
Where each type comes from, in each dialect
Reylo prepares the statement on the server and reads the description of its parameters and columns, then reads the catalog for NOT NULL, keys and enum labels.
| What | Where it comes from |
|---|---|
| Parameter types | the server's description of the prepared statement |
| Column types | the server's description; an enum as its labels, a domain as its base type, a JSON column as what its JSON Schema CHECK admits |
| Nullability | NOT NULL in the catalog, outer joins, the expressions |
| Row count | primary and unique keys, aggregates, limit 1, returning |
| Nested JSON | jsonb_build_object, jsonb_agg, and to_jsonb of a row, typed from their arguments |
When nothing in the statement types a parameter
Postgres types each parameter from its context. A bare select :x is text, a string, and select :x is null is an error (42P18) until a cast types it.
Which matches count as a key, in each dialect
A key match also counts through a join on the joined table's key, a unique index on an expression or with a where, and a distinct or group by over values the where pins.
In CI, and in builds without a database
A CI job that type-checks starts the database, applies the migrations, and runs tsc --noEmit --runExternalCode with DATABASE_URL set. Tests need the database too, or a filled .reylo/: Vitest's Vite plugin and Jest's transformer analyze each .sql file as a build does.
Every step that loads a .sql file (the type check, a plugin, the preload) keeps its analysis in .reylo/, and a later step reads it from there:
.reylo/is a cache with its own.gitignore, so git skips it.- When the database answers, an entry made against another schema is analyzed again. An editor or a dev server checks the schema again at most every 5 seconds.
- When the database is out of reach, the entries are used as they are, and a statement that
.reylo/lacks is an error.
So where the build cannot reach the database, such as a container build, run reylo generate where it can, which fills .reylo/ and writes no code, and carry .reylo/ to the build:
$ reylo generate
postgres at localhost/reylo_docs (DATABASE_URL in .env)
src/admin/find_by_email.sql ✗
src/admin/set_email.sql ✓
src/queries/count_users.sql ✓
src/queries/create_user.sql ✓
src/queries/get_user.sql ✓
src/queries/list_users.sql ✓
src/admin/find_by_email.sql:1:12: column "nmae" does not exist (42703)
1 | select id, nmae
| ^^^^
Skipped 1 file that is not one query (a migration?): db/schema.sql
✗ 1 of 6 queries failed (0.1 s)reylo generate reads every .sql file under the given paths, the current directory by default. It skips node_modules, build output, dot directories and what git ignores; the cli keys of .reylo.yaml change the set. --force asks the database again for every statement. The exit code is 1 when a query fails.
Reylo keys each entry by the server it came from (scheme, user, host, port and database), or by the SQLite file's absolute path. So the build sets the same URL as the place where reylo generate ran (DATABASE_URL, or direct_url when it is set), even though it cannot connect, and a SQLite project keeps its path.
Supported drivers
Reylo is bring-your-own-driver: pass run a client from any of these, or a transaction, savepoint or connection taken from one (see Run statements in a transaction). The type check reads the schema through pg or postgres.js on Postgres and through mysql2 on MySQL, so an app on another driver adds one of those as a dev dependency. Every driver gives Reylo's queries the same values, whatever it is set up to return for yours (Data types).
Postgres
import from "pg";
import from "./get_user.sql";
const = new .({ : .. });
const = await .(, { : 1 });Each takes its transactions and connections too: pg's Pool, Client and PoolClient; postgres.js's and Bun.sql's begin, savepoint and reserve; PGlite's transaction; Neon's Pool and Client; and pg-promise's task and tx.
MySQL
import from "mysql2/promise";
import from "./get_user.sql";
const = .(..!);
const = await .(, { : 1 });Each takes its transactions and connections too: mysql2's and mariadb's promise or callback API, PlanetScale's transaction, and Bun.sql's begin, savepoint and reserve. Use PlanetScale's driver 1.x: 2.0 binds no parameters.
SQLite
import from "better-sqlite3";
import from "./get_user.sql";
const = new ("app.db");
const = await .(, { : 1 });Each takes its transactions too: libSQL's transaction(), Bun.sql's begin and savepoint, and D1's withSession() sessions. better-sqlite3, node:sqlite and bun:sqlite take runSync as well (Run statements in a transaction).
Data types
Reylo gives every value one type, the same on every driver, whatever the driver is set up to return: it decodes each value of its own queries itself, on your client, and leaves your other queries as your driver decodes them. A 64-bit integer and a decimal are their exact text, an instant is a Date, a date or a time without a zone is the text the database wrote, JSON is parsed and binary data is a Uint8Array.
| SQL type | TypeScript |
|---|---|
smallint, integer, oid | number |
bigint | string |
real, double precision | number |
numeric, money | string |
text, varchar, char | string |
boolean | boolean |
uuid | string |
date | string, as 2026-10-20 |
timestamp | string, as 2026-10-20T10:00:00.123456 |
timestamptz | Date |
time, timetz, interval | string |
json, jsonb | unknown |
json, jsonb a JSON Schema CHECK holds (jsonb_matches_schema(…)) | what the schema admits |
jsonb_build_object(…), jsonb_agg(…) | the object or array it builds |
bytea | Uint8Array |
| An array | an array of its element type, a NULL element null |
| An enum | the union of its labels |
| A domain | its base type |
| A row value | string |
Every other type (inet, cidr, ranges, point, tsvector, xml, …) | string, as the database writes it |
A call to a function has the type the function declares.
How Reylo decodes on each driver
Each driver reads the database's protocol as it always does; Reylo makes only the choice per type that follows, for its own query, on the client you pass. A parser or option you set for your own queries does not reach Reylo's, and none of Reylo's reaches yours.
pg, pg-promise, Neon and@vercel/postgrestake a decoder table with each query, and Reylo's answers every type. PGlite takes one too, naming the statement's types.- postgres.js hands Reylo each value's text (
.raw()), and Reylo builds the row with the query's own column names, so atransformthat renames your columns does not rename Reylo's. - mysql2 takes Reylo's options with each query (
execute), mariadb every option, and PlanetScale Reylo'scast. - better-sqlite3,
node:sqliteandbun:sqliterun a statement Reylo prepares once per database, with its own integer setting, whatever the database's. - Bun.sql, libSQL, D1,
sqlite3andsqlitetake nothing per query, and Reylo converts the values they give.
Reylo refuses what would change its values and that it cannot undo: a pg client set up with binary: true, a pg-promise database whose receive event rewrites results, and a postgres.js client with a transform.row.
What a driver loses before Reylo sees the value
- Bun.sql keeps a
timestamp's and aDATETIME's milliseconds, not their microseconds, writes no fraction digits that are all zero, drops aTIME's fraction, and throws on aNULLinside anint4[]or afloat4[]. - A
real(float4) or a MySQLFLOATis a 32-bit float: a driver that reads the binary protocol (Bun.sql, mysql2) gives its exact value,1.100000023841858, and one that reads the database's text gives the shorter text's,1.1. sqlite3,sqlite, Cloudflare D1, and Bun.sql withoutsafeIntegersround an integer past 2^53 before Reylo sees it, so Reylo refuses it; libSQL at its defaultintModerefuses it itself. With libSQL'sintMode: "string", an integer in a column with no type is its digits.- JSON is parsed by
JSON.parse, so an integer past 2^53 inside a document is rounded, on every driver.
Binary, date and JSON values, in and out
- A
Buffer, which several drivers return for binary data, is aUint8Array. - A binary parameter takes a
Uint8Array, aBufferor not, on every driver: mariadb, which writes any other value as JSON, gets it from Reylo as aBuffer. - On Postgres, an array parameter takes a JavaScript array on every driver, for a SQL array (
any(:ids),:tags::text[]) and for JSON (:data::jsonb) alike: Reylo writes it in the form the driver binds right. - A
timestamptzparameter takes aDate, or a string that names its zone:"2026-10-12T19:06:48Z","2026-10-12 19:06:48+09","2026-10-12 19:06:48 Asia/Tokyo". Atimestampordateparameter, and on MySQL aDATETIME,TIMESTAMPorDATEone, has no zone and takes the wall-clock text,"2026-10-12 19:06:48"or"2026-10-12", which is also what Reylo returns for such a column: drivers write aDate's time in different zones, the app's, the server's or UTC, so Reylo refuses one before the statement runs. - A parameter the statement reads as JSON (
:data::jsonb,cast(:data as json), ajsoncolumn) takes any JSON value, and the database receives its JSON text on every driver: a string is a JSON string, so'{"a":1}'is stored as the string"{\"a\":1}", not as an object. To pass JSON text you already hold, cast it from text::data::text::jsonb. - Inside nested JSON the database writes each value as JSON: a
bigintis a number there and a date its text.
Examples
Each example shows the statement in Postgres, MySQL and SQLite, against the schema at the top of the page; pick a dialect once and every example follows it. The examples call the client db.
List rows
A statement that can match more than one row resolves to an array.
select id, name, role, tags, created_at from users
where role = :role
order by created_at desc
limit :limitimport from "./list_users.sql";
const users = await .(, { : "member", : 20 });Count rows
An aggregate without group by is exactly one row.
-- count(*) is a bigint, a string; count(*)::int is a number
select count(*) as total from users where role = :roleimport from "./count_users.sql";
const { total } = await .(, { : "admin" });Filter on an optional value
coalesce lets a parameter be null: here null matches every role. A parameter takes null only where the statement allows it, such as a coalesce or a nullable column it is written to.
select id, name from users
where role = coalesce(:role, role)import from "./users_by_role.sql";
const = await .(, { role: "admin" });Sort by a chosen column
A parameter is a value, never a name: order by :column sorts by a constant, and select * from :table is a syntax error. Choose the column with case:
select id, name, created_at from users
order by
case :sort when 'name' then name end,
created_at descimport from "./sorted_users.sql";
const users = await .(, { : "name" });Search several columns
Each parameter appears once in a statement, so that one place types it, and a second use is an error. To use a value twice, select it once in a with:
with input as (
select concat('%', :q::text, '%') as pattern
)
select id, name from users, input
where name like input.pattern or email like input.patternimport from "./search_users.sql";
const users = await .(, { : "ada" });Join tables
A column of the table a left join adds can be null. coalesce gives it a default.
select u.name, p.title, coalesce(p.title, '') as label
from users u
left join posts p on p.user_id = u.idimport from "./user_posts.sql";
const rows = await .();Page through rows
Pass the sort key of the last row you have, here created_at and id, to get the rows after it.
select id, name, created_at from users
where (created_at, id) < (:created_at, :id)
order by created_at desc, id desc
limit 20import from "./next_page.sql";
// the first page: a cursor after every row
const = await .(, {
: "3000-01-01 00:00:00+00:00",
: 0,
});
const next = await .(, .(-1)!);Filter by a list
Pass the list as one parameter.
select id, name from users where id = any(:ids)import from "./users_by_ids.sql";
const = await .(, { ids: [1, 2, 3] });Keep a list's order
Number the list's elements, and sort by the number.
-- with ordinality numbers the elements
select u.id, u.name
from unnest(:ids::int[]) with ordinality as i(id, position)
join users u on u.id = i.id
order by i.positionimport from "./users_in_order.sql";
const users = await .(, { : [3, 1, 2] });Nest related rows
Build the related rows as JSON in the statement. Reylo types the JSON from the arguments that build it.
select u.id, u.name,
coalesce((
select jsonb_agg(jsonb_build_object(
'id', p.id,
'title', p.title,
'at', p.published_at
))
from posts p where p.user_id = u.id
), '[]') as posts
from users u
where u.id = :idimport from "./user_with_posts.sql";
const user = await .(, { : 1 });Insert many rows
Pass the rows as one JSON parameter: the schema in its cast types them, and Reylo checks every row before the statement runs.
with input as (
select :users::jsonb('{
"type": "array",
"maxItems": 1000,
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"email": { "type": ["string", "null"], "format": "email" }
},
"required": ["name"]
}
}') as users
)
insert into users (name, email)
select u.name, u.email
from input, jsonb_to_recordset(input.users) as u(name text, email text)
returning idimport , { type Params } from "./create_users.sql";
const = await .(, {
: [{ : "Ada", : "ada@example.com" }, { : "Grace" }],
});Insert related rows
Users with their posts, in one parameter. The posts find their user by email, a unique key.
Postgres writes both tables in one statement: a with inserts the users, and the statement inserts the posts.
with input as (
select :users::jsonb('{
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"email": { "type": "string", "format": "email" },
"posts": {
"type": "array",
"items": {
"type": "object",
"properties": { "title": { "type": "string" } },
"required": ["title"]
}
}
},
"required": ["name", "email", "posts"]
}
}') as users
),
new_users as (
insert into users (name, email)
select u.name, u.email
from input, jsonb_to_recordset(input.users) as u(name text, email text)
returning id, email
)
insert into posts (user_id, title)
select n.id, p.title
from input
cross join jsonb_to_recordset(input.users) as u(email text, posts jsonb)
join new_users n on n.email = u.email
cross join jsonb_to_recordset(u.posts) as p(title text)import , { type Params } from "./create_users_with_posts.sql";
await .(, {
: [
{ : "Ada", : "ada@example.com", : [{ : "Notes" }] },
],
});Run statements in a transaction
A transaction, a savepoint or a connection taken from a client is a client too:
import from "postgres";
import from "./get_user.sql";
import from "./rename_user.sql";
const = (..!);
await .(async () => {
const = await .(, { : 1 });
await .(() =>
.(, { : !., : "Grace" }),
);
});Insert or update
When the email already exists, the insert updates that row instead. With returning, it resolves to the id of the row it wrote.
insert into users (name, email) values (:name, :email)
on conflict (email) do update set name = excluded.name
returning idimport from "./upsert_user.sql";
const = { : "Ada Lovelace", : "ada@example.com" };
const result = await .(, );Delete rows
A write without returning resolves to void.
delete from users where id = :idimport from "./delete_user.sql";
const result = await .(, { : 1 });Record events of several kinds
oneOf types a JSON parameter as a union. Here a const key tells the members apart:
with input as (
select :events::jsonb('{
"type": "array",
"items": {
"oneOf": [
{
"type": "object",
"properties": {
"kind": { "type": "string", "const": "click" },
"x": { "type": "integer" },
"y": { "type": "integer" }
},
"required": ["kind", "x", "y"]
},
{
"type": "object",
"properties": {
"kind": { "type": "string", "const": "key" },
"code": { "type": "string" }
},
"required": ["kind", "code"]
}
]
}
}') as events
)
insert into events (kind, payload)
select e.value ->> 'kind', e.value
from input, jsonb_array_elements(input.events) as e(value)import , { type Params } from "./record_events.sql";
await .(, {
: [
{ : "click", : 10, : 20 },
{ : "key", : "Enter" },
],
});