From b8c03a6278f4e747c5b6941057a6796d2dc7b6d8 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Tue, 1 Sep 2026 16:10:26 -0600 Subject: [PATCH 01/11] Add PostgREST dialect parser Co-Authored-By: GPT-5 Codex --- package.json | 6 +- src/dialects/postgrest.ts | 361 +++++++++++++++++++++++++++ src/parser.ts | 6 +- test/dialects/postgrest.test.ts | 416 ++++++++++++++++++++++++++++++++ tsconfig.json | 3 +- 5 files changed, 787 insertions(+), 5 deletions(-) create mode 100644 src/dialects/postgrest.ts create mode 100644 test/dialects/postgrest.test.ts diff --git a/package.json b/package.json index 836cd26..2c38800 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": "node --experimental-strip-types --test test/v2/parse.test.ts test/dialects/postgrest.test.ts", "typecheck": "tsc --noEmit" }, "devDependencies": { diff --git a/src/dialects/postgrest.ts b/src/dialects/postgrest.ts new file mode 100644 index 0000000..26321ff --- /dev/null +++ b/src/dialects/postgrest.ts @@ -0,0 +1,361 @@ +import { QueryError, SyntaxViolation } from '../errors.ts'; +import { interpretValue, negateGroup, negateTerm } from '../parser.ts'; +import type { + Condition, ElementMatch, Field, Group, ParseOptions, ParseResult, Projection, SortKey, Value, +} from '../types.ts'; + +type Term = Condition | Group | ElementMatch; + +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 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']); + +type ParseBudget = { terms: number }; + +type ParsedOperator = { + operator: string; + argument?: string; + negated: boolean; + operand: string; +}; + +function syntaxViolation(message: string): never { + throw new SyntaxViolation(`Unable to parse PostgREST query: ${message}`); +} + +function useTerms(budget: ParseBudget, count: number): void { + budget.terms += count; + if (budget.terms > MAX_TERMS) syntaxViolation(`query exceeds the ${MAX_TERMS}-term limit`); +} + +function matchingClose(open: string, close: string): boolean { + if (open === '{') return close === '}'; + if (open === '[') return close === ']' || close === ')'; + return close === ')' || close === ']'; +} + +function splitTopLevel(input: string): 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) { + parts.push(input.slice(start, index)); + start = index + 1; + } + } + + if (quoted) syntaxViolation('unterminated quoted operand'); + if (stack.length > 0) syntaxViolation('unbalanced operand delimiter'); + parts.push(input.slice(start)); + return parts; +} + +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 { + value += character; + } + } + return value; +} + +function interpretDecodedValue(token: string): Value { + const colon = token.indexOf(':'); + if (colon > 0) { + const type = token.slice(0, colon); + let encodedRest = encodeURIComponent(token.slice(colon + 1)); + if (type === 'number' && encodedRest.startsWith('%24')) encodedRest = `$${encodedRest.slice(3)}`; + return interpretValue(`${type}:${encodedRest}`); + } + return interpretValue(encodeURIComponent(token)); +} + +function parseOperand(raw: string): Value { + if (raw.startsWith('"') || raw.endsWith('"')) return decodeQuoted(raw); + return interpretDecodedValue(raw); +} + +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); + if (parts.length > MAX_LIST_VALUES) + syntaxViolation(`value list exceeds the ${MAX_LIST_VALUES}-value limit`); + return parts.map(parseOperand); +} + +function parseModifierValues(raw: string): Value[] { + if (raw.startsWith('{')) return parseList(raw, '{'); + if (raw.startsWith('(')) return parseList(raw, '('); + syntaxViolation('any/all modifier requires a value list'); +} + +function splitColumnPath(raw: string): string[] { + if (!raw) syntaxViolation('column path is empty'); + const segments = raw.split(/->>|->|\./).map((segment) => { + if (!segment) syntaxViolation('column path contains an empty segment'); + return segment.startsWith('"') || segment.endsWith('"') ? decodeQuoted(segment) : segment; + }); + return segments; +} + +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; +} + +function parseOperator(expression: string): ParsedOperator { + const match = /^(not\.)?([a-z][a-z0-9_]*)(?:\(([^()]*)\))?\.([\s\S]*)$/.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`); + return { operator, argument, negated: notPrefix !== undefined, operand }; +} + +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 = condition(path, comparator, parseList(parsed.operand, '{'), false, budget); + } else if (parsed.operator === 'cs') { + const values = parseList(parsed.operand, '{'); + term = { + operator: 'and', + terms: values.map((value) => condition(path, 'eq', value, false, budget)), + }; + } else if (parsed.operator === 'cd') { + const values = parseList(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 { + 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 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 parseLogicLeaf(raw: string, budget: ParseBudget): Term { + for (let index = 1; index < raw.length; index++) { + if (raw[index] !== '.') continue; + const expression = raw.slice(index + 1); + const operatorMatch = /^(?:not\.)?([a-z][a-z0-9_]*)(?:\([^()]*\))?\./.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'); +} + +function parseLogicTerm(raw: string, depth: number, budget: ParseBudget): Term { + const value = raw.trim(); + const call = /^(not\.)?(and|or)\(/.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 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 unsupported(feature: string, options: PostgrestOptions | undefined): boolean { + if (options?.onUnsupported === 'drop') return true; + throw new UnsupportedFeature(`PostgREST feature '${feature}' is unsupported`); +} + +function parseSelect(raw: string, options: PostgrestOptions | undefined): Projection | undefined { + const fields: Field[] = []; + for (const rawField of splitTopLevel(raw)) { + const field = rawField.trim(); + if (!field) syntaxViolation('select contains an empty field'); + let feature: string | undefined; + if (field.includes('::')) feature = `projection cast '${field}'`; + else if (field.includes(':')) feature = `projection alias '${field}'`; + else if (field.includes('(') || field.includes(')') || field.includes('!')) + feature = `resource embedding '${field}'`; + if (feature) { + if (unsupported(feature, options)) continue; + } + fields.push({ path: splitColumnPath(field) }); + } + if (fields.length === 0) return undefined; + return { mode: 'records', fields }; +} + +function parseOrder(raw: string, options: PostgrestOptions | undefined): SortKey[] | undefined { + const keys: SortKey[] = []; + for (const rawKey of splitTopLevel(raw)) { + let key = rawKey.trim(); + if (!key) syntaxViolation('order contains an empty key'); + if (/(?:^|\.)(?:nullsfirst|nullslast)$/.test(key)) { + if (unsupported(`null ordering '${key}'`, options)) continue; + } + 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'; } + keys.push({ path: splitColumnPath(key), direction }); + } + return keys.length > 0 ? keys : undefined; +} + +function parseNonNegativeInteger(raw: string, name: string): number { + if (!/^(?:0|[1-9][0-9]*)$/.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 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 }; +} + +function parseInto( + result: ParseResult, parameters: URLSearchParams, options: PostgrestOptions | undefined, +): void { + const terms: Term[] = []; + const budget: ParseBudget = { terms: 0 }; + const seenReserved = new Set(); + + for (const [key, value] of parameters.entries()) { + if (key === 'select' || key === 'order' || key === 'limit' || key === 'offset') { + if (seenReserved.has(key)) syntaxViolation(`duplicate '${key}' parameter`); + seenReserved.add(key); + if (key === 'select') result.select = parseSelect(value, options); + else if (key === 'order') result.sort = parseOrder(value, options); + 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; +} + +/** + * Parse the Appendix E PostgREST surface into the RQL §6 canonical model. + * `neq` intentionally uses RQL complement semantics, so absent properties differ from SQL `<>`. + */ +export function parsePostgrest( + search: string | URLSearchParams, options?: PostgrestOptions, +): ParseResult { + const result: ParseResult = {}; + try { + const parameters = typeof search === 'string' + ? new URLSearchParams(search.startsWith('?') ? search.slice(1) : search) + : search; + parseInto(result, parameters, options); + } catch (error) { + if (!(error instanceof QueryError)) throw error; + if (!options?.deferErrors) throw error; + result.parseError = error; + } + return result; +} diff --git a/src/parser.ts b/src/parser.ts index b40677b..497ffc5 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -14,7 +14,7 @@ const FIQL_NAME = /^[a-zA-Z_][a-zA-Z_0-9]*$/; // ── Value decoding ───────────────────────────────────────────────────────── -function interpretValue(token: string): Value { +export function interpretValue(token: string): Value { if (token === 'null') return null; if (token === 'true') return true; if (token === 'false') return false; @@ -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.test.ts b/test/dialects/postgrest.test.ts new file mode 100644 index 0000000..b932a10 --- /dev/null +++ b/test/dialects/postgrest.test.ts @@ -0,0 +1,416 @@ +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: '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: '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 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 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: '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: '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('uses URL query decoding for plus and percent escapes', () => { + assert.deepEqual( + parsePostgrest('message=eq.hello+world%25'), + filtered(cond(['message'], 'eq', 'hello world%')), + ); + }); + + 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'], + ] as const; + for (const [postgrest, core] of equivalentPairs) + assert.deepEqual(parsePostgrest(postgrest), parseQuery(core)); + }); + + it('supports deferred QueryError results explicitly', () => { + const result = parsePostgrest('limit=-1', { deferErrors: true }); + assert.ok(result.parseError instanceof QueryError); + }); +}); + +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)'], + ] 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 unsupported order decorations only when explicitly requested', () => { + assert.deepEqual( + parsePostgrest('order=id,age.nullsfirst', { onUnsupported: 'drop' }), + { sort: [sort(['id'])] }, + ); + }); + + it('drop never weakens an unsupported filter', () => { + assert.throws( + () => parsePostgrest('status=is.unknown', { onUnsupported: 'drop' }), + 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', + ] 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); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 71bb220..3e325cd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,13 +3,14 @@ "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", + "rewriteRelativeImportExtensions": true, "outDir": "dist", "declaration": true, "declarationMap": true, "sourceMap": true, "strict": true, "skipLibCheck": true, - "lib": ["ES2022"] + "lib": ["ES2022", "DOM", "DOM.Iterable"] }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist"] From 88dbff264084c89d1e0d524f94bbff0372f8d719 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Tue, 1 Sep 2026 16:10:40 -0600 Subject: [PATCH 02/11] Document PostgREST operator argument gap Co-Authored-By: GPT-5 Codex --- specification/rql-2.0.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/specification/rql-2.0.md b/specification/rql-2.0.md index 4f96d25..49b12d7 100644 --- a/specification/rql-2.0.md +++ b/specification/rql-2.0.md @@ -647,6 +647,10 @@ 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)`); 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 From 375e6afb7eb3ab65b56ee4413707e7c5ee8fe5ea Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Tue, 1 Sep 2026 16:26:29 -0600 Subject: [PATCH 03/11] Harden PostgREST dialect boundaries Co-Authored-By: GPT-5 Codex --- .gitignore | 1 + package.json | 2 +- src/dialects/postgrest.ts | 162 ++++++++++++++++++++------ src/parser.ts | 2 +- test/dialects/postgrest-dist.test.mjs | 26 +++++ test/dialects/postgrest.test.ts | 80 ++++++++++++- tsconfig.json | 2 +- 7 files changed, 234 insertions(+), 41 deletions(-) create mode 100644 test/dialects/postgrest-dist.test.mjs 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 2c38800..e10c101 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ ], "scripts": { "build": "tsc", - "test": "node --experimental-strip-types --test test/v2/parse.test.ts test/dialects/postgrest.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/src/dialects/postgrest.ts b/src/dialects/postgrest.ts index 26321ff..578a4e1 100644 --- a/src/dialects/postgrest.ts +++ b/src/dialects/postgrest.ts @@ -1,11 +1,17 @@ import { QueryError, SyntaxViolation } from '../errors.ts'; -import { interpretValue, negateGroup, negateTerm } from '../parser.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'; } @@ -24,6 +30,16 @@ const OPERATOR_NAMES = new Set([ const CONFIGURABLE_OPERATORS = new Set(['fts', 'plfts', 'phfts', 'wfts']); +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 URL_SEARCH_PARAMS = (globalThis as unknown as { + URLSearchParams: URLSearchParamsConstructor; +}).URLSearchParams; + type ParseBudget = { terms: number }; type ParsedOperator = { @@ -48,7 +64,7 @@ function matchingClose(open: string, close: string): boolean { return close === ')' || close === ']'; } -function splitTopLevel(input: string): string[] { +function splitTopLevel(input: string, maxParts = MAX_TERMS): string[] { const parts: string[] = []; const stack: string[] = []; let quoted = false; @@ -71,6 +87,7 @@ function splitTopLevel(input: string): string[] { 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; } @@ -78,10 +95,29 @@ function splitTopLevel(input: string): string[] { 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 decodeQuoted(raw: string): string { if (raw.length < 2 || raw[0] !== '"' || raw[raw.length - 1] !== '"') syntaxViolation('malformed quoted operand'); @@ -92,6 +128,8 @@ function decodeQuoted(raw: string): string { 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; } @@ -100,18 +138,16 @@ function decodeQuoted(raw: string): string { } function interpretDecodedValue(token: string): Value { - const colon = token.indexOf(':'); - if (colon > 0) { - const type = token.slice(0, colon); - let encodedRest = encodeURIComponent(token.slice(colon + 1)); - if (type === 'number' && encodedRest.startsWith('%24')) encodedRest = `$${encodedRest.slice(3)}`; - return interpretValue(`${type}:${encodedRest}`); - } - return interpretValue(encodeURIComponent(token)); + if (token === 'null') return null; + if (token === 'true') return true; + if (token === 'false') return false; + const number = +token; + if (token !== '' && !isNaN(number) && String(number) === token) return number; + return token; } function parseOperand(raw: string): Value { - if (raw.startsWith('"') || raw.endsWith('"')) return decodeQuoted(raw); + if (raw.startsWith('"')) return decodeQuoted(raw); return interpretDecodedValue(raw); } @@ -121,7 +157,7 @@ function parseList(raw: string, open: '(' | '{'): Value[] { syntaxViolation(`operator requires a ${open}${close} value list`); const inner = raw.slice(1, -1); if (inner === '') return []; - const parts = splitTopLevel(inner); + const parts = splitTopLevel(inner, MAX_LIST_VALUES); if (parts.length > MAX_LIST_VALUES) syntaxViolation(`value list exceeds the ${MAX_LIST_VALUES}-value limit`); return parts.map(parseOperand); @@ -135,10 +171,37 @@ function parseModifierValues(raw: string): Value[] { function splitColumnPath(raw: string): string[] { if (!raw) syntaxViolation('column path is empty'); - const segments = raw.split(/->>|->|\./).map((segment) => { + 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'); - return segment.startsWith('"') || segment.endsWith('"') ? decodeQuoted(segment) : 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; } @@ -152,7 +215,7 @@ function condition( } function parseOperator(expression: string): ParsedOperator { - const match = /^(not\.)?([a-z][a-z0-9_]*)(?:\(([^()]*)\))?\.([\s\S]*)$/.exec(expression); + 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}'`); @@ -228,10 +291,25 @@ function unwrapLogicBody(raw: string): string { } function parseLogicLeaf(raw: string, budget: ParseBudget): Term { + const candidateIndexes: number[] = []; + let quoted = false; + let escaped = false; for (let index = 1; index < raw.length; index++) { - if (raw[index] !== '.') continue; + 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 === '.') candidateIndexes.push(index); + } + if (quoted) syntaxViolation('unterminated quoted column path'); + for (let candidate = candidateIndexes.length - 1; candidate >= 0; candidate--) { + const index = candidateIndexes[candidate]; const expression = raw.slice(index + 1); - const operatorMatch = /^(?:not\.)?([a-z][a-z0-9_]*)(?:\([^()]*\))?\./.exec(expression); + const operatorMatch = LOGIC_OPERATOR_PATTERN.exec(expression); if (operatorMatch && OPERATOR_NAMES.has(operatorMatch[1])) return parseFilterValue(splitColumnPath(raw.slice(0, index)), expression, budget); } @@ -240,7 +318,7 @@ function parseLogicLeaf(raw: string, budget: ParseBudget): Term { function parseLogicTerm(raw: string, depth: number, budget: ParseBudget): Term { const value = raw.trim(); - const call = /^(not\.)?(and|or)\(/.exec(value); + const call = LOGIC_CALL_PATTERN.exec(value); if (!call) return parseLogicLeaf(value, budget); if (!value.endsWith(')')) syntaxViolation('unbalanced logic group'); const group = parseLogicGroup( @@ -264,43 +342,56 @@ function unsupported(feature: string, options: PostgrestOptions | undefined): bo throw new UnsupportedFeature(`PostgREST feature '${feature}' is unsupported`); } -function parseSelect(raw: string, options: PostgrestOptions | undefined): Projection | undefined { +function parseSelect( + raw: string, options: PostgrestOptions | undefined, budget: ParseBudget, +): Projection { const fields: Field[] = []; + let dropped = false; for (const rawField of splitTopLevel(raw)) { const field = rawField.trim(); if (!field) syntaxViolation('select contains an empty field'); let feature: string | undefined; - if (field.includes('::')) feature = `projection cast '${field}'`; - else if (field.includes(':')) feature = `projection alias '${field}'`; - else if (field.includes('(') || field.includes(')') || field.includes('!')) + if (includesUnquoted(field, '::')) feature = `projection cast '${field}'`; + else if (includesUnquoted(field, ':')) feature = `projection alias '${field}'`; + else if (includesUnquoted(field, '(') || includesUnquoted(field, ')') || includesUnquoted(field, '!')) feature = `resource embedding '${field}'`; if (feature) { - if (unsupported(feature, options)) continue; + if (unsupported(feature, options)) { dropped = true; continue; } } + useTerms(budget, 1); fields.push({ path: splitColumnPath(field) }); } - if (fields.length === 0) 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): SortKey[] | undefined { +function parseOrder( + raw: string, options: PostgrestOptions | undefined, budget: ParseBudget, +): SortKey[] { const keys: SortKey[] = []; + let dropped = false; for (const rawKey of splitTopLevel(raw)) { let key = rawKey.trim(); if (!key) syntaxViolation('order contains an empty key'); - if (/(?:^|\.)(?:nullsfirst|nullslast)$/.test(key)) { - if (unsupported(`null ordering '${key}'`, options)) continue; + if (NULL_ORDER_PATTERN.test(key)) { + if (unsupported(`null ordering '${key}'`, options)) { dropped = true; continue; } } 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 }); } - return keys.length > 0 ? keys : undefined; + if (keys.length === 0 && dropped) + throw new UnsupportedFeature('PostgREST cannot drop every order key'); + if (keys.length === 0) syntaxViolation('order cannot be empty'); + return keys; } function parseNonNegativeInteger(raw: string, name: string): number { - if (!/^(?:0|[1-9][0-9]*)$/.test(raw)) syntaxViolation(`${name} must be a non-negative integer`); + 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; @@ -323,8 +414,8 @@ function parseInto( if (key === 'select' || key === 'order' || key === 'limit' || key === 'offset') { if (seenReserved.has(key)) syntaxViolation(`duplicate '${key}' parameter`); seenReserved.add(key); - if (key === 'select') result.select = parseSelect(value, options); - else if (key === 'order') result.sort = parseOrder(value, options); + if (key === 'select') result.select = parseSelect(value, options, budget); + 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'; @@ -340,8 +431,7 @@ function parseInto( } /** - * Parse the Appendix E PostgREST surface into the RQL §6 canonical model. - * `neq` intentionally uses RQL complement semantics, so absent properties differ from SQL `<>`. + * Appendix E.4: `neq` uses RQL complement semantics, so absent properties differ from SQL `<>`. */ export function parsePostgrest( search: string | URLSearchParams, options?: PostgrestOptions, @@ -349,13 +439,13 @@ export function parsePostgrest( const result: ParseResult = {}; try { const parameters = typeof search === 'string' - ? new URLSearchParams(search.startsWith('?') ? search.slice(1) : search) + ? new URL_SEARCH_PARAMS(search.startsWith('?') ? search.slice(1) : search) : search; parseInto(result, parameters, options); } catch (error) { if (!(error instanceof QueryError)) throw error; if (!options?.deferErrors) throw error; - result.parseError = error; + return { parseError: error }; } return result; } diff --git a/src/parser.ts b/src/parser.ts index 497ffc5..f5876b6 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -14,7 +14,7 @@ const FIQL_NAME = /^[a-zA-Z_][a-zA-Z_0-9]*$/; // ── Value decoding ───────────────────────────────────────────────────────── -export function interpretValue(token: string): Value { +function interpretValue(token: string): Value { if (token === 'null') return null; if (token === 'true') return true; if (token === 'false') return false; diff --git a/test/dialects/postgrest-dist.test.mjs b/test/dialects/postgrest-dist.test.mjs new file mode 100644 index 0000000..25ba3fa --- /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 index b932a10..0d33f50 100644 --- a/test/dialects/postgrest.test.ts +++ b/test/dialects/postgrest.test.ts @@ -84,6 +84,31 @@ const vectors: { name: string; search: string; expected: ParseResult }[] = [ 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: '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')), @@ -114,6 +139,11 @@ const vectors: { name: string; search: string; expected: ParseResult }[] = [ 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 rightmost viable operator after a colliding path segment', + search: 'or=(meta.like.eq.5,b.eq.2)', + expected: grouped('or', cond(['meta', 'like'], 'eq', 5), cond(['b'], 'eq', 2)), + }, { 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)), @@ -326,6 +356,27 @@ describe('PostgREST input and shared-model behavior', () => { ); }); + 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('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'], @@ -338,9 +389,10 @@ describe('PostgREST input and shared-model behavior', () => { assert.deepEqual(parsePostgrest(postgrest), parseQuery(core)); }); - it('supports deferred QueryError results explicitly', () => { - const result = parsePostgrest('limit=-1', { deferErrors: true }); + 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']); }); }); @@ -374,6 +426,20 @@ describe('Unsupported PostgREST features', () => { ); }); + it('drop cannot erase the entire projection', () => { + assert.throws( + () => parsePostgrest('select=display:name', { onUnsupported: 'drop' }), + UnsupportedFeature, + ); + }); + + it('drop cannot erase every order key', () => { + assert.throws( + () => parsePostgrest('order=age.nullsfirst', { onUnsupported: 'drop' }), + UnsupportedFeature, + ); + }); + it('drop never weakens an unsupported filter', () => { assert.throws( () => parsePostgrest('status=is.unknown', { onUnsupported: 'drop' }), @@ -413,4 +479,14 @@ describe('PostgREST syntax and resource bounds', () => { 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); + }); }); diff --git a/tsconfig.json b/tsconfig.json index 3e325cd..845135c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,7 @@ "sourceMap": true, "strict": true, "skipLibCheck": true, - "lib": ["ES2022", "DOM", "DOM.Iterable"] + "lib": ["ES2022"] }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist"] From aecdc9c4cb9ed69adea61803b874370927d32a15 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Tue, 1 Sep 2026 16:26:39 -0600 Subject: [PATCH 04/11] Clarify FTS comparator serialization gap Co-Authored-By: GPT-5 Codex --- specification/rql-2.0.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/specification/rql-2.0.md b/specification/rql-2.0.md index 49b12d7..fe52995 100644 --- a/specification/rql-2.0.md +++ b/specification/rql-2.0.md @@ -650,7 +650,9 @@ Honest gaps, recorded rather than mapped away: - **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)`); a future revision could model arguments separately. + (`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 From 1161556809bc63747683e14a31466446ddd2ab8c Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Tue, 1 Sep 2026 16:52:16 -0600 Subject: [PATCH 05/11] Fix PostgREST logic and wildcard edges Co-Authored-By: GPT-5 Codex --- src/dialects/postgrest.ts | 46 ++++++++++++++++++++++++------- test/dialects/postgrest.test.ts | 48 +++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 10 deletions(-) diff --git a/src/dialects/postgrest.ts b/src/dialects/postgrest.ts index 578a4e1..81c8df9 100644 --- a/src/dialects/postgrest.ts +++ b/src/dialects/postgrest.ts @@ -252,7 +252,9 @@ function parseFilterValue(path: string[], expression: string, budget: ParseBudge } else if (parsed.operator === 'in') { term = condition(path, comparator, parseList(parsed.operand, '('), false, budget); } else if (parsed.operator === 'ov') { - term = condition(path, comparator, parseList(parsed.operand, '{'), false, budget); + term = parsed.operand.startsWith('{') + ? condition(path, comparator, parseList(parsed.operand, '{'), false, budget) + : condition(path, 'ov', parseOperand(parsed.operand), false, budget); } else if (parsed.operator === 'cs') { const values = parseList(parsed.operand, '{'); term = { @@ -292,9 +294,10 @@ function unwrapLogicBody(raw: string): string { function parseLogicLeaf(raw: string, budget: ParseBudget): Term { const candidateIndexes: number[] = []; + const stack: string[] = []; let quoted = false; let escaped = false; - for (let index = 1; index < raw.length; index++) { + for (let index = 0; index < raw.length; index++) { const character = raw[index]; if (quoted) { if (escaped) escaped = false; @@ -302,22 +305,38 @@ function parseLogicLeaf(raw: string, budget: ParseBudget): Term { else if (character === '"') quoted = false; continue; } - if (character === '"') quoted = true; - else if (character === '.') candidateIndexes.push(index); + 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 = candidateIndexes.length - 1; candidate >= 0; 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])) + if (operatorMatch && OPERATOR_NAMES.has(operatorMatch[1])) { + const previousIndex = candidate > 0 ? candidateIndexes[candidate - 1] : -1; + if (raw.slice(previousIndex + 1, index) === 'not') { + return parseFilterValue( + splitColumnPath(raw.slice(0, previousIndex)), raw.slice(previousIndex + 1), budget, + ); + } return parseFilterValue(splitColumnPath(raw.slice(0, index)), expression, budget); + } } syntaxViolation('logic leaf must have the form column.[not.]operator.operand'); } function parseLogicTerm(raw: string, depth: number, budget: ParseBudget): Term { - const value = raw.trim(); + const value = raw; const call = LOGIC_CALL_PATTERN.exec(value); if (!call) return parseLogicLeaf(value, budget); if (!value.endsWith(')')) syntaxViolation('unbalanced logic group'); @@ -344,23 +363,27 @@ function unsupported(feature: string, options: PostgrestOptions | undefined): bo function parseSelect( raw: string, options: PostgrestOptions | undefined, budget: ParseBudget, -): Projection { +): 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 (includesUnquoted(field, '::')) feature = `projection cast '${field}'`; else if (includesUnquoted(field, ':')) feature = `projection alias '${field}'`; - else if (includesUnquoted(field, '(') || includesUnquoted(field, ')') || includesUnquoted(field, '!')) - feature = `resource embedding '${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'); @@ -414,7 +437,10 @@ function parseInto( if (key === 'select' || key === 'order' || key === 'limit' || key === 'offset') { if (seenReserved.has(key)) syntaxViolation(`duplicate '${key}' parameter`); seenReserved.add(key); - if (key === 'select') result.select = parseSelect(value, options, budget); + 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') { diff --git a/test/dialects/postgrest.test.ts b/test/dialects/postgrest.test.ts index 0d33f50..c8d048b 100644 --- a/test/dialects/postgrest.test.ts +++ b/test/dialects/postgrest.test.ts @@ -157,6 +157,21 @@ const vectors: { name: string; search: string; expected: ParseResult }[] = [ 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)), @@ -197,6 +212,10 @@ const vectors: { name: string; search: string; expected: ParseResult }[] = [ 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)), @@ -297,6 +316,14 @@ const vectors: { name: string; search: string; expected: ParseResult }[] = [ 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'])] }, @@ -370,6 +397,20 @@ describe('PostgREST input and shared-model behavior', () => { ); }); + 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('allows an unquoted operand to end in a quote character', () => { assert.deepEqual( parsePostgrest('title=eq.The+%22Best%22'), @@ -433,6 +474,13 @@ describe('Unsupported PostgREST features', () => { ); }); + 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 cannot erase every order key', () => { assert.throws( () => parsePostgrest('order=age.nullsfirst', { onUnsupported: 'drop' }), From 37983aabed979d8cac08db34cb032e7363b26e03 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Tue, 1 Sep 2026 16:57:09 -0600 Subject: [PATCH 06/11] Harden PostgREST logic term parsing Co-Authored-By: GPT-5 Codex --- src/dialects/postgrest.ts | 4 ++-- test/dialects/postgrest.test.ts | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/dialects/postgrest.ts b/src/dialects/postgrest.ts index 81c8df9..5ba82a8 100644 --- a/src/dialects/postgrest.ts +++ b/src/dialects/postgrest.ts @@ -324,7 +324,7 @@ function parseLogicLeaf(raw: string, budget: ParseBudget): Term { const operatorMatch = LOGIC_OPERATOR_PATTERN.exec(expression); if (operatorMatch && OPERATOR_NAMES.has(operatorMatch[1])) { const previousIndex = candidate > 0 ? candidateIndexes[candidate - 1] : -1; - if (raw.slice(previousIndex + 1, index) === 'not') { + if (previousIndex >= 0 && raw.slice(previousIndex + 1, index) === 'not') { return parseFilterValue( splitColumnPath(raw.slice(0, previousIndex)), raw.slice(previousIndex + 1), budget, ); @@ -336,7 +336,7 @@ function parseLogicLeaf(raw: string, budget: ParseBudget): Term { } function parseLogicTerm(raw: string, depth: number, budget: ParseBudget): Term { - const value = raw; + const value = raw.trimStart(); const call = LOGIC_CALL_PATTERN.exec(value); if (!call) return parseLogicLeaf(value, budget); if (!value.endsWith(')')) syntaxViolation('unbalanced logic group'); diff --git a/test/dialects/postgrest.test.ts b/test/dialects/postgrest.test.ts index c8d048b..7e3b0ad 100644 --- a/test/dialects/postgrest.test.ts +++ b/test/dialects/postgrest.test.ts @@ -411,6 +411,20 @@ describe('PostgREST input and shared-model behavior', () => { ); }); + 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('allows an unquoted operand to end in a quote character', () => { assert.deepEqual( parsePostgrest('title=eq.The+%22Best%22'), From 240ec6df23f50dda747de80ff93acf870cccae85 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Wed, 2 Sep 2026 06:39:48 -0600 Subject: [PATCH 07/11] Harden PostgREST operator parsing Co-Authored-By: GPT-5 Codex --- src/dialects/postgrest.ts | 42 ++++++++++++++++++++++-------- test/dialects/postgrest.test.ts | 45 +++++++++++++++++++++++++-------- 2 files changed, 67 insertions(+), 20 deletions(-) diff --git a/src/dialects/postgrest.ts b/src/dialects/postgrest.ts index 5ba82a8..2b4920a 100644 --- a/src/dialects/postgrest.ts +++ b/src/dialects/postgrest.ts @@ -21,6 +21,7 @@ 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', @@ -29,12 +30,18 @@ const OPERATOR_NAMES = new Set([ ]); 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 URL_SEARCH_PARAMS = (globalThis as unknown as { URLSearchParams: URLSearchParamsConstructor; @@ -169,6 +176,13 @@ function parseModifierValues(raw: string): Value[] { syntaxViolation('any/all modifier requires a value list'); } +function parseContainmentValues(operator: string, raw: string): Value[] { + if (!raw.startsWith('{') || !raw.endsWith('}') + || (/^\{\s*"/.test(raw) && includesUnquoted(raw.slice(1, -1), ':'))) + throw new UnsupportedFeature(`PostgREST ${operator} operand '${raw}' cannot be represented`); + return parseList(raw, '{'); +} + function splitColumnPath(raw: string): string[] { if (!raw) syntaxViolation('column path is empty'); const segments: string[] = []; @@ -221,6 +235,11 @@ function parseOperator(expression: string): ParsedOperator { 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 }; } @@ -256,13 +275,13 @@ function parseFilterValue(path: string[], expression: string, budget: ParseBudge ? condition(path, comparator, parseList(parsed.operand, '{'), false, budget) : condition(path, 'ov', parseOperand(parsed.operand), false, budget); } else if (parsed.operator === 'cs') { - const values = parseList(parsed.operand, '{'); + const values = parseContainmentValues('cs', parsed.operand); term = { operator: 'and', terms: values.map((value) => condition(path, 'eq', value, false, budget)), }; } else if (parsed.operator === 'cd') { - const values = parseList(parsed.operand, '{'); + 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') { @@ -318,17 +337,11 @@ function parseLogicLeaf(raw: string, budget: ParseBudget): Term { } if (quoted) syntaxViolation('unterminated quoted column path'); if (stack.length > 0) syntaxViolation('unbalanced operand delimiter'); - for (let candidate = candidateIndexes.length - 1; candidate >= 0; candidate--) { + 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])) { - const previousIndex = candidate > 0 ? candidateIndexes[candidate - 1] : -1; - if (previousIndex >= 0 && raw.slice(previousIndex + 1, index) === 'not') { - return parseFilterValue( - splitColumnPath(raw.slice(0, previousIndex)), raw.slice(previousIndex + 1), budget, - ); - } return parseFilterValue(splitColumnPath(raw.slice(0, index)), expression, budget); } } @@ -399,7 +412,10 @@ function parseOrder( let key = rawKey.trim(); if (!key) syntaxViolation('order contains an empty key'); if (NULL_ORDER_PATTERN.test(key)) { - if (unsupported(`null ordering '${key}'`, options)) { dropped = true; continue; } + if (unsupported(`null ordering '${key}'`, options)) { + key = key.replace(NULL_ORDER_PATTERN, ''); + dropped = true; + } } let direction: 'asc' | 'desc' = 'asc'; if (key.endsWith('.asc')) key = key.slice(0, -4); @@ -432,8 +448,12 @@ function parseInto( 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); @@ -464,6 +484,8 @@ export function parsePostgrest( ): ParseResult { const result: ParseResult = {}; try { + if (typeof search === 'string' && search.length > MAX_SEARCH_LENGTH) + syntaxViolation(`query exceeds the ${MAX_SEARCH_LENGTH}-character limit`); const parameters = typeof search === 'string' ? new URL_SEARCH_PARAMS(search.startsWith('?') ? search.slice(1) : search) : search; diff --git a/test/dialects/postgrest.test.ts b/test/dialects/postgrest.test.ts index 7e3b0ad..5491733 100644 --- a/test/dialects/postgrest.test.ts +++ b/test/dialects/postgrest.test.ts @@ -140,9 +140,14 @@ const vectors: { name: string; search: string; expected: ParseResult }[] = [ expected: grouped('or', cond(['a'], 'eq', 1), group('and', cond(['b'], 'eq', 2), cond(['c'], 'eq', 3))), }, { - name: 'logic leaves bind the rightmost viable operator after a colliding path segment', - search: 'or=(meta.like.eq.5,b.eq.2)', - expected: grouped('or', cond(['meta', 'like'], 'eq', 5), cond(['b'], 'eq', 2)), + 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)', @@ -200,6 +205,10 @@ const vectors: { name: string; search: string; expected: ParseResult }[] = [ 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')), @@ -439,6 +448,8 @@ describe('PostgREST input and shared-model behavior', () => { ['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'], ] as const; for (const [postgrest, core] of equivalentPairs) assert.deepEqual(parsePostgrest(postgrest), parseQuery(core)); @@ -474,10 +485,10 @@ describe('Unsupported PostgREST features', () => { ); }); - it('drop removes unsupported order decorations only when explicitly requested', () => { + it('drop removes only unsupported null placement and preserves the order key', () => { assert.deepEqual( - parsePostgrest('order=id,age.nullsfirst', { onUnsupported: 'drop' }), - { sort: [sort(['id'])] }, + parsePostgrest('order=id,age.desc.nullsfirst', { onUnsupported: 'drop' }), + { sort: [sort(['id']), sort(['age'], 'desc')] }, ); }); @@ -495,10 +506,10 @@ describe('Unsupported PostgREST features', () => { ); }); - it('drop cannot erase every order key', () => { - assert.throws( - () => parsePostgrest('order=age.nullsfirst', { 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'])] }, ); }); @@ -508,6 +519,14 @@ describe('Unsupported PostgREST features', () => { 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); + }); }); describe('PostgREST syntax and resource bounds', () => { @@ -523,6 +542,8 @@ describe('PostgREST syntax and resource bounds', () => { '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) { @@ -551,4 +572,8 @@ describe('PostgREST syntax and resource bounds', () => { 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); + }); }); From 7aea2dc60082f2f709bd1f97e6a80c519a91b6db Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Wed, 2 Sep 2026 07:01:45 -0600 Subject: [PATCH 08/11] Address PostgREST review edge cases Co-Authored-By: GPT-5 Codex --- src/dialects/postgrest.ts | 41 +++++++++++++-------- test/dialects/postgrest.test.ts | 65 +++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 16 deletions(-) diff --git a/src/dialects/postgrest.ts b/src/dialects/postgrest.ts index 2b4920a..e058bcd 100644 --- a/src/dialects/postgrest.ts +++ b/src/dialects/postgrest.ts @@ -42,9 +42,11 @@ 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?: URLSearchParamsConstructor; }).URLSearchParams; type ParseBudget = { terms: number }; @@ -149,7 +151,7 @@ function interpretDecodedValue(token: string): Value { if (token === 'true') return true; if (token === 'false') return false; const number = +token; - if (token !== '' && !isNaN(number) && String(number) === token) return number; + if (token !== '' && Number.isFinite(number) && String(number) === token) return number; return token; } @@ -165,8 +167,6 @@ function parseList(raw: string, open: '(' | '{'): Value[] { const inner = raw.slice(1, -1); if (inner === '') return []; const parts = splitTopLevel(inner, MAX_LIST_VALUES); - if (parts.length > MAX_LIST_VALUES) - syntaxViolation(`value list exceeds the ${MAX_LIST_VALUES}-value limit`); return parts.map(parseOperand); } @@ -177,8 +177,12 @@ function parseModifierValues(raw: string): Value[] { } function parseContainmentValues(operator: string, raw: string): Value[] { - if (!raw.startsWith('{') || !raw.endsWith('}') - || (/^\{\s*"/.test(raw) && includesUnquoted(raw.slice(1, -1), ':'))) + 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, '{'); } @@ -272,7 +276,7 @@ function parseFilterValue(path: string[], expression: string, budget: ParseBudge term = condition(path, comparator, parseList(parsed.operand, '('), false, budget); } else if (parsed.operator === 'ov') { term = parsed.operand.startsWith('{') - ? condition(path, comparator, parseList(parsed.operand, '{'), false, budget) + ? 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); @@ -387,6 +391,9 @@ function parseSelect( let feature: string | undefined; if (includesUnquoted(field, '::')) feature = `projection cast '${field}'`; else if (includesUnquoted(field, ':')) feature = `projection alias '${field}'`; + else if (AGGREGATE_PROJECTION_PATTERN.test(field)) { + throw new UnsupportedFeature(`PostgREST feature 'projection aggregate (${field})' is unsupported`); + } else if (includesUnquoted(field, '(') || includesUnquoted(field, ')') || includesUnquoted(field, '!')) { throw new UnsupportedFeature(`PostgREST feature 'resource embedding (${field})' is unsupported`); } @@ -407,14 +414,14 @@ function parseOrder( raw: string, options: PostgrestOptions | undefined, budget: ParseBudget, ): SortKey[] { const keys: SortKey[] = []; - let dropped = false; 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, ''); - dropped = true; } } let direction: 'asc' | 'desc' = 'asc'; @@ -423,8 +430,6 @@ function parseOrder( useTerms(budget, 1); keys.push({ path: splitColumnPath(key), direction }); } - if (keys.length === 0 && dropped) - throw new UnsupportedFeature('PostgREST cannot drop every order key'); if (keys.length === 0) syntaxViolation('order cannot be empty'); return keys; } @@ -484,11 +489,15 @@ export function parsePostgrest( ): ParseResult { const result: ParseResult = {}; try { - if (typeof search === 'string' && search.length > MAX_SEARCH_LENGTH) - syntaxViolation(`query exceeds the ${MAX_SEARCH_LENGTH}-character limit`); - const parameters = typeof search === 'string' - ? new URL_SEARCH_PARAMS(search.startsWith('?') ? search.slice(1) : search) - : search; + 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; diff --git a/test/dialects/postgrest.test.ts b/test/dialects/postgrest.test.ts index 5491733..3afd90b 100644 --- a/test/dialects/postgrest.test.ts +++ b/test/dialects/postgrest.test.ts @@ -105,6 +105,10 @@ const vectors: { name: string; search: string; expected: ParseResult }[] = [ 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)), @@ -385,6 +389,14 @@ describe('PostgREST input and shared-model behavior', () => { 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'), @@ -434,6 +446,27 @@ describe('PostgREST input and shared-model behavior', () => { ); }); + 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'), @@ -450,6 +483,12 @@ describe('PostgREST input and shared-model behavior', () => { ['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)); @@ -470,6 +509,11 @@ describe('Unsupported PostgREST features', () => { ['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) { @@ -527,6 +571,27 @@ describe('Unsupported PostgREST features', () => { 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('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', () => { From a36f5b57a106e9e38e1304ef56a04689bad8c0ce Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Wed, 2 Sep 2026 07:09:39 -0600 Subject: [PATCH 09/11] Close PostgREST unsupported-feature gaps Co-Authored-By: GPT-5 Codex --- src/dialects/postgrest.ts | 19 ++++++++++++------- test/dialects/postgrest.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/dialects/postgrest.ts b/src/dialects/postgrest.ts index e058bcd..047f7a9 100644 --- a/src/dialects/postgrest.ts +++ b/src/dialects/postgrest.ts @@ -43,7 +43,7 @@ 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 AGGREGATE_PROJECTION_PATTERN = /(?:^|[.:])(?:sum|avg|count|min|max)\(\)(?=$|::)/; const URL_SEARCH_PARAMS = (globalThis as unknown as { URLSearchParams?: URLSearchParamsConstructor; @@ -171,9 +171,14 @@ function parseList(raw: string, open: '(' | '{'): Value[] { } function parseModifierValues(raw: string): Value[] { - if (raw.startsWith('{')) return parseList(raw, '{'); - if (raw.startsWith('(')) return parseList(raw, '('); - syntaxViolation('any/all modifier requires a value list'); + 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[] { @@ -389,11 +394,11 @@ function parseSelect( if (!field) syntaxViolation('select contains an empty field'); if (field === '*') { wildcard = true; continue; } let feature: string | undefined; - if (includesUnquoted(field, '::')) feature = `projection cast '${field}'`; - else if (includesUnquoted(field, ':')) feature = `projection alias '${field}'`; - else if (AGGREGATE_PROJECTION_PATTERN.test(field)) { + 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`); } diff --git a/test/dialects/postgrest.test.ts b/test/dialects/postgrest.test.ts index 3afd90b..7fadfe4 100644 --- a/test/dialects/postgrest.test.ts +++ b/test/dialects/postgrest.test.ts @@ -494,6 +494,11 @@ describe('PostgREST input and shared-model behavior', () => { 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); @@ -579,6 +584,24 @@ describe('Unsupported PostgREST features', () => { ); }); + 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('does not drop related ordering because it changes pagination semantics', () => { assert.throws( () => parsePostgrest('order=directors(last_name).desc', { onUnsupported: 'drop' }), From 84b223908ffee2adb8f530b1e528a675365e874e Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Wed, 2 Sep 2026 07:13:52 -0600 Subject: [PATCH 10/11] Reject undefined empty filter groups Co-Authored-By: GPT-5 Codex --- src/dialects/postgrest.ts | 6 ++++++ test/dialects/postgrest.test.ts | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/src/dialects/postgrest.ts b/src/dialects/postgrest.ts index 047f7a9..4f155d2 100644 --- a/src/dialects/postgrest.ts +++ b/src/dialects/postgrest.ts @@ -285,6 +285,8 @@ function parseFilterValue(path: string[], expression: string, budget: ParseBudge : 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)), @@ -298,6 +300,10 @@ function parseFilterValue(path: string[], expression: string, budget: ParseBudge 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)), diff --git a/test/dialects/postgrest.test.ts b/test/dialects/postgrest.test.ts index 7fadfe4..cd0b9f0 100644 --- a/test/dialects/postgrest.test.ts +++ b/test/dialects/postgrest.test.ts @@ -602,6 +602,15 @@ describe('Unsupported PostgREST features', () => { 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' }), From ab9462f36adda738ebdd323a6cda2363823500af Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Wed, 2 Sep 2026 08:55:11 -0600 Subject: [PATCH 11/11] Order the PostgREST dialect trunk-first and rename to parsePostgREST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read the file from its entry point down: parsePostgREST, then the query-parameter dispatch, then the per-parameter parsers, then operand and lexical helpers, under the section banners parser.ts already uses. No behavior change — the moved blocks are byte-identical. Rename the exported parser and options interface to match PostgREST's own capitalization, which the file's prose and the spec already use. Co-Authored-By: Claude Opus --- src/dialects/postgrest.ts | 638 +++++++++++++------------- test/dialects/postgrest-dist.test.mjs | 6 +- test/dialects/postgrest.test.ts | 82 ++-- 3 files changed, 370 insertions(+), 356 deletions(-) diff --git a/src/dialects/postgrest.ts b/src/dialects/postgrest.ts index 4f155d2..393a510 100644 --- a/src/dialects/postgrest.ts +++ b/src/dialects/postgrest.ts @@ -12,7 +12,7 @@ interface URLSearchParams { type URLSearchParamsConstructor = new (input?: string) => URLSearchParams; -export interface PostgrestOptions extends ParseOptions { +export interface PostgRESTOptions extends ParseOptions { onUnsupported?: 'throw' | 'drop'; } @@ -58,146 +58,184 @@ type ParsedOperator = { 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}`); } -function useTerms(budget: ParseBudget, count: number): void { - budget.terms += count; - if (budget.terms > MAX_TERMS) syntaxViolation(`query exceeds the ${MAX_TERMS}-term limit`); +// ── 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 matchingClose(open: string, close: string): boolean { - if (open === '{') return close === '}'; - if (open === '[') return close === ']' || close === ')'; - return close === ')' || close === ']'; +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 }; } -function splitTopLevel(input: string, maxParts = MAX_TERMS): string[] { - const parts: string[] = []; - const stack: string[] = []; - let quoted = false; - let escaped = false; - let start = 0; +// ── select, order, limit, offset ─────────────────────────────────────────── - 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; +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 (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 (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 (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; + 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 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; +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 }); } - return false; + if (keys.length === 0) syntaxViolation('order cannot be empty'); + return keys; } -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; - } - } +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 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; +function unsupported(feature: string, options: PostgRESTOptions | undefined): boolean { + if (options?.onUnsupported === 'drop') return true; + throw new UnsupportedFeature(`PostgREST feature '${feature}' is unsupported`); } -function parseOperand(raw: string): Value { - if (raw.startsWith('"')) return decodeQuoted(raw); - return interpretDecodedValue(raw); -} +// ── Logic groups ─────────────────────────────────────────────────────────── -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 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 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 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 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 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 splitColumnPath(raw: string): string[] { - if (!raw) syntaxViolation('column path is empty'); - const segments: string[] = []; +function parseLogicLeaf(raw: string, budget: ParseBudget): Term { + const candidateIndexes: number[] = []; + const stack: string[] = []; let quoted = false; let escaped = false; - let start = 0; for (let index = 0; index < raw.length; index++) { const character = raw[index]; if (quoted) { @@ -208,49 +246,29 @@ function splitColumnPath(raw: string): string[] { } if (character === '"') { quoted = true; - continue; + } 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); } - 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 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; + 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'); } -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 }; -} +// ── Filter terms ─────────────────────────────────────────────────────────── function parseFilterValue(path: string[], expression: string, budget: ParseBudget): Term { const parsed = parseOperator(expression); @@ -320,17 +338,105 @@ function parseFilterValue(path: string[], expression: string, budget: ParseBudge return term; } -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 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 parseLogicLeaf(raw: string, budget: ParseBudget): Term { - const candidateIndexes: number[] = []; - const stack: string[] = []; +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) { @@ -341,179 +447,87 @@ function parseLogicLeaf(raw: string, budget: ParseBudget): Term { } 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); + 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'); - 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'); -} - -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 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)) }; + const finalSegment = raw.slice(start); + if (!finalSegment) syntaxViolation('column path contains an empty segment'); + segments.push(finalSegment.startsWith('"') ? decodeQuoted(finalSegment) : finalSegment); + return segments; } -function unsupported(feature: string, options: PostgrestOptions | undefined): boolean { - if (options?.onUnsupported === 'drop') return true; - throw new UnsupportedFeature(`PostgREST feature '${feature}' is unsupported`); -} +function splitTopLevel(input: string, maxParts = MAX_TERMS): string[] { + const parts: string[] = []; + const stack: string[] = []; + let quoted = false; + let escaped = false; + let start = 0; -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; } + 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; } - 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, ''); - } + 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; } - 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 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 }; + 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 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)); +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; +} - const filter = filterFromTerms(terms); - if (filter) result.filter = filter; +function matchingClose(open: string, close: string): boolean { + if (open === '{') return close === '}'; + if (open === '[') return close === ']' || close === ')'; + return close === ')' || close === ']'; } -/** - * 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 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/test/dialects/postgrest-dist.test.mjs b/test/dialects/postgrest-dist.test.mjs index 25ba3fa..92e6c5a 100644 --- a/test/dialects/postgrest-dist.test.mjs +++ b/test/dialects/postgrest-dist.test.mjs @@ -5,9 +5,9 @@ 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.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'), { + assert.deepEqual(dialect.parsePostgREST('or=(a.eq.1,b.eq.2)&limit=5'), { filter: { operator: 'or', terms: [ @@ -20,7 +20,7 @@ describe('built PostgREST package surface', () => { }); it('does not export the dialect from the built Core entry point', () => { - assert.equal('parsePostgrest' in root, false); + 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 index cd0b9f0..b159c2f 100644 --- a/test/dialects/postgrest.test.ts +++ b/test/dialects/postgrest.test.ts @@ -1,7 +1,7 @@ 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 { parsePostgREST, UnsupportedFeature } from '../../src/dialects/postgrest.ts'; import type { Condition, ElementMatch, Group, ParseResult, Projection, SortKey, Value, } from '../../src/index.ts'; @@ -374,92 +374,92 @@ const vectors: { name: string; search: string; expected: ParseResult }[] = [ describe('PostgREST Appendix E conformance vectors', () => { for (const vector of vectors) { it(vector.name, () => { - assert.deepEqual(parsePostgrest(vector.search), vector.expected); + 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))); + 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'))); + 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), + 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'), + 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'), + 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'), + 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)'), + 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)'), + 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))'), + 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)'), + 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'), + parsePostgREST('meta.like=eq.5'), filtered(cond(['meta', 'like'], 'eq', 5)), ); assert.deepEqual( - parsePostgrest('or=(meta.like.eq.5,b.eq.1)'), + 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)'), + 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)), @@ -469,7 +469,7 @@ describe('PostgREST input and shared-model behavior', () => { it('allows an unquoted operand to end in a quote character', () => { assert.deepEqual( - parsePostgrest('title=eq.The+%22Best%22'), + parsePostgREST('title=eq.The+%22Best%22'), filtered(cond(['title'], 'eq', 'The "Best"')), ); }); @@ -491,16 +491,16 @@ describe('PostgREST input and shared-model behavior', () => { ['value=eq.01', 'value==01'], ] as const; for (const [postgrest, core] of equivalentPairs) - assert.deepEqual(parsePostgrest(postgrest), parseQuery(core)); + 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(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 }); + 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']); }); @@ -523,63 +523,63 @@ describe('Unsupported PostgREST features', () => { for (const [name, search] of unsupported) { it(`${name} throws UnsupportedFeature`, () => { - assert.throws(() => parsePostgrest(search), 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' }), + 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' }), + 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' }), + () => 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' }), + () => 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' }), + parsePostgREST('order=age.nullsfirst', { onUnsupported: 'drop' }), { sort: [sort(['age'])] }, ); }); it('drop never weakens an unsupported filter', () => { assert.throws( - () => parsePostgrest('status=is.unknown', { onUnsupported: 'drop' }), + () => parsePostgREST('status=is.unknown', { onUnsupported: 'drop' }), UnsupportedFeature, ); }); it('rejects unrepresentable JSON containment as UnsupportedFeature', () => { - assert.throws(() => parsePostgrest('metadata=cs.{%22tier%22:%22gold%22}'), 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); + assert.throws(() => parsePostgREST('period=cd.[1,10)'), UnsupportedFeature); }); it('names aggregate projection errors accurately', () => { assert.throws( - () => parsePostgrest('select=id,amount.sum()'), + () => parsePostgREST('select=id,amount.sum()'), (error: unknown) => error instanceof UnsupportedFeature && error.message.includes('aggregate'), ); }); @@ -591,7 +591,7 @@ describe('Unsupported PostgREST features', () => { 'select=id,total:amount.sum()::numeric', ]) { assert.throws( - () => parsePostgrest(search, { onUnsupported: 'drop' }), + () => parsePostgREST(search, { onUnsupported: 'drop' }), (error: unknown) => error instanceof UnsupportedFeature && error.message.includes('aggregate'), ); } @@ -599,28 +599,28 @@ describe('Unsupported PostgREST features', () => { 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); + 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); + 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', []))); + 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' }), + () => 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'), + () => parsePostgREST('tags=cs.{red,blue'), (error: unknown) => error instanceof QueryError && !(error instanceof UnsupportedFeature), ); }); @@ -645,32 +645,32 @@ describe('PostgREST syntax and resource bounds', () => { for (const search of hostileInputs) { it(`throws QueryError for ${search}`, () => { - assert.throws(() => parsePostgrest(search), QueryError); + 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); + 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); + 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); + 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); + 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); + assert.throws(() => parsePostgREST(`message=eq.${'x'.repeat(65_536)}`), QueryError); }); });