diff --git a/README.md b/README.md index 16ca206..b00405f 100644 --- a/README.md +++ b/README.md @@ -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); diff --git a/conformance/fixtures/mssql.sql b/conformance/fixtures/mssql.sql index 904a8cf..2475bfa 100644 --- a/conformance/fixtures/mssql.sql +++ b/conformance/fixtures/mssql.sql @@ -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 ); diff --git a/conformance/fixtures/mysql.sql b/conformance/fixtures/mysql.sql index 9334674..150389a 100644 --- a/conformance/fixtures/mysql.sql +++ b/conformance/fixtures/mysql.sql @@ -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; diff --git a/conformance/fixtures/postgres.sql b/conformance/fixtures/postgres.sql index bcfdc3d..57189c5 100644 --- a/conformance/fixtures/postgres.sql +++ b/conformance/fixtures/postgres.sql @@ -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 ( diff --git a/conformance/fixtures/sqlite.sql b/conformance/fixtures/sqlite.sql index 7b54434..b07731c 100644 --- a/conformance/fixtures/sqlite.sql +++ b/conformance/fixtures/sqlite.sql @@ -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'); diff --git a/conformance/pages.mjs b/conformance/pages.mjs index 0cfb019..d0d868a 100644 --- a/conformance/pages.mjs +++ b/conformance/pages.mjs @@ -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 } }, }, }; diff --git a/conformance/postgres.test.mjs b/conformance/postgres.test.mjs index 4f0d53f..848839f 100644 --- a/conformance/postgres.test.mjs +++ b/conformance/postgres.test.mjs @@ -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); }); }); diff --git a/docs/adapters.md b/docs/adapters.md index 1967559..08f7fa0 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -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 diff --git a/docs/discovery.md b/docs/discovery.md index 69df101..20939fe 100644 --- a/docs/discovery.md +++ b/docs/discovery.md @@ -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..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: diff --git a/docs/getting-started.md b/docs/getting-started.md index 9ce883f..e9a0962 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -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 diff --git a/docs/postgrest.md b/docs/postgrest.md index ff7d85f..962acfe 100644 --- a/docs/postgrest.md +++ b/docs/postgrest.md @@ -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 | diff --git a/docs/query-grammar.md b/docs/query-grammar.md index 29861f4..a05fc3e 100644 --- a/docs/query-grammar.md +++ b/docs/query-grammar.md @@ -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 diff --git a/docs/writes.md b/docs/writes.md index 22224d4..0863c89 100644 --- a/docs/writes.md +++ b/docs/writes.md @@ -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 diff --git a/packages/core/README.md b/packages/core/README.md index cc9a9b0..891a6c1 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -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); diff --git a/packages/core/src/options.ts b/packages/core/src/options.ts index f9af656..00e82f5 100644 --- a/packages/core/src/options.ts +++ b/packages/core/src/options.ts @@ -62,7 +62,7 @@ export interface ProcedureOptions { export interface RestRequest { 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; diff --git a/packages/core/test/fixture.mjs b/packages/core/test/fixture.mjs index 957ae12..be6ff39 100644 --- a/packages/core/test/fixture.mjs +++ b/packages/core/test/fixture.mjs @@ -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: [] }, diff --git a/packages/core/test/grammar.test.mjs b/packages/core/test/grammar.test.mjs index c06fb86..80cdfd9 100644 --- a/packages/core/test/grammar.test.mjs +++ b/packages/core/test/grammar.test.mjs @@ -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); diff --git a/packages/core/test/resources.test.mjs b/packages/core/test/resources.test.mjs index 5d21390..e86924d 100644 --- a/packages/core/test/resources.test.mjs +++ b/packages/core/test/resources.test.mjs @@ -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);