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
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Marc Villain
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@crudy/restloom",
"version": "0.1.0",
"type": "module",
"description": "A REST API generated from your database's catalog: discovery, a curl-friendly query grammar, relations of every kind, safe defaults",
"keywords": ["rest", "api", "postgrest", "sql", "database", "crud", "generator"],
"author": "Marc Villain",
"homepage": "https://restloom.crudy.fr",
"repository": { "type": "git", "url": "git+https://github.com/crudylabs/restloom.git", "directory": "packages/core" },
"license": "Apache-2.0",
"exports": {
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"files": ["dist", "LICENSE", "README.md"],
"engines": { "node": ">=20" },
"scripts": {
"build": "tsc -p .",
"prepack": "npm run build"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.9.0"
}
}
+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));
}
}
+33
View File
@@ -0,0 +1,33 @@
import { DEFAULT_LIMITS } from '../dist/index.js';
import { buildResources } from '../dist/resources.js';
const col = (name, type = 'text', extra = {}) => ({ name, type, nativeType: type, nullable: true, generated: false, ...extra });
/** The demo schema as a catalog, without a database. */
export function catalog() {
return {
functions: [],
tables: [
{ schema: 'demo', name: 'users', kind: 'table', primaryKey: ['id'],
columns: [col('id', 'integer'), col('name'), col('password_hash'), col('limit', 'integer'), col('manager_id', 'integer')],
foreignKeys: [{ columns: ['manager_id'], target: { schema: 'demo', table: 'users' }, targetColumns: ['id'] }] },
{ schema: 'demo', name: 'assets', kind: 'table', primaryKey: ['id'],
columns: [col('id', 'integer'), col('name'), col('status'), col('owner_id', 'integer'), col('cost', 'number'), col('active', 'boolean'), col('created_at', 'timestamp', { generated: true })],
foreignKeys: [{ columns: ['owner_id'], target: { schema: 'demo', table: 'users' }, targetColumns: ['id'] }] },
{ schema: 'demo', name: 'tags', kind: 'table', primaryKey: ['id'], columns: [col('id', 'integer'), col('name')], foreignKeys: [] },
{ schema: 'demo', name: 'asset_tags', kind: 'table', primaryKey: ['asset_id', 'tag_id'],
columns: [col('asset_id', 'integer'), col('tag_id', 'integer')],
foreignKeys: [
{ columns: ['asset_id'], target: { schema: 'demo', table: 'assets' }, targetColumns: ['id'] },
{ columns: ['tag_id'], target: { schema: 'demo', table: 'tags' }, targetColumns: ['id'] },
] },
{ schema: 'other', name: 'secrets', kind: 'table', primaryKey: ['id'], columns: [col('id', 'integer')], foreignKeys: [] },
],
};
}
export function resources(options = {}) {
return buildResources(catalog(), { db: null, expose: { schemas: ['demo'] }, ...options });
}
export const limits = DEFAULT_LIMITS;
+54
View File
@@ -0,0 +1,54 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseRead } from '../dist/grammar/read.js';
import { SqlRenderer } from '../dist/index.js';
import { resources, limits } from './fixture.mjs';
const r = resources();
const parse = (res, qs) => parseRead(r.get(res), new URLSearchParams(qs), limits);
const code = (fn) => { try { fn(); } catch (e) { return e.code; } return 'none'; };
test('unknown, hidden and quoted names', () => {
assert.equal(code(() => parse('users', 'select=password_hash')), 'unknown_column');
assert.equal(code(() => parse('users', 'nope=eq.1')), 'unknown_column');
assert.deepEqual(parse('users', 'select="limit"').select.columns, ['limit']);
assert.equal(parse('users', '"limit"=eq.2').where[0].path.column, 'limit');
assert.equal(code(() => parse('users', 'limit=x')), 'bad_limit');
});
test('values are checked against the column type', () => {
assert.equal(code(() => parse('assets', 'id=eq.abc')), 'invalid_value');
assert.equal(code(() => parse('assets', 'active=eq.yes')), 'invalid_value');
assert.equal(code(() => parse('assets', 'cost=like.1*')), 'bad_operator');
assert.equal(parse('assets', 'id=eq.4').where[0].value, 4);
});
test('relation paths, presence and depth', () => {
const f = parse('assets', 'owner.manager.name=eq.Ada').where[0];
assert.deepEqual(f.path.relations.map((x) => x.name), ['owner', 'manager']);
assert.equal(code(() => parse('assets', 'owner.manager.manager.name=eq.Ada')), 'too_deep');
assert.equal(parse('assets', 'tags=is.empty').where[0].kind, 'present');
assert.equal(code(() => parse('assets', 'order=tags.name')), 'bad_order');
assert.equal(parse('assets', 'order=tags.count.desc').order[0].key.kind, 'count');
});
test('limits are refused, not clamped', () => {
assert.equal(code(() => parse('assets', 'limit=1001')), 'limit_exceeded');
const many = Array.from({ length: 51 }, (_, i) => `id=neq.${i}`).join('&');
assert.equal(code(() => parse('assets', many)), 'too_many_filters');
});
test('the key is appended to the order; a grouped read orders by its columns', () => {
assert.deepEqual(parse('assets', 'order=name.desc').order.map((o) => o.key.path.column), ['name', 'id']);
assert.equal(code(() => parse('assets', 'select=status,count()&order=name')), 'bad_order');
});
test('placeholders follow the text order, for positional databases too', () => {
class Positional extends SqlRenderer { placeholder() { return '?'; } }
const renderer = new Positional();
const plan = parse('assets', 'select=id,owner(name)&status=eq.open');
const scope = (res) => res.name === 'users' ? [{ kind: 'cmp', path: { relations: [], column: 'name' }, op: 'eq', negate: false, value: 'scoped' }] : [];
const { sql, params } = renderer.read(plan, renderer.context(scope));
assert.equal(sql.split('?').length - 1, params.length);
assert.deepEqual(params, ['scoped', 'open']);
});
+46
View File
@@ -0,0 +1,46 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { resources } from './fixture.mjs';
test('only exposed schemas become resources', () => {
const r = resources();
assert.deepEqual([...r.keys()].sort(), ['asset_tags', 'assets', 'tags', 'users']);
});
test('sensitive columns are hidden unless shown, and hide removes any column', () => {
assert.equal(resources().get('users').columns.has('password_hash'), false);
assert.equal(resources({ resources: { users: { show: ['password_hash'] } } }).get('users').columns.has('password_hash'), true);
assert.equal(resources({ resources: { users: { hide: ['name'] } } }).get('users').columns.has('name'), false);
});
test('foreign keys give to-one, to-many and many-to-many relations', () => {
const r = resources();
const rel = (res, name) => { const x = r.get(res).relations.get(name); return x && `${x.kind}:${x.target.name}${x.through ? ':through' : ''}`; };
assert.equal(rel('assets', 'owner'), 'one:users');
assert.equal(rel('users', 'assets'), 'many:assets');
assert.equal(rel('users', 'manager'), 'one:users');
assert.equal(rel('users', 'users'), 'many:users');
assert.equal(rel('assets', 'tags'), 'many:tags:through');
assert.equal(rel('tags', 'assets'), 'many:assets:through');
});
test('relations can be renamed, removed and declared', () => {
const r = resources({ resources: { users: { relations: { users: false, reports: { many: 'users', via: 'manager_id' } } } } });
assert.equal(r.get('users').relations.has('users'), false);
assert.equal(r.get('users').relations.get('reports').kind, 'many');
assert.throws(() => resources({ resources: { users: { relations: { name: { one: 'users', via: 'manager_id' } } } } }), /name of a column/);
});
test('writes are off by default and never reach generated or key columns', () => {
assert.equal(resources().get('assets').write.create, false);
const a = resources({ resources: { assets: { write: true } } }).get('assets');
assert.equal(a.columns.get('name').create, true);
assert.equal(a.columns.get('created_at').create, false);
assert.equal(a.columns.get('id').update, false);
assert.equal(a.write.delete, true);
});
test('options naming unknown resources or columns fail at load', () => {
assert.throws(() => resources({ resources: { nope: {} } }), /no exposed table/);
assert.throws(() => resources({ resources: { users: { hide: ['nope'] } } }), /no column nope/);
});
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Marc Villain
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Marc Villain
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Marc Villain
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Marc Villain
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Marc Villain
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@crudy/restloom-postgres",
"version": "0.1.0",
"type": "module",
"description": "Postgres dialect for restloom",
"keywords": ["restloom", "postgres", "postgresql", "rest", "api"],
"author": "Marc Villain",
"homepage": "https://restloom.crudy.fr",
"repository": { "type": "git", "url": "git+https://github.com/crudylabs/restloom.git", "directory": "packages/postgres" },
"license": "Apache-2.0",
"exports": {
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"files": ["dist", "LICENSE", "README.md"],
"engines": { "node": ">=20" },
"scripts": {
"build": "tsc -p .",
"prepack": "npm run build"
},
"dependencies": {
"@crudy/restloom": "^0.1.0"
},
"peerDependencies": {
"pg": "^8.11.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"@types/pg": "^8.11.0",
"pg": "^8.16.0",
"typescript": "^5.9.0"
}
}
+23
View File
@@ -0,0 +1,23 @@
import { RestError } from '@crudy/restloom';
const CODES: Record<string, [number, string]> = {
'42501': [403, 'forbidden'],
'23505': [409, 'duplicate'],
'23503': [409, 'reference_missing'],
'23502': [400, 'value_required'],
'23514': [400, 'value_rejected'],
'22P02': [400, 'invalid_value'],
'22007': [400, 'invalid_value'],
'22008': [400, 'invalid_value'],
'22003': [400, 'invalid_value'],
'22001': [400, 'invalid_value'],
'22023': [400, 'invalid_value'],
'57014': [503, 'timeout'],
};
/** A Postgres error the client caused, by SQLSTATE. The database's own message is never passed on. */
export function mapError(err: unknown): RestError | null {
const code = (err as { code?: unknown })?.code;
const hit = typeof code === 'string' ? CODES[code] : undefined;
return hit ? new RestError(hit[0], hit[1]) : null;
}
+49
View File
@@ -0,0 +1,49 @@
import { SqlRenderer, type Dialect, type Tx } from '@crudy/restloom';
import type { Pool } from 'pg';
import { mapError } from './errors.js';
import { introspect } from './introspect.js';
export { mapError } from './errors.js';
/** Postgres writes every primitive the core's defaults assume; subclass it to change one. */
export class PostgresRenderer extends SqlRenderer {}
export interface PostgresOptions {
renderer?: SqlRenderer;
}
/**
* The Postgres dialect over a `pg` Pool. Every request runs in one transaction
* with a local statement timeout, read-only for reads, so a `transaction` hook
* can set the role and session settings that row security reads.
*/
export function postgres(pool: Pool, options: PostgresOptions = {}): Dialect {
return {
name: 'postgres',
capabilities: { returning: true, estimatedCount: true, rowSecurity: true },
renderer: options.renderer ?? new PostgresRenderer(),
introspect: (scope) => introspect(pool, scope),
async transaction(fn, { timeoutMs, readOnly }) {
const client = await pool.connect();
try {
await client.query(readOnly ? 'BEGIN READ ONLY' : 'BEGIN');
await client.query(`SET LOCAL statement_timeout = ${Math.max(1, Math.floor(timeoutMs))}`);
const tx: Tx = { query: async (sql, params) => (await client.query(sql, params)).rows };
const out = await fn(tx);
await client.query('COMMIT');
return out;
} catch (err) {
await client.query('ROLLBACK').catch(() => undefined);
throw err;
} finally {
client.release();
}
},
mapError,
async estimate(tx, sql, params) {
const [row] = await tx.query(`EXPLAIN (FORMAT JSON) ${sql}`, params);
const plan = (row?.['QUERY PLAN'] as { Plan?: { 'Plan Rows'?: number } }[] | undefined)?.[0]?.Plan;
return typeof plan?.['Plan Rows'] === 'number' ? plan['Plan Rows'] : null;
},
};
}
+96
View File
@@ -0,0 +1,96 @@
import type { Catalog, CatalogColumn, CatalogTable } from '@crudy/restloom';
import type { Pool } from 'pg';
import { logical } from './types.js';
const TABLES = `
SELECT c.oid, n.nspname AS schema, c.relname AS name, c.relkind AS kind
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r', 'p', 'v', 'm', 'f')
AND (n.nspname = ANY($1::text[]) OR n.nspname || '.' || c.relname = ANY($2::text[]))`;
const COLUMNS = `
SELECT a.attrelid AS oid, a.attname AS name, format_type(a.atttypid, a.atttypmod) AS native,
b.typname, b.typtype, b.typcategory, a.attnotnull AS notnull,
(a.attgenerated <> '' OR a.attidentity = 'a') AS generated
FROM pg_attribute a
JOIN pg_type t ON t.oid = a.atttypid
JOIN pg_type b ON b.oid = CASE WHEN t.typtype = 'd' THEN t.typbasetype ELSE t.oid END
WHERE a.attrelid = ANY($1::oid[]) AND a.attnum > 0 AND NOT a.attisdropped
ORDER BY a.attrelid, a.attnum`;
const PRIMARY_KEYS = `
SELECT i.indrelid AS oid, array_agg(a.attname::text ORDER BY k.ord) AS columns
FROM pg_index i
CROSS JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord)
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum
WHERE i.indisprimary AND i.indrelid = ANY($1::oid[])
GROUP BY i.indrelid`;
const FOREIGN_KEYS = `
SELECT con.conrelid AS oid, tn.nspname AS target_schema, tc.relname AS target_table,
(SELECT array_agg(a.attname::text ORDER BY k.ord) FROM unnest(con.conkey) WITH ORDINALITY k(n, ord)
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k.n) AS columns,
(SELECT array_agg(a.attname::text ORDER BY k.ord) FROM unnest(con.confkey) WITH ORDINALITY k(n, ord)
JOIN pg_attribute a ON a.attrelid = con.confrelid AND a.attnum = k.n) AS target_columns
FROM pg_constraint con
JOIN pg_class tc ON tc.oid = con.confrelid
JOIN pg_namespace tn ON tn.oid = tc.relnamespace
WHERE con.contype = 'f' AND con.conrelid = ANY($1::oid[])
ORDER BY con.conname`;
const FUNCTIONS = `
SELECT n.nspname AS schema, p.proname AS name, p.proretset AS returns_set, p.pronargs AS nargs,
p.pronargdefaults AS ndefaults, coalesce(p.proargnames, '{}') AS argnames,
(SELECT array_agg(format_type(t, NULL) ORDER BY ord) FROM unnest(p.proargtypes) WITH ORDINALITY u(t, ord)) AS natives,
(SELECT array_agg(b.typname::text || ':' || b.typtype::text || ':' || b.typcategory::text ORDER BY ord)
FROM unnest(p.proargtypes) WITH ORDINALITY u(t, ord) JOIN pg_type b ON b.oid = u.t) AS types
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE p.prokind = 'f' AND n.nspname = ANY($1::text[])`;
type Row = Record<string, unknown>;
/** Reads the tables, views, keys, foreign keys and functions of the exposed schemas and tables. */
export async function introspect(pool: Pool, scope: { schemas: string[]; tables: string[] }): Promise<Catalog> {
const { rows: tables } = await pool.query<Row>(TABLES, [scope.schemas, scope.tables]);
const oids = tables.map((t) => t.oid as number);
const [columns, keys, fks, fns] = await Promise.all([
pool.query<Row>(COLUMNS, [oids]), pool.query<Row>(PRIMARY_KEYS, [oids]),
pool.query<Row>(FOREIGN_KEYS, [oids]), pool.query<Row>(FUNCTIONS, [scope.schemas]),
]);
const byOid = new Map<number, CatalogTable>();
for (const t of tables) {
byOid.set(t.oid as number, {
schema: t.schema as string, name: t.name as string,
kind: t.kind === 'v' || t.kind === 'm' ? 'view' : 'table',
columns: [], primaryKey: [], foreignKeys: [],
});
}
for (const c of columns.rows) {
const col: CatalogColumn = {
name: c.name as string, nativeType: c.native as string, nullable: !c.notnull, generated: c.generated as boolean,
type: logical(c.typname as string, c.typtype as string, c.typcategory as string),
};
byOid.get(c.oid as number)?.columns.push(col);
}
for (const k of keys.rows) byOid.get(k.oid as number)!.primaryKey = k.columns as string[];
for (const f of fks.rows) {
byOid.get(f.oid as number)!.foreignKeys.push({
columns: f.columns as string[], targetColumns: f.target_columns as string[],
target: { schema: f.target_schema as string, table: f.target_table as string },
});
}
const functions = fns.rows.map((f) => {
const names = f.argnames as string[];
const types = (f.types as string[] | null) ?? [];
const natives = (f.natives as string[] | null) ?? [];
const nargs = f.nargs as number;
return {
schema: f.schema as string, name: f.name as string, returnsSet: f.returns_set as boolean,
args: types.map((t, i) => {
const [typname, typtype, category] = t.split(':');
return { name: names[i] ?? '', type: logical(typname, typtype, category), nativeType: natives[i], hasDefault: i >= nargs - (f.ndefaults as number) };
}),
};
});
return { tables: [...byOid.values()], functions };
}
+18
View File
@@ -0,0 +1,18 @@
import type { LogicalType } from '@crudy/restloom';
const BY_NAME: Record<string, LogicalType> = {
int2: 'integer', int4: 'integer', int8: 'integer', oid: 'integer',
numeric: 'number', float4: 'number', float8: 'number', money: 'number',
bool: 'boolean', uuid: 'uuid',
date: 'date', timestamp: 'timestamp', timestamptz: 'timestamp', time: 'time', timetz: 'time',
text: 'text', varchar: 'text', bpchar: 'text', name: 'text', citext: 'text', inet: 'text', cidr: 'text',
json: 'json', jsonb: 'json', bytea: 'binary',
};
/** A Postgres type (after resolving a domain to its base) as a logical type. Enums read as text. */
export function logical(typname: string, typtype: string, category: string): LogicalType {
if (BY_NAME[typname]) return BY_NAME[typname];
if (typtype === 'e') return 'text';
if (category === 'A') return 'array';
return 'other';
}
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Marc Villain
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.