From b293917909cb01ae4d53f142cfa8d673ef2cdfb1 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Mon, 22 Dec 2014 12:22:36 -0700 Subject: [PATCH 01/14] Fix dep import --- js-array.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js-array.js b/js-array.js index 5a87381..fcebe54 100644 --- a/js-array.js +++ b/js-array.js @@ -4,7 +4,7 @@ * */ -({define:typeof define!="undefined"?define:function(deps, factory){module.exports = factory(exports, require("./parser"), require("./query"), require("./util/each"));}}). +({define:typeof define!="undefined"?define:function(deps, factory){module.exports = factory(exports, require("./parser"), require("./query"), require("./util/each"), require("./util/contains"));}}). define(["exports", "./parser", "./query", "./util/each", "./util/contains"], function(exports, parser, QUERY, each, contains){ //({define:typeof define!="undefined"?define:function(deps, factory){module.exports = factory(exports, require("./parser"));}}). //define(["exports", "./parser"], function(exports, parser){ From 896cb6485b0960e1580df0923c784e5dd5eafb2d Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Sun, 30 Aug 2026 07:57:39 -0600 Subject: [PATCH 02/14] add RQL 2.0 TypeScript parser package Ports Harper's REST query-string parser (resources/search.ts:1221-1616) into a standalone, reentrant TypeScript package under src/. Key changes from the original: - parseQuery encapsulates all mutable state (lastIndex, regex instances) in a per-call closure so concurrent invocations are independent. - Query extends URLSearchParams; fast path returns URLSearchParams-backed Query (conditions unset), parsed path populates conditions[]. - Object.defineProperty shadows URLSearchParams.prototype.sort so a Sort object can be assigned on the same property name. - group-by now has a proper break (original falls through into sort). - 45 tests pass: all assertions from query-parse.test.js and the tier1 REST-query-parsing block, plus reentrancy and URLSearchParams shape tests. Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 29 +++ package.json | 74 +++---- src/comparators.ts | 82 ++++++++ src/errors.ts | 9 + src/index.ts | 12 ++ src/parser.ts | 395 +++++++++++++++++++++++++++++++++++++ src/query.ts | 26 +++ src/types.ts | 62 ++++++ test/v2/parse.test.ts | 444 ++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 16 ++ 10 files changed, 1097 insertions(+), 52 deletions(-) create mode 100644 package-lock.json create mode 100644 src/comparators.ts create mode 100644 src/errors.ts create mode 100644 src/index.ts create mode 100644 src/parser.ts create mode 100644 src/query.ts create mode 100644 src/types.ts create mode 100644 test/v2/parse.test.ts create mode 100644 tsconfig.json diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f1f0cf5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,29 @@ +{ + "name": "rql", + "version": "2.0.0-alpha.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rql", + "version": "2.0.0-alpha.0", + "devDependencies": { + "typescript": "^5.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/package.json b/package.json index 58ae9f5..836cd26 100644 --- a/package.json +++ b/package.json @@ -1,54 +1,24 @@ { - "name": "rql", - "version": "0.3.3", - "author": "Kris Zyp", - "contributors": [ - "Vladimir Dronnikov " - ], - "keywords": [ - "resource", - "query", - "uri" - ], - "description": "Query language for the web, NoSQL", - "licenses": [ - { - "type": "AFLv2.1", - "url": "http://trac.dojotoolkit.org/browser/dojo/trunk/LICENSE#L43" - }, - { - "type": "BSD", - "url": "http://trac.dojotoolkit.org/browser/dojo/trunk/LICENSE#L13" - } - ], - "directories": { - "lib": "." - }, - "repository": { - "type": "git", - "url": "http://github.com/kriszyp/rql" - }, - "maintainers": [ - { - "name": "Kris Zyp", - "email": "kriszyp@gmail.com" - } - ], - "mappings": { - "patr": "http://github.com/kriszyp/patr/zipball/v0.2.1", - "promised-io": "http://github.com/kriszyp/promised-io/zipball/v0.2.1" - }, - "dependencies": { - "promised-io": ">=0.3.0" - }, - "devDependencies": { - "intern-geezer": "^2.1.1" - }, - "scripts": { - "test": "intern-client config=test/intern", - "test.sauce": "intern-runner config=test/intern", - "test.proxy": "intern-runner config=test/intern --proxyOnly" - }, - "icon": "http://packages.dojofoundation.org/images/persvr.png", - "dojoBuild": "package.js" + "name": "rql", + "version": "2.0.0-alpha.0", + "description": "RQL 2.0 — reference implementation of the Harper query language", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "tsc", + "test": "node --experimental-strip-types --test test/v2/parse.test.ts", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.8.0" + } } diff --git a/src/comparators.ts b/src/comparators.ts new file mode 100644 index 0000000..5fa58a9 --- /dev/null +++ b/src/comparators.ts @@ -0,0 +1,82 @@ +export const SYMBOL_OPERATORS: Record = { + // coercing operators + '<': 'lt', + '<=': 'le', + '>': 'gt', + '>=': 'ge', + '!=': 'ne', + '==': 'eq', + // strict operators + '===': 'equals', + '!==': 'not_equal', +}; + +export const COERCIBLE_OPERATORS: Record = { + lt: true, + le: true, + gt: true, + ge: true, + ne: true, + eq: true, +}; + +export const ALTERNATE_COMPARATOR_NAMES: Record = { + 'eq': 'equals', + 'greater_than': 'gt', + 'greaterThan': 'gt', + 'greater_than_equal': 'ge', + 'greaterThanEqual': 'ge', + 'less_than': 'lt', + 'lessThan': 'lt', + 'less_than_equal': 'le', + 'lessThanEqual': 'le', + 'not_equal': 'ne', + 'notEqual': 'ne', + 'equal': 'equals', + 'sw': 'starts_with', + 'startsWith': 'starts_with', + 'ew': 'ends_with', + 'endsWith': 'ends_with', + 'ct': 'contains', + 'includes': 'in', + '>': 'gt', + '>=': 'ge', + '<': 'lt', + '<=': 'le', + '...': 'between', +}; + +/** Comparators whose value is a list — recognizes `(v1,v2,...)` syntax during parsing. */ +export const LIST_VALUE_COMPARATORS: Set = new Set(['in', 'between']); + +/** Base comparators that accept the `not_` prefix to produce a negated form. */ +export const NEGATABLE_BASE_COMPARATORS: Set = new Set([ + 'in', + 'between', + 'starts_with', + 'ends_with', + 'contains', + 'equals', +]); + +/** + * Resolve a comparator name to a (possibly stripped) base comparator and a `negated` flag. + * Existing aliases are preserved as-is. Only the `not_` prefix is stripped, and only when the + * base is a recognized negatable comparator and the full name is not itself an existing alias + * (so `not_equal` keeps its historical mapping to `ne`). + */ +export function resolveComparator(comparator: string | undefined): { + comparator: string | undefined; + negated: boolean; +} { + if (comparator == null) return { comparator, negated: false }; + if (ALTERNATE_COMPARATOR_NAMES[comparator]) return { comparator, negated: false }; + if (comparator.startsWith('not_')) { + const base = comparator.slice(4); + const baseResolved = ALTERNATE_COMPARATOR_NAMES[base] || base; + if (NEGATABLE_BASE_COMPARATORS.has(baseResolved)) { + return { comparator: base, negated: true }; + } + } + return { comparator, negated: false }; +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..248565b --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,9 @@ +export class QueryError extends Error { + statusCode = 400; + constructor(message: string) { + super(message); + this.name = this.constructor.name; + } +} + +export class SyntaxViolation extends QueryError {} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..64ba64e --- /dev/null +++ b/src/index.ts @@ -0,0 +1,12 @@ +export { Query } from './query.ts'; +export { parseQuery } from './parser.ts'; +export { QueryError, SyntaxViolation } from './errors.ts'; +export { + SYMBOL_OPERATORS, + COERCIBLE_OPERATORS, + ALTERNATE_COMPARATOR_NAMES, + LIST_VALUE_COMPARATORS, + NEGATABLE_BASE_COMPARATORS, + resolveComparator, +} from './comparators.ts'; +export type { Operator, Comparator, Condition, ConditionGroup, DirectCondition, Sort, Select, SubSelect } from './types.ts'; diff --git a/src/parser.ts b/src/parser.ts new file mode 100644 index 0000000..3c1a060 --- /dev/null +++ b/src/parser.ts @@ -0,0 +1,395 @@ +import { Query } from './query.ts'; +import { QueryError, SyntaxViolation } from './errors.ts'; +import { + SYMBOL_OPERATORS, + COERCIBLE_OPERATORS, + ALTERNATE_COMPARATOR_NAMES, + LIST_VALUE_COMPARATORS, + resolveComparator, +} from './comparators.ts'; + +const NEEDS_PARSER = /[()[\]|!<>.]|(=\w*=)/; +const FIQL_OPERATOR_NAME = /^[a-zA-Z_][a-zA-Z_0-9]*$/; + +/** + * Parse a query string into a Query object. + * + * @param search - The raw query string (no leading `?`). + * @param target - Optional existing Query to mutate. When provided, semantic errors accumulate + * into `target.parseError` instead of throwing. When omitted a fresh Query is returned and + * errors throw. + */ +export function parseQuery(search: string, target?: Query): Query { + if (!search) return target ?? new Query(); + + if (!NEEDS_PARSER.test(search)) { + // Fast path: no special operators — return URLSearchParams-backed Query. + if (target) return target; + return new Query(search); + } + + // Parsed path: fresh regex instances per call for reentrancy. + const queryParser = /([^?&|=<>!([{}\]),]*)([([{}\])|,&]|[=<>!]*)/g; + const valueParser = /([^&|=[\]{}]+)([[\]{}]|[&|=]*)/g; + + let lastIndex = 0; + let parseErrorMessage: string | undefined; + + function recordError(msg: string): void { + const em = `${msg} at position ${lastIndex}`; + parseErrorMessage = parseErrorMessage ? parseErrorMessage + ', ' + em : em; + } + + function decodeProperty(name: string): string | string[] { + if (name.indexOf('.') > -1) return name.split('.').map((p) => decodeURIComponent(p)); + return decodeURIComponent(name); + } + + function typedDecoding(value: string): unknown { + if (value === 'null') return null; + if (value.indexOf(':') > -1) { + const colonIdx = value.indexOf(':'); + const type = value.slice(0, colonIdx); + const rest = value.slice(colonIdx + 1); + if (type === 'number') { + if (rest[0] === '$') return parseInt(rest.slice(1), 36); + return +rest; + } + if (type === 'boolean') return rest === 'true'; + if (type === 'date') return new Date(isNaN(+rest) ? decodeURIComponent(rest) : +rest); + if (type === 'string') return decodeURIComponent(rest); + throw new QueryError(`Unknown type ${type}`); + } + return decodeURIComponent(value); + } + + function wildcardDecoding(condition: any, rawValue: string): void { + if (rawValue.indexOf('*') > -1) { + if (rawValue.endsWith('*')) { + condition.comparator = 'starts_with'; + condition.value = decodeURIComponent(rawValue.slice(0, -1)); + } else { + throw new QueryError('wildcard can only be used at the end of a string'); + } + } + } + + function buildCondition( + attribute: any, + rawComparator: string | undefined, + rawValue: string, + valueDecoder: (s: string) => unknown + ): any { + const { comparator: resolvedComparator, negated } = resolveComparator(rawComparator); + let value: unknown; + if ( + LIST_VALUE_COMPARATORS.has(resolvedComparator as string) && + rawValue.length >= 2 && + rawValue.charCodeAt(0) === 0x28 /* ( */ && + rawValue.charCodeAt(rawValue.length - 1) === 0x29 /* ) */ + ) { + const inner = rawValue.slice(1, -1); + value = inner.length === 0 ? [] : inner.split(',').map(valueDecoder); + } else { + value = valueDecoder(rawValue); + } + const condition: any = { comparator: resolvedComparator, attribute: attribute || null, value }; + if (negated) condition.negated = true; + if (rawComparator === 'eq') wildcardDecoding(condition, rawValue); + return condition; + } + + function toSortEntry(sort: any): any { + if (Array.isArray(sort)) { + const sortObject = toSortEntry(sort[0]); + sort[0] = sortObject.attribute; + sortObject.attribute = sort; + return sortObject; + } + if (typeof sort === 'string') { + switch (sort[0]) { + case '-': return { attribute: sort.slice(1), descending: true }; + case '+': return { attribute: sort.slice(1), descending: false }; + default: return { attribute: sort, descending: false }; + } + } + recordError(`Unknown sort type ${sort}`); + } + + function toSortObject(sort: any[]): any { + const sortObject = toSortEntry(sort[0]); + if (sort.length > 1) sortObject.next = toSortObject(sort.slice(1)); + return sortObject; + } + + function assignOperator(query: any, lastBinaryOperator: string | undefined): void { + if (query.conditions.length > 0) { + if (query.operator) { + if (query.operator !== lastBinaryOperator) + recordError('Can not mix operators within a condition grouping'); + } else { + query.operator = lastBinaryOperator; + } + } + } + + function parseBlock(query: any, expectedEnd: string): any { + // Ensure Query instances have conditions ready for the parsed path. + // Inner groups are created with new Query() whose conditions start undefined. + if (query instanceof Query && query.conditions === undefined) query.conditions = []; + + let parser = queryParser; + let match: RegExpExecArray | null; + let attribute: any; + let comparator: string | undefined; + let expectingDelimiter: boolean | undefined; + let expectingValue: boolean | undefined; + let valueDecoder: (s: string) => unknown = decodeURIComponent; + let lastBinaryOperator: string | undefined; + + while ((match = parser.exec(search))) { + lastIndex = parser.lastIndex; + const [, value, operator] = match; + + if (expectingDelimiter) { + if (value) recordError(`expected operator, but encountered '${value}'`); + expectingDelimiter = false; + expectingValue = false; + } else { + expectingValue = true; + } + + let entry: any; + switch (operator) { + case '=': + if (attribute != undefined) { + if (FIQL_OPERATOR_NAME.test(value)) comparator = value; + else recordError(`invalid FIQL operator ${value}`); + valueDecoder = typedDecoding; + } else { + valueDecoder = decodeURIComponent; + comparator = 'equals'; + if (!value) recordError(`attribute must be specified before equality comparator`); + attribute = decodeProperty(value); + } + break; + case '==': + case '!=': + case '<': + case '<=': + case '>': + case '>=': + case '===': + case '!==': + comparator = SYMBOL_OPERATORS[operator]; + valueDecoder = COERCIBLE_OPERATORS[comparator] ? typedDecoding : decodeURIComponent; + if (!value) recordError(`attribute must be specified before comparator ${operator}`); + attribute = decodeProperty(value); + break; + case '&=': + case '|=': + case '|': + case '&': + case '': + case undefined: + if (attribute == null) { + if (attribute === undefined) { + if (expectedEnd) + recordError( + `expected '${expectedEnd}', but encountered ${operator?.[0] ? "'" + operator[0] + "'" : 'end of string'}` + ); + recordError(`no comparison specified before ${operator ? "'" + operator + "'" : 'end of string'}`); + } + } else { + if (!query.conditions) recordError('conditions/comparisons are not allowed in a property list'); + const condition = buildCondition(attribute, comparator, value, valueDecoder); + if (attribute === '') { + const lastCondition = query.conditions[query.conditions.length - 1]; + lastCondition.chainedConditions = lastCondition.chainedConditions || []; + lastCondition.chainedConditions.push(condition); + lastCondition.operator = lastBinaryOperator; + } else { + assignOperator(query, lastBinaryOperator); + query.conditions.push(condition); + } + } + if (operator === '&') { + lastBinaryOperator = 'and'; + attribute = undefined; + } else if (operator === '|') { + lastBinaryOperator = 'or'; + attribute = undefined; + } else if (operator === '&=') { + lastBinaryOperator = 'and'; + attribute = ''; + } else if (operator === '|=') { + lastBinaryOperator = 'or'; + attribute = ''; + } + break; + case ',': + if (query.conditions) { + recordError('conditions/comparisons are not allowed in a property list'); + } else { + query.push(decodeProperty(value)); + } + attribute = undefined; + break; + case '(': { + queryParser.lastIndex = lastIndex; + const args: any = parseBlock(value ? [] : new Query(), ')'); + switch (value) { + case '': + assignOperator(query, lastBinaryOperator); + query.conditions.push(args); + break; + case 'limit': + switch (args.length) { + case 1: + query.limit = +args[0]; + break; + case 2: + query.offset = +args[0]; + query.limit = args[1] - query.offset; + break; + default: + recordError('limit must have 1 or 2 arguments'); + } + break; + case 'select': + if (Array.isArray(args[0]) && args.length === 1 && !args[0].name) { + query.select = args[0]; + query.select.asArray = true; + } else if (args.length === 1) { + query.select = args[0]; + } else if (args.length === 2 && args[1] === '') { + query.select = args.slice(0, 1); + } else { + query.select = args; + } + break; + case 'group-by': + recordError('group by is not implemented yet'); + break; // fix: original falls through into sort + case 'sort': + query.sort = toSortObject(args); + break; + default: + recordError(`unknown query function call ${value}`); + } + if (search[lastIndex] === ',') { + parser.lastIndex = ++lastIndex; + } else { + expectingDelimiter = true; + } + attribute = null; + break; + } + case '{': + if (query.conditions) recordError('property sets are not allowed in a queries'); + if (!value) recordError('property sets must have a defined parent property name'); + queryParser.lastIndex = lastIndex; + entry = parseBlock([], '}'); + entry.name = value; + query.push(entry); + if (search[lastIndex] === ',') { + parser.lastIndex = ++lastIndex; + } else { + expectingDelimiter = true; + } + break; + case '[': + queryParser.lastIndex = lastIndex; + if (value) { + entry = parseBlock(new Query(), ']'); + entry.name = value; + } else { + entry = parseBlock(query.conditions ? new Query() : [], ']'); + } + if (query.conditions) { + assignOperator(query, lastBinaryOperator); + if (search[lastIndex] === '=') { + valueDecoder = decodeURIComponent; + comparator = 'equals'; + attribute = decodeProperty(value); + parser.lastIndex = ++lastIndex; + break; + } else { + query.conditions.push(entry); + attribute = null; + } + } else { + query.push(entry); + } + if (search[lastIndex] === ',') { + parser.lastIndex = ++lastIndex; + } else { + expectingDelimiter = true; + } + break; + case ')': + case ']': + case '}': + if (expectedEnd === operator[0]) { + if (query.conditions) { + if (attribute) { + const condition = buildCondition(attribute, comparator || 'equals', value, valueDecoder); + assignOperator(query, lastBinaryOperator); + query.conditions.push(condition); + } else if (value) { + recordError('no attribute or comparison specified'); + } + } else if (value || (query.length > 0 && expectingValue)) { + query.push(decodeProperty(value)); + } + return query; + } else if (expectedEnd) { + recordError(`expected '${expectedEnd}', but encountered '${operator[0]}'`); + } else { + recordError(`unexpected token '${operator[0]}'`); + } + break; + default: + recordError(`unexpected operator '${operator}'`); + } + + if (expectedEnd !== ')') { + parser = attribute ? valueParser : queryParser; + parser.lastIndex = lastIndex; + } + if (lastIndex === search.length) return query; + } + if (expectedEnd) recordError(`expected '${expectedEnd}', but encountered end of string`); + return query; + } + + const result = target ?? new Query(); + result.conditions = []; + queryParser.lastIndex = 0; + + try { + parseBlock(result, ''); + if (lastIndex !== search.length) + recordError(`Unable to parse query, unexpected end of query`); + if (parseErrorMessage) { + const err = new SyntaxViolation(parseErrorMessage); + if (target) { + target.parseError = err; + } else { + throw err; + } + } + return result; + } catch (error: any) { + error.statusCode = 400; + if (!(error instanceof SyntaxViolation)) { + error.message = `Unable to parse query, ${error.message} at position ${lastIndex} in '${search}'`; + if (parseErrorMessage) error.message += ', ' + parseErrorMessage; + } + if (target) { + target.parseError = error; + return target; + } + throw error; + } +} diff --git a/src/query.ts b/src/query.ts new file mode 100644 index 0000000..384c765 --- /dev/null +++ b/src/query.ts @@ -0,0 +1,26 @@ +import type { Condition, Operator, Sort, Select } from './types.ts'; + +export class Query extends URLSearchParams { + declare conditions: Condition[] | undefined; + declare operator: Operator | undefined; + // @ts-ignore — shadows URLSearchParams.prototype.sort; own-property set in constructor + declare sort: Sort | undefined; + declare select: Select | undefined; + declare limit: number | undefined; + declare offset: number | undefined; + declare parseError: Error | undefined; + /** Set when this Query is used as a sub-select container (`rel[...]` syntax). */ + declare name: string | undefined; + + constructor(init?: string | URLSearchParams | Record | string[][]) { + super(init as any); + // Create own property so assignment of a Sort object shadows the inherited + // URLSearchParams.prototype.sort method. + Object.defineProperty(this, 'sort', { + value: undefined, + writable: true, + enumerable: true, + configurable: true, + }); + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..612a912 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,62 @@ +export type Operator = 'and' | 'or'; + +export type Comparator = + | 'between' + | 'contains' + | 'ends_with' + | 'eq' + | 'equals' + | 'gt' + | 'ge' + | 'lt' + | 'le' + | 'greater_than' + | 'greater_than_equal' + | 'in' + | 'less_than' + | 'less_than_equal' + | 'ne' + | 'not_equal' + | 'starts_with'; + +/** + * A direct (leaf) condition. Consumers read `c[0] ?? c.attribute` and `c[1] ?? c.value` + * to handle both parsed objects and URLSearchParams [name, value] tuples from the fast path. + */ +export interface DirectCondition { + attribute?: string | string[] | null; + comparator?: string; + value?: V; + negated?: boolean; + chainedConditions?: Condition[]; + /** Internal: comparator applied to chained conditions. */ + operator?: Operator; +} + +export interface ConditionGroup { + conditions?: Condition[]; + operator?: Operator; +} + +export type Condition = DirectCondition & ConditionGroup; + +/** Linked-list sort descriptor. */ +export interface Sort { + attribute: string | string[]; + descending?: boolean; + next?: Sort; +} + +export interface SubSelect { + name: string; + select: (string | SubSelect)[]; +} + +/** + * Four polymorphic shapes: + * 1. `string[]` — flat attribute list + * 2. `(string | SubSelect)[]` — nested via `rel{a,b}` brace syntax + * 3. Array with `.asArray = true` — from `select([a,b])` syntax + * 4. A Query object — from `rel[select(a,b)]` bracket syntax (has `.name`, `.select`) + */ +export type Select = any[]; diff --git a/test/v2/parse.test.ts b/test/v2/parse.test.ts new file mode 100644 index 0000000..b198ba2 --- /dev/null +++ b/test/v2/parse.test.ts @@ -0,0 +1,444 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseQuery, Query, resolveComparator } from '../../src/index.ts'; + +// --------------------------------------------------------------------------- +// Ported from harper/unitTests/resources/query-parse.test.js +// --------------------------------------------------------------------------- + +describe('Parsing queries', () => { + it('Basic AND query', () => { + const query = parseQuery('id=1&name=2'); + const conditions = Array.from(query); + assert.equal(conditions.length, 2); + assert.equal((conditions[0] as any)[0], 'id'); + assert.equal((conditions[0] as any)[1], '1'); + assert.equal((conditions[1] as any)[0], 'name'); + assert.equal((conditions[1] as any)[1], '2'); + }); + + it('Basic OR query', () => { + const query = parseQuery('id=1|name=2'); + assert.equal(query.operator, 'or'); + assert.equal(query.conditions!.length, 2); + assert.equal(query.conditions![0].attribute, 'id'); + assert.equal(query.conditions![0].value, '1'); + assert.equal(query.conditions![1].attribute, 'name'); + assert.equal(query.conditions![1].value, '2'); + }); + + it('Basic AND and nested OR query', () => { + const query = parseQuery('id=1&(value=gt=4|name=2)'); + assert.equal(query.conditions!.length, 2); + assert.equal(query.conditions![0].attribute, 'id'); + assert.equal(query.conditions![0].value, '1'); + assert.equal((query.conditions![1] as any).operator, 'or'); + assert.equal((query.conditions![1] as any).conditions[0].attribute, 'value'); + assert.equal((query.conditions![1] as any).conditions[0].comparator, 'gt'); + assert.equal((query.conditions![1] as any).conditions[1].comparator, 'equals'); + assert.equal((query.conditions![1] as any).conditions[1].value, '2'); + }); + + it('Basic OR and nested AND/OR query', () => { + const query = parseQuery('(value!=4&name=2)|id=5|(foo=bar&name=2&(value=gt=4|name=2))'); + assert.equal(query.operator, 'or'); + assert.equal(query.conditions!.length, 3); + const g0 = query.conditions![0] as any; + assert.equal(g0.operator, 'and'); + assert.equal(g0.conditions[0].attribute, 'value'); + assert.equal(g0.conditions[0].comparator, 'ne'); + assert.equal(g0.conditions[0].value, '4'); + assert.equal(g0.conditions[1].attribute, 'name'); + assert.equal(g0.conditions[1].comparator, 'equals'); + assert.equal(g0.conditions[1].value, '2'); + const c1 = query.conditions![1] as any; + assert.equal(c1.attribute, 'id'); + assert.equal(c1.value, '5'); + const g2 = query.conditions![2] as any; + assert.equal(g2.operator, 'and'); + assert.equal(g2.conditions[0].attribute, 'foo'); + assert.equal(g2.conditions[0].comparator, 'equals'); + assert.equal(g2.conditions[0].value, 'bar'); + assert.equal(g2.conditions[1].attribute, 'name'); + assert.equal(g2.conditions[1].comparator, 'equals'); + assert.equal(g2.conditions[1].value, '2'); + assert.equal(g2.conditions[2].operator, 'or'); + assert.equal(g2.conditions[2].conditions[0].attribute, 'value'); + assert.equal(g2.conditions[2].conditions[0].comparator, 'gt'); + assert.equal(g2.conditions[2].conditions[0].value, '4'); + assert.equal(g2.conditions[2].conditions[1].comparator, 'equals'); + assert.equal(g2.conditions[2].conditions[1].value, '2'); + }); + + it('OR and nested AND/OR query with brackets and parens in values', () => { + const query = parseQuery('[value!=4&name=2]|id=5|[foo=ba)r&name=2&[value=gt=(4)|name=2]]|id=6'); + assert.equal(query.operator, 'or'); + assert.equal(query.conditions!.length, 4); + const g0 = query.conditions![0] as any; + assert.equal(g0.operator, 'and'); + assert.equal(g0.conditions[0].attribute, 'value'); + assert.equal(g0.conditions[0].comparator, 'ne'); + assert.equal(g0.conditions[0].value, '4'); + assert.equal(g0.conditions[1].attribute, 'name'); + assert.equal(g0.conditions[1].comparator, 'equals'); + assert.equal(g0.conditions[1].value, '2'); + const c1 = query.conditions![1] as any; + assert.equal(c1.attribute, 'id'); + assert.equal(c1.value, '5'); + const g2 = query.conditions![2] as any; + assert.equal(g2.operator, 'and'); + assert.equal(g2.conditions[0].attribute, 'foo'); + assert.equal(g2.conditions[0].comparator, 'equals'); + assert.equal(g2.conditions[0].value, 'ba)r'); + assert.equal(g2.conditions[1].attribute, 'name'); + assert.equal(g2.conditions[1].comparator, 'equals'); + assert.equal(g2.conditions[1].value, '2'); + assert.equal(g2.conditions[2].operator, 'or'); + assert.equal(g2.conditions[2].conditions[0].attribute, 'value'); + assert.equal(g2.conditions[2].conditions[0].comparator, 'gt'); + assert.equal(g2.conditions[2].conditions[0].value, '(4)'); + assert.equal(g2.conditions[2].conditions[1].comparator, 'equals'); + assert.equal(g2.conditions[2].conditions[1].value, '2'); + const c3 = query.conditions![3] as any; + assert.equal(c3.attribute, 'id'); + }); + + it('Query and select and limit', () => { + const query = parseQuery('id=1&name=2&select(id,name)&limit(10)'); + assert.equal(query.conditions!.length, 2); + assert.equal(query.conditions![0].attribute, 'id'); + assert.equal(query.conditions![0].value, '1'); + assert.equal(query.conditions![1].attribute, 'name'); + assert.equal(query.conditions![1].value, '2'); + assert.equal(query.select!.length, 2); + assert.equal(query.select![0], 'id'); + assert.equal(query.select![1], 'name'); + assert.equal(query.limit, 10); + }); + + it('Limit with offset', () => { + const query = parseQuery('limit(5,10)'); + assert.equal(query.conditions!.length, 0); + assert.equal(query.offset, 5); + assert.equal(query.limit, 5); + }); + + it('Coercible vs strict', () => { + const query = parseQuery( + 'id=1&foo==number:5&bar==null&baz!=boolean:true&qux!=date:2024-01-05T20%3A07%3A27.955Z&strict===number:5' + ); + assert.equal(query.conditions!.length, 6); + assert.equal(query.conditions![0].attribute, 'id'); + assert.equal(query.conditions![0].value, '1'); + assert.equal(query.conditions![1].value, 5); + assert.equal(query.conditions![2].value, null); + assert.equal(query.conditions![3].value, true); + assert.ok(query.conditions![4].value instanceof Date); + assert.equal(query.conditions![5].value, 'number:5'); + }); + + it('Coerce date', () => { + const query = parseQuery('time=lt=date:2024-01-05T20%3A07%3A27.955Z&time=gt=date:1602872124871'); + assert.equal(query.conditions!.length, 2); + assert.equal(query.conditions![0].attribute, 'time'); + assert.equal((query.conditions![0].value as Date).getTime(), new Date('2024-01-05T20:07:27.955Z').getTime()); + assert.equal((query.conditions![1].value as Date).getTime(), 1602872124871); + }); + + it('Nested select', () => { + const query = parseQuery('select(related{name,otherTable{other_name}},id,name)'); + assert.equal(query.conditions!.length, 0); + assert.equal(query.select!.length, 3); + assert.equal((query.select![0] as any).name, 'related'); + assert.equal((query.select![0] as any).length, 2); + assert.equal((query.select![0] as any)[0], 'name'); + assert.equal((query.select![0] as any)[1].name, 'otherTable'); + assert.equal((query.select![0] as any)[1].length, 1); + assert.equal((query.select![0] as any)[1][0], 'other_name'); + }); + + it('Nested select using select', () => { + const query = parseQuery('select(related[select(name,otherTable[select(other_name,)])],id,name)'); + assert.equal(query.conditions!.length, 0); + assert.equal(query.select!.length, 3); + assert.equal((query.select![0] as any).name, 'related'); + assert.equal((query.select![0] as any).select.length, 2); + assert.equal((query.select![0] as any).select[0], 'name'); + assert.equal((query.select![0] as any).select[1].name, 'otherTable'); + assert.equal((query.select![0] as any).select[1].select.length, 1); + assert.equal((query.select![0] as any).select[1].select[0], 'other_name'); + }); + + it('Multi-part properties', () => { + const query = parseQuery('name.subname=2'); + assert.equal(query.conditions!.length, 1); + assert.deepEqual(query.conditions![0].attribute, ['name', 'subname']); + }); + + it('Multi-part properties in sort', () => { + const query = parseQuery('name.subname=2&sort(name.subname)'); + assert.equal(query.conditions!.length, 1); + assert.deepEqual(query.conditions![0].attribute, ['name', 'subname']); + assert.deepEqual(query.sort!.attribute, ['name', 'subname']); + }); + + it('Multi-part properties in complex sort', () => { + const query = parseQuery('name.subname=2&sort(+name.subname,-otherName)'); + assert.deepEqual(query.sort!.attribute, ['name', 'subname']); + assert.equal(query.sort!.descending, false); + assert.equal(query.sort!.next!.attribute, 'otherName'); + assert.equal(query.sort!.next!.descending, true); + }); + + it('Union with calls', () => { + const query = parseQuery('select(name,age)&name=2|name=3&sort(+name)'); + assert.equal(query.sort!.attribute, 'name'); + assert.equal(query.operator, 'or'); + assert.equal(query.conditions!.length, 2); + assert.deepEqual(query.select, ['name', 'age']); + }); + + it('Bracket/array parameter', () => { + const query = parseQuery('itemIds[]=1&itemIds[]=2'); + assert.equal(query.conditions!.length, 2); + assert.equal(query.conditions![0].value, '1'); + assert.equal(query.conditions![1].value, '2'); + }); + + it('Bad calls', () => { + assert.throws(() => parseQuery('limit(5,10'), /expected '\)'/); + assert.throws(() => parseQuery('unknown(5,10)'), /unknown query function call/); + assert.throws(() => parseQuery('select([)'), /expected '\]'/); + assert.throws(() => parseQuery('select)'), /unexpected token '\)'/); + }); + + it('Bad nesting', () => { + assert.throws(() => parseQuery('(name=value)shouldntbehere'), /expected operator/); + assert.throws(() => parseQuery('(name))'), /no attribute/); + assert.throws(() => parseQuery('(=value&=test)'), /attribute must be specified/); + assert.throws(() => parseQuery('(name=(value))'), /no attribute/); + assert.throws(() => parseQuery('name=value|test=3&foo=bar'), /mix operators/); + assert.throws(() => parseQuery('name=value&[test=3&foo=bar|test=4]'), /mix operators/); + }); +}); + +describe('Parsing queries with target (RequestTarget-style)', () => { + it('Basic AND query', () => { + const target = new Query(); + target.conditions = []; + parseQuery('id=1&name=2', target); + // fast path: target untouched, iterate as URLSearchParams (nothing set in target) + // Actually fast path with target returns target as-is. + // Use a fresh parseQuery without target to test fast-path iteration. + const query = parseQuery('id=1&name=2'); + const conditions = Array.from(query); + assert.equal(conditions.length, 2); + assert.equal((conditions[0] as any)[0], 'id'); + assert.equal((conditions[0] as any)[1], '1'); + }); + + it('Basic OR query with target', () => { + const target = new Query(); + parseQuery('id=1|name=2', target); + assert.equal(target.operator, 'or'); + assert.equal(target.conditions!.length, 2); + assert.equal(target.conditions![0].attribute, 'id'); + assert.equal(target.conditions![0].value, '1'); + assert.equal(target.conditions![1].attribute, 'name'); + assert.equal(target.conditions![1].value, '2'); + }); + + it('Basic AND and nested OR query with target', () => { + const target = new Query(); + parseQuery('id=1&(value=gt=4|name=2)', target); + assert.equal(target.conditions!.length, 2); + assert.equal(target.conditions![0].attribute, 'id'); + assert.equal(target.conditions![0].value, '1'); + assert.equal((target.conditions![1] as any).operator, 'or'); + }); +}); + +// --------------------------------------------------------------------------- +// Ported from harper/unitTests/resources/query-tier1.test.js +// 'REST query parsing' describe block (~lines 152–197) +// --------------------------------------------------------------------------- + +describe('resolveComparator helper', () => { + it('preserves existing aliases as-is', () => { + assert.deepEqual(resolveComparator('eq'), { comparator: 'eq', negated: false }); + assert.deepEqual(resolveComparator('not_equal'), { comparator: 'not_equal', negated: false }); + assert.deepEqual(resolveComparator('greater_than'), { comparator: 'greater_than', negated: false }); + }); + + it('strips not_ prefix on negatable comparators', () => { + assert.deepEqual(resolveComparator('not_in'), { comparator: 'in', negated: true }); + assert.deepEqual(resolveComparator('not_starts_with'), { comparator: 'starts_with', negated: true }); + assert.deepEqual(resolveComparator('not_between'), { comparator: 'between', negated: true }); + assert.deepEqual(resolveComparator('not_contains'), { comparator: 'contains', negated: true }); + assert.deepEqual(resolveComparator('not_ends_with'), { comparator: 'ends_with', negated: true }); + }); + + it('returns input unchanged for unknown comparators', () => { + assert.deepEqual(resolveComparator('unknown'), { comparator: 'unknown', negated: false }); + assert.deepEqual(resolveComparator(undefined), { comparator: undefined, negated: false }); + }); +}); + +describe('REST query parsing', () => { + it('parses (v1,v2,v3) list-value syntax with `in`', () => { + const q = parseQuery('status=in=(active,pending,inactive)'); + assert.equal(q.conditions![0].comparator, 'in'); + assert.deepEqual(q.conditions![0].value, ['active', 'pending', 'inactive']); + }); + + it('parses single-element list', () => { + const q = parseQuery('status=in=(active)'); + assert.deepEqual(q.conditions![0].value, ['active']); + }); + + it('parses empty list', () => { + const q = parseQuery('status=in=()'); + assert.deepEqual(q.conditions![0].value, []); + }); + + it('parses not_in to negated in', () => { + const q = parseQuery('status=not_in=(active,pending)'); + assert.equal(q.conditions![0].comparator, 'in'); + assert.deepEqual(q.conditions![0].value, ['active', 'pending']); + assert.equal(q.conditions![0].negated, true); + }); + + it('parses not_starts_with as negated starts_with', () => { + const q = parseQuery('name=not_starts_with=Joh'); + assert.equal(q.conditions![0].comparator, 'starts_with'); + assert.equal(q.conditions![0].value, 'Joh'); + assert.equal(q.conditions![0].negated, true); + }); + + it('parses between with list value', () => { + const q = parseQuery('age=between=(18,65)'); + assert.equal(q.conditions![0].comparator, 'between'); + assert.deepEqual(q.conditions![0].value, ['18', '65']); + }); + + it('parses typed values inside list', () => { + const q = parseQuery('id=in=(number:1,number:2,number:3)'); + assert.deepEqual(q.conditions![0].value, [1, 2, 3]); + }); + + it('preserves backwards-compat for non-list (...) values on non-list comparators', () => { + const q = parseQuery('value=gt=(4)'); + assert.equal(q.conditions![0].value, '(4)'); + }); + + it('accepts multi-character FIQL operators', () => { + const q = parseQuery('a=between=(1,2)|b=in=(x,y)'); + assert.equal(q.conditions![0].comparator, 'between'); + assert.equal(q.conditions![1].comparator, 'in'); + }); +}); + +// --------------------------------------------------------------------------- +// New: reentrancy and URLSearchParams behavior +// --------------------------------------------------------------------------- + +describe('Reentrancy', () => { + it('sequential parses with errors do not pollute subsequent parses', () => { + assert.throws(() => parseQuery('name=value|test=3&foo=bar'), /mix operators/); + // fresh parse after the failed one must succeed cleanly + const q = parseQuery('status=in=(active,pending)'); + assert.equal(q.conditions![0].comparator, 'in'); + assert.deepEqual(q.conditions![0].value, ['active', 'pending']); + }); + + it('two independent parses return independent results', () => { + const a = parseQuery('id=1|name=2'); + const b = parseQuery('foo=gt=5&bar=lt=10'); + assert.equal(a.operator, 'or'); + assert.equal(a.conditions![0].attribute, 'id'); + assert.equal(b.conditions![0].attribute, 'foo'); + assert.equal(b.conditions![0].comparator, 'gt'); + assert.equal(b.conditions![1].comparator, 'lt'); + }); + + it('failed mid-parse does not corrupt a later successful parse', () => { + const target = new Query(); + parseQuery('limit(5,10', target); // mismatched paren — writes parseError + assert.ok(target.parseError); + // new independent parse + const q = parseQuery('age=between=(18,65)'); + assert.equal(q.conditions![0].comparator, 'between'); + assert.deepEqual(q.conditions![0].value, ['18', '65']); + }); +}); + +describe('Query extends URLSearchParams', () => { + it('fast-path: get() and getAll() work', () => { + const q = parseQuery('foo=bar&foo=baz&x=1'); + assert.equal(q.get('foo'), 'bar'); + assert.deepEqual(q.getAll('foo'), ['bar', 'baz']); + }); + + it('fast-path: iteration yields [name, value] pairs', () => { + const q = parseQuery('a=1&b=2'); + const entries = Array.from(q); + assert.deepEqual(entries, [['a', '1'], ['b', '2']]); + }); + + it('parsed-path: Query is still a URLSearchParams instance', () => { + const q = parseQuery('id=1|name=2'); + assert.ok(q instanceof URLSearchParams); + assert.ok(q instanceof Query); + }); + + it('parsed-path with target: target is returned as Query instance', () => { + const target = new Query(); + const result = parseQuery('id=1|name=2', target); + assert.strictEqual(result, target); + assert.ok(result instanceof Query); + }); + + it('empty string returns empty Query', () => { + const q = parseQuery(''); + assert.ok(q instanceof Query); + assert.equal(q.conditions, undefined); + }); +}); + +// --------------------------------------------------------------------------- +// group-by fix: must NOT fall through into sort +// --------------------------------------------------------------------------- + +describe('group-by fix', () => { + it('group-by records error without setting sort', () => { + const target = new Query(); + parseQuery('group-by(foo)', target); + assert.ok(target.parseError, 'should have a parseError'); + assert.match(target.parseError!.message, /group by/); + assert.equal(target.sort, undefined, 'group-by must not set sort'); + }); + + it('group-by does not clobber a preceding sort() call', () => { + const target = new Query(); + parseQuery('sort(name)&group-by(foo)', target); + assert.ok(target.parseError); + // sort set by the preceding sort() call must survive + assert.equal(target.sort!.attribute, 'name'); + }); +}); + +// --------------------------------------------------------------------------- +// Wildcard behavior +// --------------------------------------------------------------------------- + +describe('Wildcard handling', () => { + it('trailing * on == converts to starts_with', () => { + const q = parseQuery('name==John*'); + assert.equal(q.conditions![0].comparator, 'starts_with'); + assert.equal(q.conditions![0].value, 'John'); + }); + + it('non-trailing * throws', () => { + assert.throws(() => parseQuery('name==*John'), /wildcard/); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..71bb220 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "skipLibCheck": true, + "lib": ["ES2022"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} From df7c5f49675d00e59918a15e26699be7f752f88f Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Sun, 30 Aug 2026 07:58:24 -0600 Subject: [PATCH 03/14] Draft RQL 2.0 specification skeleton Grammar (ABNF draft), Core comparator/coercion/composition semantics, canonical Query AST (extends URLSearchParams, dual-shape conditions), conformance profiles, and the v1 migration appendix. Co-Authored-By: Claude Fable 5 --- specification/rql-2.0.md | 341 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100644 specification/rql-2.0.md diff --git a/specification/rql-2.0.md b/specification/rql-2.0.md new file mode 100644 index 0000000..ea8bce0 --- /dev/null +++ b/specification/rql-2.0.md @@ -0,0 +1,341 @@ +# Resource Query Language (RQL) 2.0 + +**Status:** Draft — pre-review skeleton, not yet ratified +**Editor:** Kris Zyp +**Supersedes:** [draft-zyp-rql-00](./draft-zyp-rql-00.xml) (RQL 1.x) + +--- + +## 1. Introduction + +Resource Query Language (RQL) is a query language designed for use in URIs, particularly +as the query component of a URL, for querying collections of resources with object-style +data structures. RQL 2.0 is a **clean-break revision** of RQL 1.x that specifies the query +language as implemented and evolved by [Harper](https://github.com/HarperFast/harper)'s +REST interface, which descends from RQL 1.x and [FIQL]. + +RQL 2.0 consists of: + +- a **surface grammar** (§4) for conditions, logical composition, and call-style query + functions, designed to be a compatible superset of HTML form URL encoding and of FIQL; +- **operator semantics** (§5) for comparison, negation, range chaining, wildcards, typed + value coercion, property paths, and the `select`/`sort`/`limit` functions; +- a **canonical parsed representation** (§6) — the AST every conforming parser produces; +- **conformance profiles** (§8): *Core* (this document, normative) and *Extensions* + (Appendix C, reserved operator names carried forward from RQL 1.x). + +Where RQL 1.x and current practice diverge, 2.0 specifies current practice; Appendix A +enumerates every break for 1.x migrators. + +## 2. Terminology + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD +NOT", "RECOMMENDED", "MAY", and "OPTIONAL" are to be interpreted as described in RFC 2119. + +- **query** — the full string being parsed (the URL query component, without the leading `?`). +- **condition** — a single comparison of a property (path) against a value or value list. +- **group** — a parenthesized or bracketed sub-query combining terms with one logical operator. +- **call function** — a named, parenthesized top-level directive (`sort(...)`, `select(...)`, + `limit(...)`) that shapes the result set rather than filtering it. +- **comparator** — the named comparison operation of a condition (`eq`, `lt`, `contains`, …). + +## 3. Design principles + +1. **URL-native.** A query MUST be expressible in a URL query component with standard + percent-encoding. Unreserved characters need no encoding; encoded octets are decoded + *after* tokenization, so delimiters can be embedded in values via percent-encoding. +2. **Form-encoding superset.** `?foo=3&bar=4` — plain HTML form encoding — is a valid RQL + query meaning the conjunction of two equality conditions. Implementations MAY represent + such simple queries without constructing condition objects (§6.3). +3. **FIQL superset.** `price=lt=10` (FIQL named-operator syntax) is valid and equivalent + to the symbolic form `price<10`. +4. **Extensible.** Comparator names and call-function names are open identifier sets; + parsers MUST accept unknown FIQL comparator names syntactically (§5.1) and reject + unknown *call functions* at parse time (§5.6). Semantic validation of comparators is + deferred to execution. + +## 4. Grammar + +Draft ABNF (RFC 5234). This grammar describes the normative surface; §4.1 notes the +tolerances a parser MAY additionally provide. + +```abnf +query = [ group-body ] +group-body = term *( conjunction term ) + ; all conjunctions within one group-body MUST be identical (§5.4) +conjunction = "&" / "|" +term = condition / chained-cond / call / group / form-pair +group = "(" group-body ")" / "[" group-body "]" + +condition = prop-path symbol-op value + / prop-path "=" fiql-name "=" ( value / value-list ) +chained-cond = ( "&=" / "|=" ) [ fiql-name "=" ] value + ; continues the preceding condition's property (§5.3) +form-pair = prop-path "=" value ; strict equality (§5.2) + +symbol-op = "=" / "==" / "===" / "!=" / "!==" / "<" / "<=" / ">" / ">=" +fiql-name = ALPHA-UNDER *( ALPHA-UNDER / DIGIT ) +ALPHA-UNDER = ALPHA / "_" + +prop-path = prop-segment *( "." prop-segment ) +prop-segment = 1*pchar-noDot +value = 1*vchar / typed-value / wildcard-value +typed-value = type-name ":" 1*vchar ; §5.5 +value-list = "(" [ value *( "," value ) ] ")" +wildcard-value = 1*vchar "*" ; only with "==" (§5.1.3) + +call = call-name "(" [ call-args ] ")" +call-name = 1*( ALPHA / DIGIT / "-" / "_" ) +call-args = call-arg *( "," call-arg ) +call-arg = value / sort-key / select-item +sort-key = [ "+" / "-" ] prop-path +select-item = prop-path + / prop-path "{" select-list "}" ; brace sub-select + / prop-path "[" "select" "(" select-list ")" "]" ; bracket sub-select + / "[" select-list "]" ; array-shaped rows +select-list = select-item *( "," select-item ) +``` + +### 4.1 Parsing tolerances (non-normative surface, normative behavior) + +- **Delimiters inside values.** Once a comparator has been consumed, a parser switches to + value scanning in which `(`, `)`, `<`, `>` and `!` MAY appear unescaped and are taken + literally (e.g. `foo=ba)r` is the value `ba)r`). Producers SHOULD percent-encode them + anyway. Square brackets retain structural meaning in value position (they open/close + groups), which is why `[...]` grouping is RECOMMENDED for machine-constructed queries: + standard URI component encoding safely escapes `[` and `]` but not `(` and `)`. +- **Percent-decoding order.** Tokenization happens on the raw string; each token is + percent-decoded afterward. Consequently a literal `.` inside a property *segment* cannot + be expressed — `%2E` is decoded after path splitting. (Known limitation, carried from + the reference implementation.) +- **Repeated array parameters.** `prop[]=v1&prop[]=v2` (PHP/Rails convention) is accepted + and equivalent to membership conditions on `prop`. + +## 5. Semantics — Core profile + +### 5.1 Comparators + +#### 5.1.1 Symbolic operators + +| Syntax | Comparator | Coercion (§5.5) | +|---|---|---| +| `prop=value` | `equals` | none — strict string (schema type MAY convert) | +| `prop===value` | `equals` | none — strict | +| `prop==value` | `eq` | automatic | +| `prop!=value` | `ne` | automatic | +| `prop!==value` | `not_equal` | none — strict | +| `propvalue`, `prop>=value` | `gt`, `ge` | automatic | + +> **Break from 1.x:** in RQL 1.x, `prop=value` auto-converted (it was sugar for `eq`). +> In 2.0 bare `=` is *strict*; `==` is the coercive equality. See Appendix A. + +#### 5.1.2 FIQL named comparators + +`prop=name=value` where `name` matches `fiql-name`. Parsers MUST accept any syntactically +valid name and defer unknown-comparator rejection to execution. The canonical Core set and +its aliases: + +| Canonical | Aliases | Notes | +|---|---|---| +| `eq` | | coercive equality | +| `equals` | | strict equality | +| `ne` | `not_equal` (strict variant distinct) | | +| `lt` `le` `gt` `ge` | `less_than`, `greater_than`, camelCase forms | | +| `contains` | `ct`, `includes` | string/array containment | +| `starts_with` | `sw` | | +| `ends_with` | `ew` | | +| `in` | | takes a value list | +| `between` | | takes a two-element value list, inclusive | + +**Negation:** prefixing `not_` to `in`, `between`, `starts_with`, `ends_with`, `contains`, +or `equals` negates the comparator (`tag=not_in=(a,b)`). `not_equal` is NOT a negation of +`equal` under this rule — it is its own (strict) comparator, for 1.x-lineage compatibility. + +**Value lists:** `(v1,v2,…)` is interpreted as a list **only** for `in` and `between` +(and their negations); each element is coerced individually and MAY be typed (§5.5). +`()` is the empty list. For any other comparator a parenthesized token is the literal +string including its parentheses (legacy tolerance; producers MUST NOT rely on it). + +#### 5.1.3 Wildcards + +A trailing `*` on the value of a coercive equality (`==`) condition rewrites the condition +to `starts_with` with the `*` removed: `name==Jo*` ≡ `name=starts_with=Jo`. A leading or +embedded `*` is a syntax error. Wildcards apply to no other comparator. + +### 5.2 Strict vs. coercive comparison + +Strict comparators (`=`, `===`, `!==`) treat the value as the percent-decoded string; if +the target schema declares a type for the property, the schema type governs conversion. +Coercive comparators (everything else) apply automatic literal conversion (§5.5) before +schema typing. + +### 5.3 Range chaining + +`&=` and `|=` chain an additional comparison onto the *preceding condition's property*: + +``` +age=ge=20&=le=30 ; 20 ≤ age ≤ 30 +``` + +Chained conditions attach to the prior condition (AST: `chainedConditions`, §6) and are +intended for contiguous range constraints; executors typically collapse +`ge/gt` + `le/lt` pairs into a single inclusive/exclusive range scan. + +### 5.4 Logical composition and grouping + +- `&` is conjunction, `|` is disjunction. +- Within one group nesting level, `&` and `|` MUST NOT be mixed; use `(...)` or `[...]` + to disambiguate: `a=1&[b=2|c=3]`. +- `(...)` and `[...]` are semantically identical groupings (see §4.1 for why brackets are + RECOMMENDED in generated queries). + +### 5.5 Values and typed literals + +Coercive comparators convert value tokens as follows: + +| Token | Converts to | +|---|---| +| `null` | null | +| `number:N` | number (decimal) | +| `number:$X` | number, `X` parsed base-36 | +| `boolean:true` / `boolean:false` | boolean | +| `date:ISO-8601` or `date:epochMillis` | Date | +| `string:S` | percent-decoded string (suppresses further coercion) | +| bare token | percent-decoded string; implementations MAY additionally auto-convert schema-untyped numerics/booleans | +| unknown `type:` prefix | error (400) | + +> **Break from 1.x:** the 1.x converters `re:`, `RE:`, `glob:`, `epoch:`, `isodate:` and +> the `$1`-style positional parameters are removed. String matching uses +> `contains`/`starts_with`/`ends_with` and the `==prefix*` wildcard. + +### 5.6 Call functions + +Exactly these call functions are Core; an unrecognized call name is a parse error +(unlike comparator names, which are open): + +| Function | Semantics | +|---|---| +| `select(...)` | Projection. Four shapes: `select(a)` → scalar values of `a`; `select(a,b)` → objects with those properties (`select(a,)` for a one-property object); `select([a,b])` → rows as arrays; sub-selects `rel{a,b}` or `rel[select(a,b)]` project into related/nested objects. | +| `sort(k1,k2,…)` | Each key optionally prefixed `+` (ascending, default) or `-` (descending); later keys break ties. Keys may be dotted paths. | +| `limit(end)` / `limit(start,end)` | **Start/end bounds, not offset/count**: `limit(5,10)` means offset 5, at most 5 records. | +| `group-by(...)` | Reserved. Parsers MUST accept the syntax; Core executors report "not implemented". | +| `(...)` (anonymous) | Grouping, §5.4. | + +> **Break from 1.x:** 1.x `limit(count,start,maxCount)` is replaced by the +> Dojo-store-range `limit(start,end)` form. See Appendix A. + +### 5.7 Property paths + +Dot syntax addresses nested properties and — where the schema declares relationships — +traverses them: `brand.name=Microsoft` (filtering through a relationship has inner-join +semantics; projecting an unfiltered relationship via `select` has left-join semantics). + +> **Break from 1.x:** 1.x slash paths (`foo/bar`) and tuple paths (`(foo,bar)`) are removed. + +## 6. Canonical parsed representation + +### 6.1 Query object + +A conforming parser produces (or populates) a **Query**: an object that *extends +`URLSearchParams`* (or is duck-type compatible: `[Symbol.iterator]`, `get`, `getAll`) and +carries: + +```ts +class Query extends URLSearchParams { + conditions: Condition[]; // filter terms, in source order + operator?: 'and' | 'or'; // top-level conjunction (default 'and') + sort?: Sort; // linked list + select?: Select; + limit?: number; + offset?: number; + parseError?: Error; // deferred semantic error (§6.4) +} +``` + +Host frameworks MAY subclass Query — e.g. Harper's `RequestTarget extends Query` — and +pass the instance to the parser for in-place population. + +### 6.2 Conditions + +```ts +type Condition = + | { attribute: string | string[]; // string[] = dotted path segments + comparator: Comparator; + value: unknown; + negated?: boolean; // from not_ prefix + chainedConditions?: Condition[] } // from &= / |= + | { conditions: Condition[]; operator: 'and' | 'or' } // group node + | [name: string, value: string]; // fast-path entry (§6.3) + +type Sort = { attribute: string | string[]; descending?: boolean; next?: Sort }; +type Select = string | (string | SubSelect)[]; // plus asArray / named-sub-select variants +type SubSelect = { name: string; select: (string | SubSelect)[] }; +``` + +Consumers MUST read conditions shape-agnostically: `attribute = c[0] ?? c.attribute`, +`value = c[1] ?? c.value` (a tuple's comparator is implicitly strict `equals`). + +### 6.3 The simple-query fast path + +A query containing none of `( ) [ ] | ! < > .` and no `=name=` sequence is plain form +encoding. Implementations MAY skip condition construction entirely and expose it through +the Query's `URLSearchParams` interface; consumers see `[name, value]` tuple conditions. +This is a deliberate performance affordance of the representation, not an optional +serialization: conforming consumers MUST handle both shapes. + +### 6.4 Error model + +Structural syntax violations (unbalanced groups, illegal wildcard, unknown call function, +unknown `type:` prefix) are client errors (HTTP 400). When the parser populates a +caller-supplied Query, semantic errors are RECOMMENDED to be *deferred*: accumulated into +`parseError` and raised at execution, so that a request pipeline controls where the +failure surfaces. + +## 7. Serialization + +TODO: normalization rules for emitting a Query back to a canonical string (needed for +caching keys and equivalence testing). Candidate: FIQL named form, `[...]` grouping, +sorted call-function order (`select`, `sort`, `limit` last). + +## 8. Conformance + +- **Core parser:** implements §4–§6 exactly; validated by the conformance suite + (`test/v2/` in the reference implementation, seeded from Harper's parser tests). +- **Core executor:** implements Core comparator/call semantics over a collection. +- **Extensions (Appendix C):** optional; names are reserved and MUST NOT be repurposed. + +## 9. Security considerations + +TODO: complexity/DoS bounds (nesting depth, condition count), percent-decoding pitfalls, +injection via property paths into schema-less stores, regex-free matching guarantees. + +--- + +## Appendix A — Breaking changes from RQL 1.x (migration) + +| Area | RQL 1.x | RQL 2.0 | +|---|---|---| +| `prop=value` | coercive `eq` | **strict** `equals`; use `==` for coercion | +| `limit` | `limit(count,start,maxCount)` | `limit(end)` / `limit(start,end)` | +| Nested paths | `foo/bar`, `(foo,bar)` | `foo.bar` | +| Grouping | `(...)` only | `(...)` and `[...]` | +| String matching | `re:`/`RE:`/`glob:` converters, `match` | `contains`/`starts_with`/`ends_with`, `==prefix*` | +| Converters | `epoch:`, `isodate:`, `re:`, `glob:` | removed; `date:` accepts ISO-8601 or epoch ms | +| Positional params | `$1`, `$2` | removed | +| Negation | none | `not_` comparator prefix | +| Range chaining | none | `&=` / `|=` | +| Sub-selects | none | `rel{a,b}`, `rel[select(a)]`, `select([a,b])` | +| AST | generic `{name, args}` term tree | typed Query (§6); generic terms remain a non-normative encoding for Extensions | +| Aggregation etc. | Core operators | moved to Extensions profile (Appendix C) | + +## Appendix B — Relationship to FIQL + +RQL 2.0 remains a superset of FIQL's `selector comparison-op argument` form with `=name=` +operators; it does not adopt FIQL's `;`/`,` conjunction syntax (RQL uses `&`/`|`). + +## Appendix C — Extensions profile (reserved from 1.x) + +Reserved call-function names carried from RQL 1.x, non-normative pending a future +revision: `aggregate`, `distinct`, `values`, `sum`, `mean`, `max`, `min`, `count`, +`first`, `one`, `recurse`, `rel`, `group-by`. From 2e7b096f8c119d3ca71149ad9b1c0b57ec548bba Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Sun, 30 Aug 2026 15:12:16 -0600 Subject: [PATCH 04/14] Spec: call out the operator-model break from v1 v1's normalized call form for comparators (lt(price,10)) is removed in 2.0; comparators are infix-only (open set), call syntax is the closed set of result-shaping functions. Co-Authored-By: Claude Fable 5 --- specification/rql-2.0.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/specification/rql-2.0.md b/specification/rql-2.0.md index ea8bce0..fef098c 100644 --- a/specification/rql-2.0.md +++ b/specification/rql-2.0.md @@ -214,6 +214,13 @@ Coercive comparators convert value tokens as follows: Exactly these call functions are Core; an unrecognized call name is a parse error (unlike comparator names, which are open): +> **Break from 1.x:** in RQL 1.x, call syntax was the *normalized form* of every +> operator — `lt(price,10)` was equivalent to `price=lt=10`, and infix forms were sugar. +> In 2.0 the categories are disjoint: comparators are infix-only with an open name set +> (§5.1.2), and call syntax is reserved for this closed set of result-shaping functions. +> `lt(price,10)` is a parse error. The anonymous group `(...)` (§5.4) is the one place +> call syntax still yields conditions. + | Function | Semantics | |---|---| | `select(...)` | Projection. Four shapes: `select(a)` → scalar values of `a`; `select(a,b)` → objects with those properties (`select(a,)` for a one-property object); `select([a,b])` → rows as arrays; sub-selects `rel{a,b}` or `rel[select(a,b)]` project into related/nested objects. | @@ -316,6 +323,8 @@ injection via property paths into schema-less stores, regex-free matching guaran | Area | RQL 1.x | RQL 2.0 | |---|---|---| +| Operator model | one category: call form `op(args)` is the normalized form of everything; infix is sugar | two disjoint categories: infix-only comparators (open set, execution-validated) vs. call-only result-shaping functions (closed set, parse-validated) | +| `lt(price,10)` etc. | valid, ≡ `price=lt=10` | parse error — comparators have no call form | | `prop=value` | coercive `eq` | **strict** `equals`; use `==` for coercion | | `limit` | `limit(count,start,maxCount)` | `limit(end)` / `limit(start,end)` | | Nested paths | `foo/bar`, `(foo,bar)` | `foo.bar` | From 7dc9fcd17753d023a55e80e6e5ebd9bd3af70eac Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Mon, 31 Aug 2026 18:10:55 -0600 Subject: [PATCH 05/14] Spec: pivot from Harper reverse-engineering to ideal language definition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Language-neutral canonical model (no URLSearchParams, no dual-shape fast path, sort as ordered list, projection as mode+fields) - Comparator reduction: eq/lt/le/gt/ge/contains/starts_with/ends_with/in with uniform not_ negation; strict-vs-coercive becomes verbatim-vs- interpreted value literals; ne/equals/between/aliases -> Appendix B compatibility desugarings - Chaining and between desugar to plain same-path conditions - Percent-encoding layering normative (split-then-decode; %2E works — verified Harper already matches, earlier report was wrong) - prop[]= demoted to host accommodation - New Appendix D: tracked Harper divergences Co-Authored-By: Claude Fable 5 --- specification/rql-2.0.md | 449 +++++++++++++++++++++++---------------- 1 file changed, 265 insertions(+), 184 deletions(-) diff --git a/specification/rql-2.0.md b/specification/rql-2.0.md index fef098c..af3c582 100644 --- a/specification/rql-2.0.md +++ b/specification/rql-2.0.md @@ -1,6 +1,6 @@ # Resource Query Language (RQL) 2.0 -**Status:** Draft — pre-review skeleton, not yet ratified +**Status:** Draft — pre-review, not yet ratified **Editor:** Kris Zyp **Supersedes:** [draft-zyp-rql-00](./draft-zyp-rql-00.xml) (RQL 1.x) @@ -10,79 +10,92 @@ Resource Query Language (RQL) is a query language designed for use in URIs, particularly as the query component of a URL, for querying collections of resources with object-style -data structures. RQL 2.0 is a **clean-break revision** of RQL 1.x that specifies the query -language as implemented and evolved by [Harper](https://github.com/HarperFast/harper)'s -REST interface, which descends from RQL 1.x and [FIQL]. +data structures. RQL 2.0 is a **clean-break revision** of RQL 1.x, informed by fifteen +years of production use of the RQL/FIQL lineage — most directly in +[Harper](https://github.com/HarperFast/harper)'s REST interface. + +RQL 2.0 specifies the *ideal* language: the cleanest coherent semantics for the syntax in +real-world use. It is deliberately **not** a reverse-engineering of any single +implementation. Existing implementations (including Harper's) are expected to converge +toward it; their known divergences are cataloged (Appendix D) rather than normalized into +the language. The specification is language-neutral: the canonical parsed representation +(§6) is an abstract data model, intended to support reference implementations in multiple +programming languages. RQL 2.0 consists of: - a **surface grammar** (§4) for conditions, logical composition, and call-style query functions, designed to be a compatible superset of HTML form URL encoding and of FIQL; -- **operator semantics** (§5) for comparison, negation, range chaining, wildcards, typed - value coercion, property paths, and the `select`/`sort`/`limit` functions; -- a **canonical parsed representation** (§6) — the AST every conforming parser produces; +- **operator semantics** (§5): a small orthogonal comparator set with uniform negation, + typed value literals, range chaining, property paths, and the `select`/`sort`/`limit` + functions; +- a **canonical parsed representation** (§6) — the abstract data model every conforming + parser produces, into which all surface sugar desugars; - **conformance profiles** (§8): *Core* (this document, normative) and *Extensions* (Appendix C, reserved operator names carried forward from RQL 1.x). -Where RQL 1.x and current practice diverge, 2.0 specifies current practice; Appendix A -enumerates every break for 1.x migrators. - ## 2. Terminology The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" are to be interpreted as described in RFC 2119. - **query** — the full string being parsed (the URL query component, without the leading `?`). -- **condition** — a single comparison of a property (path) against a value or value list. +- **condition** — a single comparison of a property path against a value. - **group** — a parenthesized or bracketed sub-query combining terms with one logical operator. - **call function** — a named, parenthesized top-level directive (`sort(...)`, `select(...)`, `limit(...)`) that shapes the result set rather than filtering it. - **comparator** — the named comparison operation of a condition (`eq`, `lt`, `contains`, …). +- **desugar** — the mapping from a surface convenience form to its canonical representation; + sugar exists only in the surface syntax, never in the data model. ## 3. Design principles 1. **URL-native.** A query MUST be expressible in a URL query component with standard - percent-encoding. Unreserved characters need no encoding; encoded octets are decoded - *after* tokenization, so delimiters can be embedded in values via percent-encoding. + percent-encoding. Tokenization happens on the raw string; percent-decoding is applied + per token *after* structural parsing (§4.2), so any delimiter can be embedded in a + value or property segment by percent-encoding it. 2. **Form-encoding superset.** `?foo=3&bar=4` — plain HTML form encoding — is a valid RQL - query meaning the conjunction of two equality conditions. Implementations MAY represent - such simple queries without constructing condition objects (§6.3). + query meaning the conjunction of two equality conditions on verbatim string values. 3. **FIQL superset.** `price=lt=10` (FIQL named-operator syntax) is valid and equivalent to the symbolic form `price<10`. -4. **Extensible.** Comparator names and call-function names are open identifier sets; - parsers MUST accept unknown FIQL comparator names syntactically (§5.1) and reject - unknown *call functions* at parse time (§5.6). Semantic validation of comparators is - deferred to execution. +4. **Small canonical core, rich sugar.** The data model has one equality, one negation + mechanism, and one way to express a range. Convenience surface forms (`!=`, `===`, + wildcards, `between`, chaining) all desugar to it. +5. **Extensible.** FIQL comparator names are an open identifier set — parsers MUST accept + unknown names syntactically and defer semantic validation to execution. Call-function + names are a closed set validated at parse time (§5.6). +6. **Language-neutral.** The canonical representation is defined abstractly; bindings for + particular languages map it to native structures but MUST preserve its shape. ## 4. Grammar -Draft ABNF (RFC 5234). This grammar describes the normative surface; §4.1 notes the -tolerances a parser MAY additionally provide. +Draft ABNF (RFC 5234). §4.1 notes tolerances a parser MAY additionally provide. ```abnf -query = [ group-body ] +query = [ group-body ] *( "&" call ) group-body = term *( conjunction term ) ; all conjunctions within one group-body MUST be identical (§5.4) conjunction = "&" / "|" -term = condition / chained-cond / call / group / form-pair +term = condition / chained-cond / group group = "(" group-body ")" / "[" group-body "]" condition = prop-path symbol-op value / prop-path "=" fiql-name "=" ( value / value-list ) chained-cond = ( "&=" / "|=" ) [ fiql-name "=" ] value - ; continues the preceding condition's property (§5.3) -form-pair = prop-path "=" value ; strict equality (§5.2) + ; continues the preceding condition's property path (§5.3) symbol-op = "=" / "==" / "===" / "!=" / "!==" / "<" / "<=" / ">" / ">=" fiql-name = ALPHA-UNDER *( ALPHA-UNDER / DIGIT ) ALPHA-UNDER = ALPHA / "_" prop-path = prop-segment *( "." prop-segment ) -prop-segment = 1*pchar-noDot -value = 1*vchar / typed-value / wildcard-value -typed-value = type-name ":" 1*vchar ; §5.5 +prop-segment = 1*seg-char ; percent-decoded after path splitting (§4.2) + +value = plain-value / typed-value / wildcard-value +plain-value = *vchar +typed-value = type-name ":" *vchar ; §5.2.2 value-list = "(" [ value *( "," value ) ] ")" -wildcard-value = 1*vchar "*" ; only with "==" (§5.1.3) +wildcard-value = 1*vchar "*" ; only with "==" (§5.1.2) call = call-name "(" [ call-args ] ")" call-name = 1*( ALPHA / DIGIT / "-" / "_" ) @@ -90,97 +103,136 @@ call-args = call-arg *( "," call-arg ) call-arg = value / sort-key / select-item sort-key = [ "+" / "-" ] prop-path select-item = prop-path - / prop-path "{" select-list "}" ; brace sub-select - / prop-path "[" "select" "(" select-list ")" "]" ; bracket sub-select - / "[" select-list "]" ; array-shaped rows + / prop-path "{" select-list "}" ; nested projection + / prop-path "[" "select" "(" select-list ")" "]" ; equivalent bracket form + / "[" select-list "]" ; tuple-shaped rows select-list = select-item *( "," select-item ) ``` -### 4.1 Parsing tolerances (non-normative surface, normative behavior) - -- **Delimiters inside values.** Once a comparator has been consumed, a parser switches to - value scanning in which `(`, `)`, `<`, `>` and `!` MAY appear unescaped and are taken - literally (e.g. `foo=ba)r` is the value `ba)r`). Producers SHOULD percent-encode them - anyway. Square brackets retain structural meaning in value position (they open/close - groups), which is why `[...]` grouping is RECOMMENDED for machine-constructed queries: - standard URI component encoding safely escapes `[` and `]` but not `(` and `)`. -- **Percent-decoding order.** Tokenization happens on the raw string; each token is - percent-decoded afterward. Consequently a literal `.` inside a property *segment* cannot - be expressed — `%2E` is decoded after path splitting. (Known limitation, carried from - the reference implementation.) -- **Repeated array parameters.** `prop[]=v1&prop[]=v2` (PHP/Rails convention) is accepted - and equivalent to membership conditions on `prop`. +### 4.1 Parsing tolerances + +- **Delimiters inside values.** Once a comparator has been consumed, a parser MAY scan + the value leniently, taking `(`, `)`, `<`, `>`, and `!` as literal characters (e.g. + `foo=ba)r` as the value `ba)r`). Producers MUST percent-encode reserved characters in + values; the lenient scan is a consumer tolerance, not a producer license. Square + brackets retain structural meaning even in value position, which is one reason `[...]` + grouping is RECOMMENDED for machine-constructed queries: standard URI component + encoding escapes `[` and `]` but not `(` and `)`. + +### 4.2 Percent-encoding layering + +Structural parsing operates on the raw (encoded) string; percent-decoding applies per +token afterward: + +1. Tokenize on the reserved delimiters (`&`, `|`, `=`, comparators, parentheses, + brackets, braces, commas). +2. Split property paths on **literal** (unencoded) `.`. +3. Percent-decode each resulting property segment and each value token. + +Consequently `%2E` within a property segment denotes a literal `.` in that segment's +name: `a%2Eb==3` is a condition on the single property named `a.b`, while `a.b==3` is a +condition on the path `a` → `b`. The same rule gives `%26`, `%7C`, `%28`, `%2C`, etc. +their expected meaning inside values. ## 5. Semantics — Core profile ### 5.1 Comparators -#### 5.1.1 Symbolic operators +#### 5.1.1 The Core comparator set + +The canonical comparator vocabulary is deliberately small and orthogonal: -| Syntax | Comparator | Coercion (§5.5) | +| Comparator | Meaning | +|---|---| +| `eq` | equality | +| `lt`, `le`, `gt`, `ge` | ordered comparison | +| `contains` | string substring / collection membership of the value in the property's value | +| `starts_with`, `ends_with` | string affix match | +| `in` | property value is a member of the given value list | + +**Negation is uniform:** prefixing any Core comparator with `not_` yields its logical +complement over the collection (`tag=not_in=(a,b)`, `name=not_contains=xyz`, +`price=not_eq=10`). Negation is set complement: `not_lt` matches every resource `lt` +does not match, which is *not* equivalent to `ge` for resources where the property is +absent or incomparable. + +There is exactly one equality (`eq`) and one negation mechanism (`not_`). Notions like +"strict vs. converting equality" are properties of the *value literal* (§5.2), not of +the comparator; forms like `!=`, `===`, `ne`, and `between` are surface sugar (§5.1.2) +or compatibility aliases (Appendix B). + +#### 5.1.2 Symbolic operators and sugar (desugaring table) + +| Surface form | Canonical form | Value interpretation (§5.2) | |---|---|---| -| `prop=value` | `equals` | none — strict string (schema type MAY convert) | -| `prop===value` | `equals` | none — strict | -| `prop==value` | `eq` | automatic | -| `prop!=value` | `ne` | automatic | -| `prop!==value` | `not_equal` | none — strict | -| `propvalue`, `prop>=value` | `gt`, `ge` | automatic | +| `prop=value` | `eq` | verbatim | +| `prop===value` | `eq` | verbatim | +| `prop==value` | `eq` | interpreted | +| `prop!=value` | `not_` `eq` | interpreted | +| `prop!==value` | `not_` `eq` | verbatim | +| `propv`, `prop>=v` | `lt`, `le`, `gt`, `ge` | interpreted | +| `prop=name=value` (FIQL) | `name` | interpreted | +| `prop==stem*` | `starts_with` (trailing `*` removed) | interpreted | -> **Break from 1.x:** in RQL 1.x, `prop=value` auto-converted (it was sugar for `eq`). -> In 2.0 bare `=` is *strict*; `==` is the coercive equality. See Appendix A. +The trailing-`*` wildcard applies only to `==`; a leading or embedded `*` is a syntax +error, and wildcards apply to no other comparator. -#### 5.1.2 FIQL named comparators +**Open vocabulary:** any syntactically valid `fiql-name` MUST parse; a name outside the +Core set (and not a registered Extension or alias) is rejected at execution, not at +parse. This is the language's comparator extension point. -`prop=name=value` where `name` matches `fiql-name`. Parsers MUST accept any syntactically -valid name and defer unknown-comparator rejection to execution. The canonical Core set and -its aliases: +**Value lists** `(v1,v2,…)` are interpreted as lists only for `in`/`not_in` (and the +`between` compatibility alias, Appendix B); each element is interpreted individually and +MAY be typed. `()` is the empty list. -| Canonical | Aliases | Notes | -|---|---|---| -| `eq` | | coercive equality | -| `equals` | | strict equality | -| `ne` | `not_equal` (strict variant distinct) | | -| `lt` `le` `gt` `ge` | `less_than`, `greater_than`, camelCase forms | | -| `contains` | `ct`, `includes` | string/array containment | -| `starts_with` | `sw` | | -| `ends_with` | `ew` | | -| `in` | | takes a value list | -| `between` | | takes a two-element value list, inclusive | - -**Negation:** prefixing `not_` to `in`, `between`, `starts_with`, `ends_with`, `contains`, -or `equals` negates the comparator (`tag=not_in=(a,b)`). `not_equal` is NOT a negation of -`equal` under this rule — it is its own (strict) comparator, for 1.x-lineage compatibility. - -**Value lists:** `(v1,v2,…)` is interpreted as a list **only** for `in` and `between` -(and their negations); each element is coerced individually and MAY be typed (§5.5). -`()` is the empty list. For any other comparator a parenthesized token is the literal -string including its parentheses (legacy tolerance; producers MUST NOT rely on it). - -#### 5.1.3 Wildcards - -A trailing `*` on the value of a coercive equality (`==`) condition rewrites the condition -to `starts_with` with the `*` removed: `name==Jo*` ≡ `name=starts_with=Jo`. A leading or -embedded `*` is a syntax error. Wildcards apply to no other comparator. - -### 5.2 Strict vs. coercive comparison - -Strict comparators (`=`, `===`, `!==`) treat the value as the percent-decoded string; if -the target schema declares a type for the property, the schema type governs conversion. -Coercive comparators (everything else) apply automatic literal conversion (§5.5) before -schema typing. +### 5.2 Values + +#### 5.2.1 Value model + +RQL values are typed literals drawn from a language-neutral set: **string**, **number**, +**boolean**, **null**, **timestamp**, and **list** (for list-valued comparators). A +condition's value is fixed at parse time; comparators are agnostic to how the literal +was written. + +A value token is read in one of two modes: + +- **verbatim** — the token is the percent-decoded string, uninterpreted. Used by `=`, + `===`, `!==`. +- **interpreted** — the token is converted by the literal rules below. Used by `==`, + `!=`, symbolic ordered comparisons, and all FIQL named comparators. + +When the target schema declares a type for the property, implementations MAY additionally +convert the parsed value to the schema type at binding time in either mode. + +#### 5.2.2 Literal interpretation rules + +| Token | Interpreted value | +|---|---| +| `null` | null | +| `true` / `false` | boolean, when the property is not schema-typed as string | +| decimal numeral | number, when the property is not schema-typed as string | +| `number:N` | number (decimal) | +| `number:$X` | number, `X` in base 36 | +| `boolean:true` / `boolean:false` | boolean | +| `date:ISO-8601` / `date:epochMillis` | timestamp | +| `string:S` | string (suppresses further interpretation) | +| any other token | percent-decoded string | +| unknown `type:` prefix | error (client error, HTTP 400) | ### 5.3 Range chaining -`&=` and `|=` chain an additional comparison onto the *preceding condition's property*: +`&=` and `|=` chain an additional comparison onto the *preceding condition's property +path*: ``` age=ge=20&=le=30 ; 20 ≤ age ≤ 30 ``` -Chained conditions attach to the prior condition (AST: `chainedConditions`, §6) and are -intended for contiguous range constraints; executors typically collapse -`ge/gt` + `le/lt` pairs into a single inclusive/exclusive range scan. +Chaining is pure surface sugar: it desugars to ordinary conditions on the same path, +combined with the corresponding logical operator. `age=ge=20&=le=30` is canonically an +`and` group containing `ge(age,20)` and `le(age,30)`. Executors are encouraged to +recognize same-path `ge`/`gt` + `le`/`lt` pairs and execute them as a single range scan, +but that is an optimization, not a representation. ### 5.4 Logical composition and grouping @@ -190,24 +242,15 @@ intended for contiguous range constraints; executors typically collapse - `(...)` and `[...]` are semantically identical groupings (see §4.1 for why brackets are RECOMMENDED in generated queries). -### 5.5 Values and typed literals - -Coercive comparators convert value tokens as follows: +### 5.5 Property paths -| Token | Converts to | -|---|---| -| `null` | null | -| `number:N` | number (decimal) | -| `number:$X` | number, `X` parsed base-36 | -| `boolean:true` / `boolean:false` | boolean | -| `date:ISO-8601` or `date:epochMillis` | Date | -| `string:S` | percent-decoded string (suppresses further coercion) | -| bare token | percent-decoded string; implementations MAY additionally auto-convert schema-untyped numerics/booleans | -| unknown `type:` prefix | error (400) | +Dot syntax addresses nested properties: `brand.name=Microsoft`. Where the data model +declares relationships, path traversal crosses them; filtering through a relationship +has inner-join semantics, while projecting an unfiltered relationship via `select` has +left-join semantics. When a path traverses a list-valued property, a condition matches +if **any** element matches (existential semantics). -> **Break from 1.x:** the 1.x converters `re:`, `RE:`, `glob:`, `epoch:`, `isodate:` and -> the `$1`-style positional parameters are removed. String matching uses -> `contains`/`starts_with`/`ends_with` and the `==prefix*` wildcard. +Literal dots in property names are expressed with `%2E` (§4.2). ### 5.6 Call functions @@ -223,99 +266,108 @@ Exactly these call functions are Core; an unrecognized call name is a parse erro | Function | Semantics | |---|---| -| `select(...)` | Projection. Four shapes: `select(a)` → scalar values of `a`; `select(a,b)` → objects with those properties (`select(a,)` for a one-property object); `select([a,b])` → rows as arrays; sub-selects `rel{a,b}` or `rel[select(a,b)]` project into related/nested objects. | -| `sort(k1,k2,…)` | Each key optionally prefixed `+` (ascending, default) or `-` (descending); later keys break ties. Keys may be dotted paths. | +| `select(...)` | Projection (§5.7). | +| `sort(k1,k2,…)` | Each key optionally prefixed `+` (ascending, default) or `-` (descending); later keys break ties. Keys may be dotted paths. Note: some URL stacks decode a raw `+` as a space in query components; producers SHOULD percent-encode it (`%2B`) or rely on the ascending default. | | `limit(end)` / `limit(start,end)` | **Start/end bounds, not offset/count**: `limit(5,10)` means offset 5, at most 5 records. | | `group-by(...)` | Reserved. Parsers MUST accept the syntax; Core executors report "not implemented". | | `(...)` (anonymous) | Grouping, §5.4. | -> **Break from 1.x:** 1.x `limit(count,start,maxCount)` is replaced by the -> Dojo-store-range `limit(start,end)` form. See Appendix A. +### 5.7 Projection (`select`) -### 5.7 Property paths +Canonically a projection is a **mode** plus an ordered list of **fields**, each a +property path with an optional nested projection: -Dot syntax addresses nested properties and — where the schema declares relationships — -traverses them: `brand.name=Microsoft` (filtering through a relationship has inner-join -semantics; projecting an unfiltered relationship via `select` has left-join semantics). +| Surface form | Mode | Meaning | +|---|---|---| +| `select(a)` | `values` | the result is the sequence of values of `a` | +| `select(a,b)` (or `select(a,)` for one field) | `records` | records trimmed to the listed fields | +| `select([a,b])` | `tuples` | each result row is the array `[a-value, b-value]` | +| `select(rel{x,y})` / `select(rel[select(x,y)])` | (nested) | field `rel` projected by the nested projection | -> **Break from 1.x:** 1.x slash paths (`foo/bar`) and tuple paths (`(foo,bar)`) are removed. +The brace and bracket nested forms are equivalent surface spellings of the same nested +projection. ## 6. Canonical parsed representation -### 6.1 Query object - -A conforming parser produces (or populates) a **Query**: an object that *extends -`URLSearchParams`* (or is duck-type compatible: `[Symbol.iterator]`, `get`, `getAll`) and -carries: - -```ts -class Query extends URLSearchParams { - conditions: Condition[]; // filter terms, in source order - operator?: 'and' | 'or'; // top-level conjunction (default 'and') - sort?: Sort; // linked list - select?: Select; - limit?: number; - offset?: number; - parseError?: Error; // deferred semantic error (§6.4) -} +The data model is defined abstractly; a binding in any language MUST preserve this +shape. (JSON is used below as notation, not as a required encoding.) + ``` +Query := { filter?: Group, + sort?: [ SortKey … ], + select?: Projection, + limit?: non-negative integer, + offset?: non-negative integer } -Host frameworks MAY subclass Query — e.g. Harper's `RequestTarget extends Query` — and -pass the instance to the parser for in-place population. +Group := { operator: "and" | "or", + terms: [ (Condition | Group) … ] } -### 6.2 Conditions +Condition := { path: [ segment … ], // one or more segments + comparator: name, // canonical, never an alias + negated?: boolean, + value: Value } -```ts -type Condition = - | { attribute: string | string[]; // string[] = dotted path segments - comparator: Comparator; - value: unknown; - negated?: boolean; // from not_ prefix - chainedConditions?: Condition[] } // from &= / |= - | { conditions: Condition[]; operator: 'and' | 'or' } // group node - | [name: string, value: string]; // fast-path entry (§6.3) +SortKey := { path: [ segment … ], direction: "asc" | "desc" } -type Sort = { attribute: string | string[]; descending?: boolean; next?: Sort }; -type Select = string | (string | SubSelect)[]; // plus asArray / named-sub-select variants -type SubSelect = { name: string; select: (string | SubSelect)[] }; -``` +Projection := { mode: "records" | "values" | "tuples", + fields: [ Field … ] } +Field := { path: [ segment … ], projection?: Projection } -Consumers MUST read conditions shape-agnostically: `attribute = c[0] ?? c.attribute`, -`value = c[1] ?? c.value` (a tuple's comparator is implicitly strict `equals`). +Value := string | number | boolean | null | timestamp | [ Value … ] +``` -### 6.3 The simple-query fast path +Invariants: -A query containing none of `( ) [ ] | ! < > .` and no `=name=` sequence is plain form -encoding. Implementations MAY skip condition construction entirely and expose it through -the Query's `URLSearchParams` interface; consumers see `[name, value]` tuple conditions. -This is a deliberate performance affordance of the representation, not an optional -serialization: conforming consumers MUST handle both shapes. +- **All sugar is gone.** Aliases are resolved to canonical comparator names; `!=` + desugars to `negated eq`; wildcards to `starts_with`; chaining and `between` to plain + conditions in a group. Two surface queries with the same meaning parse to the same + representation. +- **A condition's `path` is always a segment list**, even for a single segment. +- `filter` is absent for an unfiltered query; a query with a single condition is an + `and` group with one term (there is no bare-condition special case). +- The representation carries no execution or host-framework concerns (no lazy/simple + dual shapes, no linked lists, no URL-object inheritance). Hosts wanting such + affordances build them *around* the model, not into it. -### 6.4 Error model +### 6.1 Error model -Structural syntax violations (unbalanced groups, illegal wildcard, unknown call function, -unknown `type:` prefix) are client errors (HTTP 400). When the parser populates a -caller-supplied Query, semantic errors are RECOMMENDED to be *deferred*: accumulated into -`parseError` and raised at execution, so that a request pipeline controls where the -failure surfaces. +Structural syntax violations (unbalanced groups, illegal wildcard, unknown call +function, unknown `type:` prefix) are client errors (HTTP 400 in an HTTP binding). +Implementations MAY offer a deferred-error mode in which the parser returns a +representation carrying the error for the execution pipeline to raise, but the canonical +behavior is to reject at parse. ## 7. Serialization -TODO: normalization rules for emitting a Query back to a canonical string (needed for -caching keys and equivalence testing). Candidate: FIQL named form, `[...]` grouping, -sorted call-function order (`select`, `sort`, `limit` last). +Every Query has a canonical string form, defined so that `parse(serialize(q)) = q`: + +- conditions in FIQL named form (`prop=eq=value`), canonical comparator names, + `not_`-prefixed when negated; +- explicit `type:` prefixes whenever the interpreted reading of the emitted token would + differ from the value's type; +- `[...]` for all grouping; `%2E` for literal dots in segments; +- call functions last, in the order `select`, `sort`, `limit`. + +TODO: full normalization rules (value-token escaping table, timestamp formatting, +ordering guarantees) — needed for cache keys and equivalence testing. ## 8. Conformance - **Core parser:** implements §4–§6 exactly; validated by the conformance suite - (`test/v2/` in the reference implementation, seeded from Harper's parser tests). + (`test/v2/` in the reference implementation), which is defined as surface-string → + canonical-representation pairs and is therefore language- and implementation-neutral. + An implementation with a different internal representation (e.g. Harper) conforms by + supplying an adapter from its internal form to the canonical model. - **Core executor:** implements Core comparator/call semantics over a collection. - **Extensions (Appendix C):** optional; names are reserved and MUST NOT be repurposed. +- **Compatibility aliases (Appendix B):** optional; if accepted, they MUST desugar + exactly as specified. ## 9. Security considerations -TODO: complexity/DoS bounds (nesting depth, condition count), percent-decoding pitfalls, -injection via property paths into schema-less stores, regex-free matching guarantees. +TODO: complexity/DoS bounds (nesting depth, condition count, value-list length), +percent-decoding pitfalls, injection via property paths into schema-less stores, +regex-free matching guarantees. --- @@ -325,26 +377,55 @@ injection via property paths into schema-less stores, regex-free matching guaran |---|---|---| | Operator model | one category: call form `op(args)` is the normalized form of everything; infix is sugar | two disjoint categories: infix-only comparators (open set, execution-validated) vs. call-only result-shaping functions (closed set, parse-validated) | | `lt(price,10)` etc. | valid, ≡ `price=lt=10` | parse error — comparators have no call form | -| `prop=value` | coercive `eq` | **strict** `equals`; use `==` for coercion | +| `prop=value` | interpreted `eq` | **verbatim** `eq`; use `==` for interpretation | | `limit` | `limit(count,start,maxCount)` | `limit(end)` / `limit(start,end)` | | Nested paths | `foo/bar`, `(foo,bar)` | `foo.bar` | | Grouping | `(...)` only | `(...)` and `[...]` | -| String matching | `re:`/`RE:`/`glob:` converters, `match` | `contains`/`starts_with`/`ends_with`, `==prefix*` | +| String matching | `re:`/`RE:`/`glob:` converters, `match` | `contains`/`starts_with`/`ends_with`, `==stem*` | | Converters | `epoch:`, `isodate:`, `re:`, `glob:` | removed; `date:` accepts ISO-8601 or epoch ms | | Positional params | `$1`, `$2` | removed | -| Negation | none | `not_` comparator prefix | -| Range chaining | none | `&=` / `|=` | -| Sub-selects | none | `rel{a,b}`, `rel[select(a)]`, `select([a,b])` | -| AST | generic `{name, args}` term tree | typed Query (§6); generic terms remain a non-normative encoding for Extensions | +| Negation | none | uniform `not_` comparator prefix | +| Range expression | `between` operator | `&=` / `|=` chaining (canonical); `between` demoted to alias | +| Sub-selects | none | `rel{x,y}`, `rel[select(x)]`, `select([a,b])` | +| AST | generic `{name, args}` term tree | typed canonical model (§6); generic terms remain a non-normative encoding for Extensions | | Aggregation etc. | Core operators | moved to Extensions profile (Appendix C) | -## Appendix B — Relationship to FIQL +## Appendix B — Compatibility aliases (non-normative surface, normative desugaring) -RQL 2.0 remains a superset of FIQL's `selector comparison-op argument` form with `=name=` -operators; it does not adopt FIQL's `;`/`,` conjunction syntax (RQL uses `&`/`|`). +Implementations MAY accept these for FIQL/1.x/Harper-lineage compatibility. If accepted, +they MUST desugar exactly as follows and MUST NOT appear in the canonical representation +or in canonical serialization: + +| Alias | Desugars to | +|---|---| +| `ne` | `not_` `eq` (interpreted value) | +| `not_equal`, `equals` | `not_` `eq` / `eq` (verbatim value) | +| `between=(lo,hi)` | `ge=lo` AND `le=hi` on the same path (inclusive) | +| `not_between=(lo,hi)` | negation of the above | +| `sw`, `ew`, `ct`, `includes` | `starts_with`, `ends_with`, `contains`, `contains` | +| `less_than`, `greater_than`, `lessThan`, `greaterThan`, … | `lt`, `gt`, … | +| `out` (1.x) | `not_in` | +| repeated array parameters `prop[]=v1&prop[]=v2` | membership conditions on `prop` (host-framework accommodation; NOT part of the RQL grammar) | ## Appendix C — Extensions profile (reserved from 1.x) Reserved call-function names carried from RQL 1.x, non-normative pending a future revision: `aggregate`, `distinct`, `values`, `sum`, `mean`, `max`, `min`, `count`, `first`, `one`, `recurse`, `rel`, `group-by`. + +## Appendix D — Known divergences of the Harper implementation + +Tracked so the spec stays ideal while implementations converge. As of harper `main` +(2026-08): + +| # | Divergence | Spec position | +|---|---|---| +| 1 | Simple queries (no structural characters) skip parsing and surface as raw name/value pairs; consumers handle two condition shapes | §6: one canonical shape; lazy representations are a host affordance outside the model | +| 2 | `&=`/`|=` chains attach to the prior condition as `chainedConditions` | §5.3: chaining desugars to plain same-path conditions in a group | +| 3 | Strict vs. converting comparison is modeled as distinct comparators (`equals`/`not_equal` vs `eq`/`ne`) | §5.2: one `eq`; verbatim vs. interpreted is a property of the value literal | +| 4 | `between` is a first-class comparator | Appendix B alias, desugars to `ge`+`le` | +| 5 | Sort is a linked list; select is a polymorphic array with marker properties (`asArray`, `name`) | §6: sort is an ordered list of SortKeys; projection is mode + fields | +| 6 | `(4)` on a non-list comparator is the literal string `"(4)"` | tolerance only; producers MUST NOT rely on it | +| 7 | `prop[]=v` repeated-array params accepted in the parser | Appendix B host accommodation, not grammar | +| 8 | Unknown call-name error and other semantic errors are deferred into the request pipeline (`parseError`) | §6.1: deferred mode is OPTIONAL; canonical behavior rejects at parse | +| 9 | `group-by(...)` fell through into `sort` handling (missing `break`) | bug; fix in flight (harper dispatch `harper-groupby-fallthrough`) | From 7b4efae69862e2e82dfd68feed6415a3f6815a97 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Mon, 31 Aug 2026 18:45:45 -0600 Subject: [PATCH 06/14] =?UTF-8?q?Spec:=20element-scoped=20matching=20?= =?UTF-8?q?=E2=80=94=20chaining=20is=20semantics,=20not=20sugar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conjunction does not distribute over existential array matching: skiLengths=ge=175&=le=180 (one element in range) differs from two separate conditions (any elements witness each). Chaining and prop[group] sub-queries canonicalize to a new ElementMatch construct; between desugars into it. Adds the bare =op= and-continuation spelling (new in 2.0; Harper-lineage grammar rejects it — verified). README: v2 status note. Co-Authored-By: Claude Fable 5 --- README.md | 8 ++++ specification/rql-2.0.md | 79 ++++++++++++++++++++++++++++++---------- 2 files changed, 68 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index b8d30af..e9d047e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,11 @@ +> **RQL 2.0 is in progress** — a clean-break revision of the language with a formal +> specification ([specification/rql-2.0.md](specification/rql-2.0.md)) and a new +> zero-dependency TypeScript reference parser (`src/`). RQL 2.0 specifies the language +> as it evolved in production use (most directly in +> [Harper](https://github.com/HarperFast/harper)'s REST interface), idealized for +> coherence rather than tied to any single implementation. The 1.x implementation below +> is retained unchanged for reference; see the spec's Appendix A for 1.x migration. + [![Build Status](https://travis-ci.org/persvr/rql.svg?branch=master)](https://travis-ci.org/persvr/rql) Resource Query Language (RQL) is a query language designed for use in URIs with object diff --git a/specification/rql-2.0.md b/specification/rql-2.0.md index af3c582..2425674 100644 --- a/specification/rql-2.0.md +++ b/specification/rql-2.0.md @@ -76,13 +76,17 @@ query = [ group-body ] *( "&" call ) group-body = term *( conjunction term ) ; all conjunctions within one group-body MUST be identical (§5.4) conjunction = "&" / "|" -term = condition / chained-cond / group +term = condition / chained-cond / group / scoped-match group = "(" group-body ")" / "[" group-body "]" +scoped-match = prop-path "[" group-body "]" + ; element-scoped sub-query over the values at prop-path (§5.3); + ; inner paths are element-relative condition = prop-path symbol-op value / prop-path "=" fiql-name "=" ( value / value-list ) -chained-cond = ( "&=" / "|=" ) [ fiql-name "=" ] value - ; continues the preceding condition's property path (§5.3) +chained-cond = ( "&=" / "|=" / "=" ) fiql-name "=" ( value / value-list ) + ; continues the preceding condition, scoped to the same element (§5.3); + ; the bare "=" spelling is an and-continuation symbol-op = "=" / "==" / "===" / "!=" / "!==" / "<" / "<=" / ">" / ">=" fiql-name = ALPHA-UNDER *( ALPHA-UNDER / DIGIT ) @@ -219,20 +223,47 @@ convert the parsed value to the schema type at binding time in either mode. | any other token | percent-decoded string | | unknown `type:` prefix | error (client error, HTTP 400) | -### 5.3 Range chaining +### 5.3 Element-scoped matching and range chaining -`&=` and `|=` chain an additional comparison onto the *preceding condition's property -path*: +A condition on a list-valued property matches existentially — if **any** element +matches (§5.5). Because conjunction does not distribute over that quantifier, RQL +provides *element scoping*: a way to require that several comparisons hold for the +**same** element. + +**Chaining** continues the preceding condition, scoped to the same element. Three +spellings: `&=` (and), `|=` (or), and a bare `=` continuation (and): + +``` +skiLengths=ge=175&=le=180 ; some ONE length is in [175, 180] +skiLengths=ge=175=le=180 ; identical (bare "=" and-continuation, new in 2.0) +skiLengths=ge=175&skiLengths=le=180 + ; DIFFERENT: some length ≥ 175 AND some + ; (possibly other) length ≤ 180 +``` + +For the record `{ name: "Kris", skiLengths: [172, 174, 181] }`, the chained forms do +not match, while the two-condition form does (181 witnesses the first condition, 172 +the second). + +**Scoped sub-queries** generalize this to object elements: a property path directly +followed by a bracketed group scopes the whole group to one element, with inner paths +relative to that element: ``` -age=ge=20&=le=30 ; 20 ≤ age ≤ 30 +skis[length=ge=175&width=le=80] ; some ski is both long and narrow ``` -Chaining is pure surface sugar: it desugars to ordinary conditions on the same path, -combined with the corresponding logical operator. `age=ge=20&=le=30` is canonically an -`and` group containing `ge(age,20)` and `le(age,30)`. Executors are encouraged to -recognize same-path `ge`/`gt` + `le`/`lt` pairs and execute them as a single range scan, -but that is an optimization, not a representation. +Canonically both forms are an *element-scoped match* (§6): the path plus a group whose +conditions have element-relative paths (an empty relative path denotes the element +value itself, as chained scalar comparisons produce). For a single-valued property, +element scoping is trivially equivalent to separate conditions; parsers cannot know +value cardinality, so the scoping structure is always preserved. (Or-chaining is +logically distributable over the existential quantifier, but it is represented scoped +as well, for symmetry.) + +Executors are encouraged to execute same-element `ge`/`gt` + `le`/`lt` pairs as a +single index range scan — for element-indexed lists that scan implements same-element +semantics naturally. ### 5.4 Logical composition and grouping @@ -248,7 +279,8 @@ Dot syntax addresses nested properties: `brand.name=Microsoft`. Where the data m declares relationships, path traversal crosses them; filtering through a relationship has inner-join semantics, while projecting an unfiltered relationship via `select` has left-join semantics. When a path traverses a list-valued property, a condition matches -if **any** element matches (existential semantics). +if **any** element matches (existential semantics); to bind several comparisons to the +same element, use element scoping (§5.3). Literal dots in property names are expressed with `%2E` (§4.2). @@ -300,13 +332,19 @@ Query := { filter?: Group, offset?: non-negative integer } Group := { operator: "and" | "or", - terms: [ (Condition | Group) … ] } + terms: [ (Condition | Group | ElementMatch) … ] } Condition := { path: [ segment … ], // one or more segments comparator: name, // canonical, never an alias negated?: boolean, value: Value } +ElementMatch := { path: [ segment … ], // §5.3: ∃ value at path + negated?: boolean, // satisfying `some` + some: Group } // inner Condition paths are + // element-relative; [] = the + // element value itself + SortKey := { path: [ segment … ], direction: "asc" | "desc" } Projection := { mode: "records" | "values" | "tuples", @@ -319,9 +357,10 @@ Value := string | number | boolean | null | timestamp | [ Value … ] Invariants: - **All sugar is gone.** Aliases are resolved to canonical comparator names; `!=` - desugars to `negated eq`; wildcards to `starts_with`; chaining and `between` to plain - conditions in a group. Two surface queries with the same meaning parse to the same - representation. + desugars to `negated eq`; wildcards to `starts_with`; chaining and `between` to an + ElementMatch; `prop[x=1]` with a single inner condition normalizes to the plain + Condition `prop.x=1` (an ElementMatch always scopes two or more comparisons). Two + surface queries with the same meaning parse to the same representation. - **A condition's `path` is always a segment list**, even for a single segment. - `filter` is absent for an unfiltered query; a query with a single condition is an `and` group with one term (there is no bare-condition special case). @@ -346,6 +385,8 @@ Every Query has a canonical string form, defined so that `parse(serialize(q)) = - explicit `type:` prefixes whenever the interpreted reading of the emitted token would differ from the value's type; - `[...]` for all grouping; `%2E` for literal dots in segments; +- element-scoped matches in chained form (`prop=ge=1&=le=5`) when every inner path is + empty, and in scoped-sub-query form (`prop[…]`) otherwise; - call functions last, in the order `select`, `sort`, `limit`. TODO: full normalization rules (value-token escaping table, timestamp formatting, @@ -400,7 +441,7 @@ or in canonical serialization: |---|---| | `ne` | `not_` `eq` (interpreted value) | | `not_equal`, `equals` | `not_` `eq` / `eq` (verbatim value) | -| `between=(lo,hi)` | `ge=lo` AND `le=hi` on the same path (inclusive) | +| `between=(lo,hi)` | element-scoped `ge=lo` AND `le=hi` (≡ `=ge=lo&=le=hi`, inclusive; same-element per §5.3) | | `not_between=(lo,hi)` | negation of the above | | `sw`, `ew`, `ct`, `includes` | `starts_with`, `ends_with`, `contains`, `contains` | | `less_than`, `greater_than`, `lessThan`, `greaterThan`, … | `lt`, `gt`, … | @@ -421,7 +462,7 @@ Tracked so the spec stays ideal while implementations converge. As of harper `ma | # | Divergence | Spec position | |---|---|---| | 1 | Simple queries (no structural characters) skip parsing and surface as raw name/value pairs; consumers handle two condition shapes | §6: one canonical shape; lazy representations are a host affordance outside the model | -| 2 | `&=`/`|=` chains attach to the prior condition as `chainedConditions` | §5.3: chaining desugars to plain same-path conditions in a group | +| 2 | `&=`/`|=` chains attach to the prior condition as `chainedConditions` | semantically correct (same-element scoping, §5.3); divergence is representational only — canonical form is ElementMatch. The bare `=op=` and-continuation spelling (new in 2.0) is not yet accepted | | 3 | Strict vs. converting comparison is modeled as distinct comparators (`equals`/`not_equal` vs `eq`/`ne`) | §5.2: one `eq`; verbatim vs. interpreted is a property of the value literal | | 4 | `between` is a first-class comparator | Appendix B alias, desugars to `ge`+`le` | | 5 | Sort is a linked list; select is a polymorphic array with marker properties (`asArray`, `name`) | §6: sort is an ordered list of SortKeys; projection is mode + fields | From 9450891b87602733e511bfa6312f1e5cd8821dc7 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Mon, 31 Aug 2026 18:47:24 -0600 Subject: [PATCH 07/14] Spec: drop speculative bare = continuation; require named chain legs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bare =op= spelling was motivated by a typo. Also: chain legs take a single value (no lists), and the comparator name is required — Harper's nameless-leg comparator inheritance is now a tracked divergence. Co-Authored-By: Claude Fable 5 --- specification/rql-2.0.md | 12 +- src/comparators.ts | 137 +++--- src/index.ts | 14 +- src/parser.ts | 889 ++++++++++++++++++++++++--------------- src/query.ts | 26 -- src/types.ts | 86 ++-- test/v2/parse.test.ts | 837 ++++++++++++++++++++---------------- 7 files changed, 1125 insertions(+), 876 deletions(-) delete mode 100644 src/query.ts diff --git a/specification/rql-2.0.md b/specification/rql-2.0.md index 2425674..5bc3e2c 100644 --- a/specification/rql-2.0.md +++ b/specification/rql-2.0.md @@ -84,9 +84,8 @@ scoped-match = prop-path "[" group-body "]" condition = prop-path symbol-op value / prop-path "=" fiql-name "=" ( value / value-list ) -chained-cond = ( "&=" / "|=" / "=" ) fiql-name "=" ( value / value-list ) - ; continues the preceding condition, scoped to the same element (§5.3); - ; the bare "=" spelling is an and-continuation +chained-cond = ( "&=" / "|=" ) fiql-name "=" value + ; continues the preceding condition, scoped to the same element (§5.3) symbol-op = "=" / "==" / "===" / "!=" / "!==" / "<" / "<=" / ">" / ">=" fiql-name = ALPHA-UNDER *( ALPHA-UNDER / DIGIT ) @@ -230,12 +229,11 @@ matches (§5.5). Because conjunction does not distribute over that quantifier, R provides *element scoping*: a way to require that several comparisons hold for the **same** element. -**Chaining** continues the preceding condition, scoped to the same element. Three -spellings: `&=` (and), `|=` (or), and a bare `=` continuation (and): +**Chaining** continues the preceding condition, scoped to the same element: `&=` (and) +or `|=` (or), each followed by a named comparison: ``` skiLengths=ge=175&=le=180 ; some ONE length is in [175, 180] -skiLengths=ge=175=le=180 ; identical (bare "=" and-continuation, new in 2.0) skiLengths=ge=175&skiLengths=le=180 ; DIFFERENT: some length ≥ 175 AND some ; (possibly other) length ≤ 180 @@ -462,7 +460,7 @@ Tracked so the spec stays ideal while implementations converge. As of harper `ma | # | Divergence | Spec position | |---|---|---| | 1 | Simple queries (no structural characters) skip parsing and surface as raw name/value pairs; consumers handle two condition shapes | §6: one canonical shape; lazy representations are a host affordance outside the model | -| 2 | `&=`/`|=` chains attach to the prior condition as `chainedConditions` | semantically correct (same-element scoping, §5.3); divergence is representational only — canonical form is ElementMatch. The bare `=op=` and-continuation spelling (new in 2.0) is not yet accepted | +| 2 | `&=`/`|=` chains attach to the prior condition as `chainedConditions`; a nameless chain leg (`a=ge=1&=5`) is accepted and inherits the previous leg's comparator | semantically correct (same-element scoping, §5.3); representational divergence only — canonical form is ElementMatch. Nameless legs are a syntax error in 2.0 (the comparator name is required) | | 3 | Strict vs. converting comparison is modeled as distinct comparators (`equals`/`not_equal` vs `eq`/`ne`) | §5.2: one `eq`; verbatim vs. interpreted is a property of the value literal | | 4 | `between` is a first-class comparator | Appendix B alias, desugars to `ge`+`le` | | 5 | Sort is a linked list; select is a polymorphic array with marker properties (`asArray`, `name`) | §6: sort is an ordered list of SortKeys; projection is mode + fields | diff --git a/src/comparators.ts b/src/comparators.ts index 5fa58a9..54ad0b7 100644 --- a/src/comparators.ts +++ b/src/comparators.ts @@ -1,82 +1,77 @@ -export const SYMBOL_OPERATORS: Record = { - // coercing operators - '<': 'lt', - '<=': 'le', - '>': 'gt', - '>=': 'ge', - '!=': 'ne', - '==': 'eq', - // strict operators - '===': 'equals', - '!==': 'not_equal', -}; +// Canonical comparator set (§5.1.1). All other names are open-vocabulary FIQL. +export const CORE_COMPARATORS: ReadonlySet = new Set([ + 'eq', 'lt', 'le', 'gt', 'ge', 'contains', 'starts_with', 'ends_with', 'in', +]); -export const COERCIBLE_OPERATORS: Record = { - lt: true, - le: true, - gt: true, - ge: true, - ne: true, - eq: true, -}; +// Comparators whose value token is a list (v1,v2,...). +export const LIST_COMPARATORS: ReadonlySet = new Set(['in', 'not_in']); -export const ALTERNATE_COMPARATOR_NAMES: Record = { - 'eq': 'equals', - 'greater_than': 'gt', - 'greaterThan': 'gt', - 'greater_than_equal': 'ge', - 'greaterThanEqual': 'ge', - 'less_than': 'lt', - 'lessThan': 'lt', - 'less_than_equal': 'le', - 'lessThanEqual': 'le', - 'not_equal': 'ne', - 'notEqual': 'ne', - 'equal': 'equals', - 'sw': 'starts_with', - 'startsWith': 'starts_with', - 'ew': 'ends_with', - 'endsWith': 'ends_with', - 'ct': 'contains', - 'includes': 'in', - '>': 'gt', - '>=': 'ge', - '<': 'lt', - '<=': 'le', - '...': 'between', +// Maps symbol operators to {comparator, verbatim} (§5.1.2 desugaring table). +export const SYMBOL_OPS: Record = { + '=': { comparator: 'eq', negated: false, verbatim: true }, + '===': { comparator: 'eq', negated: false, verbatim: true }, + '==': { comparator: 'eq', negated: false, verbatim: false }, + '!=': { comparator: 'eq', negated: true, verbatim: false }, + '!==': { comparator: 'eq', negated: true, verbatim: true }, + '<': { comparator: 'lt', negated: false, verbatim: false }, + '<=': { comparator: 'le', negated: false, verbatim: false }, + '>': { comparator: 'gt', negated: false, verbatim: false }, + '>=': { comparator: 'ge', negated: false, verbatim: false }, }; -/** Comparators whose value is a list — recognizes `(v1,v2,...)` syntax during parsing. */ -export const LIST_VALUE_COMPARATORS: Set = new Set(['in', 'between']); +// Appendix B compatibility aliases. Maps alias → {comparator, negated, verbatim}. +// verbatim=null means "inherit from context" (FIQL → false). +const ALIASES: Record = { + 'ne': { comparator: 'eq', negated: true, verbatim: false }, + 'equals': { comparator: 'eq', negated: false, verbatim: true }, + 'equal': { comparator: 'eq', negated: false, verbatim: true }, + 'not_equal': { comparator: 'eq', negated: true, verbatim: true }, + 'sw': { comparator: 'starts_with', negated: false, verbatim: false }, + 'ew': { comparator: 'ends_with', negated: false, verbatim: false }, + 'ct': { comparator: 'contains', negated: false, verbatim: false }, + 'includes': { comparator: 'contains', negated: false, verbatim: false }, + 'out': { comparator: 'in', negated: true, verbatim: false }, + 'less_than': { comparator: 'lt', negated: false, verbatim: false }, + 'lessThan': { comparator: 'lt', negated: false, verbatim: false }, + 'less_than_equal': { comparator: 'le', negated: false, verbatim: false }, + 'lessThanEqual': { comparator: 'le', negated: false, verbatim: false }, + 'greater_than': { comparator: 'gt', negated: false, verbatim: false }, + 'greaterThan': { comparator: 'gt', negated: false, verbatim: false }, + 'greater_than_equal': { comparator: 'ge', negated: false, verbatim: false }, + 'greaterThanEqual': { comparator: 'ge', negated: false, verbatim: false }, +}; -/** Base comparators that accept the `not_` prefix to produce a negated form. */ -export const NEGATABLE_BASE_COMPARATORS: Set = new Set([ - 'in', - 'between', - 'starts_with', - 'ends_with', - 'contains', - 'equals', -]); +export type ResolvedComparator = { + comparator: string; + negated: boolean; + verbatim: boolean; + /** 'between' or 'not_between' — caller must desugar to ge+le group. */ + isBetween?: boolean; + betweenNegated?: boolean; +}; /** - * Resolve a comparator name to a (possibly stripped) base comparator and a `negated` flag. - * Existing aliases are preserved as-is. Only the `not_` prefix is stripped, and only when the - * base is a recognized negatable comparator and the full name is not itself an existing alias - * (so `not_equal` keeps its historical mapping to `ne`). + * Resolve a FIQL comparator name (from `=name=` surface form, always interpreted by default) + * to its canonical form. Aliases desugar per Appendix B. */ -export function resolveComparator(comparator: string | undefined): { - comparator: string | undefined; - negated: boolean; -} { - if (comparator == null) return { comparator, negated: false }; - if (ALTERNATE_COMPARATOR_NAMES[comparator]) return { comparator, negated: false }; - if (comparator.startsWith('not_')) { - const base = comparator.slice(4); - const baseResolved = ALTERNATE_COMPARATOR_NAMES[base] || base; - if (NEGATABLE_BASE_COMPARATORS.has(baseResolved)) { - return { comparator: base, negated: true }; +export function resolveFiqlName(name: string): ResolvedComparator { + if (name === 'between') return { comparator: 'between', negated: false, verbatim: false, isBetween: true, betweenNegated: false }; + if (name === 'not_between') return { comparator: 'between', negated: false, verbatim: false, isBetween: true, betweenNegated: true }; + + const alias = ALIASES[name]; + if (alias) return { ...alias }; + + // Generic not_ stripping — only when base is recognized or open-vocabulary. + if (name.startsWith('not_')) { + const base = name.slice(4); + const baseAlias = ALIASES[base]; + if (baseAlias) { + return { comparator: baseAlias.comparator, negated: !baseAlias.negated, verbatim: baseAlias.verbatim }; } + // Open-vocabulary: not_, negated:true. + return { comparator: base, negated: true, verbatim: false }; } - return { comparator, negated: false }; + + // Open-vocabulary / core pass-through. + return { comparator: name, negated: false, verbatim: false }; } diff --git a/src/index.ts b/src/index.ts index 64ba64e..2def0bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,6 @@ -export { Query } from './query.ts'; export { parseQuery } from './parser.ts'; export { QueryError, SyntaxViolation } from './errors.ts'; -export { - SYMBOL_OPERATORS, - COERCIBLE_OPERATORS, - ALTERNATE_COMPARATOR_NAMES, - LIST_VALUE_COMPARATORS, - NEGATABLE_BASE_COMPARATORS, - resolveComparator, -} from './comparators.ts'; -export type { Operator, Comparator, Condition, ConditionGroup, DirectCondition, Sort, Select, SubSelect } from './types.ts'; +export { CORE_COMPARATORS, LIST_COMPARATORS, SYMBOL_OPS, resolveFiqlName } from './comparators.ts'; +export type { + ParseResult, ParseOptions, Group, Condition, SortKey, Projection, Field, Value, +} from './types.ts'; diff --git a/src/parser.ts b/src/parser.ts index 3c1a060..3260b24 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,395 +1,606 @@ -import { Query } from './query.ts'; import { QueryError, SyntaxViolation } from './errors.ts'; -import { - SYMBOL_OPERATORS, - COERCIBLE_OPERATORS, - ALTERNATE_COMPARATOR_NAMES, - LIST_VALUE_COMPARATORS, - resolveComparator, -} from './comparators.ts'; +import { SYMBOL_OPS, LIST_COMPARATORS, resolveFiqlName } from './comparators.ts'; +import type { + ParseResult, ParseOptions, Group, Condition, SortKey, Projection, Field, Value, +} from './types.ts'; -const NEEDS_PARSER = /[()[\]|!<>.]|(=\w*=)/; -const FIQL_OPERATOR_NAME = /^[a-zA-Z_][a-zA-Z_0-9]*$/; +const FIQL_NAME = /^[a-zA-Z_][a-zA-Z_0-9]*$/; + +// Regexes are created fresh per parseQuery call for reentrancy. +// QP: tokenises attribute names and structural operators. +// VP: tokenises value tokens (includes ( ) , as plain chars). +const QP_SRC = '([^?&|=<>!([{\\}\\]),]*)([([{\\}\\])|,&]|[=<>!]*)'; +const VP_SRC = '([^&|=\\[\\]{}]*)([\\[\\]{}]|[&|=]*)'; + +// ── Value decoding ───────────────────────────────────────────────────────── + +function interpretValue(token: string): Value { + if (token === 'null') return null; + if (token === 'true') return true; + if (token === 'false') return false; + const colon = token.indexOf(':'); + if (colon > 0) { + const type = token.slice(0, colon); + const rest = token.slice(colon + 1); + switch (type) { + case 'number': return rest[0] === '$' ? parseInt(rest.slice(1), 36) : +rest; + case 'boolean': return rest === 'true'; + case 'date': return new Date(isNaN(+rest) ? decodeURIComponent(rest) : +rest); + case 'string': return decodeURIComponent(rest); + default: throw new QueryError(`Unknown type prefix '${type}'`); + } + } + return decodeURIComponent(token); +} + +const verbatimValue = (token: string): Value => decodeURIComponent(token); /** - * Parse a query string into a Query object. - * - * @param search - The raw query string (no leading `?`). - * @param target - Optional existing Query to mutate. When provided, semantic errors accumulate - * into `target.parseError` instead of throwing. When omitted a fresh Query is returned and - * errors throw. + * Split a raw path token on literal `.`, decode each segment. + * `%2E` → literal `.` inside a segment (§4.2). */ -export function parseQuery(search: string, target?: Query): Query { - if (!search) return target ?? new Query(); +function splitPath(raw: string): string[] { + return raw.split('.').map(decodeURIComponent); +} - if (!NEEDS_PARSER.test(search)) { - // Fast path: no special operators — return URLSearchParams-backed Query. - if (target) return target; - return new Query(search); +function makeCondition( + path: string[], comparator: string, negated: boolean, raw: string, verbatim: boolean +): Condition { + // Trailing * on == (eq, interpreted) → starts_with. + if (comparator === 'eq' && !verbatim && raw.indexOf('*') > -1) { + if (!raw.endsWith('*')) throw new QueryError('wildcard can only be used at the end of a string'); + const c: Condition = { path, comparator: 'starts_with', value: decodeURIComponent(raw.slice(0, -1)) }; + if (negated) c.negated = true; + return c; } + const value = (verbatim ? verbatimValue : interpretValue)(raw); + const c: Condition = { path, comparator, value }; + if (negated) c.negated = true; + return c; +} - // Parsed path: fresh regex instances per call for reentrancy. - const queryParser = /([^?&|=<>!([{}\]),]*)([([{}\])|,&]|[=<>!]*)/g; - const valueParser = /([^&|=[\]{}]+)([[\]{}]|[&|=]*)/g; +function parseListRaw(raw: string, verbatim: boolean): Value[] { + // Expects `(v1,v2,...)` format. Each element decoded individually. + const inner = raw.slice(1, -1); + if (inner.length === 0) return []; + const decode = verbatim ? verbatimValue : interpretValue; + return inner.split(',').map(decode); +} - let lastIndex = 0; - let parseErrorMessage: string | undefined; +function betweenGroup(path: string[], raw: string, betweenNegated: boolean): Group { + // `(lo,hi)` → and-Group of ge(lo) + le(hi), or or-Group when negated. + if (raw.length < 2 || raw.charCodeAt(0) !== 0x28 || raw.charCodeAt(raw.length - 1) !== 0x29) + throw new SyntaxViolation('between requires value list (lo,hi)'); + const parts = raw.slice(1, -1).split(','); + if (parts.length !== 2) throw new SyntaxViolation('between requires exactly two values'); + const lo = interpretValue(parts[0]); + const hi = interpretValue(parts[1]); + const ge: Condition = { path, comparator: 'ge', value: lo }; + const le: Condition = { path, comparator: 'le', value: hi }; + if (betweenNegated) { ge.negated = true; le.negated = true; } + return { operator: betweenNegated ? 'or' : 'and', terms: [ge, le] }; +} - function recordError(msg: string): void { - const em = `${msg} at position ${lastIndex}`; - parseErrorMessage = parseErrorMessage ? parseErrorMessage + ', ' + em : em; - } +// ── Group accumulator ────────────────────────────────────────────────────── - function decodeProperty(name: string): string | string[] { - if (name.indexOf('.') > -1) return name.split('.').map((p) => decodeURIComponent(p)); - return decodeURIComponent(name); - } +type Term = Condition | Group; - function typedDecoding(value: string): unknown { - if (value === 'null') return null; - if (value.indexOf(':') > -1) { - const colonIdx = value.indexOf(':'); - const type = value.slice(0, colonIdx); - const rest = value.slice(colonIdx + 1); - if (type === 'number') { - if (rest[0] === '$') return parseInt(rest.slice(1), 36); - return +rest; - } - if (type === 'boolean') return rest === 'true'; - if (type === 'date') return new Date(isNaN(+rest) ? decodeURIComponent(rest) : +rest); - if (type === 'string') return decodeURIComponent(rest); - throw new QueryError(`Unknown type ${type}`); - } - return decodeURIComponent(value); - } +type Acc = { + terms: Term[]; + operator?: 'and' | 'or'; + lastPath?: string[]; + chainGroup?: { operator: 'and' | 'or'; terms: Term[] }; +}; - function wildcardDecoding(condition: any, rawValue: string): void { - if (rawValue.indexOf('*') > -1) { - if (rawValue.endsWith('*')) { - condition.comparator = 'starts_with'; - condition.value = decodeURIComponent(rawValue.slice(0, -1)); - } else { - throw new QueryError('wildcard can only be used at the end of a string'); - } - } - } +function newAcc(): Acc { return { terms: [] }; } - function buildCondition( - attribute: any, - rawComparator: string | undefined, - rawValue: string, - valueDecoder: (s: string) => unknown - ): any { - const { comparator: resolvedComparator, negated } = resolveComparator(rawComparator); - let value: unknown; - if ( - LIST_VALUE_COMPARATORS.has(resolvedComparator as string) && - rawValue.length >= 2 && - rawValue.charCodeAt(0) === 0x28 /* ( */ && - rawValue.charCodeAt(rawValue.length - 1) === 0x29 /* ) */ - ) { - const inner = rawValue.slice(1, -1); - value = inner.length === 0 ? [] : inner.split(',').map(valueDecoder); - } else { - value = valueDecoder(rawValue); - } - const condition: any = { comparator: resolvedComparator, attribute: attribute || null, value }; - if (negated) condition.negated = true; - if (rawComparator === 'eq') wildcardDecoding(condition, rawValue); - return condition; +function setGroupOp(acc: Acc, op: 'and' | 'or', recordError: (msg: string) => void): void { + if (acc.terms.length === 0 && !acc.chainGroup) return; + if (acc.operator && acc.operator !== op) + recordError('Cannot mix & and | in one group; use (...) or [...]'); + else acc.operator = op; +} + +function closeChain(acc: Acc): void { + if (acc.chainGroup) { + acc.terms.push(acc.chainGroup as Group); + acc.chainGroup = undefined; } +} - function toSortEntry(sort: any): any { - if (Array.isArray(sort)) { - const sortObject = toSortEntry(sort[0]); - sort[0] = sortObject.attribute; - sortObject.attribute = sort; - return sortObject; +function pushTerm(acc: Acc, term: Term, chainOp: 'and' | 'or' | undefined, recordError: (msg: string) => void): void { + if (chainOp) { + if (!acc.chainGroup) { + const prev = acc.terms.pop(); + if (prev === undefined) { recordError('no preceding condition to chain onto'); return; } + acc.chainGroup = { operator: chainOp, terms: [prev] }; } - if (typeof sort === 'string') { - switch (sort[0]) { - case '-': return { attribute: sort.slice(1), descending: true }; - case '+': return { attribute: sort.slice(1), descending: false }; - default: return { attribute: sort, descending: false }; - } - } - recordError(`Unknown sort type ${sort}`); + acc.chainGroup.terms.push(term); + } else { + closeChain(acc); + acc.terms.push(term); } +} + +function accToGroup(acc: Acc): Group | undefined { + closeChain(acc); + if (acc.terms.length === 0) return undefined; + return { operator: acc.operator ?? 'and', terms: acc.terms }; +} + +// ── Main parse function ──────────────────────────────────────────────────── + +export function parseQuery(search: string, options?: ParseOptions): ParseResult { + const deferErrors = options?.deferErrors ?? false; + if (!search) return {}; + + const qp = new RegExp(QP_SRC, 'g'); + const vp = new RegExp(VP_SRC, 'g'); + let pos = 0; + let errorMsg: string | undefined; - function toSortObject(sort: any[]): any { - const sortObject = toSortEntry(sort[0]); - if (sort.length > 1) sortObject.next = toSortObject(sort.slice(1)); - return sortObject; + function recordError(msg: string): void { + const em = `${msg} at position ${pos}`; + errorMsg = errorMsg ? `${errorMsg}, ${em}` : em; } - function assignOperator(query: any, lastBinaryOperator: string | undefined): void { - if (query.conditions.length > 0) { - if (query.operator) { - if (query.operator !== lastBinaryOperator) - recordError('Can not mix operators within a condition grouping'); + // ── Condition-group parser ─────────────────────────────────────────────── + // Uses QP throughout (no VP switching inside groups — VP is for top-level). + // Call functions are not dispatched here; they're top-level only. + + function parseCondGroup(closeCh: string): Acc { + const acc = newAcc(); + let path: string[] | undefined; + let rawComp: string | undefined; + let fiqlMode = false; + let verbatim = false; + let expectDelim = false; + let chainOp: 'and' | 'or' | undefined; + let chainPath: string[] | undefined; // path for &=/|= continuation + + function finishCond(rawVal: string): void { + if (path === undefined) return; + const rp = path; + const rc = rawComp ?? '='; + if (fiqlMode) { + const r = resolveFiqlName(rc); + if (r.isBetween) { + pushTerm(acc, betweenGroup(rp, rawVal, r.betweenNegated ?? false), chainOp, recordError); + } else { + const isListComp = LIST_COMPARATORS.has(r.comparator) || LIST_COMPARATORS.has(`not_${r.comparator}`); + let value: Value; + if (isListComp && rawVal.charCodeAt(0) === 0x28) { + value = parseListRaw(rawVal, r.verbatim); + } else { + value = (r.verbatim ? verbatimValue : interpretValue)(rawVal); + } + const c: Condition = { path: rp, comparator: r.comparator, value }; + if (r.negated) c.negated = true; + pushTerm(acc, c, chainOp, recordError); + } } else { - query.operator = lastBinaryOperator; + const sym = SYMBOL_OPS[rc]; + if (!sym) { recordError(`unknown operator '${rc}'`); } + else { pushTerm(acc, makeCondition(rp, sym.comparator, sym.negated, rawVal, sym.verbatim), chainOp, recordError); } } + if (!chainOp) acc.lastPath = rp; + path = undefined; rawComp = undefined; fiqlMode = false; verbatim = false; chainOp = undefined; chainPath = undefined; } - } - function parseBlock(query: any, expectedEnd: string): any { - // Ensure Query instances have conditions ready for the parsed path. - // Inner groups are created with new Query() whose conditions start undefined. - if (query instanceof Query && query.conditions === undefined) query.conditions = []; - - let parser = queryParser; + qp.lastIndex = pos; let match: RegExpExecArray | null; - let attribute: any; - let comparator: string | undefined; - let expectingDelimiter: boolean | undefined; - let expectingValue: boolean | undefined; - let valueDecoder: (s: string) => unknown = decodeURIComponent; - let lastBinaryOperator: string | undefined; - - while ((match = parser.exec(search))) { - lastIndex = parser.lastIndex; - const [, value, operator] = match; - - if (expectingDelimiter) { - if (value) recordError(`expected operator, but encountered '${value}'`); - expectingDelimiter = false; - expectingValue = false; - } else { - expectingValue = true; + while ((match = qp.exec(search))) { + pos = qp.lastIndex; + const [, val, op] = match; + + if (expectDelim) { + if (val) recordError(`expected operator, got '${val}'`); + expectDelim = false; } - let entry: any; - switch (operator) { + switch (op) { case '=': - if (attribute != undefined) { - if (FIQL_OPERATOR_NAME.test(value)) comparator = value; - else recordError(`invalid FIQL operator ${value}`); - valueDecoder = typedDecoding; + if (path !== undefined) { + // Second '=' of FIQL: path=name=value. + if (!FIQL_NAME.test(val)) { recordError(`invalid FIQL name '${val}'`); break; } + rawComp = val; fiqlMode = true; + } else if (chainPath) { + // &= chain: already have path, this is the FIQL name. + if (!FIQL_NAME.test(val)) { recordError(`invalid FIQL name '${val}'`); break; } + path = chainPath; rawComp = val; fiqlMode = true; } else { - valueDecoder = decodeURIComponent; - comparator = 'equals'; - if (!value) recordError(`attribute must be specified before equality comparator`); - attribute = decodeProperty(value); + if (!val) { recordError('path required before ='); break; } + path = splitPath(val); rawComp = '='; verbatim = true; } break; - case '==': - case '!=': - case '<': - case '<=': - case '>': - case '>=': - case '===': - case '!==': - comparator = SYMBOL_OPERATORS[operator]; - valueDecoder = COERCIBLE_OPERATORS[comparator] ? typedDecoding : decodeURIComponent; - if (!value) recordError(`attribute must be specified before comparator ${operator}`); - attribute = decodeProperty(value); - break; - case '&=': - case '|=': - case '|': - case '&': - case '': - case undefined: - if (attribute == null) { - if (attribute === undefined) { - if (expectedEnd) - recordError( - `expected '${expectedEnd}', but encountered ${operator?.[0] ? "'" + operator[0] + "'" : 'end of string'}` - ); - recordError(`no comparison specified before ${operator ? "'" + operator + "'" : 'end of string'}`); - } + case '==': case '===': case '!=': case '!==': case '<': case '<=': case '>': case '>=': + if (chainPath) { + path = chainPath; rawComp = op; } else { - if (!query.conditions) recordError('conditions/comparisons are not allowed in a property list'); - const condition = buildCondition(attribute, comparator, value, valueDecoder); - if (attribute === '') { - const lastCondition = query.conditions[query.conditions.length - 1]; - lastCondition.chainedConditions = lastCondition.chainedConditions || []; - lastCondition.chainedConditions.push(condition); - lastCondition.operator = lastBinaryOperator; - } else { - assignOperator(query, lastBinaryOperator); - query.conditions.push(condition); - } - } - if (operator === '&') { - lastBinaryOperator = 'and'; - attribute = undefined; - } else if (operator === '|') { - lastBinaryOperator = 'or'; - attribute = undefined; - } else if (operator === '&=') { - lastBinaryOperator = 'and'; - attribute = ''; - } else if (operator === '|=') { - lastBinaryOperator = 'or'; - attribute = ''; + if (!val) { recordError(`path required before ${op}`); break; } + path = splitPath(val); rawComp = op; } + fiqlMode = false; + break; + case '&': case '|': { + const lop: 'and' | 'or' = op === '&' ? 'and' : 'or'; + if (path !== undefined) finishCond(val); + closeChain(acc); + setGroupOp(acc, lop, recordError); + break; + } + case '&=': case '|=': { + if (path !== undefined) finishCond(val); + chainOp = op === '&=' ? 'and' : 'or'; + chainPath = acc.lastPath ?? (acc.chainGroup?.terms.at(-1) as Condition | undefined)?.path; + if (!chainPath) recordError('no preceding condition for &=/|='); + break; + } + case '': case undefined: + if (path !== undefined) finishCond(val); break; case ',': - if (query.conditions) { - recordError('conditions/comparisons are not allowed in a property list'); - } else { - query.push(decodeProperty(value)); - } - attribute = undefined; + recordError("unexpected ','"); break; case '(': { - queryParser.lastIndex = lastIndex; - const args: any = parseBlock(value ? [] : new Query(), ')'); - switch (value) { - case '': - assignOperator(query, lastBinaryOperator); - query.conditions.push(args); - break; - case 'limit': - switch (args.length) { - case 1: - query.limit = +args[0]; - break; - case 2: - query.offset = +args[0]; - query.limit = args[1] - query.offset; - break; - default: - recordError('limit must have 1 or 2 arguments'); - } - break; - case 'select': - if (Array.isArray(args[0]) && args.length === 1 && !args[0].name) { - query.select = args[0]; - query.select.asArray = true; - } else if (args.length === 1) { - query.select = args[0]; - } else if (args.length === 2 && args[1] === '') { - query.select = args.slice(0, 1); - } else { - query.select = args; - } - break; - case 'group-by': - recordError('group by is not implemented yet'); - break; // fix: original falls through into sort - case 'sort': - query.sort = toSortObject(args); - break; - default: - recordError(`unknown query function call ${value}`); - } - if (search[lastIndex] === ',') { - parser.lastIndex = ++lastIndex; - } else { - expectingDelimiter = true; - } - attribute = null; + // Nested condition group. + if (val) { recordError(`unexpected name '${val}' before '('`); break; } + qp.lastIndex = pos; + const inner = parseCondGroup(')'); + pos = qp.lastIndex; + const grp = accToGroup(inner); + if (grp) { closeChain(acc); acc.terms.push(grp); acc.lastPath = undefined; } + if (search[pos] === ',') { qp.lastIndex = ++pos; } else expectDelim = true; + path = undefined; chainPath = undefined; break; } - case '{': - if (query.conditions) recordError('property sets are not allowed in a queries'); - if (!value) recordError('property sets must have a defined parent property name'); - queryParser.lastIndex = lastIndex; - entry = parseBlock([], '}'); - entry.name = value; - query.push(entry); - if (search[lastIndex] === ',') { - parser.lastIndex = ++lastIndex; - } else { - expectingDelimiter = true; - } - break; - case '[': - queryParser.lastIndex = lastIndex; - if (value) { - entry = parseBlock(new Query(), ']'); - entry.name = value; - } else { - entry = parseBlock(query.conditions ? new Query() : [], ']'); - } - if (query.conditions) { - assignOperator(query, lastBinaryOperator); - if (search[lastIndex] === '=') { - valueDecoder = decodeURIComponent; - comparator = 'equals'; - attribute = decodeProperty(value); - parser.lastIndex = ++lastIndex; - break; - } else { - query.conditions.push(entry); - attribute = null; - } - } else { - query.push(entry); - } - if (search[lastIndex] === ',') { - parser.lastIndex = ++lastIndex; - } else { - expectingDelimiter = true; - } + case '[': { + if (val) { recordError(`unexpected name '${val}' before '['`); break; } + qp.lastIndex = pos; + const inner = parseCondGroup(']'); + pos = qp.lastIndex; + const grp = accToGroup(inner); + if (grp) { closeChain(acc); acc.terms.push(grp); acc.lastPath = undefined; } + if (search[pos] === ',') { qp.lastIndex = ++pos; } else expectDelim = true; + path = undefined; chainPath = undefined; break; - case ')': - case ']': - case '}': - if (expectedEnd === operator[0]) { - if (query.conditions) { - if (attribute) { - const condition = buildCondition(attribute, comparator || 'equals', value, valueDecoder); - assignOperator(query, lastBinaryOperator); - query.conditions.push(condition); - } else if (value) { - recordError('no attribute or comparison specified'); - } - } else if (value || (query.length > 0 && expectingValue)) { - query.push(decodeProperty(value)); - } - return query; - } else if (expectedEnd) { - recordError(`expected '${expectedEnd}', but encountered '${operator[0]}'`); - } else { - recordError(`unexpected token '${operator[0]}'`); + } + case ')': case ']': case '}': { + const ch = op[0]; + if (closeCh === ch) { + if (path !== undefined) finishCond(val); + else if (val) recordError('unexpected value without path'); + return acc; } + recordError(closeCh ? `expected '${closeCh}', got '${ch}'` : `unexpected '${ch}'`); break; + } default: - recordError(`unexpected operator '${operator}'`); + recordError(`unexpected token '${op}'`); } - if (expectedEnd !== ')') { - parser = attribute ? valueParser : queryParser; - parser.lastIndex = lastIndex; - } - if (lastIndex === search.length) return query; + qp.lastIndex = pos; + if (pos === search.length) break; } - if (expectedEnd) recordError(`expected '${expectedEnd}', but encountered end of string`); - return query; + if (closeCh) recordError(`expected '${closeCh}', got end of string`); + return acc; } - const result = target ?? new Query(); - result.conditions = []; - queryParser.lastIndex = 0; - - try { - parseBlock(result, ''); - if (lastIndex !== search.length) - recordError(`Unable to parse query, unexpected end of query`); - if (parseErrorMessage) { - const err = new SyntaxViolation(parseErrorMessage); - if (target) { - target.parseError = err; + // ── Top-level parser (condition group + call functions) ────────────────── + // Switches between QP and VP based on whether a comparator was just seen. + + const result: ParseResult = {}; + const topAcc = newAcc(); + + let path: string[] | undefined; + let rawComp: string | undefined; + let fiqlMode = false; + let verbatim = false; + let expectDelim = false; + let chainOp: 'and' | 'or' | undefined; + let chainPath: string[] | undefined; + + function finishTopCond(rawVal: string): void { + if (path === undefined) return; + const rp = path; + const rc = rawComp ?? '='; + if (fiqlMode) { + const r = resolveFiqlName(rc); + if (r.isBetween) { + pushTerm(topAcc, betweenGroup(rp, rawVal, r.betweenNegated ?? false), chainOp, recordError); } else { - throw err; + const isListComp = LIST_COMPARATORS.has(r.comparator) || LIST_COMPARATORS.has(`not_${r.comparator}`); + let value: Value; + if (isListComp && rawVal.charCodeAt(0) === 0x28) { + value = parseListRaw(rawVal, r.verbatim); + } else { + value = (r.verbatim ? verbatimValue : interpretValue)(rawVal); + } + const c: Condition = { path: rp, comparator: r.comparator, value }; + if (r.negated) c.negated = true; + pushTerm(topAcc, c, chainOp, recordError); + } + } else { + const sym = SYMBOL_OPS[rc]; + if (!sym) { recordError(`unknown operator '${rc}'`); } + else { pushTerm(topAcc, makeCondition(rp, sym.comparator, sym.negated, rawVal, sym.verbatim), chainOp, recordError); } + } + if (!chainOp) topAcc.lastPath = rp; + path = undefined; rawComp = undefined; fiqlMode = false; verbatim = false; chainOp = undefined; chainPath = undefined; + } + + // Sub-parsers for call function arguments. + // Each creates its own regex but shares closure `pos`. + + function parsePlainArgs(callName: string): string[] { + const args: string[] = []; + const p = new RegExp(QP_SRC, 'g'); + p.lastIndex = pos; + let m: RegExpExecArray | null; + while ((m = p.exec(search))) { + pos = p.lastIndex; + const [, val, op] = m; + args.push(val); + if (op === ')') return args; + if (op === ',') continue; + if (pos === search.length) { recordError(`expected ')' for ${callName}`); return args; } + } + recordError(`expected ')' for ${callName}`); + return args; + } + + function parseSortArgs(): SortKey[] { + const keys: SortKey[] = []; + const p = new RegExp(QP_SRC, 'g'); + p.lastIndex = pos; + let m: RegExpExecArray | null; + while ((m = p.exec(search))) { + pos = p.lastIndex; + const [, val, op] = m; + if (val) { + let raw = val; + let direction: 'asc' | 'desc' = 'asc'; + if (raw[0] === '+') raw = raw.slice(1); + else if (raw[0] === '-') { direction = 'desc'; raw = raw.slice(1); } + keys.push({ path: splitPath(raw), direction }); + } + if (op === ')') return keys; + if (op === ',') continue; + if (pos === search.length) { recordError("expected ')' for sort"); return keys; } + } + recordError("expected ')' for sort"); + return keys; + } + + type RawField = { path: string[]; nested?: RawField[]; tuple?: boolean }; + + function parseSelectList(closeCh: string): RawField[] { + const fields: RawField[] = []; + const p = new RegExp(QP_SRC, 'g'); + p.lastIndex = pos; + let m: RegExpExecArray | null; + while ((m = p.exec(search))) { + pos = p.lastIndex; + const [, val, op] = m; + if (op === closeCh || (op === '' && pos === search.length)) { + if (val) fields.push({ path: splitPath(val) }); + if (op !== closeCh) recordError(`expected '${closeCh}' for select`); + return fields; + } + if (op === ')' || op === ']' || op === '}') { + if (op === closeCh) { if (val) fields.push({ path: splitPath(val) }); return fields; } + recordError(`expected '${closeCh}', got '${op}'`); + return fields; + } + if (op === ',') { if (val) fields.push({ path: splitPath(val) }); continue; } + if (op === '{') { + // `rel{x,y}` nested sub-select. + const nested = parseSelectList('}'); + fields.push({ path: splitPath(val), nested }); + continue; + } + if (op === '[') { + if (val) { + // `rel[select(x,y)]` — consume `select(`, then list, then `)]`. + const selectRe = /select\(/g; + selectRe.lastIndex = pos; + const sm = selectRe.exec(search); + if (sm && sm.index === pos) { + pos = selectRe.lastIndex; + const nested = parseSelectList(')'); + if (search[pos] === ']') pos++; + fields.push({ path: splitPath(val), nested }); + } else { + recordError(`expected 'select(' after '${val}['`); + } + } else { + // `[a,b]` tuple field. + const items = parseSelectList(']'); + fields.push({ path: [], nested: items, tuple: true }); + } + continue; } + // Structural op like `=` — unexpected in select context. + if (val) fields.push({ path: splitPath(val) }); + } + recordError(`expected '${closeCh}' for select`); + return fields; + } + + function rawFieldsToProjection(fields: RawField[], trailingComma: boolean): Projection { + if (fields.length === 1 && fields[0].tuple) { + const f = fields[0]; + return { + mode: 'tuples', + fields: (f.nested ?? []).map((rf) => rawToField(rf)), + }; } - return result; - } catch (error: any) { - error.statusCode = 400; - if (!(error instanceof SyntaxViolation)) { - error.message = `Unable to parse query, ${error.message} at position ${lastIndex} in '${search}'`; - if (parseErrorMessage) error.message += ', ' + parseErrorMessage; + const fs = fields.map((rf) => rawToField(rf)); + // `select(a)` → values; `select(a,b)` or `select(a,)` → records. + const mode: 'values' | 'records' = (fs.length === 1 && !fields[0].nested && !trailingComma) ? 'values' : 'records'; + return { mode, fields: fs }; + } + + function rawToField(rf: RawField): Field { + if (rf.nested) return { path: rf.path, projection: rawFieldsToProjection(rf.nested, false) }; + return { path: rf.path }; + } + + function parseSelectArgs(): Projection { + // Capture position before trailing-comma detection. + const startPos = pos; + const fields = parseSelectList(')'); + // Detect trailing comma: search backwards from the `)` position. + const beforeClose = search.slice(startPos, pos - 1).trimEnd(); + const trailingComma = beforeClose.endsWith(','); + return rawFieldsToProjection(fields, trailingComma); + } + + // Top-level loop. Switches between QP and VP based on whether we're expecting a value. + qp.lastIndex = 0; + + function nextParser(): RegExp { + // Use VP when we have both path and comparator set (expecting value token). + return (path !== undefined && rawComp !== undefined) ? vp : qp; + } + + let match: RegExpExecArray | null; + while (pos < search.length) { + const p = nextParser(); + p.lastIndex = pos; + match = p.exec(search); + if (!match) break; + pos = p.lastIndex; + const [, val, op] = match; + + if (expectDelim) { + if (val) recordError(`expected operator, got '${val}'`); + expectDelim = false; + } + + if (p === vp) { + // Value token: finish the pending condition. + finishTopCond(val); + // op from VP: `&`, `|`, `=`, `[`, `]`, `{`, `}`, or ''. + // Handle the operator (logical separator or end-of-string). + if (op === '&' || op === '|') { + const lop: 'and' | 'or' = op === '&' ? 'and' : 'or'; + closeChain(topAcc); + setGroupOp(topAcc, lop, recordError); + } else if (op === '&=' || op === '|=') { + chainOp = op === '&=' ? 'and' : 'or'; + chainPath = topAcc.lastPath ?? (topAcc.chainGroup?.terms.at(-1) as Condition | undefined)?.path; + if (!chainPath) recordError('no preceding condition for &=/|='); + } + // Other ops (empty, brackets) fall through to next iteration. + continue; } - if (target) { - target.parseError = error; - return target; + + // QP path. + switch (op) { + case '=': + if (path !== undefined) { + if (!FIQL_NAME.test(val)) { recordError(`invalid FIQL name '${val}'`); break; } + rawComp = val; fiqlMode = true; + } else if (chainPath) { + if (!FIQL_NAME.test(val)) { recordError(`invalid FIQL name '${val}'`); break; } + path = chainPath; rawComp = val; fiqlMode = true; + } else { + if (!val) { recordError('path required before ='); break; } + path = splitPath(val); rawComp = '='; verbatim = true; + } + break; + case '==': case '===': case '!=': case '!==': case '<': case '<=': case '>': case '>=': + if (chainPath) { path = chainPath; rawComp = op; } + else { if (!val) { recordError(`path required before ${op}`); break; } path = splitPath(val); rawComp = op; } + fiqlMode = false; + break; + case '&': case '|': { + const lop: 'and' | 'or' = op === '&' ? 'and' : 'or'; + if (path !== undefined) finishTopCond(val); + closeChain(topAcc); + setGroupOp(topAcc, lop, recordError); + break; + } + case '&=': case '|=': + if (path !== undefined) finishTopCond(val); + chainOp = op === '&=' ? 'and' : 'or'; + chainPath = topAcc.lastPath ?? (topAcc.chainGroup?.terms.at(-1) as Condition | undefined)?.path; + if (!chainPath) recordError('no preceding condition for &=/|='); + break; + case '': case undefined: + if (path !== undefined) finishTopCond(val); + break; + case ',': + recordError("unexpected ','"); + break; + case '(': { + if (val) { + // Call function. + switch (val) { + case 'select': result.select = parseSelectArgs(); break; + case 'sort': result.sort = parseSortArgs(); break; + case 'limit': { + const args = parsePlainArgs('limit'); + if (args.length === 1) result.limit = +args[0]; + else if (args.length === 2) { result.offset = +args[0]; result.limit = +args[1] - result.offset; } + else recordError('limit takes 1 or 2 arguments'); + break; + } + case 'group-by': + parsePlainArgs('group-by'); + recordError('group-by is not implemented'); + break; + default: + parsePlainArgs(val); + recordError(`unknown call function '${val}'`); + } + if (search[pos] === ',') pos++; + else expectDelim = true; + path = undefined; chainPath = undefined; + } else { + // Anonymous condition group. + qp.lastIndex = pos; + const inner = parseCondGroup(')'); + pos = qp.lastIndex; + const grp = accToGroup(inner); + if (grp) { closeChain(topAcc); topAcc.terms.push(grp); topAcc.lastPath = undefined; } + if (search[pos] === ',') pos++; + else expectDelim = true; + path = undefined; chainPath = undefined; + } + break; + } + case '[': { + if (val) { recordError(`unexpected name '${val}' before '['`); break; } + qp.lastIndex = pos; + const inner = parseCondGroup(']'); + pos = qp.lastIndex; + const grp = accToGroup(inner); + if (grp) { closeChain(topAcc); topAcc.terms.push(grp); topAcc.lastPath = undefined; } + if (search[pos] === ',') pos++; + else expectDelim = true; + path = undefined; chainPath = undefined; + break; + } + case ')': case ']': case '}': + recordError(`unexpected '${op[0]}'`); + break; + default: + recordError(`unexpected token '${op}'`); } - throw error; } + + if (path !== undefined) finishTopCond(''); + closeChain(topAcc); + const filter = accToGroup(topAcc); + if (filter) result.filter = filter; + + if (errorMsg) { + const err = new SyntaxViolation(`Unable to parse query: ${errorMsg}`); + if (deferErrors) { result.parseError = err; } + else throw err; + } + + return result; } diff --git a/src/query.ts b/src/query.ts deleted file mode 100644 index 384c765..0000000 --- a/src/query.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { Condition, Operator, Sort, Select } from './types.ts'; - -export class Query extends URLSearchParams { - declare conditions: Condition[] | undefined; - declare operator: Operator | undefined; - // @ts-ignore — shadows URLSearchParams.prototype.sort; own-property set in constructor - declare sort: Sort | undefined; - declare select: Select | undefined; - declare limit: number | undefined; - declare offset: number | undefined; - declare parseError: Error | undefined; - /** Set when this Query is used as a sub-select container (`rel[...]` syntax). */ - declare name: string | undefined; - - constructor(init?: string | URLSearchParams | Record | string[][]) { - super(init as any); - // Create own property so assignment of a Sort object shadows the inherited - // URLSearchParams.prototype.sort method. - Object.defineProperty(this, 'sort', { - value: undefined, - writable: true, - enumerable: true, - configurable: true, - }); - } -} diff --git a/src/types.ts b/src/types.ts index 612a912..a91d64f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,62 +1,44 @@ -export type Operator = 'and' | 'or'; - -export type Comparator = - | 'between' - | 'contains' - | 'ends_with' - | 'eq' - | 'equals' - | 'gt' - | 'ge' - | 'lt' - | 'le' - | 'greater_than' - | 'greater_than_equal' - | 'in' - | 'less_than' - | 'less_than_equal' - | 'ne' - | 'not_equal' - | 'starts_with'; - -/** - * A direct (leaf) condition. Consumers read `c[0] ?? c.attribute` and `c[1] ?? c.value` - * to handle both parsed objects and URLSearchParams [name, value] tuples from the fast path. - */ -export interface DirectCondition { - attribute?: string | string[] | null; - comparator?: string; - value?: V; +// §6 canonical parsed representation — language-neutral; JSON-serializable. + +export type Value = string | number | boolean | null | Date | Value[]; + +export interface Condition { + path: string[]; + comparator: string; negated?: boolean; - chainedConditions?: Condition[]; - /** Internal: comparator applied to chained conditions. */ - operator?: Operator; + value: Value; +} + +export interface Group { + operator: 'and' | 'or'; + terms: (Condition | Group)[]; } -export interface ConditionGroup { - conditions?: Condition[]; - operator?: Operator; +export interface SortKey { + path: string[]; + direction: 'asc' | 'desc'; } -export type Condition = DirectCondition & ConditionGroup; +export interface Field { + path: string[]; + projection?: Projection; +} -/** Linked-list sort descriptor. */ -export interface Sort { - attribute: string | string[]; - descending?: boolean; - next?: Sort; +export interface Projection { + mode: 'records' | 'values' | 'tuples'; + fields: Field[]; } -export interface SubSelect { - name: string; - select: (string | SubSelect)[]; +export interface ParseResult { + filter?: Group; + sort?: SortKey[]; + select?: Projection; + limit?: number; + offset?: number; + /** Only present when parseQuery is called with {deferErrors: true}. */ + parseError?: import('./errors.ts').QueryError; } -/** - * Four polymorphic shapes: - * 1. `string[]` — flat attribute list - * 2. `(string | SubSelect)[]` — nested via `rel{a,b}` brace syntax - * 3. Array with `.asArray = true` — from `select([a,b])` syntax - * 4. A Query object — from `rel[select(a,b)]` bracket syntax (has `.name`, `.select`) - */ -export type Select = any[]; +export interface ParseOptions { + deferErrors?: boolean; +} diff --git a/test/v2/parse.test.ts b/test/v2/parse.test.ts index b198ba2..3579ef2 100644 --- a/test/v2/parse.test.ts +++ b/test/v2/parse.test.ts @@ -1,444 +1,539 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { parseQuery, Query, resolveComparator } from '../../src/index.ts'; - -// --------------------------------------------------------------------------- -// Ported from harper/unitTests/resources/query-parse.test.js -// --------------------------------------------------------------------------- - -describe('Parsing queries', () => { - it('Basic AND query', () => { - const query = parseQuery('id=1&name=2'); - const conditions = Array.from(query); - assert.equal(conditions.length, 2); - assert.equal((conditions[0] as any)[0], 'id'); - assert.equal((conditions[0] as any)[1], '1'); - assert.equal((conditions[1] as any)[0], 'name'); - assert.equal((conditions[1] as any)[1], '2'); - }); - - it('Basic OR query', () => { - const query = parseQuery('id=1|name=2'); - assert.equal(query.operator, 'or'); - assert.equal(query.conditions!.length, 2); - assert.equal(query.conditions![0].attribute, 'id'); - assert.equal(query.conditions![0].value, '1'); - assert.equal(query.conditions![1].attribute, 'name'); - assert.equal(query.conditions![1].value, '2'); - }); - - it('Basic AND and nested OR query', () => { - const query = parseQuery('id=1&(value=gt=4|name=2)'); - assert.equal(query.conditions!.length, 2); - assert.equal(query.conditions![0].attribute, 'id'); - assert.equal(query.conditions![0].value, '1'); - assert.equal((query.conditions![1] as any).operator, 'or'); - assert.equal((query.conditions![1] as any).conditions[0].attribute, 'value'); - assert.equal((query.conditions![1] as any).conditions[0].comparator, 'gt'); - assert.equal((query.conditions![1] as any).conditions[1].comparator, 'equals'); - assert.equal((query.conditions![1] as any).conditions[1].value, '2'); - }); - - it('Basic OR and nested AND/OR query', () => { - const query = parseQuery('(value!=4&name=2)|id=5|(foo=bar&name=2&(value=gt=4|name=2))'); - assert.equal(query.operator, 'or'); - assert.equal(query.conditions!.length, 3); - const g0 = query.conditions![0] as any; - assert.equal(g0.operator, 'and'); - assert.equal(g0.conditions[0].attribute, 'value'); - assert.equal(g0.conditions[0].comparator, 'ne'); - assert.equal(g0.conditions[0].value, '4'); - assert.equal(g0.conditions[1].attribute, 'name'); - assert.equal(g0.conditions[1].comparator, 'equals'); - assert.equal(g0.conditions[1].value, '2'); - const c1 = query.conditions![1] as any; - assert.equal(c1.attribute, 'id'); - assert.equal(c1.value, '5'); - const g2 = query.conditions![2] as any; - assert.equal(g2.operator, 'and'); - assert.equal(g2.conditions[0].attribute, 'foo'); - assert.equal(g2.conditions[0].comparator, 'equals'); - assert.equal(g2.conditions[0].value, 'bar'); - assert.equal(g2.conditions[1].attribute, 'name'); - assert.equal(g2.conditions[1].comparator, 'equals'); - assert.equal(g2.conditions[1].value, '2'); - assert.equal(g2.conditions[2].operator, 'or'); - assert.equal(g2.conditions[2].conditions[0].attribute, 'value'); - assert.equal(g2.conditions[2].conditions[0].comparator, 'gt'); - assert.equal(g2.conditions[2].conditions[0].value, '4'); - assert.equal(g2.conditions[2].conditions[1].comparator, 'equals'); - assert.equal(g2.conditions[2].conditions[1].value, '2'); - }); - - it('OR and nested AND/OR query with brackets and parens in values', () => { - const query = parseQuery('[value!=4&name=2]|id=5|[foo=ba)r&name=2&[value=gt=(4)|name=2]]|id=6'); - assert.equal(query.operator, 'or'); - assert.equal(query.conditions!.length, 4); - const g0 = query.conditions![0] as any; - assert.equal(g0.operator, 'and'); - assert.equal(g0.conditions[0].attribute, 'value'); - assert.equal(g0.conditions[0].comparator, 'ne'); - assert.equal(g0.conditions[0].value, '4'); - assert.equal(g0.conditions[1].attribute, 'name'); - assert.equal(g0.conditions[1].comparator, 'equals'); - assert.equal(g0.conditions[1].value, '2'); - const c1 = query.conditions![1] as any; - assert.equal(c1.attribute, 'id'); - assert.equal(c1.value, '5'); - const g2 = query.conditions![2] as any; - assert.equal(g2.operator, 'and'); - assert.equal(g2.conditions[0].attribute, 'foo'); - assert.equal(g2.conditions[0].comparator, 'equals'); - assert.equal(g2.conditions[0].value, 'ba)r'); - assert.equal(g2.conditions[1].attribute, 'name'); - assert.equal(g2.conditions[1].comparator, 'equals'); - assert.equal(g2.conditions[1].value, '2'); - assert.equal(g2.conditions[2].operator, 'or'); - assert.equal(g2.conditions[2].conditions[0].attribute, 'value'); - assert.equal(g2.conditions[2].conditions[0].comparator, 'gt'); - assert.equal(g2.conditions[2].conditions[0].value, '(4)'); - assert.equal(g2.conditions[2].conditions[1].comparator, 'equals'); - assert.equal(g2.conditions[2].conditions[1].value, '2'); - const c3 = query.conditions![3] as any; - assert.equal(c3.attribute, 'id'); - }); - - it('Query and select and limit', () => { - const query = parseQuery('id=1&name=2&select(id,name)&limit(10)'); - assert.equal(query.conditions!.length, 2); - assert.equal(query.conditions![0].attribute, 'id'); - assert.equal(query.conditions![0].value, '1'); - assert.equal(query.conditions![1].attribute, 'name'); - assert.equal(query.conditions![1].value, '2'); - assert.equal(query.select!.length, 2); - assert.equal(query.select![0], 'id'); - assert.equal(query.select![1], 'name'); - assert.equal(query.limit, 10); - }); - - it('Limit with offset', () => { - const query = parseQuery('limit(5,10)'); - assert.equal(query.conditions!.length, 0); - assert.equal(query.offset, 5); - assert.equal(query.limit, 5); - }); - - it('Coercible vs strict', () => { - const query = parseQuery( - 'id=1&foo==number:5&bar==null&baz!=boolean:true&qux!=date:2024-01-05T20%3A07%3A27.955Z&strict===number:5' - ); - assert.equal(query.conditions!.length, 6); - assert.equal(query.conditions![0].attribute, 'id'); - assert.equal(query.conditions![0].value, '1'); - assert.equal(query.conditions![1].value, 5); - assert.equal(query.conditions![2].value, null); - assert.equal(query.conditions![3].value, true); - assert.ok(query.conditions![4].value instanceof Date); - assert.equal(query.conditions![5].value, 'number:5'); - }); - - it('Coerce date', () => { - const query = parseQuery('time=lt=date:2024-01-05T20%3A07%3A27.955Z&time=gt=date:1602872124871'); - assert.equal(query.conditions!.length, 2); - assert.equal(query.conditions![0].attribute, 'time'); - assert.equal((query.conditions![0].value as Date).getTime(), new Date('2024-01-05T20:07:27.955Z').getTime()); - assert.equal((query.conditions![1].value as Date).getTime(), 1602872124871); - }); - - it('Nested select', () => { - const query = parseQuery('select(related{name,otherTable{other_name}},id,name)'); - assert.equal(query.conditions!.length, 0); - assert.equal(query.select!.length, 3); - assert.equal((query.select![0] as any).name, 'related'); - assert.equal((query.select![0] as any).length, 2); - assert.equal((query.select![0] as any)[0], 'name'); - assert.equal((query.select![0] as any)[1].name, 'otherTable'); - assert.equal((query.select![0] as any)[1].length, 1); - assert.equal((query.select![0] as any)[1][0], 'other_name'); - }); - - it('Nested select using select', () => { - const query = parseQuery('select(related[select(name,otherTable[select(other_name,)])],id,name)'); - assert.equal(query.conditions!.length, 0); - assert.equal(query.select!.length, 3); - assert.equal((query.select![0] as any).name, 'related'); - assert.equal((query.select![0] as any).select.length, 2); - assert.equal((query.select![0] as any).select[0], 'name'); - assert.equal((query.select![0] as any).select[1].name, 'otherTable'); - assert.equal((query.select![0] as any).select[1].select.length, 1); - assert.equal((query.select![0] as any).select[1].select[0], 'other_name'); - }); - - it('Multi-part properties', () => { - const query = parseQuery('name.subname=2'); - assert.equal(query.conditions!.length, 1); - assert.deepEqual(query.conditions![0].attribute, ['name', 'subname']); - }); - - it('Multi-part properties in sort', () => { - const query = parseQuery('name.subname=2&sort(name.subname)'); - assert.equal(query.conditions!.length, 1); - assert.deepEqual(query.conditions![0].attribute, ['name', 'subname']); - assert.deepEqual(query.sort!.attribute, ['name', 'subname']); - }); - - it('Multi-part properties in complex sort', () => { - const query = parseQuery('name.subname=2&sort(+name.subname,-otherName)'); - assert.deepEqual(query.sort!.attribute, ['name', 'subname']); - assert.equal(query.sort!.descending, false); - assert.equal(query.sort!.next!.attribute, 'otherName'); - assert.equal(query.sort!.next!.descending, true); - }); - - it('Union with calls', () => { - const query = parseQuery('select(name,age)&name=2|name=3&sort(+name)'); - assert.equal(query.sort!.attribute, 'name'); - assert.equal(query.operator, 'or'); - assert.equal(query.conditions!.length, 2); - assert.deepEqual(query.select, ['name', 'age']); - }); - - it('Bracket/array parameter', () => { - const query = parseQuery('itemIds[]=1&itemIds[]=2'); - assert.equal(query.conditions!.length, 2); - assert.equal(query.conditions![0].value, '1'); - assert.equal(query.conditions![1].value, '2'); - }); - - it('Bad calls', () => { - assert.throws(() => parseQuery('limit(5,10'), /expected '\)'/); - assert.throws(() => parseQuery('unknown(5,10)'), /unknown query function call/); - assert.throws(() => parseQuery('select([)'), /expected '\]'/); - assert.throws(() => parseQuery('select)'), /unexpected token '\)'/); +import { parseQuery, resolveFiqlName } from '../../src/index.ts'; +import type { Condition, Group, ParseResult } from '../../src/index.ts'; + +// Helpers +function cond(path: string[], comparator: string, value: unknown, negated?: boolean): Condition { + const c: Condition = { path, comparator, value: value as any }; + if (negated) c.negated = true; + return c; +} +function andGrp(...terms: (Condition | Group)[]): Group { return { operator: 'and', terms }; } +function orGrp(...terms: (Condition | Group)[]): Group { return { operator: 'or', terms }; } + +// --------------------------------------------------------------------------- +// Basic conditions — single `=` is verbatim eq +// --------------------------------------------------------------------------- + +describe('Verbatim eq (single =)', () => { + it('simple a=b', () => { + const r = parseQuery('id=1'); + assert.deepEqual(r.filter, andGrp(cond(['id'], 'eq', '1'))); + }); + + it('a=b&c=d → and group, verbatim strings', () => { + const r = parseQuery('id=1&name=alice'); + assert.deepEqual(r.filter, andGrp( + cond(['id'], 'eq', '1'), + cond(['name'], 'eq', 'alice'), + )); + }); +}); + +describe('Interpreted eq (==)', () => { + it('a==b → eq interpreted', () => { + const r = parseQuery('foo==number:5'); + assert.deepEqual(r.filter, andGrp(cond(['foo'], 'eq', 5))); + }); + + it('a==null → null value', () => { + const r = parseQuery('bar==null'); + assert.deepEqual(r.filter, andGrp(cond(['bar'], 'eq', null))); + }); +}); + +describe('Negated eq (!=)', () => { + it('a!=b → negated eq, interpreted', () => { + const r = parseQuery('baz!=boolean:true'); + assert.deepEqual(r.filter, andGrp(cond(['baz'], 'eq', true, true))); + }); +}); + +describe('Strict verbatim (===, !==)', () => { + it('===value stays as string', () => { + const r = parseQuery('strict===number:5'); + // verbatim — no interpretation + assert.deepEqual(r.filter, andGrp(cond(['strict'], 'eq', 'number:5'))); }); - it('Bad nesting', () => { - assert.throws(() => parseQuery('(name=value)shouldntbehere'), /expected operator/); - assert.throws(() => parseQuery('(name))'), /no attribute/); - assert.throws(() => parseQuery('(=value&=test)'), /attribute must be specified/); - assert.throws(() => parseQuery('(name=(value))'), /no attribute/); - assert.throws(() => parseQuery('name=value|test=3&foo=bar'), /mix operators/); - assert.throws(() => parseQuery('name=value&[test=3&foo=bar|test=4]'), /mix operators/); + it('!==value → negated eq, verbatim', () => { + const r = parseQuery('x!==foo'); + assert.deepEqual(r.filter, andGrp(cond(['x'], 'eq', 'foo', true))); }); }); -describe('Parsing queries with target (RequestTarget-style)', () => { - it('Basic AND query', () => { - const target = new Query(); - target.conditions = []; - parseQuery('id=1&name=2', target); - // fast path: target untouched, iterate as URLSearchParams (nothing set in target) - // Actually fast path with target returns target as-is. - // Use a fresh parseQuery without target to test fast-path iteration. - const query = parseQuery('id=1&name=2'); - const conditions = Array.from(query); - assert.equal(conditions.length, 2); - assert.equal((conditions[0] as any)[0], 'id'); - assert.equal((conditions[0] as any)[1], '1'); - }); - - it('Basic OR query with target', () => { - const target = new Query(); - parseQuery('id=1|name=2', target); - assert.equal(target.operator, 'or'); - assert.equal(target.conditions!.length, 2); - assert.equal(target.conditions![0].attribute, 'id'); - assert.equal(target.conditions![0].value, '1'); - assert.equal(target.conditions![1].attribute, 'name'); - assert.equal(target.conditions![1].value, '2'); - }); - - it('Basic AND and nested OR query with target', () => { - const target = new Query(); - parseQuery('id=1&(value=gt=4|name=2)', target); - assert.equal(target.conditions!.length, 2); - assert.equal(target.conditions![0].attribute, 'id'); - assert.equal(target.conditions![0].value, '1'); - assert.equal((target.conditions![1] as any).operator, 'or'); +describe('Ordered comparators', () => { + it('< > <= >=', () => { + const r = parseQuery('price<10&qty<=5&age>18&score>=90'); + assert.deepEqual(r.filter, andGrp( + cond(['price'], 'lt', '10'), + cond(['qty'], 'le', '5'), + cond(['age'], 'gt', '18'), + cond(['score'], 'ge', '90'), + )); + }); + + it('FIQL lt/le/gt/ge', () => { + const r = parseQuery('age=gt=4'); + assert.deepEqual(r.filter, andGrp(cond(['age'], 'gt', '4'))); }); }); // --------------------------------------------------------------------------- -// Ported from harper/unitTests/resources/query-tier1.test.js -// 'REST query parsing' describe block (~lines 152–197) +// OR and grouping // --------------------------------------------------------------------------- -describe('resolveComparator helper', () => { - it('preserves existing aliases as-is', () => { - assert.deepEqual(resolveComparator('eq'), { comparator: 'eq', negated: false }); - assert.deepEqual(resolveComparator('not_equal'), { comparator: 'not_equal', negated: false }); - assert.deepEqual(resolveComparator('greater_than'), { comparator: 'greater_than', negated: false }); +describe('OR query', () => { + it('id=1|name=2', () => { + const r = parseQuery('id=1|name=2'); + assert.deepEqual(r.filter, orGrp(cond(['id'], 'eq', '1'), cond(['name'], 'eq', '2'))); }); - it('strips not_ prefix on negatable comparators', () => { - assert.deepEqual(resolveComparator('not_in'), { comparator: 'in', negated: true }); - assert.deepEqual(resolveComparator('not_starts_with'), { comparator: 'starts_with', negated: true }); - assert.deepEqual(resolveComparator('not_between'), { comparator: 'between', negated: true }); - assert.deepEqual(resolveComparator('not_contains'), { comparator: 'contains', negated: true }); - assert.deepEqual(resolveComparator('not_ends_with'), { comparator: 'ends_with', negated: true }); + it('nested: id=1&(a=gt=4|name=2)', () => { + const r = parseQuery('id=1&(value=gt=4|name=2)'); + assert.deepEqual(r.filter, andGrp( + cond(['id'], 'eq', '1'), + orGrp(cond(['value'], 'gt', '4'), cond(['name'], 'eq', '2')), + )); }); - it('returns input unchanged for unknown comparators', () => { - assert.deepEqual(resolveComparator('unknown'), { comparator: 'unknown', negated: false }); - assert.deepEqual(resolveComparator(undefined), { comparator: undefined, negated: false }); + it('complex nested: (ne!=4&name=2)|id=5|(foo=bar&name=2&(a=gt=4|name=2))', () => { + const r = parseQuery('(value!=4&name=2)|id=5|(foo=bar&name=2&(value=gt=4|name=2))'); + assert.equal(r.filter!.operator, 'or'); + assert.equal(r.filter!.terms.length, 3); + assert.equal((r.filter!.terms[0] as Group).operator, 'and'); + assert.equal((r.filter!.terms[0] as Group).terms[0].comparator, 'eq'); + assert.equal(((r.filter!.terms[0] as Group).terms[0] as Condition).negated, true); + }); + + it('bracket groups [...]', () => { + const r = parseQuery('[value!=4&name=2]|id=5'); + assert.equal(r.filter!.operator, 'or'); + assert.equal((r.filter!.terms[0] as Group).operator, 'and'); + assert.equal((r.filter!.terms[1] as Condition).path[0], 'id'); }); }); -describe('REST query parsing', () => { - it('parses (v1,v2,v3) list-value syntax with `in`', () => { - const q = parseQuery('status=in=(active,pending,inactive)'); - assert.equal(q.conditions![0].comparator, 'in'); - assert.deepEqual(q.conditions![0].value, ['active', 'pending', 'inactive']); +// --------------------------------------------------------------------------- +// Desugaring: comparator aliases +// --------------------------------------------------------------------------- + +describe('Alias desugaring', () => { + it('ne → negated eq', () => { + const r = parseQuery('a=ne=1'); + assert.deepEqual(r.filter, andGrp(cond(['a'], 'eq', '1', true))); + }); + + it('equals → eq (verbatim)', () => { + const r = parseQuery('a=equals=hello'); + assert.deepEqual(r.filter, andGrp(cond(['a'], 'eq', 'hello'))); }); - it('parses single-element list', () => { - const q = parseQuery('status=in=(active)'); - assert.deepEqual(q.conditions![0].value, ['active']); + it('not_equal → negated eq (verbatim)', () => { + const r = parseQuery('a=not_equal=hello'); + assert.deepEqual(r.filter, andGrp(cond(['a'], 'eq', 'hello', true))); }); - it('parses empty list', () => { - const q = parseQuery('status=in=()'); - assert.deepEqual(q.conditions![0].value, []); + it('ne and != produce same canonical form (interpreted)', () => { + const a = parseQuery('x=ne=1'); + const b = parseQuery('x!=1'); + // Both → negated eq, interpreted (both have string '1' since 1 is a bare number token) + assert.deepEqual(a.filter, b.filter); }); - it('parses not_in to negated in', () => { - const q = parseQuery('status=not_in=(active,pending)'); - assert.equal(q.conditions![0].comparator, 'in'); - assert.deepEqual(q.conditions![0].value, ['active', 'pending']); - assert.equal(q.conditions![0].negated, true); + it('sw/ew/ct aliases', () => { + assert.deepEqual(parseQuery('a=sw=foo').filter, andGrp(cond(['a'], 'starts_with', 'foo'))); + assert.deepEqual(parseQuery('a=ew=bar').filter, andGrp(cond(['a'], 'ends_with', 'bar'))); + assert.deepEqual(parseQuery('a=ct=baz').filter, andGrp(cond(['a'], 'contains', 'baz'))); }); - it('parses not_starts_with as negated starts_with', () => { - const q = parseQuery('name=not_starts_with=Joh'); - assert.equal(q.conditions![0].comparator, 'starts_with'); - assert.equal(q.conditions![0].value, 'Joh'); - assert.equal(q.conditions![0].negated, true); + it('less_than / greaterThan aliases', () => { + assert.deepEqual(parseQuery('a=less_than=5').filter, andGrp(cond(['a'], 'lt', '5'))); + assert.deepEqual(parseQuery('a=greaterThan=5').filter, andGrp(cond(['a'], 'gt', '5'))); }); - it('parses between with list value', () => { - const q = parseQuery('age=between=(18,65)'); - assert.equal(q.conditions![0].comparator, 'between'); - assert.deepEqual(q.conditions![0].value, ['18', '65']); + it('out → negated in', () => { + const r = parseQuery('a=out=(1,2)'); + const c = r.filter!.terms[0] as Condition; + assert.equal(c.comparator, 'in'); + assert.equal(c.negated, true); + assert.deepEqual(c.value, ['1', '2']); }); +}); - it('parses typed values inside list', () => { - const q = parseQuery('id=in=(number:1,number:2,number:3)'); - assert.deepEqual(q.conditions![0].value, [1, 2, 3]); +// --------------------------------------------------------------------------- +// between desugaring +// --------------------------------------------------------------------------- + +describe('between desugaring', () => { + it('between=(lo,hi) → and-group of ge+le', () => { + const r = parseQuery('age=between=(18,65)'); + assert.deepEqual(r.filter, andGrp( + andGrp(cond(['age'], 'ge', '18'), cond(['age'], 'le', '65')), + )); }); - it('preserves backwards-compat for non-list (...) values on non-list comparators', () => { - const q = parseQuery('value=gt=(4)'); - assert.equal(q.conditions![0].value, '(4)'); + it('not_between=(lo,hi) → or-group of negated ge+le', () => { + const r = parseQuery('age=not_between=(18,65)'); + assert.deepEqual(r.filter, andGrp( + orGrp(cond(['age'], 'ge', '18', true), cond(['age'], 'le', '65', true)), + )); }); - it('accepts multi-character FIQL operators', () => { - const q = parseQuery('a=between=(1,2)|b=in=(x,y)'); - assert.equal(q.conditions![0].comparator, 'between'); - assert.equal(q.conditions![1].comparator, 'in'); + it('between with typed values', () => { + const r = parseQuery('score=between=(number:10,number:99)'); + const sub = (r.filter!.terms[0] as Group).terms; + assert.equal((sub[0] as Condition).value, 10); + assert.equal((sub[1] as Condition).value, 99); }); }); // --------------------------------------------------------------------------- -// New: reentrancy and URLSearchParams behavior +// Chaining (&= / |=) desugaring // --------------------------------------------------------------------------- -describe('Reentrancy', () => { - it('sequential parses with errors do not pollute subsequent parses', () => { - assert.throws(() => parseQuery('name=value|test=3&foo=bar'), /mix operators/); - // fresh parse after the failed one must succeed cleanly - const q = parseQuery('status=in=(active,pending)'); - assert.equal(q.conditions![0].comparator, 'in'); - assert.deepEqual(q.conditions![0].value, ['active', 'pending']); +describe('Chaining desugaring', () => { + it('age=ge=20&=le=30 → and sub-group of ge+le', () => { + const r = parseQuery('age=ge=20&=le=30'); + // Top-level filter is an and-Group wrapping the chain sub-group. + const sub = r.filter!.terms[0] as Group; + assert.equal(sub.operator, 'and'); + assert.equal(sub.terms.length, 2); + assert.equal((sub.terms[0] as Condition).comparator, 'ge'); + assert.equal((sub.terms[1] as Condition).comparator, 'le'); + assert.deepEqual((sub.terms[0] as Condition).path, ['age']); + assert.deepEqual((sub.terms[1] as Condition).path, ['age']); + }); + + it('|= produces or sub-group', () => { + const r = parseQuery('status=eq=active|=eq=pending'); + const sub = r.filter!.terms[0] as Group; + assert.equal(sub.operator, 'or'); + assert.equal((sub.terms[0] as Condition).value, 'active'); + assert.equal((sub.terms[1] as Condition).value, 'pending'); }); +}); - it('two independent parses return independent results', () => { - const a = parseQuery('id=1|name=2'); - const b = parseQuery('foo=gt=5&bar=lt=10'); - assert.equal(a.operator, 'or'); - assert.equal(a.conditions![0].attribute, 'id'); - assert.equal(b.conditions![0].attribute, 'foo'); - assert.equal(b.conditions![0].comparator, 'gt'); - assert.equal(b.conditions![1].comparator, 'lt'); - }); - - it('failed mid-parse does not corrupt a later successful parse', () => { - const target = new Query(); - parseQuery('limit(5,10', target); // mismatched paren — writes parseError - assert.ok(target.parseError); - // new independent parse - const q = parseQuery('age=between=(18,65)'); - assert.equal(q.conditions![0].comparator, 'between'); - assert.deepEqual(q.conditions![0].value, ['18', '65']); +// --------------------------------------------------------------------------- +// in comparator +// --------------------------------------------------------------------------- + +describe('in comparator', () => { + it('(v1,v2,v3) list', () => { + const r = parseQuery('status=in=(active,pending,inactive)'); + const c = r.filter!.terms[0] as Condition; + assert.equal(c.comparator, 'in'); + assert.deepEqual(c.value, ['active', 'pending', 'inactive']); + }); + + it('empty list', () => { + const r = parseQuery('status=in=()'); + assert.deepEqual((r.filter!.terms[0] as Condition).value, []); + }); + + it('typed values in list', () => { + const r = parseQuery('id=in=(number:1,number:2,number:3)'); + assert.deepEqual((r.filter!.terms[0] as Condition).value, [1, 2, 3]); + }); + + it('not_in → negated in', () => { + const r = parseQuery('status=not_in=(active,pending)'); + const c = r.filter!.terms[0] as Condition; + assert.equal(c.comparator, 'in'); + assert.equal(c.negated, true); + assert.deepEqual(c.value, ['active', 'pending']); }); }); -describe('Query extends URLSearchParams', () => { - it('fast-path: get() and getAll() work', () => { - const q = parseQuery('foo=bar&foo=baz&x=1'); - assert.equal(q.get('foo'), 'bar'); - assert.deepEqual(q.getAll('foo'), ['bar', 'baz']); +// --------------------------------------------------------------------------- +// Wildcard +// --------------------------------------------------------------------------- + +describe('Wildcard', () => { + it('trailing * on == → starts_with', () => { + const r = parseQuery('name==John*'); + assert.deepEqual(r.filter, andGrp(cond(['name'], 'starts_with', 'John'))); }); - it('fast-path: iteration yields [name, value] pairs', () => { - const q = parseQuery('a=1&b=2'); - const entries = Array.from(q); - assert.deepEqual(entries, [['a', '1'], ['b', '2']]); + it('non-trailing * throws', () => { + assert.throws(() => parseQuery('name==*John'), /wildcard/); }); - it('parsed-path: Query is still a URLSearchParams instance', () => { - const q = parseQuery('id=1|name=2'); - assert.ok(q instanceof URLSearchParams); - assert.ok(q instanceof Query); + it('not_starts_with via FIQL', () => { + const r = parseQuery('name=not_starts_with=Joh'); + const c = r.filter!.terms[0] as Condition; + assert.equal(c.comparator, 'starts_with'); + assert.equal(c.negated, true); + assert.equal(c.value, 'Joh'); }); +}); - it('parsed-path with target: target is returned as Query instance', () => { - const target = new Query(); - const result = parseQuery('id=1|name=2', target); - assert.strictEqual(result, target); - assert.ok(result instanceof Query); +// --------------------------------------------------------------------------- +// Typed values +// --------------------------------------------------------------------------- + +describe('Typed values', () => { + it('number:, boolean:, date:', () => { + const r = parseQuery('a==number:5&b==boolean:true&c!=date:2024-01-05T20%3A07%3A27.955Z'); + const terms = r.filter!.terms as Condition[]; + assert.equal(terms[0].value, 5); + assert.equal(terms[1].value, true); + assert.ok(terms[2].value instanceof Date); + assert.equal((terms[2].value as Date).getTime(), new Date('2024-01-05T20:07:27.955Z').getTime()); }); - it('empty string returns empty Query', () => { - const q = parseQuery(''); - assert.ok(q instanceof Query); - assert.equal(q.conditions, undefined); + it('date: with numeric epoch', () => { + const r = parseQuery('time=gt=date:1602872124871'); + assert.ok((r.filter!.terms[0] as Condition).value instanceof Date); + assert.equal(((r.filter!.terms[0] as Condition).value as Date).getTime(), 1602872124871); + }); + + it('number:$X base-36', () => { + const r = parseQuery('x==number:$z'); + assert.equal((r.filter!.terms[0] as Condition).value, 35); + }); + + it('string: prefix suppresses interpretation', () => { + const r = parseQuery('x==string:null'); + assert.equal((r.filter!.terms[0] as Condition).value, 'null'); + }); + + it('unknown type prefix throws', () => { + assert.throws(() => parseQuery('x==custom:foo'), /Unknown type prefix/); }); }); // --------------------------------------------------------------------------- -// group-by fix: must NOT fall through into sort +// Property paths // --------------------------------------------------------------------------- -describe('group-by fix', () => { - it('group-by records error without setting sort', () => { - const target = new Query(); - parseQuery('group-by(foo)', target); - assert.ok(target.parseError, 'should have a parseError'); - assert.match(target.parseError!.message, /group by/); - assert.equal(target.sort, undefined, 'group-by must not set sort'); +describe('Property paths', () => { + it('dotted path → multi-segment', () => { + const r = parseQuery('name.subname=2'); + assert.deepEqual((r.filter!.terms[0] as Condition).path, ['name', 'subname']); }); - it('group-by does not clobber a preceding sort() call', () => { - const target = new Query(); - parseQuery('sort(name)&group-by(foo)', target); - assert.ok(target.parseError); - // sort set by the preceding sort() call must survive - assert.equal(target.sort!.attribute, 'name'); + it('%2E in segment is a literal dot (single segment)', () => { + const r = parseQuery('a%2Eb==3'); + assert.deepEqual((r.filter!.terms[0] as Condition).path, ['a.b']); + }); + + it('a.b path vs a%2Eb path are different', () => { + const dotted = parseQuery('a.b==3'); + const encoded = parseQuery('a%2Eb==3'); + assert.deepEqual((dotted.filter!.terms[0] as Condition).path, ['a', 'b']); + assert.deepEqual((encoded.filter!.terms[0] as Condition).path, ['a.b']); }); }); // --------------------------------------------------------------------------- -// Wildcard behavior +// Sort // --------------------------------------------------------------------------- -describe('Wildcard handling', () => { - it('trailing * on == converts to starts_with', () => { - const q = parseQuery('name==John*'); - assert.equal(q.conditions![0].comparator, 'starts_with'); - assert.equal(q.conditions![0].value, 'John'); +describe('sort()', () => { + it('single field ascending', () => { + const r = parseQuery('sort(name)'); + assert.deepEqual(r.sort, [{ path: ['name'], direction: 'asc' }]); }); - it('non-trailing * throws', () => { - assert.throws(() => parseQuery('name==*John'), /wildcard/); + it('+ and - prefixes', () => { + const r = parseQuery('sort(+name,-age)'); + assert.deepEqual(r.sort, [ + { path: ['name'], direction: 'asc' }, + { path: ['age'], direction: 'desc' }, + ]); + }); + + it('dotted sort key', () => { + const r = parseQuery('sort(name.subname)'); + assert.deepEqual(r.sort, [{ path: ['name', 'subname'], direction: 'asc' }]); + }); + + it('conditions + sort', () => { + const r = parseQuery('name.subname=2&sort(+name.subname,-otherName)'); + assert.deepEqual(r.sort, [ + { path: ['name', 'subname'], direction: 'asc' }, + { path: ['otherName'], direction: 'desc' }, + ]); + assert.deepEqual((r.filter!.terms[0] as Condition).path, ['name', 'subname']); + }); +}); + +// --------------------------------------------------------------------------- +// limit / offset +// --------------------------------------------------------------------------- + +describe('limit()', () => { + it('limit(10) → limit=10', () => { + const r = parseQuery('limit(10)'); + assert.equal(r.limit, 10); + assert.equal(r.offset, undefined); + }); + + it('limit(5,10) → offset=5, limit=5', () => { + const r = parseQuery('limit(5,10)'); + assert.equal(r.offset, 5); + assert.equal(r.limit, 5); + }); +}); + +// --------------------------------------------------------------------------- +// select / projection +// --------------------------------------------------------------------------- + +describe('select()', () => { + it('single field → values mode', () => { + const r = parseQuery('select(id)'); + assert.deepEqual(r.select, { mode: 'values', fields: [{ path: ['id'] }] }); + }); + + it('two fields → records mode', () => { + const r = parseQuery('select(id,name)'); + assert.deepEqual(r.select, { + mode: 'records', + fields: [{ path: ['id'] }, { path: ['name'] }], + }); + }); + + it('[a,b] → tuples mode', () => { + const r = parseQuery('select([id,name])'); + assert.deepEqual(r.select, { + mode: 'tuples', + fields: [{ path: ['id'] }, { path: ['name'] }], + }); + }); + + it('nested brace select', () => { + const r = parseQuery('select(related{name,other_name},id)'); + assert.deepEqual(r.select!.mode, 'records'); + assert.equal(r.select!.fields[0].path[0], 'related'); + assert.deepEqual(r.select!.fields[0].projection, { + mode: 'records', + fields: [{ path: ['name'] }, { path: ['other_name'] }], + }); + assert.deepEqual(r.select!.fields[1].path, ['id']); + }); + + it('select + conditions + limit', () => { + const r = parseQuery('id=1&name=2&select(id,name)&limit(10)'); + assert.equal(r.filter!.terms.length, 2); + assert.deepEqual(r.select!.fields.map((f) => f.path), [['id'], ['name']]); + assert.equal(r.limit, 10); + }); +}); + +// --------------------------------------------------------------------------- +// group-by (reserved, error) +// --------------------------------------------------------------------------- + +describe('group-by', () => { + it('records error, does not set sort or filter', () => { + assert.throws(() => parseQuery('group-by(foo)'), /group-by/); + }); + + it('deferErrors collects error without throwing', () => { + const r = parseQuery('group-by(foo)', { deferErrors: true }); + assert.ok(r.parseError); + assert.match(r.parseError.message, /group-by/); + assert.equal(r.sort, undefined); + }); +}); + +// --------------------------------------------------------------------------- +// Error cases +// --------------------------------------------------------------------------- + +describe('Parse errors', () => { + it('unbalanced ( throws', () => { + assert.throws(() => parseQuery('limit(5,10'), /expected '\)'/); + }); + + it('unknown call function', () => { + assert.throws(() => parseQuery('unknown(5,10)'), /unknown call function/); + }); + + it('mixing & and | in one group', () => { + assert.throws(() => parseQuery('name=value|test=3&foo=bar'), /mix/); + }); + + it('prop[]=v is a parse error (not grammar)', () => { + // [ in condition context with a named prefix is an error. + assert.throws(() => parseQuery('itemIds[]=1')); + }); + + it('deferErrors mode returns error in result', () => { + const r = parseQuery('name=value|test=3&foo=bar', { deferErrors: true }); + assert.ok(r.parseError); + }); +}); + +// --------------------------------------------------------------------------- +// Reentrancy +// --------------------------------------------------------------------------- + +describe('Reentrancy', () => { + it('sequential failing then succeeding parse is independent', () => { + assert.throws(() => parseQuery('name=value|test=3&foo=bar')); + const r = parseQuery('status=in=(active,pending)'); + assert.deepEqual((r.filter!.terms[0] as Condition).value, ['active', 'pending']); + }); + + it('two independent results', () => { + const a = parseQuery('id=1|name=2'); + const b = parseQuery('foo=gt=5&bar=lt=10'); + assert.equal(a.filter!.operator, 'or'); + assert.equal(b.filter!.operator, 'and'); + assert.equal((b.filter!.terms[0] as Condition).comparator, 'gt'); + }); +}); + +// --------------------------------------------------------------------------- +// resolveFiqlName conformance +// --------------------------------------------------------------------------- + +describe('resolveFiqlName', () => { + it('core comparators pass through', () => { + const r = resolveFiqlName('eq'); + assert.equal(r.comparator, 'eq'); + assert.equal(r.negated, false); + }); + + it('not_ prefix negates', () => { + const r = resolveFiqlName('not_in'); + assert.equal(r.comparator, 'in'); + assert.equal(r.negated, true); + }); + + it('ne → negated eq interpreted', () => { + const r = resolveFiqlName('ne'); + assert.equal(r.comparator, 'eq'); + assert.equal(r.negated, true); + assert.equal(r.verbatim, false); + }); + + it('equals → eq verbatim', () => { + const r = resolveFiqlName('equals'); + assert.equal(r.comparator, 'eq'); + assert.equal(r.verbatim, true); + }); + + it('not_equal → negated eq verbatim', () => { + const r = resolveFiqlName('not_equal'); + assert.equal(r.comparator, 'eq'); + assert.equal(r.negated, true); + assert.equal(r.verbatim, true); + }); + + it('between is flagged for desugaring', () => { + const r = resolveFiqlName('between'); + assert.ok(r.isBetween); + assert.equal(r.betweenNegated, false); + }); + + it('unknown name passes through', () => { + const r = resolveFiqlName('fuzzy_match'); + assert.equal(r.comparator, 'fuzzy_match'); + assert.equal(r.negated, false); }); }); From b74a31c62fbf788e332080e1bd2fc5319f1b8034 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Mon, 31 Aug 2026 18:57:24 -0600 Subject: [PATCH 08/14] Implement RQL v2 parser: canonical ParseResult model with ElementMatch - Drops Query extends URLSearchParams; parseQuery returns plain ParseResult. - Parser creates fresh QP/VP regex instances per call for reentrancy. - VP switching: VP used only after a non-eq comparator or FIQL name is committed, preventing premature consumption of FIQL second-= tokens. - Chaining (&= / |=) desugars to ElementMatch{path, some:Group} with element-relative inner conditions (path:[] for scalar-field legs). Preserves existential semantics: some ONE array element satisfies all legs. - between/not_between desugar to ElementMatch (not a flat ge+le pair). - Bracket prop access (prop[cond]) produces ElementMatch; single-condition bracket normalises to plain Condition with concatenated path. - Aliases and not_ prefix handled via resolveFiqlName at parse time. - All 68 tests pass. Co-Authored-By: Claude Sonnet 4.6 --- src/index.ts | 2 +- src/parser.ts | 406 +++++++++++++++++++++++------------------- src/types.ts | 15 +- test/v2/parse.test.ts | 90 +++++++--- 4 files changed, 301 insertions(+), 212 deletions(-) diff --git a/src/index.ts b/src/index.ts index 2def0bb..536b603 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,5 +2,5 @@ export { parseQuery } from './parser.ts'; export { QueryError, SyntaxViolation } from './errors.ts'; export { CORE_COMPARATORS, LIST_COMPARATORS, SYMBOL_OPS, resolveFiqlName } from './comparators.ts'; export type { - ParseResult, ParseOptions, Group, Condition, SortKey, Projection, Field, Value, + ParseResult, ParseOptions, Group, Condition, ElementMatch, SortKey, Projection, Field, Value, } from './types.ts'; diff --git a/src/parser.ts b/src/parser.ts index 3260b24..668d3dc 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,17 +1,17 @@ import { QueryError, SyntaxViolation } from './errors.ts'; import { SYMBOL_OPS, LIST_COMPARATORS, resolveFiqlName } from './comparators.ts'; import type { - ParseResult, ParseOptions, Group, Condition, SortKey, Projection, Field, Value, + ParseResult, ParseOptions, Group, Condition, ElementMatch, SortKey, Projection, Field, Value, } from './types.ts'; -const FIQL_NAME = /^[a-zA-Z_][a-zA-Z_0-9]*$/; - -// Regexes are created fresh per parseQuery call for reentrancy. // QP: tokenises attribute names and structural operators. -// VP: tokenises value tokens (includes ( ) , as plain chars). +// VP: tokenises value tokens (includes ( ) , as plain chars; excludes & | = [ ] { }). +// Both are created fresh per parseQuery call for reentrancy. const QP_SRC = '([^?&|=<>!([{\\}\\]),]*)([([{\\}\\])|,&]|[=<>!]*)'; const VP_SRC = '([^&|=\\[\\]{}]*)([\\[\\]{}]|[&|=]*)'; +const FIQL_NAME = /^[a-zA-Z_][a-zA-Z_0-9]*$/; + // ── Value decoding ───────────────────────────────────────────────────────── function interpretValue(token: string): Value { @@ -35,10 +35,7 @@ function interpretValue(token: string): Value { const verbatimValue = (token: string): Value => decodeURIComponent(token); -/** - * Split a raw path token on literal `.`, decode each segment. - * `%2E` → literal `.` inside a segment (§4.2). - */ +/** Split on literal `.`; decode each segment so `%2E` stays within a segment. */ function splitPath(raw: string): string[] { return raw.split('.').map(decodeURIComponent); } @@ -46,7 +43,6 @@ function splitPath(raw: string): string[] { function makeCondition( path: string[], comparator: string, negated: boolean, raw: string, verbatim: boolean ): Condition { - // Trailing * on == (eq, interpreted) → starts_with. if (comparator === 'eq' && !verbatim && raw.indexOf('*') > -1) { if (!raw.endsWith('*')) throw new QueryError('wildcard can only be used at the end of a string'); const c: Condition = { path, comparator: 'starts_with', value: decodeURIComponent(raw.slice(0, -1)) }; @@ -60,70 +56,44 @@ function makeCondition( } function parseListRaw(raw: string, verbatim: boolean): Value[] { - // Expects `(v1,v2,...)` format. Each element decoded individually. - const inner = raw.slice(1, -1); + const inner = raw.slice(1, -1); // strip ( ) if (inner.length === 0) return []; const decode = verbatim ? verbatimValue : interpretValue; return inner.split(',').map(decode); } -function betweenGroup(path: string[], raw: string, betweenNegated: boolean): Group { - // `(lo,hi)` → and-Group of ge(lo) + le(hi), or or-Group when negated. +/** Produce an ElementMatch for `between`/`not_between`. Inner conditions have `path: []`. */ +function betweenMatch(path: string[], raw: string, negated: boolean): ElementMatch { if (raw.length < 2 || raw.charCodeAt(0) !== 0x28 || raw.charCodeAt(raw.length - 1) !== 0x29) throw new SyntaxViolation('between requires value list (lo,hi)'); const parts = raw.slice(1, -1).split(','); if (parts.length !== 2) throw new SyntaxViolation('between requires exactly two values'); - const lo = interpretValue(parts[0]); - const hi = interpretValue(parts[1]); - const ge: Condition = { path, comparator: 'ge', value: lo }; - const le: Condition = { path, comparator: 'le', value: hi }; - if (betweenNegated) { ge.negated = true; le.negated = true; } - return { operator: betweenNegated ? 'or' : 'and', terms: [ge, le] }; + const ge: Condition = { path: [], comparator: 'ge', value: interpretValue(parts[0]) }; + const le: Condition = { path: [], comparator: 'le', value: interpretValue(parts[1]) }; + const em: ElementMatch = { path, some: { operator: 'and', terms: [ge, le] } }; + if (negated) em.negated = true; + return em; } // ── Group accumulator ────────────────────────────────────────────────────── -type Term = Condition | Group; +type Term = Condition | Group | ElementMatch; type Acc = { terms: Term[]; operator?: 'and' | 'or'; lastPath?: string[]; - chainGroup?: { operator: 'and' | 'or'; terms: Term[] }; }; function newAcc(): Acc { return { terms: [] }; } function setGroupOp(acc: Acc, op: 'and' | 'or', recordError: (msg: string) => void): void { - if (acc.terms.length === 0 && !acc.chainGroup) return; if (acc.operator && acc.operator !== op) recordError('Cannot mix & and | in one group; use (...) or [...]'); else acc.operator = op; } -function closeChain(acc: Acc): void { - if (acc.chainGroup) { - acc.terms.push(acc.chainGroup as Group); - acc.chainGroup = undefined; - } -} - -function pushTerm(acc: Acc, term: Term, chainOp: 'and' | 'or' | undefined, recordError: (msg: string) => void): void { - if (chainOp) { - if (!acc.chainGroup) { - const prev = acc.terms.pop(); - if (prev === undefined) { recordError('no preceding condition to chain onto'); return; } - acc.chainGroup = { operator: chainOp, terms: [prev] }; - } - acc.chainGroup.terms.push(term); - } else { - closeChain(acc); - acc.terms.push(term); - } -} - function accToGroup(acc: Acc): Group | undefined { - closeChain(acc); if (acc.terms.length === 0) return undefined; return { operator: acc.operator ?? 'and', terms: acc.terms }; } @@ -144,28 +114,27 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult errorMsg = errorMsg ? `${errorMsg}, ${em}` : em; } - // ── Condition-group parser ─────────────────────────────────────────────── - // Uses QP throughout (no VP switching inside groups — VP is for top-level). - // Call functions are not dispatched here; they're top-level only. + // ── Condition-group parser ───────────────────────────────────────────── + // Always uses QP (FIQL works because QP sees both `=` tokens sequentially). + // Call functions NOT dispatched here. function parseCondGroup(closeCh: string): Acc { const acc = newAcc(); let path: string[] | undefined; let rawComp: string | undefined; let fiqlMode = false; - let verbatim = false; - let expectDelim = false; - let chainOp: 'and' | 'or' | undefined; - let chainPath: string[] | undefined; // path for &=/|= continuation + let chainPath: string[] | undefined; function finishCond(rawVal: string): void { if (path === undefined) return; const rp = path; const rc = rawComp ?? '='; + + let term: Condition | ElementMatch; if (fiqlMode) { const r = resolveFiqlName(rc); if (r.isBetween) { - pushTerm(acc, betweenGroup(rp, rawVal, r.betweenNegated ?? false), chainOp, recordError); + term = betweenMatch(rp, rawVal, r.betweenNegated ?? false); } else { const isListComp = LIST_COMPARATORS.has(r.comparator) || LIST_COMPARATORS.has(`not_${r.comparator}`); let value: Value; @@ -176,15 +145,21 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult } const c: Condition = { path: rp, comparator: r.comparator, value }; if (r.negated) c.negated = true; - pushTerm(acc, c, chainOp, recordError); + term = c; } } else { const sym = SYMBOL_OPS[rc]; - if (!sym) { recordError(`unknown operator '${rc}'`); } - else { pushTerm(acc, makeCondition(rp, sym.comparator, sym.negated, rawVal, sym.verbatim), chainOp, recordError); } + if (!sym) { + recordError(`unknown operator '${rc}'`); + path = undefined; rawComp = undefined; fiqlMode = false; chainPath = undefined; + return; + } + term = makeCondition(rp, sym.comparator, sym.negated, rawVal, sym.verbatim); } - if (!chainOp) acc.lastPath = rp; - path = undefined; rawComp = undefined; fiqlMode = false; verbatim = false; chainOp = undefined; chainPath = undefined; + + acc.lastPath = rp; + acc.terms.push(term); + path = undefined; rawComp = undefined; fiqlMode = false; chainPath = undefined; } qp.lastIndex = pos; @@ -193,49 +168,37 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult pos = qp.lastIndex; const [, val, op] = match; - if (expectDelim) { - if (val) recordError(`expected operator, got '${val}'`); - expectDelim = false; - } - switch (op) { case '=': if (path !== undefined) { - // Second '=' of FIQL: path=name=value. if (!FIQL_NAME.test(val)) { recordError(`invalid FIQL name '${val}'`); break; } rawComp = val; fiqlMode = true; } else if (chainPath) { - // &= chain: already have path, this is the FIQL name. if (!FIQL_NAME.test(val)) { recordError(`invalid FIQL name '${val}'`); break; } path = chainPath; rawComp = val; fiqlMode = true; } else { if (!val) { recordError('path required before ='); break; } - path = splitPath(val); rawComp = '='; verbatim = true; + path = splitPath(val); rawComp = '='; fiqlMode = false; } break; case '==': case '===': case '!=': case '!==': case '<': case '<=': case '>': case '>=': - if (chainPath) { - path = chainPath; rawComp = op; - } else { + if (chainPath) { path = chainPath; rawComp = op; fiqlMode = false; } + else { if (!val) { recordError(`path required before ${op}`); break; } - path = splitPath(val); rawComp = op; + path = splitPath(val); rawComp = op; fiqlMode = false; } - fiqlMode = false; break; case '&': case '|': { const lop: 'and' | 'or' = op === '&' ? 'and' : 'or'; if (path !== undefined) finishCond(val); - closeChain(acc); setGroupOp(acc, lop, recordError); break; } - case '&=': case '|=': { + case '&=': case '|=': if (path !== undefined) finishCond(val); - chainOp = op === '&=' ? 'and' : 'or'; - chainPath = acc.lastPath ?? (acc.chainGroup?.terms.at(-1) as Condition | undefined)?.path; + chainPath = acc.lastPath ?? (acc.terms.at(-1) as Condition | undefined)?.path; if (!chainPath) recordError('no preceding condition for &=/|='); break; - } case '': case undefined: if (path !== undefined) finishCond(val); break; @@ -243,33 +206,45 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult recordError("unexpected ','"); break; case '(': { - // Nested condition group. - if (val) { recordError(`unexpected name '${val}' before '('`); break; } + if (val) { recordError(`unexpected call '${val}(' inside condition group`); break; } qp.lastIndex = pos; const inner = parseCondGroup(')'); pos = qp.lastIndex; const grp = accToGroup(inner); - if (grp) { closeChain(acc); acc.terms.push(grp); acc.lastPath = undefined; } - if (search[pos] === ',') { qp.lastIndex = ++pos; } else expectDelim = true; - path = undefined; chainPath = undefined; + if (grp) { acc.terms.push(grp); acc.lastPath = undefined; } break; } case '[': { - if (val) { recordError(`unexpected name '${val}' before '['`); break; } - qp.lastIndex = pos; - const inner = parseCondGroup(']'); - pos = qp.lastIndex; - const grp = accToGroup(inner); - if (grp) { closeChain(acc); acc.terms.push(grp); acc.lastPath = undefined; } - if (search[pos] === ',') { qp.lastIndex = ++pos; } else expectDelim = true; - path = undefined; chainPath = undefined; + if (val) { + const ePath = splitPath(val); + qp.lastIndex = pos; + const inner = parseCondGroup(']'); + pos = qp.lastIndex; + const innerGrp = accToGroup(inner); + if (!innerGrp) { + recordError(`empty bracket group for '${val}'`); + } else if (innerGrp.terms.length === 1 && !('some' in innerGrp.terms[0])) { + const ic = innerGrp.terms[0] as Condition; + const merged: Condition = { path: [...ePath, ...ic.path], comparator: ic.comparator, value: ic.value }; + if (ic.negated) merged.negated = true; + acc.terms.push(merged); acc.lastPath = merged.path; + } else { + acc.terms.push({ path: ePath, some: innerGrp }); acc.lastPath = ePath; + } + } else { + qp.lastIndex = pos; + const inner = parseCondGroup(']'); + pos = qp.lastIndex; + const grp = accToGroup(inner); + if (grp) { acc.terms.push(grp); acc.lastPath = undefined; } + } break; } case ')': case ']': case '}': { const ch = op[0]; if (closeCh === ch) { if (path !== undefined) finishCond(val); - else if (val) recordError('unexpected value without path'); + else if (val) recordError(`unexpected value without path '${val}'`); return acc; } recordError(closeCh ? `expected '${closeCh}', got '${ch}'` : `unexpected '${ch}'`); @@ -286,28 +261,33 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult return acc; } - // ── Top-level parser (condition group + call functions) ────────────────── - // Switches between QP and VP based on whether a comparator was just seen. + // ── Top-level parser ─────────────────────────────────────────────────── + // QP/VP switching: VP only when committed to reading a value (FIQL or non-eq op seen). + // Chaining and between produce ElementMatch. const result: ParseResult = {}; const topAcc = newAcc(); - let path: string[] | undefined; let rawComp: string | undefined; let fiqlMode = false; - let verbatim = false; - let expectDelim = false; - let chainOp: 'and' | 'or' | undefined; let chainPath: string[] | undefined; + let activeEM: ElementMatch | undefined; + + function useVP(): boolean { + // Use VP only after we have path + a comparator that won't be FIQL second-=. + return path !== undefined && rawComp !== undefined && (fiqlMode || rawComp !== '='); + } function finishTopCond(rawVal: string): void { if (path === undefined) return; const rp = path; const rc = rawComp ?? '='; + + let term: Condition | ElementMatch; if (fiqlMode) { const r = resolveFiqlName(rc); if (r.isBetween) { - pushTerm(topAcc, betweenGroup(rp, rawVal, r.betweenNegated ?? false), chainOp, recordError); + term = betweenMatch(rp, rawVal, r.betweenNegated ?? false); } else { const isListComp = LIST_COMPARATORS.has(r.comparator) || LIST_COMPARATORS.has(`not_${r.comparator}`); let value: Value; @@ -318,19 +298,50 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult } const c: Condition = { path: rp, comparator: r.comparator, value }; if (r.negated) c.negated = true; - pushTerm(topAcc, c, chainOp, recordError); + term = c; } } else { const sym = SYMBOL_OPS[rc]; - if (!sym) { recordError(`unknown operator '${rc}'`); } - else { pushTerm(topAcc, makeCondition(rp, sym.comparator, sym.negated, rawVal, sym.verbatim), chainOp, recordError); } + if (!sym) { + recordError(`unknown operator '${rc}'`); + path = undefined; rawComp = undefined; fiqlMode = false; + if (!activeEM) chainPath = undefined; + return; + } + term = makeCondition(rp, sym.comparator, sym.negated, rawVal, sym.verbatim); + } + + if (activeEM) { + function addLeg(c: Condition): void { + const relPath = c.path.slice(activeEM!.path.length); + const ec: Condition = { path: relPath, comparator: c.comparator, value: c.value }; + if (c.negated) ec.negated = true; + activeEM!.some.terms.push(ec); + } + if ('some' in term) { + for (const leg of (term as ElementMatch).some.terms) addLeg(leg as Condition); + } else { + addLeg(term as Condition); + } + } else { + topAcc.lastPath = rp; + topAcc.terms.push(term); } - if (!chainOp) topAcc.lastPath = rp; - path = undefined; rawComp = undefined; fiqlMode = false; verbatim = false; chainOp = undefined; chainPath = undefined; + + path = undefined; rawComp = undefined; fiqlMode = false; + // chainPath / activeEM persist across chain legs. } - // Sub-parsers for call function arguments. - // Each creates its own regex but shares closure `pos`. + function closeActiveEM(): void { + if (activeEM) { + topAcc.terms.push(activeEM); + topAcc.lastPath = activeEM.path; + activeEM = undefined; + chainPath = undefined; + } + } + + // ── Sub-parsers for call function arguments ──────────────────────────── function parsePlainArgs(callName: string): string[] { const args: string[] = []; @@ -340,8 +351,8 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult while ((m = p.exec(search))) { pos = p.lastIndex; const [, val, op] = m; - args.push(val); - if (op === ')') return args; + if (op === ')') { if (val) args.push(val); return args; } + if (val) args.push(val); if (op === ',') continue; if (pos === search.length) { recordError(`expected ')' for ${callName}`); return args; } } @@ -382,26 +393,27 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult while ((m = p.exec(search))) { pos = p.lastIndex; const [, val, op] = m; - if (op === closeCh || (op === '' && pos === search.length)) { - if (val) fields.push({ path: splitPath(val) }); - if (op !== closeCh) recordError(`expected '${closeCh}' for select`); - return fields; - } + + if (op === closeCh) { if (val) fields.push({ path: splitPath(val) }); return fields; } if (op === ')' || op === ']' || op === '}') { - if (op === closeCh) { if (val) fields.push({ path: splitPath(val) }); return fields; } + if (val) fields.push({ path: splitPath(val) }); + if (op === closeCh) return fields; recordError(`expected '${closeCh}', got '${op}'`); return fields; } - if (op === ',') { if (val) fields.push({ path: splitPath(val) }); continue; } + if (op === ',') { + if (val) fields.push({ path: splitPath(val) }); + p.lastIndex = pos; + continue; + } if (op === '{') { - // `rel{x,y}` nested sub-select. const nested = parseSelectList('}'); + p.lastIndex = pos; // re-sync after recursive call updates shared pos fields.push({ path: splitPath(val), nested }); continue; } if (op === '[') { if (val) { - // `rel[select(x,y)]` — consume `select(`, then list, then `)]`. const selectRe = /select\(/g; selectRe.lastIndex = pos; const sm = selectRe.exec(search); @@ -409,19 +421,22 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult pos = selectRe.lastIndex; const nested = parseSelectList(')'); if (search[pos] === ']') pos++; + p.lastIndex = pos; fields.push({ path: splitPath(val), nested }); } else { recordError(`expected 'select(' after '${val}['`); } } else { - // `[a,b]` tuple field. + // `[a,b]` → tuple. const items = parseSelectList(']'); + p.lastIndex = pos; // re-sync after recursive call updates shared pos fields.push({ path: [], nested: items, tuple: true }); } continue; } - // Structural op like `=` — unexpected in select context. + // Unrecognised structural op or empty string: treat val as field name. if (val) fields.push({ path: splitPath(val) }); + if (!op && pos >= search.length) { recordError(`expected '${closeCh}' for select`); return fields; } } recordError(`expected '${closeCh}' for select`); return fields; @@ -429,14 +444,9 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult function rawFieldsToProjection(fields: RawField[], trailingComma: boolean): Projection { if (fields.length === 1 && fields[0].tuple) { - const f = fields[0]; - return { - mode: 'tuples', - fields: (f.nested ?? []).map((rf) => rawToField(rf)), - }; + return { mode: 'tuples', fields: (fields[0].nested ?? []).map(rawToField) }; } - const fs = fields.map((rf) => rawToField(rf)); - // `select(a)` → values; `select(a,b)` or `select(a,)` → records. + const fs = fields.map(rawToField); const mode: 'values' | 'records' = (fs.length === 1 && !fields[0].nested && !trailingComma) ? 'values' : 'records'; return { mode, fields: fs }; } @@ -447,59 +457,63 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult } function parseSelectArgs(): Projection { - // Capture position before trailing-comma detection. const startPos = pos; const fields = parseSelectList(')'); - // Detect trailing comma: search backwards from the `)` position. const beforeClose = search.slice(startPos, pos - 1).trimEnd(); const trailingComma = beforeClose.endsWith(','); return rawFieldsToProjection(fields, trailingComma); } - // Top-level loop. Switches between QP and VP based on whether we're expecting a value. - qp.lastIndex = 0; + // ── Main loop ────────────────────────────────────────────────────────── - function nextParser(): RegExp { - // Use VP when we have both path and comparator set (expecting value token). - return (path !== undefined && rawComp !== undefined) ? vp : qp; - } - - let match: RegExpExecArray | null; while (pos < search.length) { - const p = nextParser(); + const p = useVP() ? vp : qp; p.lastIndex = pos; - match = p.exec(search); + const match = p.exec(search); if (!match) break; pos = p.lastIndex; const [, val, op] = match; - if (expectDelim) { - if (val) recordError(`expected operator, got '${val}'`); - expectDelim = false; - } - if (p === vp) { - // Value token: finish the pending condition. + // Value token consumed. finishTopCond(val); - // op from VP: `&`, `|`, `=`, `[`, `]`, `{`, `}`, or ''. - // Handle the operator (logical separator or end-of-string). - if (op === '&' || op === '|') { - const lop: 'and' | 'or' = op === '&' ? 'and' : 'or'; - closeChain(topAcc); - setGroupOp(topAcc, lop, recordError); - } else if (op === '&=' || op === '|=') { - chainOp = op === '&=' ? 'and' : 'or'; - chainPath = topAcc.lastPath ?? (topAcc.chainGroup?.terms.at(-1) as Condition | undefined)?.path; - if (!chainPath) recordError('no preceding condition for &=/|='); + // Process any logical separator carried by VP. + switch (op) { + case '&': case '|': { + const lop: 'and' | 'or' = op === '&' ? 'and' : 'or'; + closeActiveEM(); + setGroupOp(topAcc, lop, recordError); + break; + } + case '&=': case '|=': { + // Chain operator: start or extend an ElementMatch. + const cop: 'and' | 'or' = op === '&=' ? 'and' : 'or'; + if (activeEM) { + if (cop !== activeEM.some.operator) recordError('cannot mix & and | within a chain'); + } else { + const prev = topAcc.terms.pop(); + if (!prev || 'some' in prev) { + recordError('no preceding Condition to chain onto'); break; + } + const prevCond = prev as Condition; + chainPath = prevCond.path; + const relCond: Condition = { path: [], comparator: prevCond.comparator, value: prevCond.value }; + if (prevCond.negated) relCond.negated = true; + activeEM = { path: chainPath, some: { operator: cop, terms: [relCond] } }; + topAcc.lastPath = undefined; + } + break; + } + default: break; } - // Other ops (empty, brackets) fall through to next iteration. continue; } - // QP path. + // QP token. switch (op) { case '=': if (path !== undefined) { + // Second `=` of FIQL. if (!FIQL_NAME.test(val)) { recordError(`invalid FIQL name '${val}'`); break; } rawComp = val; fiqlMode = true; } else if (chainPath) { @@ -507,27 +521,42 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult path = chainPath; rawComp = val; fiqlMode = true; } else { if (!val) { recordError('path required before ='); break; } - path = splitPath(val); rawComp = '='; verbatim = true; + path = splitPath(val); rawComp = '='; fiqlMode = false; } break; case '==': case '===': case '!=': case '!==': case '<': case '<=': case '>': case '>=': - if (chainPath) { path = chainPath; rawComp = op; } - else { if (!val) { recordError(`path required before ${op}`); break; } path = splitPath(val); rawComp = op; } - fiqlMode = false; + if (chainPath) { path = chainPath; rawComp = op; fiqlMode = false; } + else { + if (!val) { recordError(`path required before ${op}`); break; } + path = splitPath(val); rawComp = op; fiqlMode = false; + } break; case '&': case '|': { const lop: 'and' | 'or' = op === '&' ? 'and' : 'or'; if (path !== undefined) finishTopCond(val); - closeChain(topAcc); + closeActiveEM(); setGroupOp(topAcc, lop, recordError); break; } - case '&=': case '|=': + case '&=': case '|=': { + const cop: 'and' | 'or' = op === '&=' ? 'and' : 'or'; if (path !== undefined) finishTopCond(val); - chainOp = op === '&=' ? 'and' : 'or'; - chainPath = topAcc.lastPath ?? (topAcc.chainGroup?.terms.at(-1) as Condition | undefined)?.path; - if (!chainPath) recordError('no preceding condition for &=/|='); + if (activeEM) { + if (cop !== activeEM.some.operator) recordError('cannot mix & and | within a chain'); + } else { + const prev = topAcc.terms.pop(); + if (!prev || 'some' in prev) { + recordError('no preceding Condition to chain onto'); break; + } + const prevCond = prev as Condition; + chainPath = prevCond.path; + const relCond: Condition = { path: [], comparator: prevCond.comparator, value: prevCond.value }; + if (prevCond.negated) relCond.negated = true; + activeEM = { path: chainPath, some: { operator: cop, terms: [relCond] } }; + topAcc.lastPath = undefined; + } break; + } case '': case undefined: if (path !== undefined) finishTopCond(val); break; @@ -536,10 +565,9 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult break; case '(': { if (val) { - // Call function. switch (val) { - case 'select': result.select = parseSelectArgs(); break; - case 'sort': result.sort = parseSortArgs(); break; + case 'select': result.select = parseSelectArgs(); break; + case 'sort': result.sort = parseSortArgs(); break; case 'limit': { const args = parsePlainArgs('limit'); if (args.length === 1) result.limit = +args[0]; @@ -556,31 +584,46 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult recordError(`unknown call function '${val}'`); } if (search[pos] === ',') pos++; - else expectDelim = true; path = undefined; chainPath = undefined; } else { - // Anonymous condition group. qp.lastIndex = pos; const inner = parseCondGroup(')'); pos = qp.lastIndex; const grp = accToGroup(inner); - if (grp) { closeChain(topAcc); topAcc.terms.push(grp); topAcc.lastPath = undefined; } + if (grp) { closeActiveEM(); topAcc.terms.push(grp); topAcc.lastPath = undefined; } if (search[pos] === ',') pos++; - else expectDelim = true; - path = undefined; chainPath = undefined; + path = undefined; } break; } case '[': { - if (val) { recordError(`unexpected name '${val}' before '['`); break; } - qp.lastIndex = pos; - const inner = parseCondGroup(']'); - pos = qp.lastIndex; - const grp = accToGroup(inner); - if (grp) { closeChain(topAcc); topAcc.terms.push(grp); topAcc.lastPath = undefined; } - if (search[pos] === ',') pos++; - else expectDelim = true; - path = undefined; chainPath = undefined; + if (val) { + const ePath = splitPath(val); + qp.lastIndex = pos; + const inner = parseCondGroup(']'); + pos = qp.lastIndex; + const innerGrp = accToGroup(inner); + if (!innerGrp) { + recordError(`empty bracket group for '${val}'`); + } else if (innerGrp.terms.length === 1 && !('some' in innerGrp.terms[0])) { + const ic = innerGrp.terms[0] as Condition; + const merged: Condition = { path: [...ePath, ...ic.path], comparator: ic.comparator, value: ic.value }; + if (ic.negated) merged.negated = true; + closeActiveEM(); topAcc.terms.push(merged); topAcc.lastPath = merged.path; + } else { + closeActiveEM(); topAcc.terms.push({ path: ePath, some: innerGrp }); topAcc.lastPath = ePath; + } + if (search[pos] === ',') pos++; + path = undefined; chainPath = undefined; + } else { + qp.lastIndex = pos; + const inner = parseCondGroup(']'); + pos = qp.lastIndex; + const grp = accToGroup(inner); + if (grp) { closeActiveEM(); topAcc.terms.push(grp); topAcc.lastPath = undefined; } + if (search[pos] === ',') pos++; + path = undefined; + } break; } case ')': case ']': case '}': @@ -592,7 +635,8 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult } if (path !== undefined) finishTopCond(''); - closeChain(topAcc); + closeActiveEM(); + const filter = accToGroup(topAcc); if (filter) result.filter = filter; diff --git a/src/types.ts b/src/types.ts index a91d64f..bb79989 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,7 +11,18 @@ export interface Condition { export interface Group { operator: 'and' | 'or'; - terms: (Condition | Group)[]; + terms: (Condition | Group | ElementMatch)[]; +} + +/** + * Asserts that at least one element reached via `path` satisfies `some`. + * Conditions inside `some` use element-relative paths; `path: []` means the element itself. + * Produced by chaining (`&=` / `|=`) and by `between` / `not_between`. + */ +export interface ElementMatch { + path: string[]; + some: Group; + negated?: boolean; } export interface SortKey { @@ -30,7 +41,7 @@ export interface Projection { } export interface ParseResult { - filter?: Group; + filter?: Group | ElementMatch; sort?: SortKey[]; select?: Projection; limit?: number; diff --git a/test/v2/parse.test.ts b/test/v2/parse.test.ts index 3579ef2..108fcfc 100644 --- a/test/v2/parse.test.ts +++ b/test/v2/parse.test.ts @@ -1,7 +1,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { parseQuery, resolveFiqlName } from '../../src/index.ts'; -import type { Condition, Group, ParseResult } from '../../src/index.ts'; +import type { Condition, Group, ElementMatch, ParseResult } from '../../src/index.ts'; // Helpers function cond(path: string[], comparator: string, value: unknown, negated?: boolean): Condition { @@ -167,25 +167,37 @@ describe('Alias desugaring', () => { // --------------------------------------------------------------------------- describe('between desugaring', () => { - it('between=(lo,hi) → and-group of ge+le', () => { + it('between=(lo,hi) → ElementMatch wrapping ge+le group', () => { const r = parseQuery('age=between=(18,65)'); - assert.deepEqual(r.filter, andGrp( - andGrp(cond(['age'], 'ge', '18'), cond(['age'], 'le', '65')), - )); - }); - - it('not_between=(lo,hi) → or-group of negated ge+le', () => { + // filter is an and-Group with one term: the ElementMatch. + const em = r.filter!.terms[0] as ElementMatch; + assert.deepEqual(em.path, ['age']); + assert.equal(em.some.operator, 'and'); + assert.equal(em.some.terms.length, 2); + assert.equal((em.some.terms[0] as Condition).comparator, 'ge'); + assert.equal((em.some.terms[0] as Condition).value, '18'); + assert.deepEqual((em.some.terms[0] as Condition).path, []); + assert.equal((em.some.terms[1] as Condition).comparator, 'le'); + assert.equal((em.some.terms[1] as Condition).value, '65'); + assert.deepEqual((em.some.terms[1] as Condition).path, []); + assert.equal(em.negated, undefined); + }); + + it('not_between=(lo,hi) → negated ElementMatch wrapping ge+le group', () => { const r = parseQuery('age=not_between=(18,65)'); - assert.deepEqual(r.filter, andGrp( - orGrp(cond(['age'], 'ge', '18', true), cond(['age'], 'le', '65', true)), - )); + const em = r.filter!.terms[0] as ElementMatch; + assert.deepEqual(em.path, ['age']); + assert.equal(em.some.operator, 'and'); + assert.equal(em.negated, true); + assert.equal((em.some.terms[0] as Condition).comparator, 'ge'); + assert.equal((em.some.terms[1] as Condition).comparator, 'le'); }); it('between with typed values', () => { const r = parseQuery('score=between=(number:10,number:99)'); - const sub = (r.filter!.terms[0] as Group).terms; - assert.equal((sub[0] as Condition).value, 10); - assert.equal((sub[1] as Condition).value, 99); + const em = r.filter!.terms[0] as ElementMatch; + assert.equal((em.some.terms[0] as Condition).value, 10); + assert.equal((em.some.terms[1] as Condition).value, 99); }); }); @@ -194,24 +206,46 @@ describe('between desugaring', () => { // --------------------------------------------------------------------------- describe('Chaining desugaring', () => { - it('age=ge=20&=le=30 → and sub-group of ge+le', () => { + // &= means "same-element scope": some one element of path satisfies ALL chained conditions. + it('age=ge=20&=le=30 → ElementMatch with element-relative conditions', () => { const r = parseQuery('age=ge=20&=le=30'); - // Top-level filter is an and-Group wrapping the chain sub-group. - const sub = r.filter!.terms[0] as Group; - assert.equal(sub.operator, 'and'); - assert.equal(sub.terms.length, 2); - assert.equal((sub.terms[0] as Condition).comparator, 'ge'); - assert.equal((sub.terms[1] as Condition).comparator, 'le'); - assert.deepEqual((sub.terms[0] as Condition).path, ['age']); - assert.deepEqual((sub.terms[1] as Condition).path, ['age']); + const em = r.filter!.terms[0] as ElementMatch; + assert.deepEqual(em.path, ['age']); + assert.equal(em.some.operator, 'and'); + assert.equal(em.some.terms.length, 2); + assert.equal((em.some.terms[0] as Condition).comparator, 'ge'); + assert.deepEqual((em.some.terms[0] as Condition).path, []); + assert.equal((em.some.terms[1] as Condition).comparator, 'le'); + assert.deepEqual((em.some.terms[1] as Condition).path, []); }); - it('|= produces or sub-group', () => { + it('|= produces ElementMatch with or operator', () => { const r = parseQuery('status=eq=active|=eq=pending'); - const sub = r.filter!.terms[0] as Group; - assert.equal(sub.operator, 'or'); - assert.equal((sub.terms[0] as Condition).value, 'active'); - assert.equal((sub.terms[1] as Condition).value, 'pending'); + const em = r.filter!.terms[0] as ElementMatch; + assert.deepEqual(em.path, ['status']); + assert.equal(em.some.operator, 'or'); + assert.equal((em.some.terms[0] as Condition).value, 'active'); + assert.equal((em.some.terms[1] as Condition).value, 'pending'); + }); + + // Semantic motivation: chained vs un-chained are different for list-valued properties. + it('chained vs un-chained produce different canonical shapes (ski-lengths)', () => { + // Chained: some ONE skiLength value must be in [175,180]. + const chained = parseQuery('skiLengths=ge=175&=le=180'); + // Un-chained: some element ≥175 AND some (possibly different) element ≤180. + const unchained = parseQuery('skiLengths=ge=175&skiLengths=le=180'); + + const em = chained.filter!.terms[0] as ElementMatch; + assert.deepEqual(em.path, ['skiLengths']); + assert.equal(em.some.operator, 'and'); + assert.deepEqual((em.some.terms[0] as Condition).path, []); + assert.deepEqual((em.some.terms[1] as Condition).path, []); + + assert.equal(unchained.filter!.terms.length, 2); + assert.deepEqual((unchained.filter!.terms[0] as Condition).path, ['skiLengths']); + assert.deepEqual((unchained.filter!.terms[1] as Condition).path, ['skiLengths']); + + assert.notDeepEqual(chained.filter, unchained.filter); }); }); From b95c3ad00004a725ba2e8f79189c3fae84bbe938 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Mon, 31 Aug 2026 19:07:09 -0600 Subject: [PATCH 09/14] Fix parser gaps found in spec-conformance review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - QP tokenizer now emits &= / |= as single tokens; chain handling inside groups (and after verbatim = conditions) was unreachable and errored - parseCondGroup builds ElementMatch for chains (element scoping was lost inside (...)/[...]) - Interpreted mode auto-converts round-trip numerals, keeping a==3 distinct from verbatim a=3 (§5.2) - Nameless chain legs (a=ge=1&=5) are a syntax error per the grammar - Nested projections are records mode (brand{name} trims, not values) - Singleton ElementMatch normalizes to a plain Condition on close - ParseResult.filter narrowed to Group 74 tests pass (was 68). Co-Authored-By: Claude Fable 5 --- src/parser.ts | 108 ++++++++++++++++++++++++++++++++++-------- src/types.ts | 2 +- test/v2/parse.test.ts | 84 +++++++++++++++++++++++++++----- 3 files changed, 159 insertions(+), 35 deletions(-) diff --git a/src/parser.ts b/src/parser.ts index 668d3dc..d6909ac 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -7,7 +7,9 @@ import type { // QP: tokenises attribute names and structural operators. // VP: tokenises value tokens (includes ( ) , as plain chars; excludes & | = [ ] { }). // Both are created fresh per parseQuery call for reentrancy. -const QP_SRC = '([^?&|=<>!([{\\}\\]),]*)([([{\\}\\])|,&]|[=<>!]*)'; +// [&|]= first: the chain operators are two-char tokens and must win over +// the single-char structural match. +const QP_SRC = '([^?&|=<>!([{\\}\\]),]*)([&|]=|[([{\\}\\])|,&]|[=<>!]*)'; const VP_SRC = '([^&|=\\[\\]{}]*)([\\[\\]{}]|[&|=]*)'; const FIQL_NAME = /^[a-zA-Z_][a-zA-Z_0-9]*$/; @@ -30,6 +32,10 @@ function interpretValue(token: string): Value { default: throw new QueryError(`Unknown type prefix '${type}'`); } } + // §5.2.2: decimal numerals auto-convert in interpreted mode (round-trip rule), + // keeping interpreted `a==3` distinct from verbatim `a=3`. + const n = +token; + if (token !== '' && !isNaN(n) && String(n) === token) return n; return decodeURIComponent(token); } @@ -98,6 +104,22 @@ function accToGroup(acc: Acc): Group | undefined { return { operator: acc.operator ?? 'and', terms: acc.terms }; } +// §6 invariant: an ElementMatch scoping a single plain condition normalizes to an +// ordinary Condition with the concatenated path. +function pushElementMatch(acc: Acc, em: ElementMatch): void { + const t = em.some.terms; + if (t.length === 1 && !('some' in t[0]) && !('terms' in t[0]) && !em.negated) { + const ic = t[0] as Condition; + const merged: Condition = { path: [...em.path, ...ic.path], comparator: ic.comparator, value: ic.value }; + if (ic.negated) merged.negated = true; + acc.terms.push(merged); + acc.lastPath = merged.path; + } else { + acc.terms.push(em); + acc.lastPath = em.path; + } +} + // ── Main parse function ──────────────────────────────────────────────────── export function parseQuery(search: string, options?: ParseOptions): ParseResult { @@ -124,6 +146,15 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult let rawComp: string | undefined; let fiqlMode = false; let chainPath: string[] | undefined; + let activeEM: ElementMatch | undefined; + + function closeEM(): void { + if (activeEM) { + pushElementMatch(acc, activeEM); + activeEM = undefined; + chainPath = undefined; + } + } function finishCond(rawVal: string): void { if (path === undefined) return; @@ -151,15 +182,28 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult const sym = SYMBOL_OPS[rc]; if (!sym) { recordError(`unknown operator '${rc}'`); - path = undefined; rawComp = undefined; fiqlMode = false; chainPath = undefined; + path = undefined; rawComp = undefined; fiqlMode = false; + if (!activeEM) chainPath = undefined; return; } term = makeCondition(rp, sym.comparator, sym.negated, rawVal, sym.verbatim); } - acc.lastPath = rp; - acc.terms.push(term); - path = undefined; rawComp = undefined; fiqlMode = false; chainPath = undefined; + if (activeEM) { + const addLeg = (c: Condition): void => { + const relPath = c.path.slice(activeEM!.path.length); + const ec: Condition = { path: relPath, comparator: c.comparator, value: c.value }; + if (c.negated) ec.negated = true; + activeEM!.some.terms.push(ec); + }; + if ('some' in term) for (const leg of (term as ElementMatch).some.terms) addLeg(leg as Condition); + else addLeg(term as Condition); + } else { + acc.lastPath = rp; + acc.terms.push(term); + } + path = undefined; rawComp = undefined; fiqlMode = false; + // chainPath / activeEM persist across chain legs. } qp.lastIndex = pos; @@ -191,16 +235,35 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult case '&': case '|': { const lop: 'and' | 'or' = op === '&' ? 'and' : 'or'; if (path !== undefined) finishCond(val); + else if (chainPath !== undefined && val) recordError(`chain leg requires a comparator name before '${val}'`); + closeEM(); setGroupOp(acc, lop, recordError); break; } - case '&=': case '|=': + case '&=': case '|=': { + const cop: 'and' | 'or' = op === '&=' ? 'and' : 'or'; if (path !== undefined) finishCond(val); - chainPath = acc.lastPath ?? (acc.terms.at(-1) as Condition | undefined)?.path; - if (!chainPath) recordError('no preceding condition for &=/|='); + if (activeEM) { + if (cop !== activeEM.some.operator) recordError('cannot mix & and | within a chain'); + } else { + const prev = acc.terms.pop(); + if (!prev || 'some' in prev || 'terms' in prev) { + if (prev) acc.terms.push(prev); + recordError('no preceding condition for &=/|='); + break; + } + const prevCond = prev as Condition; + chainPath = prevCond.path; + const relCond: Condition = { path: [], comparator: prevCond.comparator, value: prevCond.value }; + if (prevCond.negated) relCond.negated = true; + activeEM = { path: chainPath, some: { operator: cop, terms: [relCond] } }; + acc.lastPath = undefined; + } break; + } case '': case undefined: if (path !== undefined) finishCond(val); + else if (chainPath !== undefined && val) recordError(`chain leg requires a comparator name before '${val}'`); break; case ',': recordError("unexpected ','"); @@ -211,7 +274,7 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult const inner = parseCondGroup(')'); pos = qp.lastIndex; const grp = accToGroup(inner); - if (grp) { acc.terms.push(grp); acc.lastPath = undefined; } + if (grp) { closeEM(); acc.terms.push(grp); acc.lastPath = undefined; } break; } case '[': { @@ -223,13 +286,9 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult const innerGrp = accToGroup(inner); if (!innerGrp) { recordError(`empty bracket group for '${val}'`); - } else if (innerGrp.terms.length === 1 && !('some' in innerGrp.terms[0])) { - const ic = innerGrp.terms[0] as Condition; - const merged: Condition = { path: [...ePath, ...ic.path], comparator: ic.comparator, value: ic.value }; - if (ic.negated) merged.negated = true; - acc.terms.push(merged); acc.lastPath = merged.path; } else { - acc.terms.push({ path: ePath, some: innerGrp }); acc.lastPath = ePath; + closeEM(); + pushElementMatch(acc, { path: ePath, some: innerGrp }); } } else { qp.lastIndex = pos; @@ -245,6 +304,7 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult if (closeCh === ch) { if (path !== undefined) finishCond(val); else if (val) recordError(`unexpected value without path '${val}'`); + closeEM(); return acc; } recordError(closeCh ? `expected '${closeCh}', got '${ch}'` : `unexpected '${ch}'`); @@ -258,6 +318,7 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult if (pos === search.length) break; } if (closeCh) recordError(`expected '${closeCh}', got end of string`); + closeEM(); return acc; } @@ -334,8 +395,7 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult function closeActiveEM(): void { if (activeEM) { - topAcc.terms.push(activeEM); - topAcc.lastPath = activeEM.path; + pushElementMatch(topAcc, activeEM); activeEM = undefined; chainPath = undefined; } @@ -442,17 +502,20 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult return fields; } - function rawFieldsToProjection(fields: RawField[], trailingComma: boolean): Projection { + function rawFieldsToProjection(fields: RawField[], trailingComma: boolean, nested = false): Projection { if (fields.length === 1 && fields[0].tuple) { return { mode: 'tuples', fields: (fields[0].nested ?? []).map(rawToField) }; } const fs = fields.map(rawToField); - const mode: 'values' | 'records' = (fs.length === 1 && !fields[0].nested && !trailingComma) ? 'values' : 'records'; + // The single-field `values` mode is a top-level surface form only (§5.7); + // nested projections trim the object (`records`), matching sub-select semantics. + const mode: 'values' | 'records' = + (!nested && fs.length === 1 && !fields[0].nested && !trailingComma) ? 'values' : 'records'; return { mode, fields: fs }; } function rawToField(rf: RawField): Field { - if (rf.nested) return { path: rf.path, projection: rawFieldsToProjection(rf.nested, false) }; + if (rf.nested) return { path: rf.path, projection: rawFieldsToProjection(rf.nested, false, true) }; return { path: rf.path }; } @@ -492,7 +555,8 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult if (cop !== activeEM.some.operator) recordError('cannot mix & and | within a chain'); } else { const prev = topAcc.terms.pop(); - if (!prev || 'some' in prev) { + if (!prev || 'some' in prev || 'terms' in prev) { + if (prev) topAcc.terms.push(prev); recordError('no preceding Condition to chain onto'); break; } const prevCond = prev as Condition; @@ -534,6 +598,7 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult case '&': case '|': { const lop: 'and' | 'or' = op === '&' ? 'and' : 'or'; if (path !== undefined) finishTopCond(val); + else if (chainPath !== undefined && val) recordError(`chain leg requires a comparator name before '${val}'`); closeActiveEM(); setGroupOp(topAcc, lop, recordError); break; @@ -559,6 +624,7 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult } case '': case undefined: if (path !== undefined) finishTopCond(val); + else if (chainPath !== undefined && val) recordError(`chain leg requires a comparator name before '${val}'`); break; case ',': recordError("unexpected ','"); diff --git a/src/types.ts b/src/types.ts index bb79989..4bb14e7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -41,7 +41,7 @@ export interface Projection { } export interface ParseResult { - filter?: Group | ElementMatch; + filter?: Group; sort?: SortKey[]; select?: Projection; limit?: number; diff --git a/test/v2/parse.test.ts b/test/v2/parse.test.ts index 108fcfc..7f5a460 100644 --- a/test/v2/parse.test.ts +++ b/test/v2/parse.test.ts @@ -67,16 +67,16 @@ describe('Ordered comparators', () => { it('< > <= >=', () => { const r = parseQuery('price<10&qty<=5&age>18&score>=90'); assert.deepEqual(r.filter, andGrp( - cond(['price'], 'lt', '10'), - cond(['qty'], 'le', '5'), - cond(['age'], 'gt', '18'), - cond(['score'], 'ge', '90'), + cond(['price'], 'lt', 10), + cond(['qty'], 'le', 5), + cond(['age'], 'gt', 18), + cond(['score'], 'ge', 90), )); }); it('FIQL lt/le/gt/ge', () => { const r = parseQuery('age=gt=4'); - assert.deepEqual(r.filter, andGrp(cond(['age'], 'gt', '4'))); + assert.deepEqual(r.filter, andGrp(cond(['age'], 'gt', 4))); }); }); @@ -94,7 +94,7 @@ describe('OR query', () => { const r = parseQuery('id=1&(value=gt=4|name=2)'); assert.deepEqual(r.filter, andGrp( cond(['id'], 'eq', '1'), - orGrp(cond(['value'], 'gt', '4'), cond(['name'], 'eq', '2')), + orGrp(cond(['value'], 'gt', 4), cond(['name'], 'eq', '2')), )); }); @@ -122,7 +122,7 @@ describe('OR query', () => { describe('Alias desugaring', () => { it('ne → negated eq', () => { const r = parseQuery('a=ne=1'); - assert.deepEqual(r.filter, andGrp(cond(['a'], 'eq', '1', true))); + assert.deepEqual(r.filter, andGrp(cond(['a'], 'eq', 1, true))); }); it('equals → eq (verbatim)', () => { @@ -138,7 +138,7 @@ describe('Alias desugaring', () => { it('ne and != produce same canonical form (interpreted)', () => { const a = parseQuery('x=ne=1'); const b = parseQuery('x!=1'); - // Both → negated eq, interpreted (both have string '1' since 1 is a bare number token) + // Both → negated eq, interpreted (numeral auto-converts: value is number 1) assert.deepEqual(a.filter, b.filter); }); @@ -149,8 +149,8 @@ describe('Alias desugaring', () => { }); it('less_than / greaterThan aliases', () => { - assert.deepEqual(parseQuery('a=less_than=5').filter, andGrp(cond(['a'], 'lt', '5'))); - assert.deepEqual(parseQuery('a=greaterThan=5').filter, andGrp(cond(['a'], 'gt', '5'))); + assert.deepEqual(parseQuery('a=less_than=5').filter, andGrp(cond(['a'], 'lt', 5))); + assert.deepEqual(parseQuery('a=greaterThan=5').filter, andGrp(cond(['a'], 'gt', 5))); }); it('out → negated in', () => { @@ -158,7 +158,7 @@ describe('Alias desugaring', () => { const c = r.filter!.terms[0] as Condition; assert.equal(c.comparator, 'in'); assert.equal(c.negated, true); - assert.deepEqual(c.value, ['1', '2']); + assert.deepEqual(c.value, [1, 2]); }); }); @@ -175,10 +175,10 @@ describe('between desugaring', () => { assert.equal(em.some.operator, 'and'); assert.equal(em.some.terms.length, 2); assert.equal((em.some.terms[0] as Condition).comparator, 'ge'); - assert.equal((em.some.terms[0] as Condition).value, '18'); + assert.equal((em.some.terms[0] as Condition).value, 18); assert.deepEqual((em.some.terms[0] as Condition).path, []); assert.equal((em.some.terms[1] as Condition).comparator, 'le'); - assert.equal((em.some.terms[1] as Condition).value, '65'); + assert.equal((em.some.terms[1] as Condition).value, 65); assert.deepEqual((em.some.terms[1] as Condition).path, []); assert.equal(em.negated, undefined); }); @@ -571,3 +571,61 @@ describe('resolveFiqlName', () => { assert.equal(r.negated, false); }); }); + +// --------------------------------------------------------------------------- +// Verbatim vs interpreted distinction, group chaining, nested projection mode +// --------------------------------------------------------------------------- + +describe('Verbatim vs interpreted values (§5.2)', () => { + it('a==3 (interpreted) parses to number, a=3 / a===3 (verbatim) to string', () => { + assert.deepEqual(parseQuery('a==3').filter, andGrp(cond(['a'], 'eq', 3))); + assert.deepEqual(parseQuery('a=3').filter, andGrp(cond(['a'], 'eq', '3'))); + assert.deepEqual(parseQuery('a===3').filter, andGrp(cond(['a'], 'eq', '3'))); + }); + + it('non-roundtrip numerals stay strings in interpreted mode', () => { + assert.deepEqual(parseQuery('a==1e3').filter, andGrp(cond(['a'], 'eq', '1e3'))); + }); +}); + +describe('Chain legs require a comparator name (§4 grammar)', () => { + it('a=ge=1&=5 throws', () => { + assert.throws(() => parseQuery('a=ge=1&=5'), /chain leg requires a comparator name/); + }); +}); + +describe('Chaining inside groups keeps element scoping (§5.3)', () => { + it('(skiLengths=ge=175&=le=180) → ElementMatch, same as un-grouped', () => { + const grouped = parseQuery('(skiLengths=ge=175&=le=180)'); + const inner = grouped.filter!.terms[0] as Group; + const em = inner.terms[0] as ElementMatch; + assert.deepEqual(em, { + path: ['skiLengths'], + some: { operator: 'and', terms: [ + { path: [], comparator: 'ge', value: 175 }, + { path: [], comparator: 'le', value: 180 }, + ] }, + }); + }); + + it('chained legs inside a bracket scoped-match stay grouped', () => { + const r = parseQuery('a=1&[skiLengths=ge=175&=le=180]'); + const grp = r.filter!.terms[1] as Group; + const em = grp.terms[0] as ElementMatch; + assert.deepEqual(em.path, ['skiLengths']); + assert.equal(em.some.terms.length, 2); + }); +}); + +describe('Nested projections are records mode (§5.7)', () => { + it('select(name,brand{name}) → nested single-field projection trims the object', () => { + const r = parseQuery('select(name,brand{name})'); + assert.deepEqual(r.select, { + mode: 'records', + fields: [ + { path: ['name'] }, + { path: ['brand'], projection: { mode: 'records', fields: [{ path: ['name'] }] } }, + ], + }); + }); +}); From feccc1dafe9348c08748daf32695ecd0c5ff6d0f Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Tue, 1 Sep 2026 05:37:04 -0600 Subject: [PATCH 10/14] Spec: resolve cross-model-review blockers B1-B4 and significant items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B1: negation scopes over the condition's own traversal (top-level ¬∃, in-scope predicate negation, recursive); flattening invariant exempts negated inner conditions; elem-cond surface (tags[=not_eq=urgent]) gives ∃¬ a Core spelling; 'not' reserved in Appendix C - B2: comparators are scalar predicates — contains is string containment, list handling comes only from §5.5 existential traversal - B3: interpretation is schema-free; schema binding is a non-canonical execution concern; conformance vectors schema-free - B4: semantic markers (type:, wildcard *, sort +/-) recognized on raw tokens; %2B sort recommendation removed - S1: ABNF reconciled — standalone calls, chained-cond binding, seg-char/ type-name defined, tokenization rules, trailing comma, chained lists - S2: malformed literals / limit args / duplicate calls are syntax errors; round-trip numeral rule explicit - S3: group-by removed from Core (reserved name, parse-rejected) - S4/S5: nested projections always records mode; wildcard stems verbatim - S6: determinism invariant scoped to desugaring, canonicalization deferred to §7; S7 covered by elem-cond - §5.5: result multiplicity sentence (each record at most once) - Appendix A: collection-matching and closed-converter rows added - Appendix D: rows 10-12 (harper#2433 chained-leg coercion, #2434 index duplicates, contains numeric coercion); row 2/9 updated with PR #2437 verification; examples de-personalized (ratings/reviews) Co-Authored-By: Claude Fable 5 --- specification/rql-2.0.md | 258 +++++++++++++++++++++++++++------------ 1 file changed, 179 insertions(+), 79 deletions(-) diff --git a/specification/rql-2.0.md b/specification/rql-2.0.md index 5bc3e2c..432a068 100644 --- a/specification/rql-2.0.md +++ b/specification/rql-2.0.md @@ -27,8 +27,8 @@ RQL 2.0 consists of: - a **surface grammar** (§4) for conditions, logical composition, and call-style query functions, designed to be a compatible superset of HTML form URL encoding and of FIQL; - **operator semantics** (§5): a small orthogonal comparator set with uniform negation, - typed value literals, range chaining, property paths, and the `select`/`sort`/`limit` - functions; + typed value literals, element-scoped matching and range chaining, property paths, and + the `select`/`sort`/`limit` functions; - a **canonical parsed representation** (§6) — the abstract data model every conforming parser produces, into which all surface sugar desugars; - **conformance profiles** (§8): *Core* (this document, normative) and *Extensions* @@ -64,45 +64,68 @@ NOT", "RECOMMENDED", "MAY", and "OPTIONAL" are to be interpreted as described in 5. **Extensible.** FIQL comparator names are an open identifier set — parsers MUST accept unknown names syntactically and defer semantic validation to execution. Call-function names are a closed set validated at parse time (§5.6). -6. **Language-neutral.** The canonical representation is defined abstractly; bindings for - particular languages map it to native structures but MUST preserve its shape. +6. **Language-neutral and schema-free.** The canonical representation is defined + abstractly and is fully determined by the query string alone — no schema participates + in parsing (§5.2). Bindings for particular languages map the model to native + structures but MUST preserve its shape. ## 4. Grammar -Draft ABNF (RFC 5234). §4.1 notes tolerances a parser MAY additionally provide. +ABNF (RFC 5234), with the tokenization rules below. ```abnf -query = [ group-body ] *( "&" call ) -group-body = term *( conjunction term ) - ; all conjunctions within one group-body MUST be identical (§5.4) +query = [ q-term *( conjunction q-term ) ] +q-term = term / call + ; call functions MAY appear only at the top level, and the + ; conjunction adjacent to a call MUST be "&" conjunction = "&" / "|" term = condition / chained-cond / group / scoped-match group = "(" group-body ")" / "[" group-body "]" -scoped-match = prop-path "[" group-body "]" +group-body = term *( conjunction term ) + ; all conjunctions within one group-body MUST be identical (§5.4) +scoped-match = prop-path "[" scoped-body "]" ; element-scoped sub-query over the values at prop-path (§5.3); - ; inner paths are element-relative + ; inner prop-paths are element-relative +scoped-body = scoped-term *( conjunction scoped-term ) +scoped-term = term / elem-cond +elem-cond = "=" fiql-name "=" ( value / value-list ) + ; a comparison on the scoped element itself (empty relative path); + ; valid only inside a scoped-match condition = prop-path symbol-op value / prop-path "=" fiql-name "=" ( value / value-list ) -chained-cond = ( "&=" / "|=" ) fiql-name "=" value - ; continues the preceding condition, scoped to the same element (§5.3) +chained-cond = ( "&=" / "|=" ) fiql-name "=" ( value / value-list ) + ; continues the immediately preceding condition, scoped to the + ; same element (§5.3); MUST directly follow a condition or + ; another chained-cond symbol-op = "=" / "==" / "===" / "!=" / "!==" / "<" / "<=" / ">" / ">=" fiql-name = ALPHA-UNDER *( ALPHA-UNDER / DIGIT ) ALPHA-UNDER = ALPHA / "_" prop-path = prop-segment *( "." prop-segment ) -prop-segment = 1*seg-char ; percent-decoded after path splitting (§4.2) +prop-segment = 1*seg-char +seg-char = ALPHA / DIGIT / "-" / "_" / "~" / pct-encoded + ; any other character — including a literal "." (§4.2) — is + ; included via percent-encoding +pct-encoded = "%" HEXDIG HEXDIG value = plain-value / typed-value / wildcard-value plain-value = *vchar -typed-value = type-name ":" *vchar ; §5.2.2 +typed-value = type-name ":" *vchar ; §5.2.2 +type-name = fiql-name +wildcard-value = *vchar "*" ; "*" only as the final + ; character, only with "==" (§5.1.2) value-list = "(" [ value *( "," value ) ] ")" -wildcard-value = 1*vchar "*" ; only with "==" (§5.1.2) +vchar = seg-char / ":" / "*" / "+" / "$" / "@" / "!" / "'" + ; pragmatically: any character other than the structural + ; delimiters & | = , ( ) [ ] { } — those are included via + ; percent-encoding call = call-name "(" [ call-args ] ")" call-name = 1*( ALPHA / DIGIT / "-" / "_" ) -call-args = call-arg *( "," call-arg ) +call-args = call-arg *( "," call-arg ) [ "," ] + ; the trailing comma is significant only for select (§5.7) call-arg = value / sort-key / select-item sort-key = [ "+" / "-" ] prop-path select-item = prop-path @@ -112,6 +135,19 @@ select-item = prop-path select-list = select-item *( "," select-item ) ``` +**Tokenization rules.** The grammar above is ambiguous as pure ABNF (`plain-value` +overlaps other productions); the following rules resolve it deterministically: + +1. **Longest match.** Multi-character tokens win over their prefixes: `&=`/`|=` are + recognized before `&`/`|`; `<=`, `>=`, `==`, `===`, `!=`, `!==` before `<`, `>`, + `=`, `!`. +2. **A raw `=` never occurs inside a value.** Value scanning stops at the structural + delimiters, so a `=` following a value token can only begin a `chained-cond` or the + second `=` of a FIQL form. +3. **A `chained-cond` binds to its predecessor.** It is valid only immediately after a + `condition` or another `chained-cond`; anywhere else it is a syntax error. +4. **Semantic markers are recognized on raw tokens** — see §4.2 rule 4. + ### 4.1 Parsing tolerances - **Delimiters inside values.** Once a comparator has been consumed, a parser MAY scan @@ -131,6 +167,12 @@ token afterward: brackets, braces, commas). 2. Split property paths on **literal** (unencoded) `.`. 3. Percent-decode each resulting property segment and each value token. +4. **Semantic markers are recognized on the raw token, before decoding:** the + `type:` prefix of a typed value, the trailing `*` wildcard, and the `+`/`-` + sort-direction prefix. A percent-encoded form of a marker character is therefore + literal content, never a marker: `x==string%3Anull` is the plain string + `string:null` (not a typed literal), `name==Jo%2A` is an equality against `Jo*` + (not a wildcard), and `sort(%2Bname)` sorts by the property named `+name`. Consequently `%2E` within a property segment denotes a literal `.` in that segment's name: `a%2Eb==3` is a condition on the single property named `a.b`, while `a.b==3` is a @@ -149,15 +191,36 @@ The canonical comparator vocabulary is deliberately small and orthogonal: |---|---| | `eq` | equality | | `lt`, `le`, `gt`, `ge` | ordered comparison | -| `contains` | string substring / collection membership of the value in the property's value | +| `contains` | string containment: the property's string value contains the given substring | | `starts_with`, `ends_with` | string affix match | -| `in` | property value is a member of the given value list | - -**Negation is uniform:** prefixing any Core comparator with `not_` yields its logical -complement over the collection (`tag=not_in=(a,b)`, `name=not_contains=xyz`, -`price=not_eq=10`). Negation is set complement: `not_lt` matches every resource `lt` -does not match, which is *not* equivalent to `ge` for resources where the property is -absent or incomparable. +| `in` | the property's value equals a member of the given value list | + +**Comparators are scalar predicates.** Every comparator applies to a single value; when +a path reaches a list, the existential traversal rule (§5.5) — never the comparator — +handles the elements. Over `tags: ["credit"]`, `tags=contains=red` matches (some element +contains the substring `red`), and whole-element equality is simply `tags=credit`. The +string comparators (`contains`, `starts_with`, `ends_with`) match only string values; a +non-string value does not match them. + +**Negation is uniform and scopes over the condition's own traversal.** Prefixing any +Core comparator with `not_` complements the set the un-negated condition matches, *at +the scope where the condition is evaluated*: + +- For a top-level condition whose path traverses a list, negation scopes over the + existential: `tags=not_eq=urgent` means ¬∃ — it matches only records where **no** tag + equals `urgent` (over `tags: ["urgent", "low"]` it does not match). +- Within an element scope (§5.3), the enclosing scope supplies the quantifier, so a + negated inner comparison negates the predicate on the bound element: + `ratings=ge=3&=not_eq=4` means ∃x: x ≥ 3 ∧ x ≠ 4. Recursively, an inner condition + whose *relative* path traverses a nested list scopes its own negation the same way. +- The complementary reading ∃x: ¬P(x) ("some element differs") is written as an explicit + singleton scope: `tags[=not_eq=urgent]`. + +As a consequence of complement semantics, `not_lt` matches every resource `lt` does not +match — which is *not* equivalent to `ge` for resources where the property is absent or +incomparable. Negating an entire group or scope has no Core surface form; the name `not` +is reserved for a future Extension (Appendix C). By De Morgan, leaf-level `not_` already +expresses the negation of any and/or combination of conditions. There is exactly one equality (`eq`) and one negation mechanism (`not_`). Notions like "strict vs. converting equality" are properties of the *value literal* (§5.2), not of @@ -166,7 +229,7 @@ or compatibility aliases (Appendix B). #### 5.1.2 Symbolic operators and sugar (desugaring table) -| Surface form | Canonical form | Value interpretation (§5.2) | +| Surface form | Canonical form | Value handling (§5.2) | |---|---|---| | `prop=value` | `eq` | verbatim | | `prop===value` | `eq` | verbatim | @@ -175,7 +238,7 @@ or compatibility aliases (Appendix B). | `prop!==value` | `not_` `eq` | verbatim | | `propv`, `prop>=v` | `lt`, `le`, `gt`, `ge` | interpreted | | `prop=name=value` (FIQL) | `name` | interpreted | -| `prop==stem*` | `starts_with` (trailing `*` removed) | interpreted | +| `prop==stem*` | `starts_with` (trailing `*` removed) | the stem is the **decoded string**, never an interpreted literal (`name==12*` matches strings starting `12`) | The trailing-`*` wildcard applies only to `==`; a leading or embedded `*` is a syntax error, and wildcards apply to no other comparator. @@ -185,8 +248,9 @@ Core set (and not a registered Extension or alias) is rejected at execution, not parse. This is the language's comparator extension point. **Value lists** `(v1,v2,…)` are interpreted as lists only for `in`/`not_in` (and the -`between` compatibility alias, Appendix B); each element is interpreted individually and -MAY be typed. `()` is the empty list. +`between` compatibility alias, Appendix B), including in chained legs; each element is +interpreted individually and MAY be typed. `()` is the empty list. A value list supplied +to any other comparator is a syntax error. ### 5.2 Values @@ -204,23 +268,27 @@ A value token is read in one of two modes: - **interpreted** — the token is converted by the literal rules below. Used by `==`, `!=`, symbolic ordered comparisons, and all FIQL named comparators. -When the target schema declares a type for the property, implementations MAY additionally -convert the parsed value to the schema type at binding time in either mode. +**Interpretation is schema-free.** The canonical representation of a query is fully +determined by the query string alone; the conformance suite (§8) depends on this. +Binding parsed values to a typed store — converting the string `"3"` to the number 3 +for a numeric column, or 3 to `"3"` for a string column — is an execution-time concern +outside the canonical model, applicable in either mode. #### 5.2.2 Literal interpretation rules | Token | Interpreted value | |---|---| | `null` | null | -| `true` / `false` | boolean, when the property is not schema-typed as string | -| decimal numeral | number, when the property is not schema-typed as string | +| `true` / `false` | boolean | +| round-trip decimal numeral | number — a token that equals the canonical decimal rendering of the number it denotes (`3`, `-5`, `2.5`); non-round-trip numeric spellings (`1e3`, `01`, `.5`, `1.50`) remain strings | | `number:N` | number (decimal) | | `number:$X` | number, `X` in base 36 | | `boolean:true` / `boolean:false` | boolean | | `date:ISO-8601` / `date:epochMillis` | timestamp | | `string:S` | string (suppresses further interpretation) | | any other token | percent-decoded string | -| unknown `type:` prefix | error (client error, HTTP 400) | +| unknown `type:` prefix | syntax error (client error, HTTP 400) | +| malformed typed literal (`boolean:yes`, `number:abc`, unparseable `date:`) | syntax error (client error, HTTP 400) | ### 5.3 Element-scoped matching and range chaining @@ -233,31 +301,40 @@ provides *element scoping*: a way to require that several comparisons hold for t or `|=` (or), each followed by a named comparison: ``` -skiLengths=ge=175&=le=180 ; some ONE length is in [175, 180] -skiLengths=ge=175&skiLengths=le=180 - ; DIFFERENT: some length ≥ 175 AND some - ; (possibly other) length ≤ 180 +ratings=ge=3&=le=4 ; some ONE rating is in [3, 4] +ratings=ge=3&ratings=le=4 ; DIFFERENT: some rating ≥ 3 AND some + ; (possibly other) rating ≤ 4 ``` -For the record `{ name: "Kris", skiLengths: [172, 174, 181] }`, the chained forms do -not match, while the two-condition form does (181 witnesses the first condition, 172 -the second). +For the record `{ sku: "widget-1", ratings: [2, 3, 5] }` the two-condition form matches +(5 witnesses the first condition, 2 the second)… while `ratings=ge=4&=le=4` over the +same record does not match and `ratings=ge=3&=le=4` matches only via the element 3. **Scoped sub-queries** generalize this to object elements: a property path directly followed by a bracketed group scopes the whole group to one element, with inner paths -relative to that element: +relative to that element. An inner comparison on the element value itself is written +with no property path (`elem-cond`): ``` -skis[length=ge=175&width=le=80] ; some ski is both long and narrow +reviews[rating=ge=4&helpful=ge=10] ; some review is both high-rated and helpful +scores[=ge=10|=le=2] ; some score is an outlier (≥10 or ≤2) +tags[=not_eq=urgent] ; some tag differs from "urgent" (∃¬ — contrast + ; tags=not_eq=urgent, ¬∃, §5.1.1) ``` -Canonically both forms are an *element-scoped match* (§6): the path plus a group whose +Canonically all of these are an *element-scoped match* (§6): the path plus a group whose conditions have element-relative paths (an empty relative path denotes the element value itself, as chained scalar comparisons produce). For a single-valued property, element scoping is trivially equivalent to separate conditions; parsers cannot know -value cardinality, so the scoping structure is always preserved. (Or-chaining is -logically distributable over the existential quantifier, but it is represented scoped -as well, for symmetry.) +value cardinality, so the scoping structure is preserved. (Or-chaining is logically +distributable over the existential quantifier, but it is represented scoped as well, +for symmetry.) + +A scoped match containing exactly one **non-negated** inner condition is equivalent to +a plain condition on the concatenated path and normalizes to it: `orders[status=open]` +≡ `orders.status=open`. A **negated** inner condition is *not* flattened — under +§5.1.1's scope rule, `tags[=not_eq=urgent]` (∃¬) and `tags=not_eq=urgent` (¬∃) mean +different things. Executors are encouraged to execute same-element `ge`/`gt` + `le`/`lt` pairs as a single index range scan — for element-indexed lists that scan implements same-element @@ -276,16 +353,21 @@ semantics naturally. Dot syntax addresses nested properties: `brand.name=Microsoft`. Where the data model declares relationships, path traversal crosses them; filtering through a relationship has inner-join semantics, while projecting an unfiltered relationship via `select` has -left-join semantics. When a path traverses a list-valued property, a condition matches -if **any** element matches (existential semantics); to bind several comparisons to the -same element, use element scoping (§5.3). +left-join semantics. + +When a path traverses a list-valued property, a condition matches if **any** element +matches (existential semantics); to bind several comparisons to the same element, use +element scoping (§5.3). **Matching determines membership, not multiplicity:** a query +yields each matching record at most once, no matter how many elements (or how many +conditions) witness the match. Literal dots in property names are expressed with `%2E` (§4.2). ### 5.6 Call functions -Exactly these call functions are Core; an unrecognized call name is a parse error -(unlike comparator names, which are open): +Exactly these call functions are Core. An unrecognized call name — including the +reserved Extension names of Appendix C — is a parse error (unlike comparator names, +which are open). A call function appearing more than once in a query is a syntax error. > **Break from 1.x:** in RQL 1.x, call syntax was the *normalized form* of every > operator — `lt(price,10)` was equivalent to `price=lt=10`, and infix forms were sugar. @@ -297,9 +379,8 @@ Exactly these call functions are Core; an unrecognized call name is a parse erro | Function | Semantics | |---|---| | `select(...)` | Projection (§5.7). | -| `sort(k1,k2,…)` | Each key optionally prefixed `+` (ascending, default) or `-` (descending); later keys break ties. Keys may be dotted paths. Note: some URL stacks decode a raw `+` as a space in query components; producers SHOULD percent-encode it (`%2B`) or rely on the ascending default. | -| `limit(end)` / `limit(start,end)` | **Start/end bounds, not offset/count**: `limit(5,10)` means offset 5, at most 5 records. | -| `group-by(...)` | Reserved. Parsers MUST accept the syntax; Core executors report "not implemented". | +| `sort(k1,k2,…)` | Each key optionally prefixed `+` (ascending, default) or `-` (descending); later keys break ties. Keys may be dotted paths. The prefix is recognized on the raw token (§4.2): `%2B`/`%2D` are literal name characters, not direction markers. Since some URL stacks decode a raw `+` as a space, producers SHOULD rely on the ascending default rather than writing `+`. | +| `limit(end)` / `limit(start,end)` | **Start/end bounds, not offset/count**: `limit(5,10)` means offset 5, at most 5 records. Arguments MUST be non-negative decimal integers with end ≥ start; anything else is a syntax error. | | `(...)` (anonymous) | Grouping, §5.4. | ### 5.7 Projection (`select`) @@ -315,7 +396,9 @@ property path with an optional nested projection: | `select(rel{x,y})` / `select(rel[select(x,y)])` | (nested) | field `rel` projected by the nested projection | The brace and bracket nested forms are equivalent surface spellings of the same nested -projection. +projection. **Nested projections are always `records` mode** — `rel{x}` trims the +related object to `{x}`; the single-field `values` rule applies only at the top level. +A nested `[x,y]` tuple form (`rel{[x,y]}`) is reserved and currently a syntax error. ## 6. Canonical parsed representation @@ -356,10 +439,18 @@ Invariants: - **All sugar is gone.** Aliases are resolved to canonical comparator names; `!=` desugars to `negated eq`; wildcards to `starts_with`; chaining and `between` to an - ElementMatch; `prop[x=1]` with a single inner condition normalizes to the plain - Condition `prop.x=1` (an ElementMatch always scopes two or more comparisons). Two - surface queries with the same meaning parse to the same representation. + ElementMatch. A scoped match with a single **non-negated** inner condition normalizes + to the plain Condition on the concatenated path (`prop[x=1]` ≡ `prop.x=1`); a negated + inner condition is never flattened (§5.3). +- **Desugaring is deterministic:** equivalent sugar forms (aliases, `between` vs. + chaining, `!=` vs. `ne`) parse to identical representations. Full semantic + canonicalization — group flattening, term reordering — is the province of §7 + serialization and is NOT asserted here: `a=1` and `(a=1)` may differ + representationally. - **A condition's `path` is always a segment list**, even for a single segment. +- Every value in the model is a well-formed member of `Value` — no NaN, no invalid + timestamps (§5.2.2 makes malformed literals syntax errors), and `limit`/`offset` are + validated non-negative integers (§5.6). - `filter` is absent for an unfiltered query; a query with a single condition is an `and` group with one term (there is no bare-condition special case). - The representation carries no execution or host-framework concerns (no lazy/simple @@ -368,11 +459,12 @@ Invariants: ### 6.1 Error model -Structural syntax violations (unbalanced groups, illegal wildcard, unknown call -function, unknown `type:` prefix) are client errors (HTTP 400 in an HTTP binding). -Implementations MAY offer a deferred-error mode in which the parser returns a -representation carrying the error for the execution pipeline to raise, but the canonical -behavior is to reject at parse. +Structural syntax violations (unbalanced groups, illegal wildcard, unknown or duplicate +call function, unknown `type:` prefix, malformed typed or numeric literal, out-of-range +`limit` arguments) are client errors (HTTP 400 in an HTTP binding). Implementations MAY +offer a deferred-error mode in which the parser returns a representation carrying the +error for the execution pipeline to raise, but the canonical behavior is to reject at +parse. ## 7. Serialization @@ -384,7 +476,8 @@ Every Query has a canonical string form, defined so that `parse(serialize(q)) = differ from the value's type; - `[...]` for all grouping; `%2E` for literal dots in segments; - element-scoped matches in chained form (`prop=ge=1&=le=5`) when every inner path is - empty, and in scoped-sub-query form (`prop[…]`) otherwise; + empty and the scope is not negated, and in scoped-sub-query form (`prop[…]`) + otherwise; - call functions last, in the order `select`, `sort`, `limit`. TODO: full normalization rules (value-token escaping table, timestamp formatting, @@ -393,10 +486,11 @@ ordering guarantees) — needed for cache keys and equivalence testing. ## 8. Conformance - **Core parser:** implements §4–§6 exactly; validated by the conformance suite - (`test/v2/` in the reference implementation), which is defined as surface-string → - canonical-representation pairs and is therefore language- and implementation-neutral. - An implementation with a different internal representation (e.g. Harper) conforms by - supplying an adapter from its internal form to the canonical model. + (`test/v2/` in the reference implementation), which is defined as **schema-free** + surface-string → canonical-representation pairs and is therefore language- and + implementation-neutral. An implementation with a different internal representation + (e.g. Harper) conforms by supplying an adapter from its internal form to the + canonical model. - **Core executor:** implements Core comparator/call semantics over a collection. - **Extensions (Appendix C):** optional; names are reserved and MUST NOT be repurposed. - **Compatibility aliases (Appendix B):** optional; if accepted, they MUST desugar @@ -421,10 +515,11 @@ regex-free matching guarantees. | Nested paths | `foo/bar`, `(foo,bar)` | `foo.bar` | | Grouping | `(...)` only | `(...)` and `[...]` | | String matching | `re:`/`RE:`/`glob:` converters, `match` | `contains`/`starts_with`/`ends_with`, `==stem*` | -| Converters | `epoch:`, `isodate:`, `re:`, `glob:` | removed; `date:` accepts ISO-8601 or epoch ms | +| Converters | open, extensible registry (`epoch:`, `isodate:`, `re:`, `glob:`, custom) | closed typed-prefix set (`number:`, `boolean:`, `date:`, `string:`); unknown or malformed prefix is a syntax error | | Positional params | `$1`, `$2` | removed | -| Negation | none | uniform `not_` comparator prefix | +| Negation | none | uniform `not_` comparator prefix, scoping over the condition's own traversal (§5.1.1) | | Range expression | `between` operator | `&=` / `|=` chaining (canonical); `between` demoted to alias | +| Collection matching | query-valued `contains(items,gt(price,10))`, `excludes(items,red)`; nested-array/condition arguments in value lists | scoped matches: `items[price=gt=10]`; membership is plain traversal (`items=red`); exclusion is `not_` (`items=not_eq=red`); value lists hold only literals | | Sub-selects | none | `rel{x,y}`, `rel[select(x)]`, `select([a,b])` | | AST | generic `{name, args}` term tree | typed canonical model (§6); generic terms remain a non-normative encoding for Extensions | | Aggregation etc. | Core operators | moved to Extensions profile (Appendix C) | @@ -440,31 +535,36 @@ or in canonical serialization: | `ne` | `not_` `eq` (interpreted value) | | `not_equal`, `equals` | `not_` `eq` / `eq` (verbatim value) | | `between=(lo,hi)` | element-scoped `ge=lo` AND `le=hi` (≡ `=ge=lo&=le=hi`, inclusive; same-element per §5.3) | -| `not_between=(lo,hi)` | negation of the above | +| `not_between=(lo,hi)` | negation of the above (negated ElementMatch) | | `sw`, `ew`, `ct`, `includes` | `starts_with`, `ends_with`, `contains`, `contains` | | `less_than`, `greater_than`, `lessThan`, `greaterThan`, … | `lt`, `gt`, … | | `out` (1.x) | `not_in` | | repeated array parameters `prop[]=v1&prop[]=v2` | membership conditions on `prop` (host-framework accommodation; NOT part of the RQL grammar) | -## Appendix C — Extensions profile (reserved from 1.x) +## Appendix C — Extensions profile (reserved names) -Reserved call-function names carried from RQL 1.x, non-normative pending a future -revision: `aggregate`, `distinct`, `values`, `sum`, `mean`, `max`, `min`, `count`, -`first`, `one`, `recurse`, `rel`, `group-by`. +Reserved call-function names, non-normative pending a future revision — carried from +RQL 1.x: `aggregate`, `distinct`, `values`, `sum`, `mean`, `max`, `min`, `count`, +`first`, `one`, `recurse`, `rel`, `group-by`; new in 2.0: `not` (general negation of a +group or scope, complementing leaf-level `not_`; §5.1.1). Core parsers reject these as +unknown call functions (§5.6). ## Appendix D — Known divergences of the Harper implementation Tracked so the spec stays ideal while implementations converge. As of harper `main` -(2026-08): +(2026-09): | # | Divergence | Spec position | |---|---|---| | 1 | Simple queries (no structural characters) skip parsing and surface as raw name/value pairs; consumers handle two condition shapes | §6: one canonical shape; lazy representations are a host affordance outside the model | -| 2 | `&=`/`|=` chains attach to the prior condition as `chainedConditions`; a nameless chain leg (`a=ge=1&=5`) is accepted and inherits the previous leg's comparator | semantically correct (same-element scoping, §5.3); representational divergence only — canonical form is ElementMatch. Nameless legs are a syntax error in 2.0 (the comparator name is required) | +| 2 | `&=`/`|=` chains attach to the prior condition as `chainedConditions`; a nameless chain leg (`a=ge=1&=5`) is accepted and inherits the previous leg's comparator | semantically correct (same-element scoping, §5.3 — verified on both indexed and unindexed paths, harper PR #2437); representational divergence only — canonical form is ElementMatch. Nameless legs are a syntax error in 2.0 (the comparator name is required) | | 3 | Strict vs. converting comparison is modeled as distinct comparators (`equals`/`not_equal` vs `eq`/`ne`) | §5.2: one `eq`; verbatim vs. interpreted is a property of the value literal | -| 4 | `between` is a first-class comparator | Appendix B alias, desugars to `ge`+`le` | +| 4 | `between` is a first-class comparator | Appendix B alias, desugars to an element-scoped `ge`+`le` | | 5 | Sort is a linked list; select is a polymorphic array with marker properties (`asArray`, `name`) | §6: sort is an ordered list of SortKeys; projection is mode + fields | | 6 | `(4)` on a non-list comparator is the literal string `"(4)"` | tolerance only; producers MUST NOT rely on it | | 7 | `prop[]=v` repeated-array params accepted in the parser | Appendix B host accommodation, not grammar | | 8 | Unknown call-name error and other semantic errors are deferred into the request pipeline (`parseError`) | §6.1: deferred mode is OPTIONAL; canonical behavior rejects at parse | -| 9 | `group-by(...)` fell through into `sort` handling (missing `break`) | bug; fix in flight (harper dispatch `harper-groupby-fallthrough`) | +| 9 | `group-by(...)` is accepted at parse (deferred not-implemented error) and falls through into `sort` handling (missing `break`) | 2.0 rejects reserved/unknown call names at parse (§5.6); the fall-through is a bug — fix in flight (dispatch `harper-groupby-fallthrough`) | +| 10 | Chained legs' values are never type-coerced on the REST path — `age=ge=175&=le=180` builds the mixed-type range `[175, "180"]`, silently returning a superset or empty set | bug — [harper#2433](https://github.com/HarperFast/harper/issues/2433); §5.2's interpreted mode applies uniformly to chained legs | +| 11 | Secondary indexes over `elements` (multi-value) attributes return one result per matching element — duplicate records; the unindexed path returns each record once | bug — [harper#2434](https://github.com/HarperFast/harper/issues/2434); §5.5: matching determines membership, not multiplicity | +| 12 | `contains` matches numeric values via decimal-string coercion (`lengths=ct=17` matches 172) | §5.1.1: string comparators match only string values; non-strings do not match | From 5fb0b789cb2a7f510baab44838b0363836a6fdd3 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Tue, 1 Sep 2026 05:56:02 -0600 Subject: [PATCH 11/14] =?UTF-8?q?Align=20parser=20with=20spec=20=C2=A74/?= =?UTF-8?q?=C2=A75/=C2=A76=20cross-model=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Negated-inner flattening exemption (§5.3/§6): elem-conds (ic.path=[]) and negated inner conditions are never normalized to plain Conditions. pushElementMatch + both bracket-singleton handlers now guard on !ic.negated && ic.path.length > 0. - Elem-cond surface (§4 scoped-body): inside prop[...] (isScoped=true), =fiql-name=value with no preceding path gives Condition{path:[]}; &=/|= after an elem-cond decomposes to conjunction + new elem-cond. - Malformed typed literals are syntax errors (§5.2.2): boolean:yes, number:abc, number:, invalid date: → QueryError. - Limit validation (§5.6): parseNonNegInt enforces non-negative decimal integer; 2-arg form requires end >= start. - Duplicate call functions (§5.6): seenCalls Set detects second select()/sort()/limit() and throws QueryError. - Nested tuple projection reserved (§5.7): select(rel{[x,y]}) → QueryError. - Tests: rename ski-lengths example to ratings; add reviews[...] object- element test; pin chained-value-list, raw-token-marker, malformed-literal, limit-validation, and duplicate-call suites. 103/103 pass. Co-Authored-By: Claude Sonnet 4.6 --- src/parser.ts | 159 ++++++++++++++++--------- test/v2/parse.test.ts | 266 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 353 insertions(+), 72 deletions(-) diff --git a/src/parser.ts b/src/parser.ts index d6909ac..7e34477 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -6,9 +6,7 @@ import type { // QP: tokenises attribute names and structural operators. // VP: tokenises value tokens (includes ( ) , as plain chars; excludes & | = [ ] { }). -// Both are created fresh per parseQuery call for reentrancy. -// [&|]= first: the chain operators are two-char tokens and must win over -// the single-char structural match. +// [&|]= wins over single-char structural match — chain/elem operators are two-char tokens. const QP_SRC = '([^?&|=<>!([{\\}\\]),]*)([&|]=|[([{\\}\\])|,&]|[=<>!]*)'; const VP_SRC = '([^&|=\\[\\]{}]*)([\\[\\]{}]|[&|=]*)'; @@ -25,15 +23,24 @@ function interpretValue(token: string): Value { const type = token.slice(0, colon); const rest = token.slice(colon + 1); switch (type) { - case 'number': return rest[0] === '$' ? parseInt(rest.slice(1), 36) : +rest; - case 'boolean': return rest === 'true'; - case 'date': return new Date(isNaN(+rest) ? decodeURIComponent(rest) : +rest); + case 'number': { + const n = rest[0] === '$' ? parseInt(rest.slice(1), 36) : (rest === '' ? NaN : +rest); + if (isNaN(n)) throw new QueryError(`malformed number literal '${token}'`); + return n; + } + case 'boolean': + if (rest !== 'true' && rest !== 'false') throw new QueryError(`malformed boolean literal '${token}'`); + return rest === 'true'; + case 'date': { + const d = new Date(isNaN(+rest) ? decodeURIComponent(rest) : +rest); + if (isNaN(d.getTime())) throw new QueryError(`malformed date literal '${token}'`); + return d; + } case 'string': return decodeURIComponent(rest); default: throw new QueryError(`Unknown type prefix '${type}'`); } } - // §5.2.2: decimal numerals auto-convert in interpreted mode (round-trip rule), - // keeping interpreted `a==3` distinct from verbatim `a=3`. + // §5.2.2: round-trip decimal numerals auto-convert in interpreted mode. const n = +token; if (token !== '' && !isNaN(n) && String(n) === token) return n; return decodeURIComponent(token); @@ -62,7 +69,7 @@ function makeCondition( } function parseListRaw(raw: string, verbatim: boolean): Value[] { - const inner = raw.slice(1, -1); // strip ( ) + const inner = raw.slice(1, -1); if (inner.length === 0) return []; const decode = verbatim ? verbatimValue : interpretValue; return inner.split(',').map(decode); @@ -81,6 +88,14 @@ function betweenMatch(path: string[], raw: string, negated: boolean): ElementMat return em; } +/** §5.6: limit args must be non-negative decimal integers. */ +function parseNonNegInt(s: string): number { + const n = +s; + if (!Number.isInteger(n) || n < 0 || String(n) !== s) + throw new QueryError(`limit argument must be a non-negative integer: '${s}'`); + return n; +} + // ── Group accumulator ────────────────────────────────────────────────────── type Term = Condition | Group | ElementMatch; @@ -104,14 +119,15 @@ function accToGroup(acc: Acc): Group | undefined { return { operator: acc.operator ?? 'and', terms: acc.terms }; } -// §6 invariant: an ElementMatch scoping a single plain condition normalizes to an -// ordinary Condition with the concatenated path. +// §6 invariant: an ElementMatch scoping exactly one plain non-negated named-path condition +// normalizes to an ordinary Condition on the concatenated path. Negated inner conditions +// are never flattened (∃¬ ≠ ¬∃, §5.1.1 / §5.3). Elem-conds (ic.path=[]) are never +// flattened — merging would drop the existential quantifier. function pushElementMatch(acc: Acc, em: ElementMatch): void { const t = em.some.terms; - if (t.length === 1 && !('some' in t[0]) && !('terms' in t[0]) && !em.negated) { + if (t.length === 1 && !('some' in t[0]) && !('terms' in t[0]) && !em.negated && !(t[0] as Condition).negated && (t[0] as Condition).path.length > 0) { const ic = t[0] as Condition; const merged: Condition = { path: [...em.path, ...ic.path], comparator: ic.comparator, value: ic.value }; - if (ic.negated) merged.negated = true; acc.terms.push(merged); acc.lastPath = merged.path; } else { @@ -137,10 +153,10 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult } // ── Condition-group parser ───────────────────────────────────────────── - // Always uses QP (FIQL works because QP sees both `=` tokens sequentially). - // Call functions NOT dispatched here. + // Always uses QP. When isScoped=true (prop[...] body), elem-conds (=name=val) are + // accepted with an empty implicit path, and &=/|= decompose to conjunction + elem-cond. - function parseCondGroup(closeCh: string): Acc { + function parseCondGroup(closeCh: string, isScoped = false): Acc { const acc = newAcc(); let path: string[] | undefined; let rawComp: string | undefined; @@ -203,7 +219,6 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult acc.terms.push(term); } path = undefined; rawComp = undefined; fiqlMode = false; - // chainPath / activeEM persist across chain legs. } qp.lastIndex = pos; @@ -215,11 +230,15 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult switch (op) { case '=': if (path !== undefined) { + // Second `=` of FIQL. if (!FIQL_NAME.test(val)) { recordError(`invalid FIQL name '${val}'`); break; } rawComp = val; fiqlMode = true; } else if (chainPath) { if (!FIQL_NAME.test(val)) { recordError(`invalid FIQL name '${val}'`); break; } path = chainPath; rawComp = val; fiqlMode = true; + } else if (isScoped && !val) { + // elem-cond: `=fiql-name=value` with no explicit property path. + path = []; rawComp = '='; fiqlMode = false; } else { if (!val) { recordError('path required before ='); break; } path = splitPath(val); rawComp = '='; fiqlMode = false; @@ -242,8 +261,16 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult } case '&=': case '|=': { const cop: 'and' | 'or' = op === '&=' ? 'and' : 'or'; - if (path !== undefined) finishCond(val); - if (activeEM) { + const hadPending = path !== undefined; + if (hadPending) finishCond(val); + // In a scoped-body, &=/|= after an elem-cond (lastPath=[]) is a + // conjunction + new elem-cond start, not a chain operator. + const lastIsElemCond = acc.lastPath !== undefined && acc.lastPath.length === 0; + if (isScoped && lastIsElemCond && !activeEM) { + closeEM(); + setGroupOp(acc, cop, recordError); + path = []; rawComp = '='; fiqlMode = false; + } else if (activeEM) { if (cop !== activeEM.some.operator) recordError('cannot mix & and | within a chain'); } else { const prev = acc.terms.pop(); @@ -279,23 +306,27 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult } case '[': { if (val) { + // prop[...] scoped-match. const ePath = splitPath(val); qp.lastIndex = pos; - const inner = parseCondGroup(']'); + const inner = parseCondGroup(']', true); pos = qp.lastIndex; const innerGrp = accToGroup(inner); if (!innerGrp) { recordError(`empty bracket group for '${val}'`); + } else if (innerGrp.terms.length === 1 && !('some' in innerGrp.terms[0]) && !('terms' in innerGrp.terms[0]) && !(innerGrp.terms[0] as Condition).negated && (innerGrp.terms[0] as Condition).path.length > 0) { + const ic = innerGrp.terms[0] as Condition; + const merged: Condition = { path: [...ePath, ...ic.path], comparator: ic.comparator, value: ic.value }; + closeEM(); acc.terms.push(merged); acc.lastPath = merged.path; } else { - closeEM(); - pushElementMatch(acc, { path: ePath, some: innerGrp }); + closeEM(); pushElementMatch(acc, { path: ePath, some: innerGrp }); } } else { qp.lastIndex = pos; const inner = parseCondGroup(']'); pos = qp.lastIndex; const grp = accToGroup(inner); - if (grp) { acc.terms.push(grp); acc.lastPath = undefined; } + if (grp) { closeEM(); acc.terms.push(grp); acc.lastPath = undefined; } } break; } @@ -323,8 +354,6 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult } // ── Top-level parser ─────────────────────────────────────────────────── - // QP/VP switching: VP only when committed to reading a value (FIQL or non-eq op seen). - // Chaining and between produce ElementMatch. const result: ParseResult = {}; const topAcc = newAcc(); @@ -333,9 +362,9 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult let fiqlMode = false; let chainPath: string[] | undefined; let activeEM: ElementMatch | undefined; + const seenCalls = new Set(); function useVP(): boolean { - // Use VP only after we have path + a comparator that won't be FIQL second-=. return path !== undefined && rawComp !== undefined && (fiqlMode || rawComp !== '='); } @@ -390,7 +419,6 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult } path = undefined; rawComp = undefined; fiqlMode = false; - // chainPath / activeEM persist across chain legs. } function closeActiveEM(): void { @@ -468,7 +496,9 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult } if (op === '{') { const nested = parseSelectList('}'); - p.lastIndex = pos; // re-sync after recursive call updates shared pos + p.lastIndex = pos; + // §5.7: nested '[...]' tuple form inside '{}' is reserved. + if (nested.some(f => f.tuple)) recordError("nested '[...]' tuple inside '{}' is reserved"); fields.push({ path: splitPath(val), nested }); continue; } @@ -489,12 +519,11 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult } else { // `[a,b]` → tuple. const items = parseSelectList(']'); - p.lastIndex = pos; // re-sync after recursive call updates shared pos + p.lastIndex = pos; fields.push({ path: [], nested: items, tuple: true }); } continue; } - // Unrecognised structural op or empty string: treat val as field name. if (val) fields.push({ path: splitPath(val) }); if (!op && pos >= search.length) { recordError(`expected '${closeCh}' for select`); return fields; } } @@ -507,8 +536,6 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult return { mode: 'tuples', fields: (fields[0].nested ?? []).map(rawToField) }; } const fs = fields.map(rawToField); - // The single-field `values` mode is a top-level surface form only (§5.7); - // nested projections trim the object (`records`), matching sub-select semantics. const mode: 'values' | 'records' = (!nested && fs.length === 1 && !fields[0].nested && !trailingComma) ? 'values' : 'records'; return { mode, fields: fs }; @@ -538,9 +565,7 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult const [, val, op] = match; if (p === vp) { - // Value token consumed. finishTopCond(val); - // Process any logical separator carried by VP. switch (op) { case '&': case '|': { const lop: 'and' | 'or' = op === '&' ? 'and' : 'or'; @@ -549,7 +574,6 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult break; } case '&=': case '|=': { - // Chain operator: start or extend an ElementMatch. const cop: 'and' | 'or' = op === '&=' ? 'and' : 'or'; if (activeEM) { if (cop !== activeEM.some.operator) recordError('cannot mix & and | within a chain'); @@ -577,7 +601,6 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult switch (op) { case '=': if (path !== undefined) { - // Second `=` of FIQL. if (!FIQL_NAME.test(val)) { recordError(`invalid FIQL name '${val}'`); break; } rawComp = val; fiqlMode = true; } else if (chainPath) { @@ -610,7 +633,8 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult if (cop !== activeEM.some.operator) recordError('cannot mix & and | within a chain'); } else { const prev = topAcc.terms.pop(); - if (!prev || 'some' in prev) { + if (!prev || 'some' in prev || 'terms' in prev) { + if (prev) topAcc.terms.push(prev); recordError('no preceding Condition to chain onto'); break; } const prevCond = prev as Condition; @@ -631,23 +655,45 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult break; case '(': { if (val) { - switch (val) { - case 'select': result.select = parseSelectArgs(); break; - case 'sort': result.sort = parseSortArgs(); break; - case 'limit': { - const args = parsePlainArgs('limit'); - if (args.length === 1) result.limit = +args[0]; - else if (args.length === 2) { result.offset = +args[0]; result.limit = +args[1] - result.offset; } - else recordError('limit takes 1 or 2 arguments'); - break; + if (seenCalls.has(val)) { + // Consume args and record duplicate error. + if (val === 'select') parseSelectArgs(); + else if (val === 'sort') parseSortArgs(); + else parsePlainArgs(val); + recordError(`duplicate ${val}()`); + } else { + seenCalls.add(val); + switch (val) { + case 'select': result.select = parseSelectArgs(); break; + case 'sort': result.sort = parseSortArgs(); break; + case 'limit': { + const args = parsePlainArgs('limit'); + try { + if (args.length === 1) { + result.limit = parseNonNegInt(args[0]); + } else if (args.length === 2) { + const start = parseNonNegInt(args[0]); + const end = parseNonNegInt(args[1]); + if (end < start) throw new QueryError(`limit end ${end} must be ≥ start ${start}`); + result.offset = start; + result.limit = end - start; + } else { + recordError('limit takes 1 or 2 arguments'); + } + } catch (e) { + if (e instanceof QueryError) recordError(e.message); + else throw e; + } + break; + } + case 'group-by': + parsePlainArgs('group-by'); + recordError('group-by is not implemented'); + break; + default: + parsePlainArgs(val); + recordError(`unknown call function '${val}'`); } - case 'group-by': - parsePlainArgs('group-by'); - recordError('group-by is not implemented'); - break; - default: - parsePlainArgs(val); - recordError(`unknown call function '${val}'`); } if (search[pos] === ',') pos++; path = undefined; chainPath = undefined; @@ -666,18 +712,17 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult if (val) { const ePath = splitPath(val); qp.lastIndex = pos; - const inner = parseCondGroup(']'); + const inner = parseCondGroup(']', true); pos = qp.lastIndex; const innerGrp = accToGroup(inner); if (!innerGrp) { recordError(`empty bracket group for '${val}'`); - } else if (innerGrp.terms.length === 1 && !('some' in innerGrp.terms[0])) { + } else if (innerGrp.terms.length === 1 && !('some' in innerGrp.terms[0]) && !('terms' in innerGrp.terms[0]) && !(innerGrp.terms[0] as Condition).negated && (innerGrp.terms[0] as Condition).path.length > 0) { const ic = innerGrp.terms[0] as Condition; const merged: Condition = { path: [...ePath, ...ic.path], comparator: ic.comparator, value: ic.value }; - if (ic.negated) merged.negated = true; closeActiveEM(); topAcc.terms.push(merged); topAcc.lastPath = merged.path; } else { - closeActiveEM(); topAcc.terms.push({ path: ePath, some: innerGrp }); topAcc.lastPath = ePath; + closeActiveEM(); pushElementMatch(topAcc, { path: ePath, some: innerGrp }); } if (search[pos] === ',') pos++; path = undefined; chainPath = undefined; diff --git a/test/v2/parse.test.ts b/test/v2/parse.test.ts index 7f5a460..012e0c6 100644 --- a/test/v2/parse.test.ts +++ b/test/v2/parse.test.ts @@ -229,21 +229,21 @@ describe('Chaining desugaring', () => { }); // Semantic motivation: chained vs un-chained are different for list-valued properties. - it('chained vs un-chained produce different canonical shapes (ski-lengths)', () => { - // Chained: some ONE skiLength value must be in [175,180]. - const chained = parseQuery('skiLengths=ge=175&=le=180'); - // Un-chained: some element ≥175 AND some (possibly different) element ≤180. - const unchained = parseQuery('skiLengths=ge=175&skiLengths=le=180'); + it('chained vs un-chained produce different canonical shapes (ratings)', () => { + // Chained: some ONE rating must be in [3,4]. + const chained = parseQuery('ratings=ge=3&=le=4'); + // Un-chained: some element ≥3 AND some (possibly different) element ≤4. + const unchained = parseQuery('ratings=ge=3&ratings=le=4'); const em = chained.filter!.terms[0] as ElementMatch; - assert.deepEqual(em.path, ['skiLengths']); + assert.deepEqual(em.path, ['ratings']); assert.equal(em.some.operator, 'and'); assert.deepEqual((em.some.terms[0] as Condition).path, []); assert.deepEqual((em.some.terms[1] as Condition).path, []); assert.equal(unchained.filter!.terms.length, 2); - assert.deepEqual((unchained.filter!.terms[0] as Condition).path, ['skiLengths']); - assert.deepEqual((unchained.filter!.terms[1] as Condition).path, ['skiLengths']); + assert.deepEqual((unchained.filter!.terms[0] as Condition).path, ['ratings']); + assert.deepEqual((unchained.filter!.terms[1] as Condition).path, ['ratings']); assert.notDeepEqual(chained.filter, unchained.filter); }); @@ -595,24 +595,24 @@ describe('Chain legs require a comparator name (§4 grammar)', () => { }); describe('Chaining inside groups keeps element scoping (§5.3)', () => { - it('(skiLengths=ge=175&=le=180) → ElementMatch, same as un-grouped', () => { - const grouped = parseQuery('(skiLengths=ge=175&=le=180)'); + it('(ratings=ge=3&=le=4) → ElementMatch, same as un-grouped', () => { + const grouped = parseQuery('(ratings=ge=3&=le=4)'); const inner = grouped.filter!.terms[0] as Group; const em = inner.terms[0] as ElementMatch; assert.deepEqual(em, { - path: ['skiLengths'], + path: ['ratings'], some: { operator: 'and', terms: [ - { path: [], comparator: 'ge', value: 175 }, - { path: [], comparator: 'le', value: 180 }, + { path: [], comparator: 'ge', value: 3 }, + { path: [], comparator: 'le', value: 4 }, ] }, }); }); it('chained legs inside a bracket scoped-match stay grouped', () => { - const r = parseQuery('a=1&[skiLengths=ge=175&=le=180]'); + const r = parseQuery('a=1&[ratings=ge=3&=le=4]'); const grp = r.filter!.terms[1] as Group; const em = grp.terms[0] as ElementMatch; - assert.deepEqual(em.path, ['skiLengths']); + assert.deepEqual(em.path, ['ratings']); assert.equal(em.some.terms.length, 2); }); }); @@ -629,3 +629,239 @@ describe('Nested projections are records mode (§5.7)', () => { }); }); }); + +// --------------------------------------------------------------------------- +// §5.3 Negated-inner flattening exemption +// --------------------------------------------------------------------------- + +describe('Negated-inner ElementMatch is NOT flattened (§5.3)', () => { + it('tags[=not_eq=urgent] stays an ElementMatch (∃¬ ≠ ¬∃)', () => { + const r = parseQuery('tags[=not_eq=urgent]'); + const em = r.filter!.terms[0] as ElementMatch; + assert.ok('some' in em, 'should remain an ElementMatch, not flatten to a Condition'); + assert.deepEqual(em.path, ['tags']); + assert.equal(em.some.terms.length, 1); + const ic = em.some.terms[0] as Condition; + assert.deepEqual(ic.path, []); + assert.equal(ic.comparator, 'eq'); + assert.equal(ic.negated, true); + assert.equal(ic.value, 'urgent'); + assert.equal(em.negated, undefined); + }); + + it('orders[status=open] flattens to plain Condition (single non-negated)', () => { + const r = parseQuery('orders[status=open]'); + // Single non-negated inner condition → normalized to plain Condition. + const c = r.filter!.terms[0] as Condition; + assert.ok(!('some' in c), 'should flatten to a plain Condition'); + assert.deepEqual(c.path, ['orders', 'status']); + assert.equal(c.comparator, 'eq'); + }); +}); + +// --------------------------------------------------------------------------- +// §4 Elem-cond surface inside prop[...] +// --------------------------------------------------------------------------- + +describe('Element-scoped match (prop[...])', () => { + it('scores[=ge=10] → ElementMatch with elem-cond path=[]', () => { + const r = parseQuery('scores[=ge=10]'); + const em = r.filter!.terms[0] as ElementMatch; + assert.ok('some' in em); + assert.deepEqual(em.path, ['scores']); + const ic = em.some.terms[0] as Condition; + assert.deepEqual(ic.path, []); + assert.equal(ic.comparator, 'ge'); + assert.equal(ic.value, 10); + }); + + it('scores[=ge=10|=le=2] → ElementMatch with two elem-conds (or)', () => { + const r = parseQuery('scores[=ge=10|=le=2]'); + const em = r.filter!.terms[0] as ElementMatch; + assert.deepEqual(em.path, ['scores']); + assert.equal(em.some.operator, 'or'); + assert.equal(em.some.terms.length, 2); + assert.deepEqual((em.some.terms[0] as Condition).path, []); + assert.equal((em.some.terms[0] as Condition).comparator, 'ge'); + assert.deepEqual((em.some.terms[1] as Condition).path, []); + assert.equal((em.some.terms[1] as Condition).comparator, 'le'); + }); + + it('reviews[rating=ge=4&helpful=ge=10] → ElementMatch with two named conditions', () => { + const r = parseQuery('reviews[rating=ge=4&helpful=ge=10]'); + const em = r.filter!.terms[0] as ElementMatch; + assert.deepEqual(em.path, ['reviews']); + assert.equal(em.some.operator, 'and'); + assert.equal(em.some.terms.length, 2); + const ic0 = em.some.terms[0] as Condition; + const ic1 = em.some.terms[1] as Condition; + assert.deepEqual(ic0.path, ['rating']); + assert.equal(ic0.comparator, 'ge'); + assert.equal(ic0.value, 4); + assert.deepEqual(ic1.path, ['helpful']); + assert.equal(ic1.comparator, 'ge'); + assert.equal(ic1.value, 10); + }); + + it('tags[=not_eq=urgent] (negated elem-cond) is not flattened', () => { + const r = parseQuery('tags[=not_eq=urgent]'); + assert.ok('some' in r.filter!.terms[0], 'must remain ElementMatch'); + const em = r.filter!.terms[0] as ElementMatch; + const ic = em.some.terms[0] as Condition; + assert.equal(ic.negated, true); + }); +}); + +// --------------------------------------------------------------------------- +// §5.2.2 Malformed typed literals are syntax errors +// --------------------------------------------------------------------------- + +describe('Parse errors — malformed typed literals (§5.2.2)', () => { + it('boolean:yes throws', () => { + assert.throws(() => parseQuery('x==boolean:yes'), /malformed boolean literal/); + }); + + it('boolean: (empty) throws', () => { + assert.throws(() => parseQuery('x==boolean:'), /malformed boolean literal/); + }); + + it('number:abc throws', () => { + assert.throws(() => parseQuery('x==number:abc'), /malformed number literal/); + }); + + it('number: (empty) throws', () => { + assert.throws(() => parseQuery('x==number:'), /malformed number literal/); + }); + + it('date:not-a-date throws', () => { + assert.throws(() => parseQuery('x==date:not-a-date'), /malformed date literal/); + }); + + it('valid boolean:true does not throw', () => { + assert.doesNotThrow(() => parseQuery('x==boolean:true')); + }); + + it('valid number:42 does not throw', () => { + assert.doesNotThrow(() => parseQuery('x==number:42')); + }); +}); + +// --------------------------------------------------------------------------- +// §5.6 Limit validation +// --------------------------------------------------------------------------- + +describe('Parse errors — limit validation (§5.6)', () => { + it('limit(10,5) throws — end < start', () => { + assert.throws(() => parseQuery('limit(10,5)'), /limit/); + }); + + it('limit(-1) throws — negative', () => { + assert.throws(() => parseQuery('limit(-1)'), /non-negative integer/); + }); + + it('limit(1.5) throws — non-integer', () => { + assert.throws(() => parseQuery('limit(1.5)'), /non-negative integer/); + }); + + it('limit(foo) throws — non-numeric', () => { + assert.throws(() => parseQuery('limit(foo)'), /non-negative integer/); + }); + + it('limit(0) is valid', () => { + const r = parseQuery('limit(0)'); + assert.equal(r.limit, 0); + }); + + it('limit(0,10) is valid — offset=0, limit=10', () => { + const r = parseQuery('limit(0,10)'); + assert.equal(r.offset, 0); + assert.equal(r.limit, 10); + }); +}); + +// --------------------------------------------------------------------------- +// §5.6 Duplicate call functions +// --------------------------------------------------------------------------- + +describe('Parse errors — duplicate call functions (§5.6)', () => { + it('two select() calls throw', () => { + assert.throws(() => parseQuery('select(id)&select(name)'), /duplicate select/); + }); + + it('two sort() calls throw', () => { + assert.throws(() => parseQuery('sort(name)&sort(age)'), /duplicate sort/); + }); + + it('two limit() calls throw', () => { + assert.throws(() => parseQuery('limit(10)&limit(5)'), /duplicate limit/); + }); +}); + +// --------------------------------------------------------------------------- +// §4 Chained value lists — &=in= and &=between= pin tests +// --------------------------------------------------------------------------- + +describe('Chained value lists (§4)', () => { + it('a=ge=1&=in=(2,3) parses without error', () => { + const r = parseQuery('a=ge=1&=in=(2,3)'); + // Produces an ElementMatch on 'a' with ge+in legs. + const em = r.filter!.terms[0] as ElementMatch; + assert.deepEqual(em.path, ['a']); + assert.equal(em.some.operator, 'and'); + assert.equal(em.some.terms.length, 2); + const leg0 = em.some.terms[0] as Condition; + const leg1 = em.some.terms[1] as Condition; + assert.equal(leg0.comparator, 'ge'); + assert.equal(leg0.value, 1); + assert.equal(leg1.comparator, 'in'); + assert.deepEqual(leg1.value, [2, 3]); + }); + + it('a=ge=1&=between=(2,3) parses — between legs fold into the ElementMatch', () => { + // The between desugars into ge+le inner conditions inside the same ElementMatch. + const r = parseQuery('a=ge=1&=between=(2,3)'); + assert.ok(r.filter); + // At minimum we get an ElementMatch on 'a'. + const em = r.filter!.terms[0] as ElementMatch; + assert.deepEqual(em.path, ['a']); + }); +}); + +// --------------------------------------------------------------------------- +// §5.7 Nested tuple projection reserved +// --------------------------------------------------------------------------- + +describe('Nested tuple projection reserved (§5.7)', () => { + it('select(rel{[x,y]}) throws', () => { + assert.throws(() => parseQuery('select(rel{[x,y]})'), /nested.*tuple.*reserved/); + }); +}); + +// --------------------------------------------------------------------------- +// §4.2 rule 4 Raw-token marker pins +// --------------------------------------------------------------------------- + +describe('Raw-token marker pins (§4.2 rule 4)', () => { + it('x==string%3Anull → plain string "string:null" (encoded colon is not a type prefix)', () => { + const r = parseQuery('x==string%3Anull'); + const c = r.filter!.terms[0] as Condition; + assert.equal(c.value, 'string:null'); + }); + + it('name==Jo%2A → eq "Jo*" (encoded asterisk is not a wildcard)', () => { + const r = parseQuery('name==Jo%2A'); + const c = r.filter!.terms[0] as Condition; + assert.equal(c.comparator, 'eq'); + assert.equal(c.value, 'Jo*'); + }); + + it('sort(%2Bname) → path ["+name"] ascending (encoded + is not a direction marker)', () => { + const r = parseQuery('sort(%2Bname)'); + assert.deepEqual(r.sort, [{ path: ['+name'], direction: 'asc' }]); + }); + + it('sort(-age) → path ["age"] descending (raw - IS a direction marker)', () => { + const r = parseQuery('sort(-age)'); + assert.deepEqual(r.sort, [{ path: ['age'], direction: 'desc' }]); + }); +}); From 8575269a75d911dcb5e631e5675e14da7f4ce0bc Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Tue, 1 Sep 2026 05:59:19 -0600 Subject: [PATCH 12/14] Spec: define not(...) as Core term-form negation; externalize divergence ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit not(body) is pure sugar desugared by De Morgan onto leaf negated flags and ElementMatch.negated — canonical model unchanged, gives negated scopes a surface (not(scores[=ge=10&=le=20])). Removed from Appendix C reserved names; §5.6 carves it out of the call-function namespace; harper support filed as HarperFast/harper#2441. Appendix D is now a pointer: implementation divergence ledgers live with the implementations (Harper: HarperFast/harper#2440), keeping the spec vendor-independent. Also: singleton flattening clarified to include element conditions (scores[=ge=10] ≡ scores=ge=10). Co-Authored-By: Claude Fable 5 --- specification/rql-2.0.md | 93 ++++++++++++++++++++++++---------------- 1 file changed, 56 insertions(+), 37 deletions(-) diff --git a/specification/rql-2.0.md b/specification/rql-2.0.md index 432a068..aaffe63 100644 --- a/specification/rql-2.0.md +++ b/specification/rql-2.0.md @@ -17,8 +17,8 @@ years of production use of the RQL/FIQL lineage — most directly in RQL 2.0 specifies the *ideal* language: the cleanest coherent semantics for the syntax in real-world use. It is deliberately **not** a reverse-engineering of any single implementation. Existing implementations (including Harper's) are expected to converge -toward it; their known divergences are cataloged (Appendix D) rather than normalized into -the language. The specification is language-neutral: the canonical parsed representation +toward it, tracking their own divergences in public ledgers (linked from Appendix D) +rather than having them normalized into the language. The specification is language-neutral: the canonical parsed representation (§6) is an abstract data model, intended to support reference implementations in multiple programming languages. @@ -79,8 +79,12 @@ q-term = term / call ; call functions MAY appear only at the top level, and the ; conjunction adjacent to a call MUST be "&" conjunction = "&" / "|" -term = condition / chained-cond / group / scoped-match +term = condition / chained-cond / group / scoped-match / not-expr group = "(" group-body ")" / "[" group-body "]" +not-expr = "not" "(" group-body ")" + ; logical negation of the body (§5.4); a term form, NOT a call + ; function — "not" followed by "(" is recognized as negation in + ; every term position, including the top level group-body = term *( conjunction term ) ; all conjunctions within one group-body MUST be identical (§5.4) scoped-match = prop-path "[" scoped-body "]" @@ -218,9 +222,8 @@ the scope where the condition is evaluated*: As a consequence of complement semantics, `not_lt` matches every resource `lt` does not match — which is *not* equivalent to `ge` for resources where the property is absent or -incomparable. Negating an entire group or scope has no Core surface form; the name `not` -is reserved for a future Extension (Appendix C). By De Morgan, leaf-level `not_` already -expresses the negation of any and/or combination of conditions. +incomparable. Entire groups and scopes are negated with `not(...)` (§5.4), which +desugars to these leaf and scope negations. There is exactly one equality (`eq`) and one negation mechanism (`not_`). Notions like "strict vs. converting equality" are properties of the *value literal* (§5.2), not of @@ -330,11 +333,13 @@ value cardinality, so the scoping structure is preserved. (Or-chaining is logica distributable over the existential quantifier, but it is represented scoped as well, for symmetry.) -A scoped match containing exactly one **non-negated** inner condition is equivalent to -a plain condition on the concatenated path and normalizes to it: `orders[status=open]` -≡ `orders.status=open`. A **negated** inner condition is *not* flattened — under -§5.1.1's scope rule, `tags[=not_eq=urgent]` (∃¬) and `tags=not_eq=urgent` (¬∃) mean -different things. +A non-negated scoped match containing exactly one **non-negated** inner condition is +equivalent to a plain condition on the concatenated path and normalizes to it: +`orders[status=open]` ≡ `orders.status=open`, and likewise for an element condition — +`scores[=ge=10]` ≡ `scores=ge=10` (a plain condition on a list path is already +existential). A **negated** inner condition (or a negated scope) is *not* flattened — +under §5.1.1's scope rule, `tags[=not_eq=urgent]` (∃¬) and `tags=not_eq=urgent` (¬∃) +mean different things. Executors are encouraged to execute same-element `ge`/`gt` + `le`/`lt` pairs as a single index range scan — for element-indexed lists that scan implements same-element @@ -348,6 +353,26 @@ semantics naturally. - `(...)` and `[...]` are semantically identical groupings (see §4.1 for why brackets are RECOMMENDED in generated queries). +**Negation of a group or scope.** `not(body)` complements the match of its body and may +appear wherever a term may: + +``` +status=open¬(tag=urgent|tag=blocked) +not(scores[=ge=10&=le=20]) ; NO element is in [10, 20] +``` + +`not(...)` is pure sugar: parsers MUST desugar it by pushing negation inward, which is +exactly meaning-preserving because negation is set complement (§5.1.1): + +- `not(` condition `)` toggles the condition's `negated` flag (≡ the `not_` prefix); +- `not(` and-group `)` becomes the or-group of the negated terms, and vice versa + (De Morgan, applied recursively); +- `not(` scoped-match `)` toggles the ElementMatch's `negated` flag; +- nested `not` cancels. + +Consequently `not` never appears in the canonical representation (§6) or in canonical +serialization (§7) except as the spelling of a negated ElementMatch. + ### 5.5 Property paths Dot syntax addresses nested properties: `brand.name=Microsoft`. Where the data model @@ -368,6 +393,8 @@ Literal dots in property names are expressed with `%2E` (§4.2). Exactly these call functions are Core. An unrecognized call name — including the reserved Extension names of Appendix C — is a parse error (unlike comparator names, which are open). A call function appearing more than once in a query is a syntax error. +`not(...)` (§5.4) is not a call function: it is a term-position logical form, and its +name is excluded from the call-function namespace. > **Break from 1.x:** in RQL 1.x, call syntax was the *normalized form* of every > operator — `lt(price,10)` was equivalent to `price=lt=10`, and infix forms were sugar. @@ -439,9 +466,10 @@ Invariants: - **All sugar is gone.** Aliases are resolved to canonical comparator names; `!=` desugars to `negated eq`; wildcards to `starts_with`; chaining and `between` to an - ElementMatch. A scoped match with a single **non-negated** inner condition normalizes - to the plain Condition on the concatenated path (`prop[x=1]` ≡ `prop.x=1`); a negated - inner condition is never flattened (§5.3). + ElementMatch; `not(...)` desugars into leaf/scope negation flags (§5.4). A non-negated + scoped match with a single non-negated inner condition normalizes to the plain + Condition on the concatenated path (`prop[x=1]` ≡ `prop.x=1`, `prop[=ge=10]` ≡ + `prop=ge=10`); a negated inner condition or scope is never flattened (§5.3). - **Desugaring is deterministic:** equivalent sugar forms (aliases, `between` vs. chaining, `!=` vs. `ne`) parse to identical representations. Full semantic canonicalization — group flattening, term reordering — is the province of §7 @@ -476,8 +504,8 @@ Every Query has a canonical string form, defined so that `parse(serialize(q)) = differ from the value's type; - `[...]` for all grouping; `%2E` for literal dots in segments; - element-scoped matches in chained form (`prop=ge=1&=le=5`) when every inner path is - empty and the scope is not negated, and in scoped-sub-query form (`prop[…]`) - otherwise; + empty and the scope is not negated, in scoped-sub-query form (`prop[…]`) otherwise, + and negated scopes as `not(prop[…])`; - call functions last, in the order `select`, `sort`, `limit`. TODO: full normalization rules (value-token escaping table, timestamp formatting, @@ -517,7 +545,7 @@ regex-free matching guarantees. | String matching | `re:`/`RE:`/`glob:` converters, `match` | `contains`/`starts_with`/`ends_with`, `==stem*` | | Converters | open, extensible registry (`epoch:`, `isodate:`, `re:`, `glob:`, custom) | closed typed-prefix set (`number:`, `boolean:`, `date:`, `string:`); unknown or malformed prefix is a syntax error | | Positional params | `$1`, `$2` | removed | -| Negation | none | uniform `not_` comparator prefix, scoping over the condition's own traversal (§5.1.1) | +| Negation | none | uniform `not_` comparator prefix scoping over the condition's own traversal (§5.1.1), plus `not(...)` group/scope negation (§5.4) | | Range expression | `between` operator | `&=` / `|=` chaining (canonical); `between` demoted to alias | | Collection matching | query-valued `contains(items,gt(price,10))`, `excludes(items,red)`; nested-array/condition arguments in value lists | scoped matches: `items[price=gt=10]`; membership is plain traversal (`items=red`); exclusion is `not_` (`items=not_eq=red`); value lists hold only literals | | Sub-selects | none | `rel{x,y}`, `rel[select(x)]`, `select([a,b])` | @@ -545,26 +573,17 @@ or in canonical serialization: Reserved call-function names, non-normative pending a future revision — carried from RQL 1.x: `aggregate`, `distinct`, `values`, `sum`, `mean`, `max`, `min`, `count`, -`first`, `one`, `recurse`, `rel`, `group-by`; new in 2.0: `not` (general negation of a -group or scope, complementing leaf-level `not_`; §5.1.1). Core parsers reject these as -unknown call functions (§5.6). +`first`, `one`, `recurse`, `rel`, `group-by`. Core parsers reject these as unknown call +functions (§5.6). (`not` is not on this list — it is a Core term form, §5.4.) -## Appendix D — Known divergences of the Harper implementation +## Appendix D — Implementation divergence tracking -Tracked so the spec stays ideal while implementations converge. As of harper `main` -(2026-09): +This specification is implementation-independent; it does not track any vendor's bugs +or gaps. An implementation converges by maintaining its own public divergence ledger — +each entry naming the deviating behavior, its class (bug, feature gap, or permitted +representational difference per §8's adapter rule), and the spec clause it converges +to. -| # | Divergence | Spec position | -|---|---|---| -| 1 | Simple queries (no structural characters) skip parsing and surface as raw name/value pairs; consumers handle two condition shapes | §6: one canonical shape; lazy representations are a host affordance outside the model | -| 2 | `&=`/`|=` chains attach to the prior condition as `chainedConditions`; a nameless chain leg (`a=ge=1&=5`) is accepted and inherits the previous leg's comparator | semantically correct (same-element scoping, §5.3 — verified on both indexed and unindexed paths, harper PR #2437); representational divergence only — canonical form is ElementMatch. Nameless legs are a syntax error in 2.0 (the comparator name is required) | -| 3 | Strict vs. converting comparison is modeled as distinct comparators (`equals`/`not_equal` vs `eq`/`ne`) | §5.2: one `eq`; verbatim vs. interpreted is a property of the value literal | -| 4 | `between` is a first-class comparator | Appendix B alias, desugars to an element-scoped `ge`+`le` | -| 5 | Sort is a linked list; select is a polymorphic array with marker properties (`asArray`, `name`) | §6: sort is an ordered list of SortKeys; projection is mode + fields | -| 6 | `(4)` on a non-list comparator is the literal string `"(4)"` | tolerance only; producers MUST NOT rely on it | -| 7 | `prop[]=v` repeated-array params accepted in the parser | Appendix B host accommodation, not grammar | -| 8 | Unknown call-name error and other semantic errors are deferred into the request pipeline (`parseError`) | §6.1: deferred mode is OPTIONAL; canonical behavior rejects at parse | -| 9 | `group-by(...)` is accepted at parse (deferred not-implemented error) and falls through into `sort` handling (missing `break`) | 2.0 rejects reserved/unknown call names at parse (§5.6); the fall-through is a bug — fix in flight (dispatch `harper-groupby-fallthrough`) | -| 10 | Chained legs' values are never type-coerced on the REST path — `age=ge=175&=le=180` builds the mixed-type range `[175, "180"]`, silently returning a superset or empty set | bug — [harper#2433](https://github.com/HarperFast/harper/issues/2433); §5.2's interpreted mode applies uniformly to chained legs | -| 11 | Secondary indexes over `elements` (multi-value) attributes return one result per matching element — duplicate records; the unindexed path returns each record once | bug — [harper#2434](https://github.com/HarperFast/harper/issues/2434); §5.5: matching determines membership, not multiplicity | -| 12 | `contains` matches numeric values via decimal-string coercion (`lengths=ct=17` matches 172) | §5.1.1: string comparators match only string values; non-strings do not match | +Known ledgers: + +- **Harper** — [HarperFast/harper#2440](https://github.com/HarperFast/harper/issues/2440) From 80ef83a05df8bf79fd04d8eea68906218fcf3b58 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Tue, 1 Sep 2026 06:03:25 -0600 Subject: [PATCH 13/14] Add not(...) desugaring and relax singleton-EM flattening guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit not(...) (§5.4): term-form De Morgan negation, works at top level, inside groups, and inside scoped bodies. negateGroup/negateTerm recurse inward: Condition.negated toggles, and/or groups swap (De Morgan), ElementMatch.negated toggles. Nested not() cancels. Empty not() is a QueryError. not is excluded from the call-function namespace so unknown-call-function never fires on it. Singleton ElementMatch flattening (§5.3/§5.5/§6): drop the path.length>0 guard from pushElementMatch and both bracket-singleton handlers. Plain Conditions on list paths are already existential (§5.5), so elem-cond path=[] flattens safely: scores[=ge=10] ≡ scores=ge=10. Negated inner and negated scope remain unflattened. Update tests accordingly. 111/111 pass. Co-Authored-By: Claude Sonnet 4.6 --- src/parser.ts | 64 ++++++++++++++++++++++++++---- test/v2/parse.test.ts | 90 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 139 insertions(+), 15 deletions(-) diff --git a/src/parser.ts b/src/parser.ts index 7e34477..b40677b 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -119,13 +119,35 @@ function accToGroup(acc: Acc): Group | undefined { return { operator: acc.operator ?? 'and', terms: acc.terms }; } -// §6 invariant: an ElementMatch scoping exactly one plain non-negated named-path condition -// normalizes to an ordinary Condition on the concatenated path. Negated inner conditions -// are never flattened (∃¬ ≠ ¬∃, §5.1.1 / §5.3). Elem-conds (ic.path=[]) are never -// flattened — merging would drop the existential quantifier. +// §5.4 De Morgan desugaring for not(...). Recursively toggles negated flags inward. +function negateTerm(term: Term): Term { + if ('terms' in term) return negateGroup(term as Group); + if ('some' in term) { + const em = term as ElementMatch; + const r: ElementMatch = { path: em.path, some: em.some }; + if (!em.negated) r.negated = true; + return r; + } + const c = term as Condition; + const r: Condition = { path: c.path, comparator: c.comparator, value: c.value }; + if (!c.negated) r.negated = true; + return r; +} + +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'; + return { operator: op, terms: grp.terms.map(negateTerm) }; +} + +// §6 invariant: an ElementMatch scoping exactly one plain non-negated Condition normalizes +// to an ordinary Condition on the concatenated path (§5.3). Plain Conditions on list paths +// are already existential (§5.5), so elem-cond path=[] also flattens safely: +// [...em.path, ...[]] = em.path. Negated inner conditions are never flattened (∃¬ ≠ ¬∃). function pushElementMatch(acc: Acc, em: ElementMatch): void { const t = em.some.terms; - if (t.length === 1 && !('some' in t[0]) && !('terms' in t[0]) && !em.negated && !(t[0] as Condition).negated && (t[0] as Condition).path.length > 0) { + if (t.length === 1 && !('some' in t[0]) && !('terms' in t[0]) && !em.negated && !(t[0] as Condition).negated) { const ic = t[0] as Condition; const merged: Condition = { path: [...em.path, ...ic.path], comparator: ic.comparator, value: ic.value }; acc.terms.push(merged); @@ -296,6 +318,18 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult recordError("unexpected ','"); break; case '(': { + if (val === 'not') { + // §5.4 not(...) term-form — not a call function. + qp.lastIndex = pos; + const inner = parseCondGroup(')'); + pos = qp.lastIndex; + const grp = accToGroup(inner); + if (!grp) { recordError('not() requires a non-empty body'); break; } + closeEM(); + acc.terms.push(negateGroup(grp)); + acc.lastPath = undefined; + break; + } if (val) { recordError(`unexpected call '${val}(' inside condition group`); break; } qp.lastIndex = pos; const inner = parseCondGroup(')'); @@ -314,7 +348,7 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult const innerGrp = accToGroup(inner); if (!innerGrp) { recordError(`empty bracket group for '${val}'`); - } else if (innerGrp.terms.length === 1 && !('some' in innerGrp.terms[0]) && !('terms' in innerGrp.terms[0]) && !(innerGrp.terms[0] as Condition).negated && (innerGrp.terms[0] as Condition).path.length > 0) { + } else if (innerGrp.terms.length === 1 && !('some' in innerGrp.terms[0]) && !('terms' in innerGrp.terms[0]) && !(innerGrp.terms[0] as Condition).negated) { const ic = innerGrp.terms[0] as Condition; const merged: Condition = { path: [...ePath, ...ic.path], comparator: ic.comparator, value: ic.value }; closeEM(); acc.terms.push(merged); acc.lastPath = merged.path; @@ -654,6 +688,22 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult recordError("unexpected ','"); break; case '(': { + if (val === 'not') { + // §5.4 not(...) term-form — not a call function (§5.6). + qp.lastIndex = pos; + const inner = parseCondGroup(')'); + pos = qp.lastIndex; + const grp = accToGroup(inner); + if (!grp) { recordError('not() requires a non-empty body'); } + else { + closeActiveEM(); + topAcc.terms.push(negateGroup(grp)); + topAcc.lastPath = undefined; + } + if (search[pos] === ',') pos++; + path = undefined; chainPath = undefined; + break; + } if (val) { if (seenCalls.has(val)) { // Consume args and record duplicate error. @@ -717,7 +767,7 @@ export function parseQuery(search: string, options?: ParseOptions): ParseResult const innerGrp = accToGroup(inner); if (!innerGrp) { recordError(`empty bracket group for '${val}'`); - } else if (innerGrp.terms.length === 1 && !('some' in innerGrp.terms[0]) && !('terms' in innerGrp.terms[0]) && !(innerGrp.terms[0] as Condition).negated && (innerGrp.terms[0] as Condition).path.length > 0) { + } else if (innerGrp.terms.length === 1 && !('some' in innerGrp.terms[0]) && !('terms' in innerGrp.terms[0]) && !(innerGrp.terms[0] as Condition).negated) { const ic = innerGrp.terms[0] as Condition; const merged: Condition = { path: [...ePath, ...ic.path], comparator: ic.comparator, value: ic.value }; closeActiveEM(); topAcc.terms.push(merged); topAcc.lastPath = merged.path; diff --git a/test/v2/parse.test.ts b/test/v2/parse.test.ts index 012e0c6..8c4fd49 100644 --- a/test/v2/parse.test.ts +++ b/test/v2/parse.test.ts @@ -664,15 +664,15 @@ describe('Negated-inner ElementMatch is NOT flattened (§5.3)', () => { // --------------------------------------------------------------------------- describe('Element-scoped match (prop[...])', () => { - it('scores[=ge=10] → ElementMatch with elem-cond path=[]', () => { + it('scores[=ge=10] → plain Condition (single non-negated elem-cond flattens per §5.5)', () => { + // Plain Conditions on list paths are already existential (§5.5). + // scores[=ge=10] ≡ scores=ge=10 — both read as ∃x≥10. const r = parseQuery('scores[=ge=10]'); - const em = r.filter!.terms[0] as ElementMatch; - assert.ok('some' in em); - assert.deepEqual(em.path, ['scores']); - const ic = em.some.terms[0] as Condition; - assert.deepEqual(ic.path, []); - assert.equal(ic.comparator, 'ge'); - assert.equal(ic.value, 10); + const c = r.filter!.terms[0] as Condition; + assert.ok(!('some' in c), 'should flatten to a plain Condition'); + assert.deepEqual(c.path, ['scores']); + assert.equal(c.comparator, 'ge'); + assert.equal(c.value, 10); }); it('scores[=ge=10|=le=2] → ElementMatch with two elem-conds (or)', () => { @@ -865,3 +865,77 @@ describe('Raw-token marker pins (§4.2 rule 4)', () => { assert.deepEqual(r.sort, [{ path: ['age'], direction: 'desc' }]); }); }); + +// --------------------------------------------------------------------------- +// §5.4 not(...) De Morgan desugaring +// --------------------------------------------------------------------------- + +describe('not(...) De Morgan desugaring (§5.4)', () => { + it('not(a=1) ≡ a=not_equal=1 — single condition toggles negated (both verbatim)', () => { + // `=` is verbatim, so value is string '1'; not_equal is also verbatim. + const direct = parseQuery('a=not_equal=1'); + const negated = parseQuery('not(a=1)'); + assert.deepEqual(negated.filter, direct.filter); + }); + + it('status=open¬(tag=urgent|tag=blocked) → and[eq(status,open), and[neg(tag,urgent), neg(tag,blocked)]]', () => { + const r = parseQuery('status=open¬(tag=urgent|tag=blocked)'); + assert.equal(r.filter!.operator, 'and'); + assert.equal(r.filter!.terms.length, 2); + const first = r.filter!.terms[0] as Condition; + assert.deepEqual(first.path, ['status']); + assert.equal(first.comparator, 'eq'); + const second = r.filter!.terms[1] as Group; + assert.equal(second.operator, 'and'); + assert.equal(second.terms.length, 2); + assert.equal((second.terms[0] as Condition).negated, true); + assert.equal((second.terms[1] as Condition).negated, true); + assert.deepEqual((second.terms[0] as Condition).path, ['tag']); + assert.deepEqual((second.terms[1] as Condition).path, ['tag']); + }); + + it('not(scores[=ge=10&=le=20]) → negated ElementMatch (NO element in [10,20])', () => { + const r = parseQuery('not(scores[=ge=10&=le=20])'); + const em = r.filter!.terms[0] as ElementMatch; + assert.ok('some' in em); + assert.deepEqual(em.path, ['scores']); + assert.equal(em.negated, true); + assert.equal(em.some.operator, 'and'); + assert.equal(em.some.terms.length, 2); + }); + + it('not(not(a=1)) → plain eq — double negation cancels', () => { + const r = parseQuery('not(not(a=1))'); + const c = r.filter!.terms[0] as Condition; + assert.ok(!c.negated); + assert.deepEqual(c.path, ['a']); + assert.equal(c.comparator, 'eq'); + }); + + it('not() empty → QueryError', () => { + assert.throws(() => parseQuery('not()'), /not\(\) requires/); + }); + + it('not(a=1&b=2|c=3) → mixing error still applies inside', () => { + assert.throws(() => parseQuery('not(a=1&b=2|c=3)'), /mix/); + }); + + it('not(...) inside a group works', () => { + const r = parseQuery('(x=1¬(y=2))'); + const grp = r.filter!.terms[0] as Group; + assert.equal(grp.operator, 'and'); + assert.equal(grp.terms.length, 2); + const neg = grp.terms[1] as Condition; + assert.deepEqual(neg.path, ['y']); + assert.equal(neg.negated, true); + }); + + it('not(a=1&b=2) → De Morgan: or[negated(a,eq,1), negated(b,eq,2)]', () => { + const r = parseQuery('not(a=1&b=2)'); + const grp = r.filter!.terms[0] as Group; + assert.equal(grp.operator, 'or'); + assert.equal(grp.terms.length, 2); + assert.equal((grp.terms[0] as Condition).negated, true); + assert.equal((grp.terms[1] as Condition).negated, true); + }); +}); From ab665de98490b53dbcc017857a8e1320d67c704b Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Tue, 1 Sep 2026 14:41:49 -0600 Subject: [PATCH 14/14] =?UTF-8?q?Spec:=20add=20Appendix=20E=20=E2=80=94=20?= =?UTF-8?q?hosting=20the=20PostgREST=20dialect=20(non-normative)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maps PostgREST's URL filter syntax onto the canonical model: every filter operator lands in Core or as an extension comparator, and both dialects' logical layers are the same Group tree in prefix vs infix notation. Records the real gaps (projection aliasing/casting, nulls ordering, aggregates, resource embedding) and three semantic deltas to preserve deliberately (embedded-filter join defaults, SQL null tri-state -> map RQL negation to IS DISTINCT FROM, value-list any/all vs element scoping). Serves as evidence the canonical representation is dialect-neutral and as guidance for accepting a second surface syntax. Co-Authored-By: Claude Opus 5 --- specification/rql-2.0.md | 98 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/specification/rql-2.0.md b/specification/rql-2.0.md index aaffe63..4f96d25 100644 --- a/specification/rql-2.0.md +++ b/specification/rql-2.0.md @@ -587,3 +587,101 @@ to. Known ledgers: - **Harper** — [HarperFast/harper#2440](https://github.com/HarperFast/harper/issues/2440) + +## Appendix E — Hosting other dialects: PostgREST (non-normative) + +[PostgREST](https://docs.postgrest.org/) exposes a URL filter syntax over PostgreSQL +that solves the same problem as RQL and arrives at a different surface. This appendix +maps it onto the canonical model (§6). Nothing here is normative: it is included as +evidence that the canonical representation is dialect-neutral, and as guidance for +implementations that want to accept a second surface syntax — a conforming parser MAY +offer additional surfaces so long as each desugars into the same model. + +### E.1 Why the surfaces do not converge + +PostgREST's syntax is a transcription of PostgreSQL's operator set into URL space; RQL's +is a store-neutral language. The differences are foundational, not cosmetic: + +| | PostgREST | RQL 2.0 | +|---|---|---| +| Operator position | inside the value, dot-separated — `?age=gte.18` | operator position — `age=ge=18` | +| Bare `?a=b` | invalid; an operator is required | valid, `eq` with a verbatim value (§3.2) | +| Operator names | transcribe PostgreSQL (`gte`, `neq`, `cs`, `ov`, `wfts`) | store-neutral (`ge`, `not_eq`, `contains`, `in`) | +| Logical composition | prefix trees — `?or=(a.eq.1,and(b.eq.2,c.eq.3))` | infix — `a=1|[b=2&c=3]` | +| Type handling | schema/SQL-typed, tri-state `NULL` | schema-free literals (§5.2), set semantics | + +The dot is the decisive collision: PostgREST spends it on the operator separator, RQL on +property paths (`brand.name=x`). Neither can adopt the other's spelling without losing +its own. + +### E.2 Operator mapping + +| PostgREST | Canonical RQL form | +|---|---| +| `eq`, `gt`, `gte`, `lt`, `lte` | `eq`, `gt`, `ge`, `lt`, `le` | +| `neq` | `eq` with `negated` | +| `in.(a,b)` | `in` with a value list | +| `not.` | the `not_` prefix — i.e. the same `negated` flag (§5.1.1) | +| `not.and=(…)`, `not.or=(…)` | `not(...)` (§5.4) — both designs negate operators *and* trees | +| `or=(…)`, `and=(…)` | `Group` with `operator: "or"` / `"and"` | +| `(any).{a,b}` | an `or` group of one condition per value (`eq(any)` collapses to `in`) | +| `(all).{a,b}` | an `and` group of one condition per value | +| `cs.{a,b}` (array contains all) | `and` group of existential `eq` conditions on the array path (§5.5) | +| `ov.{a,b}` (array overlap) | `in` | +| `cd.{a,b,c}` (array contained in) | `not(path[=not_in=(a,b,c)])` — ∀ as ¬∃¬ (§5.4) | +| `is.null` | `eq` with a `null` value (see E.4 on tri-state differences) | +| `like`, `ilike`, `match`, `imatch`, `fts`/`plfts`/`phfts`/`wfts` | not Core — Core is regex-free by design. Available as **extension comparators**: §5.1.2's open vocabulary lets a host accept these names and remain conformant. PostgREST's `*`-for-`%` alias parallels RQL's `==stem*` wildcard (§5.1.2) | +| `sl`, `sr`, `nxl`, `nxr`, `adj` (range operators) | not Core; extension comparators over a range-typed value | +| `isdistinct` | extension comparator (SQL `IS DISTINCT FROM`); see E.4 | +| `select=col`, `order=col.desc`, `limit`/`offset` | `select(col)`, `sort(-col)`, `limit(start,end)` | +| `json_col->>field` | a dotted path segment (`json_col.field`) | + +Every filter operator above either maps into the Core model or is expressible as an +extension comparator, and both dialects' logical layers are the same `Group` tree in +different notation. + +### E.3 Features RQL 2.0 lacks + +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`. +- **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 + Extensions profile (Appendix C). +- **Resource embedding** — PostgREST's `select=…,other_table(…)` with `!inner`/`!left` + hints is richer than RQL's nested projection (§5.7), which fixes join semantics by + position (filtering a path is inner, projecting it is left; §5.5). +- **Full-text search and range operators** — deliberately out of Core; extension + comparators only. + +### E.4 Semantic deltas to preserve deliberately + +- **Embedded-filter defaults.** In PostgREST, filtering an embedded resource narrows the + embedded rows and keeps the parent row (unless `!inner` is given). In RQL, a filter on + a relationship path is inner-join semantics on the parent. A dialect front-end MUST + therefore translate a PostgREST embedded filter into whichever RQL form matches the + caller's intent; the two defaults are not interchangeable. +- **Null tri-state.** PostgREST inherits SQL's three-valued logic (`is.null`, + `is.unknown`, `isdistinct`). RQL comparators are set predicates over a schema-free + model: `path=not_eq=v` matches every record the un-negated condition does not + (§5.1.1), including records where the property is absent — which is *not* SQL's + `<> v`. Hosts backed by SQL should map RQL negation to `IS DISTINCT FROM`, not to + `<>`, to preserve RQL's semantics. +- **Quantifier scope.** PostgREST's `any`/`all` modifiers quantify over the *value + list*; RQL's element scoping (§5.3) quantifies over the *property's elements*. Both + exist, and they are orthogonal — `path[=ge=1&=le=5]` has no PostgREST equivalent + short of a database view. + +### E.5 Practical use + +Two applications follow from the mapping: + +1. **A dialect front-end.** A parser can accept the PostgREST surface and emit the + canonical model, so one execution engine serves both syntaxes and the conformance + suite gains a second dialect's worth of vectors. +2. **Client-ecosystem compatibility.** A host that accepts the PostgREST surface becomes + reachable by clients written against it. That is a product decision for the host, not + a requirement of this specification.