diff --git a/.gitignore b/.gitignore index db6fa17..dcb6a0c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ downloaded-modules node_modules npm-debug.log +dist diff --git a/package.json b/package.json index 836cd26..e10c101 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,10 @@ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" + }, + "./postgrest": { + "types": "./dist/dialects/postgrest.d.ts", + "default": "./dist/dialects/postgrest.js" } }, "files": [ @@ -15,7 +19,7 @@ ], "scripts": { "build": "tsc", - "test": "node --experimental-strip-types --test test/v2/parse.test.ts", + "test": "npm run build && node --experimental-strip-types --test test/v2/parse.test.ts test/dialects/postgrest.test.ts test/dialects/postgrest-dist.test.mjs", "typecheck": "tsc --noEmit" }, "devDependencies": { diff --git a/specification/rql-2.0.md b/specification/rql-2.0.md index 4f96d25..fe52995 100644 --- a/specification/rql-2.0.md +++ b/specification/rql-2.0.md @@ -647,6 +647,12 @@ Honest gaps, recorded rather than mapped away: - **Projection aliasing and casting** — `select=alias:column`, `select=column::text`. RQL's `Projection` has no rename or cast; a future revision could add an optional `as`/`cast` to `Field`. +- **Operator configuration arguments** — `phfts(english).The%20Fat%20Cats` carries a + full-text-search configuration, but `Condition` has no operator-argument slot. A + dialect front-end can preserve it in the opaque extension comparator name + (`phfts(english)`); that spelling falls outside §4's `fiql-name` grammar and does + not round-trip through §7 serialization. A future revision could model arguments + separately. - **Null ordering** — `order=age.nullsfirst`. `SortKey` (§6) has no nulls placement; reserved for a future revision. - **Aggregates in projections** — `select=amount.sum()`; RQL keeps aggregation in the diff --git a/src/dialects/postgrest.ts b/src/dialects/postgrest.ts new file mode 100644 index 0000000..393a510 --- /dev/null +++ b/src/dialects/postgrest.ts @@ -0,0 +1,533 @@ +import { QueryError, SyntaxViolation } from '../errors.ts'; +import { negateGroup, negateTerm } from '../parser.ts'; +import type { + Condition, ElementMatch, Field, Group, ParseOptions, ParseResult, Projection, SortKey, Value, +} from '../types.ts'; + +type Term = Condition | Group | ElementMatch; + +interface URLSearchParams { + entries(): IterableIterator<[string, string]>; +} + +type URLSearchParamsConstructor = new (input?: string) => URLSearchParams; + +export interface PostgRESTOptions extends ParseOptions { + onUnsupported?: 'throw' | 'drop'; +} + +export class UnsupportedFeature extends QueryError {} + +const MAX_LOGIC_DEPTH = 32; +const MAX_TERMS = 1_000; +const MAX_LIST_VALUES = 1_000; +const MAX_SEARCH_LENGTH = 65_536; + +const OPERATOR_NAMES = new Set([ + 'eq', 'gt', 'gte', 'lt', 'lte', 'neq', 'in', 'cs', 'cd', 'ov', 'is', + 'like', 'ilike', 'match', 'imatch', 'fts', 'plfts', 'phfts', 'wfts', + 'sl', 'sr', 'nxl', 'nxr', 'adj', 'isdistinct', +]); + +const CONFIGURABLE_OPERATORS = new Set(['fts', 'plfts', 'phfts', 'wfts']); +const MODIFIER_OPERATORS = new Set([ + 'eq', 'gt', 'gte', 'lt', 'lte', 'neq', + 'like', 'ilike', 'match', 'imatch', 'fts', 'plfts', 'phfts', 'wfts', + 'sl', 'sr', 'nxl', 'nxr', 'adj', 'isdistinct', +]); + +const FILTER_PATTERN = /^(not\.)?([a-z][a-z0-9_]*)(?:\(([^()]*)\))?\.([\s\S]*)$/; +const LOGIC_OPERATOR_PATTERN = /^(?:not\.)?([a-z][a-z0-9_]*)(?:\([^()]*\))?\./; +const LOGIC_CALL_PATTERN = /^(not\.)?(and|or)\(/; +const NULL_ORDER_PATTERN = /(?:^|\.)(?:nullsfirst|nullslast)$/; +const NON_NEGATIVE_INTEGER_PATTERN = /^(?:0|[1-9][0-9]*)$/; +const CONFIGURATION_ARGUMENT_PATTERN = /^[a-z_][a-z0-9_$]*$/i; +const JSON_OBJECT_OPERAND_PATTERN = /^\{\s*"/; +const AGGREGATE_PROJECTION_PATTERN = /(?:^|[.:])(?:sum|avg|count|min|max)\(\)(?=$|::)/; + +const URL_SEARCH_PARAMS = (globalThis as unknown as { + URLSearchParams?: URLSearchParamsConstructor; +}).URLSearchParams; + +type ParseBudget = { terms: number }; + +type ParsedOperator = { + operator: string; + argument?: string; + negated: boolean; + operand: string; +}; + +// ── Entry point ──────────────────────────────────────────────────────────── + +/** + * Appendix E.4: `neq` uses RQL complement semantics, so absent properties differ from SQL `<>`. + */ +export function parsePostgREST( + search: string | URLSearchParams, options?: PostgRESTOptions, +): ParseResult { + const result: ParseResult = {}; + try { + let parameters: URLSearchParams; + if (typeof search === 'string') { + if (search.length > MAX_SEARCH_LENGTH) + syntaxViolation(`query exceeds the ${MAX_SEARCH_LENGTH}-character limit`); + if (!URL_SEARCH_PARAMS) throw new QueryError('URLSearchParams is unavailable in this runtime'); + parameters = new URL_SEARCH_PARAMS(search.startsWith('?') ? search.slice(1) : search); + } else { + parameters = search; + } + parseInto(result, parameters, options); + } catch (error) { + if (!(error instanceof QueryError)) throw error; + if (!options?.deferErrors) throw error; + return { parseError: error }; + } + return result; +} + +function syntaxViolation(message: string): never { + throw new SyntaxViolation(`Unable to parse PostgREST query: ${message}`); +} + +// ── Query-parameter dispatch ─────────────────────────────────────────────── + +function parseInto( + result: ParseResult, parameters: URLSearchParams, options: PostgRESTOptions | undefined, +): void { + const terms: Term[] = []; + const budget: ParseBudget = { terms: 0 }; + const seenReserved = new Set(); + let decodedLength = 0; + + for (const [key, value] of parameters.entries()) { + decodedLength += key.length + value.length + 2; + if (decodedLength > MAX_SEARCH_LENGTH) + syntaxViolation(`query exceeds the ${MAX_SEARCH_LENGTH}-character limit`); + if (key === 'select' || key === 'order' || key === 'limit' || key === 'offset') { + if (seenReserved.has(key)) syntaxViolation(`duplicate '${key}' parameter`); + seenReserved.add(key); + if (key === 'select') { + const select = parseSelect(value, options, budget); + if (select) result.select = select; + } + else if (key === 'order') result.sort = parseOrder(value, options, budget); + else result[key] = parseNonNegativeInteger(value, key); + } else if (key === 'or' || key === 'and' || key === 'not.or' || key === 'not.and') { + const operator = key.endsWith('or') ? 'or' : 'and'; + const group = parseLogicGroup(unwrapLogicBody(value), operator, 1, budget); + terms.push(key.startsWith('not.') ? negateGroup(group) : group); + } else { + terms.push(parseFilterValue(splitColumnPath(key), value, budget)); + } + } + + const filter = filterFromTerms(terms); + if (filter) result.filter = filter; +} + +function filterFromTerms(terms: Term[]): Group | undefined { + if (terms.length === 0) return undefined; + if (terms.length === 1 && 'terms' in terms[0]) return terms[0]; + return { operator: 'and', terms }; +} + +// ── select, order, limit, offset ─────────────────────────────────────────── + +function parseSelect( + raw: string, options: PostgRESTOptions | undefined, budget: ParseBudget, +): Projection | undefined { + const fields: Field[] = []; + let dropped = false; + let wildcard = false; + for (const rawField of splitTopLevel(raw)) { + const field = rawField.trim(); + if (!field) syntaxViolation('select contains an empty field'); + if (field === '*') { wildcard = true; continue; } + let feature: string | undefined; + if (AGGREGATE_PROJECTION_PATTERN.test(field)) { + throw new UnsupportedFeature(`PostgREST feature 'projection aggregate (${field})' is unsupported`); + } + if (includesUnquoted(field, '::')) feature = `projection cast '${field}'`; + else if (includesUnquoted(field, ':')) feature = `projection alias '${field}'`; + else if (includesUnquoted(field, '(') || includesUnquoted(field, ')') || includesUnquoted(field, '!')) { + throw new UnsupportedFeature(`PostgREST feature 'resource embedding (${field})' is unsupported`); + } + if (feature) { + if (unsupported(feature, options)) { dropped = true; continue; } + } + useTerms(budget, 1); + fields.push({ path: splitColumnPath(field) }); + } + if (wildcard) return undefined; + if (fields.length === 0 && dropped) + throw new UnsupportedFeature('PostgREST cannot drop every selected field'); + if (fields.length === 0) syntaxViolation('select cannot be empty'); + return { mode: 'records', fields }; +} + +function parseOrder( + raw: string, options: PostgRESTOptions | undefined, budget: ParseBudget, +): SortKey[] { + const keys: SortKey[] = []; + for (const rawKey of splitTopLevel(raw)) { + let key = rawKey.trim(); + if (!key) syntaxViolation('order contains an empty key'); + if (includesUnquoted(key, '(') || includesUnquoted(key, ')')) + throw new UnsupportedFeature(`PostgREST feature 'related ordering (${key})' is unsupported`); + if (NULL_ORDER_PATTERN.test(key)) { + if (unsupported(`null ordering '${key}'`, options)) { + key = key.replace(NULL_ORDER_PATTERN, ''); + } + } + let direction: 'asc' | 'desc' = 'asc'; + if (key.endsWith('.asc')) key = key.slice(0, -4); + else if (key.endsWith('.desc')) { key = key.slice(0, -5); direction = 'desc'; } + useTerms(budget, 1); + keys.push({ path: splitColumnPath(key), direction }); + } + if (keys.length === 0) syntaxViolation('order cannot be empty'); + return keys; +} + +function parseNonNegativeInteger(raw: string, name: string): number { + if (!NON_NEGATIVE_INTEGER_PATTERN.test(raw)) syntaxViolation(`${name} must be a non-negative integer`); + const value = Number(raw); + if (!Number.isSafeInteger(value)) syntaxViolation(`${name} exceeds the safe integer range`); + return value; +} + +function unsupported(feature: string, options: PostgRESTOptions | undefined): boolean { + if (options?.onUnsupported === 'drop') return true; + throw new UnsupportedFeature(`PostgREST feature '${feature}' is unsupported`); +} + +// ── Logic groups ─────────────────────────────────────────────────────────── + +function unwrapLogicBody(raw: string): string { + if (raw.length < 2 || raw[0] !== '(' || raw[raw.length - 1] !== ')') + syntaxViolation('logic parameter requires a parenthesized body'); + return raw.slice(1, -1); +} + +function parseLogicGroup( + body: string, operator: 'and' | 'or', depth: number, budget: ParseBudget, +): Group { + if (depth > MAX_LOGIC_DEPTH) syntaxViolation(`logic exceeds the depth limit of ${MAX_LOGIC_DEPTH}`); + if (!body) syntaxViolation('logic group cannot be empty'); + const parts = splitTopLevel(body); + if (parts.some((part) => part.trim() === '')) syntaxViolation('logic group contains an empty term'); + return { operator, terms: parts.map((part) => parseLogicTerm(part, depth, budget)) }; +} + +function parseLogicTerm(raw: string, depth: number, budget: ParseBudget): Term { + const value = raw.trimStart(); + const call = LOGIC_CALL_PATTERN.exec(value); + if (!call) return parseLogicLeaf(value, budget); + if (!value.endsWith(')')) syntaxViolation('unbalanced logic group'); + const group = parseLogicGroup( + value.slice(call[0].length, -1), call[2] as 'and' | 'or', depth + 1, budget, + ); + return call[1] ? negateGroup(group) : group; +} + +function parseLogicLeaf(raw: string, budget: ParseBudget): Term { + const candidateIndexes: number[] = []; + const stack: string[] = []; + let quoted = false; + let escaped = false; + for (let index = 0; index < raw.length; index++) { + const character = raw[index]; + if (quoted) { + if (escaped) escaped = false; + else if (character === '\\') escaped = true; + else if (character === '"') quoted = false; + continue; + } + if (character === '"') { + quoted = true; + } else if (character === '(' || character === '[' || character === '{') { + stack.push(character); + } else if (character === ')' || character === ']' || character === '}') { + const open = stack.pop(); + if (!open || !matchingClose(open, character)) syntaxViolation('unbalanced operand delimiter'); + } else if (character === '.' && index > 0 && stack.length === 0) { + candidateIndexes.push(index); + } + } + if (quoted) syntaxViolation('unterminated quoted column path'); + if (stack.length > 0) syntaxViolation('unbalanced operand delimiter'); + for (let candidate = 0; candidate < candidateIndexes.length; candidate++) { + const index = candidateIndexes[candidate]; + const expression = raw.slice(index + 1); + const operatorMatch = LOGIC_OPERATOR_PATTERN.exec(expression); + if (operatorMatch && OPERATOR_NAMES.has(operatorMatch[1])) { + return parseFilterValue(splitColumnPath(raw.slice(0, index)), expression, budget); + } + } + syntaxViolation('logic leaf must have the form column.[not.]operator.operand'); +} + +// ── Filter terms ─────────────────────────────────────────────────────────── + +function parseFilterValue(path: string[], expression: string, budget: ParseBudget): Term { + const parsed = parseOperator(expression); + let comparator: string; + let intrinsicallyNegated = false; + + switch (parsed.operator) { + case 'eq': comparator = 'eq'; break; + case 'gt': comparator = 'gt'; break; + case 'gte': comparator = 'ge'; break; + case 'lt': comparator = 'lt'; break; + case 'lte': comparator = 'le'; break; + case 'neq': comparator = 'eq'; intrinsicallyNegated = true; break; + case 'in': comparator = 'in'; break; + case 'ov': comparator = 'in'; break; + case 'is': comparator = 'eq'; break; + default: comparator = parsed.argument && parsed.argument !== 'any' && parsed.argument !== 'all' + ? `${parsed.operator}(${parsed.argument})` + : parsed.operator; + } + + let term: Term; + if (parsed.operator === 'is') { + if (parsed.operand !== 'null' && parsed.operand !== 'true' && parsed.operand !== 'false') + throw new UnsupportedFeature(`PostgREST feature 'is.${parsed.operand}' is unsupported`); + term = condition(path, comparator, interpretDecodedValue(parsed.operand), false, budget); + } else if (parsed.operator === 'in') { + term = condition(path, comparator, parseList(parsed.operand, '('), false, budget); + } else if (parsed.operator === 'ov') { + term = parsed.operand.startsWith('{') + ? condition(path, comparator, parseContainmentValues('ov', parsed.operand), false, budget) + : condition(path, 'ov', parseOperand(parsed.operand), false, budget); + } else if (parsed.operator === 'cs') { + const values = parseContainmentValues('cs', parsed.operand); + if (values.length === 0) + throw new UnsupportedFeature('PostgREST cs.{} has no canonical always-true filter representation'); + term = { + operator: 'and', + terms: values.map((value) => condition(path, 'eq', value, false, budget)), + }; + } else if (parsed.operator === 'cd') { + const values = parseContainmentValues('cd', parsed.operand); + const inner = condition([], 'in', values, true, budget); + term = negateTerm({ path, some: { operator: 'and', terms: [inner] } }); + } else if (parsed.argument === 'any' || parsed.argument === 'all') { + const values = parseModifierValues(parsed.operand); + if (parsed.operator === 'eq' && parsed.argument === 'any') { + term = condition(path, 'in', values, false, budget); + } else { + if (values.length === 0) + throw new UnsupportedFeature( + `PostgREST ${parsed.operator}(${parsed.argument}) empty list has no canonical filter representation`, + ); + term = { + operator: parsed.argument === 'any' ? 'or' : 'and', + terms: values.map((value) => condition(path, comparator, value, intrinsicallyNegated, budget)), + }; + intrinsicallyNegated = false; + } + } else { + term = condition(path, comparator, parseOperand(parsed.operand), intrinsicallyNegated, budget); + intrinsicallyNegated = false; + } + + if (intrinsicallyNegated) term = negateTerm(term); + if (parsed.negated) term = negateTerm(term); + return term; +} + +function parseOperator(expression: string): ParsedOperator { + const match = FILTER_PATTERN.exec(expression); + if (!match) syntaxViolation('filter must have the form [not.]operator.operand'); + const [, notPrefix, operator, argument, operand] = match; + if (!OPERATOR_NAMES.has(operator)) syntaxViolation(`unknown PostgREST operator '${operator}'`); + if (argument !== undefined && argument !== 'any' && argument !== 'all' && !CONFIGURABLE_OPERATORS.has(operator)) + syntaxViolation(`operator '${operator}' does not accept configuration arguments`); + if ((argument === 'any' || argument === 'all') && !MODIFIER_OPERATORS.has(operator)) + syntaxViolation(`operator '${operator}' does not accept the '${argument}' modifier`); + if (argument !== undefined && argument !== 'any' && argument !== 'all' + && !CONFIGURATION_ARGUMENT_PATTERN.test(argument)) + syntaxViolation(`operator '${operator}' has an invalid configuration argument`); + return { operator, argument, negated: notPrefix !== undefined, operand }; +} + +function condition( + path: string[], comparator: string, value: Value, negated: boolean, budget: ParseBudget, +): Condition { + useTerms(budget, 1); + const result: Condition = { path, comparator, value }; + if (negated) result.negated = true; + return result; +} + +// ── Operands and value lists ─────────────────────────────────────────────── + +function parseList(raw: string, open: '(' | '{'): Value[] { + const close = open === '(' ? ')' : '}'; + if (raw[0] !== open || raw[raw.length - 1] !== close) + syntaxViolation(`operator requires a ${open}${close} value list`); + const inner = raw.slice(1, -1); + if (inner === '') return []; + const parts = splitTopLevel(inner, MAX_LIST_VALUES); + return parts.map(parseOperand); +} + +function parseModifierValues(raw: string): Value[] { + const open = raw[0]; + if (open !== '{' && open !== '(') syntaxViolation('any/all modifier requires a value list'); + const close = open === '{' ? '}' : ')'; + if (!raw.endsWith(close)) syntaxViolation(`any/all ${open}${close} value list is not closed`); + const inner = raw.slice(1, -1); + if (['{', '}', '(', ')'].some((token) => includesUnquoted(inner, token))) + throw new UnsupportedFeature(`PostgREST any/all operand '${raw}' cannot be represented`); + return parseList(raw, open); +} + +function parseContainmentValues(operator: string, raw: string): Value[] { + if (!raw.startsWith('{')) + throw new UnsupportedFeature(`PostgREST ${operator} operand '${raw}' cannot be represented`); + if (!raw.endsWith('}')) syntaxViolation(`${operator} array operand is not closed`); + const inner = raw.slice(1, -1); + if ((JSON_OBJECT_OPERAND_PATTERN.test(raw) && includesUnquoted(inner, ':')) + || ['{', '}', '(', ')'].some((token) => includesUnquoted(inner, token))) + throw new UnsupportedFeature(`PostgREST ${operator} operand '${raw}' cannot be represented`); + return parseList(raw, '{'); +} + +function parseOperand(raw: string): Value { + if (raw.startsWith('"')) return decodeQuoted(raw); + return interpretDecodedValue(raw); +} + +function decodeQuoted(raw: string): string { + if (raw.length < 2 || raw[0] !== '"' || raw[raw.length - 1] !== '"') + syntaxViolation('malformed quoted operand'); + let value = ''; + for (let index = 1; index < raw.length - 1; index++) { + const character = raw[index]; + if (character === '\\') { + index++; + if (index >= raw.length - 1) syntaxViolation('malformed quoted operand escape'); + value += raw[index]; + } else if (character === '"') { + syntaxViolation('unescaped quote inside quoted operand'); + } else { + value += character; + } + } + return value; +} + +function interpretDecodedValue(token: string): Value { + if (token === 'null') return null; + if (token === 'true') return true; + if (token === 'false') return false; + const number = +token; + if (token !== '' && Number.isFinite(number) && String(number) === token) return number; + return token; +} + +// ── Lexical and budget helpers ───────────────────────────────────────────── + +function splitColumnPath(raw: string): string[] { + if (!raw) syntaxViolation('column path is empty'); + const segments: string[] = []; + let quoted = false; + let escaped = false; + let start = 0; + for (let index = 0; index < raw.length; index++) { + const character = raw[index]; + if (quoted) { + if (escaped) escaped = false; + else if (character === '\\') escaped = true; + else if (character === '"') quoted = false; + continue; + } + if (character === '"') { + quoted = true; + continue; + } + let delimiterLength = 0; + if (character === '.') delimiterLength = 1; + else if (raw.startsWith('->>', index)) delimiterLength = 3; + else if (raw.startsWith('->', index)) delimiterLength = 2; + if (!delimiterLength) continue; + const segment = raw.slice(start, index); + if (!segment) syntaxViolation('column path contains an empty segment'); + segments.push(segment.startsWith('"') ? decodeQuoted(segment) : segment); + index += delimiterLength - 1; + start = index + 1; + } + if (quoted) syntaxViolation('unterminated quoted column path'); + const finalSegment = raw.slice(start); + if (!finalSegment) syntaxViolation('column path contains an empty segment'); + segments.push(finalSegment.startsWith('"') ? decodeQuoted(finalSegment) : finalSegment); + return segments; +} + +function splitTopLevel(input: string, maxParts = MAX_TERMS): string[] { + const parts: string[] = []; + const stack: string[] = []; + let quoted = false; + let escaped = false; + let start = 0; + + for (let index = 0; index < input.length; index++) { + const character = input[index]; + if (quoted) { + if (escaped) escaped = false; + else if (character === '\\') escaped = true; + else if (character === '"') quoted = false; + continue; + } + if (character === '"') { + quoted = true; + } else if (character === '(' || character === '[' || character === '{') { + stack.push(character); + } else if (character === ')' || character === ']' || character === '}') { + const open = stack.pop(); + if (!open || !matchingClose(open, character)) syntaxViolation('unbalanced operand delimiter'); + } else if (character === ',' && stack.length === 0) { + if (parts.length >= maxParts) syntaxViolation(`list exceeds the ${maxParts}-item limit`); + parts.push(input.slice(start, index)); + start = index + 1; + } + } + + if (quoted) syntaxViolation('unterminated quoted operand'); + if (stack.length > 0) syntaxViolation('unbalanced operand delimiter'); + if (parts.length >= maxParts) syntaxViolation(`list exceeds the ${maxParts}-item limit`); + parts.push(input.slice(start)); + return parts; +} + +function includesUnquoted(input: string, token: string): boolean { + let quoted = false; + let escaped = false; + for (let index = 0; index < input.length; index++) { + const character = input[index]; + if (quoted) { + if (escaped) escaped = false; + else if (character === '\\') escaped = true; + else if (character === '"') quoted = false; + } else if (character === '"') { + quoted = true; + } else if (input.startsWith(token, index)) { + return true; + } + } + return false; +} + +function matchingClose(open: string, close: string): boolean { + if (open === '{') return close === '}'; + if (open === '[') return close === ']' || close === ')'; + return close === ')' || close === ']'; +} + +function useTerms(budget: ParseBudget, count: number): void { + budget.terms += count; + if (budget.terms > MAX_TERMS) syntaxViolation(`query exceeds the ${MAX_TERMS}-term limit`); +} diff --git a/src/parser.ts b/src/parser.ts index b40677b..f5876b6 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -120,7 +120,7 @@ function accToGroup(acc: Acc): Group | undefined { } // §5.4 De Morgan desugaring for not(...). Recursively toggles negated flags inward. -function negateTerm(term: Term): Term { +export function negateTerm(term: Term): Term { if ('terms' in term) return negateGroup(term as Group); if ('some' in term) { const em = term as ElementMatch; @@ -134,7 +134,7 @@ function negateTerm(term: Term): Term { return r; } -function negateGroup(grp: Group): Term { +export function negateGroup(grp: Group): Term { // Single-term group: collapse to the negated leaf directly. if (grp.terms.length === 1) return negateTerm(grp.terms[0]); const op: 'and' | 'or' = grp.operator === 'and' ? 'or' : 'and'; diff --git a/test/dialects/postgrest-dist.test.mjs b/test/dialects/postgrest-dist.test.mjs new file mode 100644 index 0000000..92e6c5a --- /dev/null +++ b/test/dialects/postgrest-dist.test.mjs @@ -0,0 +1,26 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import * as dialect from 'rql/postgrest'; +import * as root from 'rql'; + +describe('built PostgREST package surface', () => { + it('exports the dialect parser and error from the built subpath', () => { + assert.equal(typeof dialect.parsePostgREST, 'function'); + assert.ok(dialect.UnsupportedFeature.prototype instanceof root.QueryError); + assert.deepEqual(dialect.parsePostgREST('or=(a.eq.1,b.eq.2)&limit=5'), { + filter: { + operator: 'or', + terms: [ + { path: ['a'], comparator: 'eq', value: 1 }, + { path: ['b'], comparator: 'eq', value: 2 }, + ], + }, + limit: 5, + }); + }); + + it('does not export the dialect from the built Core entry point', () => { + assert.equal('parsePostgREST' in root, false); + assert.equal('UnsupportedFeature' in root, false); + }); +}); diff --git a/test/dialects/postgrest.test.ts b/test/dialects/postgrest.test.ts new file mode 100644 index 0000000..b159c2f --- /dev/null +++ b/test/dialects/postgrest.test.ts @@ -0,0 +1,676 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseQuery, QueryError } from '../../src/index.ts'; +import { parsePostgREST, UnsupportedFeature } from '../../src/dialects/postgrest.ts'; +import type { + Condition, ElementMatch, Group, ParseResult, Projection, SortKey, Value, +} from '../../src/index.ts'; + +type Term = Condition | Group | ElementMatch; + +function cond(path: string[], comparator: string, value: Value, negated = false): Condition { + const result: Condition = { path, comparator, value }; + if (negated) result.negated = true; + return result; +} + +function group(operator: 'and' | 'or', ...terms: Term[]): Group { + return { operator, terms }; +} + +function filtered(...terms: Term[]): ParseResult { + return { filter: group('and', ...terms) }; +} + +function grouped(operator: 'and' | 'or', ...terms: Term[]): ParseResult { + return { filter: group(operator, ...terms) }; +} + +function projection(...paths: string[][]): Projection { + return { mode: 'records', fields: paths.map((path) => ({ path })) }; +} + +function sort(path: string[], direction: 'asc' | 'desc' = 'asc'): SortKey { + return { path, direction }; +} + +const vectors: { name: string; search: string; expected: ParseResult }[] = [ + { + name: 'E.2 eq maps to canonical eq', search: 'age=eq.11', + expected: filtered(cond(['age'], 'eq', 11)), + }, + { + name: 'E.2 gt passes through', search: 'age=gt.11', + expected: filtered(cond(['age'], 'gt', 11)), + }, + { + name: 'E.2 gte maps to ge', search: 'age=gte.11', + expected: filtered(cond(['age'], 'ge', 11)), + }, + { + name: 'E.2 lt passes through', search: 'age=lt.11', + expected: filtered(cond(['age'], 'lt', 11)), + }, + { + name: 'E.2 lte maps to le', search: 'age=lte.11', + expected: filtered(cond(['age'], 'le', 11)), + }, + { + name: 'E.2 neq maps to complement-semantics negated eq', search: 'status=neq.archived', + expected: filtered(cond(['status'], 'eq', 'archived', true)), + }, + { + name: 'E.2 not.eq toggles the same negated flag', search: 'status=not.eq.archived', + expected: filtered(cond(['status'], 'eq', 'archived', true)), + }, + { + name: 'E.2 not.neq cancels the intrinsic neq negation', search: 'status=not.neq.archived', + expected: filtered(cond(['status'], 'eq', 'archived')), + }, + { + name: 'E.2 in maps to a canonical value list', search: 'id=in.(1,2,3)', + expected: filtered(cond(['id'], 'in', [1, 2, 3])), + }, + { + name: 'E.2 in preserves commas inside quoted values', + search: 'message=in.("hi,there","yes,you")', + expected: filtered(cond(['message'], 'in', ['hi,there', 'yes,you'])), + }, + { + name: 'quoted list values suppress literal interpretation', search: 'code=in.("3","true")', + expected: filtered(cond(['code'], 'in', ['3', 'true'])), + }, + { + name: 'quoted scalar values suppress literal interpretation', search: 'code=eq."0123"', + expected: filtered(cond(['code'], 'eq', '0123')), + }, + { + name: 'PostgREST timestamps remain schema-free strings', + search: 'created_at=gte.2024-01-01T00%3A00%3A00Z', + expected: filtered(cond(['created_at'], 'ge', '2024-01-01T00:00:00Z')), + }, + { + name: 'PostgREST URLs remain schema-free strings', search: 'homepage=eq.https%3A%2F%2Fexample.com', + expected: filtered(cond(['homepage'], 'eq', 'https://example.com')), + }, + { + name: 'RQL typed-prefix spelling is ordinary data in PostgREST', search: 'kind=eq.number%3A42', + expected: filtered(cond(['kind'], 'eq', 'number:42')), + }, + { + name: 'non-roundtrip exponential numerals remain strings', search: 'value=eq.1e3', + expected: filtered(cond(['value'], 'eq', '1e3')), + }, + { + name: 'non-roundtrip leading-zero numerals remain strings', search: 'value=eq.01', + expected: filtered(cond(['value'], 'eq', '01')), + }, + { + name: 'non-finite numeric spellings remain strings', search: 'value=eq.Infinity', + expected: filtered(cond(['value'], 'eq', 'Infinity')), + }, + { + name: 'roundtrip signed and decimal numerals are interpreted', search: 'low=eq.-5&ratio=eq.2.5', + expected: filtered(cond(['low'], 'eq', -5), cond(['ratio'], 'eq', 2.5)), + }, + { + name: 'dotted filter keys become path segments', search: 'account.owner=eq.alice', + expected: filtered(cond(['account', 'owner'], 'eq', 'alice')), + }, + { + name: 'E.2 json arrow filter path becomes a dotted canonical path', + search: 'json_col->>field=eq.value', + expected: filtered(cond(['json_col', 'field'], 'eq', 'value')), + }, + { + name: 'separate filter parameters are conjunctive', search: 'age=gte.18&active=is.true', + expected: filtered(cond(['age'], 'ge', 18), cond(['active'], 'eq', true)), + }, + { + name: 'repeated filter parameters remain separate conjunctive terms', search: 'age=gte.18&age=lte.65', + expected: filtered(cond(['age'], 'ge', 18), cond(['age'], 'le', 65)), + }, + { + name: 'E.2 or tree maps directly to an or Group', search: 'or=(age.eq.11,age.eq.12)', + expected: grouped('or', cond(['age'], 'eq', 11), cond(['age'], 'eq', 12)), + }, + { + name: 'E.2 and tree maps directly to an and Group', search: 'and=(age.gte.11,age.lte.20)', + expected: grouped('and', cond(['age'], 'ge', 11), cond(['age'], 'le', 20)), + }, + { + name: 'E.2 logic grammar recursively parses nested and inside or', + search: 'or=(a.eq.1,and(b.eq.2,c.eq.3))', + expected: grouped('or', cond(['a'], 'eq', 1), group('and', cond(['b'], 'eq', 2), cond(['c'], 'eq', 3))), + }, + { + name: 'logic leaves bind the first viable operator before operator-like operand text', + search: 'or=(version.eq.v1.eq.beta,b.eq.1)', + expected: grouped('or', cond(['version'], 'eq', 'v1.eq.beta'), cond(['b'], 'eq', 1)), + }, + { + name: 'quoted logic path segments can use operator names', + search: 'or=(metrics.%22eq%22.gt.2,b.eq.1)', + expected: grouped('or', cond(['metrics', 'eq'], 'gt', 2), cond(['b'], 'eq', 1)), + }, + { + name: 'E.2 not.and applies Core De Morgan desugaring', search: 'not.and=(a.eq.1,b.eq.2)', + expected: grouped('or', cond(['a'], 'eq', 1, true), cond(['b'], 'eq', 2, true)), + }, + { + name: 'E.2 not.or applies Core De Morgan desugaring', search: 'not.or=(a.eq.1,b.eq.2)', + expected: grouped('and', cond(['a'], 'eq', 1, true), cond(['b'], 'eq', 2, true)), + }, + { + name: 'E.2 nested not.and desugars inside a logic tree', + search: 'or=(a.eq.1,not.and(b.eq.2,c.eq.3))', + expected: grouped('or', cond(['a'], 'eq', 1), group('or', cond(['b'], 'eq', 2, true), cond(['c'], 'eq', 3, true))), + }, + { + name: 'E.2 not.eq remains a negated operator inside a logic leaf', + search: 'or=(a.not.eq.1,b.eq.2)', + expected: grouped('or', cond(['a'], 'eq', 1, true), cond(['b'], 'eq', 2)), + }, + { + name: 'E.2 not.in remains a negated operator inside a logic leaf', + search: 'or=(a.not.in.(1,2),b.eq.2)', + expected: grouped('or', cond(['a'], 'in', [1, 2], true), cond(['b'], 'eq', 2)), + }, + { + name: 'E.2 leaf negation composes with a negated logic tree', + search: 'not.or=(a.not.eq.1,b.eq.2)', + expected: grouped('and', cond(['a'], 'eq', 1), cond(['b'], 'eq', 2, true)), + }, + { + name: 'E.2 gt(any) becomes an or Group over values', search: 'age=gt(any).{11,21}', + expected: grouped('or', cond(['age'], 'gt', 11), cond(['age'], 'gt', 21)), + }, + { + name: 'E.2 like(any) retains its extension comparator', search: 'name=like(any).{A*,B*}', + expected: grouped('or', cond(['name'], 'like', 'A*'), cond(['name'], 'like', 'B*')), + }, + { + name: 'E.2 eq(any) collapses to canonical in', search: 'status=eq(any).{open,closed}', + expected: filtered(cond(['status'], 'in', ['open', 'closed'])), + }, + { + name: 'E.2 gt(all) becomes an and Group over values', search: 'age=gt(all).{11,21}', + expected: grouped('and', cond(['age'], 'gt', 11), cond(['age'], 'gt', 21)), + }, + { + name: 'E.2 neq(all) preserves intrinsic leaf negations', search: 'age=neq(all).{11,21}', + expected: grouped('and', cond(['age'], 'eq', 11, true), cond(['age'], 'eq', 21, true)), + }, + { + name: 'E.2 not.gt(any) negates the expanded group through De Morgan', search: 'age=not.gt(any).{11,21}', + expected: grouped('and', cond(['age'], 'gt', 11, true), cond(['age'], 'gt', 21, true)), + }, + { + name: 'E.2 cs array contains-all becomes conjunctive existential eq', search: 'tags=cs.{red,blue}', + expected: grouped('and', cond(['tags'], 'eq', 'red'), cond(['tags'], 'eq', 'blue')), + }, + { + name: 'E.2 cs preserves a colon in an array value', search: 'tags=cs.{red:blue,green}', + expected: grouped('and', cond(['tags'], 'eq', 'red:blue'), cond(['tags'], 'eq', 'green')), + }, + { + name: 'E.2 singleton cs remains an explicit and Group', search: 'tags=cs.{red}', + expected: grouped('and', cond(['tags'], 'eq', 'red')), + }, + { + name: 'E.2 not.cs negates the generated contains-all group', search: 'tags=not.cs.{red,blue}', + expected: grouped('or', cond(['tags'], 'eq', 'red', true), cond(['tags'], 'eq', 'blue', true)), + }, + { + name: 'E.2 ov array overlap becomes canonical in', search: 'tags=ov.{red,blue}', + expected: filtered(cond(['tags'], 'in', ['red', 'blue'])), + }, + { + name: 'range-form ov remains an extension comparator over the range value', search: 'period=ov.[1,10)', + expected: filtered(cond(['period'], 'ov', '[1,10)')), + }, + { + name: 'E.2 not.ov becomes negated canonical in', search: 'tags=not.ov.{red,blue}', + expected: filtered(cond(['tags'], 'in', ['red', 'blue'], true)), + }, + { + name: 'E.2 cd uses the forall-as-not-exists-not ElementMatch shape', search: 'tags=cd.{red,blue}', + expected: filtered({ + path: ['tags'], negated: true, + some: group('and', cond([], 'in', ['red', 'blue'], true)), + }), + }, + { + name: 'E.2 not.cd toggles the outer not-exists scope', search: 'tags=not.cd.{red,blue}', + expected: filtered({ + path: ['tags'], + some: group('and', cond([], 'in', ['red', 'blue'], true)), + }), + }, + { + name: 'E.2 is.null maps to eq null', search: 'deleted_at=is.null', + expected: filtered(cond(['deleted_at'], 'eq', null)), + }, + { + name: 'E.2 is.true maps to eq true', search: 'active=is.true', + expected: filtered(cond(['active'], 'eq', true)), + }, + { + name: 'E.2 is.false maps to eq false', search: 'active=is.false', + expected: filtered(cond(['active'], 'eq', false)), + }, + { + name: 'E.2 like is an extension comparator', search: 'name=like.*son', + expected: filtered(cond(['name'], 'like', '*son')), + }, + { + name: 'E.2 ilike is an extension comparator', search: 'name=ilike.*SON', + expected: filtered(cond(['name'], 'ilike', '*SON')), + }, + { + name: 'E.2 match is an extension comparator', search: 'name=match.^A', + expected: filtered(cond(['name'], 'match', '^A')), + }, + { + name: 'E.2 imatch is an extension comparator', search: 'name=imatch.^a', + expected: filtered(cond(['name'], 'imatch', '^a')), + }, + { + name: 'E.2 fts is an extension comparator', search: 'body=fts.cats', + expected: filtered(cond(['body'], 'fts', 'cats')), + }, + { + name: 'E.2 plfts is an extension comparator', search: 'body=plfts.fat cats', + expected: filtered(cond(['body'], 'plfts', 'fat cats')), + }, + { + name: 'E.2 phfts argument is folded into the opaque comparator name', + search: 'body=phfts(english).The Fat Cats', + expected: filtered(cond(['body'], 'phfts(english)', 'The Fat Cats')), + }, + { + name: 'E.2 wfts argument is folded into the opaque comparator name', + search: 'body=wfts(simple).The Fat Cats', + expected: filtered(cond(['body'], 'wfts(simple)', 'The Fat Cats')), + }, + { + name: 'E.2 sl retains a range operand as an extension value', search: 'period=sl.[1,10)', + expected: filtered(cond(['period'], 'sl', '[1,10)')), + }, + { + name: 'E.2 sr is an extension comparator', search: 'period=sr.[1,10)', + expected: filtered(cond(['period'], 'sr', '[1,10)')), + }, + { + name: 'E.2 nxl is an extension comparator', search: 'period=nxl.[1,10)', + expected: filtered(cond(['period'], 'nxl', '[1,10)')), + }, + { + name: 'E.2 nxr is an extension comparator', search: 'period=nxr.[1,10)', + expected: filtered(cond(['period'], 'nxr', '[1,10)')), + }, + { + name: 'E.2 adj is an extension comparator', search: 'period=adj.[1,10)', + expected: filtered(cond(['period'], 'adj', '[1,10)')), + }, + { + name: 'E.2 isdistinct is an extension comparator', search: 'status=isdistinct.archived', + expected: filtered(cond(['status'], 'isdistinct', 'archived')), + }, + { + name: 'E.2 single select field remains record-shaped in the PostgREST dialect', search: 'select=id', + expected: { select: projection(['id']) }, + }, + { + name: 'E.2 select list maps to canonical projection fields', search: 'select=id,name', + expected: { select: projection(['id'], ['name']) }, + }, + { + name: 'E.2 json arrow select path becomes a dotted canonical path', search: 'select=json_col->>field', + expected: { select: projection(['json_col', 'field']) }, + }, + { + name: 'PostgREST wildcard select maps to an absent canonical projection', search: 'select=*', + expected: {}, + }, + { + name: 'wildcard dominates an explicit PostgREST select list', search: 'select=id,*', + expected: {}, + }, + { + name: 'E.2 order defaults to ascending', search: 'order=name', + expected: { sort: [sort(['name'])] }, + }, + { + name: 'E.2 order supports multiple keys and desc', search: 'order=age.desc,name.asc', + expected: { sort: [sort(['age'], 'desc'), sort(['name'])] }, + }, + { + name: 'E.2 order supports dotted paths', search: 'order=account.name.desc', + expected: { sort: [sort(['account', 'name'], 'desc')] }, + }, + { + name: 'E.2 limit maps to canonical limit', search: 'limit=25', + expected: { limit: 25 }, + }, + { + name: 'E.2 offset maps to canonical offset', search: 'offset=10', + expected: { offset: 10 }, + }, + { + name: 'E.2 limit and offset coexist', search: 'limit=25&offset=10', + expected: { limit: 25, offset: 10 }, + }, + { + name: 'filters and result-shaping parameters share one ParseResult', + search: 'active=is.true&select=id,name&order=name.desc&limit=5&offset=2', + expected: { + filter: group('and', cond(['active'], 'eq', true)), + select: projection(['id'], ['name']), sort: [sort(['name'], 'desc')], limit: 5, offset: 2, + }, + }, +]; + +describe('PostgREST Appendix E conformance vectors', () => { + for (const vector of vectors) { + it(vector.name, () => { + assert.deepEqual(parsePostgREST(vector.search), vector.expected); + }); + } +}); + +describe('PostgREST input and shared-model behavior', () => { + it('accepts a leading question mark', () => { + assert.deepEqual(parsePostgREST('?age=gte.18'), filtered(cond(['age'], 'ge', 18))); + }); + + it('accepts URLSearchParams without decoding values twice', () => { + const parameters = new URLSearchParams([['message', 'eq.100% ready']]); + assert.deepEqual(parsePostgREST(parameters), filtered(cond(['message'], 'eq', '100% ready'))); + }); + + it('accepts searchParams from a URL object', () => { + const url = new URL('https://example.test/?id=eq.1&active=is.true'); + assert.deepEqual( + parsePostgREST(url.searchParams), + filtered(cond(['id'], 'eq', 1), cond(['active'], 'eq', true)), + ); + }); + + it('uses URL query decoding for plus and percent escapes', () => { + assert.deepEqual( + parsePostgREST('message=eq.hello+world%25'), + filtered(cond(['message'], 'eq', 'hello world%')), + ); + }); + + it('keeps delimiters inside quoted column names', () => { + assert.deepEqual( + parsePostgREST('%22first.name%22=eq.bob'), + filtered(cond(['first.name'], 'eq', 'bob')), + ); + }); + + it('does not treat a colon inside a quoted select field as an alias', () => { + assert.deepEqual( + parsePostgREST('select=%22namespace%3Afield%22'), + { select: projection(['namespace:field']) }, + ); + }); + + it('parses a leading quoted identifier inside a logic tree', () => { + assert.deepEqual( + parsePostgREST('or=(%22information.cpe%22.eq.x,b.eq.2)'), + grouped('or', cond(['information.cpe'], 'eq', 'x'), cond(['b'], 'eq', 2)), + ); + }); + + it('preserves trailing whitespace in logic-leaf operands', () => { + assert.deepEqual( + parsePostgREST('or=(name.eq.Bob%20,id.eq.1)'), + grouped('or', cond(['name'], 'eq', 'Bob '), cond(['id'], 'eq', 1)), + ); + }); + + it('ignores leading separator whitespace in logic terms', () => { + assert.deepEqual( + parsePostgREST('or=(a.eq.1,%20and(b.eq.2,c.eq.3))'), + grouped('or', cond(['a'], 'eq', 1), group('and', cond(['b'], 'eq', 2), cond(['c'], 'eq', 3))), + ); + }); + + it('does not interpret a first-segment not column as operator negation', () => { + assert.deepEqual( + parsePostgREST('or=(not.eq.ab,b.eq.2)'), + grouped('or', cond(['not'], 'eq', 'ab'), cond(['b'], 'eq', 2)), + ); + }); + + it('pins PostgREST field-first binding against top-level canonical dotted paths', () => { + assert.deepEqual( + parsePostgREST('meta.like=eq.5'), + filtered(cond(['meta', 'like'], 'eq', 5)), + ); + assert.deepEqual( + parsePostgREST('or=(meta.like.eq.5,b.eq.1)'), + grouped('or', cond(['meta'], 'like', 'eq.5'), cond(['b'], 'eq', 1)), + ); + }); + + it('combines repeated logic parameters conjunctively', () => { + assert.deepEqual( + parsePostgREST('or=(a.eq.1,b.eq.2)&or=(c.eq.3,d.eq.4)'), + filtered( + group('or', cond(['a'], 'eq', 1), cond(['b'], 'eq', 2)), + group('or', cond(['c'], 'eq', 3), cond(['d'], 'eq', 4)), + ), + ); + }); + + it('allows an unquoted operand to end in a quote character', () => { + assert.deepEqual( + parsePostgREST('title=eq.The+%22Best%22'), + filtered(cond(['title'], 'eq', 'The "Best"')), + ); + }); + + it('shares canonical literal interpretation with parseQuery', () => { + const equivalentPairs = [ + ['age=gte.18', 'age=ge=18'], + ['active=is.true', 'active=eq=true'], + ['deleted=is.null', 'deleted=eq=null'], + ['id=in.(1,2,3)', 'id=in=(1,2,3)'], + ['status=neq.archived', 'status=ne=archived'], + ['id=eq(any).{1,2}', 'id=in=(1,2)'], + ['tags=cs.{red,blue}', 'tags=red&tags=blue'], + ['value=eq.-0', 'value==-0'], + ['value=eq.1.0', 'value==1.0'], + ['value=eq..5', 'value==.5'], + ['value=eq.%2B1', 'value==%2B1'], + ['value=eq.1e3', 'value==1e3'], + ['value=eq.01', 'value==01'], + ] as const; + for (const [postgrest, core] of equivalentPairs) + assert.deepEqual(parsePostgREST(postgrest), parseQuery(core)); + }); + + it('pins the non-finite literal boundary against Core interpretation', () => { + assert.deepEqual(parsePostgREST('value=eq.Infinity'), filtered(cond(['value'], 'eq', 'Infinity'))); + assert.deepEqual(parseQuery('value==Infinity'), filtered(cond(['value'], 'eq', Infinity))); + }); + + it('deferred errors never return a partially usable query', () => { + const result = parsePostgREST('id=eq.1&limit=5&status=is.unknown', { deferErrors: true }); + assert.ok(result.parseError instanceof QueryError); + assert.deepEqual(Object.keys(result), ['parseError']); + }); +}); + +describe('Unsupported PostgREST features', () => { + const unsupported = [ + ['projection alias', 'select=display:name'], + ['projection cast', 'select=age::text'], + ['nullsfirst ordering', 'order=age.nullsfirst'], + ['nullslast ordering', 'order=age.desc.nullslast'], + ['resource embedding', 'select=id,orders(id,total)'], + ['hinted embedding', 'select=id,orders!inner(id)'], + ['projection aggregate', 'select=id,amount.sum()'], + ['related ordering', 'order=directors(last_name).desc'], + ['multidimensional cs array', 'tags=cs.{{red},{blue}}'], + ['multidimensional cd array', 'tags=cd.{{red},{blue}}'], + ['multidimensional ov array', 'tags=ov.{{red},{blue}}'], + ] as const; + + for (const [name, search] of unsupported) { + it(`${name} throws UnsupportedFeature`, () => { + assert.throws(() => parsePostgREST(search), UnsupportedFeature); + }); + } + + it('drop removes unsupported projection fields only when explicitly requested', () => { + assert.deepEqual( + parsePostgREST('select=id,display:name', { onUnsupported: 'drop' }), + { select: projection(['id']) }, + ); + }); + + it('drop removes only unsupported null placement and preserves the order key', () => { + assert.deepEqual( + parsePostgREST('order=id,age.desc.nullsfirst', { onUnsupported: 'drop' }), + { sort: [sort(['id']), sort(['age'], 'desc')] }, + ); + }); + + it('drop cannot erase the entire projection', () => { + assert.throws( + () => parsePostgREST('select=display:name', { onUnsupported: 'drop' }), + UnsupportedFeature, + ); + }); + + it('drop does not discard resource embedding because embedded-filter semantics differ', () => { + assert.throws( + () => parsePostgREST('select=title,actors(*)&actors.first_name=eq.Jehanne', { onUnsupported: 'drop' }), + UnsupportedFeature, + ); + }); + + it('drop preserves a sole order key when removing null placement', () => { + assert.deepEqual( + parsePostgREST('order=age.nullsfirst', { onUnsupported: 'drop' }), + { sort: [sort(['age'])] }, + ); + }); + + it('drop never weakens an unsupported filter', () => { + assert.throws( + () => parsePostgREST('status=is.unknown', { onUnsupported: 'drop' }), + UnsupportedFeature, + ); + }); + + it('rejects unrepresentable JSON containment as UnsupportedFeature', () => { + assert.throws(() => parsePostgREST('metadata=cs.{%22tier%22:%22gold%22}'), UnsupportedFeature); + }); + + it('rejects unrepresentable range containment as UnsupportedFeature', () => { + assert.throws(() => parsePostgREST('period=cd.[1,10)'), UnsupportedFeature); + }); + + it('names aggregate projection errors accurately', () => { + assert.throws( + () => parsePostgREST('select=id,amount.sum()'), + (error: unknown) => error instanceof UnsupportedFeature && error.message.includes('aggregate'), + ); + }); + + it('never drops aliased or cast aggregate projections', () => { + for (const search of [ + 'select=id,total:amount.sum()', + 'select=id,amount.sum()::numeric', + 'select=id,total:amount.sum()::numeric', + ]) { + assert.throws( + () => parsePostgREST(search, { onUnsupported: 'drop' }), + (error: unknown) => error instanceof UnsupportedFeature && error.message.includes('aggregate'), + ); + } + }); + + it('rejects nested any/all operands instead of stringifying them', () => { + for (const search of ['value=eq(any).{{1},{2}}', 'value=gt(all).((1),(2))']) + assert.throws(() => parsePostgREST(search), UnsupportedFeature); + }); + + it('rejects empty lists that would create undefined zero-term groups', () => { + for (const search of ['tags=cs.{}', 'value=gt(any).{}', 'value=gt(all).{}']) + assert.throws(() => parsePostgREST(search), UnsupportedFeature); + }); + + it('retains empty eq(any) as the defined canonical empty in condition', () => { + assert.deepEqual(parsePostgREST('value=eq(any).{}'), filtered(cond(['value'], 'in', []))); + }); + + it('does not drop related ordering because it changes pagination semantics', () => { + assert.throws( + () => parsePostgREST('order=directors(last_name).desc', { onUnsupported: 'drop' }), + UnsupportedFeature, + ); + }); + + it('classifies an unclosed containment array as syntax, not an unsupported feature', () => { + assert.throws( + () => parsePostgREST('tags=cs.{red,blue'), + (error: unknown) => error instanceof QueryError && !(error instanceof UnsupportedFeature), + ); + }); +}); + +describe('PostgREST syntax and resource bounds', () => { + const hostileInputs = [ + 'a=wat.1', + 'or=(a.eq.1,,b.eq.2)', + 'or=(a.eq.1,and(b.eq.2,c.eq.3)', + 'id=in.(1,2', + 'code=eq."unterminated', + 'limit=-1', + 'offset=1.5', + 'limit=1&limit=2', + 'offset=1&offset=2', + 'select=id&select=name', + 'order=id&order=name', + 'tags=ov(all).{red,blue}', + 'body=phfts(english%27%3Bdrop).cats', + ] as const; + + for (const search of hostileInputs) { + it(`throws QueryError for ${search}`, () => { + assert.throws(() => parsePostgREST(search), QueryError); + }); + } + + it('rejects logic nesting beyond the parser depth budget as QueryError', () => { + let logic = 'a.eq.1'; + for (let depth = 0; depth < 40; depth++) logic = `and(${logic})`; + assert.throws(() => parsePostgREST(`or=(${logic})`), QueryError); + }); + + it('rejects lists beyond the parser value budget as QueryError', () => { + const values = Array.from({ length: 1_001 }, (_, index) => String(index)).join(','); + assert.throws(() => parsePostgREST(`id=in.(${values})`), QueryError); + }); + + it('applies the parser term budget to projection fields', () => { + const fields = Array.from({ length: 1_001 }, (_, index) => `field${index}`).join(','); + assert.throws(() => parsePostgREST(`select=${fields}`), QueryError); + }); + + it('applies the parser term budget to order keys', () => { + const keys = Array.from({ length: 1_001 }, (_, index) => `field${index}`).join(','); + assert.throws(() => parsePostgREST(`order=${keys}`), QueryError); + }); + + it('rejects an oversized encoded query before URL decoding', () => { + assert.throws(() => parsePostgREST(`message=eq.${'x'.repeat(65_536)}`), QueryError); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 71bb220..845135c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", + "rewriteRelativeImportExtensions": true, "outDir": "dist", "declaration": true, "declarationMap": true,