Skip to content

Commit ef26bbd

Browse files
dylanbstoreyDylan Bobby Storey
andauthored
TCK batch: exists + SET-entity + NaN + ORDER BY comparator + Any-type funcs (+18) (#87)
* exists: existential subquery brace form with inner WHERE (+2 TCK) `MATCH (n) WHERE exists { (n)-->(m) WHERE n.prop = m.prop } RETURN n` (ExistentialSubquery1 [2]/[4]) needs the brace form to (a) parse an inner WHERE and (b) allow fresh inner pattern variables (`m`, `r`). - cypher_exists_expr gains `where_clause` (inner predicate) and `is_subquery` (brace vs paren pattern-predicate). Grammar rule `EXISTS '{' pattern_list WHERE expr '}'` populates both. - The EXISTS_TYPE_PATTERN emitter now 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_to restores the outer scope (mirrors the pattern-comprehension save/restore). - The WHERE fresh-variable validator skips is_subquery EXISTS nodes: the brace form legitimately scopes fresh vars; the paren pattern-predicate keeps the stricter rule. Struct field added -> angreal dev clean. No bison conflicts (still `%expect 15` / `%expect-rr 3`). Fixes ExistentialSubquery1 [2] and [4] (all of ExistentialSubquery1 now green). Zero TCK regressions. 3710 -> 3712. Unit 944/944, functional clean. * set: bulk SET from an entity copies its properties (SET r = a) (+2 TCK) `MERGE (a)-[r:TYPE]->(b) ON CREATE SET r = a` (Merge6 [6]) and the ON MATCH variant (Merge7 [4]) copy all of node `a`'s properties onto rel `r`. The bulk-SET handler only accepted a map literal or JSON parameter as the RHS and errored ("Bulk SET value must be a map literal or parameter") on an entity identifier, so the copy silently produced no properties. Added `copy_entity_properties()`: reads the source entity's five property-type tables (text/int/real/bool/json) joined to property_keys and re-sets each on the destination via cypher_schema_set_{node,edge}_property, incrementing result->properties_set. Wired into the bulk-SET path as a new RHS case (AST_NODE_IDENTIFIER); replace mode (`=`) reuses the existing delete-all-first step, `+=` merges. Fixes Merge6 [6] and Merge7 [4]. Merge8 [1] / Merge9 [3] still fail on the separate multi-row MATCH+MERGE cartesian-iteration gap (deferred). Zero TCK regressions. 3712 -> 3714. Unit 944/944, functional clean. * expr: NaN constant comparison semantics for 0.0/0.0 (+7 TCK) SQLite collapses float division-by-zero to NULL at the operator level, so a NaN value can neither survive as a native double nor be distinguished from null at runtime. Every NaN TCK scenario produces NaN via the literal constant `0.0 / 0.0`, so detect that shape at compile time and emit the correct Cypher comparison result directly. is_nan_const() recognizes `DIV(0-lit, 0-lit)`. classify_nan_other() buckets the other operand (number / string / null / non-null / unknown). For a NaN operand the comparison emits a raw SQL truth value (1/0/NULL — the same shape a native comparison yields, so an enclosing _gql_bool_str(CASE WHEN ...) or WHERE filter evaluates it correctly; a tagged 'true'/'false' text would be re-read as falsy): NaN = x -> false (x non-null; vs null -> null) NaN <> x -> true (x non-null; vs null -> null) NaN </<=/>/>= number-or-NaN -> false; vs other type -> null Falls through untouched when the other operand is not a compile-time literal (so non-NaN comparisons are unaffected — verified by a rigorous full pass-set diff: zero regressions, exactly 7 newly passing). Fixes Comparison1 [8] (4 examples) and Comparison2 [5] (3 examples). NaN via a variable (ReturnOrderBy1 [11]/[12], Comparison2 [3]) needs the cross-type total-ordering comparator and is deferred. 3714 -> 3721. Unit 944/944, functional clean. * unwind: splice MATCH FROM into UNWIND of entity-containing list (correctness, +0 TCK) `MATCH p = (n)-[r]->() UNWIND [n, r, p, ...] AS x` crashed with `no such column: _gql_default_alias_0.id`. The UNWIND LIST branch emitted a per-UNION-arm FROM clause only when a WITH projection was being carried (has_carry); pre-WITH MATCH node/edge variables are deliberately excluded from carry (their alias is a table alias, not an id column ref), so a list whose elements reference bound entities produced arms like `SELECT json_object('id', _gql_default_alias_0.id, ...) AS value` with no FROM — the aliases were unbound. The LIST branch now splices the prior MATCH's FROM tables (and re-attaches its WHERE) into each UNION arm when inner_sql is a splicable `SELECT * FROM ...`, mirroring the existing function-call/subscript/binary-op branch. Element expressions keep referencing the original aliases, which are now in scope, and cardinality correctly tracks the surrounding MATCH. Prerequisite for the ORDER-BY total-ordering scenarios (ReturnOrderBy1 [11]/[12], WithOrderBy1 [21]/[22]): they no longer crash (error -> fail) but still need a Cypher orderability key in _gql_order_key, a distinguishable NaN value, and path-through-UNWIND hydration — all deferred. Rigorous full pass-set diff vs prior HEAD: zero regressions, zero newly passing (the 4 scenarios move error -> fail). 3721 pass unchanged. Unit 944/944, functional clean. * order: Cypher orderability type-rank as primary ORDER BY key (correctness, +0 TCK) ORDER BY over heterogeneous values used SQLite's native storage-class order (null < number < text < blob), which is wrong for Cypher. Cypher orderability is map < node < rel < list < path < string < bool < number < NaN < null. New `_gql_order_rank(value)` UDF returns the integer type rank 0..9 (JSON entity/map/path shapes distinguished by their distinctive keys: nodes&rels -> path, labels -> node, startNode* -> rel, else map). `sql_order_by` (sql_builder.c) and the WITH ORDER-BY path (transform_with.c) now emit `_gql_order_rank(e) <dir>, _gql_order_key(e) <dir>`: the rank groups by type for the correct cross-type order, and the existing `_gql_order_key` sorts within each (now homogeneous) rank. test_sql_builder.c assertions updated to the two-key form. This is the GQLITE-T-0340 comparator. It is standalone correctness groundwork: the mixed-type ORDER-BY TCK scenarios (ReturnOrderBy1 [11]/[12], WithOrderBy1 [21]/[22]) additionally require a renderable NaN value and path-through-UNWIND hydration, both deferred (tracked in GQLITE-T-0340). The rank UDF already detects the planned NaN sentinel (rank 8) for forward-compat. Rigorous full pass-set diff: zero regressions, zero newly passing. Mixed-type ORDER BY now verifiably sorts map<node<rel<list<string<bool<number. Unit 944/944, functional clean. 3721 pass unchanged. * nan: renderable NaN value for 0.0/0.0 (+1 TCK, GQLITE-T-0340 sub-feature B) SQLite collapses float division-by-zero to NULL and drops result subtypes across CTE boundaries, so a runtime NaN can neither survive as a native double nor be distinguished from null. Carry NaN as the private string GQL_NAN_SENTINEL (0x01 'N' 'a' 'N') — recognized by content, collision-proof (the leading control byte can't begin a real Cypher string). - transform_expr_ops.c: standalone `0.0/0.0` emits `(CHAR(1) || 'NaN')` (the comparison-operand case is still folded at compile time by the earlier is_cmp NaN block, so this only fires for non-comparison NaN constants). - executor_match.c create_property_agtype_value: map the sentinel to a float NaN agtype so entity/agtype result rendering emits the bare token. - agtype.c AGTV_FLOAT serializer: render isnan() as `NaN` (not "nan"). - extension.c plain formatter: print the sentinel as `NaN`. Combined with the orderability rank (sub-feature A), this fixes WithOrderBy1 [22]. ReturnOrderBy1 [11]/[12] and WithOrderBy1 [21] now order correctly and fail only on path-as-list-element rendering (sub-feature C, deferred). Rigorous full pass-set diff: zero regressions, +1 (WithOrderBy1 [22]). 3721 -> 3722. Unit 944/944, functional clean. * path: hydrate path as a list element under UNWIND (+3 TCK, GQLITE-T-0340 sub-feature C) A path variable used as a list element (`UNWIND [n, r, p, ...]`) rendered as the raw elem_ids array `[1,1,2]` instead of the `{nodes,rels}` path object, because the executor's elem_ids post-hydration (build_path_from_ids) only reaches top-level RETURN columns — not a value buried inside an UNWIND row. Added a transform-context flag `emit_hydrated_path`. When set, the path projection in transform_expression emits the self-contained fully-hydrated path JSON (reusing the pattern-comprehension builder: json_object('nodes', json_array(...), 'rels', json_array(...))) for non-single-varlen paths instead of elem_ids. transform_unwind sets the flag around each list-element expression transform and restores it after. Completes the GQLITE-T-0340 type-ordering stack: A (orderability rank, 22b00a7) + B (renderable NaN, d74210e) + C (this). Mixed-type ORDER BY now fully follows Cypher orderability map<node<rel<list<path<string<bool<number<NaN<null. Fixes ReturnOrderBy1 [11]/[12] and WithOrderBy1 [21] (WithOrderBy1 [22] landed with B). Struct field added -> angreal dev clean. 3722 -> 3725. Unit 944/944, functional clean. * metis: GQLITE-T-0340 type-ordering stack complete (A+B+C, +4 TCK) * func: labels()/type()/keys() accept type Any (+3 TCK) `labels(list[0])`, `type(list[0])`, `keys($param)` were rejected at compile time because these functions only accepted a bare node/rel/map identifier argument. openCypher types a list/subscript element (and a parameter) as Any, resolved at runtime. - labels()/type() on a non-identifier now emit new `_gql_labels` / `_gql_type` UDFs. They inspect the runtime value: a node/relationship JSON object yields its labels/type; null yields null; anything else raises `TypeError: InvalidArgumentValue` (sqlite3_result_error, which the harness classifies as TypeError). This satisfies both the accept-Any scenarios (Graph3 [6], Graph4 [5]) and the fail-on-invalid scenario (Graph3 [9]), which a naive json_extract would have regressed. - keys() on a parameter/expression emits a single-eval subquery over json_each, using the value's `properties` object when present (node/rel) else the value's own keys (map). Fixes Map3 [2]. Rigorous full pass-set diff: zero regressions, +3. 3725 -> 3728. Unit 944/944, functional clean. --------- Co-authored-by: Dylan Bobby Storey <dstorey@dstorey-personal-m3.local>
1 parent f04b1aa commit ef26bbd

24 files changed

Lines changed: 750 additions & 34 deletions
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
---
2+
id: cypher-cross-type-total-ordering
3+
level: task
4+
title: "Cypher cross-type total-ordering comparator (ORDER BY orderability)"
5+
short_code: "GQLITE-T-0340"
6+
created_at: 2026-05-29T18:22:22.437480+00:00
7+
updated_at: 2026-05-29T18:22:22.437480+00:00
8+
parent:
9+
blocked_by: []
10+
archived: false
11+
12+
tags:
13+
- "#task"
14+
- "#phase/backlog"
15+
- "#feature"
16+
17+
18+
exit_criteria_met: false
19+
initiative_id: NULL
20+
---
21+
22+
# Cypher cross-type total-ordering comparator (ORDER BY orderability)
23+
24+
## Objective
25+
26+
Make `ORDER BY` over heterogeneous values follow Cypher's total orderability so the
27+
mixed-type ORDER-BY scenarios pass. Target TCK scenarios:
28+
- ReturnOrderBy1 [11]/[12] — ORDER BY distinct types asc/desc
29+
- WithOrderBy1 [21]/[22] — sort distinct types asc/desc
30+
- (related) Comparison2 [3] — needs WITH-WHERE input-scope, tracked separately
31+
32+
Branch: `i0339-next6` (PR #87). This is the "full stack" follow-on after the
33+
+11 batch already on that branch.
34+
35+
## Cypher orderability (ascending)
36+
37+
map < node < rel < list < path < string < bool < number < NaN < null
38+
39+
(null sorts LAST in ascending. NaN sorts after all other numbers.)
40+
41+
## Three stacked sub-features (all required before any scenario passes)
42+
43+
### A. Orderability rank key — `_gql_order_rank(value)` + two-column ORDER BY
44+
- New UDF returns int rank 0..9 by Cypher type. Detection from the SQLite value:
45+
- NULL -> 9 (null). (NaN must be a non-NULL sentinel, see B.)
46+
- INTEGER/REAL -> 7 (number).
47+
- TEXT: boolean subtype or 'true'/'false' -> 6; starts `{` -> inspect keys
48+
(nodes&rels -> path 4; labels(&id) -> node 1; type&startNode* -> rel 2; else map 0);
49+
starts `[` -> list 3; NaN sentinel -> 8; else string 5.
50+
- Change `sql_order_by` (src/backend/transform/sql_builder.c:472) to emit
51+
`_gql_order_rank(expr) <dir>, _gql_order_key(expr) <dir>` — rank groups by type
52+
(cross-type order), existing `_gql_order_key` sorts within type (homogeneous,
53+
so SQLite-native sort is correct). Also transform_with.c:688 ORDER BY path.
54+
- RISK: changes cross-type ORDER BY for ALL queries. Must run rigorous pass-set diff.
55+
56+
### B. NaN sentinel value (survives CTE; renders as NaN; ranks 8)
57+
- Subtypes DO NOT survive CTE/subquery boundaries in SQLite (verified). TEXT
58+
CONTENT does. So NaN = a private sentinel STRING recognized by content, not subtype.
59+
- Emit the sentinel for `0.0/0.0` (compile-time detectable; transform_unwind list
60+
arm + transform_expr_ops division). Formatter (src/extension.c) renders the
61+
sentinel as unquoted `NaN`. `_gql_order_rank` detects it -> rank 8.
62+
- Use an unlikely sentinel (e.g. control-char prefix) to avoid colliding with the
63+
literal Cypher string "NaN".
64+
65+
### C. Path hydration through UNWIND list
66+
- `UNWIND [..., p, ...]` renders path `p` as `[1,1,2]` instead of the path object
67+
`{"nodes":[...],"rels":[...]}` (direct `RETURN p` renders correctly). Fix the
68+
path-as-list-element transform to emit the full path object.
69+
70+
## Status Updates
71+
72+
- 2026-05-29: Prerequisite landed on branch (commit 71fe525): UNWIND of an
73+
entity-containing list no longer crashes (`no such column`); the 4 target
74+
scenarios moved error->fail.
75+
- 2026-05-29: **Sub-feature A (orderability rank) DONE**`_gql_order_rank` UDF
76+
+ two-column ORDER BY (`_gql_order_rank(e), _gql_order_key(e)`) in sql_builder.c
77+
and transform_with.c. Verified: mixed-type ORDER BY now sorts
78+
map<node<rel<list<string<bool<number correctly; unit 944/944; rigorous
79+
pass-set diff = zero regressions, zero newly passing (target scenarios still
80+
need B+C). Committed as correctness groundwork.
81+
- 2026-05-29: **Sub-feature B (NaN value) attempted, reverted.** NaN sentinel
82+
`\x01NaN` works for the ORDER rank (rank 8 detection kept as forward-compat in
83+
the UDF). But RENDERING it as `NaN` requires patching 3+ independent formatter
84+
paths in extension.c, AND the UNWIND result-collection path delivers the value
85+
already JSON-quoted (`"\x01NaN"`) to the formatter — a content sentinel can't be
86+
cleanly intercepted everywhere. Reverted the division->sentinel emission + the
87+
one formatter branch. NEXT: introduce a single shared scalar-render helper in
88+
extension.c, then re-add the sentinel emission + render in that one place.
89+
- 2026-05-29: **Sub-feature C (path-through-UNWIND hydration) NOT started.**
90+
`UNWIND [..., p, ...]` renders the path as `[1,1,2]` (elem ids) instead of the
91+
path object; direct `RETURN p` is correct. Lives in the UNWIND list-element
92+
path-expr / build_path_from_ids interaction.
93+
- 2026-05-29: **Sub-feature B (NaN value) DONE** (commit d74210e, +1). NaN carried
94+
as the private string GQL_NAN_SENTINEL (0x01 'N' 'a' 'N'); standalone `0.0/0.0`
95+
emits `(CHAR(1)||'NaN')`; agtype `create_property_agtype_value` maps it to a
96+
float NaN whose AGTV_FLOAT serializer prints `NaN`; plain formatter prints it
97+
too. Fixed WithOrderBy1 [22]. Rigorous diff: zero regressions.
98+
- 2026-05-29: **Sub-feature C (path-as-list-element) DONE** (commit, +3). New
99+
context flag `emit_hydrated_path` makes the path projection emit the full
100+
{nodes,rels} object inline (reusing the comprehension builder) for non-varlen
101+
paths; transform_unwind sets it around each list-element transform. Fixed
102+
ReturnOrderBy1 [11]/[12], WithOrderBy1 [21].
103+
- 2026-05-29: **STACK COMPLETE.** All four target scenarios pass; mixed-type
104+
ORDER BY follows Cypher orderability map<node<rel<list<path<string<bool<number
105+
<NaN<null. 3721 -> 3725 (+4: B +1, C +3; A +0 groundwork). Two full TCK runs
106+
confirm stability and zero regressions (the ReturnOrderBy1 [1] entry seen in an
107+
interim `comm` was a baseline-run transient — the scenario is deterministic and
108+
passes). Task can be marked done.

docs/testing/semantic-coverage-matrix.md

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,3 +399,152 @@ unit 944/944; functional clean):
399399
`%expect 15` / `%expect-rr 3`). The brace form **with** an inner `WHERE`
400400
([2]/[4]) and the full-query/aggregation/nested forms (ExistentialSubquery2/3)
401401
remain unsupported — they need inner-variable registration and are deferred.
402+
403+
## Coverage update (2026-05-29) — existential subquery brace form with inner WHERE
404+
405+
`cypher_gram.y` + `cypher_ast.{h,c}` + `transform_expr_predicate.c` +
406+
`transform_validate.c`. Verified via the TCK harness (3710 -> 3712, zero
407+
regressions; unit 944/944; functional clean):
408+
409+
- **`WHERE exists { (n)-->(m) WHERE n.prop = m.prop }` evaluates** correctly
410+
(ExistentialSubquery1 [2]/[4]). The brace form may introduce fresh inner
411+
variables (`m`, `r`). Implementation:
412+
- `cypher_exists_expr` gains `where_clause` (the inner predicate) and
413+
`is_subquery` (brace vs paren). Grammar rule `EXISTS '{' pattern_list
414+
WHERE expr '}'` sets both.
415+
- The `EXISTS_TYPE_PATTERN` emitter registers the inner pattern's *new*
416+
node/rel variables against their subquery aliases (`n%d` / `e%d`) before
417+
transforming the inner WHERE, folds it in as ` AND (<expr>)`, then
418+
`transform_var_truncate_to`s back to the saved scope.
419+
- The WHERE-pattern fresh-variable validator skips `is_subquery` EXISTS
420+
nodes (the brace form legitimately scopes fresh vars; the paren
421+
pattern-predicate form keeps the stricter rule).
422+
- Full-query/aggregation/nested existential subqueries (ExistentialSubquery2
423+
[1]/[2], ExistentialSubquery3) remain deferred.
424+
425+
## Coverage update (2026-05-29) — bulk SET from an entity (SET r = a)
426+
427+
`executor_set.c`. Verified via the TCK harness (3712 -> 3714, zero regressions;
428+
unit 944/944; functional clean):
429+
430+
- **`SET <entity> = <entity>` / `+= <entity>` copies all properties** from the
431+
source entity to the destination (Merge6 [6] `ON CREATE SET r = a`, Merge7 [4]
432+
`ON MATCH SET r = a`). The bulk-SET handler previously accepted only a map
433+
literal or JSON parameter as RHS and errored on an identifier. Added a
434+
`copy_entity_properties` helper that reads the source's five property-type
435+
tables and re-sets each on the destination via the typed schema setters
436+
(incrementing `properties_set`); replace-mode (`=`) reuses the existing
437+
delete-all-first step. Merge8 [1] and Merge9 [3] still fail on the unrelated
438+
multi-row MATCH+MERGE cartesian-iteration gap (deferred).
439+
440+
## Coverage update (2026-05-29) — NaN constant comparison semantics
441+
442+
`transform_expr_ops.c`. Verified via the TCK harness (3714 -> 3721, rigorous
443+
full pass-set diff: zero regressions, 7 newly passing; unit 944/944; functional
444+
clean):
445+
446+
- **`0.0 / 0.0` comparisons follow Cypher NaN semantics** (Comparison1 [8],
447+
Comparison2 [5]). SQLite collapses float division-by-zero to NULL at the
448+
operator level, so NaN cannot survive as a native double nor be told apart
449+
from null at runtime. Every NaN TCK scenario uses the literal constant
450+
`0.0 / 0.0`, so it is detected at compile time (`is_nan_const`: DIV of two
451+
zero-valued numeric literals) and the comparison emits the correct raw SQL
452+
truth value (`1`/`0`/`NULL`, matching a native comparison's shape so any
453+
enclosing boolean wrapper evaluates it right):
454+
- `NaN = x` -> false, `NaN <> x` -> true (x non-null; vs null -> null)
455+
- `NaN </<=/>/>= number-or-NaN` -> false; vs other type -> null (cross-type
456+
ordering undefined).
457+
Falls through untouched when the other operand isn't a compile-time literal.
458+
NaN flowing through a variable (ReturnOrderBy1 [11]/[12], Comparison2 [3])
459+
needs the full cross-type total-ordering comparator and is deferred.
460+
461+
## Coverage update (2026-05-29) — UNWIND of a list containing bound entities
462+
463+
`transform_unwind.c`. Verified via the TCK harness (3721 -> 3721 pass; 4
464+
scenarios move error -> fail; rigorous full pass-set diff: zero regressions;
465+
unit 944/944; functional clean):
466+
467+
- **`MATCH ... UNWIND [n, r, p, ...] AS x` no longer crashes** with
468+
`no such column: _gql_default_alias_0.id`. The LIST branch only emitted a
469+
per-arm `FROM` when a WITH projection was carried (`has_carry`); pre-WITH
470+
MATCH entity variables are excluded from carry, so an entity-referencing list
471+
produced UNION arms with no FROM and unbound aliases. The branch now splices
472+
the prior MATCH's FROM tables (and WHERE) into each arm — mirroring the
473+
function-call branch — when `inner_sql` is a splicable `SELECT * FROM ...`.
474+
This is a **prerequisite** for the ORDER-BY type-ordering scenarios
475+
(ReturnOrderBy1 [11]/[12], WithOrderBy1 [21]/[22]), which now produce output
476+
but still fail pending: (a) a Cypher total-orderability key in
477+
`_gql_order_key` (map<node<rel<list<path<string<bool<number<NaN<null vs the
478+
current SQLite-native order), (b) a distinguishable NaN value/rendering (NaN
479+
currently collapses to NULL), and (c) path hydration through UNWIND. Those
480+
remain deferred. Comparison2 [3] additionally needs WITH-WHERE input-scope
481+
referencing (`WHERE i <> j` after a projection that drops `i`/`j`).
482+
483+
## Coverage update (2026-05-29) — Cypher orderability type-rank in ORDER BY
484+
485+
`sql_builder.c`, `transform_with.c`, `udf_helpers.c`, `udf_register.c`. Verified
486+
via the TCK harness (3721 -> 3721; rigorous full pass-set diff: zero regressions,
487+
zero newly passing; unit 944/944; functional clean):
488+
489+
- **ORDER BY over mixed types now follows Cypher orderability**
490+
(map < node < rel < list < path < string < bool < number < NaN < null) instead
491+
of SQLite's native storage-class order. New `_gql_order_rank(value)` UDF returns
492+
the type rank 0..9 (entities/maps/paths told apart by their distinctive JSON
493+
keys); `sql_order_by` and the WITH ORDER-BY path now emit
494+
`_gql_order_rank(e) <dir>, _gql_order_key(e) <dir>` — rank groups by type,
495+
`_gql_order_key` orders within the (homogeneous) rank. This is the
496+
GQLITE-T-0340 comparator: standalone groundwork for the mixed-type ORDER-BY
497+
scenarios (ReturnOrderBy1 [11]/[12], WithOrderBy1 [21]/[22]), which also need
498+
a renderable NaN value and path-through-UNWIND hydration (both deferred —
499+
see GQLITE-T-0340). The rank UDF already detects the planned NaN sentinel
500+
(rank 8) for forward-compat.
501+
502+
## Coverage update (2026-05-29) — renderable NaN value (GQLITE-T-0340 sub-feature B)
503+
504+
`transform_expr_ops.c`, `executor_match.c`, `agtype.c`, `extension.c`. Verified
505+
via the TCK harness (3721 -> 3722, rigorous full pass-set diff: zero regressions,
506+
+1 WithOrderBy1 [22]; unit 944/944; functional clean):
507+
508+
- **`0.0 / 0.0` now produces a renderable NaN value** that prints as the bare
509+
token `NaN` and orders at rank 8. SQLite collapses float `/0` to NULL and drops
510+
subtypes across CTE boundaries, so NaN is carried as the private string
511+
`GQL_NAN_SENTINEL` (0x01 'N' 'a' 'N') — recognized by content, collision-proof.
512+
Standalone `0.0/0.0` emits `(CHAR(1) || 'NaN')`; the agtype layer
513+
(`create_property_agtype_value`) maps the sentinel to a float NaN whose
514+
serializer prints `NaN`; the plain formatter prints the sentinel as `NaN`.
515+
Combined with the orderability rank (sub-feature A) this fixes WithOrderBy1
516+
[22]. ReturnOrderBy1 [11]/[12], WithOrderBy1 [21] now order correctly and only
517+
fail on path-as-list-element rendering (sub-feature C, deferred).
518+
519+
## Coverage update (2026-05-29) — path-as-list-element hydration (GQLITE-T-0340 sub-feature C)
520+
521+
`cypher_transform.h`, `transform_return.c`, `transform_unwind.c`. Verified via the
522+
TCK harness (3722 -> 3725; unit 944/944; functional clean):
523+
524+
- **A path variable used as a list element under UNWIND now renders as the full
525+
`{nodes,rels}` object** instead of the raw `elem_ids` array. The executor's
526+
elem_ids post-hydration only reaches top-level RETURN columns, not values buried
527+
in an UNWIND row. New context flag `emit_hydrated_path` makes the path
528+
projection emit the self-contained hydrated JSON (reusing the pattern-
529+
comprehension builder) for non-varlen paths; `transform_unwind` sets it around
530+
each list-element transform. Completes the GQLITE-T-0340 stack (A rank + B NaN +
531+
C path): fixes ReturnOrderBy1 [11]/[12] and WithOrderBy1 [21] (WithOrderBy1 [22]
532+
landed with B). Mixed-type ORDER BY now fully follows Cypher orderability
533+
map<node<rel<list<path<string<bool<number<NaN<null.
534+
535+
## Coverage update (2026-05-30) — labels()/type()/keys() accept type Any
536+
537+
`transform_func_entity.c`, `transform_func_aggregate.c`, `udf_helpers.c`,
538+
`udf_register.c`. Verified via the TCK harness (3725 -> 3728, rigorous full
539+
pass-set diff: zero regressions, +3; unit 944/944; functional clean):
540+
541+
- **`labels()`, `type()`, `keys()` accept a statically-Any argument** (e.g.
542+
`labels(list[0])`, `type(list[0])`, `keys($param)`) — previously rejected at
543+
compile time unless the argument was a bare node/rel identifier. labels()/type()
544+
on a non-identifier now route through new `_gql_labels` / `_gql_type` UDFs that
545+
inspect the runtime value: a node/relationship JSON object yields its
546+
labels/type, null yields null, and anything else raises a runtime
547+
`TypeError: InvalidArgumentValue` — so the negative scenarios (Graph3 [9]) still
548+
error. keys() on a parameter/expression emits a single-eval subquery over
549+
json_each, using the value's `properties` object when present (node/rel) else
550+
its own keys (map). Fixes Graph3 [6], Graph4 [5], Map3 [2].

src/backend/executor/agtype.c

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
#include <stdlib.h>
77
#include <string.h>
88
#include <stdarg.h>
9+
#include <math.h>
910
#include "executor/agtype.h"
1011
#include "parser/cypher_debug.h"
1112

@@ -976,8 +977,14 @@ char* agtype_value_to_string(agtype_value *val)
976977
case AGTV_FLOAT: {
977978
result = malloc(40);
978979
if (result) {
979-
/* %.17g preserves full double precision (TCK expects). */
980-
snprintf(result, 40, "%.17g", val->val.float_value);
980+
if (isnan(val->val.float_value)) {
981+
/* Cypher renders NaN as the bare token `NaN` (GQLITE-T-0340),
982+
* not the platform's "nan"/"-nan". */
983+
snprintf(result, 40, "NaN");
984+
} else {
985+
/* %.17g preserves full double precision (TCK expects). */
986+
snprintf(result, 40, "%.17g", val->val.float_value);
987+
}
981988
}
982989
break;
983990
}

src/backend/executor/executor_match.c

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
#include <stdlib.h>
88
#include <string.h>
99
#include <errno.h>
10+
#include <math.h>
1011
#include <limits.h>
1112

1213
#include "executor/executor_internal.h"
@@ -574,9 +575,16 @@ agtype_value* create_property_agtype_value(const char* value)
574575
if (!value) {
575576
return agtype_value_create_null();
576577
}
577-
578+
579+
/* NaN sentinel (GQLITE-T-0340): carried as the private string
580+
* GQL_NAN_SENTINEL. Materialize as a float NaN so the serializer renders
581+
* the bare token `NaN` instead of a quoted control-char string. */
582+
if (strcmp(value, GQL_NAN_SENTINEL) == 0) {
583+
return agtype_value_create_float(NAN);
584+
}
585+
578586
/* Try to detect the data type from the string value */
579-
587+
580588
/* Check for boolean values */
581589
if (strcmp(value, "true") == 0) {
582590
return agtype_value_create_bool(true);

0 commit comments

Comments
 (0)