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/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){ 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/specification/rql-2.0.md b/specification/rql-2.0.md new file mode 100644 index 0000000..4f96d25 --- /dev/null +++ b/specification/rql-2.0.md @@ -0,0 +1,687 @@ +# Resource Query Language (RQL) 2.0 + +**Status:** Draft — pre-review, 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, 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, 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. + +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, 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* + (Appendix C, reserved operator names carried forward from RQL 1.x). + +## 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. +- **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. 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 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. **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 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 + +ABNF (RFC 5234), with the tokenization rules below. + +```abnf +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 / 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 "]" + ; element-scoped sub-query over the values at prop-path (§5.3); + ; 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 / 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 +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 +type-name = fiql-name +wildcard-value = *vchar "*" ; "*" only as the final + ; character, only with "==" (§5.1.2) +value-list = "(" [ value *( "," value ) ] ")" +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 ) [ "," ] + ; 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 + / 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 ) +``` + +**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 + 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. +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 +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 The Core comparator set + +The canonical comparator vocabulary is deliberately small and orthogonal: + +| Comparator | Meaning | +|---|---| +| `eq` | equality | +| `lt`, `le`, `gt`, `ge` | ordered comparison | +| `contains` | string containment: the property's string value contains the given substring | +| `starts_with`, `ends_with` | string affix match | +| `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. 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 +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 handling (§5.2) | +|---|---|---| +| `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) | 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. + +**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. + +**Value lists** `(v1,v2,…)` are interpreted as lists only for `in`/`not_in` (and the +`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 + +#### 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. + +**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 | +| 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 | 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 + +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: `&=` (and) +or `|=` (or), each followed by a named comparison: + +``` +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 `{ 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. An inner comparison on the element value itself is written +with no property path (`elem-cond`): + +``` +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 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 preserved. (Or-chaining is logically +distributable over the existential quantifier, but it is represented scoped as well, +for symmetry.) + +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 +semantics naturally. + +### 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). + +**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 +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). **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 — 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. +> 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 (§5.7). | +| `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`) + +Canonically a projection is a **mode** plus an ordered list of **fields**, each a +property path with an optional nested projection: + +| 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 | + +The brace and bracket nested forms are equivalent surface spellings of the same nested +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 + +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 } + +Group := { operator: "and" | "or", + 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", + fields: [ Field … ] } +Field := { path: [ segment … ], projection?: Projection } + +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 an + 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 + 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 + dual shapes, no linked lists, no URL-object inheritance). Hosts wanting such + affordances build them *around* the model, not into it. + +### 6.1 Error model + +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 + +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; +- element-scoped matches in chained form (`prop=ge=1&=le=5`) when every inner path is + 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, +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 **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 + exactly as specified. + +## 9. Security considerations + +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. + +--- + +## Appendix A — Breaking changes from RQL 1.x (migration) + +| 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` | 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`, `==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), 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])` | +| 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 — Compatibility aliases (non-normative surface, normative desugaring) + +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)` | 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 (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 names) + +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`. 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 — Implementation divergence tracking + +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. + +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. diff --git a/src/comparators.ts b/src/comparators.ts new file mode 100644 index 0000000..54ad0b7 --- /dev/null +++ b/src/comparators.ts @@ -0,0 +1,77 @@ +// 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', +]); + +// Comparators whose value token is a list (v1,v2,...). +export const LIST_COMPARATORS: ReadonlySet = new Set(['in', 'not_in']); + +// 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 }, +}; + +// 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 }, +}; + +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 FIQL comparator name (from `=name=` surface form, always interpreted by default) + * to its canonical form. Aliases desugar per Appendix B. + */ +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 }; + } + + // Open-vocabulary / core pass-through. + return { comparator: name, negated: false, verbatim: 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..536b603 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,6 @@ +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, ElementMatch, SortKey, Projection, Field, Value, +} from './types.ts'; diff --git a/src/parser.ts b/src/parser.ts new file mode 100644 index 0000000..b40677b --- /dev/null +++ b/src/parser.ts @@ -0,0 +1,811 @@ +import { QueryError, SyntaxViolation } from './errors.ts'; +import { SYMBOL_OPS, LIST_COMPARATORS, resolveFiqlName } from './comparators.ts'; +import type { + ParseResult, ParseOptions, Group, Condition, ElementMatch, SortKey, Projection, Field, Value, +} from './types.ts'; + +// QP: tokenises attribute names and structural operators. +// VP: tokenises value tokens (includes ( ) , as plain chars; excludes & | = [ ] { }). +// [&|]= wins over single-char structural match — chain/elem operators are two-char tokens. +const QP_SRC = '([^?&|=<>!([{\\}\\]),]*)([&|]=|[([{\\}\\])|,&]|[=<>!]*)'; +const VP_SRC = '([^&|=\\[\\]{}]*)([\\[\\]{}]|[&|=]*)'; + +const FIQL_NAME = /^[a-zA-Z_][a-zA-Z_0-9]*$/; + +// ── 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': { + 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: round-trip decimal numerals auto-convert in interpreted mode. + const n = +token; + if (token !== '' && !isNaN(n) && String(n) === token) return n; + return decodeURIComponent(token); +} + +const verbatimValue = (token: string): Value => decodeURIComponent(token); + +/** Split on literal `.`; decode each segment so `%2E` stays within a segment. */ +function splitPath(raw: string): string[] { + return raw.split('.').map(decodeURIComponent); +} + +function makeCondition( + path: string[], comparator: string, negated: boolean, raw: string, verbatim: boolean +): Condition { + 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; +} + +function parseListRaw(raw: string, verbatim: boolean): Value[] { + const inner = raw.slice(1, -1); + if (inner.length === 0) return []; + const decode = verbatim ? verbatimValue : interpretValue; + return inner.split(',').map(decode); +} + +/** 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 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; +} + +/** §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; + +type Acc = { + terms: Term[]; + operator?: 'and' | 'or'; + lastPath?: string[]; +}; + +function newAcc(): Acc { return { terms: [] }; } + +function setGroupOp(acc: Acc, op: 'and' | 'or', recordError: (msg: string) => void): void { + if (acc.operator && acc.operator !== op) + recordError('Cannot mix & and | in one group; use (...) or [...]'); + else acc.operator = op; +} + +function accToGroup(acc: Acc): Group | undefined { + if (acc.terms.length === 0) return undefined; + return { operator: acc.operator ?? 'and', terms: acc.terms }; +} + +// §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) { + const ic = t[0] as Condition; + const merged: Condition = { path: [...em.path, ...ic.path], comparator: ic.comparator, value: ic.value }; + 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 { + 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 recordError(msg: string): void { + const em = `${msg} at position ${pos}`; + errorMsg = errorMsg ? `${errorMsg}, ${em}` : em; + } + + // ── Condition-group parser ───────────────────────────────────────────── + // 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, isScoped = false): Acc { + const acc = newAcc(); + let path: string[] | undefined; + 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; + const rp = path; + const rc = rawComp ?? '='; + + let term: Condition | ElementMatch; + if (fiqlMode) { + const r = resolveFiqlName(rc); + if (r.isBetween) { + term = betweenMatch(rp, rawVal, r.betweenNegated ?? false); + } 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; + term = c; + } + } else { + const sym = SYMBOL_OPS[rc]; + 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) { + 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; + } + + qp.lastIndex = pos; + let match: RegExpExecArray | null; + while ((match = qp.exec(search))) { + pos = qp.lastIndex; + const [, val, op] = match; + + 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; + } + break; + case '==': case '===': case '!=': case '!==': case '<': case '<=': case '>': case '>=': + 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) finishCond(val); + else if (chainPath !== undefined && val) recordError(`chain leg requires a comparator name before '${val}'`); + closeEM(); + setGroupOp(acc, lop, recordError); + break; + } + case '&=': case '|=': { + const cop: 'and' | 'or' = op === '&=' ? 'and' : 'or'; + 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(); + 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 ','"); + 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(')'); + pos = qp.lastIndex; + const grp = accToGroup(inner); + if (grp) { closeEM(); acc.terms.push(grp); acc.lastPath = undefined; } + break; + } + case '[': { + if (val) { + // prop[...] scoped-match. + const ePath = splitPath(val); + qp.lastIndex = pos; + 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) { + 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 }); + } + } else { + qp.lastIndex = pos; + const inner = parseCondGroup(']'); + pos = qp.lastIndex; + const grp = accToGroup(inner); + if (grp) { closeEM(); 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 '${val}'`); + closeEM(); + return acc; + } + recordError(closeCh ? `expected '${closeCh}', got '${ch}'` : `unexpected '${ch}'`); + break; + } + default: + recordError(`unexpected token '${op}'`); + } + + qp.lastIndex = pos; + if (pos === search.length) break; + } + if (closeCh) recordError(`expected '${closeCh}', got end of string`); + closeEM(); + return acc; + } + + // ── Top-level parser ─────────────────────────────────────────────────── + + const result: ParseResult = {}; + const topAcc = newAcc(); + let path: string[] | undefined; + let rawComp: string | undefined; + let fiqlMode = false; + let chainPath: string[] | undefined; + let activeEM: ElementMatch | undefined; + const seenCalls = new Set(); + + function useVP(): boolean { + 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) { + term = betweenMatch(rp, rawVal, r.betweenNegated ?? false); + } 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; + term = c; + } + } else { + const sym = SYMBOL_OPS[rc]; + 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); + } + + path = undefined; rawComp = undefined; fiqlMode = false; + } + + function closeActiveEM(): void { + if (activeEM) { + pushElementMatch(topAcc, activeEM); + activeEM = undefined; + chainPath = undefined; + } + } + + // ── Sub-parsers for call function arguments ──────────────────────────── + + 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; + 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; } + } + 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) { if (val) fields.push({ path: splitPath(val) }); return fields; } + if (op === ')' || op === ']' || op === '}') { + 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) }); + p.lastIndex = pos; + continue; + } + if (op === '{') { + const nested = parseSelectList('}'); + 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; + } + if (op === '[') { + if (val) { + 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++; + p.lastIndex = pos; + fields.push({ path: splitPath(val), nested }); + } else { + recordError(`expected 'select(' after '${val}['`); + } + } else { + // `[a,b]` → tuple. + const items = parseSelectList(']'); + p.lastIndex = pos; + fields.push({ path: [], nested: items, tuple: true }); + } + continue; + } + 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; + } + + 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' = + (!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, true) }; + return { path: rf.path }; + } + + function parseSelectArgs(): Projection { + const startPos = pos; + const fields = parseSelectList(')'); + const beforeClose = search.slice(startPos, pos - 1).trimEnd(); + const trailingComma = beforeClose.endsWith(','); + return rawFieldsToProjection(fields, trailingComma); + } + + // ── Main loop ────────────────────────────────────────────────────────── + + while (pos < search.length) { + const p = useVP() ? vp : qp; + p.lastIndex = pos; + const match = p.exec(search); + if (!match) break; + pos = p.lastIndex; + const [, val, op] = match; + + if (p === vp) { + finishTopCond(val); + switch (op) { + case '&': case '|': { + const lop: 'and' | 'or' = op === '&' ? 'and' : 'or'; + closeActiveEM(); + setGroupOp(topAcc, lop, recordError); + break; + } + case '&=': case '|=': { + 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 || 'terms' in prev) { + if (prev) topAcc.terms.push(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; + } + continue; + } + + // QP token. + 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 = '='; fiqlMode = false; + } + break; + case '==': case '===': case '!=': case '!==': case '<': case '<=': case '>': case '>=': + 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); + else if (chainPath !== undefined && val) recordError(`chain leg requires a comparator name before '${val}'`); + closeActiveEM(); + setGroupOp(topAcc, lop, recordError); + break; + } + case '&=': case '|=': { + const cop: 'and' | 'or' = op === '&=' ? 'and' : 'or'; + if (path !== undefined) finishTopCond(val); + 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 || 'terms' in prev) { + if (prev) topAcc.terms.push(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); + else if (chainPath !== undefined && val) recordError(`chain leg requires a comparator name before '${val}'`); + break; + case ',': + 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. + 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}'`); + } + } + 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 '[': { + if (val) { + const ePath = splitPath(val); + qp.lastIndex = pos; + 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) { + 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; + } else { + closeActiveEM(); pushElementMatch(topAcc, { path: ePath, some: innerGrp }); + } + 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 '}': + recordError(`unexpected '${op[0]}'`); + break; + default: + recordError(`unexpected token '${op}'`); + } + } + + if (path !== undefined) finishTopCond(''); + closeActiveEM(); + + 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/types.ts b/src/types.ts new file mode 100644 index 0000000..4bb14e7 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,55 @@ +// §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; + value: Value; +} + +export interface Group { + operator: 'and' | 'or'; + 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 { + path: string[]; + direction: 'asc' | 'desc'; +} + +export interface Field { + path: string[]; + projection?: Projection; +} + +export interface Projection { + mode: 'records' | 'values' | 'tuples'; + fields: Field[]; +} + +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; +} + +export interface ParseOptions { + deferErrors?: boolean; +} diff --git a/test/v2/parse.test.ts b/test/v2/parse.test.ts new file mode 100644 index 0000000..8c4fd49 --- /dev/null +++ b/test/v2/parse.test.ts @@ -0,0 +1,941 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseQuery, resolveFiqlName } 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 { + 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('!==value → negated eq, verbatim', () => { + const r = parseQuery('x!==foo'); + assert.deepEqual(r.filter, andGrp(cond(['x'], 'eq', 'foo', true))); + }); +}); + +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))); + }); +}); + +// --------------------------------------------------------------------------- +// OR and grouping +// --------------------------------------------------------------------------- + +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('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('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'); + }); +}); + +// --------------------------------------------------------------------------- +// 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('not_equal → negated eq (verbatim)', () => { + const r = parseQuery('a=not_equal=hello'); + assert.deepEqual(r.filter, andGrp(cond(['a'], 'eq', 'hello', true))); + }); + + it('ne and != produce same canonical form (interpreted)', () => { + const a = parseQuery('x=ne=1'); + const b = parseQuery('x!=1'); + // Both → negated eq, interpreted (numeral auto-converts: value is number 1) + assert.deepEqual(a.filter, b.filter); + }); + + 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('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('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]); + }); +}); + +// --------------------------------------------------------------------------- +// between desugaring +// --------------------------------------------------------------------------- + +describe('between desugaring', () => { + it('between=(lo,hi) → ElementMatch wrapping ge+le group', () => { + const r = parseQuery('age=between=(18,65)'); + // 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)'); + 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 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); + }); +}); + +// --------------------------------------------------------------------------- +// Chaining (&= / |=) desugaring +// --------------------------------------------------------------------------- + +describe('Chaining desugaring', () => { + // &= 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'); + 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 ElementMatch with or operator', () => { + const r = parseQuery('status=eq=active|=eq=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 (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, ['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, ['ratings']); + assert.deepEqual((unchained.filter!.terms[1] as Condition).path, ['ratings']); + + assert.notDeepEqual(chained.filter, unchained.filter); + }); +}); + +// --------------------------------------------------------------------------- +// 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']); + }); +}); + +// --------------------------------------------------------------------------- +// Wildcard +// --------------------------------------------------------------------------- + +describe('Wildcard', () => { + it('trailing * on == → starts_with', () => { + const r = parseQuery('name==John*'); + assert.deepEqual(r.filter, andGrp(cond(['name'], 'starts_with', 'John'))); + }); + + it('non-trailing * throws', () => { + assert.throws(() => parseQuery('name==*John'), /wildcard/); + }); + + 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'); + }); +}); + +// --------------------------------------------------------------------------- +// 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('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/); + }); +}); + +// --------------------------------------------------------------------------- +// Property paths +// --------------------------------------------------------------------------- + +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('%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']); + }); +}); + +// --------------------------------------------------------------------------- +// Sort +// --------------------------------------------------------------------------- + +describe('sort()', () => { + it('single field ascending', () => { + const r = parseQuery('sort(name)'); + assert.deepEqual(r.sort, [{ path: ['name'], direction: 'asc' }]); + }); + + 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); + }); +}); + +// --------------------------------------------------------------------------- +// 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('(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: ['ratings'], + some: { operator: 'and', terms: [ + { 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&[ratings=ge=3&=le=4]'); + const grp = r.filter!.terms[1] as Group; + const em = grp.terms[0] as ElementMatch; + assert.deepEqual(em.path, ['ratings']); + 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'] }] } }, + ], + }); + }); +}); + +// --------------------------------------------------------------------------- +// §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] → 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 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)', () => { + 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' }]); + }); +}); + +// --------------------------------------------------------------------------- +// §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); + }); +}); 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"] +}