Reylo

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
schema.sql
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 tsc and 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@beta

Then set DATABASE_URL to your database, in the environment or in .env: see url.

TypeScript

Add Reylo to tsconfig.json:

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).

package.json
{
  "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:

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
tsconfig.json
{
  "compilerOptions": {
    "module": "nodenext",
    "strict": true,
    "types": ["node"]
  },
  "contentMappers": [
    { "package": "reylo", "extensions": [".sql"] }
  ]
}
package.json
{
  "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.

  1. Install the TypeScript 7 extension (TypeScriptTeam.native-preview).

  2. 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"
    }
  3. 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:

.vscode/extensions.json
{ "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.ts

Plugins

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:

get_user.sql
select id, name, email from users where id = :id
main.ts
import  from "postgres";
import  from "./get_user.sql";

const  = (..!);

const user = await .(, { : 1 });
const user: {
    id: number;
    name: string;
    email: string | null;
} | null

An insert with returning resolves to the row it wrote:

create_user.sql
insert into users (name, email) values (:name, :email)
returning id
main.ts
import  from "./create_user.sql";

const created = await .(, { : "Ada", : "ada@example.com" });
const created: {
    id: number;
}

An update by primary key with returning resolves to the row or null:

rename_user.sql
update users set name = :name where id = :id
returning id, name
main.ts
import  from "./rename_user.sql";

const renamed = await .(, { : 1, : "Ada Lovelace" });
const renamed: {
    id: number;
    name: string;
} | null

The module also exports the statement's types:

main.ts
import , { type , type Row } from "./get_user.sql";
type Row = {
    id: number;
    name: string;
    email: string | null;
}

run

run(client, params) runs the statement and resolves to its result, shaped by how many rows Reylo proves the statement can return:

The statementcardinalityrun resolves to
A match on a primary or unique key, or limit 1maybeOneRow | null
An aggregate without group by, a one-row insert … returningoneRow
An insert, update or delete without returningnonevoid
Anything elsemanyRow[]

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:

main.ts
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:

main.ts
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:

main.ts
import  from "./get_user.sql";

const  = .();
const  = await ({ : 1 });

sql

The statement as Reylo sends it to the database, typed as its string literal:

main.ts
import  from "./get_user.sql";

.sql;
sql: "select id, name, email from users where id = $1\n"

The statement as Reylo sends it to the database, as the module's sql export: :id is $1 on Postgres and ? on MySQL and SQLite, and each cast is the dialect's own.

raw

The file's contents, as written:

main.ts
import  from "./get_user.sql";

.raw;
raw: "select id, name, email from users where id = :id\n"

The .sql file's contents, exactly as written, as the module's raw export.

params

The parameters' names, in the order an array of values follows. A query also carries its row count, cardinality, and its dialect:

main.ts
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.

main.ts
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:

main.ts
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:

users_named.sql
select id, name from users
where name like concat(:prefix::text, '%')
main.ts
import  from "./users_named.sql";

const  = await .(, { prefix: "Ad" });
prefix: string

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:

add_users.sql
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 input
main.ts
import  from "./add_users.sql";

await .(, { names: ["Ada", "Grace"] });
names: string[]
  • The schema is JSON Schema 2020-12: string, number, integer, boolean, null, object and array, a key optional unless required lists it, and { "type": ["string", "null"] } for a value that may be null.
  • Its checks, such as format, pattern, minimum, maxItems and enum, run before the statement does, and a value that fails makes run reject with every issue listed. enum and const also narrow the type, and oneOf, anyOf and allOf make a union.
  • The schema stays in Reylo: the database receives the value as JSON text, $1::jsonb on 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:

.env
# or postgresql://; the type check uses your pg or postgres.js
DATABASE_URL=postgres://localhost:5432/app

A ${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:

.reylo.yaml
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.

.reylo.yaml
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.

.reylo.yaml
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.

.reylo.yaml
strict: true

cli

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.

.reylo.yaml
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.

How Reylo types a .sql import. TypeScript 7.1 hands get_user.sql to Reylo, its content mapper. Reylo has your database (Postgres, MySQL or SQLite) prepare the statement without running it, reads the catalog, and gives TypeScript the types: for select id, name, email from users where id = :id, the parameters { id: number } and the result { id: number; name: string; email: string | null } | null, at most one row because id is the primary key.TypeScript 7.1tsc and the editorReylocontent mapperyour databasePostgres, MySQL or SQLiteget_user.sqlits typesprepare, not runcatalogget_user.sqlselect id, name, emailfrom users where id = :idparameters{ id: number }result{ id: number; name: string; email: string | null } | nullat most one row: id is the primary key

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 NULL and outer joins
  • the row count, from keys, aggregates, limit 1 and returning

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:

Terminal
$ 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:1

A wrong argument is an error at the call site:

main.ts
import  from "./get_user.sql";

const  = await .(, { id: "1" });
Type 'string' is not assignable to type 'number'.

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.

WhatWhere it comes from
Parameter typesthe server's description of the prepared statement
Column typesthe server's description; an enum as its labels, a domain as its base type, a JSON column as what its JSON Schema CHECK admits
NullabilityNOT NULL in the catalog, outer joins, the expressions
Row countprimary and unique keys, aggregates, limit 1, returning
Nested JSONjsonb_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 typeTypeScript
smallint, integer, oidnumber
bigintstring
real, double precisionnumber
numeric, moneystring
text, varchar, charstring
booleanboolean
uuidstring
datestring, as 2026-10-20
timestampstring, as 2026-10-20T10:00:00.123456
timestamptzDate
time, timetz, intervalstring
json, jsonbunknown
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
byteaUint8Array
An arrayan array of its element type, a NULL element null
An enumthe union of its labels
A domainits base type
A row valuestring
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/postgres take 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 a transform that renames your columns does not rename Reylo's.
  • mysql2 takes Reylo's options with each query (execute), mariadb every option, and PlanetScale Reylo's cast.
  • better-sqlite3, node:sqlite and bun:sqlite run a statement Reylo prepares once per database, with its own integer setting, whatever the database's.
  • Bun.sql, libSQL, D1, sqlite3 and sqlite take 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 a DATETIME's milliseconds, not their microseconds, writes no fraction digits that are all zero, drops a TIME's fraction, and throws on a NULL inside an int4[] or a float4[].
  • A real (float4) or a MySQL FLOAT is 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 without safeIntegers round an integer past 2^53 before Reylo sees it, so Reylo refuses it; libSQL at its default intMode refuses it itself. With libSQL's intMode: "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 a Uint8Array.
  • A binary parameter takes a Uint8Array, a Buffer or not, on every driver: mariadb, which writes any other value as JSON, gets it from Reylo as a Buffer.
  • 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 timestamptz parameter takes a Date, 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". A timestamp or date parameter, and on MySQL a DATETIME, TIMESTAMP or DATE one, 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 a Date'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), a json column) 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 bigint is 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.

list_users.sql
select id, name, role, tags, created_at from users
where role = :role
order by created_at desc
limit :limit
main.ts
import  from "./list_users.sql";

const users = await .(, { : "member", : 20 });
const users: {
    id: number;
    name: string;
    role: "admin" | "member";
    tags: string[];
    created_at: Date;
}[]

Count rows

An aggregate without group by is exactly one row.

count_users.sql
-- count(*) is a bigint, a string; count(*)::int is a number
select count(*) as total from users where role = :role
main.ts
import  from "./count_users.sql";

const { total } = await .(, { : "admin" });
const total: string

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.

users_by_role.sql
select id, name from users
where role = coalesce(:role, role)
main.ts
import  from "./users_by_role.sql";

const  = await .(, { role: "admin" });
role: "admin" | "member" | null

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:

sorted_users.sql
select id, name, created_at from users
order by
  case :sort when 'name' then name end,
  created_at desc
main.ts
import  from "./sorted_users.sql";

const users = await .(, { : "name" });
const users: {
    id: number;
    name: string;
    created_at: Date;
}[]

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:

search_users.sql
with input as (
  select concat('%', :q::text, '%') as pattern
)
select id, name from users, input
where name like input.pattern or email like input.pattern
main.ts
import  from "./search_users.sql";

const users = await .(, { : "ada" });
const users: {
    id: number;
    name: string;
}[]

Join tables

A column of the table a left join adds can be null. coalesce gives it a default.

user_posts.sql
select u.name, p.title, coalesce(p.title, '') as label
from users u
left join posts p on p.user_id = u.id
main.ts
import  from "./user_posts.sql";

const rows = await .();
const rows: {
    name: string;
    title: string | null;
    label: string;
}[]

Page through rows

Pass the sort key of the last row you have, here created_at and id, to get the rows after it.

next_page.sql
select id, name, created_at from users
where (created_at, id) < (:created_at, :id)
order by created_at desc, id desc
limit 20
main.ts
import  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)!);
const next: {
    id: number;
    name: string;
    created_at: Date;
}[]

Filter by a list

Pass the list as one parameter.

users_by_ids.sql
select id, name from users where id = any(:ids)
main.ts
import  from "./users_by_ids.sql";

const  = await .(, { ids: [1, 2, 3] });
ids: number[]

Keep a list's order

Number the list's elements, and sort by the number.

users_in_order.sql
-- 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.position
main.ts
import  from "./users_in_order.sql";

const users = await .(, { : [3, 1, 2] });
const users: {
    id: number;
    name: string;
}[]

Build the related rows as JSON in the statement. Reylo types the JSON from the arguments that build it.

user_with_posts.sql
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 = :id
main.ts
import  from "./user_with_posts.sql";

const user = await .(, { : 1 });
const user: {
    id: number;
    name: string;
    posts: ({
        id: number;
        title: string;
        at: string | null;
    })[];
} | null

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.

create_users.sql
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 id
main.ts
import , { type Params } from "./create_users.sql";
type Params = {
    users: {
        name: string;
        email?: string | null;
    }[];
}
const = await .(, { : [{ : "Ada", : "ada@example.com" }, { : "Grace" }], });

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.

create_users_with_posts.sql
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)
main.ts
import , { type Params } from "./create_users_with_posts.sql";
type Params = {
    users: {
        name: string;
        email: string;
        posts: {
            title: string;
        }[];
    }[];
}
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:

main.ts
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.

upsert_user.sql
insert into users (name, email) values (:name, :email)
on conflict (email) do update set name = excluded.name
returning id
main.ts
import  from "./upsert_user.sql";

const  = { : "Ada Lovelace", : "ada@example.com" };
const result = await .(, );
const result: {
    id: number;
}

Delete rows

A write without returning resolves to void.

delete_user.sql
delete from users where id = :id
main.ts
import  from "./delete_user.sql";

const result = await .(, { : 1 });
const result: void

Record events of several kinds

oneOf types a JSON parameter as a union. Here a const key tells the members apart:

record_events.sql
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)
main.ts
import , { type Params } from "./record_events.sql";
type Params = {
    events: ({
        kind: "click";
        x: number;
        y: number;
    } | {
        kind: "key";
        code: string;
    })[];
}
await .(, { : [ { : "click", : 10, : 20 }, { : "key", : "Enter" }, ], });

On this page

GitHub