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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@crudy/restloom",
"version": "0.2.0",
"version": "0.3.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"],
+4
View File
@@ -37,6 +37,10 @@ function condition(resource: Resource, segs: Segment[], expr: string, limits: Li
const items = listItems(raw);
if (items.length === 0 || items.length > limits.inList) throw badRequest('bad_list', 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') {
value = raw === 'null' ? null : raw === 'true';
} else if (op === 'like' || op === 'ilike') {
+5 -5
View File
@@ -10,8 +10,8 @@ export function splitTopLevel(input: string): string[] {
const c = input[i];
if (c === '\\' && quoted) { i++; continue; }
if (c === '"') quoted = !quoted;
else if (!quoted && c === '(') depth++;
else if (!quoted && c === ')') depth--;
else if (!quoted && (c === '(' || c === '{')) depth++;
else if (!quoted && (c === ')' || c === '}')) depth--;
else if (!quoted && depth === 0 && c === ',') { out.push(input.slice(start, i)); start = i + 1; }
if (depth < 0) throw badRequest('bad_syntax', input);
}
@@ -27,9 +27,9 @@ export function unquote(item: string): string {
return t;
}
/** `(a,b,"c,d")` into its items. */
export function listItems(value: string): string[] {
if (!value.startsWith('(') || !value.endsWith(')')) throw badRequest('bad_list', value);
/** `(a,b,"c,d")`, or `{a,b}` for an array, into its items. */
export function listItems(value: string, open = '(', close = ')'): string[] {
if (!value.startsWith(open) || !value.endsWith(close)) throw badRequest('bad_list', value);
const inner = value.slice(1, -1);
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. */
export function operatorFits(op: Operator, type: LogicalType, value: string): boolean {
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);
return COMPARABLE.has(type);
}
+6
View File
@@ -52,6 +52,12 @@ export interface ResourceOptions {
export interface ProcedureOptions {
/** Schema-qualified function name. */
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> {
+1 -1
View File
@@ -1,6 +1,6 @@
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 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 {
template: string;
method: 'GET' | 'POST';
pattern: RegExp;
params: string[];
fn: CatalogFunction;
@@ -24,7 +25,7 @@ export function buildProcedures(catalog: Catalog, declared: Record<string, Proce
params.push(p[1]);
return '([^/]+)';
}).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;
}
/** 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][] {
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 };
+5 -4
View File
@@ -1,7 +1,7 @@
import type { Catalog } from './catalog.js';
import { RestError, notFound } from './errors.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 { buildResources, type Resource } from './resources.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 call = matchProcedure(api.procedures, request.path);
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' });
const args = procedureArgs(call.procedure.fn, call.values, request.body);
return inTransaction(request, false, async (tx) => {
const args = procedureArgs(fn, call.values, method === 'GET' ? queryArgs(fn, request.query) : request.body);
return inTransaction(request, method === 'GET', async (tx) => {
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));
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 '\\'`;
}
/** 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 {
return `${expr} IS ${value ? 'TRUE' : 'FALSE'}`;
}
+3
View File
@@ -46,6 +46,9 @@ export class SqlReader extends SqlPrimitives {
switch (f.op) {
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 '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 'ilike': return this.like(expr, bind(f.value as Value), true);
default: return `${expr} ${CMP[f.op]} ${bind(f.value as Value)}`;