From 8735abfe79cce9b3695ead9295c4a20d36ace612 Mon Sep 17 00:00:00 2001 From: Marc Villain Date: Mon, 2 Nov 2026 01:50:34 +0100 Subject: [PATCH] feat: Prefer count header, procedures outside exposed schemas; 0.2.0 --- CHANGELOG.md | 11 +++++++++++ conformance/postgres.test.mjs | 10 ++++++++++ package-lock.json | 26 +++++++++++++------------- packages/core/package.json | 2 +- packages/core/src/dialect.ts | 7 +++++-- packages/core/src/read.ts | 2 ++ packages/core/src/rest.ts | 3 ++- packages/core/test/node.test.mjs | 6 ++++++ packages/express/package.json | 4 ++-- packages/fastify/package.json | 4 ++-- packages/mssql/package.json | 4 ++-- packages/mssql/src/introspect.ts | 5 +++-- packages/mysql/package.json | 4 ++-- packages/mysql/src/introspect.ts | 7 ++++--- packages/postgres/package.json | 4 ++-- packages/postgres/src/introspect.ts | 6 +++--- packages/sqlite/package.json | 4 ++-- packages/sqlite/src/introspect.ts | 2 +- 18 files changed, 73 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f9f172..684c1ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +## [0.2.0] - 2026-11-02 + +### Added +- `Prefer: count=exact` (or `estimated`) counts a list, as the `count` parameter does. +- A declared procedure's function is read wherever it lives, not only in an exposed schema. + +### Changed +- `Dialect.introspect` takes the declared functions as `functions`. + +## [0.1.0] - 2026-11-02 + ### Added - `@crudy/restloom`: discovery from the catalog, relations from foreign keys and junction tables, the read grammar, the overridable SQL renderer, `createREST` and a Node `http` listener. - Writes: create, update and delete by key, bulk by filter where enabled, scope applied, rows read back with `Prefer: return=representation`. diff --git a/conformance/postgres.test.mjs b/conformance/postgres.test.mjs index f1d9286..34d58d9 100644 --- a/conformance/postgres.test.mjs +++ b/conformance/postgres.test.mjs @@ -29,3 +29,13 @@ 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 }); + assert.equal(res.status, 200); + assert.equal(res.body, 3); + }); +}); diff --git a/package-lock.json b/package-lock.json index e6c334c..e1690f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3314,7 +3314,7 @@ }, "packages/core": { "name": "@crudy/restloom", - "version": "0.1.0", + "version": "0.2.0", "license": "Apache-2.0", "devDependencies": { "@types/node": "^24.0.0", @@ -3326,10 +3326,10 @@ }, "packages/express": { "name": "@crudy/restloom-express", - "version": "0.1.0", + "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@crudy/restloom": "^0.1.0" + "@crudy/restloom": "^0.2.0" }, "devDependencies": { "@types/express": "^5.0.0", @@ -3346,10 +3346,10 @@ }, "packages/fastify": { "name": "@crudy/restloom-fastify", - "version": "0.1.0", + "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@crudy/restloom": "^0.1.0" + "@crudy/restloom": "^0.2.0" }, "devDependencies": { "@types/node": "^24.0.0", @@ -3365,10 +3365,10 @@ }, "packages/mssql": { "name": "@crudy/restloom-mssql", - "version": "0.1.0", + "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@crudy/restloom": "^0.1.0" + "@crudy/restloom": "^0.2.0" }, "devDependencies": { "@types/mssql": "^12.0.0", @@ -3385,10 +3385,10 @@ }, "packages/mysql": { "name": "@crudy/restloom-mysql", - "version": "0.1.0", + "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@crudy/restloom": "^0.1.0" + "@crudy/restloom": "^0.2.0" }, "devDependencies": { "@types/node": "^24.0.0", @@ -3404,10 +3404,10 @@ }, "packages/postgres": { "name": "@crudy/restloom-postgres", - "version": "0.1.0", + "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@crudy/restloom": "^0.1.0" + "@crudy/restloom": "^0.2.0" }, "devDependencies": { "@types/node": "^24.0.0", @@ -3424,10 +3424,10 @@ }, "packages/sqlite": { "name": "@crudy/restloom-sqlite", - "version": "0.1.0", + "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@crudy/restloom": "^0.1.0" + "@crudy/restloom": "^0.2.0" }, "devDependencies": { "@types/better-sqlite3": "^7.6.0", diff --git a/packages/core/package.json b/packages/core/package.json index 7393832..c001739 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@crudy/restloom", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "description": "A REST API generated from your database's catalog: discovery, a curl-friendly query grammar, relations of every kind, safe defaults", "keywords": ["rest", "api", "postgrest", "sql", "database", "crud", "generator"], diff --git a/packages/core/src/dialect.ts b/packages/core/src/dialect.ts index 761853c..58929cb 100644 --- a/packages/core/src/dialect.ts +++ b/packages/core/src/dialect.ts @@ -23,8 +23,11 @@ export interface Dialect { readonly name: string; readonly capabilities: Capabilities; readonly renderer: SqlRenderer; - /** Reads the tables, views and functions of the given schemas and tables. */ - introspect(scope: { schemas: string[]; tables: string[] }): Promise; + /** + * Reads the tables and views of the given schemas and tables, and the + * functions of the given schemas plus the named ones (schema.name). + */ + introspect(scope: { schemas: string[]; tables: string[]; functions: string[] }): Promise; /** Runs fn in one transaction with a statement timeout; read-only when asked. */ transaction(fn: (tx: Tx) => Promise, options: { timeoutMs: number; readOnly: boolean }): Promise; /** The JSON value of a statement's `r` column as a JavaScript value: drivers that parse JSON return it as is, others parse text. */ diff --git a/packages/core/src/read.ts b/packages/core/src/read.ts index 33404ef..a7e1ae2 100644 --- a/packages/core/src/read.ts +++ b/packages/core/src/read.ts @@ -30,6 +30,8 @@ export async function readRows(db: Dialect, tx: Tx, resource: Resource, request: scope: (r: Resource) => Filter[], id?: string, extra: Filter[] = []): Promise { const plan = parseRead(resource, request.query, limits); plan.where.push(...extra); + const prefer = /\bcount=(exact|estimated)\b/.exec(request.headers.prefer ?? ''); + if (plan.count === 'none' && prefer) plan.count = prefer[1] as 'exact' | 'estimated'; if (id !== undefined) { plan.where.unshift(keyFilter(resource, id)); plan.limit = 1; diff --git a/packages/core/src/rest.ts b/packages/core/src/rest.ts index f493dee..a950c82 100644 --- a/packages/core/src/rest.ts +++ b/packages/core/src/rest.ts @@ -43,7 +43,8 @@ export function createREST(options: RestOptions): Rest { const db = options.db; let loading: Promise | null = null; - const load = () => (loading ??= db.introspect({ schemas: options.expose.schemas ?? [], tables: options.expose.tables ?? [] }) + const functions = Object.values(options.procedures ?? {}).map((p) => p.function).filter((f) => f.includes('.')); + const load = () => (loading ??= db.introspect({ schemas: options.expose.schemas ?? [], tables: options.expose.tables ?? [], functions }) .then((catalog: Catalog) => ({ resources: buildResources(catalog, options as RestOptions), procedures: buildProcedures(catalog, options.procedures ?? {}), diff --git a/packages/core/test/node.test.mjs b/packages/core/test/node.test.mjs index bb35dfc..8de6be3 100644 --- a/packages/core/test/node.test.mjs +++ b/packages/core/test/node.test.mjs @@ -18,3 +18,9 @@ test('the node listener serves under its prefix and refuses bad bodies', async ( server.close(); } }); + +test('Prefer: count=exact counts as count=exact does', async () => { + const rest = createREST({ db: fakeDialect(), expose: { schemas: ['app'] } }); + const res = await rest.handle({ method: 'GET', path: '/items', query: new URLSearchParams(), headers: { prefer: 'count=exact' }, context: undefined }); + assert.equal(res.headers['content-range'], '0-0/1'); +}); diff --git a/packages/express/package.json b/packages/express/package.json index 5911d9f..56da2a5 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -1,6 +1,6 @@ { "name": "@crudy/restloom-express", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "description": "Express adapter for restloom", "keywords": [ @@ -36,7 +36,7 @@ "prepack": "npm run build" }, "dependencies": { - "@crudy/restloom": "^0.1.0" + "@crudy/restloom": "^0.2.0" }, "peerDependencies": { "express": "^5.0.0" diff --git a/packages/fastify/package.json b/packages/fastify/package.json index 5ca9d1f..f00c399 100644 --- a/packages/fastify/package.json +++ b/packages/fastify/package.json @@ -1,6 +1,6 @@ { "name": "@crudy/restloom-fastify", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "description": "Fastify adapter for restloom", "keywords": [ @@ -36,7 +36,7 @@ "prepack": "npm run build" }, "dependencies": { - "@crudy/restloom": "^0.1.0" + "@crudy/restloom": "^0.2.0" }, "peerDependencies": { "fastify": "^5.0.0" diff --git a/packages/mssql/package.json b/packages/mssql/package.json index 36e3bbf..69b62a7 100644 --- a/packages/mssql/package.json +++ b/packages/mssql/package.json @@ -1,6 +1,6 @@ { "name": "@crudy/restloom-mssql", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "description": "SQL Server dialect for restloom", "keywords": [ @@ -37,7 +37,7 @@ "prepack": "npm run build" }, "dependencies": { - "@crudy/restloom": "^0.1.0" + "@crudy/restloom": "^0.2.0" }, "peerDependencies": { "mssql": "^12.0.0" diff --git a/packages/mssql/src/introspect.ts b/packages/mssql/src/introspect.ts index ea71b3f..f74b2fc 100644 --- a/packages/mssql/src/introspect.ts +++ b/packages/mssql/src/introspect.ts @@ -9,6 +9,7 @@ SELECT o.object_id AS id, SCHEMA_NAME(o.schema_id) AS s, o.name AS n, RTRIM(o.ty FROM sys.objects o WHERE o.type IN ('U', 'V', 'FN', 'IF', 'TF') AND (SCHEMA_NAME(o.schema_id) IN (SELECT value FROM STRING_SPLIT(@schemas, ',')) + OR (o.type IN ('FN', 'IF', 'TF') AND SCHEMA_NAME(o.schema_id) + '.' + o.name IN (SELECT value FROM STRING_SPLIT(@functions, ','))) OR (o.type IN ('U', 'V') AND SCHEMA_NAME(o.schema_id) + '.' + o.name IN (SELECT value FROM STRING_SPLIT(@tables, ','))))`; const COLUMNS = ` @@ -40,13 +41,13 @@ SELECT p.object_id AS id, SUBSTRING(p.name, 2, 200) AS name, TYPE_NAME(p.user_ty ORDER BY p.object_id, p.parameter_id`; /** Reads tables, views and functions from the catalog views. T-SQL does not expose parameter defaults, so every argument is required. */ -export async function introspect(pool: ConnectionPool, scope: { schemas: string[]; tables: string[] }): Promise { +export async function introspect(pool: ConnectionPool, scope: { schemas: string[]; tables: string[]; functions: string[] }): Promise { const run = async (sql: string, params: Record) => { const req = pool.request(); for (const [k, v] of Object.entries(params)) req.input(k, v); return (await req.query(sql)).recordset as Row[]; }; - const objects = await run(OBJECTS, { schemas: scope.schemas.join(','), tables: scope.tables.join(',') }); + const objects = await run(OBJECTS, { schemas: scope.schemas.join(','), tables: scope.tables.join(','), functions: scope.functions.join(',') }); const ids = { ids: objects.map((o) => String(o.id)).join(',') }; const [columns, keys, fks, params] = await Promise.all([run(COLUMNS, ids), run(PRIMARY_KEYS, ids), run(FOREIGN_KEYS, ids), run(PARAMETERS, ids)]); const columnsOf = (id: unknown) => columns.filter((c) => c.id === id).map((c) => ({ diff --git a/packages/mysql/package.json b/packages/mysql/package.json index b5ffafc..93f816e 100644 --- a/packages/mysql/package.json +++ b/packages/mysql/package.json @@ -1,6 +1,6 @@ { "name": "@crudy/restloom-mysql", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "description": "MySQL dialect for restloom", "keywords": [ @@ -36,7 +36,7 @@ "prepack": "npm run build" }, "dependencies": { - "@crudy/restloom": "^0.1.0" + "@crudy/restloom": "^0.2.0" }, "peerDependencies": { "mysql2": "^3.11.0" diff --git a/packages/mysql/src/introspect.ts b/packages/mysql/src/introspect.ts index c193fb9..78e28de 100644 --- a/packages/mysql/src/introspect.ts +++ b/packages/mysql/src/introspect.ts @@ -7,9 +7,10 @@ type Row = Record; const IN_SCOPE = '(t.TABLE_SCHEMA IN (?) OR CONCAT(t.TABLE_SCHEMA, \'.\', t.TABLE_NAME) IN (?))'; /** Reads tables, views, columns, keys and functions from information_schema. MySQL's schemas are its databases. */ -export async function introspect(pool: Pool, scope: { schemas: string[]; tables: string[] }): Promise { +export async function introspect(pool: Pool, scope: { schemas: string[]; tables: string[]; functions: string[] }): Promise { const schemas = scope.schemas.length ? scope.schemas : ['']; const tablesIn = scope.tables.length ? scope.tables : ['']; + const functionsIn = scope.functions.length ? scope.functions : ['']; const q = async (sql: string, params: unknown[]) => (await pool.query(sql, params))[0] as Row[]; const [tables, columns, keys, params, functions] = await Promise.all([ q(`SELECT TABLE_SCHEMA AS s, TABLE_NAME AS n, TABLE_TYPE AS k FROM information_schema.TABLES t WHERE ${IN_SCOPE}`, [schemas, tablesIn]), @@ -22,8 +23,8 @@ export async function introspect(pool: Pool, scope: { schemas: string[]; tables: WHERE ${IN_SCOPE} AND (k.CONSTRAINT_NAME = 'PRIMARY' OR k.REFERENCED_TABLE_NAME IS NOT NULL) ORDER BY k.CONSTRAINT_NAME, k.ORDINAL_POSITION`, [schemas, tablesIn]), q(`SELECT SPECIFIC_SCHEMA AS s, SPECIFIC_NAME AS n, PARAMETER_NAME AS name, DATA_TYPE AS dt, DTD_IDENTIFIER AS ct - FROM information_schema.PARAMETERS WHERE SPECIFIC_SCHEMA IN (?) AND ORDINAL_POSITION > 0 ORDER BY ORDINAL_POSITION`, [schemas]), - q(`SELECT ROUTINE_SCHEMA AS s, ROUTINE_NAME AS n FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA IN (?) AND ROUTINE_TYPE = 'FUNCTION'`, [schemas]), + FROM information_schema.PARAMETERS WHERE (SPECIFIC_SCHEMA IN (?) OR CONCAT(SPECIFIC_SCHEMA, '.', SPECIFIC_NAME) IN (?)) AND ORDINAL_POSITION > 0 ORDER BY ORDINAL_POSITION`, [schemas, functionsIn]), + q(`SELECT ROUTINE_SCHEMA AS s, ROUTINE_NAME AS n FROM information_schema.ROUTINES WHERE (ROUTINE_SCHEMA IN (?) OR CONCAT(ROUTINE_SCHEMA, '.', ROUTINE_NAME) IN (?)) AND ROUTINE_TYPE = 'FUNCTION'`, [schemas, functionsIn]), ]); const byName = new Map(); for (const t of tables) { diff --git a/packages/postgres/package.json b/packages/postgres/package.json index 03554a5..f01503b 100644 --- a/packages/postgres/package.json +++ b/packages/postgres/package.json @@ -1,6 +1,6 @@ { "name": "@crudy/restloom-postgres", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "description": "Postgres dialect for restloom", "keywords": ["restloom", "postgres", "postgresql", "rest", "api"], @@ -18,7 +18,7 @@ "prepack": "npm run build" }, "dependencies": { - "@crudy/restloom": "^0.1.0" + "@crudy/restloom": "^0.2.0" }, "peerDependencies": { "pg": "^8.11.0" diff --git a/packages/postgres/src/introspect.ts b/packages/postgres/src/introspect.ts index e5e5732..36db1bf 100644 --- a/packages/postgres/src/introspect.ts +++ b/packages/postgres/src/introspect.ts @@ -45,17 +45,17 @@ SELECT n.nspname AS schema, p.proname AS name, p.proretset AS returns_set, p.pro (SELECT array_agg(b.typname::text || ':' || b.typtype::text || ':' || b.typcategory::text ORDER BY ord) FROM unnest(p.proargtypes) WITH ORDINALITY u(t, ord) JOIN pg_type b ON b.oid = u.t) AS types FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace - WHERE p.prokind = 'f' AND n.nspname = ANY($1::text[])`; + WHERE p.prokind = 'f' AND (n.nspname = ANY($1::text[]) OR n.nspname || '.' || p.proname = ANY($2::text[]))`; type Row = Record; /** Reads the tables, views, keys, foreign keys and functions of the exposed schemas and tables. */ -export async function introspect(pool: Pool, scope: { schemas: string[]; tables: string[] }): Promise { +export async function introspect(pool: Pool, scope: { schemas: string[]; tables: string[]; functions: string[] }): Promise { const { rows: tables } = await pool.query(TABLES, [scope.schemas, scope.tables]); const oids = tables.map((t) => t.oid as number); const [columns, keys, fks, fns] = await Promise.all([ pool.query(COLUMNS, [oids]), pool.query(PRIMARY_KEYS, [oids]), - pool.query(FOREIGN_KEYS, [oids]), pool.query(FUNCTIONS, [scope.schemas]), + pool.query(FOREIGN_KEYS, [oids]), pool.query(FUNCTIONS, [scope.schemas, scope.functions]), ]); const byOid = new Map(); for (const t of tables) { diff --git a/packages/sqlite/package.json b/packages/sqlite/package.json index 5886f0f..44d510b 100644 --- a/packages/sqlite/package.json +++ b/packages/sqlite/package.json @@ -1,6 +1,6 @@ { "name": "@crudy/restloom-sqlite", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "description": "SQLite dialect for restloom", "keywords": [ @@ -36,7 +36,7 @@ "prepack": "npm run build" }, "dependencies": { - "@crudy/restloom": "^0.1.0" + "@crudy/restloom": "^0.2.0" }, "peerDependencies": { "better-sqlite3": ">=12" diff --git a/packages/sqlite/src/introspect.ts b/packages/sqlite/src/introspect.ts index 62a8c0d..c231569 100644 --- a/packages/sqlite/src/introspect.ts +++ b/packages/sqlite/src/introspect.ts @@ -5,7 +5,7 @@ import { logical } from './types.js'; type Row = Record; /** Reads tables and views of the main database or of attached ones; SQLite's schemas are its attached databases. */ -export function introspect(db: Database, scope: { schemas: string[]; tables: string[] }): Catalog { +export function introspect(db: Database, scope: { schemas: string[]; tables: string[]; functions: string[] }): Catalog { const schemas = new Set([...scope.schemas, ...scope.tables.map((t) => t.split('.')[0])]); const attached = new Set((db.prepare('PRAGMA database_list').all() as Row[]).map((r) => r.name as string)); const tables: CatalogTable[] = [];