feat: regular expression and array filters, GET procedures; 0.3.0
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}');
|
||||
|
||||
@@ -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)$'), []);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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)`.
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
Generated
+13
-13
@@ -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",
|
||||
|
||||
@@ -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