docs: a to-do app as the example schema in the docs, fixtures and tests

This commit is contained in:
2026-11-02 22:19:20 +01:00
parent 93427d8cb6
commit 8fc42bc33b
18 changed files with 215 additions and 215 deletions
+4 -4
View File
@@ -3,13 +3,13 @@
A REST API generated from your database's catalog. Name the schemas or tables to serve, and restloom reads their columns, types, keys and relations, and answers queries like this one:
```
GET /assets?select=id,name,owner(name),tags(name)&tags.name=in.(pci,gdpr)&order=owner.name.desc
GET /todos?select=id,name,owner(name),tags(name)&tags.name=in.(work,home)&order=owner.name.desc
```
```json
[
{ "id": 4, "name": "payroll db", "owner": { "name": "Linus" }, "tags": [{ "name": "pci" }, { "name": "gdpr" }] },
{ "id": 2, "name": "mail relay", "owner": { "name": "Grace" }, "tags": [{ "name": "gdpr" }] }
{ "id": 4, "name": "pay rent", "owner": { "name": "Linus" }, "tags": [{ "name": "work" }, { "name": "home" }] },
{ "id": 2, "name": "file taxes", "owner": { "name": "Grace" }, "tags": [{ "name": "home" }] }
]
```
@@ -46,7 +46,7 @@ import { postgres } from '@crudy/restloom-postgres';
const rest = createREST({
db: postgres(new pg.Pool()),
expose: { schemas: ['app'] },
resources: { assets: { write: true } },
resources: { todos: { write: true } },
});
http.createServer(nodeListener(rest)).listen(3000);
+1 -1
View File
@@ -62,7 +62,7 @@ export interface ProcedureOptions {
export interface RestRequest<C = unknown> {
method: string;
/** Path without the mount prefix, e.g. /assets/42. */
/** Path without the mount prefix, e.g. /todos/42. */
path: string;
query: URLSearchParams;
headers: Record<string, string | undefined>;
+5 -5
View File
@@ -11,14 +11,14 @@ export function catalog() {
{ 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 })],
{ schema: 'demo', name: 'todos', kind: 'table', primaryKey: ['id'],
columns: [col('id', 'integer'), col('name'), col('status'), col('owner_id', 'integer'), col('estimate', 'number'), col('pinned', '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')],
{ schema: 'demo', name: 'todo_tags', kind: 'table', primaryKey: ['todo_id', 'tag_id'],
columns: [col('todo_id', 'integer'), col('tag_id', 'integer')],
foreignKeys: [
{ columns: ['asset_id'], target: { schema: 'demo', table: 'assets' }, targetColumns: ['id'] },
{ columns: ['todo_id'], target: { schema: 'demo', table: 'todos' }, 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: [] },
+14 -14
View File
@@ -17,36 +17,36 @@ test('unknown, hidden and quoted names', () => {
});
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);
assert.equal(code(() => parse('todos', 'id=eq.abc')), 'invalid_value');
assert.equal(code(() => parse('todos', 'pinned=eq.yes')), 'invalid_value');
assert.equal(code(() => parse('todos', 'estimate=like.1*')), 'bad_operator');
assert.equal(parse('todos', 'id=eq.4').where[0].value, 4);
});
test('relation paths, presence and depth', () => {
const f = parse('assets', 'owner.manager.name=eq.Ada').where[0];
const f = parse('todos', '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');
assert.equal(code(() => parse('todos', 'owner.manager.manager.name=eq.Ada')), 'too_deep');
assert.equal(parse('todos', 'tags=is.empty').where[0].kind, 'present');
assert.equal(code(() => parse('todos', 'order=tags.name')), 'bad_order');
assert.equal(parse('todos', 'order=tags.count.desc').order[0].key.kind, 'count');
});
test('limits are refused, not clamped', () => {
assert.equal(code(() => parse('assets', 'limit=1001')), 'limit_exceeded');
assert.equal(code(() => parse('todos', 'limit=1001')), 'limit_exceeded');
const many = Array.from({ length: 51 }, (_, i) => `id=neq.${i}`).join('&');
assert.equal(code(() => parse('assets', many)), 'too_many_filters');
assert.equal(code(() => parse('todos', 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');
assert.deepEqual(parse('todos', 'order=name.desc').order.map((o) => o.key.path.column), ['name', 'id']);
assert.equal(code(() => parse('todos', '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 plan = parse('todos', '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);
+7 -7
View File
@@ -4,7 +4,7 @@ import { resources } from './fixture.mjs';
test('only exposed schemas become resources', () => {
const r = resources();
assert.deepEqual([...r.keys()].sort(), ['asset_tags', 'assets', 'tags', 'users']);
assert.deepEqual([...r.keys()].sort(), ['tags', 'todo_tags', 'todos', 'users']);
});
test('sensitive columns are hidden unless shown, and hide removes any column', () => {
@@ -16,12 +16,12 @@ test('sensitive columns are hidden unless shown, and hide removes any column', (
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('todos', 'owner'), 'one:users');
assert.equal(rel('users', 'todos'), 'many:todos');
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');
assert.equal(rel('todos', 'tags'), 'many:tags:through');
assert.equal(rel('tags', 'todos'), 'many:todos:through');
});
test('relations can be renamed, removed and declared', () => {
@@ -32,8 +32,8 @@ test('relations can be renamed, removed and declared', () => {
});
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(resources().get('todos').write.create, false);
const a = resources({ resources: { todos: { write: true } } }).get('todos');
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);