From 215d1c2f7ef7430b1895534c2ddf453bc7a8c055 Mon Sep 17 00:00:00 2001 From: Marc Villain Date: Mon, 2 Nov 2026 02:09:06 +0100 Subject: [PATCH] feat: regular expression and array filters, GET procedures; 0.3.0 --- CHANGELOG.md | 7 +++++++ conformance/fixtures/postgres.sql | 7 +++++++ conformance/mysql.test.mjs | 10 ++++++++++ conformance/postgres.test.mjs | 29 +++++++++++++++++++++++++++++ conformance/sqlite.test.mjs | 11 +++++++++++ docs/dialects.md | 2 +- docs/errors.md | 4 +++- docs/query-grammar.md | 2 ++ docs/writes.md | 4 +++- docs/writing-a-dialect.md | 2 ++ package-lock.json | 26 +++++++++++++------------- packages/core/package.json | 2 +- packages/core/src/grammar/filter.ts | 4 ++++ packages/core/src/grammar/split.ts | 10 +++++----- packages/core/src/grammar/values.ts | 3 ++- packages/core/src/options.ts | 6 ++++++ packages/core/src/plan.ts | 2 +- packages/core/src/procedures.ts | 22 ++++++++++++++++++++-- packages/core/src/rest.ts | 9 +++++---- packages/core/src/sql/primitives.ts | 10 ++++++++++ packages/core/src/sql/reader.ts | 3 +++ packages/express/package.json | 4 ++-- packages/fastify/package.json | 4 ++-- packages/mssql/package.json | 4 ++-- packages/mssql/src/renderer.ts | 6 +++++- packages/mysql/package.json | 4 ++-- packages/mysql/src/renderer.ts | 4 ++++ packages/postgres/package.json | 4 ++-- packages/sqlite/package.json | 4 ++-- packages/sqlite/src/renderer.ts | 6 +++++- 30 files changed, 171 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 684c1ff..2d22876 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [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 ### Added diff --git a/conformance/fixtures/postgres.sql b/conformance/fixtures/postgres.sql index a6407bf..bcfdc3d 100644 --- a/conformance/fixtures/postgres.sql +++ b/conformance/fixtures/postgres.sql @@ -66,3 +66,10 @@ CREATE FUNCTION demo.assets_tagged(tag text) RETURNS SETOF demo.assets 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 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}'); diff --git a/conformance/mysql.test.mjs b/conformance/mysql.test.mjs index fc7ec2d..4ab31da 100644 --- a/conformance/mysql.test.mjs +++ b/conformance/mysql.test.mjs @@ -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)$'), []); + }); +}); diff --git a/conformance/postgres.test.mjs b/conformance/postgres.test.mjs index 34d58d9..4f0d53f 100644 --- a/conformance/postgres.test.mjs +++ b/conformance/postgres.test.mjs @@ -39,3 +39,32 @@ describe('postgres procedures outside the exposed schemas', { skip: !url && 'RES 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); + }); +}); diff --git a/conformance/sqlite.test.mjs b/conformance/sqlite.test.mjs index 3b02596..2523331 100644 --- a/conformance/sqlite.test.mjs +++ b/conformance/sqlite.test.mjs @@ -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' }); + }); +}); diff --git a/docs/dialects.md b/docs/dialects.md index 7915a71..a7c5e2e 100644 --- a/docs/dialects.md +++ b/docs/dialects.md @@ -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-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 diff --git a/docs/errors.md b/docs/errors.md index d390b52..7771b1b 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -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 | | `unknown_column` | 400 | the column does not exist or is hidden | | `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 | | `invalid_value` | 400 | the value does not fit the column's type | | `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 | | `limit_exceeded` | 400 | a limit over `limits.maxPage` | | `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 @@ -50,6 +51,7 @@ A message never contains SQL or database text. | `out_of_scope` | 403 | a value contradicts the host's scope | | `unknown_argument` | 400 | the procedure's function takes no argument of that name | | `missing_argument` | 400 | an argument without a default is missing | +| `bad_argument` | 400 | a GET procedure's argument is given twice | ## The database diff --git a/docs/query-grammar.md b/docs/query-grammar.md index dc85f71..29861f4 100644 --- a/docs/query-grammar.md +++ b/docs/query-grammar.md @@ -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 | | `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 | +| `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 | `not.` before the operator negates it: `status=not.in.(retired,lost)`. diff --git a/docs/writes.md b/docs/writes.md index 788e8d9..22224d4 100644 --- a/docs/writes.md +++ b/docs/writes.md @@ -160,7 +160,9 @@ POST /incidents ## 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 POST /assets/3/retire diff --git a/docs/writing-a-dialect.md b/docs/writing-a-dialect.md index f004b90..ab9acaf 100644 --- a/docs/writing-a-dialect.md +++ b/docs/writing-a-dialect.md @@ -40,6 +40,8 @@ The core decides everything else from the logical types: which operators a colum | `nested(expr)` | a nested JSON subquery, unchanged | | `like(expr, pattern, insensitive)` | `LIKE` or `ILIKE ... ESCAPE '\'` | | `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` | | `page(limit, offset)` | `LIMIT n OFFSET m` | | `first(select)` | `... LIMIT 1` | diff --git a/package-lock.json b/package-lock.json index e1690f7..ff68ada 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3314,7 +3314,7 @@ }, "packages/core": { "name": "@crudy/restloom", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", "devDependencies": { "@types/node": "^24.0.0", @@ -3326,10 +3326,10 @@ }, "packages/express": { "name": "@crudy/restloom-express", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@crudy/restloom": "^0.2.0" + "@crudy/restloom": "^0.3.0" }, "devDependencies": { "@types/express": "^5.0.0", @@ -3346,10 +3346,10 @@ }, "packages/fastify": { "name": "@crudy/restloom-fastify", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@crudy/restloom": "^0.2.0" + "@crudy/restloom": "^0.3.0" }, "devDependencies": { "@types/node": "^24.0.0", @@ -3365,10 +3365,10 @@ }, "packages/mssql": { "name": "@crudy/restloom-mssql", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@crudy/restloom": "^0.2.0" + "@crudy/restloom": "^0.3.0" }, "devDependencies": { "@types/mssql": "^12.0.0", @@ -3385,10 +3385,10 @@ }, "packages/mysql": { "name": "@crudy/restloom-mysql", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@crudy/restloom": "^0.2.0" + "@crudy/restloom": "^0.3.0" }, "devDependencies": { "@types/node": "^24.0.0", @@ -3404,10 +3404,10 @@ }, "packages/postgres": { "name": "@crudy/restloom-postgres", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@crudy/restloom": "^0.2.0" + "@crudy/restloom": "^0.3.0" }, "devDependencies": { "@types/node": "^24.0.0", @@ -3424,10 +3424,10 @@ }, "packages/sqlite": { "name": "@crudy/restloom-sqlite", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@crudy/restloom": "^0.2.0" + "@crudy/restloom": "^0.3.0" }, "devDependencies": { "@types/better-sqlite3": "^7.6.0", diff --git a/packages/core/package.json b/packages/core/package.json index c001739..41d90b6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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"], diff --git a/packages/core/src/grammar/filter.ts b/packages/core/src/grammar/filter.ts index 1b45743..b68b64e 100644 --- a/packages/core/src/grammar/filter.ts +++ b/packages/core/src/grammar/filter.ts @@ -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') { diff --git a/packages/core/src/grammar/split.ts b/packages/core/src/grammar/split.ts index 0cf34db..24bd50f 100644 --- a/packages/core/src/grammar/split.ts +++ b/packages/core/src/grammar/split.ts @@ -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); } diff --git a/packages/core/src/grammar/values.ts b/packages/core/src/grammar/values.ts index d65f791..813f30f 100644 --- a/packages/core/src/grammar/values.ts +++ b/packages/core/src/grammar/values.ts @@ -8,7 +8,8 @@ const ORDERED: ReadonlySet = 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); } diff --git a/packages/core/src/options.ts b/packages/core/src/options.ts index 68005ed..f9af656 100644 --- a/packages/core/src/options.ts +++ b/packages/core/src/options.ts @@ -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 { diff --git a/packages/core/src/plan.ts b/packages/core/src/plan.ts index d038f2e..3922f5f 100644 --- a/packages/core/src/plan.ts +++ b/packages/core/src/plan.ts @@ -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; diff --git a/packages/core/src/procedures.ts b/packages/core/src/procedures.ts index 942636e..4f83294 100644 --- a/packages/core/src/procedures.ts +++ b/packages/core/src/procedures.ts @@ -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 { + const out: Record = {}; + 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, 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 = { ...(body as Record | undefined), ...fromPath }; diff --git a/packages/core/src/rest.ts b/packages/core/src/rest.ts index a950c82..8fbae79 100644 --- a/packages/core/src/rest.ts +++ b/packages/core/src/rest.ts @@ -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(options: RestOptions): Rest { 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 }; diff --git a/packages/core/src/sql/primitives.ts b/packages/core/src/sql/primitives.ts index 9e19ca1..46c35f1 100644 --- a/packages/core/src/sql/primitives.ts +++ b/packages/core/src/sql/primitives.ts @@ -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'}`; } diff --git a/packages/core/src/sql/reader.ts b/packages/core/src/sql/reader.ts index ac668f4..c1e00f4 100644 --- a/packages/core/src/sql/reader.ts +++ b/packages/core/src/sql/reader.ts @@ -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)}`; diff --git a/packages/express/package.json b/packages/express/package.json index 56da2a5..2c5d2e9 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -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" diff --git a/packages/fastify/package.json b/packages/fastify/package.json index f00c399..88ce1a2 100644 --- a/packages/fastify/package.json +++ b/packages/fastify/package.json @@ -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" diff --git a/packages/mssql/package.json b/packages/mssql/package.json index 69b62a7..6d65b98 100644 --- a/packages/mssql/package.json +++ b/packages/mssql/package.json @@ -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" diff --git a/packages/mssql/src/renderer.ts b/packages/mssql/src/renderer.ts index a0ef242..cad30de 100644 --- a/packages/mssql/src/renderer.ts +++ b/packages/mssql/src/renderer.ts @@ -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}`; } diff --git a/packages/mysql/package.json b/packages/mysql/package.json index 93f816e..f8d0bad 100644 --- a/packages/mysql/package.json +++ b/packages/mysql/package.json @@ -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" diff --git a/packages/mysql/src/renderer.ts b/packages/mysql/src/renderer.ts index ac35366..9335c51 100644 --- a/packages/mysql/src/renderer.ts +++ b/packages/mysql/src/renderer.ts @@ -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}`; } diff --git a/packages/postgres/package.json b/packages/postgres/package.json index f01503b..54f3a2f 100644 --- a/packages/postgres/package.json +++ b/packages/postgres/package.json @@ -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" diff --git a/packages/sqlite/package.json b/packages/sqlite/package.json index 44d510b..120e8d3 100644 --- a/packages/sqlite/package.json +++ b/packages/sqlite/package.json @@ -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" diff --git a/packages/sqlite/src/renderer.ts b/packages/sqlite/src/renderer.ts index f52112f..2d917a3 100644 --- a/packages/sqlite/src/renderer.ts +++ b/packages/sqlite/src/renderer.ts @@ -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}`; }