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
@@ -8,13 +8,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" }] }
]
```
@@ -51,7 +51,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);
+28 -28
View File
@@ -1,8 +1,8 @@
IF OBJECT_ID('demo.assets_tagged') IS NOT NULL DROP FUNCTION demo.assets_tagged;
IF OBJECT_ID('demo.incidents') IS NOT NULL DROP TABLE demo.incidents;
IF OBJECT_ID('demo.asset_tags') IS NOT NULL DROP TABLE demo.asset_tags;
IF OBJECT_ID('demo.todos_tagged') IS NOT NULL DROP FUNCTION demo.todos_tagged;
IF OBJECT_ID('demo.steps') IS NOT NULL DROP TABLE demo.steps;
IF OBJECT_ID('demo.todo_tags') IS NOT NULL DROP TABLE demo.todo_tags;
IF OBJECT_ID('demo.tags') IS NOT NULL DROP TABLE demo.tags;
IF OBJECT_ID('demo.assets') IS NOT NULL DROP TABLE demo.assets;
IF OBJECT_ID('demo.todos') IS NOT NULL DROP TABLE demo.todos;
IF OBJECT_ID('demo.users') IS NOT NULL DROP TABLE demo.users;
IF SCHEMA_ID('demo') IS NOT NULL DROP SCHEMA demo;
GO
@@ -17,13 +17,13 @@ CREATE TABLE demo.users (
manager_id INT REFERENCES demo.users (id)
);
CREATE TABLE demo.assets (
CREATE TABLE demo.todos (
id INT PRIMARY KEY,
name NVARCHAR(100) NOT NULL,
status NVARCHAR(20) NOT NULL,
owner_id INT REFERENCES demo.users (id),
cost DECIMAL(10, 2),
active BIT NOT NULL DEFAULT 1,
estimate DECIMAL(10, 2),
pinned BIT NOT NULL DEFAULT 1,
created_at DATETIMEOFFSET(0) NOT NULL
);
@@ -32,18 +32,18 @@ CREATE TABLE demo.tags (
name NVARCHAR(100) NOT NULL UNIQUE
);
CREATE TABLE demo.asset_tags (
asset_id INT NOT NULL REFERENCES demo.assets (id),
CREATE TABLE demo.todo_tags (
todo_id INT NOT NULL REFERENCES demo.todos (id),
tag_id INT NOT NULL REFERENCES demo.tags (id),
PRIMARY KEY (asset_id, tag_id)
PRIMARY KEY (todo_id, tag_id)
);
CREATE TABLE demo.incidents (
CREATE TABLE demo.steps (
id INT PRIMARY KEY,
title NVARCHAR(200) NOT NULL,
asset_id INT NOT NULL REFERENCES demo.assets (id),
severity INT NOT NULL,
opened_on DATE NOT NULL
todo_id INT NOT NULL REFERENCES demo.todos (id),
priority INT NOT NULL,
due_on DATE NOT NULL
);
INSERT INTO demo.users (id, name, email, password_hash, [limit], manager_id) VALUES
@@ -51,24 +51,24 @@ INSERT INTO demo.users (id, name, email, password_hash, [limit], manager_id) VAL
(2, 'Grace', 'grace@example.com', 'x', NULL, 1),
(3, 'Linus', NULL, 'x', 2, 1);
INSERT INTO demo.assets (id, name, status, owner_id, cost, active, created_at) VALUES
(1, 'vpn gateway', 'open', 1, 1200.50, 1, '2026-01-02T10:00:00+00:00'),
(2, 'mail relay', 'open', 2, 300.00, 1, '2026-01-05T09:30:00+00:00'),
(3, 'old wiki', 'retired', NULL, NULL, 0, '2025-06-01T00:00:00+00:00'),
(4, 'payroll db', 'open', 3, 4000.00, 1, '2026-02-11T16:45:00+00:00');
INSERT INTO demo.todos (id, name, status, owner_id, estimate, pinned, created_at) VALUES
(1, 'water plants', 'open', 1, 2.50, 1, '2026-01-02T10:00:00+00:00'),
(2, 'file taxes', 'open', 2, 1.00, 1, '2026-01-05T09:30:00+00:00'),
(3, 'fix bike', 'done', NULL, NULL, 0, '2025-06-01T00:00:00+00:00'),
(4, 'pay rent', 'open', 3, 4.00, 1, '2026-02-11T16:45:00+00:00');
INSERT INTO demo.tags (id, name) VALUES (1, 'pci'), (2, 'gdpr'), (3, 'internal');
INSERT INTO demo.tags (id, name) VALUES (1, 'work'), (2, 'home'), (3, 'urgent');
INSERT INTO demo.asset_tags (asset_id, tag_id) VALUES (1, 1), (1, 3), (2, 2), (4, 1), (4, 2);
INSERT INTO demo.todo_tags (todo_id, tag_id) VALUES (1, 1), (1, 3), (2, 2), (4, 1), (4, 2);
INSERT INTO demo.incidents (id, title, asset_id, severity, opened_on) VALUES
(1, 'VPN outage', 1, 3, '2026-01-10'),
(2, 'Spoofed mail', 2, 2, '2026-02-01'),
(3, 'Brute force', 1, 4, '2026-03-05');
INSERT INTO demo.steps (id, title, todo_id, priority, due_on) VALUES
(1, 'Water the ferns', 1, 3, '2026-01-10'),
(2, 'Find the receipts', 2, 2, '2026-02-01'),
(3, 'Buy a watering can', 1, 4, '2026-03-05');
GO
CREATE FUNCTION demo.assets_tagged(@tag NVARCHAR(100)) RETURNS TABLE AS RETURN (
SELECT a.* FROM demo.assets a
JOIN demo.asset_tags t ON t.asset_id = a.id
CREATE FUNCTION demo.todos_tagged(@tag NVARCHAR(100)) RETURNS TABLE AS RETURN (
SELECT a.* FROM demo.todos a
JOIN demo.todo_tags t ON t.todo_id = a.id
JOIN demo.tags g ON g.id = t.tag_id
WHERE g.name = @tag
);
+26 -26
View File
@@ -12,13 +12,13 @@ CREATE TABLE demo.users (
FOREIGN KEY (manager_id) REFERENCES demo.users (id)
);
CREATE TABLE demo.assets (
CREATE TABLE demo.todos (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL,
owner_id INT,
cost DECIMAL(10, 2),
active TINYINT(1) NOT NULL DEFAULT 1,
estimate DECIMAL(10, 2),
pinned TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL,
FOREIGN KEY (owner_id) REFERENCES demo.users (id)
);
@@ -28,21 +28,21 @@ CREATE TABLE demo.tags (
name VARCHAR(100) NOT NULL UNIQUE
);
CREATE TABLE demo.asset_tags (
asset_id INT NOT NULL,
CREATE TABLE demo.todo_tags (
todo_id INT NOT NULL,
tag_id INT NOT NULL,
PRIMARY KEY (asset_id, tag_id),
FOREIGN KEY (asset_id) REFERENCES demo.assets (id),
PRIMARY KEY (todo_id, tag_id),
FOREIGN KEY (todo_id) REFERENCES demo.todos (id),
FOREIGN KEY (tag_id) REFERENCES demo.tags (id)
);
CREATE TABLE demo.incidents (
CREATE TABLE demo.steps (
id INT PRIMARY KEY,
title VARCHAR(200) NOT NULL,
asset_id INT NOT NULL,
severity INT NOT NULL,
opened_on DATE NOT NULL,
FOREIGN KEY (asset_id) REFERENCES demo.assets (id)
todo_id INT NOT NULL,
priority INT NOT NULL,
due_on DATE NOT NULL,
FOREIGN KEY (todo_id) REFERENCES demo.todos (id)
);
SET time_zone = '+00:00';
@@ -52,23 +52,23 @@ INSERT INTO demo.users (id, name, email, password_hash, `limit`, manager_id) VAL
(2, 'Grace', 'grace@example.com', 'x', NULL, 1),
(3, 'Linus', NULL, 'x', 2, 1);
INSERT INTO demo.assets (id, name, status, owner_id, cost, active, created_at) VALUES
(1, 'vpn gateway', 'open', 1, 1200.50, 1, '2026-01-02 10:00:00'),
(2, 'mail relay', 'open', 2, 300.00, 1, '2026-01-05 09:30:00'),
(3, 'old wiki', 'retired', NULL, NULL, 0, '2025-06-01 00:00:00'),
(4, 'payroll db', 'open', 3, 4000.00, 1, '2026-02-11 16:45:00');
INSERT INTO demo.todos (id, name, status, owner_id, estimate, pinned, created_at) VALUES
(1, 'water plants', 'open', 1, 2.50, 1, '2026-01-02 10:00:00'),
(2, 'file taxes', 'open', 2, 1.00, 1, '2026-01-05 09:30:00'),
(3, 'fix bike', 'done', NULL, NULL, 0, '2025-06-01 00:00:00'),
(4, 'pay rent', 'open', 3, 4.00, 1, '2026-02-11 16:45:00');
INSERT INTO demo.tags (id, name) VALUES (1, 'pci'), (2, 'gdpr'), (3, 'internal');
INSERT INTO demo.tags (id, name) VALUES (1, 'work'), (2, 'home'), (3, 'urgent');
INSERT INTO demo.asset_tags (asset_id, tag_id) VALUES (1, 1), (1, 3), (2, 2), (4, 1), (4, 2);
INSERT INTO demo.todo_tags (todo_id, tag_id) VALUES (1, 1), (1, 3), (2, 2), (4, 1), (4, 2);
INSERT INTO demo.incidents (id, title, asset_id, severity, opened_on) VALUES
(1, 'VPN outage', 1, 3, '2026-01-10'),
(2, 'Spoofed mail', 2, 2, '2026-02-01'),
(3, 'Brute force', 1, 4, '2026-03-05');
INSERT INTO demo.steps (id, title, todo_id, priority, due_on) VALUES
(1, 'Water the ferns', 1, 3, '2026-01-10'),
(2, 'Find the receipts', 2, 2, '2026-02-01'),
(3, 'Buy a watering can', 1, 4, '2026-03-05');
CREATE FUNCTION demo.retire_asset(asset INT, reason TEXT) RETURNS INT MODIFIES SQL DATA
CREATE FUNCTION demo.complete_todo(todo INT, note TEXT) RETURNS INT MODIFIES SQL DATA
BEGIN
UPDATE demo.assets SET status = 'retired' WHERE id = asset;
RETURN asset;
UPDATE demo.todos SET status = 'done' WHERE id = todo;
RETURN todo;
END;
+25 -25
View File
@@ -10,13 +10,13 @@ CREATE TABLE demo.users (
manager_id integer REFERENCES demo.users (id)
);
CREATE TABLE demo.assets (
CREATE TABLE demo.todos (
id integer PRIMARY KEY,
name text NOT NULL,
status text NOT NULL,
owner_id integer REFERENCES demo.users (id),
cost numeric(10, 2),
active boolean NOT NULL DEFAULT true,
estimate numeric(10, 2),
pinned boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL
);
@@ -25,18 +25,18 @@ CREATE TABLE demo.tags (
name text NOT NULL UNIQUE
);
CREATE TABLE demo.asset_tags (
asset_id integer NOT NULL REFERENCES demo.assets (id),
CREATE TABLE demo.todo_tags (
todo_id integer NOT NULL REFERENCES demo.todos (id),
tag_id integer NOT NULL REFERENCES demo.tags (id),
PRIMARY KEY (asset_id, tag_id)
PRIMARY KEY (todo_id, tag_id)
);
CREATE TABLE demo.incidents (
CREATE TABLE demo.steps (
id integer PRIMARY KEY,
title text NOT NULL,
asset_id integer NOT NULL REFERENCES demo.assets (id),
severity integer NOT NULL,
opened_on date NOT NULL
todo_id integer NOT NULL REFERENCES demo.todos (id),
priority integer NOT NULL,
due_on date NOT NULL
);
INSERT INTO demo.users (id, name, email, password_hash, "limit", manager_id) VALUES
@@ -44,27 +44,27 @@ INSERT INTO demo.users (id, name, email, password_hash, "limit", manager_id) VAL
(2, 'Grace', 'grace@example.com', 'x', NULL, 1),
(3, 'Linus', NULL, 'x', 2, 1);
INSERT INTO demo.assets (id, name, status, owner_id, cost, active, created_at) VALUES
(1, 'vpn gateway', 'open', 1, 1200.50, true, '2026-01-02T10:00:00Z'),
(2, 'mail relay', 'open', 2, 300.00, true, '2026-01-05T09:30:00Z'),
(3, 'old wiki', 'retired', NULL, NULL, false, '2025-06-01T00:00:00Z'),
(4, 'payroll db', 'open', 3, 4000.00, true, '2026-02-11T16:45:00Z');
INSERT INTO demo.todos (id, name, status, owner_id, estimate, pinned, created_at) VALUES
(1, 'water plants', 'open', 1, 2.50, true, '2026-01-02T10:00:00Z'),
(2, 'file taxes', 'open', 2, 1.00, true, '2026-01-05T09:30:00Z'),
(3, 'fix bike', 'done', NULL, NULL, false, '2025-06-01T00:00:00Z'),
(4, 'pay rent', 'open', 3, 4.00, true, '2026-02-11T16:45:00Z');
INSERT INTO demo.tags (id, name) VALUES (1, 'pci'), (2, 'gdpr'), (3, 'internal');
INSERT INTO demo.tags (id, name) VALUES (1, 'work'), (2, 'home'), (3, 'urgent');
INSERT INTO demo.asset_tags (asset_id, tag_id) VALUES (1, 1), (1, 3), (2, 2), (4, 1), (4, 2);
INSERT INTO demo.todo_tags (todo_id, tag_id) VALUES (1, 1), (1, 3), (2, 2), (4, 1), (4, 2);
INSERT INTO demo.incidents (id, title, asset_id, severity, opened_on) VALUES
(1, 'VPN outage', 1, 3, '2026-01-10'),
(2, 'Spoofed mail', 2, 2, '2026-02-01'),
(3, 'Brute force', 1, 4, '2026-03-05');
INSERT INTO demo.steps (id, title, todo_id, priority, due_on) VALUES
(1, 'Water the ferns', 1, 3, '2026-01-10'),
(2, 'Find the receipts', 2, 2, '2026-02-01'),
(3, 'Buy a watering can', 1, 4, '2026-03-05');
CREATE FUNCTION demo.retire_asset(asset integer, reason text DEFAULT NULL) RETURNS integer
LANGUAGE sql AS $$ UPDATE demo.assets SET status = 'retired' WHERE id = asset RETURNING id $$;
CREATE FUNCTION demo.complete_todo(todo integer, note text DEFAULT NULL) RETURNS integer
LANGUAGE sql AS $$ UPDATE demo.todos SET status = 'done' WHERE id = todo RETURNING id $$;
CREATE FUNCTION demo.assets_tagged(tag text) RETURNS SETOF demo.assets
CREATE FUNCTION demo.todos_tagged(tag text) RETURNS SETOF demo.todos
LANGUAGE sql STABLE AS $$
SELECT a.* FROM demo.assets a JOIN demo.asset_tags t ON t.asset_id = a.id JOIN demo.tags g ON g.id = t.tag_id
SELECT a.* FROM demo.todos a JOIN demo.todo_tags t ON t.todo_id = a.id JOIN demo.tags g ON g.id = t.tag_id
WHERE g.name = tag ORDER BY a.id $$;
CREATE TABLE demo.notes (
+21 -21
View File
@@ -7,13 +7,13 @@ CREATE TABLE demo.users (
manager_id INTEGER REFERENCES users (id)
);
CREATE TABLE demo.assets (
CREATE TABLE demo.todos (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
status TEXT NOT NULL,
owner_id INTEGER REFERENCES users (id),
cost NUMERIC,
active BOOLEAN NOT NULL DEFAULT 1,
estimate NUMERIC,
pinned BOOLEAN NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL
);
@@ -22,18 +22,18 @@ CREATE TABLE demo.tags (
name TEXT NOT NULL UNIQUE
);
CREATE TABLE demo.asset_tags (
asset_id INTEGER NOT NULL REFERENCES assets (id),
CREATE TABLE demo.todo_tags (
todo_id INTEGER NOT NULL REFERENCES todos (id),
tag_id INTEGER NOT NULL REFERENCES tags (id),
PRIMARY KEY (asset_id, tag_id)
PRIMARY KEY (todo_id, tag_id)
);
CREATE TABLE demo.incidents (
CREATE TABLE demo.steps (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
asset_id INTEGER NOT NULL REFERENCES assets (id),
severity INTEGER NOT NULL,
opened_on DATE NOT NULL
todo_id INTEGER NOT NULL REFERENCES todos (id),
priority INTEGER NOT NULL,
due_on DATE NOT NULL
);
INSERT INTO demo.users (id, name, email, password_hash, "limit", manager_id) VALUES
@@ -41,17 +41,17 @@ INSERT INTO demo.users (id, name, email, password_hash, "limit", manager_id) VAL
(2, 'Grace', 'grace@example.com', 'x', NULL, 1),
(3, 'Linus', NULL, 'x', 2, 1);
INSERT INTO demo.assets (id, name, status, owner_id, cost, active, created_at) VALUES
(1, 'vpn gateway', 'open', 1, 1200.50, 1, '2026-01-02T10:00:00+00:00'),
(2, 'mail relay', 'open', 2, 300.00, 1, '2026-01-05T09:30:00+00:00'),
(3, 'old wiki', 'retired', NULL, NULL, 0, '2025-06-01T00:00:00+00:00'),
(4, 'payroll db', 'open', 3, 4000.00, 1, '2026-02-11T16:45:00+00:00');
INSERT INTO demo.todos (id, name, status, owner_id, estimate, pinned, created_at) VALUES
(1, 'water plants', 'open', 1, 2.50, 1, '2026-01-02T10:00:00+00:00'),
(2, 'file taxes', 'open', 2, 1.00, 1, '2026-01-05T09:30:00+00:00'),
(3, 'fix bike', 'done', NULL, NULL, 0, '2025-06-01T00:00:00+00:00'),
(4, 'pay rent', 'open', 3, 4.00, 1, '2026-02-11T16:45:00+00:00');
INSERT INTO demo.tags (id, name) VALUES (1, 'pci'), (2, 'gdpr'), (3, 'internal');
INSERT INTO demo.tags (id, name) VALUES (1, 'work'), (2, 'home'), (3, 'urgent');
INSERT INTO demo.asset_tags (asset_id, tag_id) VALUES (1, 1), (1, 3), (2, 2), (4, 1), (4, 2);
INSERT INTO demo.todo_tags (todo_id, tag_id) VALUES (1, 1), (1, 3), (2, 2), (4, 1), (4, 2);
INSERT INTO demo.incidents (id, title, asset_id, severity, opened_on) VALUES
(1, 'VPN outage', 1, 3, '2026-01-10'),
(2, 'Spoofed mail', 2, 2, '2026-02-01'),
(3, 'Brute force', 1, 4, '2026-03-05');
INSERT INTO demo.steps (id, title, todo_id, priority, due_on) VALUES
(1, 'Water the ferns', 1, 3, '2026-01-10'),
(2, 'Find the receipts', 2, 2, '2026-02-01'),
(3, 'Buy a watering can', 1, 4, '2026-03-05');
+7 -7
View File
@@ -2,8 +2,8 @@
export const PAGES = ['query-grammar.md', 'writes.md'];
const PROCEDURES = {
'assets/{asset}/retire': { function: 'demo.retire_asset' },
'tags/{tag}/assets': { function: 'demo.assets_tagged' },
'todos/{todo}/complete': { function: 'demo.complete_todo' },
'tags/{tag}/todos': { function: 'demo.todos_tagged' },
};
/**
@@ -12,18 +12,18 @@ const PROCEDURES = {
*/
const AVAILABLE = {
postgres: Object.keys(PROCEDURES),
mssql: ['tags/{tag}/assets'],
mysql: ['assets/{asset}/retire'],
mssql: ['tags/{tag}/todos'],
mysql: ['todos/{todo}/complete'],
sqlite: [],
};
export const OPTIONS = {
expose: { schemas: ['demo'] },
resources: {
asset_tags: { expose: false },
todo_tags: { expose: false },
tags: { write: true },
assets: { write: { create: ['name', 'status', 'owner_id', 'cost'], update: ['name', 'status', 'owner_id'], delete: false } },
incidents: { write: { create: true, update: true, delete: true, bulk: true } },
todos: { write: { create: ['name', 'status', 'owner_id', 'estimate'], update: ['name', 'status', 'owner_id'], delete: false } },
steps: { write: { create: true, update: true, delete: true, bulk: true } },
},
};
+7 -7
View File
@@ -33,8 +33,8 @@ for (const page of PAGES) {
describe('postgres procedures outside the exposed schemas', { skip: !url && 'RESTLOOM_PG_URL is not set' }, () => {
test('a declared function is read even when only tables are exposed', async () => {
await pool.query(fixture);
const rest = createREST({ db: postgres(pool), expose: { tables: ['demo.tags'] }, procedures: { 'assets/{asset}/retire': { function: 'demo.retire_asset' } } });
const res = await rest.handle({ method: 'POST', path: '/assets/3/retire', query: new URLSearchParams(), headers: {}, body: {}, context: undefined });
const rest = createREST({ db: postgres(pool), expose: { tables: ['demo.tags'] }, procedures: { 'todos/{todo}/complete': { function: 'demo.complete_todo' } } });
const res = await rest.handle({ method: 'POST', path: '/todos/3/complete', query: new URLSearchParams(), headers: {}, body: {}, context: undefined });
assert.equal(res.status, 200);
assert.equal(res.body, 3);
});
@@ -44,7 +44,7 @@ describe('postgres arrays, regular expressions and GET procedures', { skip: !url
let rest;
before(async () => {
await pool.query(fixture);
rest = createREST({ db: postgres(pool), expose: { schemas: ['demo'] }, procedures: { 'tags/{tag}/assets': { function: 'demo.assets_tagged', method: 'GET' } } });
rest = createREST({ db: postgres(pool), expose: { schemas: ['demo'] }, procedures: { 'tags/{tag}/todos': { function: 'demo.todos_tagged', method: 'GET' } } });
});
const get = (path, qs = '') => rest.handle({ method: 'GET', path, query: new URLSearchParams(qs), headers: {}, context: undefined });
@@ -52,7 +52,7 @@ describe('postgres arrays, regular expressions and GET procedures', { skip: !url
assert.deepEqual((await get('/notes', 'select=id&labels=ov.{a,z}')).body, [{ id: 1 }, { id: 3 }]);
assert.deepEqual((await get('/notes', 'select=id&labels=cs.{b}')).body, [{ id: 1 }, { id: 2 }]);
assert.deepEqual((await get('/notes', 'select=id&labels=cd.{a,b,z}')).body, [{ id: 1 }, { id: 3 }]);
assert.equal((await get('/assets', 'name=ov.{a}')).body.error, 'bad_operator');
assert.equal((await get('/todos', 'name=ov.{a}')).body.error, 'bad_operator');
});
test('regular expressions, with and without case', async () => {
@@ -61,10 +61,10 @@ describe('postgres arrays, regular expressions and GET procedures', { skip: !url
});
test('a GET procedure takes its arguments from the query and refuses POST', async () => {
const rows = (await get('/tags/gdpr/assets', 'select=ignored')).body;
const rows = (await get('/tags/home/todos', 'select=ignored')).body;
assert.equal(rows.error, 'unknown_argument');
assert.deepEqual((await get('/tags/gdpr/assets')).body.map((r) => r.id), [2, 4]);
const post = await rest.handle({ method: 'POST', path: '/tags/gdpr/assets', query: new URLSearchParams(), headers: {}, body: {}, context: undefined });
assert.deepEqual((await get('/tags/home/todos')).body.map((r) => r.id), [2, 4]);
const post = await rest.handle({ method: 'POST', path: '/tags/home/todos', query: new URLSearchParams(), headers: {}, body: {}, context: undefined });
assert.equal(post.status, 405);
});
});
+1 -1
View File
@@ -7,7 +7,7 @@ const response = await rest.handle({ method, path, query, headers, body, context
// { status, headers, body }
```
`handle` never throws: every failure is a response. `path` is the path below where the API is mounted (`/assets/4`), `query` a `URLSearchParams`, `body` the parsed JSON.
`handle` never throws: every failure is a response. `path` is the path below where the API is mounted (`/todos/4`), `query` a `URLSearchParams`, `body` the parsed JSON.
## Node
+5 -5
View File
@@ -8,16 +8,16 @@ restloom reads the database's catalog once, on the first request, and builds the
```ts
expose: { schemas: ['app'] } // every table and view of app
expose: { tables: ['app.assets', 'app.users'] } // only these
expose: { tables: ['app.todos', 'app.users'] } // only these
```
A resource is named after its table. When two exposed schemas hold a table of the same name, both are named `schema_table`. `resources.<name>.expose: false` removes one, for example a junction table you only want used as a relation:
```ts
resources: { asset_tags: { expose: false } }
resources: { todo_tags: { expose: false } }
```
A resource's key is its primary key. A view has none, so it uses a column named `id` when it has one, or the columns you give it: `resources: { asset_rows: { key: ['asset_id'] } }`. A resource without a key can still be listed, but has no `/{id}` path and no stable page order.
A resource's key is its primary key. A view has none, so it uses a column named `id` when it has one, or the columns you give it: `resources: { todo_rows: { key: ['todo_id'] } }`. A resource without a key can still be listed, but has no `/{id}` path and no stable page order.
## Columns
@@ -36,8 +36,8 @@ Relations come from foreign keys between exposed resources:
| In the catalog | Gives |
|---|---|
| `assets.owner_id` references `users.id` | `assets.owner`, to-one; `users.assets`, to-many |
| `asset_tags` has a primary key made of a key to `assets` and a key to `tags` | `assets.tags` and `tags.assets`, many-to-many |
| `todos.owner_id` references `users.id` | `todos.owner`, to-one; `users.todos`, to-many |
| `todo_tags` has a primary key made of a key to `todos` and a key to `tags` | `todos.tags` and `tags.todos`, many-to-many |
Names:
+1 -1
View File
@@ -34,7 +34,7 @@ http.createServer(nodeListener(rest, { prefix: '/api' })).listen(3000);
Every table and view of `app` is now a resource, read-only:
```sh
curl -g 'http://localhost:3000/api/assets?select=id,name,owner(name)&status=eq.open'
curl -g 'http://localhost:3000/api/todos?select=id,name,owner(name)&status=eq.open'
```
## Next
+2 -2
View File
@@ -11,8 +11,8 @@ restloom keeps what makes PostgREST pleasant, the catalog as the source and a qu
| Writes | everything the role may write | off until a resource enables them, by column |
| Authorisation | database roles and row security | your hooks, plus row security where the database has it |
| Relations | inferred from foreign keys | inferred from foreign keys, overridable, removable |
| Filtering through a to-many relation | an embed with `!inner` | `tags.name=eq.pci`: at least one; `not.`: none |
| Ordering by a related column | on embedded resources | `order=owner.name`, `order=incidents.count` |
| Filtering through a to-many relation | an embed with `!inner` | `tags.name=eq.work`: at least one; `not.`: none |
| Ordering by a related column | on embedded resources | `order=owner.name`, `order=steps.count` |
| A name that is a keyword | not addressable as a filter | quote it: `"limit"=eq.5` |
| Over-sized limit | capped | refused with `limit_exceeded` |
| Errors | database messages | stable codes, no database text |
+34 -34
View File
@@ -6,10 +6,10 @@ The demo schema has four resources:
| Resource | Columns | Relations |
|---|---|---|
| `users` | id, name, email, limit, manager_id | `manager` (one user), `users` (the users they manage), `assets` |
| `assets` | id, name, status, owner_id, cost, active, created_at | `owner` (one user), `tags` (many, through `asset_tags`), `incidents` (many) |
| `tags` | id, name | `assets` (many, through `asset_tags`) |
| `incidents` | id, title, asset_id, severity, opened_on | `asset` (one asset) |
| `users` | id, name, email, limit, manager_id | `manager` (one user), `users` (the users they manage), `todos` |
| `todos` | id, name, status, owner_id, estimate, pinned, created_at | `owner` (one user), `tags` (many, through `todo_tags`), `steps` (many) |
| `tags` | id, name | `todos` (many, through `todo_tags`) |
| `steps` | id, title, todo_id, priority, due_on | `todo` (one todo) |
`users.password_hash` exists in the table but is never served: its name looks sensitive, so it is hidden.
@@ -20,21 +20,21 @@ Examples are shown unencoded. A client encodes them like any query string: `curl
Without `select`, a row has every visible column. With it, only the listed ones, in that order.
```http
GET /assets?select=id,name&status=eq.open&order=id
GET /todos?select=id,name&status=eq.open&order=id
```
```json
[{ "id": 1, "name": "vpn gateway" }, { "id": 2, "name": "mail relay" }, { "id": 4, "name": "payroll db" }]
[{ "id": 1, "name": "water plants" }, { "id": 2, "name": "file taxes" }, { "id": 4, "name": "pay rent" }]
```
One row is read by its key. It answers 404 when the row does not exist or is not visible.
```http
GET /assets/1?select=id,name,owner(name)
GET /todos/1?select=id,name,owner(name)
```
```json
{ "id": 1, "name": "vpn gateway", "owner": { "name": "Ada" } }
{ "id": 1, "name": "water plants", "owner": { "name": "Ada" } }
```
## Related rows: embedding
@@ -42,7 +42,7 @@ GET /assets/1?select=id,name,owner(name)
`relation(columns)` puts related rows inside each row: an object (or `null`) for a to-one relation, an array for a to-many one. `relation(*)` takes every visible column. Embeds nest up to the depth limit (2 by default).
```http
GET /assets?status=eq.open&select=id,owner(name,manager(name))
GET /todos?status=eq.open&select=id,owner(name,manager(name))
```
```json
@@ -70,15 +70,15 @@ GET /users?select=id,name,manager(name),users(name)
An embedded array is ordered by the related key, unless `relation.order` says otherwise. `relation.limit` and `relation.offset` page it. Without a limit it holds at most `maxPage` rows (1000).
```http
GET /assets?select=id,incidents(title)&incidents.order=severity.desc&incidents.limit=1&order=incidents.count.desc
GET /todos?select=id,steps(title)&steps.order=priority.desc&steps.limit=1&order=steps.count.desc
```
```json
[
{ "id": 1, "incidents": [{ "title": "Brute force" }] },
{ "id": 2, "incidents": [{ "title": "Spoofed mail" }] },
{ "id": 3, "incidents": [] },
{ "id": 4, "incidents": [] }
{ "id": 1, "steps": [{ "title": "Buy a watering can" }] },
{ "id": 2, "steps": [{ "title": "Find the receipts" }] },
{ "id": 3, "steps": [] },
{ "id": 4, "steps": [] }
]
```
@@ -96,16 +96,16 @@ A filter is `column=operator.value`. Every filter must hold.
| `ov.{a,b}`, `cs.{a,b}`, `cd.{a,b}` | the array overlaps, contains, or is contained by the list | array columns (Postgres) |
| `is.null`, `is.true`, `is.false` | null, or a boolean value | `null` any column, `true` and `false` booleans |
`not.` before the operator negates it: `status=not.in.(retired,lost)`.
`not.` before the operator negates it: `status=not.in.(done,cancelled)`.
A value is checked against the column's type before any SQL runs: `id=eq.abc` answers `invalid_value`. An operator that does not fit the column is refused:
```http
GET /assets?cost=like.1*
GET /todos?estimate=like.1*
```
```json
{ "error": "bad_operator", "message": "like on cost" }
{ "error": "bad_operator", "message": "like on estimate" }
```
### Through relations
@@ -117,29 +117,29 @@ A filter can name a column of a related resource: `relation.column=operator.valu
- **With `not.`** on a relation path: no related row may match.
```http
GET /assets?select=id,name&owner.name=ilike.*a*
GET /todos?select=id,name&owner.name=ilike.*a*
```
```json
[{ "id": 1, "name": "vpn gateway" }, { "id": 2, "name": "mail relay" }]
[{ "id": 1, "name": "water plants" }, { "id": 2, "name": "file taxes" }]
```
```http
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": 1, "name": "vpn gateway", "owner": { "name": "Ada" }, "tags": [{ "name": "pci" }, { "name": "internal" }] }
{ "id": 4, "name": "pay rent", "owner": { "name": "Linus" }, "tags": [{ "name": "work" }, { "name": "home" }] },
{ "id": 2, "name": "file taxes", "owner": { "name": "Grace" }, "tags": [{ "name": "home" }] },
{ "id": 1, "name": "water plants", "owner": { "name": "Ada" }, "tags": [{ "name": "work" }, { "name": "urgent" }] }
]
```
Assets with no `pci` tag, including assets with no tag at all:
Todos with no `work` tag, including todos with no tag at all:
```http
GET /assets?select=id&tags.name=not.eq.pci
GET /todos?select=id&tags.name=not.eq.work
```
```json
@@ -149,7 +149,7 @@ GET /assets?select=id&tags.name=not.eq.pci
`relation=is.empty` keeps rows with no related row, `relation=not.is.empty` rows with at least one. `is.null` means the same.
```http
GET /assets?select=id&tags=is.empty
GET /todos?select=id&tags=is.empty
```
```json
@@ -161,7 +161,7 @@ GET /assets?select=id&tags=is.empty
`or=(term,term)` holds when one term does. A term is `path.operator.value`, with the same operators and relation paths. `and=(...)` groups terms inside an `or`, one level deep. `not.or` and `not.and` negate the group.
```http
GET /assets?select=id&or=(status.eq.retired,owner.name.eq.Linus)
GET /todos?select=id&or=(status.eq.done,owner.name.eq.Linus)
```
```json
@@ -173,16 +173,16 @@ GET /assets?select=id&or=(status.eq.retired,owner.name.eq.Linus)
`order=column.direction,...` with `asc` (default) or `desc`, and optionally `nullsfirst` or `nullslast`.
- **To-one paths:** sort by a related column: `order=owner.name.desc`.
- **To-many relations:** sort by how many related rows each row has: `order=incidents.count.desc`. A to-many relation cannot be sorted by one of its columns, because a row has many values.
- **To-many relations:** sort by how many related rows each row has: `order=steps.count.desc`. A to-many relation cannot be sorted by one of its columns, because a row has many values.
- **Stable pages:** the key is appended to every order.
- **Nulls:** they sort last ascending and first descending, as in Postgres, on every database.
```http
GET /assets?select=id,cost&order=cost.desc
GET /todos?select=id,estimate&order=estimate.desc
```
```json
[{ "id": 3, "cost": null }, { "id": 4, "cost": 4000 }, { "id": 1, "cost": 1200.5 }, { "id": 2, "cost": 300 }]
[{ "id": 3, "estimate": null }, { "id": 4, "estimate": 4 }, { "id": 1, "estimate": 2.5 }, { "id": 2, "estimate": 1 }]
```
## Pages and counts
@@ -190,7 +190,7 @@ GET /assets?select=id,cost&order=cost.desc
`limit` (default 50) and `offset` page a read. A limit over 1000 is refused, never silently reduced:
```http
GET /assets?limit=1001
GET /todos?limit=1001
```
```json
@@ -200,7 +200,7 @@ GET /assets?limit=1001
Every list answers a `Content-Range` header: `0-49/*`, or `*/0` for an empty page. `count=exact` replaces `*` with the total, and `count=estimated` with the planner's estimate where the database has one.
```http
GET /assets?select=id&limit=2&offset=1&count=exact
GET /todos?select=id&limit=2&offset=1&count=exact
```
```json
@@ -214,11 +214,11 @@ The answer above comes with `Content-Range: 1-2/4`.
`count()`, and `column.sum()`, `.avg()`, `.min()`, `.max()`, group by every plain column selected. `sum` and `avg` take numbers; `min` and `max` anything orderable. An aggregate read cannot embed relations, and orders by its selected columns only.
```http
GET /assets?select=status,count(),cost.sum()&order=status
GET /todos?select=status,count(),estimate.sum()&order=status
```
```json
[{ "status": "open", "count": 3, "sum": 5500.5 }, { "status": "retired", "count": 1, "sum": null }]
[{ "status": "done", "count": 1, "sum": null }, { "status": "open", "count": 3, "sum": 7.5 }]
```
## Names that are keywords
+23 -23
View File
@@ -5,12 +5,12 @@ Writes are off until a resource enables them. The examples on this page run with
```ts
resources: {
tags: { write: true },
assets: { write: { create: ['name', 'status', 'owner_id', 'cost'], update: ['name', 'status', 'owner_id'], delete: false } },
incidents: { write: { create: true, update: true, delete: true, bulk: true } },
todos: { write: { create: ['name', 'status', 'owner_id', 'estimate'], update: ['name', 'status', 'owner_id'], delete: false } },
steps: { write: { create: true, update: true, delete: true, bulk: true } },
},
procedures: {
'assets/{asset}/retire': { function: 'demo.retire_asset' },
'tags/{tag}/assets': { function: 'demo.assets_tagged' },
'todos/{todo}/complete': { function: 'demo.complete_todo' },
'tags/{tag}/todos': { function: 'demo.todos_tagged' },
},
```
@@ -21,7 +21,7 @@ procedures: {
| POST | `/tags` | creates one row, or an array of rows | 201 |
| PATCH | `/tags/4` | updates one row | 204, or 200 with the row |
| DELETE | `/tags/4` | deletes one row | 204, or 200 with the row |
| PATCH, DELETE | `/incidents?severity=lt.3` | by filter, only where `bulk: true` | 204, or 200 with the rows |
| PATCH, DELETE | `/steps?priority=lt.3` | by filter, only where `bulk: true` | 204, or 200 with the rows |
## Getting the rows back
@@ -63,13 +63,13 @@ POST /tags
A column outside the resource's writable list is refused, whatever its value:
```http
POST /assets
POST /todos
{ "name": "new laptop", "status": "open", "active": false }
{ "name": "book dentist", "status": "open", "pinned": false }
```
```json
{ "error": "not_writable", "message": "active" }
{ "error": "not_writable", "message": "pinned" }
```
## Updating and deleting
@@ -77,7 +77,7 @@ POST /assets
PATCH and DELETE act on one row, by its key. A row that does not exist, or is not visible, answers 404.
```http
PATCH /assets/2?select=id,name,owner(name)
PATCH /todos/2?select=id,name,owner(name)
Prefer: return=representation
{ "owner_id": 1 }
@@ -86,7 +86,7 @@ Prefer: return=representation
Answer: 200
```json
{ "id": 2, "name": "mail relay", "owner": { "name": "Ada" } }
{ "id": 2, "name": "file taxes", "owner": { "name": "Ada" } }
```
```http
@@ -106,7 +106,7 @@ DELETE /tags/3
```
```http
DELETE /assets/1
DELETE /todos/1
```
```json
@@ -116,18 +116,18 @@ DELETE /assets/1
A resource with `bulk: true` also takes PATCH and DELETE on the collection, with at least one filter. Without a filter they are refused, so no request can empty a table by accident.
```http
DELETE /incidents?severity=lt.3&select=id,title
DELETE /steps?priority=lt.3&select=id,title
Prefer: return=representation
```
Answer: 200
```json
[{ "id": 2, "title": "Spoofed mail" }]
[{ "id": 2, "title": "Find the receipts" }]
```
```http
DELETE /incidents
DELETE /steps
```
```json
@@ -149,9 +149,9 @@ POST /tags
```
```http
POST /incidents
POST /steps
{ "id": 9, "title": "Lost badge", "asset_id": 99, "severity": 1, "opened_on": "2026-04-01" }
{ "id": 9, "title": "Call the clinic", "todo_id": 99, "priority": 1, "due_on": "2026-04-01" }
```
```json
@@ -165,9 +165,9 @@ A procedure calls a database function at a path you choose. A `{name}` in the pa
A procedure is a POST unless declared `method: 'GET'`. A GET procedure takes its arguments from the query string (`/search?q=vpn&limit=10`), each once, and runs in a read-only transaction: use it for a function without side effects.
```http
POST /assets/3/retire
POST /todos/3/complete
{ "reason": "end of life" }
{ "note": "bought a new one" }
```
```json
@@ -177,20 +177,20 @@ POST /assets/3/retire
A function returning a set answers an array of its rows. Mail relay belongs to Ada since the update above.
```http
POST /tags/gdpr/assets
POST /tags/home/todos
```
```json
[
{ "id": 2, "name": "mail relay", "status": "open", "owner_id": 1, "cost": 300, "active": true, "created_at": "2026-01-05T09:30:00+00:00" },
{ "id": 4, "name": "payroll db", "status": "open", "owner_id": 3, "cost": 4000, "active": true, "created_at": "2026-02-11T16:45:00+00:00" }
{ "id": 2, "name": "file taxes", "status": "open", "owner_id": 1, "estimate": 1, "pinned": true, "created_at": "2026-01-05T09:30:00+00:00" },
{ "id": 4, "name": "pay rent", "status": "open", "owner_id": 3, "estimate": 4, "pinned": true, "created_at": "2026-02-11T16:45:00+00:00" }
]
```
```http
POST /assets/3/retire
POST /todos/3/complete
{ "why": "no reason" }
{ "why": "too busy" }
```
```json
+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);