feat: regular expression and array filters, GET procedures; 0.3.0

This commit is contained in:
2026-11-02 02:09:06 +01:00
parent 8735abfe79
commit 215d1c2f7e
30 changed files with 171 additions and 44 deletions
+7
View File
@@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [Unreleased] ## [Unreleased]
## [0.3.0] - 2026-11-02
### Added
- `match` and `imatch` regular expression filters, on Postgres and MySQL; SQLite and SQL Server answer `unsupported`.
- `ov`, `cs` and `cd` filters on Postgres array columns.
- GET procedures (`method: 'GET'`): arguments from the query string, run read-only.
## [0.2.0] - 2026-11-02 ## [0.2.0] - 2026-11-02
### Added ### Added
+7
View File
@@ -66,3 +66,10 @@ CREATE FUNCTION demo.assets_tagged(tag text) RETURNS SETOF demo.assets
LANGUAGE sql STABLE AS $$ 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.assets a JOIN demo.asset_tags t ON t.asset_id = a.id JOIN demo.tags g ON g.id = t.tag_id
WHERE g.name = tag ORDER BY a.id $$; WHERE g.name = tag ORDER BY a.id $$;
CREATE TABLE demo.notes (
id integer PRIMARY KEY,
labels text[] NOT NULL
);
INSERT INTO demo.notes (id, labels) VALUES (1, '{a,b}'), (2, '{b,c}'), (3, '{z}');
+10
View File
@@ -34,3 +34,13 @@ for (const page of PAGES) {
} }
}); });
} }
describe('mysql regular expressions', { skip: !url && 'RESTLOOM_MYSQL_URL is not set' }, () => {
test('imatch ignores case, match does not', async () => {
await admin.query(fixture);
const rest = createREST({ db: mysql(pool), expose: { schemas: ['demo'] } });
const get = async (qs) => (await rest.handle({ method: 'GET', path: '/users', query: new URLSearchParams(qs), headers: {}, context: undefined })).body;
assert.deepEqual(await get('select=id&name=imatch.^(ada|LINUS)$'), [{ id: 1 }, { id: 3 }]);
assert.deepEqual(await get('select=id&name=match.^(ada|LINUS)$'), []);
});
});
+29
View File
@@ -39,3 +39,32 @@ describe('postgres procedures outside the exposed schemas', { skip: !url && 'RES
assert.equal(res.body, 3); assert.equal(res.body, 3);
}); });
}); });
describe('postgres arrays, regular expressions and GET procedures', { skip: !url && 'RESTLOOM_PG_URL is not set' }, () => {
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' } } });
});
const get = (path, qs = '') => rest.handle({ method: 'GET', path, query: new URLSearchParams(qs), headers: {}, context: undefined });
test('array overlap, contains and contained by', async () => {
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');
});
test('regular expressions, with and without case', async () => {
assert.deepEqual((await get('/users', 'select=id&name=imatch.^(ada|LINUS)$')).body, [{ id: 1 }, { id: 3 }]);
assert.deepEqual((await get('/users', 'select=id&name=match.^(ada|LINUS)$')).body, []);
});
test('a GET procedure takes its arguments from the query and refuses POST', async () => {
const rows = (await get('/tags/gdpr/assets', '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.equal(post.status, 405);
});
});
+11
View File
@@ -26,3 +26,14 @@ for (const page of PAGES) {
} }
}); });
} }
describe('sqlite refuses what it cannot do', () => {
test('a regular expression answers unsupported', async () => {
const db = new Database(':memory:');
db.exec("ATTACH ':memory:' AS demo");
db.exec(fixture);
const rest = createREST({ db: sqlite(db), expose: { schemas: ['demo'] } });
const res = await rest.handle({ method: 'GET', path: '/users', query: new URLSearchParams('name=imatch.a'), headers: {}, context: undefined });
assert.deepEqual(res.body, { error: 'unsupported', message: 'regular expressions' });
});
});
+1 -1
View File
@@ -9,7 +9,7 @@ A dialect is the package that knows one database: how to read its catalog, how t
| `@crudy/restloom-mssql` | SQL Server 2022+ | `JSON_OBJECT`, `STRING_AGG` | `OUTPUT` | no | security policies | scalar and table functions | | `@crudy/restloom-mssql` | SQL Server 2022+ | `JSON_OBJECT`, `STRING_AGG` | `OUTPUT` | no | security policies | scalar and table functions |
| `@crudy/restloom-sqlite` | SQLite 3.44+ (better-sqlite3) | `json_object`, `json_group_array` | `RETURNING` | no | no | none | | `@crudy/restloom-sqlite` | SQLite 3.44+ (better-sqlite3) | `json_object`, `json_group_array` | `RETURNING` | no | no | none |
Every dialect passes the same conformance suite, so a request answers the same rows on each. Where they differ, the sections below say how. Every dialect passes the same conformance suite, so a request answers the same rows on each. Regular expressions (`match`, `imatch`) need Postgres or MySQL, and array filters (`ov`, `cs`, `cd`) Postgres array columns; elsewhere they answer `unsupported`. Where else the dialects differ, the sections below say how.
## Postgres ## Postgres
+3 -1
View File
@@ -16,7 +16,7 @@ A message never contains SQL or database text.
| `method_not_allowed` | 405 | the resource does not allow this method, or a procedure was not called with POST | | `method_not_allowed` | 405 | the resource does not allow this method, or a procedure was not called with POST |
| `unknown_column` | 400 | the column does not exist or is hidden | | `unknown_column` | 400 | the column does not exist or is hidden |
| `unknown_relation` | 400 | the relation does not exist | | `unknown_relation` | 400 | the relation does not exist |
| `unknown_operator` | 400 | the operator is not one of `eq neq gt gte lt lte in like ilike is` | | `unknown_operator` | 400 | the operator is not one of `eq neq gt gte lt lte in like ilike match imatch is ov cs cd` |
| `bad_operator` | 400 | the operator does not apply to the column's type | | `bad_operator` | 400 | the operator does not apply to the column's type |
| `invalid_value` | 400 | the value does not fit the column's type | | `invalid_value` | 400 | the value does not fit the column's type |
| `bad_filter` | 400 | a filter is not `operator.value` | | `bad_filter` | 400 | a filter is not `operator.value` |
@@ -37,6 +37,7 @@ A message never contains SQL or database text.
| `bad_limit`, `bad_offset` | 400 | not a whole number | | `bad_limit`, `bad_offset` | 400 | not a whole number |
| `limit_exceeded` | 400 | a limit over `limits.maxPage` | | `limit_exceeded` | 400 | a limit over `limits.maxPage` |
| `bad_count` | 400 | `count` is not `exact` or `estimated` | | `bad_count` | 400 | `count` is not `exact` or `estimated` |
| `unsupported` | 400 | the database cannot do what was asked, e.g. a regular expression on SQLite |
## Writes and procedures ## Writes and procedures
@@ -50,6 +51,7 @@ A message never contains SQL or database text.
| `out_of_scope` | 403 | a value contradicts the host's scope | | `out_of_scope` | 403 | a value contradicts the host's scope |
| `unknown_argument` | 400 | the procedure's function takes no argument of that name | | `unknown_argument` | 400 | the procedure's function takes no argument of that name |
| `missing_argument` | 400 | an argument without a default is missing | | `missing_argument` | 400 | an argument without a default is missing |
| `bad_argument` | 400 | a GET procedure's argument is given twice |
## The database ## The database
+2
View File
@@ -92,6 +92,8 @@ A filter is `column=operator.value`. Every filter must hold.
| `gt`, `gte`, `lt`, `lte` | greater or less than | text, numbers, dates and times | | `gt`, `gte`, `lt`, `lte` | greater or less than | text, numbers, dates and times |
| `in.(a,b,c)` | one of the list; quote an item holding a comma: `in.("a,b",c)` | any comparable column | | `in.(a,b,c)` | one of the list; quote an item holding a comma: `in.("a,b",c)` | any comparable column |
| `like`, `ilike` | pattern, `*` for any text; `ilike` ignores case | text | | `like`, `ilike` | pattern, `*` for any text; `ilike` ignores case | text |
| `match`, `imatch` | regular expression; `imatch` ignores case | text, where the database has them (Postgres, MySQL) |
| `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 | | `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.(retired,lost)`.
+3 -1
View File
@@ -160,7 +160,9 @@ POST /incidents
## Procedures ## Procedures
A procedure calls a database function at a path you choose. A `{name}` in the path passes that segment as the argument of the same name; the body's keys pass the others. Arguments are checked by name and type, and one with a default may be left out. A procedure is always a POST. A procedure calls a database function at a path you choose. A `{name}` in the path passes that segment as the argument of the same name; the body's keys pass the others. Arguments are checked by name and type, and one with a default may be left out.
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 ```http
POST /assets/3/retire POST /assets/3/retire
+2
View File
@@ -40,6 +40,8 @@ The core decides everything else from the logical types: which operators a colum
| `nested(expr)` | a nested JSON subquery, unchanged | | `nested(expr)` | a nested JSON subquery, unchanged |
| `like(expr, pattern, insensitive)` | `LIKE` or `ILIKE ... ESCAPE '\'` | | `like(expr, pattern, insensitive)` | `LIKE` or `ILIKE ... ESCAPE '\'` |
| `isBoolean(expr, value)` | `IS TRUE`, `IS FALSE` | | `isBoolean(expr, value)` | `IS TRUE`, `IS FALSE` |
| `regex(expr, pattern, insensitive)` | `~`, `~*`; throw `RestError(400, 'unsupported')` without regular expressions |
| `arrayCompare(expr, op, array)` | `&&`, `@>`, `<@` |
| `orderBy(expr, desc, nulls)` | `ASC`/`DESC NULLS FIRST`/`LAST` | | `orderBy(expr, desc, nulls)` | `ASC`/`DESC NULLS FIRST`/`LAST` |
| `page(limit, offset)` | `LIMIT n OFFSET m` | | `page(limit, offset)` | `LIMIT n OFFSET m` |
| `first(select)` | `... LIMIT 1` | | `first(select)` | `... LIMIT 1` |
+13 -13
View File
@@ -3314,7 +3314,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@crudy/restloom", "name": "@crudy/restloom",
"version": "0.2.0", "version": "0.3.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
@@ -3326,10 +3326,10 @@
}, },
"packages/express": { "packages/express": {
"name": "@crudy/restloom-express", "name": "@crudy/restloom-express",
"version": "0.2.0", "version": "0.3.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@crudy/restloom": "^0.2.0" "@crudy/restloom": "^0.3.0"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
@@ -3346,10 +3346,10 @@
}, },
"packages/fastify": { "packages/fastify": {
"name": "@crudy/restloom-fastify", "name": "@crudy/restloom-fastify",
"version": "0.2.0", "version": "0.3.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@crudy/restloom": "^0.2.0" "@crudy/restloom": "^0.3.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
@@ -3365,10 +3365,10 @@
}, },
"packages/mssql": { "packages/mssql": {
"name": "@crudy/restloom-mssql", "name": "@crudy/restloom-mssql",
"version": "0.2.0", "version": "0.3.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@crudy/restloom": "^0.2.0" "@crudy/restloom": "^0.3.0"
}, },
"devDependencies": { "devDependencies": {
"@types/mssql": "^12.0.0", "@types/mssql": "^12.0.0",
@@ -3385,10 +3385,10 @@
}, },
"packages/mysql": { "packages/mysql": {
"name": "@crudy/restloom-mysql", "name": "@crudy/restloom-mysql",
"version": "0.2.0", "version": "0.3.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@crudy/restloom": "^0.2.0" "@crudy/restloom": "^0.3.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
@@ -3404,10 +3404,10 @@
}, },
"packages/postgres": { "packages/postgres": {
"name": "@crudy/restloom-postgres", "name": "@crudy/restloom-postgres",
"version": "0.2.0", "version": "0.3.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@crudy/restloom": "^0.2.0" "@crudy/restloom": "^0.3.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
@@ -3424,10 +3424,10 @@
}, },
"packages/sqlite": { "packages/sqlite": {
"name": "@crudy/restloom-sqlite", "name": "@crudy/restloom-sqlite",
"version": "0.2.0", "version": "0.3.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@crudy/restloom": "^0.2.0" "@crudy/restloom": "^0.3.0"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.0", "@types/better-sqlite3": "^7.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@crudy/restloom", "name": "@crudy/restloom",
"version": "0.2.0", "version": "0.3.0",
"type": "module", "type": "module",
"description": "A REST API generated from your database's catalog: discovery, a curl-friendly query grammar, relations of every kind, safe defaults", "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"], "keywords": ["rest", "api", "postgrest", "sql", "database", "crud", "generator"],
+4
View File
@@ -37,6 +37,10 @@ function condition(resource: Resource, segs: Segment[], expr: string, limits: Li
const items = listItems(raw); const items = listItems(raw);
if (items.length === 0 || items.length > limits.inList) throw badRequest('bad_list', col.name); if (items.length === 0 || items.length > limits.inList) throw badRequest('bad_list', col.name);
value = items.map((i) => typed(i, col.type, col.name)); value = items.map((i) => typed(i, col.type, col.name));
} else if (op === 'ov' || op === 'cs' || op === 'cd') {
const items = listItems(raw, '{', '}');
if (items.length > limits.inList) throw badRequest('bad_list', col.name);
value = items;
} else if (op === 'is') { } else if (op === 'is') {
value = raw === 'null' ? null : raw === 'true'; value = raw === 'null' ? null : raw === 'true';
} else if (op === 'like' || op === 'ilike') { } else if (op === 'like' || op === 'ilike') {
+5 -5
View File
@@ -10,8 +10,8 @@ export function splitTopLevel(input: string): string[] {
const c = input[i]; const c = input[i];
if (c === '\\' && quoted) { i++; continue; } if (c === '\\' && quoted) { i++; continue; }
if (c === '"') quoted = !quoted; if (c === '"') quoted = !quoted;
else if (!quoted && c === '(') depth++; else if (!quoted && (c === '(' || c === '{')) depth++;
else if (!quoted && c === ')') depth--; else if (!quoted && (c === ')' || c === '}')) depth--;
else if (!quoted && depth === 0 && c === ',') { out.push(input.slice(start, i)); start = i + 1; } else if (!quoted && depth === 0 && c === ',') { out.push(input.slice(start, i)); start = i + 1; }
if (depth < 0) throw badRequest('bad_syntax', input); if (depth < 0) throw badRequest('bad_syntax', input);
} }
@@ -27,9 +27,9 @@ export function unquote(item: string): string {
return t; return t;
} }
/** `(a,b,"c,d")` into its items. */ /** `(a,b,"c,d")`, or `{a,b}` for an array, into its items. */
export function listItems(value: string): string[] { export function listItems(value: string, open = '(', close = ')'): string[] {
if (!value.startsWith('(') || !value.endsWith(')')) throw badRequest('bad_list', value); if (!value.startsWith(open) || !value.endsWith(close)) throw badRequest('bad_list', value);
const inner = value.slice(1, -1); const inner = value.slice(1, -1);
return inner.trim() === '' ? [] : splitTopLevel(inner).map(unquote); return inner.trim() === '' ? [] : splitTopLevel(inner).map(unquote);
} }
+2 -1
View File
@@ -8,7 +8,8 @@ const ORDERED: ReadonlySet<LogicalType> = new Set(['text', 'integer', 'number',
/** Whether an operator applies to a column of this type, decided before any SQL. */ /** Whether an operator applies to a column of this type, decided before any SQL. */
export function operatorFits(op: Operator, type: LogicalType, value: string): boolean { export function operatorFits(op: Operator, type: LogicalType, value: string): boolean {
if (op === 'is') return value === 'null' || (type === 'boolean' && (value === 'true' || value === 'false')); if (op === 'is') return value === 'null' || (type === 'boolean' && (value === 'true' || value === 'false'));
if (op === 'like' || op === 'ilike') return type === 'text'; if (op === 'like' || op === 'ilike' || op === 'match' || op === 'imatch') return type === 'text';
if (op === 'ov' || op === 'cs' || op === 'cd') return type === 'array';
if (op === 'gt' || op === 'gte' || op === 'lt' || op === 'lte') return ORDERED.has(type); if (op === 'gt' || op === 'gte' || op === 'lt' || op === 'lte') return ORDERED.has(type);
return COMPARABLE.has(type); return COMPARABLE.has(type);
} }
+6
View File
@@ -52,6 +52,12 @@ export interface ResourceOptions {
export interface ProcedureOptions { export interface ProcedureOptions {
/** Schema-qualified function name. */ /** Schema-qualified function name. */
function: string; function: string;
/**
* POST (default) takes arguments from the body. GET takes them from the
* query string and runs read-only: use it for a function without side
* effects, such as a search.
*/
method?: 'GET' | 'POST';
} }
export interface RestRequest<C = unknown> { export interface RestRequest<C = unknown> {
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Relation, Resource } from './resources.js'; import type { Relation, Resource } from './resources.js';
export const OPERATORS = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'like', 'ilike', 'in', 'is'] as const; export const OPERATORS = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'like', 'ilike', 'match', 'imatch', 'in', 'is', 'ov', 'cs', 'cd'] as const;
export type Operator = typeof OPERATORS[number]; export type Operator = typeof OPERATORS[number];
export const AGGREGATES = ['count', 'sum', 'avg', 'min', 'max'] as const; export const AGGREGATES = ['count', 'sum', 'avg', 'min', 'max'] as const;
+20 -2
View File
@@ -5,6 +5,7 @@ import type { ProcedureOptions } from './options.js';
export interface Procedure { export interface Procedure {
template: string; template: string;
method: 'GET' | 'POST';
pattern: RegExp; pattern: RegExp;
params: string[]; params: string[];
fn: CatalogFunction; fn: CatalogFunction;
@@ -24,7 +25,7 @@ export function buildProcedures(catalog: Catalog, declared: Record<string, Proce
params.push(p[1]); params.push(p[1]);
return '([^/]+)'; return '([^/]+)';
}).join('/'); }).join('/');
return { template, pattern: new RegExp(`^/${source}/?$`), params, fn: matches[0] }; return { template, method: o.method ?? 'POST', pattern: new RegExp(`^/${source}/?$`), params, fn: matches[0] };
}); });
} }
@@ -41,7 +42,24 @@ export function matchProcedure(list: Procedure[], path: string): { procedure: Pr
return null; return null;
} }
/** The call's arguments from the path and the body, by name, each checked against its type. */ /** A query-string argument as the body would give it: JSON for structured types, the text otherwise. */
function fromQuery(raw: string, type: CatalogFunction['args'][number]['type']): unknown {
if (type !== 'json' && type !== 'array') return raw;
try { return JSON.parse(raw); } catch { throw badRequest('invalid_value', raw); }
}
/** The arguments of a GET procedure: each query parameter by name, once. */
export function queryArgs(fn: CatalogFunction, query: URLSearchParams): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [name, raw] of query) {
if (name in out) throw badRequest('bad_argument', name);
const arg = fn.args.find((a) => a.name === name);
out[name] = arg ? fromQuery(raw, arg.type) : raw;
}
return out;
}
/** The call's arguments from the path and the body (or query), by name, each checked against its type. */
export function procedureArgs(fn: CatalogFunction, fromPath: Record<string, string>, body: unknown): [string, unknown][] { export function procedureArgs(fn: CatalogFunction, fromPath: Record<string, string>, body: unknown): [string, unknown][] {
if (body !== undefined && (body === null || typeof body !== 'object' || Array.isArray(body))) throw badRequest('bad_body', 'arguments are an object'); if (body !== undefined && (body === null || typeof body !== 'object' || Array.isArray(body))) throw badRequest('bad_body', 'arguments are an object');
const given: Record<string, unknown> = { ...(body as Record<string, unknown> | undefined), ...fromPath }; const given: Record<string, unknown> = { ...(body as Record<string, unknown> | undefined), ...fromPath };
+5 -4
View File
@@ -1,7 +1,7 @@
import type { Catalog } from './catalog.js'; import type { Catalog } from './catalog.js';
import { RestError, notFound } from './errors.js'; import { RestError, notFound } from './errors.js';
import { DEFAULT_LIMITS, type Limits, type RestOptions, type RestRequest, type RestResponse } from './options.js'; import { DEFAULT_LIMITS, type Limits, type RestOptions, type RestRequest, type RestResponse } from './options.js';
import { buildProcedures, matchProcedure, procedureArgs, type Procedure } from './procedures.js'; import { buildProcedures, matchProcedure, procedureArgs, queryArgs, type Procedure } from './procedures.js';
import { readRows } from './read.js'; import { readRows } from './read.js';
import { buildResources, type Resource } from './resources.js'; import { buildResources, type Resource } from './resources.js';
import { scopeFilters } from './scope.js'; import { scopeFilters } from './scope.js';
@@ -63,10 +63,11 @@ export function createREST<C = unknown>(options: RestOptions<C>): Rest<C> {
const scope = scopeFilters(options, request.context); const scope = scopeFilters(options, request.context);
const call = matchProcedure(api.procedures, request.path); const call = matchProcedure(api.procedures, request.path);
if (call) { if (call) {
if (request.method !== 'POST') throw new RestError(405, 'method_not_allowed'); const { method, fn } = call.procedure;
if (request.method !== method) throw new RestError(405, 'method_not_allowed');
await options.hooks?.authorize?.(request, { procedure: call.procedure.template, action: 'call' }); await options.hooks?.authorize?.(request, { procedure: call.procedure.template, action: 'call' });
const args = procedureArgs(call.procedure.fn, call.values, request.body); const args = procedureArgs(fn, call.values, method === 'GET' ? queryArgs(fn, request.query) : request.body);
return inTransaction(request, false, async (tx) => { return inTransaction(request, method === 'GET', async (tx) => {
const stmt = db.renderer.call(call.procedure.fn, args, db.renderer.context(scope)); const stmt = db.renderer.call(call.procedure.fn, args, db.renderer.context(scope));
const rows = (await tx.query(stmt.sql, stmt.params)).map((row) => db.decode(row.r)); const rows = (await tx.query(stmt.sql, stmt.params)).map((row) => db.decode(row.r));
return { status: 200, headers: {}, body: call.procedure.fn.returnsSet ? rows : rows[0] ?? null }; return { status: 200, headers: {}, body: call.procedure.fn.returnsSet ? rows : rows[0] ?? null };
+10
View File
@@ -50,6 +50,16 @@ export class SqlPrimitives {
return `${expr} ${insensitive ? 'ILIKE' : 'LIKE'} ${pattern} ESCAPE '\\'`; return `${expr} ${insensitive ? 'ILIKE' : 'LIKE'} ${pattern} ESCAPE '\\'`;
} }
/** A POSIX regular expression match; a dialect without one refuses with `unsupported`. */
regex(expr: string, pattern: string, insensitive: boolean): string {
return `${expr} ${insensitive ? '~*' : '~'} ${pattern}`;
}
/** Array overlap (ov), contains (cs) or contained by (cd); only Postgres has array columns. */
arrayCompare(expr: string, op: 'ov' | 'cs' | 'cd', array: string): string {
return `${expr} ${op === 'ov' ? '&&' : op === 'cs' ? '@>' : '<@'} ${array}`;
}
isBoolean(expr: string, value: boolean): string { isBoolean(expr: string, value: boolean): string {
return `${expr} IS ${value ? 'TRUE' : 'FALSE'}`; return `${expr} IS ${value ? 'TRUE' : 'FALSE'}`;
} }
+3
View File
@@ -46,6 +46,9 @@ export class SqlReader extends SqlPrimitives {
switch (f.op) { switch (f.op) {
case 'in': return `${expr} IN (${(f.value as Value[]).map(bind).join(', ')})`; case 'in': return `${expr} IN (${(f.value as Value[]).map(bind).join(', ')})`;
case 'is': return f.value === null ? `${expr} IS NULL` : this.isBoolean(expr, f.value as boolean); case 'is': return f.value === null ? `${expr} IS NULL` : this.isBoolean(expr, f.value as boolean);
case 'ov': case 'cs': case 'cd': return this.arrayCompare(expr, f.op, ctx.params.add(f.value));
case 'match': return this.regex(expr, bind(f.value as Value), false);
case 'imatch': return this.regex(expr, bind(f.value as Value), true);
case 'like': return this.like(expr, bind(f.value as Value), false); case 'like': return this.like(expr, bind(f.value as Value), false);
case 'ilike': return this.like(expr, bind(f.value as Value), true); case 'ilike': return this.like(expr, bind(f.value as Value), true);
default: return `${expr} ${CMP[f.op]} ${bind(f.value as Value)}`; default: return `${expr} ${CMP[f.op]} ${bind(f.value as Value)}`;
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@crudy/restloom-express", "name": "@crudy/restloom-express",
"version": "0.2.0", "version": "0.3.0",
"type": "module", "type": "module",
"description": "Express adapter for restloom", "description": "Express adapter for restloom",
"keywords": [ "keywords": [
@@ -36,7 +36,7 @@
"prepack": "npm run build" "prepack": "npm run build"
}, },
"dependencies": { "dependencies": {
"@crudy/restloom": "^0.2.0" "@crudy/restloom": "^0.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"express": "^5.0.0" "express": "^5.0.0"
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@crudy/restloom-fastify", "name": "@crudy/restloom-fastify",
"version": "0.2.0", "version": "0.3.0",
"type": "module", "type": "module",
"description": "Fastify adapter for restloom", "description": "Fastify adapter for restloom",
"keywords": [ "keywords": [
@@ -36,7 +36,7 @@
"prepack": "npm run build" "prepack": "npm run build"
}, },
"dependencies": { "dependencies": {
"@crudy/restloom": "^0.2.0" "@crudy/restloom": "^0.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"fastify": "^5.0.0" "fastify": "^5.0.0"
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@crudy/restloom-mssql", "name": "@crudy/restloom-mssql",
"version": "0.2.0", "version": "0.3.0",
"type": "module", "type": "module",
"description": "SQL Server dialect for restloom", "description": "SQL Server dialect for restloom",
"keywords": [ "keywords": [
@@ -37,7 +37,7 @@
"prepack": "npm run build" "prepack": "npm run build"
}, },
"dependencies": { "dependencies": {
"@crudy/restloom": "^0.2.0" "@crudy/restloom": "^0.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"mssql": "^12.0.0" "mssql": "^12.0.0"
+5 -1
View File
@@ -1,4 +1,4 @@
import { SqlRenderer, type CatalogColumn, type CatalogFunction, type Filter, type OrderTerm, type RenderContext, type Resource, type Statement } from '@crudy/restloom'; import { RestError, SqlRenderer, type CatalogColumn, type CatalogFunction, type Filter, type OrderTerm, type RenderContext, type Resource, type Statement } from '@crudy/restloom';
/** /**
* SQL Server 2022: bracketed names, @p placeholders, JSON_OBJECT for rows and * SQL Server 2022: bracketed names, @p placeholders, JSON_OBJECT for rows and
@@ -44,6 +44,10 @@ export class MssqlRenderer extends SqlRenderer {
: `${expr} COLLATE Latin1_General_100_CS_AS LIKE ${pattern} ESCAPE '\\'`; : `${expr} COLLATE Latin1_General_100_CS_AS LIKE ${pattern} ESCAPE '\\'`;
} }
regex(): string {
throw new RestError(400, 'unsupported', 'regular expressions');
}
isBoolean(expr: string, value: boolean): string { isBoolean(expr: string, value: boolean): string {
return `${expr} = ${value ? 1 : 0}`; return `${expr} = ${value ? 1 : 0}`;
} }
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@crudy/restloom-mysql", "name": "@crudy/restloom-mysql",
"version": "0.2.0", "version": "0.3.0",
"type": "module", "type": "module",
"description": "MySQL dialect for restloom", "description": "MySQL dialect for restloom",
"keywords": [ "keywords": [
@@ -36,7 +36,7 @@
"prepack": "npm run build" "prepack": "npm run build"
}, },
"dependencies": { "dependencies": {
"@crudy/restloom": "^0.2.0" "@crudy/restloom": "^0.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"mysql2": "^3.11.0" "mysql2": "^3.11.0"
+4
View File
@@ -41,6 +41,10 @@ export class MysqlRenderer extends SqlRenderer {
: `${expr} COLLATE utf8mb4_bin LIKE ${pattern} ESCAPE '\\\\'`; : `${expr} COLLATE utf8mb4_bin LIKE ${pattern} ESCAPE '\\\\'`;
} }
regex(expr: string, pattern: string, insensitive: boolean): string {
return `REGEXP_LIKE(${expr}, ${pattern}, '${insensitive ? 'i' : 'c'}')`;
}
isBoolean(expr: string, value: boolean): string { isBoolean(expr: string, value: boolean): string {
return `${expr} = ${value ? 1 : 0}`; return `${expr} = ${value ? 1 : 0}`;
} }
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@crudy/restloom-postgres", "name": "@crudy/restloom-postgres",
"version": "0.2.0", "version": "0.3.0",
"type": "module", "type": "module",
"description": "Postgres dialect for restloom", "description": "Postgres dialect for restloom",
"keywords": ["restloom", "postgres", "postgresql", "rest", "api"], "keywords": ["restloom", "postgres", "postgresql", "rest", "api"],
@@ -18,7 +18,7 @@
"prepack": "npm run build" "prepack": "npm run build"
}, },
"dependencies": { "dependencies": {
"@crudy/restloom": "^0.2.0" "@crudy/restloom": "^0.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"pg": "^8.11.0" "pg": "^8.11.0"
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@crudy/restloom-sqlite", "name": "@crudy/restloom-sqlite",
"version": "0.2.0", "version": "0.3.0",
"type": "module", "type": "module",
"description": "SQLite dialect for restloom", "description": "SQLite dialect for restloom",
"keywords": [ "keywords": [
@@ -36,7 +36,7 @@
"prepack": "npm run build" "prepack": "npm run build"
}, },
"dependencies": { "dependencies": {
"@crudy/restloom": "^0.2.0" "@crudy/restloom": "^0.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"better-sqlite3": ">=12" "better-sqlite3": ">=12"
+5 -1
View File
@@ -1,4 +1,4 @@
import { SqlRenderer, type CatalogColumn } from '@crudy/restloom'; import { RestError, SqlRenderer, type CatalogColumn } from '@crudy/restloom';
/** SQLite: JSON through json_object and json_group_array, booleans as 0 and 1, positional placeholders. */ /** SQLite: JSON through json_object and json_group_array, booleans as 0 and 1, positional placeholders. */
export class SqliteRenderer extends SqlRenderer { export class SqliteRenderer extends SqlRenderer {
@@ -28,6 +28,10 @@ export class SqliteRenderer extends SqlRenderer {
return insensitive ? `lower(${expr}) LIKE lower(${pattern}) ESCAPE '\\'` : `${expr} LIKE ${pattern} ESCAPE '\\'`; return insensitive ? `lower(${expr}) LIKE lower(${pattern}) ESCAPE '\\'` : `${expr} LIKE ${pattern} ESCAPE '\\'`;
} }
regex(): string {
throw new RestError(400, 'unsupported', 'regular expressions');
}
isBoolean(expr: string, value: boolean): string { isBoolean(expr: string, value: boolean): string {
return `${expr} = ${value ? 1 : 0}`; return `${expr} = ${value ? 1 : 0}`;
} }