feat: regular expression and array filters, GET procedures; 0.3.0
This commit is contained in:
@@ -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"],
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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'}`;
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@crudy/restloom-express",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"description": "Express adapter for restloom",
|
||||
"keywords": [
|
||||
@@ -36,7 +36,7 @@
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@crudy/restloom": "^0.2.0"
|
||||
"@crudy/restloom": "^0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": "^5.0.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@crudy/restloom-fastify",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"description": "Fastify adapter for restloom",
|
||||
"keywords": [
|
||||
@@ -36,7 +36,7 @@
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@crudy/restloom": "^0.2.0"
|
||||
"@crudy/restloom": "^0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"fastify": "^5.0.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@crudy/restloom-mssql",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"description": "SQL Server dialect for restloom",
|
||||
"keywords": [
|
||||
@@ -37,7 +37,7 @@
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@crudy/restloom": "^0.2.0"
|
||||
"@crudy/restloom": "^0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"mssql": "^12.0.0"
|
||||
|
||||
@@ -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
|
||||
@@ -44,6 +44,10 @@ export class MssqlRenderer extends SqlRenderer {
|
||||
: `${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 {
|
||||
return `${expr} = ${value ? 1 : 0}`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@crudy/restloom-mysql",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"description": "MySQL dialect for restloom",
|
||||
"keywords": [
|
||||
@@ -36,7 +36,7 @@
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@crudy/restloom": "^0.2.0"
|
||||
"@crudy/restloom": "^0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"mysql2": "^3.11.0"
|
||||
|
||||
@@ -41,6 +41,10 @@ export class MysqlRenderer extends SqlRenderer {
|
||||
: `${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 {
|
||||
return `${expr} = ${value ? 1 : 0}`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@crudy/restloom-postgres",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.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.2.0"
|
||||
"@crudy/restloom": "^0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg": "^8.11.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@crudy/restloom-sqlite",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"description": "SQLite dialect for restloom",
|
||||
"keywords": [
|
||||
@@ -36,7 +36,7 @@
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@crudy/restloom": "^0.2.0"
|
||||
"@crudy/restloom": "^0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"better-sqlite3": ">=12"
|
||||
|
||||
@@ -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. */
|
||||
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 '\\'`;
|
||||
}
|
||||
|
||||
regex(): string {
|
||||
throw new RestError(400, 'unsupported', 'regular expressions');
|
||||
}
|
||||
|
||||
isBoolean(expr: string, value: boolean): string {
|
||||
return `${expr} = ${value ? 1 : 0}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user