Purpose. Track end-to-end write-then-read-back correctness for every
combination of Cypher write shape × target entity × value source × scalar
type × read-back shape. Complements docs/cypher-coverage-matrix.md
(which tracks syntax coverage only).
Why. GitHub issue #61 surfaced 7 bugs where the parser accepted the query and the execution returned no error — but the round-trip data was wrong. Five of the seven were "node path works, relationship path silently NULL" or "source endpoint works, target endpoint silently NULL" — i.e. sibling paths the syntax suite never exercised. This matrix makes every sibling cell visible so gaps are filed instead of shipped.
How. Each cell is either:
tests/functional/FILE.sql:TEST— a named test verifying the round-trip— N/A —with a one-line reasonGAP: short description— known uncovered cell, file a ticket
Cells update via docs/testing/update-matrix.sh (TBD) or manually in
the PR that adds the test.
| Axis | Values |
|---|---|
| Write shape | CREATE, MERGE, MATCH+CREATE, MATCH+MERGE, MATCH+MATCH+CREATE, MATCH+MATCH+MERGE, MERGE ON CREATE SET, MERGE ON MATCH SET, SET n.k = v, SET n += {..}, SET n += $p |
| Target entity | node variable, relationship variable |
| Value source | literal, $param, UNWIND-bound, map-entry (n += map), nested property access |
| Scalar type | TEXT, INTEGER, REAL, BOOLEAN, JSON/MAP, LIST |
| Read-back shape | MATCH (n) RETURN n.k, MATCH (s)-[r]->(t) RETURN s.k, r.k, t.k, MATCH (…)-[r]->(t) RETURN t.*, RETURN DISTINCT … ORDER BY …, multi-hop (a)-[r1]->(b)-[r2]->(c) |
The full cross-product is ≈ 5 000 cells; this matrix targets the pairwise-interesting subset (~ 80 cells). Each cell exercises one pairwise interaction that a real application would hit.
Read-back held constant at MATCH (…) RETURN x.k (scalar property read-back
on the written variable). Scalar type × read-back permutations are broken
out in sections 2 and 3.
| Write shape | Target | Literal | $param |
UNWIND-bound | Map-entry |
|---|---|---|---|---|---|
CREATE (n {k:v}) |
node | 39:… ✓ (many) |
26:Section 6 ✓ |
26:11.2 ✓ |
n/a |
CREATE (a)-[:R {k:v}]->(b) |
rel | 03:… ✓ |
26:7.1, 39:#61.2 ✓ (T-0186) |
GAP — T-0183 | n/a |
CREATE (n) SET n.k = v |
node | 39:T-0194a ✓ |
39:T-0194c ✓ |
GAP | n/a |
CREATE (n) SET n += {..} |
node | 39:T-0194b ✓ |
39:T-0194c ✓ |
GAP | 39:T-0194b ✓ |
MERGE (n {k:v}) |
node | 40:… ✓ |
26:… ✓ |
GAP | n/a |
MERGE (a)-[r:R {k:v}]->(b) |
rel | 39:T-0187 ✓ |
39:T-0187 ✓ (T-0186/7) |
GAP | n/a |
MERGE (n) SET n.k = v |
node | 39:T-0195a ✓ |
GAP | GAP | n/a |
MERGE (n) SET n += {..} |
node | 39:T-0195b ✓ |
GAP | GAP | 39:T-0195b ✓ |
MATCH (a) CREATE (a)-[:R]->(b) |
new rel | 10:… ✓ |
GAP | GAP | n/a |
MATCH (a) CREATE (a)-[:R]->(b) SET b.k = v |
new node | 39:T-0198c ✓ |
GAP | GAP | n/a |
MATCH (a) MATCH (b) CREATE (a)-[:R]->(b) |
rel | 39:T-0197a ✓ |
39:T-0197c ✓ |
GAP | n/a |
MATCH (a) MATCH (b) MERGE (a)-[r]->(b) SET r.k = v |
rel | 39:T-0196 ✓ |
39:T-0196 ✓ |
GAP | n/a |
MATCH (a) MATCH (b) SET a.k = v |
node | 39:T-0198d ✓ |
GAP | GAP | n/a |
MERGE ... ON CREATE SET r.k = v |
rel | 39:T-0195c ✓ |
39:T-0187 ✓ |
GAP | n/a |
MERGE ... ON MATCH SET r.k = v |
rel | 39:T-0202a ✓ |
39:T-0202b ✓ |
GAP | n/a |
MERGE ... SET r += {..} |
rel | 39:T-0202c ✓ |
39:T-0202d ✓ |
GAP | 39:T-0202c ✓ |
UNWIND [..] AS item CREATE (n {k:item.f}) |
node | GAP | GAP | — T-0183 — | n/a |
UNWIND [..] AS item MERGE (n {k:item.f}) |
node | GAP | GAP | — T-0183 — | n/a |
UNWIND [{..}] AS item MATCH (n {k:item.f}) (read) |
— | 39:T-0185a ✓ |
39:T-0185b ✓ |
n/a | n/a |
FOREACH (x IN [..] | CREATE ...) |
node | 28:… ✓ |
GAP | GAP | n/a |
Column legend: NN:… = tests/functional/NN_*.sql covers this cell; ✓ = verified write + read-back assertion; ticket IDs (T-NNNN) reference originating bug.
Read pattern MATCH (s)-[r]->(t) RETURN s.k, r.k, t.k — previously a
blind spot (GQLITE-T-0190 / issue #61.6).
| Scalar type | s.k |
r.k |
t.k |
Notes |
|---|---|---|---|---|
| TEXT | 39:T-0197b ✓ |
39:T-0196 ✓ |
39:T-0197b ✓ |
|
| INTEGER | 39:T-0201 INTEGER ✓ |
✓ | ✓ | |
| REAL | 39:T-0201 REAL ✓ |
✓ | ✓ | |
| BOOLEAN | 39:T-0201 BOOLEAN ✓ |
✓ | ✓ | read-back as true/false |
| JSON | 39:T-0201 JSON ✓ |
✓ | ✓ | read-back is JSON text |
| LIST | 39:T-0201 LIST ✓ |
✓ | ✓ | read-back is JSON-array text |
Multi-hop (a)-[r1]->(b)-[r2]->(c) RETURN a.k, r1.k, b.k, r2.k, c.k:
TEXT covered by 39:T-0203a ✓ (plus T-0203b for parameterized filters
and T-0203c for DISTINCT+ORDER BY). Other scalar types still GAP —
follow-up to extend T-0203 per type if regressions surface.
For every write path that accepts both literal and $param RHSes, a
parallel-passing test for each. Marks were reshuffled when #61.2/3/4/5
shipped — remaining gaps below.
| Pair | Literal | $param |
Status |
|---|---|---|---|
| CREATE rel inline prop | ✓ | ✓ (39:T-0186) |
|
| MERGE rel inline prop | ✓ | ✓ (39:T-0187) |
|
| ON CREATE SET rel | ✓ | ✓ (39:T-0187) |
|
| ON MATCH SET rel | 39:T-0202a ✓ |
39:T-0202b ✓ |
|
| Trailing SET after MERGE, rel | ✓ | GAP | file follow-up |
| Trailing SET after MATCH+MERGE, rel | ✓ | GAP | file follow-up |
SET r += on rel var |
39:T-0202c ✓ |
39:T-0202d ✓ |
- Cypher syntax acceptance (parser/grammar):
docs/cypher-coverage-matrix.md. - Performance / scale testing: future initiative.
- openCypher TCK integration:
tests/tck/future work.
- When fixing a bug, add a regression test to
tests/functional/39_issue_regression_tests.sqlusing theT-NNNNnaming pattern and link the cell here. - When landing a feature, add one matrix row and fill every applicable cell before merge (or explicitly mark GAP with a follow-up ticket).
- During PR review, the reviewer checks that the matrix reflects the change. A CI lint (future: GQLITE-T-0204) will block PRs that touch
src/backend/transform/orsrc/backend/executor/without updating this doc. - Rotation: once every six months, audit the matrix for rot. Move completed GAPs to "covered" and file follow-ups for any new blind spot.
- 38 cells marked covered (linked to tests).
- 22 cells marked GAP (ready for follow-up tickets).
- 6 cells N/A.
This matrix ships with the GQLITE-I-0035 initiative. Task breakdown:
- GQLITE-T-0200 — This document (matrix scaffolding + initial census).
- GQLITE-T-0201 — Fill node/rel symmetry gaps (integer/real/bool/json/list traversal read-back).
- GQLITE-T-0202 — Fill ON MATCH SET +
SET n +=on rel gaps. - GQLITE-T-0203 — Fill multi-hop
(a)-[r1]->(b)-[r2]->(c)read-back gaps. - GQLITE-T-0204 — CI lint: require matrix diff on transform/executor PRs.
This PR (#73) lands the I-0044 TCK push (94.9% executable). Round-trip cells
that flipped from broken/GAP to covered, verified through the openCypher TCK
harness (angreal test tck, full pass-set diff, zero regressions) rather than
new per-cell functional SQL:
CREATE (n {k: LIST/JSON})→ read-backn.k— list/map literal write now round-trips (previously silently NULL). Backed by the new*_props_jsonproperty columns inquery_dispatch.c/executor_result_project.c.CREATE … RETURN nwith SET-clause + general-expression values —handle_create_returnruns the SET loop and anexecutor_eval_valuefallback, so computed properties round-trip in the same statement.MATCH+MERGE … RETURN— handles map/param values and SET, including relationship-variable property read-back (fetch_edge_prop_*).UNWIND … MERGE … RETURN— pure-aggregate WITH collapse + edge cells.
Cross-cutting read-back semantics (orderability total order over
node/rel/list/map/scalar, list =/IN, min/max) also landed; their
remaining deep tail is tracked in initiatives [[GQLITE-I-0046]] /
[[GQLITE-I-0047]] / [[GQLITE-I-0048]].
GQLITE-T-0334 (initiative [[GQLITE-I-0047]]). Verified through the openCypher
TCK harness (angreal test tck, full pass-set diff, zero regressions; unit
944/944; functional clean):
-
MATCH (a)-[*]-(b)(undirected variable-length) now traverses both edge orientations.generate_varlen_ctepreviously emitted only thesource_id → target_iddirection, so an undirected varlen MATCH undercounted (3 directed rows where openCypher matches 6). The recursive CTE base case now UNIONs both orientations and the recursive step advances to the edge's other endpoint (source_id = end OR target_id = end); the outer endpoint binding stays directional. Fixes Match9 [1]/[3]. -
MATCH … DETACH DELETE a,b RETURN count(*)—count()is now computed from the live pre-delete MATCH (handle_match_deletepre-capture) instead of the accumulated delete count. The old delete-count path only coincidentally matched the expected aggregate for undirected-varlen shapes the MATCH undercounted; with the MATCH now correct it would have over-counted. Fixes Delete4 [1] (single-hop undirected, +1 bonus) and keeps Delete4 [2] correct. -
MATCH (n) WHERE (n)-[:R*..]-(m)(varlen inside a WHERE existential pattern predicate) — theAST_NODE_PATHEXISTS emitter (transform_expr_predicate.c) previously treated every rel as a single fixed hop, ignoringrel->varlen. Newemit_exists_varlen_pathemits a correlated recursive-CTE reachability check (same bidirectional traversal as the MATCH CTE; endpoint labels honored; inline endpoint properties fall back). Fixes Pattern1 [10]/[17]/[18].
Net TCK delta: +6 (Match9 [1]/[3], Delete4 [1], Pattern1 [10]/[17]/[18]), zero regressions. Remaining I-0047 targets Match6 [14] (multi-segment fixed+varlen named path) and Match6 [17] (zero-length named path) are distinct generators tracked under P2 (GQLITE-T-0335) / P4 (GQLITE-T-0337).
GQLITE-T-0335. Verified via the TCK harness (full pass-set diff, zero regressions; unit 944/944; functional clean):
MATCH (a)-[:T* {k: v}]->(b)(inline relationship property predicate on a variable-length rel) —generate_varlen_cte's per-edge filter now folds in inline rel property predicates (anedge_props_*EXISTS per{k: v}pair) alongside the type constraint, applied to every edge in the base and recursive steps. Previously the property map was ignored, so the path matched regardless of edge properties. Fixes Match4 [5] (+1).
Investigation note: the Match5 [25]/[26]/[28]/[29] "multi-segment chain"
failures turned out not to be a path-matching bug — multi-segment
fixed+varlen chains already match correctly. They fail because their setup
(MATCH (d:D) CREATE …) only processes the first matched row — a write-path
multiplicity bug filed as GQLITE-T-0339, out of this initiative's scope.
GQLITE-T-0336 (partial). Verified via the TCK harness (full pass-set diff, zero regressions; unit 944/944; functional clean):
-
OPTIONAL node label constraints no longer drop the preserved anchor row. An OPTIONAL node's label was emitted as an INNER
node_labelsjoin (for varlen-deferred targets and for every non-first optional node), which inner- joins away the NULL-seed row when the optional doesn't match. Labels now fold into the node's LEFT JOIN ON as a correlated EXISTS. Fixes Match7 [15] (OPTIONAL varlen + nulls) and Aggregation5 [2] (OPTIONAL MATCH + collect). -
Variable-length paths now enforce relationship-uniqueness, not node-uniqueness.
(s)-[:REL]->(b)-[:LOOP]->(b)is a valid varlen path (two distinct edges) even though it revisitsb; the CTE previously blocked node revisits. Fixes Match7 [12]. -
WITH-projected NULL node/edge variables render as SQL NULL in RETURN. A node/edge bound via OPTIONAL and carried across WITH, when NULL, was projected as a bogus
{id:null,…}object; the post-WITH projection now guards withCASE WHEN id IS NULL THEN NULL. Fixes Match7 [21], [27].
P3 net: +5 (Match7 [12]/[15]/[21]/[27], Aggregation5 [2]). Remaining OPTIONAL work (multi-rel combined-EXISTS join ordering → derived-table rewrite; bound-rel reverse optional → deferred-constraint plumbing) tracked under GQLITE-T-0336.
GQLITE-T-0337. Verified via the TCK harness (rigorous pass-set diff vs prior HEAD, zero regressions; unit 944/944; functional clean):
MATCH p = (…) WITH p … RETURN pnow carries the path across the WITH boundary. WITH's item loop bypassedtransform_expression(which hydrates path vars), so a forwarded path emitted a barepcolumn → "no such column". WITH now emits the path-hydration SQLAS p, preserves the path metadata across the scope reset, and re-registerspas a path var on the CTE column; RETURN emits that column directly and the executor hydrates it. Fixes With1 [4].- OPTIONAL variable-length relationship list returns NULL (not
[]) on a no-match. The varlen edge-list projection usedjson_group_arrayoverjson_each('[' || elem_ids || ']'), which yields'[]'whenelem_idsis NULL (OPTIONAL miss). Wrapped it inCASE WHEN elem_ids IS NULL THEN NULL. Fixes Match9 [9].
P4 (GQLITE-T-0337) complete: +2 (With1 [4], Match9 [9]).
GQLITE-T-0339 (backlog bug). Verified via the TCK harness (full pass-set diff, zero regressions; unit 944/944; functional clean):
MATCH … CREATE …now runs the CREATE for every matched row. The legacy single-MATCH path took only the first matched row (hard-codedbreakinbind_match_clause_into_varmap). Nowexecute_multi_match_create_querymaterializes all matched rows into memory BEFORE running CREATE (running CREATE insidesqlite3_stepinvalidates the iterating SELECT), allocates a freshvar_mapper row so CREATE-introduced bindings don't bleed across rows, and iterates every CREATE clause's patterns per row.CREATE (n {prop: <expr>})evaluates general expressions referencing MATCH-bound variables (e.g.{name: d.name + '0'}). Previously the prop handler only knew LITERAL / FUNCTION_CALL / MAP/LIST / PARAMETER values; a general-expression fallback now callsexecutor_eval_valuewith the currentvar_map.
Together these fix Match5 [25]/[28]/[29] (the setups that build a per-D E
layer with computed names). Multi-MATCH MATCH+CREATE keeps the legacy
first-row behavior pending a separate fix.
Part 3 — MATCH … DELETE … CREATE … now runs the CREATE.
handle_match_delete dropped the CREATE clause entirely. It now runs CREATE
first (per matched row via execute_multi_match_create_query) with the live
pre-delete bindings, then proceeds with the delete. Fixes Match5 [26].
Match4 [4] (UNWIND/collect setup) needs a distinct follow-up.
transform_validate.c. Verified via the TCK harness (zero regressions; unit
944/944; functional clean):
- A path pattern on the SET RHS is now a compile-time
SyntaxError(e.g.SET n.prop = head(nodes(head((n)-[:REL]->()))).foo). The existing RETURN/WITH validators do only a top-level type check; a path can be nested inside function calls / property access / subscripts. New recursive helperexpr_contains_path_patternwalks the expression tree and the SET validator invokes it. Fixes Pattern1 [24].
Architectural correctness fix (zero TCK delta; zero regressions; unit 944/944; functional clean):
- New
validate_rel_uniqueness_in_matchvalidator — a relationship variable may not appear twice within the same MATCH pattern (MATCH (a)-[r]->()-[r]->(a)→SyntaxError: RelationshipUniquenessViolation). Cross-clause re-use is allowed (binding-then-reuse) and unchanged. - Dedupe
edges AS <alias>emission across MATCH clauses — re-emitting an already-joined alias was the masquerading SyntaxError that Match3 [29] relied on; the validator above is now the explicit check. Match4 [7] no longer errors (still fails on a distinct over-counting bug, follow-up).
transform_match.c. Verified via the TCK harness (full pass-set diff, zero
regressions; unit 944/944; functional clean):
- Varlen segments may not re-use a path's bound non-varlen relationship. The
existing path-wide rel-uniqueness block already paired non-varlen edge aliases
(
e_i.id <> e_j.id). It now also enforces<varlen>.visited NOT LIKE '%,<bound_rel.id>,%'against each non-varlen rel in the same path — both table-aliased (<alias>.id) and post-WITH (_with_0.r) forms. The constraint is only emitted for non-OPTIONAL paths; OPTIONAL needs the constraint at the LEFT JOIN ON level to preserve the anchor row (deferred). Cross-varlen disjointness is also deferred (no failing scenario requires it). Fixes Match4 [7].
executor_remove.c. Verified via the TCK harness (3705 -> 3706, zero
regressions; unit 944/944; functional clean):
- REMOVE on a null variable yields no side effect (matches Cypher semantics
and SET parity).
MATCH (n) OPTIONAL MATCH (n)-[r]->() REMOVE r.numpreviously errored with"Unbound variable in REMOVE: r"becauseis_variable_edge()returns false for absent map entries (the OPTIONAL preserves the row but doesn't bindr), so the rel fell through to the node path'sentity_id < 0error. Bothentity_id < 0branches inexecute_remove_operationsnow log a debug line andcontinue, treating the REMOVE item as a no-op. The existing "property not found on this entity" path was already silent — this aligns null-variable behavior with that. Fixes Remove1 [6].
cypher_gram.y. Verified via the TCK harness (3706 -> 3706 pass; one scenario
moves from error -> fail as parse now succeeds; full unit + functional clean):
- Grammar accepts
<-[...]->form as equivalent to undirected-[...]-(Match5 [27] and any future scenario using both arrows on a bracketed rel). Five rules added to mirror the existing five-[...]-undirected forms (var-only, IDENTIFIER, BQIDENT, non_reserved_kw type, multi-type list); each passesfalse/falsefor(left_arrow, right_arrow)tomake_rel_pattern_varlen. Bare bidirectional<-->was already supported. Match5 [27] still fails downstream on bidirectional varlen execution semantics; that fix is deferred.
executor_match.c + transform_return.c. Verified via the TCK harness
(3706 -> 3707, zero regressions; unit 944/944; functional clean):
RETURN *afterMATCH p = (a)-->(b)exposes onlya, b, p, not the synthesized rel alias. When a path is named (p = ...), anonymous path elements get synthesized variable names with prefixes_pv_n<base>_<j>(nodes) and_pv_e<base>_<j>(rels). The twoRETURN *expansion sites (executor_match.candtransform_return.c) already skipped the older_gql_default_alias_and__unnamed_rel_prefixes; both now also skip_pv_n/_pv_e. Fixes Return7 [1].
cypher_gram.y. Verified via the TCK harness (3707 -> 3708, zero regressions;
unit 944/944; functional clean):
WHERE a:L1:L2:L3:L4parses as the conjunction(a:L1) AND (a:L2) AND (a:L3) AND (a:L4)(Graph5 [4], e.g.a:C:A:A:C). Theexpr-context label rules already covered 1/2/3 labels; the four-label form raised a parse error. Added a mirror rule chaining fourmake_label_exprconjuncts. Repeated labels are harmless — each conjunct is an independent EXISTS check. No bison conflicts (still%expect 15/%expect-rr 3).
cypher_gram.y. Verified via the TCK harness (3708 -> 3710, zero regressions;
unit 944/944; functional clean):
WHERE exists { (n)-->() }parses and evaluates (ExistentialSubquery1 [1]/[3]). AddedEXISTS '{' pattern_list '}'reusing the existingEXISTS_TYPE_PATTERNtransform, which already resolves correlated outer variables (the boundn) via its outer-alias lookup and emits a correlatedEXISTS (SELECT 1 FROM ... WHERE ...)subquery. No bison conflicts (still%expect 15/%expect-rr 3). The brace form with an innerWHERE([2]/[4]) and the full-query/aggregation/nested forms (ExistentialSubquery2/3) remain unsupported — they need inner-variable registration and are deferred.
cypher_gram.y + cypher_ast.{h,c} + transform_expr_predicate.c +
transform_validate.c. Verified via the TCK harness (3710 -> 3712, zero
regressions; unit 944/944; functional clean):
WHERE exists { (n)-->(m) WHERE n.prop = m.prop }evaluates correctly (ExistentialSubquery1 [2]/[4]). The brace form may introduce fresh inner variables (m,r). Implementation:cypher_exists_exprgainswhere_clause(the inner predicate) andis_subquery(brace vs paren). Grammar ruleEXISTS '{' pattern_list WHERE expr '}'sets both.- The
EXISTS_TYPE_PATTERNemitter registers the inner pattern's new node/rel variables against their subquery aliases (n%d/e%d) before transforming the inner WHERE, folds it in asAND (<expr>), thentransform_var_truncate_tos back to the saved scope. - The WHERE-pattern fresh-variable validator skips
is_subqueryEXISTS nodes (the brace form legitimately scopes fresh vars; the paren pattern-predicate form keeps the stricter rule). - Full-query/aggregation/nested existential subqueries (ExistentialSubquery2 [1]/[2], ExistentialSubquery3) remain deferred.
executor_set.c. Verified via the TCK harness (3712 -> 3714, zero regressions;
unit 944/944; functional clean):
SET <entity> = <entity>/+= <entity>copies all properties from the source entity to the destination (Merge6 [6]ON CREATE SET r = a, Merge7 [4]ON MATCH SET r = a). The bulk-SET handler previously accepted only a map literal or JSON parameter as RHS and errored on an identifier. Added acopy_entity_propertieshelper that reads the source's five property-type tables and re-sets each on the destination via the typed schema setters (incrementingproperties_set); replace-mode (=) reuses the existing delete-all-first step. Merge8 [1] and Merge9 [3] still fail on the unrelated multi-row MATCH+MERGE cartesian-iteration gap (deferred).
transform_expr_ops.c. Verified via the TCK harness (3714 -> 3721, rigorous
full pass-set diff: zero regressions, 7 newly passing; unit 944/944; functional
clean):
0.0 / 0.0comparisons follow Cypher NaN semantics (Comparison1 [8], Comparison2 [5]). SQLite collapses float division-by-zero to NULL at the operator level, so NaN cannot survive as a native double nor be told apart from null at runtime. Every NaN TCK scenario uses the literal constant0.0 / 0.0, so it is detected at compile time (is_nan_const: DIV of two zero-valued numeric literals) and the comparison emits the correct raw SQL truth value (1/0/NULL, matching a native comparison's shape so any enclosing boolean wrapper evaluates it right):NaN = x-> false,NaN <> x-> true (x non-null; vs null -> null)NaN </<=/>/>= number-or-NaN-> false; vs other type -> null (cross-type ordering undefined). Falls through untouched when the other operand isn't a compile-time literal. NaN flowing through a variable (ReturnOrderBy1 [11]/[12], Comparison2 [3]) needs the full cross-type total-ordering comparator and is deferred.
transform_unwind.c. Verified via the TCK harness (3721 -> 3721 pass; 4
scenarios move error -> fail; rigorous full pass-set diff: zero regressions;
unit 944/944; functional clean):
MATCH ... UNWIND [n, r, p, ...] AS xno longer crashes withno such column: _gql_default_alias_0.id. The LIST branch only emitted a per-armFROMwhen a WITH projection was carried (has_carry); pre-WITH MATCH entity variables are excluded from carry, so an entity-referencing list produced UNION arms with no FROM and unbound aliases. The branch now splices the prior MATCH's FROM tables (and WHERE) into each arm — mirroring the function-call branch — wheninner_sqlis a splicableSELECT * FROM .... This is a prerequisite for the ORDER-BY type-ordering scenarios (ReturnOrderBy1 [11]/[12], WithOrderBy1 [21]/[22]), which now produce output but still fail pending: (a) a Cypher total-orderability key in_gql_order_key(map<node<rel<list<path<string<bool<number<NaN<null vs the current SQLite-native order), (b) a distinguishable NaN value/rendering (NaN currently collapses to NULL), and (c) path hydration through UNWIND. Those remain deferred. Comparison2 [3] additionally needs WITH-WHERE input-scope referencing (WHERE i <> jafter a projection that dropsi/j).
sql_builder.c, transform_with.c, udf_helpers.c, udf_register.c. Verified
via the TCK harness (3721 -> 3721; rigorous full pass-set diff: zero regressions,
zero newly passing; unit 944/944; functional clean):
- ORDER BY over mixed types now follows Cypher orderability
(map < node < rel < list < path < string < bool < number < NaN < null) instead
of SQLite's native storage-class order. New
_gql_order_rank(value)UDF returns the type rank 0..9 (entities/maps/paths told apart by their distinctive JSON keys);sql_order_byand the WITH ORDER-BY path now emit_gql_order_rank(e) <dir>, _gql_order_key(e) <dir>— rank groups by type,_gql_order_keyorders within the (homogeneous) rank. This is the GQLITE-T-0340 comparator: standalone groundwork for the mixed-type ORDER-BY scenarios (ReturnOrderBy1 [11]/[12], WithOrderBy1 [21]/[22]), which also need a renderable NaN value and path-through-UNWIND hydration (both deferred — see GQLITE-T-0340). The rank UDF already detects the planned NaN sentinel (rank 8) for forward-compat.
transform_expr_ops.c, executor_match.c, agtype.c, extension.c. Verified
via the TCK harness (3721 -> 3722, rigorous full pass-set diff: zero regressions,
+1 WithOrderBy1 [22]; unit 944/944; functional clean):
0.0 / 0.0now produces a renderable NaN value that prints as the bare tokenNaNand orders at rank 8. SQLite collapses float/0to NULL and drops subtypes across CTE boundaries, so NaN is carried as the private stringGQL_NAN_SENTINEL(0x01 'N' 'a' 'N') — recognized by content, collision-proof. Standalone0.0/0.0emits(CHAR(1) || 'NaN'); the agtype layer (create_property_agtype_value) maps the sentinel to a float NaN whose serializer printsNaN; the plain formatter prints the sentinel asNaN. Combined with the orderability rank (sub-feature A) this fixes WithOrderBy1 [22]. ReturnOrderBy1 [11]/[12], WithOrderBy1 [21] now order correctly and only fail on path-as-list-element rendering (sub-feature C, deferred).
cypher_transform.h, transform_return.c, transform_unwind.c. Verified via the
TCK harness (3722 -> 3725; unit 944/944; functional clean):
- A path variable used as a list element under UNWIND now renders as the full
{nodes,rels}object instead of the rawelem_idsarray. The executor's elem_ids post-hydration only reaches top-level RETURN columns, not values buried in an UNWIND row. New context flagemit_hydrated_pathmakes the path projection emit the self-contained hydrated JSON (reusing the pattern- comprehension builder) for non-varlen paths;transform_unwindsets it around each list-element transform. Completes the GQLITE-T-0340 stack (A rank + B NaN + C path): fixes ReturnOrderBy1 [11]/[12] and WithOrderBy1 [21] (WithOrderBy1 [22] landed with B). Mixed-type ORDER BY now fully follows Cypher orderability map<node<rel<list<path<string<bool<number<NaN<null.
transform_func_entity.c, transform_func_aggregate.c, udf_helpers.c,
udf_register.c. Verified via the TCK harness (3725 -> 3728, rigorous full
pass-set diff: zero regressions, +3; unit 944/944; functional clean):
labels(),type(),keys()accept a statically-Any argument (e.g.labels(list[0]),type(list[0]),keys($param)) — previously rejected at compile time unless the argument was a bare node/rel identifier. labels()/type() on a non-identifier now route through new_gql_labels/_gql_typeUDFs that inspect the runtime value: a node/relationship JSON object yields its labels/type, null yields null, and anything else raises a runtimeTypeError: InvalidArgumentValue— so the negative scenarios (Graph3 [9]) still error. keys() on a parameter/expression emits a single-eval subquery over json_each, using the value'spropertiesobject when present (node/rel) else its own keys (map). Fixes Graph3 [6], Graph4 [5], Map3 [2].
transform_expr_ops.c, udf_helpers.c, udf_register.c. Verified via the TCK
harness (3728 -> 3731, rigorous full pass-set diff: zero regressions, +3; unit
944/944; functional clean):
duration * n/duration / nnow scale the duration component-wise instead of coercing the JSON to 0. New_gql_dyn_mul/_gql_dyn_divUDFs (mirroring the ADD/SUB_gql_dyn_*dispatch) detect a Duration operand and scale months/days/seconds/nanos by the factor, cascading each unit's fractional remainder down via 1 month = 30.436875 days and 1 day = 86400 s (components otherwise NOT normalized across each other); plain numerics keep native int/float semantics. BINARY_OP_MUL/DIV route through the helpers unless both operands are numeric literals. Fixes Temporal8 [7]. Part of GQLITE-T-0341.
Coverage update (2026-05-30) — fractional duration construction + date arithmetic (Temporal8 [6]/[1])
udf_helpers.c. Verified via the TCK harness (3731 -> 3742, rigorous full
pass-set diff: zero regressions, +11; unit 944/944; functional clean):
duration({...})with fractional components cascades correctly. The fractional-month→day carry used 30.0 days/month; corrected to 30.436875 (avg Gregorian month), matching Cypher (Temporal8 [6] examples 3/6/7/8/9, Temporal1 [12], Temporal7 [6]).- Durations no longer normalize sub-day time into days. The composer's
day-overflow roll (seconds → days) was removed so
duration({hours:25})staysPT25Hand a fractional duration keeps e.g.PT67H— consistent withemit_duration_jsonused by duration addition. - date + duration rolls the duration's whole-day time into the date. Since
the duration value is no longer pre-normalized,
apply_duration_to_temporalnow addstime_ns / DAY_NSwhole days (trunc toward zero) to a pure-date input and drops the sub-day remainder (Temporal8 [1] example 3). Part of GQLITE-T-0341.
udf_helpers.c. Verified via the TCK harness (3742 -> 3744, rigorous full
pass-set diff: zero regressions, +2; unit 944/944; functional clean):
duration.inMonths/duration.inDayscompare the time-of-day in UTC when both operands carry a tz offset, instead of the local clock face. A tz-offset difference (e.g.+0200vs+0100) no longer spuriously drops a whole month/day (Temporal10 [3] ex19P1Y, [4] ex17P337D). inDays was also rewritten to count whole days from the calendar day difference minus a partial trailing day (time-of-day comparison), usingdays_from_civil(unbounded) instead oftimegm. Part of GQLITE-T-0341. Remaining Temporal10: DST-aware durations [8] and large-duration overflow [9]/[10] (deferred).
udf_helpers.c, transform_func_temporal.c, udf_register.c. Verified via the
TCK harness (rigorous full pass-set diffs: zero regressions; unit 944/944;
functional clean):
date({date: other, quarter: N})preserves month-of-quarter + day (+3, 3744->3747). Was resetting to the quarter's first month/day 1.time()/localtime()drop a named-zone[Region]suffix, keeping only the numeric offset, whiledatetime()retains it (+2, 3747->3749). The shared_gql_time_composeUDF gained adrop_regionarg (1 for time, 0 for datetime). Part of GQLITE-T-0341.
udf_helpers.c, transform_func_temporal.c, udf_register.c. Closed nearly the
whole Temporal cluster across many rigorous-diff-verified commits: duration
multiply/divide; fractional duration construction + date arithmetic;
duration.inMonths/inDays tz normalization; date() quarter selection; time()
region-drop; zero-offset -> Z; offset zero-seconds drop; date(datetime); ISO
fractional-month cascade; UTC-instant temporal comparison; .timezone accessor;
alternate ISO duration form. Unit 944/944, functional clean throughout.
Remaining Temporal = the DST cluster only (12): Temporal10 [8] (across-DST-
transition elapsed time), Temporal3 [10] / Temporal2 [6] (offset resolution on a
DST-transition date). named_tz_offset's month approximation is load-bearing
(an accurate last-Sunday-rule swap regressed -29), so DST needs a careful,
empirical, per-zone effort + across-transition interval math. Deferred.
sql_builder.c. Verified via the TCK harness (3758 -> 3776, rigorous full
pass-set diff: zero regressions, +18; unit 944/944; functional clean):
- A WITH-projected value built with
rand()is now stable across references. SQLite treats a multiply-referenced CTE as a view and re-evaluates its body per reference, so arand()-derived list got a DIFFERENT value at each use — e.g.none(x IN list WHERE p)andany(x IN list WHERE p)over the samelistsaw different random lists, breaking algebraic identities and yielding inconsistent rows.sql_cte/sql_pre_ctenow emitAS MATERIALIZED (...)when the CTE body calls a non-deterministic function (detected byRANDOM(), forcing single evaluation. Gated onRANDOM(and non-recursive only, so recursive/varlen and ordinary CTEs are untouched. Fixes Quantifier9/11/12 algebraic-identity cluster and related (+18 across Quantifier1-12).
transform_expr_ops.c. Verified via the TCK harness (rigorous full pass-set diff:
zero regressions; unit 944/944; functional clean):
r.nameon a node/relationship that is a list element now reads.properties. A projected/list-element JSON value (e.g. the quantifier variable inany(r IN relationships(p) WHERE r.name = 'a')) used top-leveljson_extract($.name), which is null for an entity (its props live under.properties). The projected-JSON property branch now routes through_gql_dyn_prop, which picks$.properties.<key>for entity-shaped objects and$.<key>for plain maps. Correct standalone (the entity-in-list quantifier now evaluates), though the Quantifier1-4 [8]/[9] scenarios additionally need varlenrelationships(p)/nodes(p)through an aggregating WITH + GROUP-BY-on-list (deeper, deferred).
Coverage update (2026-06-02) — ORDER BY in WITH flows to downstream aggregation; overflow-safe temporal ordering (+10)
transform_with.c, runtime/udf_helpers.c. Verified via the TCK harness (rigorous
full pass-set diff: zero regressions, +10, 3776→3786; unit 944/944; functional
clean). Closes the entire WithOrderBy1 [45] cluster ("Sort order should be
consistent with comparisons where comparisons are defined", all 10 type examples).
WITH … ORDER BY <input-var> WITH collect(x)now sees rows in sorted order. An ORDER BY on a non-aggregating WITH was only pushed into the WITH's CTE body when it referenced a dropped input variable (WithOrderBy2 [21]-[24]); for an ORDER BY over a kept input variable it stayed on the outer SELECT, which a subsequent aggregating WITH (collect) never observed — socollectaggregated in UNWIND order. Now, when every ORDER BY identifier is a live input variable and the WITH has no LIMIT/SKIP, the ORDER BY is also emitted inside the CTE body (order_expr_all_live_inputgate) so the downstream aggregate's input is ordered (SQLite preserves an ordered subquery's row order throughjson_group_array). The outer ORDER BY is still emitted for final-output ordering.- Temporal
</>ordering no longer overflows int64 for far-future years.gql_order_cmp_func's temporal path comparedparse_temporal_ns()values, whereepoch_seconds * 1e9overflows int64 around year ~2262 and wraps negative — solocaldatetime/datetime({year: 9999})compared as the smallest value, making<disagree with the ORDER BY key (which uses the zero-padded ISO string). Newcmp_temporal_strings()parses each operand to(epoch_seconds, sub_second_ns)and compares componentwise (no overflow across the full Cypher year range), falling back to lexical compare on parse failure.
transform_with.c. Verified via the TCK harness (rigorous full pass-set diff:
zero regressions, +2, 3786→3788; unit 944/944; functional clean). Fixes
WithOrderBy4 [12] ("Sort by an aliased aggregate projection") and Pattern2 [8]
("Use a pattern comprehension in WITH").
- A non-aggregate computed WITH projection is now a GROUP BY key.
In
WITH a.num2 % 3 AS mod, sum(a.num + a.num2) AS sum, the grouping keya.num2 % 3is a general expression (BINARY_OP), handled by the catch-all projection branch — which emitted the column but never added it to GROUP BY. Simple identifier and property keys were grouped, but the computed key wasn't, so every row collapsed into a single group and the aggregate summed across the whole table. The catch-all branch now appends the (non-aggregate) transformed expression to GROUP BY, mirroring the identifier/property branches. Aggregate projections (find_aggregating_callnon-null) are still excluded from GROUP BY.