feat: core grammar, discovery and renderer; Postgres dialect; conformance suite

This commit is contained in:
2026-11-02 01:11:38 +01:00
commit 4b276f21e3
54 changed files with 4103 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
/**
* What a dialect reads from its database's catalog. The core never queries the
* catalog itself: every dialect maps its own types onto these logical ones, and
* operators, aggregates and value checks are decided on the logical type.
*/
export type LogicalType =
| 'text' | 'integer' | 'number' | 'boolean' | 'uuid'
| 'date' | 'timestamp' | 'time' | 'json' | 'binary' | 'array' | 'other';
export interface CatalogColumn {
name: string;
type: LogicalType;
/** The database's own type name, kept for error messages and dialect use. */
nativeType: string;
nullable: boolean;
/** Computed by the database: never writable. */
generated: boolean;
}
export interface CatalogForeignKey {
columns: string[];
target: { schema: string; table: string };
targetColumns: string[];
}
export interface CatalogTable {
schema: string;
name: string;
kind: 'table' | 'view';
columns: CatalogColumn[];
primaryKey: string[];
foreignKeys: CatalogForeignKey[];
}
export interface CatalogFunction {
schema: string;
name: string;
args: { name: string; type: LogicalType; nativeType: string; hasDefault: boolean }[];
returnsSet: boolean;
}
export interface Catalog {
tables: CatalogTable[];
functions: CatalogFunction[];
}
export function qualified(t: { schema: string; name: string } | { schema: string; table: string }): string {
return `${t.schema}.${'name' in t ? t.name : t.table}`;
}
+32
View File
@@ -0,0 +1,32 @@
import type { Catalog } from './catalog.js';
import type { RestError } from './errors.js';
import type { SqlRenderer } from './sql/renderer.js';
/** A connection inside a transaction. Rows come back as plain objects. */
export interface Tx {
query(sql: string, params: unknown[]): Promise<Record<string, unknown>[]>;
}
/** What a database can do natively. A request needing something missing is refused with `unsupported`. */
export interface Capabilities {
/** INSERT/UPDATE/DELETE return the written rows in the same statement. */
returning: boolean;
/** A planner estimate for count=estimated. */
estimatedCount: boolean;
/** Row security enforced by the database for the session's role. */
rowSecurity: boolean;
}
export interface Dialect {
readonly name: string;
readonly capabilities: Capabilities;
readonly renderer: SqlRenderer;
/** Reads the tables, views and functions of the given schemas and tables. */
introspect(scope: { schemas: string[]; tables: string[] }): Promise<Catalog>;
/** Runs fn in one transaction with a statement timeout; read-only when asked. */
transaction<T>(fn: (tx: Tx) => Promise<T>, options: { timeoutMs: number; readOnly: boolean }): Promise<T>;
/** A database error the client caused, as a RestError; null for anything else, which stays a 500. */
mapError(err: unknown): RestError | null;
/** The planner's row estimate for a count query, when estimatedCount is supported. */
estimate?(tx: Tx, sql: string, params: unknown[]): Promise<number | null>;
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Every refusal restloom answers. The code is stable and documented; the detail
* names the part of the request at fault and never carries database text.
*/
export class RestError extends Error {
constructor(public readonly status: number, public readonly code: string, public readonly detail?: string) {
super(detail ? `${code}: ${detail}` : code);
}
}
export const badRequest = (code: string, detail?: string) => new RestError(400, code, detail);
export const notFound = (detail?: string) => new RestError(404, 'not_found', detail);
+83
View File
@@ -0,0 +1,83 @@
import { badRequest } from '../errors.js';
import type { Limits } from '../options.js';
import { OPERATORS, type Filter, type Operator, type Value } from '../plan.js';
import type { Resource } from '../resources.js';
import { headAndRest, segments, type Segment } from './names.js';
import { column, walk } from './resolve.js';
import { listItems, splitTopLevel, unquote } from './split.js';
import { likePattern, operatorFits, typed } from './values.js';
interface Operation { op: Operator; negate: boolean; raw: string }
function operation(expr: string): Operation {
let rest = expr;
let negate = false;
if (rest.startsWith('not.')) { negate = true; rest = rest.slice(4); }
const dot = rest.indexOf('.');
const op = (dot < 0 ? rest : rest.slice(0, dot)) as Operator;
if (!OPERATORS.includes(op)) throw badRequest('unknown_operator', op);
if (dot < 0) throw badRequest('bad_filter', expr);
return { op, negate, raw: rest.slice(dot + 1) };
}
/** One filter from its path segments and its `[not.]op.value`. */
function condition(resource: Resource, segs: Segment[], expr: string, limits: Limits): Filter {
const { op, negate, raw } = operation(expr);
const { relations, resource: owner, last } = walk(resource, segs, limits.depth);
const asRelation = !last.quoted ? owner.relations.get(last.name) : undefined;
if (asRelation) {
if (op !== 'is' || (raw !== 'empty' && raw !== 'null')) throw badRequest('bad_filter', expr);
if (relations.length + 1 > limits.depth) throw badRequest('too_deep', segs.map((s) => s.name).join('.'));
return { kind: 'present', relations: [...relations, asRelation], negate: !negate };
}
const col = column(owner, last.name);
if (!operatorFits(op, col.type, raw)) throw badRequest('bad_operator', `${op} on ${col.name}`);
let value: Value | Value[];
if (op === 'in') {
const items = listItems(raw);
if (items.length === 0 || items.length > limits.inList) throw badRequest('bad_list', col.name);
value = items.map((i) => typed(i, col.type, col.name));
} else if (op === 'is') {
value = raw === 'null' ? null : raw === 'true';
} else if (op === 'like' || op === 'ilike') {
value = likePattern(unquote(raw));
} else {
value = typed(unquote(raw), col.type, col.name);
}
return { kind: 'cmp', path: { relations, column: col.name }, op, negate, value };
}
/** A term of or/and: `path.op.value`, `path.not.op.value`, or a nested and(...)/or(...). */
function term(resource: Resource, expr: string, limits: Limits, depth: number): Filter {
const nested = /^(not\.)?(and|or)(\(.*\))$/s.exec(expr);
if (nested) {
if (depth >= 1) throw badRequest('too_deep', expr);
return logic(resource, nested[2] as 'and' | 'or', nested[3], !!nested[1], limits, depth + 1);
}
const path: string[] = [];
let rest: string | null = expr;
while (rest !== null) {
const [head, tail]: [string, string | null] = headAndRest(rest);
if (!head.startsWith('"') && (head === 'not' || (OPERATORS as readonly string[]).includes(head)) && path.length > 0) {
return condition(resource, segments(path.join('.')), rest, limits);
}
path.push(head);
rest = tail;
}
throw badRequest('bad_filter', expr);
}
export function logic(resource: Resource, kind: 'and' | 'or', value: string, negate: boolean, limits: Limits, depth = 0): Filter {
if (!value.startsWith('(') || !value.endsWith(')')) throw badRequest('bad_logic', value);
const terms = splitTopLevel(value.slice(1, -1)).map((t) => term(resource, t.trim(), limits, depth));
if (terms.length === 0) throw badRequest('bad_logic', value);
return { kind, negate, terms };
}
export function filter(resource: Resource, key: string, value: string, limits: Limits): Filter {
return condition(resource, segments(key), value, limits);
}
export function countTerms(f: Filter): number {
return f.kind === 'or' || f.kind === 'and' ? f.terms.reduce((n, t) => n + countTerms(t), 0) : 1;
}
+48
View File
@@ -0,0 +1,48 @@
import { badRequest } from '../errors.js';
/** One part of a dotted name. A quoted part is always a column, never a keyword. */
export interface Segment { name: string; quoted: boolean }
/**
* Reads `owner."order".name` into its segments. Quotes let a name hold a dot
* or a keyword; a backslash escapes a quote inside them.
*/
export function segments(input: string): Segment[] {
const out: Segment[] = [];
let i = 0;
while (i <= input.length) {
if (input[i] === '"') {
let name = '';
i++;
while (i < input.length && input[i] !== '"') {
if (input[i] === '\\') i++;
name += input[i++] ?? '';
}
if (input[i] !== '"') throw badRequest('bad_name', input);
i++;
out.push({ name, quoted: true });
} else {
const end = input.indexOf('.', i);
const name = input.slice(i, end < 0 ? input.length : end);
out.push({ name, quoted: false });
i = end < 0 ? input.length : end;
}
if (i >= input.length) break;
if (input[i] !== '.') throw badRequest('bad_name', input);
i++;
if (i === input.length) throw badRequest('bad_name', input);
}
if (out.some((s) => s.name === '')) throw badRequest('bad_name', input);
return out;
}
/** Splits `name.rest` at the first dot outside quotes. */
export function headAndRest(input: string): [string, string | null] {
let quoted = false;
for (let i = 0; i < input.length; i++) {
if (input[i] === '\\' && quoted) { i++; continue; }
if (input[i] === '"') quoted = !quoted;
else if (!quoted && input[i] === '.') return [input.slice(0, i), input.slice(i + 1)];
}
return [input, null];
}
+51
View File
@@ -0,0 +1,51 @@
import { badRequest } from '../errors.js';
import type { Limits } from '../options.js';
import type { OrderTerm, Selection } from '../plan.js';
import type { Resource } from '../resources.js';
import { segments } from './names.js';
import { column, walk } from './resolve.js';
import { splitTopLevel } from './split.js';
import { orderable } from './values.js';
const MODIFIERS = new Set(['asc', 'desc', 'nullsfirst', 'nullslast']);
function orderTerm(resource: Resource, raw: string, limits: Limits): OrderTerm {
const segs = segments(raw.trim());
const term: Omit<OrderTerm, 'key'> = { desc: false };
while (segs.length > 1 && !segs[segs.length - 1].quoted && MODIFIERS.has(segs[segs.length - 1].name)) {
const m = segs.pop()!.name;
if (m === 'asc' || m === 'desc') term.desc = m === 'desc';
else term.nulls = m === 'nullsfirst' ? 'first' : 'last';
}
const last = segs[segs.length - 1];
if (segs.length > 1 && !last.quoted && last.name === 'count') {
const { relations } = walk(resource, [...segs.slice(0, -1), last], limits.depth);
const rel = relations[relations.length - 1];
if (rel.kind !== 'many' || relations.slice(0, -1).some((r) => r.kind !== 'one')) throw badRequest('bad_order', raw);
return { key: { kind: 'count', relations }, ...term };
}
const { relations, resource: owner } = walk(resource, segs, limits.depth);
if (relations.some((r) => r.kind !== 'one')) throw badRequest('bad_order', raw);
const col = column(owner, last.name);
if (!orderable(col.type)) throw badRequest('bad_order', raw);
return { key: { kind: 'column', path: { relations, column: col.name } }, ...term };
}
/**
* The order of a read. A grouped read orders by its selected plain columns
* only. Any other read gets the key appended, so pages never shift.
*/
export function ordering(resource: Resource, raw: string | null, select: Selection, limits: Limits): OrderTerm[] {
const terms = (raw ? splitTopLevel(raw) : []).filter((t) => t.trim() !== '').map((t) => orderTerm(resource, t, limits));
if (select.aggregates.length > 0) {
for (const t of terms) {
if (t.key.kind !== 'column' || t.key.path.relations.length > 0 || !select.columns.includes(t.key.path.column)) throw badRequest('bad_order', 'a grouped read orders by its selected columns');
}
return terms;
}
const direct = new Set(terms.flatMap((t) => (t.key.kind === 'column' && t.key.path.relations.length === 0 ? [t.key.path.column] : [])));
for (const k of resource.key) {
if (!direct.has(k)) terms.push({ key: { kind: 'column', path: { relations: [], column: k } }, desc: false });
}
return terms;
}
+81
View File
@@ -0,0 +1,81 @@
import { badRequest } from '../errors.js';
import type { Limits } from '../options.js';
import type { Embed, Filter, ReadPlan, Selection } from '../plan.js';
import type { Resource } from '../resources.js';
import { countTerms, filter, logic } from './filter.js';
import { segments } from './names.js';
import { ordering } from './order.js';
import { selection } from './select.js';
const RESERVED = new Set(['select', 'order', 'limit', 'offset', 'count', 'or', 'and', 'not.or', 'not.and']);
const EMBED_MODIFIERS = new Set(['order', 'limit', 'offset']);
export function bounded(raw: string | null, fallback: number, max: number, code: string): number {
if (raw === null) return fallback;
if (!/^\d{1,15}$/.test(raw)) throw badRequest(code, raw);
const n = Number(raw);
if (n > max) throw badRequest(code === 'bad_limit' ? 'limit_exceeded' : code, raw);
return n;
}
function embedAt(select: Selection, names: string[]): Embed {
let current = select;
let found: Embed | undefined;
for (const name of names) {
found = current.embeds.find((e) => e.name === name);
if (!found) throw badRequest('not_embedded', names.join('.'));
current = found.select;
}
return found!;
}
/** `rel.order`, `rel.limit`, `rel.offset`: the last segment is an unquoted modifier after at least one embed name. */
function embedModifier(key: string): { path: string[]; modifier: string } | null {
const segs = segments(key);
const last = segs[segs.length - 1];
if (segs.length < 2 || last.quoted || !EMBED_MODIFIERS.has(last.name)) return null;
return { path: segs.slice(0, -1).map((s) => s.name), modifier: last.name };
}
/** An embedded array without a limit holds at most a page of the largest size. */
function capEmbeds(select: Selection, limits: Limits): void {
for (const e of select.embeds) {
if (e.relation.kind === 'many') e.limit ??= limits.maxPage;
capEmbeds(e.select, limits);
}
}
/** Reads a query string into a read plan. Every name is checked against the resource; nothing unknown reaches SQL. */
export function parseRead(resource: Resource, query: URLSearchParams, limits: Limits): ReadPlan {
const select = selection(resource, query.get('select'), limits);
const where: Filter[] = [];
for (const [key, value] of query) {
if (RESERVED.has(key)) {
if (key === 'or' || key === 'and') where.push(logic(resource, key, value, false, limits));
if (key === 'not.or' || key === 'not.and') where.push(logic(resource, key.slice(4) as 'or' | 'and', value, true, limits));
continue;
}
const mod = embedModifier(key);
if (mod) {
const embed = embedAt(select, mod.path);
if (mod.modifier === 'order') embed.order = ordering(embed.relation.target, value, embed.select, limits);
else if (mod.modifier === 'limit') embed.limit = bounded(value, limits.page, limits.maxPage, 'bad_limit');
else embed.offset = bounded(value, 0, Number.MAX_SAFE_INTEGER, 'bad_offset');
continue;
}
where.push(filter(resource, key, value, limits));
}
capEmbeds(select, limits);
if (where.reduce((n, f) => n + countTerms(f), 0) > limits.filters) throw badRequest('too_many_filters');
const count = query.get('count');
if (count !== null && count !== 'exact' && count !== 'estimated') throw badRequest('bad_count', count);
return {
resource,
select,
where,
order: ordering(resource, query.get('order'), select, limits),
limit: bounded(query.get('limit'), limits.page, limits.maxPage, 'bad_limit'),
offset: bounded(query.get('offset'), 0, Number.MAX_SAFE_INTEGER, 'bad_offset'),
count: count ?? 'none',
};
}
+28
View File
@@ -0,0 +1,28 @@
import { badRequest } from '../errors.js';
import type { Column, Relation, Resource } from '../resources.js';
import type { Segment } from './names.js';
export interface Resolved { relations: Relation[]; resource: Resource; last: Segment }
/**
* Walks the leading segments as relations and returns the resource the last
* segment is read on. A quoted segment is a column, so it cannot be a hop.
*/
export function walk(resource: Resource, segs: Segment[], depth: number): Resolved {
const relations: Relation[] = [];
let current = resource;
for (const seg of segs.slice(0, -1)) {
const rel = seg.quoted ? undefined : current.relations.get(seg.name);
if (!rel) throw badRequest('unknown_relation', seg.name);
relations.push(rel);
current = rel.target;
}
if (relations.length > depth) throw badRequest('too_deep', segs.map((s) => s.name).join('.'));
return { relations, resource: current, last: segs[segs.length - 1] };
}
export function column(resource: Resource, name: string): Column {
const c = resource.columns.get(name);
if (!c) throw badRequest('unknown_column', name);
return c;
}
+62
View File
@@ -0,0 +1,62 @@
import { badRequest } from '../errors.js';
import type { Limits } from '../options.js';
import { AGGREGATES, type Aggregate, type Selection } from '../plan.js';
import type { Resource } from '../resources.js';
import { segments } from './names.js';
import { column } from './resolve.js';
import { splitTopLevel } from './split.js';
import { orderable } from './values.js';
function single(raw: string): string {
const segs = segments(raw.trim());
if (segs.length !== 1) throw badRequest('bad_select', raw);
return segs[0].name;
}
function all(resource: Resource): string[] {
return [...resource.columns.keys()];
}
/**
* A select list: columns, `*`, `count()`, `col.sum()` and the other
* aggregates, and `relation(list)` embeds, nested up to the depth limit.
*/
export function selection(resource: Resource, raw: string | null, limits: Limits, level = 0): Selection {
const out: Selection = { columns: [], aggregates: [], embeds: [] };
if (raw === null || raw.trim() === '' || raw.trim() === '*') return { ...out, columns: all(resource) };
const seen = new Set<string>();
const claim = (name: string) => {
if (seen.has(name)) throw badRequest('duplicate_select', name);
seen.add(name);
};
for (const part of splitTopLevel(raw).map((p) => p.trim())) {
if (part === '*') { all(resource).forEach((c) => { claim(c); out.columns.push(c); }); continue; }
if (part === 'count()') { claim('count'); out.aggregates.push({ fn: 'count', column: null }); continue; }
const agg = /^(.+)\.([a-z]+)\(\)$/s.exec(part);
if (agg) {
const fn = agg[2] as Aggregate;
if (!AGGREGATES.includes(fn) || fn === 'count') throw badRequest('unknown_aggregate', agg[2]);
const col = column(resource, single(agg[1]));
const fits = fn === 'sum' || fn === 'avg' ? col.type === 'integer' || col.type === 'number' : orderable(col.type);
if (!fits) throw badRequest('bad_aggregate', part);
claim(fn);
out.aggregates.push({ fn, column: col.name });
continue;
}
const embed = /^([A-Za-z_][A-Za-z0-9_]*)\((.*)\)$/s.exec(part);
if (embed) {
const relation = resource.relations.get(embed[1]);
if (!relation) throw badRequest('unknown_relation', embed[1]);
if (level + 1 > limits.depth) throw badRequest('too_deep', embed[1]);
claim(embed[1]);
out.embeds.push({ name: embed[1], relation, select: selection(relation.target, embed[2], limits, level + 1), order: [] });
continue;
}
const name = column(resource, single(part)).name;
claim(name);
out.columns.push(name);
}
if (out.aggregates.length > 0 && out.embeds.length > 0) throw badRequest('aggregate_with_embed');
if (out.embeds.some((e) => e.select.aggregates.length > 0)) throw badRequest('aggregate_in_embed');
return out;
}
+35
View File
@@ -0,0 +1,35 @@
import { badRequest } from '../errors.js';
/** Splits on top-level commas, keeping commas inside double quotes and parentheses. */
export function splitTopLevel(input: string): string[] {
const out: string[] = [];
let depth = 0;
let quoted = false;
let start = 0;
for (let i = 0; i < input.length; i++) {
const c = input[i];
if (c === '\\' && quoted) { i++; continue; }
if (c === '"') quoted = !quoted;
else if (!quoted && c === '(') depth++;
else if (!quoted && c === ')') depth--;
else if (!quoted && depth === 0 && c === ',') { out.push(input.slice(start, i)); start = i + 1; }
if (depth < 0) throw badRequest('bad_syntax', input);
}
if (quoted || depth !== 0) throw badRequest('bad_syntax', input);
out.push(input.slice(start));
return out;
}
/** A value item: double quotes delimit it and a backslash escapes the next character. */
export function unquote(item: string): string {
const t = item.trim();
if (t.length >= 2 && t.startsWith('"') && t.endsWith('"')) return t.slice(1, -1).replace(/\\(.)/g, '$1');
return t;
}
/** `(a,b,"c,d")` into its items. */
export function listItems(value: string): string[] {
if (!value.startsWith('(') || !value.endsWith(')')) throw badRequest('bad_list', value);
const inner = value.slice(1, -1);
return inner.trim() === '' ? [] : splitTopLevel(inner).map(unquote);
}
+47
View File
@@ -0,0 +1,47 @@
import type { LogicalType } from '../catalog.js';
import { badRequest } from '../errors.js';
import type { Operator, Value } from '../plan.js';
const COMPARABLE: ReadonlySet<LogicalType> = new Set(['text', 'integer', 'number', 'boolean', 'uuid', 'date', 'timestamp', 'time']);
const ORDERED: ReadonlySet<LogicalType> = new Set(['text', 'integer', 'number', 'date', 'timestamp', 'time']);
/** Whether an operator applies to a column of this type, decided before any SQL. */
export function operatorFits(op: Operator, type: LogicalType, value: string): boolean {
if (op === 'is') return value === 'null' || (type === 'boolean' && (value === 'true' || value === 'false'));
if (op === 'like' || op === 'ilike') return type === 'text';
if (op === 'gt' || op === 'gte' || op === 'lt' || op === 'lte') return ORDERED.has(type);
return COMPARABLE.has(type);
}
export function orderable(type: LogicalType): boolean {
return ORDERED.has(type) || type === 'boolean' || type === 'uuid';
}
const CHECKS: Partial<Record<LogicalType, RegExp>> = {
integer: /^-?\d{1,19}$/,
number: /^-?\d+(\.\d+)?([eE][-+]?\d+)?$/,
uuid: /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/,
date: /^\d{4}-\d{2}-\d{2}$/,
timestamp: /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?)?(Z|[+-]\d{2}(:?\d{2})?)?$/,
time: /^\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?$/,
};
/**
* A request value as a typed value to bind. Checking it here keeps databases
* that convert loosely ('abc' = 0 in MySQL) from answering a different question.
*/
export function typed(raw: string, type: LogicalType, column: string): Value {
const check = CHECKS[type];
if (check && !check.test(raw)) throw badRequest('invalid_value', column);
if (type === 'boolean') {
if (raw !== 'true' && raw !== 'false') throw badRequest('invalid_value', column);
return raw === 'true';
}
if (type === 'integer' && Number.isSafeInteger(Number(raw))) return Number(raw);
return raw;
}
/** A like pattern with PostgREST's `*` wildcard, other wildcards escaped. */
export function likePattern(raw: string): string {
return raw.replace(/[\\%_]/g, (c) => `\\${c}`).replace(/\*/g, '%');
}
+12
View File
@@ -0,0 +1,12 @@
export { createREST, type Rest } from './rest.js';
export { nodeListener, type NodeListenerOptions } from './node.js';
export { RestError, badRequest, notFound } from './errors.js';
export { DEFAULT_LIMITS, DEFAULT_SENSITIVE } from './options.js';
export type { Hooks, Limits, ProcedureOptions, RelationOptions, ResourceOptions, RestOptions, RestRequest, RestResponse, WriteOptions } from './options.js';
export type { Capabilities, Dialect, Tx } from './dialect.js';
export type { Catalog, CatalogColumn, CatalogForeignKey, CatalogFunction, CatalogTable, LogicalType } from './catalog.js';
export type { Column, Junction, Relation, Resource } from './resources.js';
export type { Aggregate, Embed, Filter, Operator, OrderTerm, Path, ReadPlan, Selection, Value } from './plan.js';
export { SqlPrimitives } from './sql/primitives.js';
export { SqlRenderer, type Statement } from './sql/renderer.js';
export { RenderContext } from './sql/context.js';
+60
View File
@@ -0,0 +1,60 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import type { RestResponse } from './options.js';
import type { Rest } from './rest.js';
export interface NodeListenerOptions<C> {
/** Builds the context hooks receive, e.g. the authenticated user. */
context?(req: IncomingMessage): C | Promise<C>;
/** Path the API is mounted under, e.g. /api. */
prefix?: string;
/** Largest request body read, in bytes (default 1 MiB). */
maxBodyBytes?: number;
}
function readBody(req: IncomingMessage, max: number): Promise<unknown> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let size = 0;
req.on('data', (c: Buffer) => {
size += c.length;
if (size > max) { reject(Object.assign(new Error('body_too_large'), { status: 413 })); req.destroy(); }
else chunks.push(c);
});
req.on('end', () => {
if (size === 0) return resolve(undefined);
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } catch { reject(Object.assign(new Error('bad_body'), { status: 400 })); }
});
req.on('error', reject);
});
}
function send(res: ServerResponse, r: RestResponse): void {
const body = r.body === undefined ? '' : JSON.stringify(r.body);
res.writeHead(r.status, { ...r.headers, ...(body ? { 'content-type': 'application/json; charset=utf-8' } : {}) });
res.end(body);
}
/** A node:http request listener serving the API. */
export function nodeListener<C>(rest: Rest<C>, options: NodeListenerOptions<C> = {}) {
const prefix = options.prefix?.replace(/\/$/, '') ?? '';
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
const url = new URL(req.url ?? '/', 'http://localhost');
if (prefix && url.pathname !== prefix && !url.pathname.startsWith(`${prefix}/`)) return send(res, { status: 404, headers: {}, body: { error: 'not_found' } });
let body: unknown;
try {
body = await readBody(req, options.maxBodyBytes ?? 1 << 20);
} catch (err) {
const status = (err as { status?: number }).status ?? 400;
return send(res, { status, headers: {}, body: { error: status === 413 ? 'body_too_large' : 'bad_body' } });
}
const headers = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(', ') : v]));
send(res, await rest.handle({
method: req.method ?? 'GET',
path: url.pathname.slice(prefix.length) || '/',
query: url.searchParams,
headers,
body,
context: (await options.context?.(req)) as C,
}));
};
}
+98
View File
@@ -0,0 +1,98 @@
import type { Dialect, Tx } from './dialect.js';
import type { Value } from './plan.js';
export interface Limits {
/** Rows in a page when the request names no limit. */
page: number;
/** Largest limit a request may ask for; more is refused, not clamped. */
maxPage: number;
/** Relations a path or an embed may walk through. */
depth: number;
/** Filters, including the terms of every or/and, in one request. */
filters: number;
/** Items in one in.(...) list. */
inList: number;
/** Rows in one write body. */
bodyRows: number;
/** Statement timeout, set by the dialect on every transaction. */
timeoutMs: number;
}
export const DEFAULT_LIMITS: Limits = { page: 50, maxPage: 1000, depth: 2, filters: 50, inList: 500, bodyRows: 1000, timeoutMs: 5000 };
export type RelationOptions =
| { one: string; via: string; references?: string }
| { many: string; via: string; references?: string }
| { many: string; through: string };
export interface WriteOptions {
/** true: every visible column that is not generated. A list: only those. */
create?: boolean | string[];
update?: boolean | string[];
delete?: boolean;
/** PATCH and DELETE by filter on the collection, not only by id. */
bulk?: boolean;
}
export interface ResourceOptions {
/** false removes a discovered table or view from the API. */
expose?: boolean;
/** Columns kept out of the API although their names look harmless. */
hide?: string[];
/** Columns shown although their names look sensitive. */
show?: string[];
/** Off by default: true allows create, update and delete of visible columns. */
write?: boolean | WriteOptions;
/** Columns that identify a row, for a view or a table without a primary key. */
key?: string[];
/** Override, add (any other name) or remove (false) a relation. */
relations?: Record<string, RelationOptions | false>;
}
export interface ProcedureOptions {
/** Schema-qualified function name. */
function: string;
}
export interface RestRequest<C = unknown> {
method: string;
/** Path without the mount prefix, e.g. /assets/42. */
path: string;
query: URLSearchParams;
headers: Record<string, string | undefined>;
body?: unknown;
/** Whatever the host passes in; it reaches every hook. */
context: C;
}
export interface RestResponse {
status: number;
headers: Record<string, string>;
body: unknown;
}
export interface Hooks<C = unknown> {
/** Throw a RestError to refuse a request before any SQL runs. */
authorize?(request: RestRequest<C>, target: { resource?: string; procedure?: string; action: 'read' | 'create' | 'update' | 'delete' | 'call' }): void | Promise<void>;
/** First statement of every transaction: session settings, role, search path. */
transaction?(tx: Tx, context: C): Promise<void>;
/** Equality filters added to every query on a resource, for databases without row security. */
scope?(resource: string, context: C): Record<string, Value> | undefined;
/** Called with every error answered as a 500, for the host's logs. The client only sees `internal`. */
onError?(error: unknown, request: RestRequest<C>): void;
}
export interface RestOptions<C = unknown> {
db: Dialect;
/** Nothing is served until a schema or a table is named here. */
expose: { schemas?: string[]; tables?: string[] };
resources?: Record<string, ResourceOptions>;
procedures?: Record<string, ProcedureOptions>;
/** Replaces the default list of sensitive name patterns. */
sensitive?: RegExp;
hooks?: Hooks<C>;
limits?: Partial<Limits>;
}
/** Column names hidden unless shown on purpose. Matched against whole words of the name. */
export const DEFAULT_SENSITIVE = /(^|_)(password|passwd|pwd|hash|hashed|salt|secret|token|api_?key|private_?key|otp|totp|mfa|recovery)(_|$)/i;
+46
View File
@@ -0,0 +1,46 @@
import type { Relation, Resource } from './resources.js';
export const OPERATORS = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'like', 'ilike', 'in', 'is'] as const;
export type Operator = typeof OPERATORS[number];
export const AGGREGATES = ['count', 'sum', 'avg', 'min', 'max'] as const;
export type Aggregate = typeof AGGREGATES[number];
/** A typed value ready to bind: the core checked it against the column's logical type. */
export type Value = string | number | boolean | null;
/** Relations walked from the resource, then a column of the last one. */
export interface Path { relations: Relation[]; column: string }
export type Filter =
| { kind: 'cmp'; path: Path; op: Operator; negate: boolean; value: Value | Value[] }
| { kind: 'present'; relations: Relation[]; negate: boolean }
| { kind: 'or' | 'and'; negate: boolean; terms: Filter[] };
export type OrderKey = { kind: 'column'; path: Path } | { kind: 'count'; relations: Relation[] };
export interface OrderTerm { key: OrderKey; desc: boolean; nulls?: 'first' | 'last' }
export interface Selection {
columns: string[];
aggregates: { fn: Aggregate; column: string | null }[];
embeds: Embed[];
}
export interface Embed {
name: string;
relation: Relation;
select: Selection;
order: OrderTerm[];
limit?: number;
offset?: number;
}
export interface ReadPlan {
resource: Resource;
select: Selection;
where: Filter[];
order: OrderTerm[];
limit: number;
offset: number;
count: 'none' | 'exact' | 'estimated';
}
+52
View File
@@ -0,0 +1,52 @@
import type { Dialect, Tx } from './dialect.js';
import { notFound } from './errors.js';
import { parseRead } from './grammar/read.js';
import type { Limits, RestRequest, RestResponse } from './options.js';
import type { Filter, ReadPlan } from './plan.js';
import type { Resource } from './resources.js';
import { typed } from './grammar/values.js';
function decode(row: Record<string, unknown>): unknown {
const r = row.r;
return typeof r === 'string' ? JSON.parse(r) : r;
}
async function total(db: Dialect, tx: Tx, plan: ReadPlan, scope: (r: Resource) => Filter[]): Promise<number | null> {
if (plan.count === 'none') return null;
const stmt = db.renderer.count(plan, db.renderer.context(scope));
if (plan.count === 'estimated' && db.capabilities.estimatedCount && db.estimate) {
const n = await db.estimate(tx, stmt.sql, stmt.params);
if (n !== null) return n;
}
const [row] = await tx.query(stmt.sql, stmt.params);
return Number(row?.n ?? 0);
}
export function keyFilter(resource: Resource, id: string): Filter {
if (resource.key.length !== 1) throw notFound();
const column = resource.all.get(resource.key[0])!;
let value;
try { value = typed(id, column.type, column.name); } catch { throw notFound(); }
return { kind: 'cmp', path: { relations: [], column: column.name }, op: 'eq', negate: false, value };
}
/** GET on a collection, or on one row when id is given. */
export async function readRows(db: Dialect, tx: Tx, resource: Resource, request: RestRequest<unknown>, limits: Limits,
scope: (r: Resource) => Filter[], id?: string): Promise<RestResponse> {
const plan = parseRead(resource, request.query, limits);
if (id !== undefined) {
plan.where.unshift(keyFilter(resource, id));
plan.limit = 1;
plan.offset = 0;
plan.count = 'none';
}
const stmt = db.renderer.read(plan, db.renderer.context(scope));
const rows = (await tx.query(stmt.sql, stmt.params)).map(decode);
if (id !== undefined) {
if (rows.length === 0) throw notFound();
return { status: 200, headers: {}, body: rows[0] };
}
const n = await total(db, tx, plan, scope);
const range = rows.length ? `${plan.offset}-${plan.offset + rows.length - 1}` : '*';
return { status: 200, headers: { 'content-range': `${range}/${n ?? '*'}` }, body: rows };
}
+81
View File
@@ -0,0 +1,81 @@
import { qualified, type Catalog, type CatalogForeignKey, type CatalogTable } from './catalog.js';
import type { RelationOptions, ResourceOptions } from './options.js';
import type { Relation, Resource } from './resources.js';
function stripId(columns: string[]): string | null {
return columns.length === 1 && columns[0].endsWith('_id') && columns[0].length > 3 ? columns[0].slice(0, -3) : null;
}
function taken(r: Resource, name: string): boolean {
return r.all.has(name) || r.relations.has(name);
}
/** Adds a discovered relation under the first free candidate name. */
function addDiscovered(r: Resource, candidates: (string | null)[], rel: Omit<Relation, 'name'>): void {
const name = candidates.find((c): c is string => !!c && !taken(r, c));
if (!name) throw new Error(`restloom: no free name for a relation of ${r.name} to ${rel.target.name}; name it in resources.${r.name}.relations`);
r.relations.set(name, { name, ...rel });
}
/** A table whose primary key is exactly the columns of its two foreign keys links the two tables they point to. */
function junctionKeys(t: CatalogTable): [CatalogForeignKey, CatalogForeignKey] | null {
if (t.foreignKeys.length !== 2) return null;
const fkCols = t.foreignKeys.flatMap((fk) => fk.columns);
const pk = new Set(t.primaryKey);
if (pk.size !== fkCols.length || !fkCols.every((c) => pk.has(c))) return null;
return [t.foreignKeys[0], t.foreignKeys[1]];
}
function explicit(name: string, source: Resource, o: RelationOptions, catalog: Catalog, resources: Map<string, Resource>): Relation {
const target = resources.get('one' in o ? o.one : o.many);
if (!target) throw new Error(`restloom: relation ${source.name}.${name} points to a resource that is not exposed`);
const need = (r: Resource, c: string) => { if (!r.all.has(c)) throw new Error(`restloom: ${r.name} has no column ${c} for relation ${source.name}.${name}`); return c; };
if ('one' in o) return { name, kind: 'one', target, from: [need(source, o.via)], to: [need(target, o.references ?? target.key[0] ?? 'id')] };
if ('via' in o) return { name, kind: 'many', target, from: [need(source, o.references ?? source.key[0] ?? 'id')], to: [need(target, o.via)] };
const ref = o.through.includes('.') ? o.through : `${source.schema}.${o.through}`;
const j = catalog.tables.find((t) => qualified(t) === ref);
const fkTo = (r: Resource) => j?.foreignKeys.find((fk) => fk.target.schema === r.schema && fk.target.table === r.table);
const toSource = fkTo(source);
const toTarget = j?.foreignKeys.find((fk) => fk !== toSource && fk.target.schema === target.schema && fk.target.table === target.table);
if (!j || !toSource || !toTarget) throw new Error(`restloom: ${o.through} does not link ${source.name} and ${target.name} with two foreign keys`);
return {
name, kind: 'many', target, from: toSource.targetColumns, to: toTarget.targetColumns,
through: { schema: j.schema, table: j.name, from: toSource.columns, to: toTarget.columns },
};
}
/**
* Relations between exposed resources: a foreign key gives a to-one relation on
* its side and a to-many on the other, a junction table gives a many-to-many on
* both ends. Relations to tables that are not exposed do not exist.
*/
export function discoverRelations(catalog: Catalog, resources: Map<string, Resource>, config: Record<string, ResourceOptions>): void {
const byTable = new Map([...resources.values()].map((r) => [`${r.schema}.${r.table}`, r]));
for (const t of catalog.tables) {
const source = byTable.get(qualified(t));
for (const fk of t.foreignKeys) {
const target = byTable.get(qualified(fk.target));
if (!source || !target) continue;
const stem = stripId(fk.columns);
addDiscovered(source, [stem, target.name, `${fk.columns.join('_')}_${target.name}`], { kind: 'one', target, from: fk.columns, to: fk.targetColumns });
addDiscovered(target, [source.name, `${source.name}_by_${stem ?? fk.columns.join('_')}`], { kind: 'many', target: source, from: fk.targetColumns, to: fk.columns });
}
const pair = junctionKeys(t);
const a = pair && byTable.get(qualified(pair[0].target));
const b = pair && byTable.get(qualified(pair[1].target));
if (!pair || !a || !b) continue;
const via = (x: CatalogForeignKey, y: CatalogForeignKey) => ({ schema: t.schema, table: t.name, from: x.columns, to: y.columns });
addDiscovered(a, [b.name, `${b.name}_via_${t.name}`], { kind: 'many', target: b, from: pair[0].targetColumns, to: pair[1].targetColumns, through: via(pair[0], pair[1]) });
if (a !== b) addDiscovered(b, [a.name, `${a.name}_via_${t.name}`], { kind: 'many', target: a, from: pair[1].targetColumns, to: pair[0].targetColumns, through: via(pair[1], pair[0]) });
}
for (const [resName, o] of Object.entries(config)) {
const source = resources.get(resName);
if (!source) continue;
for (const [name, ro] of Object.entries(o.relations ?? {})) {
source.relations.delete(name);
if (ro === false) continue;
if (source.all.has(name)) throw new Error(`restloom: relation ${resName}.${name} has the name of a column`);
source.relations.set(name, explicit(name, source, ro, catalog, resources));
}
}
}
+114
View File
@@ -0,0 +1,114 @@
import { qualified, type Catalog, type CatalogColumn, type CatalogTable } from './catalog.js';
import { DEFAULT_SENSITIVE, type ResourceOptions, type RestOptions } from './options.js';
import { discoverRelations } from './relations.js';
export interface Column extends CatalogColumn {
create: boolean;
update: boolean;
}
export interface Junction {
schema: string;
table: string;
/** Junction columns matching the source key, in order. */
from: string[];
/** Junction columns matching the target key, in order. */
to: string[];
}
/**
* A relation from a resource to another. `one` and `many` match target.to
* against source.from; `many` through a junction matches the junction instead.
*/
export interface Relation {
name: string;
kind: 'one' | 'many';
target: Resource;
from: string[];
to: string[];
through?: Junction;
}
export interface Resource {
name: string;
schema: string;
table: string;
kind: 'table' | 'view';
/** Visible columns: everything the API may read, filter, sort or write. */
columns: Map<string, Column>;
/** Every column, hidden ones included, for joins only. */
all: Map<string, CatalogColumn>;
key: string[];
relations: Map<string, Relation>;
write: { create: boolean; update: boolean; delete: boolean; bulk: boolean };
}
function exposed(t: CatalogTable, expose: RestOptions['expose']): boolean {
return (expose.schemas ?? []).includes(t.schema) || (expose.tables ?? []).includes(qualified(t));
}
function names(tables: CatalogTable[]): Map<CatalogTable, string> {
const count = new Map<string, number>();
for (const t of tables) count.set(t.name, (count.get(t.name) ?? 0) + 1);
return new Map(tables.map((t) => [t, count.get(t.name)! > 1 ? `${t.schema}_${t.name}` : t.name]));
}
function columnsOf(t: CatalogTable, o: ResourceOptions, sensitive: RegExp): Map<string, Column> {
const hide = new Set(o.hide ?? []);
const show = new Set(o.show ?? []);
for (const c of [...hide, ...show]) {
if (!t.columns.some((col) => col.name === c)) throw new Error(`restloom: ${t.name} has no column ${c} to hide or show`);
}
const w = o.write === true ? { create: true, update: true } : o.write || {};
const allowed = (setting: boolean | string[] | undefined, c: CatalogColumn) =>
!c.generated && (setting === true || (Array.isArray(setting) && setting.includes(c.name)));
const out = new Map<string, Column>();
for (const c of t.columns) {
if (hide.has(c.name) || (sensitive.test(c.name) && !show.has(c.name))) continue;
const keyColumn = t.primaryKey.includes(c.name);
out.set(c.name, { ...c, create: allowed(w.create, c), update: !keyColumn && allowed(w.update, c) });
}
for (const setting of [w.create, w.update]) {
for (const c of Array.isArray(setting) ? setting : []) {
if (!out.has(c)) throw new Error(`restloom: ${t.name}.${c} is not a visible column and cannot be writable`);
}
}
return out;
}
/**
* Turns a catalog into the API's resources. Only exposed tables and views
* become resources; relations come from foreign keys between them, then the
* options override, add or remove them.
*/
export function buildResources(catalog: Catalog, options: RestOptions<unknown>): Map<string, Resource> {
const sensitive = options.sensitive ?? DEFAULT_SENSITIVE;
const tables = catalog.tables.filter((t) => exposed(t, options.expose));
const named = names(tables);
const config = options.resources ?? {};
for (const name of Object.keys(config)) {
if (![...named.values()].includes(name)) throw new Error(`restloom: no exposed table or view is named ${name}`);
}
const resources = new Map<string, Resource>();
for (const t of tables) {
const name = named.get(t)!;
const o = config[name] ?? {};
if (o.expose === false) continue;
const key = o.key ?? (t.primaryKey.length ? t.primaryKey : t.columns.some((c) => c.name === 'id') ? ['id'] : []);
const w = o.write === true ? { delete: true, bulk: false } : o.write || {};
const columns = columnsOf(t, o, sensitive);
resources.set(name, {
name, schema: t.schema, table: t.name, kind: t.kind, columns, key,
all: new Map(t.columns.map((c) => [c.name, c])),
relations: new Map(),
write: {
create: [...columns.values()].some((c) => c.create),
update: [...columns.values()].some((c) => c.update),
delete: w.delete === true,
bulk: w.bulk === true,
},
});
}
discoverRelations(catalog, resources, config);
return resources;
}
+72
View File
@@ -0,0 +1,72 @@
import type { Catalog } from './catalog.js';
import { RestError, notFound } from './errors.js';
import { DEFAULT_LIMITS, type Limits, type RestOptions, type RestRequest, type RestResponse } from './options.js';
import { readRows } from './read.js';
import { buildResources, type Resource } from './resources.js';
import { scopeFilters } from './scope.js';
export interface Rest<C = unknown> {
/** Answers one request. Never throws: every failure is a response. */
handle(request: RestRequest<C>): Promise<RestResponse>;
/** Rereads the catalog on the next request. */
reload(): void;
/** The resources the API serves, read from the catalog on first use. */
resources(): Promise<ReadonlyMap<string, Resource>>;
}
function errorBody(err: RestError): RestResponse {
return { status: err.status, headers: {}, body: { error: err.code, ...(err.detail ? { message: err.detail } : {}) } };
}
function route(path: string): { resource: string; id?: string } | null {
const parts = path.split('/').filter((p) => p !== '');
if (parts.length < 1 || parts.length > 2) return null;
try {
return { resource: decodeURIComponent(parts[0]), id: parts[1] === undefined ? undefined : decodeURIComponent(parts[1]) };
} catch {
return null;
}
}
/**
* A REST API over the exposed tables and views of a database. Everything is
* read from the catalog on first use; options only override.
*/
export function createREST<C = unknown>(options: RestOptions<C>): Rest<C> {
const limits: Limits = { ...DEFAULT_LIMITS, ...options.limits };
const db = options.db;
let loading: Promise<Map<string, Resource>> | null = null;
const load = () => (loading ??= db.introspect({ schemas: options.expose.schemas ?? [], tables: options.expose.tables ?? [] })
.then((catalog: Catalog) => buildResources(catalog, options as RestOptions<unknown>))
.catch((err: unknown) => { loading = null; throw err; }));
async function dispatch(request: RestRequest<C>): Promise<RestResponse> {
const target = route(request.path);
const resource = target && (await load()).get(target.resource);
if (!target || !resource) throw notFound();
if (request.method !== 'GET') throw new RestError(405, 'method_not_allowed');
await options.hooks?.authorize?.(request, { resource: resource.name, action: 'read' });
const scope = scopeFilters(options, request.context);
return db.transaction(async (tx) => {
await options.hooks?.transaction?.(tx, request.context);
return readRows(db, tx, resource, request as RestRequest<unknown>, limits, scope, target.id);
}, { timeoutMs: limits.timeoutMs, readOnly: true });
}
return {
async handle(request) {
try {
return await dispatch(request);
} catch (err) {
if (err instanceof RestError) return errorBody(err);
const mapped = db.mapError(err);
if (mapped) return errorBody(mapped);
options.hooks?.onError?.(err, request);
return errorBody(new RestError(500, 'internal'));
}
},
reload() { loading = null; },
resources: load,
};
}
+15
View File
@@ -0,0 +1,15 @@
import type { Filter } from './plan.js';
import type { Resource } from './resources.js';
import type { RestOptions } from './options.js';
/** The host's scope filters for a resource, as equality filters on its own columns (hidden ones included). */
export function scopeFilters<C>(options: RestOptions<C>, context: C): (resource: Resource) => Filter[] {
const hook = options.hooks?.scope;
if (!hook) return () => [];
return (resource) => Object.entries(hook(resource.name, context) ?? {}).map(([column, value]): Filter => {
if (!resource.all.has(column)) throw new Error(`restloom: scope for ${resource.name} names a column it does not have: ${column}`);
return value === null
? { kind: 'cmp', path: { relations: [], column }, op: 'is', negate: false, value: null }
: { kind: 'cmp', path: { relations: [], column }, op: 'eq', negate: false, value };
});
}
+14
View File
@@ -0,0 +1,14 @@
import type { Filter } from '../plan.js';
import type { Resource } from '../resources.js';
import { Params } from './params.js';
/** State of one statement being written: its bound values, fresh aliases, and the host's scope per resource. */
export class RenderContext {
readonly params = new Params();
private next = 0;
constructor(readonly scope: (resource: Resource) => Filter[]) {}
alias(prefix = 't'): string {
return `${prefix}${this.next++}`;
}
}
+25
View File
@@ -0,0 +1,25 @@
const TOKEN = /\u0000(\d+)\u0000/g;
/**
* Bound values of one statement. Fragments carry a token per value, so they can
* be written in any order and repeated; finalize() writes the database's own
* placeholders in text order. The token holds a NUL, which no identifier or
* literal can contain.
*/
export class Params {
readonly values: unknown[] = [];
add(value: unknown): string {
this.values.push(value);
return `\u0000${this.values.length}\u0000`;
}
finalize(sql: string, placeholder: (index: number) => string): { sql: string; params: unknown[] } {
const params: unknown[] = [];
const text = sql.replace(TOKEN, (_, n: string) => {
params.push(this.values[Number(n) - 1]);
return placeholder(params.length);
});
return { sql: text, params };
}
}
+78
View File
@@ -0,0 +1,78 @@
import type { CatalogColumn } from '../catalog.js';
import type { Aggregate } from '../plan.js';
/**
* The smallest pieces of SQL, written for Postgres. A dialect overrides the
* ones its database writes differently; the statements are built from these
* pieces only, so an override reaches every statement that uses it.
*/
export class SqlPrimitives {
quote(identifier: string): string {
return `"${identifier.replace(/"/g, '""')}"`;
}
literal(text: string): string {
return `'${text.replace(/'/g, "''")}'`;
}
placeholder(index: number): string {
return `$${index}`;
}
table(schema: string, table: string): string {
return `${this.quote(schema)}.${this.quote(table)}`;
}
column(alias: string, name: string): string {
return `${alias}.${this.quote(name)}`;
}
/** A column as it goes into a JSON object; a dialect fixes types its JSON functions misread. */
jsonValue(expr: string, _column: CatalogColumn): string {
return expr;
}
/** A JSON value produced by a subquery, nested into an outer JSON object. */
nested(expr: string): string {
return expr;
}
jsonObject(pairs: [string, string][]): string {
return `json_build_object(${pairs.map(([k, v]) => `${this.literal(k)}, ${v}`).join(', ')})`;
}
/** JSON array of rowExpr over a derived table, in the order of orderExpr, [] when empty. */
jsonArray(rowExpr: string, orderExpr: string): string {
return `COALESCE(json_agg(${rowExpr} ORDER BY ${orderExpr}), '[]'::json)`;
}
like(expr: string, pattern: string, insensitive: boolean): string {
return `${expr} ${insensitive ? 'ILIKE' : 'LIKE'} ${pattern} ESCAPE '\\'`;
}
isBoolean(expr: string, value: boolean): string {
return `${expr} IS ${value ? 'TRUE' : 'FALSE'}`;
}
orderBy(expr: string, desc: boolean, nulls?: 'first' | 'last'): string {
return `${expr} ${desc ? 'DESC' : 'ASC'}${nulls ? ` NULLS ${nulls.toUpperCase()}` : ''}`;
}
/** limit and offset are integers checked by the grammar. */
page(limit: number | undefined, offset: number | undefined): string {
return `${limit !== undefined ? ` LIMIT ${limit}` : ''}${offset ? ` OFFSET ${offset}` : ''}`;
}
rowNumber(orderSql: string): string {
return `ROW_NUMBER() OVER (ORDER BY ${orderSql})`;
}
aggregate(fn: Aggregate, expr: string | null): string {
return fn === 'count' ? 'COUNT(*)' : `${fn.toUpperCase()}(${expr})`;
}
/** A row-producing statement limited to its first row. */
first(selectSql: string): string {
return `${selectSql} LIMIT 1`;
}
}
+149
View File
@@ -0,0 +1,149 @@
import type { Embed, Filter, OrderTerm, Path, ReadPlan, Selection, Value } from '../plan.js';
import type { Relation, Resource } from '../resources.js';
import { RenderContext } from './context.js';
import { SqlPrimitives } from './primitives.js';
export interface Statement { sql: string; params: unknown[] }
const CMP: Record<string, string> = { eq: '=', neq: '<>', gt: '>', gte: '>=', lt: '<', lte: '<=' };
/**
* Writes read statements from a plan. Relations are correlated subqueries, so
* a statement is one SQL text whatever the depth, and every table it touches
* carries the host's scope filters. Each method can be overridden by a dialect.
*/
export class SqlRenderer extends SqlPrimitives {
context(scope: (r: Resource) => Filter[]): RenderContext {
return new RenderContext(scope);
}
/** FROM clause and correlation of a relation's target, seen from the parent alias. */
relationSource(rel: Relation, parent: string, alias: string, ctx: RenderContext): { from: string; where: string[] } {
const target = this.table(rel.target.schema, rel.target.table);
const scoped = this.filters(rel.target, alias, ctx.scope(rel.target), ctx);
if (!rel.through) {
const on = rel.to.map((c, i) => `${this.column(alias, c)} = ${this.column(parent, rel.from[i])}`);
return { from: `${target} ${alias}`, where: [...on, ...scoped] };
}
const j = ctx.alias('j');
const join = rel.to.map((c, i) => `${this.column(j, rel.through!.to[i])} = ${this.column(alias, c)}`).join(' AND ');
const on = rel.from.map((c, i) => `${this.column(j, rel.through!.from[i])} = ${this.column(parent, c)}`);
return { from: `${target} ${alias} JOIN ${this.table(rel.through.schema, rel.through.table)} ${j} ON ${join}`, where: [...on, ...scoped] };
}
/** EXISTS through a chain of relations, with `inner` written against the last alias. */
exists(relations: Relation[], parent: string, ctx: RenderContext, inner: (alias: string) => string | null): string {
const [rel, ...rest] = relations;
const alias = ctx.alias();
const src = this.relationSource(rel, parent, alias, ctx);
const tail = rest.length ? this.exists(rest, alias, ctx, inner) : inner(alias);
const where = [...src.where, ...(tail ? [tail] : [])];
return `EXISTS (SELECT 1 FROM ${src.from}${where.length ? ` WHERE ${where.join(' AND ')}` : ''})`;
}
comparison(expr: string, f: Extract<Filter, { kind: 'cmp' }>, ctx: RenderContext): string {
const bind = (v: Value) => ctx.params.add(v);
switch (f.op) {
case 'in': return `${expr} IN (${(f.value as Value[]).map(bind).join(', ')})`;
case 'is': return f.value === null ? `${expr} IS NULL` : this.isBoolean(expr, f.value as boolean);
case 'like': return this.like(expr, bind(f.value as Value), false);
case 'ilike': return this.like(expr, bind(f.value as Value), true);
default: return `${expr} ${CMP[f.op]} ${bind(f.value as Value)}`;
}
}
filter(alias: string, f: Filter, ctx: RenderContext): string {
if (f.kind === 'present') return `${f.negate ? 'NOT ' : ''}${this.exists(f.relations, alias, ctx, () => null)}`;
if (f.kind !== 'cmp') {
const body = `(${f.terms.map((t) => this.filter(alias, t, ctx)).join(f.kind === 'or' ? ' OR ' : ' AND ')})`;
return f.negate ? `NOT ${body}` : body;
}
if (f.path.relations.length === 0) {
const cmp = this.comparison(this.column(alias, f.path.column), f, ctx);
return f.negate ? `NOT (${cmp})` : cmp;
}
const found = this.exists(f.path.relations, alias, ctx, (a) => this.comparison(this.column(a, f.path.column), f, ctx));
return f.negate ? `NOT ${found}` : found;
}
filters(_resource: Resource, alias: string, list: Filter[], ctx: RenderContext): string[] {
return list.map((f) => this.filter(alias, f, ctx));
}
/** A value reached through to-one relations, or a count of a to-many one, as a scalar subquery. */
pathValue(relations: Relation[], parent: string, ctx: RenderContext, leaf: (alias: string) => string): string {
const [rel, ...rest] = relations;
const alias = ctx.alias();
const src = this.relationSource(rel, parent, alias, ctx);
const value = rest.length ? this.pathValue(rest, alias, ctx, leaf) : leaf(alias);
return `(${this.first(`SELECT ${value} FROM ${src.from} WHERE ${src.where.join(' AND ')}`)})`;
}
orderExpr(t: OrderTerm, alias: string, ctx: RenderContext): string {
if (t.key.kind === 'count') {
const rels = t.key.relations;
const counted = (parent: string) => {
const a = ctx.alias();
const src = this.relationSource(rels[rels.length - 1], parent, a, ctx);
return `(SELECT COUNT(*) FROM ${src.from} WHERE ${src.where.join(' AND ')})`;
};
return rels.length === 1 ? counted(alias) : this.pathValue(rels.slice(0, -1), alias, ctx, counted);
}
const path: Path = t.key.path;
return path.relations.length === 0
? this.column(alias, path.column)
: this.pathValue(path.relations, alias, ctx, (a) => this.column(a, path.column));
}
order(terms: OrderTerm[], alias: string, ctx: RenderContext): string {
return terms.map((t) => this.orderBy(this.orderExpr(t, alias, ctx), t.desc, t.nulls)).join(', ');
}
rowJson(resource: Resource, select: Selection, alias: string, ctx: RenderContext): string {
const pairs: [string, string][] = select.columns.map((c) => [c, this.jsonValue(this.column(alias, c), resource.columns.get(c)!)]);
for (const a of select.aggregates) pairs.push([a.fn, this.aggregate(a.fn, a.column && this.column(alias, a.column))]);
for (const e of select.embeds) pairs.push([e.name, this.nested(this.embed(e, alias, ctx))]);
return this.jsonObject(pairs);
}
embed(e: Embed, parent: string, ctx: RenderContext): string {
const alias = ctx.alias();
const src = this.relationSource(e.relation, parent, alias, ctx);
const target = e.relation.target;
const row = this.rowJson(target, e.select, alias, ctx);
const where = ` WHERE ${src.where.join(' AND ')}`;
if (e.relation.kind === 'one') return `(${this.first(`SELECT ${row} FROM ${src.from}${where}`)})`;
const terms = e.order.length ? e.order : target.key.map((k) => ({ key: { kind: 'column' as const, path: { relations: [], column: k } }, desc: false }));
const ordered = this.order(terms, alias, ctx);
const inner = `SELECT ${row} AS r, ${this.rowNumber(ordered || '1')} AS n FROM ${src.from}${where}${ordered ? ` ORDER BY ${ordered}` : ''}${this.page(e.limit, e.offset)}`;
return `(SELECT ${this.jsonArray('x.r', 'x.n')} FROM (${inner}) x)`;
}
where(plan: ReadPlan, alias: string, ctx: RenderContext): string {
const parts = [...this.filters(plan.resource, alias, ctx.scope(plan.resource), ctx), ...this.filters(plan.resource, alias, plan.where, ctx)];
return parts.length ? ` WHERE ${parts.join(' AND ')}` : '';
}
groupBy(plan: ReadPlan, alias: string): string {
return plan.select.aggregates.length && plan.select.columns.length
? ` GROUP BY ${plan.select.columns.map((c) => this.column(alias, c)).join(', ')}` : '';
}
read(plan: ReadPlan, ctx: RenderContext): Statement {
const alias = ctx.alias();
const row = this.rowJson(plan.resource, plan.select, alias, ctx);
const where = this.where(plan, alias, ctx);
const ordered = this.order(plan.order, alias, ctx);
const sql = `SELECT ${row} AS r FROM ${this.table(plan.resource.schema, plan.resource.table)} ${alias}${where}${this.groupBy(plan, alias)}${ordered ? ` ORDER BY ${ordered}` : ''}${this.page(plan.limit, plan.offset)}`;
return ctx.params.finalize(sql, (i) => this.placeholder(i));
}
count(plan: ReadPlan, ctx: RenderContext): Statement {
const alias = ctx.alias();
const from = `${this.table(plan.resource.schema, plan.resource.table)} ${alias}${this.where(plan, alias, ctx)}`;
const sql = plan.select.aggregates.length && plan.select.columns.length
? `SELECT COUNT(*) AS n FROM (SELECT 1 AS one FROM ${from}${this.groupBy(plan, alias)}) g`
: plan.select.aggregates.length ? 'SELECT 1 AS n' : `SELECT COUNT(*) AS n FROM ${from}`;
return ctx.params.finalize(sql, (i) => this.placeholder(i));
}
}