Skip to content

Latest commit

 

History

History
701 lines (564 loc) · 39.7 KB

File metadata and controls

701 lines (564 loc) · 39.7 KB

Semantic Coverage Matrix

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 reason
  • GAP: 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.


Axes

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.


1. Write shape × Target × Value source

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.


2. Target endpoint property access in traversals

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.


3. Parameterized vs literal symmetry

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

Out-of-scope (tracked separately, not gap-tracked here)

  • Cypher syntax acceptance (parser/grammar): docs/cypher-coverage-matrix.md.
  • Performance / scale testing: future initiative.
  • openCypher TCK integration: tests/tck/ future work.

Process

  1. When fixing a bug, add a regression test to tests/functional/39_issue_regression_tests.sql using the T-NNNN naming pattern and link the cell here.
  2. When landing a feature, add one matrix row and fill every applicable cell before merge (or explicitly mark GAP with a follow-up ticket).
  3. 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/ or src/backend/executor/ without updating this doc.
  4. Rotation: once every six months, audit the matrix for rot. Move completed GAPs to "covered" and file follow-ups for any new blind spot.

Current gap census (2026-04-18)

  • 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.

Coverage update (2026-05-26) — I-0044 Phase-B conformance push

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-back n.k — list/map literal write now round-trips (previously silently NULL). Backed by the new *_props_json property columns in query_dispatch.c / executor_result_project.c.
  • CREATE … RETURN n with SET-clause + general-expression valueshandle_create_return runs the SET loop and an executor_eval_value fallback, 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]].

Coverage update (2026-05-27) — I-0047 P1: undirected variable-length paths

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_cte previously emitted only the source_id → target_id direction, 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_delete pre-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) — the AST_NODE_PATH EXISTS emitter (transform_expr_predicate.c) previously treated every rel as a single fixed hop, ignoring rel->varlen. New emit_exists_varlen_path emits 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).

Coverage update (2026-05-27) — I-0047 P2: varlen relationship-property predicate

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 (an edge_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.

Coverage update (2026-05-27) — I-0047 P3: OPTIONAL MATCH row preservation

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_labels join (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 revisits b; 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 with CASE 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.

Coverage update (2026-05-27) — I-0047 P4: forward a path variable through WITH

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 p now carries the path across the WITH boundary. WITH's item loop bypassed transform_expression (which hydrates path vars), so a forwarded path emitted a bare p column → "no such column". WITH now emits the path-hydration SQL AS p, preserves the path metadata across the scope reset, and re-registers p as 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 used json_group_array over json_each('[' || elem_ids || ']'), which yields '[]' when elem_ids is NULL (OPTIONAL miss). Wrapped it in CASE WHEN elem_ids IS NULL THEN NULL. Fixes Match9 [9].

P4 (GQLITE-T-0337) complete: +2 (With1 [4], Match9 [9]).

Coverage update (2026-05-28) — T-0339: MATCH+CREATE multi-row + general expressions

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-coded break in bind_match_clause_into_varmap). Now execute_multi_match_create_query materializes all matched rows into memory BEFORE running CREATE (running CREATE inside sqlite3_step invalidates the iterating SELECT), allocates a fresh var_map per 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 calls executor_eval_value with the current var_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.

Coverage update (2026-05-28) — validate: pattern expressions in SET RHS

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 helper expr_contains_path_pattern walks the expression tree and the SET validator invokes it. Fixes Pattern1 [24].

Coverage update (2026-05-28) — validate+match: rel-uniqueness + dedupe edges-table emission

Architectural correctness fix (zero TCK delta; zero regressions; unit 944/944; functional clean):

  • New validate_rel_uniqueness_in_match validator — 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).

Coverage update (2026-05-28) — path-wide rel-uniqueness across varlen and bound rels

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].

Coverage update (2026-05-28) — REMOVE on null/unbound variable is a no-op

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.num previously errored with "Unbound variable in REMOVE: r" because is_variable_edge() returns false for absent map entries (the OPTIONAL preserves the row but doesn't bind r), so the rel fell through to the node path's entity_id < 0 error. Both entity_id < 0 branches in execute_remove_operations now log a debug line and continue, 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].

Coverage update (2026-05-28) — accept bidirectional bracketed rel pattern <-[...]->

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 passes false/false for (left_arrow, right_arrow) to make_rel_pattern_varlen. Bare bidirectional <--> was already supported. Match5 [27] still fails downstream on bidirectional varlen execution semantics; that fix is deferred.

Coverage update (2026-05-28) — RETURN * skips named-path anon element prefixes

executor_match.c + transform_return.c. Verified via the TCK harness (3706 -> 3707, zero regressions; unit 944/944; functional clean):

  • RETURN * after MATCH p = (a)-->(b) exposes only a, 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 two RETURN * expansion sites (executor_match.c and transform_return.c) already skipped the older _gql_default_alias_ and __unnamed_rel_ prefixes; both now also skip _pv_n / _pv_e. Fixes Return7 [1].

Coverage update (2026-05-28) — four-label conjunction in expression context

cypher_gram.y. Verified via the TCK harness (3707 -> 3708, zero regressions; unit 944/944; functional clean):

  • WHERE a:L1:L2:L3:L4 parses as the conjunction (a:L1) AND (a:L2) AND (a:L3) AND (a:L4) (Graph5 [4], e.g. a:C:A:A:C). The expr-context label rules already covered 1/2/3 labels; the four-label form raised a parse error. Added a mirror rule chaining four make_label_expr conjuncts. Repeated labels are harmless — each conjunct is an independent EXISTS check. No bison conflicts (still %expect 15 / %expect-rr 3).

Coverage update (2026-05-28) — existential subquery brace form (no inner WHERE)

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]). Added EXISTS '{' pattern_list '}' reusing the existing EXISTS_TYPE_PATTERN transform, which already resolves correlated outer variables (the bound n) via its outer-alias lookup and emits a correlated EXISTS (SELECT 1 FROM ... WHERE ...) subquery. No bison conflicts (still %expect 15 / %expect-rr 3). The brace form with an inner WHERE ([2]/[4]) and the full-query/aggregation/nested forms (ExistentialSubquery2/3) remain unsupported — they need inner-variable registration and are deferred.

Coverage update (2026-05-29) — existential subquery brace form with inner WHERE

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_expr gains where_clause (the inner predicate) and is_subquery (brace vs paren). Grammar rule EXISTS '{' pattern_list WHERE expr '}' sets both.
    • The EXISTS_TYPE_PATTERN emitter 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 as AND (<expr>), then transform_var_truncate_tos back to the saved scope.
    • The WHERE-pattern fresh-variable validator skips is_subquery EXISTS 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.

Coverage update (2026-05-29) — bulk SET from an entity (SET r = a)

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 a copy_entity_properties helper that reads the source's five property-type tables and re-sets each on the destination via the typed schema setters (incrementing properties_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).

Coverage update (2026-05-29) — NaN constant comparison semantics

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.0 comparisons 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 constant 0.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.

Coverage update (2026-05-29) — UNWIND of a list containing bound entities

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 x no longer crashes with no such column: _gql_default_alias_0.id. The LIST branch only emitted a per-arm FROM when 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 — when inner_sql is a splicable SELECT * 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 <> j after a projection that drops i/j).

Coverage update (2026-05-29) — Cypher orderability type-rank in ORDER BY

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_by and the WITH ORDER-BY path now emit _gql_order_rank(e) <dir>, _gql_order_key(e) <dir> — rank groups by type, _gql_order_key orders 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.

Coverage update (2026-05-29) — renderable NaN value (GQLITE-T-0340 sub-feature B)

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.0 now produces a renderable NaN value that prints as the bare token NaN and orders at rank 8. SQLite collapses float /0 to NULL and drops subtypes across CTE boundaries, so NaN is carried as the private string GQL_NAN_SENTINEL (0x01 'N' 'a' 'N') — recognized by content, collision-proof. Standalone 0.0/0.0 emits (CHAR(1) || 'NaN'); the agtype layer (create_property_agtype_value) maps the sentinel to a float NaN whose serializer prints NaN; the plain formatter prints the sentinel as NaN. 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).

Coverage update (2026-05-29) — path-as-list-element hydration (GQLITE-T-0340 sub-feature C)

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 raw elem_ids array. The executor's elem_ids post-hydration only reaches top-level RETURN columns, not values buried in an UNWIND row. New context flag emit_hydrated_path makes the path projection emit the self-contained hydrated JSON (reusing the pattern- comprehension builder) for non-varlen paths; transform_unwind sets 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.

Coverage update (2026-05-30) — labels()/type()/keys() accept type Any

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_type UDFs that inspect the runtime value: a node/relationship JSON object yields its labels/type, null yields null, and anything else raises a runtime TypeError: 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's properties object when present (node/rel) else its own keys (map). Fixes Graph3 [6], Graph4 [5], Map3 [2].

Coverage update (2026-05-30) — duration multiply/divide by a number (Temporal8 [7])

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 / n now scale the duration component-wise instead of coercing the JSON to 0. New _gql_dyn_mul / _gql_dyn_div UDFs (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}) stays PT25H and a fractional duration keeps e.g. PT67H — consistent with emit_duration_json used 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_temporal now adds time_ns / DAY_NS whole days (trunc toward zero) to a pure-date input and drops the sub-day remainder (Temporal8 [1] example 3). Part of GQLITE-T-0341.

Coverage update (2026-05-30) — duration.inMonths/inDays tz normalization (Temporal10 [3]/[4])

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.inDays compare 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. +0200 vs +0100) no longer spuriously drops a whole month/day (Temporal10 [3] ex19 P1Y, [4] ex17 P337D). inDays was also rewritten to count whole days from the calendar day difference minus a partial trailing day (time-of-day comparison), using days_from_civil (unbounded) instead of timegm. Part of GQLITE-T-0341. Remaining Temporal10: DST-aware durations [8] and large-duration overflow [9]/[10] (deferred).

Coverage update (2026-05-30) — time() drops named-zone region; date() quarter selection (Temporal3)

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, while datetime() retains it (+2, 3747->3749). The shared _gql_time_compose UDF gained a drop_region arg (1 for time, 0 for datetime). Part of GQLITE-T-0341.

Coverage update (2026-05-31) — Temporal cluster grind (T-0341), 3728 -> 3758 (+30)

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.

Coverage update (2026-06-01) — MATERIALIZE non-deterministic WITH/pre CTEs (Quantifier +18)

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 a rand()-derived list got a DIFFERENT value at each use — e.g. none(x IN list WHERE p) and any(x IN list WHERE p) over the same list saw different random lists, breaking algebraic identities and yielding inconsistent rows. sql_cte/sql_pre_cte now emit AS MATERIALIZED (...) when the CTE body calls a non-deterministic function (detected by RANDOM(), forcing single evaluation. Gated on RANDOM( 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).

Coverage update (2026-06-01) — property access on an entity inside a list (groundwork)

transform_expr_ops.c. Verified via the TCK harness (rigorous full pass-set diff: zero regressions; unit 944/944; functional clean):

  • r.name on a node/relationship that is a list element now reads .properties. A projected/list-element JSON value (e.g. the quantifier variable in any(r IN relationships(p) WHERE r.name = 'a')) used top-level json_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 varlen relationships(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 — so collect aggregated 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_input gate) so the downstream aggregate's input is ordered (SQLite preserves an ordered subquery's row order through json_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 compared parse_temporal_ns() values, where epoch_seconds * 1e9 overflows int64 around year ~2262 and wraps negative — so localdatetime/datetime({year: 9999}) compared as the smallest value, making < disagree with the ORDER BY key (which uses the zero-padded ISO string). New cmp_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.

Coverage update (2026-06-03) — implicit GROUP BY on a computed key (+2)

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 key a.num2 % 3 is 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_call non-null) are still excluded from GROUP BY.