From 70249069093716593953ba7fe808d9cec8cfd3ed Mon Sep 17 00:00:00 2001 From: Dylan Bobby Storey Date: Fri, 29 May 2026 07:46:45 -0400 Subject: [PATCH 1/9] 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 ()`, 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. --- docs/testing/semantic-coverage-matrix.md | 22 +++++++++++ src/backend/parser/cypher_ast.c | 3 ++ src/backend/parser/cypher_gram.y | 15 ++++++- .../transform/transform_expr_predicate.c | 39 +++++++++++++++++++ src/backend/transform/transform_validate.c | 10 ++++- src/include/parser/cypher_ast.h | 5 +++ 6 files changed, 91 insertions(+), 3 deletions(-) diff --git a/docs/testing/semantic-coverage-matrix.md b/docs/testing/semantic-coverage-matrix.md index a30b672b..995d033c 100644 --- a/docs/testing/semantic-coverage-matrix.md +++ b/docs/testing/semantic-coverage-matrix.md @@ -399,3 +399,25 @@ unit 944/944; functional clean): `%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 ()`, then + `transform_var_truncate_to`s 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. diff --git a/src/backend/parser/cypher_ast.c b/src/backend/parser/cypher_ast.c index ffc9596e..5409b684 100644 --- a/src/backend/parser/cypher_ast.c +++ b/src/backend/parser/cypher_ast.c @@ -417,6 +417,9 @@ void ast_node_free(ast_node *node) } else if (exists_expr->expr_type == EXISTS_TYPE_PROPERTY) { ast_node_free(exists_expr->expr.property); } + if (exists_expr->where_clause) { + ast_node_free(exists_expr->where_clause); + } } break; diff --git a/src/backend/parser/cypher_gram.y b/src/backend/parser/cypher_gram.y index 924f6ac2..a05a4633 100644 --- a/src/backend/parser/cypher_gram.y +++ b/src/backend/parser/cypher_gram.y @@ -1506,7 +1506,20 @@ function_call: * (ExistentialSubquery1 [1]/[3]). Reuses the pattern-existence * transform; correlated outer variables resolve via the * EXISTS_TYPE_PATTERN emitter's outer-alias lookup. */ - $$ = (ast_node*)make_exists_pattern_expr($3, @1.first_line); + cypher_exists_expr *ee = make_exists_pattern_expr($3, @1.first_line); + if (ee) ee->is_subquery = true; + $$ = (ast_node*)ee; + } + | EXISTS '{' pattern_list WHERE expr '}' + { + /* Existential subquery with inner WHERE: + * EXISTS { (n)-->(m) WHERE n.prop = m.prop } + * (ExistentialSubquery1 [2]/[4]). The inner predicate is stored + * on where_clause and folded into the subquery by the transform, + * which registers the inner pattern variables first. */ + cypher_exists_expr *ee = make_exists_pattern_expr($3, @1.first_line); + if (ee) { ee->where_clause = $5; ee->is_subquery = true; } + $$ = (ast_node*)ee; } | EXISTS '(' IDENTIFIER '.' IDENTIFIER ')' { diff --git a/src/backend/transform/transform_expr_predicate.c b/src/backend/transform/transform_expr_predicate.c index 2fabcbdb..1afd107f 100644 --- a/src/backend/transform/transform_expr_predicate.c +++ b/src/backend/transform/transform_expr_predicate.c @@ -221,6 +221,14 @@ int transform_exists_expression(cypher_transform_context *ctx, cypher_exists_exp bool node_is_external[10]; /* Track which nodes are from outer context */ int node_count = 0; + /* Brace form may carry an inner WHERE referencing the + * subquery's own pattern variables (e.g. `m`, `r`). + * Register those inner variables against their subquery + * aliases so transform_expression can resolve them, then + * truncate the var context back once the subquery SQL is + * emitted (mirrors pattern-comprehension save/restore). */ + int exists_saved_vars = transform_var_count(ctx->var_ctx); + /* Process each element in the path */ for (int i = 0; i < path->elements->count; i++) { ast_node *element = path->elements->items[i]; @@ -249,16 +257,31 @@ int transform_exists_expression(cypher_transform_context *ctx, cypher_exists_exp "n%d", node_count); append_sql(ctx, "nodes AS %s", node_aliases[node_count]); node_is_external[node_count] = false; + /* Register the inner node var so an inner WHERE + * can reference it (ExistentialSubquery1 [2]). */ + if (node->variable && exists_expr->where_clause) { + transform_var_register_node(ctx->var_ctx, node->variable, + node_aliases[node_count], NULL); + } first_table = false; } node_count++; } else if (element->type == AST_NODE_REL_PATTERN && i > 0) { /* Relationship pattern: -[variable:TYPE]-> */ + cypher_rel_pattern *rel = (cypher_rel_pattern*)element; if (!first_table) { append_sql(ctx, ", "); } append_sql(ctx, "edges AS e%d", i/2); /* Relationships are at odd indices */ + /* Register the inner rel var for the inner WHERE + * (ExistentialSubquery1 [4]: `type(r) = 'NA'`). */ + if (rel->variable && exists_expr->where_clause) { + char ealias[16]; + snprintf(ealias, sizeof(ealias), "e%d", i/2); + transform_var_register_edge(ctx->var_ctx, rel->variable, + ealias, rel->type); + } first_table = false; } } @@ -334,6 +357,22 @@ int transform_exists_expression(cypher_transform_context *ctx, cypher_exists_exp } } } + + /* Fold the inner WHERE predicate into the subquery, + * now that the pattern's own variables are registered. + * ExistentialSubquery1 [2]/[4]. */ + if (exists_expr->where_clause) { + append_sql(ctx, first_condition ? "(" : " AND ("); + if (transform_expression(ctx, exists_expr->where_clause) < 0) { + transform_var_truncate_to(ctx->var_ctx, exists_saved_vars); + return -1; + } + append_sql(ctx, ")"); + first_condition = false; + } + + /* Restore the outer variable scope. */ + transform_var_truncate_to(ctx->var_ctx, exists_saved_vars); } else { /* Empty pattern - should not happen */ append_sql(ctx, "SELECT 0"); diff --git a/src/backend/transform/transform_validate.c b/src/backend/transform/transform_validate.c index 29b7e56e..ea684885 100644 --- a/src/backend/transform/transform_validate.c +++ b/src/backend/transform/transform_validate.c @@ -1579,8 +1579,14 @@ static int validate_where_pattern_vars(ast_node *expr, const name_set *bound, } } } - for (int i = 0; i < ex->expr.pattern->count; i++) { - if (validate_where_pattern_vars(ex->expr.pattern->items[i], bound, error_message) < 0) return -1; + /* The brace form `EXISTS { (n)-->(m) ... }` is an existential + * subquery — it MAY introduce fresh pattern variables (`m`), so + * skip the fresh-variable recursion for it. The paren + * pattern-predicate form keeps the stricter rule. */ + if (!ex->is_subquery) { + for (int i = 0; i < ex->expr.pattern->count; i++) { + if (validate_where_pattern_vars(ex->expr.pattern->items[i], bound, error_message) < 0) return -1; + } } } return 0; diff --git a/src/include/parser/cypher_ast.h b/src/include/parser/cypher_ast.h index bffa481a..68d671dd 100644 --- a/src/include/parser/cypher_ast.h +++ b/src/include/parser/cypher_ast.h @@ -395,6 +395,11 @@ typedef struct cypher_exists_expr { ast_list *pattern; /* For EXISTS((pattern)) - list of path elements */ ast_node *property; /* For EXISTS(property) - property access expression */ } expr; + ast_node *where_clause; /* Optional inner WHERE for brace form + * EXISTS { (n)-->(m) WHERE } (NULL otherwise) */ + bool is_subquery; /* true for the brace form EXISTS { ... }, which + * (unlike the paren pattern-predicate) MAY + * introduce fresh pattern variables */ } cypher_exists_expr; /* List predicate: all/any/none/single(x IN list WHERE predicate) */ From 2d9f83a33603f738e2dc9b84998db20848b01dba Mon Sep 17 00:00:00 2001 From: Dylan Bobby Storey Date: Fri, 29 May 2026 08:05:58 -0400 Subject: [PATCH 2/9] 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. --- docs/testing/semantic-coverage-matrix.md | 15 ++++ src/backend/executor/executor_set.c | 91 +++++++++++++++++++++++- 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/docs/testing/semantic-coverage-matrix.md b/docs/testing/semantic-coverage-matrix.md index 995d033c..f0761708 100644 --- a/docs/testing/semantic-coverage-matrix.md +++ b/docs/testing/semantic-coverage-matrix.md @@ -421,3 +421,18 @@ regressions; unit 944/944; functional clean): 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 = ` / `+= ` 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). diff --git a/src/backend/executor/executor_set.c b/src/backend/executor/executor_set.c index b91eb13a..82bbc459 100644 --- a/src/backend/executor/executor_set.c +++ b/src/backend/executor/executor_set.c @@ -583,6 +583,65 @@ int execute_set_clause(cypher_executor *executor, cypher_set *set, cypher_result return -1; } +/* Copy all properties from a source entity (node or edge) onto a destination + * entity. Implements the entity RHS of bulk SET — `SET r = a` / `SET r += a` + * (Merge6 [6], Merge7 [4], Merge8 [1], Merge9 [3]). Reads each of the five + * property-type tables for the source and re-sets them on the destination via + * the typed schema setters, incrementing result->properties_set per copy. */ +static int copy_entity_properties(cypher_executor *executor, + int src_id, bool src_is_edge, + int dst_id, bool dst_is_edge, + cypher_result *result) +{ + const char *node_tbls[] = {"node_props_text", "node_props_int", + "node_props_real", "node_props_bool", + "node_props_json"}; + const char *edge_tbls[] = {"edge_props_text", "edge_props_int", + "edge_props_real", "edge_props_bool", + "edge_props_json"}; + const property_type types[] = {PROP_TYPE_TEXT, PROP_TYPE_INTEGER, + PROP_TYPE_REAL, PROP_TYPE_BOOLEAN, + PROP_TYPE_JSON}; + const char **tbls = src_is_edge ? edge_tbls : node_tbls; + const char *idcol = src_is_edge ? "edge_id" : "node_id"; + + for (int t = 0; t < 5; t++) { + char sql[512]; + snprintf(sql, sizeof(sql), + "SELECT pk.key, p.value FROM %s p " + "JOIN property_keys pk ON p.key_id = pk.id WHERE p.%s = %d", + tbls[t], idcol, src_id); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(executor->db, sql, -1, &st, NULL) != SQLITE_OK) continue; + while (sqlite3_step(st) == SQLITE_ROW) { + const char *key = (const char*)sqlite3_column_text(st, 0); + if (!key) continue; + property_type pt = types[t]; + const void *pv = NULL; + int64_t ival = 0; double rval = 0; int bval = 0; + switch (pt) { + case PROP_TYPE_TEXT: + case PROP_TYPE_JSON: + pv = (const char*)sqlite3_column_text(st, 1); + break; + case PROP_TYPE_INTEGER: + ival = sqlite3_column_int64(st, 1); pv = &ival; break; + case PROP_TYPE_REAL: + rval = sqlite3_column_double(st, 1); pv = &rval; break; + case PROP_TYPE_BOOLEAN: + bval = sqlite3_column_int(st, 1); pv = &bval; break; + } + if (!pv) continue; + int rc = dst_is_edge + ? cypher_schema_set_edge_property(executor->schema_mgr, dst_id, key, pt, pv) + : cypher_schema_set_node_property(executor->schema_mgr, dst_id, key, pt, pv); + if (rc == 0) result->properties_set++; + } + sqlite3_finalize(st); + } + return 0; +} + /* Execute SET operations with variable bindings */ int execute_set_operations(cypher_executor *executor, cypher_set *set, variable_map *var_map, cypher_result *result) { @@ -643,13 +702,20 @@ int execute_set_operations(cypher_executor *executor, cypher_set *set, variable_ return -1; } - /* Resolve the map expression — map literal or parameter */ + /* Resolve the map expression — map literal, parameter, or an + * entity identifier (SET r = a copies a's properties). */ cypher_map *map = NULL; char *resolved_json = NULL; + cypher_identifier *src_entity = NULL; /* non-NULL => entity-copy RHS */ property_value param_pv; property_value_init(¶m_pv); - if (item->expr->type == AST_NODE_MAP) { + if (item->expr->type == AST_NODE_IDENTIFIER) { + /* SET = / += — copy all properties + * from the source entity (Merge6 [6] etc.). Handled after the + * destination entity id and replace-mode delete are resolved. */ + src_entity = (cypher_identifier*)item->expr; + } else if (item->expr->type == AST_NODE_MAP) { map = (cypher_map*)item->expr; } else if (item->expr->type == AST_NODE_PARAMETER && executor->params_json) { cypher_parameter *param = (cypher_parameter*)item->expr; @@ -707,6 +773,27 @@ int execute_set_operations(cypher_executor *executor, cypher_set *set, variable_ } } + /* Entity-copy RHS: SET = / += . The replace-mode + * delete above already cleared dst for `=`; now copy src's props. */ + if (src_entity) { + bool src_is_edge = is_variable_edge(var_map, src_entity->name); + int src_id = src_is_edge + ? get_variable_edge_id(var_map, src_entity->name) + : get_variable_node_id(var_map, src_entity->name); + if (src_id < 0) { + char error[256]; + snprintf(error, sizeof(error), + "Unbound source variable in bulk SET copy: %s", src_entity->name); + set_result_error(result, error); + property_value_free(¶m_pv); + return -1; + } + copy_entity_properties(executor, src_id, src_is_edge, + entity_id, is_edge, result); + property_value_free(¶m_pv); + continue; + } + /* Set each property from the map */ if (map && map->pairs) { for (int j = 0; j < map->pairs->count; j++) { From 75adfe86e95b25c166ab92997692654ba31b630d Mon Sep 17 00:00:00 2001 From: Dylan Bobby Storey Date: Fri, 29 May 2026 12:40:00 -0400 Subject: [PATCH 3/9] expr: NaN constant comparison semantics for 0.0/0.0 (+7 TCK) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/testing/semantic-coverage-matrix.md | 21 ++++++ src/backend/transform/transform_expr_ops.c | 88 ++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/docs/testing/semantic-coverage-matrix.md b/docs/testing/semantic-coverage-matrix.md index f0761708..01daabb9 100644 --- a/docs/testing/semantic-coverage-matrix.md +++ b/docs/testing/semantic-coverage-matrix.md @@ -436,3 +436,24 @@ unit 944/944; functional clean): (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. diff --git a/src/backend/transform/transform_expr_ops.c b/src/backend/transform/transform_expr_ops.c index 03eada8a..4b4c80ee 100644 --- a/src/backend/transform/transform_expr_ops.c +++ b/src/backend/transform/transform_expr_ops.c @@ -21,6 +21,59 @@ #include "transform/transform_func_dispatch.h" #include "parser/cypher_debug.h" +/* --- NaN constant handling (Comparison1 [8], Comparison2 [5]) ------------- + * SQLite collapses float division-by-zero to NULL at the operator level, so + * a NaN value cannot survive as a native double or be told apart from null + * at runtime. Every TCK scenario that exercises NaN does so via the literal + * constant `0.0 / 0.0`, so we detect that shape at compile time and emit the + * correct Cypher comparison result directly. + * + * Cypher NaN semantics: + * NaN = x -> false (x non-null; null -> null) + * NaN <> x -> true (x non-null; null -> null) + * NaN />= number/NaN -> false + * NaN />= other-type -> null (cross-type ordering is undefined) + */ + +/* A numeric literal equal to zero (0 or 0.0). */ +static bool is_zero_numeric_literal(ast_node *e) +{ + if (!e || e->type != AST_NODE_LITERAL) return false; + cypher_literal *lit = (cypher_literal *)e; + if (lit->literal_type == LITERAL_INTEGER) return lit->value.integer == 0; + if (lit->literal_type == LITERAL_DECIMAL) return lit->value.decimal == 0.0; + return false; +} + +/* The constant NaN expression `0.0 / 0.0` (two zero-valued numeric literals). */ +static bool is_nan_const(ast_node *e) +{ + if (!e || e->type != AST_NODE_BINARY_OP) return false; + cypher_binary_op *b = (cypher_binary_op *)e; + return b->op_type == BINARY_OP_DIV && + is_zero_numeric_literal(b->left) && + is_zero_numeric_literal(b->right); +} + +/* Compile-time type class of the operand a NaN is being compared against. */ +typedef enum { NAN_OTHER_NUMBER, NAN_OTHER_STRING, NAN_OTHER_NULL, + NAN_OTHER_NONNULL, NAN_OTHER_UNKNOWN } nan_other_class; + +static nan_other_class classify_nan_other(ast_node *e) +{ + if (is_nan_const(e)) return NAN_OTHER_NUMBER; /* NaN vs NaN behaves like a number */ + if (!e || e->type != AST_NODE_LITERAL) return NAN_OTHER_UNKNOWN; + cypher_literal *lit = (cypher_literal *)e; + switch (lit->literal_type) { + case LITERAL_INTEGER: + case LITERAL_DECIMAL: return NAN_OTHER_NUMBER; + case LITERAL_STRING: return NAN_OTHER_STRING; + case LITERAL_NULL: return NAN_OTHER_NULL; + case LITERAL_BOOLEAN: return NAN_OTHER_NONNULL; + } + return NAN_OTHER_UNKNOWN; +} + /* Transform label expression (e.g., n:Person) */ int transform_label_expression(cypher_transform_context *ctx, cypher_label_expr *label_expr) { @@ -177,6 +230,41 @@ int transform_binary_operation(cypher_transform_context *ctx, cypher_binary_op * } } + /* NaN constant comparisons (Comparison1 [8], Comparison2 [5]). Fires only + * when one operand is the literal `0.0 / 0.0` and the other is a + * compile-time-classifiable literal (or another NaN const); otherwise we + * fall through to the normal paths untouched. */ + if (is_cmp) { + ast_node *nan_side = NULL, *other = NULL; + if (is_nan_const(binary_op->left)) { nan_side = binary_op->left; other = binary_op->right; } + else if (is_nan_const(binary_op->right)) { nan_side = binary_op->right; other = binary_op->left; } + if (nan_side) { + nan_other_class oc = classify_nan_other(other); + if (oc != NAN_OTHER_UNKNOWN) { + /* Emit a raw SQL truth value (1 / 0 / NULL) — the same shape a + * native comparison yields — so any enclosing boolean wrapper + * (`_gql_bool_str(CASE WHEN ... )`, a WHERE filter, etc.) + * evaluates it correctly. Emitting a tagged 'true'/'false' + * text here would be re-read as a falsy condition. */ + const char *result_sql; /* "1" | "0" | "NULL" */ + switch (binary_op->op_type) { + case BINARY_OP_EQ: + result_sql = (oc == NAN_OTHER_NULL) ? "NULL" : "0"; + break; + case BINARY_OP_NEQ: + result_sql = (oc == NAN_OTHER_NULL) ? "NULL" : "1"; + break; + default: /* ordering: <, <=, >, >= */ + result_sql = (oc == NAN_OTHER_NUMBER) ? "0" : "NULL"; + break; + } + append_sql(ctx, "%s", result_sql); + ctx->in_comparison = was_in_comparison; + return 0; + } + } + } + /* Handle list/map equality with Cypher three-valued semantics via the * _gql_eq() UDF. Triggered only when at least one operand is a literal * list/map (which is when the existing JSON-string equality breaks down From 71fe525f9ed16193160a5f7b0135a6e482112e55 Mon Sep 17 00:00:00 2001 From: Dylan Bobby Storey Date: Fri, 29 May 2026 14:18:08 -0400 Subject: [PATCH 4/9] unwind: splice MATCH FROM into UNWIND of entity-containing list (correctness, +0 TCK) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- docs/testing/semantic-coverage-matrix.md | 22 ++++++++++++++++++++++ src/backend/transform/transform_unwind.c | 23 ++++++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/docs/testing/semantic-coverage-matrix.md b/docs/testing/semantic-coverage-matrix.md index 01daabb9..21db6b8a 100644 --- a/docs/testing/semantic-coverage-matrix.md +++ b/docs/testing/semantic-coverage-matrix.md @@ -457,3 +457,25 @@ clean): 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 j` after a projection that drops `i`/`j`). diff --git a/src/backend/transform/transform_unwind.c b/src/backend/transform/transform_unwind.c index 2f28d5e7..d3bc53c2 100644 --- a/src/backend/transform/transform_unwind.c +++ b/src/backend/transform/transform_unwind.c @@ -184,6 +184,15 @@ int transform_unwind_clause(cypher_transform_context *ctx, cypher_unwind *unwind /* List literal: use UNION ALL approach */ cypher_list *list = (cypher_list*)unwind->expr; + /* When the list elements reference bound MATCH variables (e.g. + * `UNWIND [n, r, p, ...]` — ReturnOrderBy1 [11], Comparison2 [3]), + * each UNION arm must splice the prior MATCH's FROM tables so those + * aliases stay in scope (and so cardinality matches the surrounding + * scope). Mirrors the function-call branch's splice handling. */ + bool list_splicable = (inner_sql && strlen(inner_sql) > 0 && + strncmp(inner_sql, "SELECT * FROM ", 14) == 0); + const char *list_where = list_splicable ? strstr(inner_sql, " WHERE ") : NULL; + if (!list->items || list->items->count == 0) { /* Empty list: return no rows using impossible condition */ dbuf_append(&cte_query, "SELECT NULL AS value"); @@ -265,7 +274,19 @@ int transform_unwind_clause(cypher_transform_context *ctx, cypher_unwind *unwind if (!ok) dbuf_append(&cte_query, "NULL"); } dbuf_append(&cte_query, " AS value"); - if (has_carry) { + if (list_splicable) { + /* Element exprs reference the original MATCH aliases, so + * splice the FROM tables directly (not wrapped as _prev). */ + if (has_carry) dbuf_append(&cte_query, dbuf_get(&carry_cols_orig)); + dbuf_append(&cte_query, " FROM "); + if (list_where) { + size_t tlen = (size_t)(list_where - (inner_sql + 14)); + dbuf_appendf(&cte_query, "%.*s", (int)tlen, inner_sql + 14); + dbuf_append(&cte_query, list_where); /* re-attach WHERE */ + } else { + dbuf_append(&cte_query, inner_sql + 14); + } + } else if (has_carry) { dbuf_append(&cte_query, dbuf_get(&carry_cols)); dbuf_appendf(&cte_query, " FROM (%s) AS _prev", inner_sql); } From 22b00a798198f99843bc92a0068c3e289558c69c Mon Sep 17 00:00:00 2001 From: Dylan Bobby Storey Date: Fri, 29 May 2026 17:48:00 -0400 Subject: [PATCH 5/9] 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) , _gql_order_key(e) `: 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 9 (null). (NaN must be a non-NULL sentinel, see B.) + - INTEGER/REAL -> 7 (number). + - TEXT: boolean subtype or 'true'/'false' -> 6; starts `{` -> inspect keys + (nodes&rels -> path 4; labels(&id) -> node 1; type&startNode* -> rel 2; else map 0); + starts `[` -> list 3; NaN sentinel -> 8; else string 5. +- Change `sql_order_by` (src/backend/transform/sql_builder.c:472) to emit + `_gql_order_rank(expr) , _gql_order_key(expr) ` — rank groups by type + (cross-type order), existing `_gql_order_key` sorts within type (homogeneous, + so SQLite-native sort is correct). Also transform_with.c:688 ORDER BY path. +- RISK: changes cross-type ORDER BY for ALL queries. Must run rigorous pass-set diff. + +### B. NaN sentinel value (survives CTE; renders as NaN; ranks 8) +- Subtypes DO NOT survive CTE/subquery boundaries in SQLite (verified). TEXT + CONTENT does. So NaN = a private sentinel STRING recognized by content, not subtype. +- Emit the sentinel for `0.0/0.0` (compile-time detectable; transform_unwind list + arm + transform_expr_ops division). Formatter (src/extension.c) renders the + sentinel as unquoted `NaN`. `_gql_order_rank` detects it -> rank 8. +- Use an unlikely sentinel (e.g. control-char prefix) to avoid colliding with the + literal Cypher string "NaN". + +### C. Path hydration through UNWIND list +- `UNWIND [..., p, ...]` renders path `p` as `[1,1,2]` instead of the path object + `{"nodes":[...],"rels":[...]}` (direct `RETURN p` renders correctly). Fix the + path-as-list-element transform to emit the full path object. + +## Status Updates + +- 2026-05-29: Prerequisite landed on branch (commit 71fe525): UNWIND of an + entity-containing list no longer crashes (`no such column`); the 4 target + scenarios moved error->fail. +- 2026-05-29: **Sub-feature A (orderability rank) DONE** — `_gql_order_rank` UDF + + two-column ORDER BY (`_gql_order_rank(e), _gql_order_key(e)`) in sql_builder.c + and transform_with.c. Verified: mixed-type ORDER BY now sorts + mapsentinel emission + the + one formatter branch. NEXT: introduce a single shared scalar-render helper in + extension.c, then re-add the sentinel emission + render in that one place. +- 2026-05-29: **Sub-feature C (path-through-UNWIND hydration) NOT started.** + `UNWIND [..., p, ...]` renders the path as `[1,1,2]` (elem ids) instead of the + path object; direct `RETURN p` is correct. Lives in the UNWIND list-element + path-expr / build_path_from_ids interaction. +- BLOCKERS SUMMARY: the 4 target ORDER-BY scenarios need A (done) + B + C all + three. A is shippable groundwork on its own. diff --git a/docs/testing/semantic-coverage-matrix.md b/docs/testing/semantic-coverage-matrix.md index 21db6b8a..065a60c1 100644 --- a/docs/testing/semantic-coverage-matrix.md +++ b/docs/testing/semantic-coverage-matrix.md @@ -479,3 +479,22 @@ unit 944/944; functional clean): 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) , _gql_order_key(e) ` — 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. diff --git a/src/backend/runtime/udf_helpers.c b/src/backend/runtime/udf_helpers.c index f48cdd90..59975f60 100644 --- a/src/backend/runtime/udf_helpers.c +++ b/src/backend/runtime/udf_helpers.c @@ -1210,6 +1210,53 @@ void gql_order_key_func( sqlite3_result_value(context, argv[0]); } +/* Cypher orderability type-rank (0..9), the PRIMARY ORDER BY key so mixed-type + * sorts follow Cypher's total order: + * map < node < rel < list < path < string < bool < number < NaN < null + * The secondary key (_gql_order_key) then orders within a homogeneous rank. + * Values arrive as untyped SQLite cells; JSON entity/map/path shapes are told + * apart by their distinctive keys (heuristic, sufficient for the value shapes + * this engine emits). GQLITE-T-0340. */ +void gql_order_rank_func( + sqlite3_context *context, + int argc, + sqlite3_value **argv +) { + if (argc != 1) { sqlite3_result_int(context, 9); return; } + int t = sqlite3_value_type(argv[0]); + if (t == SQLITE_NULL) { sqlite3_result_int(context, 9); return; } /* null */ + if (t == SQLITE_INTEGER || t == SQLITE_FLOAT) { + sqlite3_result_int(context, 7); return; /* number */ + } + if (t == SQLITE_TEXT) { + const char *s = (const char*)sqlite3_value_text(argv[0]); + if (!s) { sqlite3_result_int(context, 9); return; } + /* NaN sentinel — ranks just after numbers. */ + if (strcmp(s, GQL_NAN_SENTINEL) == 0) { sqlite3_result_int(context, 8); return; } + /* Boolean: subtype-tagged or the canonical literals. */ + if (sqlite3_value_subtype(argv[0]) == GQL_SUBTYPE_BOOLEAN || + strcmp(s, "true") == 0 || strcmp(s, "false") == 0) { + sqlite3_result_int(context, 6); return; /* bool */ + } + if (s[0] == '{') { + /* Entity/path/map JSON. Path carries both "nodes" and "rels" + * arrays; a node carries "labels"; a relationship carries + * "startNode"/"startNodeId"; otherwise it is a plain map. Order of + * checks matters because a path's text contains the inner keys. */ + bool has_nodes = strstr(s, "\"nodes\"") != NULL; + bool has_rels = strstr(s, "\"rels\"") != NULL; + if (has_nodes && has_rels) { sqlite3_result_int(context, 4); return; } /* path */ + if (strstr(s, "\"labels\"")) { sqlite3_result_int(context, 1); return; } /* node */ + if (strstr(s, "\"startNode")) { sqlite3_result_int(context, 2); return; }/* rel */ + sqlite3_result_int(context, 0); return; /* map */ + } + if (s[0] == '[') { sqlite3_result_int(context, 3); return; } /* list */ + sqlite3_result_int(context, 5); return; /* string */ + } + /* BLOB / unknown — treat as string-ish. */ + sqlite3_result_int(context, 5); +} + /* --- Cypher-orderability min()/max() aggregates (Aggregation2 [9]/[11]/[12]). * SQLite's native MIN/MAX use storage-class order, which mis-orders mixed-type * and list values. These custom aggregates keep the element whose Cypher diff --git a/src/backend/runtime/udf_register.c b/src/backend/runtime/udf_register.c index 20f0cd6a..1875df5d 100644 --- a/src/backend/runtime/udf_register.c +++ b/src/backend/runtime/udf_register.c @@ -80,6 +80,14 @@ int graphqlite_register_helper_udfs(sqlite3 *db) gql_order_key_func, 0, 0); if (rc != SQLITE_OK) return rc; + /* Cypher orderability type-rank (0..9) — the primary ORDER BY key so mixed + * types sort maporder_by, ", "); } - /* Wrap with _gql_order_key() so time/datetime strings sort by UTC - * instant rather than local-time lexicographic order, and lists sort - * by Cypher list semantics. Pass-through for other types. */ - dbuf_append(&b->order_by, "_gql_order_key("); + /* Cypher orderability: sort first by type rank so mixed types order + * maporder_by, "_gql_order_rank("); dbuf_append(&b->order_by, expr); dbuf_append(&b->order_by, ")"); - if (desc) { - dbuf_append(&b->order_by, " DESC"); - } + dbuf_append(&b->order_by, dir); + dbuf_append(&b->order_by, ", _gql_order_key("); + dbuf_append(&b->order_by, expr); + dbuf_append(&b->order_by, ")"); + dbuf_append(&b->order_by, dir); b->order_count++; } diff --git a/src/backend/transform/transform_expr_ops.c b/src/backend/transform/transform_expr_ops.c index 4b4c80ee..d1d196ed 100644 --- a/src/backend/transform/transform_expr_ops.c +++ b/src/backend/transform/transform_expr_ops.c @@ -172,7 +172,7 @@ int transform_null_check(cypher_transform_context *ctx, cypher_null_check *null_ int transform_binary_operation(cypher_transform_context *ctx, cypher_binary_op *binary_op) { CYPHER_DEBUG("Transforming binary operation: op_type=%d", binary_op->op_type); - + /* Set comparison context for comparison operators */ bool was_in_comparison = ctx->in_comparison; bool is_cmp = (binary_op->op_type == BINARY_OP_EQ || binary_op->op_type == BINARY_OP_NEQ || diff --git a/src/backend/transform/transform_with.c b/src/backend/transform/transform_with.c index 9a359960..1f2090ac 100644 --- a/src/backend/transform/transform_with.c +++ b/src/backend/transform/transform_with.c @@ -685,8 +685,12 @@ int transform_with_clause(cypher_transform_context *ctx, cypher_with *with) cypher_order_by_item *oi = (cypher_order_by_item*)with->order_by->items[i]; if (i > 0) dbuf_append(&cte_body, ", "); char *oe = transform_expression_to_string(ctx, oi->expr); - dbuf_appendf(&cte_body, "_gql_order_key(%s)%s", oe ? oe : "NULL", - oi->descending ? " DESC" : ""); + const char *oexpr = oe ? oe : "NULL"; + const char *odir = oi->descending ? " DESC" : ""; + /* Type-rank primary key for Cypher orderability, then the + * within-type key (GQLITE-T-0340). */ + dbuf_appendf(&cte_body, "_gql_order_rank(%s)%s, _gql_order_key(%s)%s", + oexpr, odir, oexpr, odir); free(oe); } order_pushed_to_cte = true; diff --git a/src/include/runtime/gql_error.h b/src/include/runtime/gql_error.h index f49c51fb..2813ab3a 100644 --- a/src/include/runtime/gql_error.h +++ b/src/include/runtime/gql_error.h @@ -34,6 +34,14 @@ * (SQLITE_INTEGER=1 .. SQLITE_NULL=5) so existing readers ignore it. */ #define GQL_COL_TYPE_BOOLEAN 100 +/* NaN sentinel. SQLite collapses float NaN to NULL and drops subtypes across + * CTE boundaries, so a runtime NaN value (e.g. produced by `0.0/0.0` inside an + * UNWIND list) is carried as a private TEXT string recognized by content. The + * leading control byte (0x01, SOH) makes collision with a real Cypher string + * effectively impossible. The formatter renders it as unquoted `NaN`; the + * orderability rank places it just after numbers (rank 8). (GQLITE-T-0340.) */ +#define GQL_NAN_SENTINEL "\x01NaN" + void graphqlite_result_error(sqlite3_context *context, const char *message, const char *code); diff --git a/src/include/runtime/udf_helpers.h b/src/include/runtime/udf_helpers.h index b18586f5..d43ad3dd 100644 --- a/src/include/runtime/udf_helpers.h +++ b/src/include/runtime/udf_helpers.h @@ -36,6 +36,7 @@ void gql_order_cmp_func(sqlite3_context *ctx, int argc, sqlite3_value **argv); /* Order key + namespace/timezone extractors */ void gql_order_key_func(sqlite3_context *ctx, int argc, sqlite3_value **argv); +void gql_order_rank_func(sqlite3_context *ctx, int argc, sqlite3_value **argv); void gql_extract_ns_func(sqlite3_context *ctx, int argc, sqlite3_value **argv); void gql_strip_tz_func(sqlite3_context *ctx, int argc, sqlite3_value **argv); void gql_extract_tz_func(sqlite3_context *ctx, int argc, sqlite3_value **argv); diff --git a/tests/test_sql_builder.c b/tests/test_sql_builder.c index 9d5bb156..dbad2e24 100644 --- a/tests/test_sql_builder.c +++ b/tests/test_sql_builder.c @@ -443,7 +443,9 @@ static void test_sql_builder_order_by(void) /* ORDER BY columns are wrapped in _gql_order_key() so openCypher * NULL/type ordering semantics are honored. */ CU_ASSERT(strstr(sql, "SELECT n.name FROM nodes AS n") != NULL); - CU_ASSERT(strstr(sql, "ORDER BY _gql_order_key(n.name)") != NULL); + /* GQLITE-T-0340: ORDER BY emits a type-rank primary key then the + * within-type key. */ + CU_ASSERT(strstr(sql, "ORDER BY _gql_order_rank(n.name), _gql_order_key(n.name)") != NULL); free(sql); } sql_builder_free(b); @@ -464,7 +466,7 @@ static void test_sql_builder_order_by_desc(void) char *sql = sql_builder_to_string(b); CU_ASSERT_PTR_NOT_NULL(sql); if (sql) { - CU_ASSERT(strstr(sql, "ORDER BY _gql_order_key(n.age) DESC") != NULL); + CU_ASSERT(strstr(sql, "ORDER BY _gql_order_rank(n.age) DESC, _gql_order_key(n.age) DESC") != NULL); free(sql); } sql_builder_free(b); @@ -789,7 +791,7 @@ static void test_sql_builder_complex(void) CU_ASSERT(strstr(sql, "SELECT n.id AS node_id") != NULL); CU_ASSERT(strstr(sql, "JOIN edges") != NULL); CU_ASSERT(strstr(sql, "WHERE") != NULL); - CU_ASSERT(strstr(sql, "ORDER BY _gql_order_key(m.name)") != NULL); + CU_ASSERT(strstr(sql, "ORDER BY _gql_order_rank(m.name), _gql_order_key(m.name)") != NULL); CU_ASSERT(strstr(sql, "LIMIT 10") != NULL); free(sql); } From d74210e0a6b29cfff63cd6b9976264a3e89871d4 Mon Sep 17 00:00:00 2001 From: Dylan Bobby Storey Date: Fri, 29 May 2026 22:39:35 -0400 Subject: [PATCH 6/9] nan: renderable NaN value for 0.0/0.0 (+1 TCK, GQLITE-T-0340 sub-feature B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/testing/semantic-coverage-matrix.md | 17 +++++++++++++++++ src/backend/executor/agtype.c | 11 +++++++++-- src/backend/executor/executor_match.c | 12 ++++++++++-- src/backend/transform/transform_expr_ops.c | 10 ++++++++++ src/extension.c | 3 +++ 5 files changed, 49 insertions(+), 4 deletions(-) diff --git a/docs/testing/semantic-coverage-matrix.md b/docs/testing/semantic-coverage-matrix.md index 065a60c1..d03b5750 100644 --- a/docs/testing/semantic-coverage-matrix.md +++ b/docs/testing/semantic-coverage-matrix.md @@ -498,3 +498,20 @@ zero newly passing; unit 944/944; functional clean): 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). diff --git a/src/backend/executor/agtype.c b/src/backend/executor/agtype.c index 0e88ed57..ef44856f 100644 --- a/src/backend/executor/agtype.c +++ b/src/backend/executor/agtype.c @@ -6,6 +6,7 @@ #include #include #include +#include #include "executor/agtype.h" #include "parser/cypher_debug.h" @@ -976,8 +977,14 @@ char* agtype_value_to_string(agtype_value *val) case AGTV_FLOAT: { result = malloc(40); if (result) { - /* %.17g preserves full double precision (TCK expects). */ - snprintf(result, 40, "%.17g", val->val.float_value); + if (isnan(val->val.float_value)) { + /* Cypher renders NaN as the bare token `NaN` (GQLITE-T-0340), + * not the platform's "nan"/"-nan". */ + snprintf(result, 40, "NaN"); + } else { + /* %.17g preserves full double precision (TCK expects). */ + snprintf(result, 40, "%.17g", val->val.float_value); + } } break; } diff --git a/src/backend/executor/executor_match.c b/src/backend/executor/executor_match.c index 6f08f7c4..186555bf 100644 --- a/src/backend/executor/executor_match.c +++ b/src/backend/executor/executor_match.c @@ -7,6 +7,7 @@ #include #include #include +#include #include #include "executor/executor_internal.h" @@ -574,9 +575,16 @@ agtype_value* create_property_agtype_value(const char* value) if (!value) { return agtype_value_create_null(); } - + + /* NaN sentinel (GQLITE-T-0340): carried as the private string + * GQL_NAN_SENTINEL. Materialize as a float NaN so the serializer renders + * the bare token `NaN` instead of a quoted control-char string. */ + if (strcmp(value, GQL_NAN_SENTINEL) == 0) { + return agtype_value_create_float(NAN); + } + /* Try to detect the data type from the string value */ - + /* Check for boolean values */ if (strcmp(value, "true") == 0) { return agtype_value_create_bool(true); diff --git a/src/backend/transform/transform_expr_ops.c b/src/backend/transform/transform_expr_ops.c index d1d196ed..92064de6 100644 --- a/src/backend/transform/transform_expr_ops.c +++ b/src/backend/transform/transform_expr_ops.c @@ -173,6 +173,16 @@ int transform_binary_operation(cypher_transform_context *ctx, cypher_binary_op * { CYPHER_DEBUG("Transforming binary operation: op_type=%d", binary_op->op_type); + /* Standalone NaN constant `0.0 / 0.0` (not a comparison operand — those are + * folded by the is_cmp block below). SQLite would yield NULL; emit the NaN + * sentinel string instead (GQL_NAN_SENTINEL = 0x01 'N' 'a' 'N') so it + * renders as `NaN` and orders at rank 8 (GQLITE-T-0340). CHAR(1) is the + * 0x01 prefix byte. */ + if (is_nan_const((ast_node *)binary_op)) { + append_sql(ctx, "(CHAR(1) || 'NaN')"); + return 0; + } + /* Set comparison context for comparison operators */ bool was_in_comparison = ctx->in_comparison; bool is_cmp = (binary_op->op_type == BINARY_OP_EQ || binary_op->op_type == BINARY_OP_NEQ || diff --git a/src/extension.c b/src/extension.c index ded4a1d8..0264093d 100644 --- a/src/extension.c +++ b/src/extension.c @@ -364,6 +364,9 @@ static void graphqlite_cypher_func(sqlite3_context *context, int argc, sqlite3_v * (e.g. toString(true) result). I-0040 M13. */ size_t slen = strlen(val); if (offset + slen < buffer_size) { memcpy(json_result + offset, val, slen); offset += slen; } + } else if (strcmp(val, GQL_NAN_SENTINEL) == 0) { + /* NaN sentinel (GQLITE-T-0340) — emit unquoted NaN. */ + offset += snprintf(json_result + offset, buffer_size - offset, "NaN"); } else { /* String value - quote and escape */ offset += snprintf(json_result + offset, buffer_size - offset, "\""); From 86ea2d47012ac39710bf04b39a896c47cf224777 Mon Sep 17 00:00:00 2001 From: Dylan Bobby Storey Date: Fri, 29 May 2026 23:21:35 -0400 Subject: [PATCH 7/9] path: hydrate path as a list element under UNWIND (+3 TCK, GQLITE-T-0340 sub-feature C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 angreal dev clean. 3722 -> 3725. Unit 944/944, functional clean. --- docs/testing/semantic-coverage-matrix.md | 16 ++++++++++++++++ src/backend/transform/transform_return.c | 7 +++++-- src/backend/transform/transform_unwind.c | 7 +++++++ src/include/transform/cypher_transform.h | 4 ++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/docs/testing/semantic-coverage-matrix.md b/docs/testing/semantic-coverage-matrix.md index d03b5750..f23f443d 100644 --- a/docs/testing/semantic-coverage-matrix.md +++ b/docs/testing/semantic-coverage-matrix.md @@ -515,3 +515,19 @@ via the TCK harness (3721 -> 3722, rigorous full pass-set diff: zero regressions 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 + mappath_type == VAR_PATH_COMPREHENSION) { + if (path_var->path_type == VAR_PATH_COMPREHENSION || + (ctx->emit_hydrated_path && !single_varlen)) { /* T-0332: pattern comprehension path — emit fully hydrated * path JSON ({nodes:[...], rels:[...]}) so it survives nested * inside a json_group_array aggregate without needing executor * post-processing of list elements. Build using json_object so - * SQLite quotes/escapes correctly. */ + * SQLite quotes/escapes correctly. + * GQLITE-T-0340 sub-C: also used for a path as a list element + * under UNWIND, where executor elem_ids hydration can't reach. */ append_sql(ctx, "json_object('nodes', json_array("); bool first_n = true; for (int i = 0; i < path_var->path_elements->count; i++) { diff --git a/src/backend/transform/transform_unwind.c b/src/backend/transform/transform_unwind.c index d3bc53c2..0d2b0ffd 100644 --- a/src/backend/transform/transform_unwind.c +++ b/src/backend/transform/transform_unwind.c @@ -260,10 +260,17 @@ int transform_unwind_clause(cypher_transform_context *ctx, cypher_unwind *unwind ctx->sql_buffer = temp_buffer; ctx->sql_size = 0; ctx->sql_capacity = temp_capacity; + /* A path variable as a list element must materialize as + * a self-contained {nodes,rels} object — the executor's + * elem_ids post-hydration only reaches top-level RETURN + * columns, not values buried in an UNWIND row. (T-0340 sub-C) */ + bool saved_ehp = ctx->emit_hydrated_path; + ctx->emit_hydrated_path = true; if (transform_expression(ctx, item) == 0 && ctx->sql_buffer[0]) { dbuf_appendf(&cte_query, "(%s)", ctx->sql_buffer); ok = true; } + ctx->emit_hydrated_path = saved_ehp; /* append_sql may realloc; free what ctx->sql_buffer * now points to, not the original temp_buffer. */ free(ctx->sql_buffer); diff --git a/src/include/transform/cypher_transform.h b/src/include/transform/cypher_transform.h index d34c53c6..fe4dede6 100644 --- a/src/include/transform/cypher_transform.h +++ b/src/include/transform/cypher_transform.h @@ -44,6 +44,10 @@ struct cypher_transform_context { /* Context flags */ bool in_comparison; /* True when transforming expressions in comparison context */ bool in_union; /* True when transforming UNION branches (skip buffer reset) */ + bool emit_hydrated_path; /* True when a path expression must emit the full + * {nodes,rels} JSON object inline (e.g. as a list + * element under UNWIND) rather than elem_ids for + * executor post-hydration. GQLITE-T-0340 sub-C. */ /* Unique alias counters */ int global_alias_counter; /* Global counter for all unnamed entities (like AGE) */ From c822ec88a6b6019eb07dacbf29cefa07f15ecd57 Mon Sep 17 00:00:00 2001 From: Dylan Bobby Storey Date: Fri, 29 May 2026 23:25:35 -0400 Subject: [PATCH 8/9] metis: GQLITE-T-0340 type-ordering stack complete (A+B+C, +4 TCK) --- .metis/backlog/features/GQLITE-T-0340.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.metis/backlog/features/GQLITE-T-0340.md b/.metis/backlog/features/GQLITE-T-0340.md index a53c4cde..1a48fb15 100644 --- a/.metis/backlog/features/GQLITE-T-0340.md +++ b/.metis/backlog/features/GQLITE-T-0340.md @@ -90,5 +90,19 @@ map < node < rel < list < path < string < bool < number < NaN < null `UNWIND [..., p, ...]` renders the path as `[1,1,2]` (elem ids) instead of the path object; direct `RETURN p` is correct. Lives in the UNWIND list-element path-expr / build_path_from_ids interaction. -- BLOCKERS SUMMARY: the 4 target ORDER-BY scenarios need A (done) + B + C all - three. A is shippable groundwork on its own. +- 2026-05-29: **Sub-feature B (NaN value) DONE** (commit d74210e, +1). NaN carried + as the private string GQL_NAN_SENTINEL (0x01 'N' 'a' 'N'); standalone `0.0/0.0` + emits `(CHAR(1)||'NaN')`; agtype `create_property_agtype_value` maps it to a + float NaN whose AGTV_FLOAT serializer prints `NaN`; plain formatter prints it + too. Fixed WithOrderBy1 [22]. Rigorous diff: zero regressions. +- 2026-05-29: **Sub-feature C (path-as-list-element) DONE** (commit, +3). New + context flag `emit_hydrated_path` makes the path projection emit the full + {nodes,rels} object inline (reusing the comprehension builder) for non-varlen + paths; transform_unwind sets it around each list-element transform. Fixed + ReturnOrderBy1 [11]/[12], WithOrderBy1 [21]. +- 2026-05-29: **STACK COMPLETE.** All four target scenarios pass; mixed-type + ORDER BY follows Cypher orderability map 3725 (+4: B +1, C +3; A +0 groundwork). Two full TCK runs + confirm stability and zero regressions (the ReturnOrderBy1 [1] entry seen in an + interim `comm` was a baseline-run transient — the scenario is deterministic and + passes). Task can be marked done. From 1541eef176759dc4b2319edb78e1b7bbe8e1cf0c Mon Sep 17 00:00:00 2001 From: Dylan Bobby Storey Date: Sat, 30 May 2026 09:35:00 -0400 Subject: [PATCH 9/9] 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. --- docs/testing/semantic-coverage-matrix.md | 17 +++++++ src/backend/runtime/udf_helpers.c | 46 +++++++++++++++++++ src/backend/runtime/udf_register.c | 11 +++++ .../transform/transform_func_aggregate.c | 11 +++-- src/backend/transform/transform_func_entity.c | 28 ++++++++--- src/include/runtime/udf_helpers.h | 2 + 6 files changed, 106 insertions(+), 9 deletions(-) diff --git a/docs/testing/semantic-coverage-matrix.md b/docs/testing/semantic-coverage-matrix.md index f23f443d..43583107 100644 --- a/docs/testing/semantic-coverage-matrix.md +++ b/docs/testing/semantic-coverage-matrix.md @@ -531,3 +531,20 @@ TCK harness (3722 -> 3725; unit 944/944; functional clean): C path): fixes ReturnOrderBy1 [11]/[12] and WithOrderBy1 [21] (WithOrderBy1 [22] landed with B). Mixed-type ORDER BY now fully follows Cypher orderability map 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]. diff --git a/src/backend/runtime/udf_helpers.c b/src/backend/runtime/udf_helpers.c index 59975f60..23c3cb7e 100644 --- a/src/backend/runtime/udf_helpers.c +++ b/src/backend/runtime/udf_helpers.c @@ -577,6 +577,52 @@ void gql_subscript_func( "TypeError: InvalidArgumentType: Cannot subscript value of non-list/non-map", -1); } +/* labels()/type() over a statically-Any argument (Graph3 [6], Graph4 [5]). + * The runtime value decides: a node/relationship JSON object yields its + * labels/type; null yields null; anything else raises a TypeError so the + * "fail on invalid argument" scenarios (Graph3 [9]) still error. `path` is + * "$.labels" or "$.type"; `want_array` distinguishes the two shapes. */ +static void gql_entity_accessor(sqlite3_context *context, sqlite3_value *v, + const char *path, bool want_array, + const char *err) { + if (sqlite3_value_type(v) == SQLITE_NULL) { sqlite3_result_null(context); return; } + const char *s = (sqlite3_value_type(v) == SQLITE_TEXT) + ? (const char*)sqlite3_value_text(v) : NULL; + if (s && s[0] == '{') { + sqlite3 *db = sqlite3_context_db_handle(context); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(db, "SELECT json_type(?1, ?2), json_extract(?1, ?2)", + -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_value(st, 1, v); + sqlite3_bind_text(st, 2, path, -1, SQLITE_TRANSIENT); + int ok = 0; + if (sqlite3_step(st) == SQLITE_ROW) { + const char *jt = (const char*)sqlite3_column_text(st, 0); + if (jt && ((want_array && strcmp(jt, "array") == 0) || + (!want_array && strcmp(jt, "text") == 0))) { + sqlite3_result_value(context, sqlite3_column_value(st, 1)); + ok = 1; + } + } + sqlite3_finalize(st); + if (ok) return; + } + } + sqlite3_result_error(context, err, -1); +} + +void gql_labels_func(sqlite3_context *context, int argc, sqlite3_value **argv) { + if (argc != 1) { sqlite3_result_null(context); return; } + gql_entity_accessor(context, argv[0], "$.labels", true, + "TypeError: InvalidArgumentValue: labels() requires a Node"); +} + +void gql_type_func(sqlite3_context *context, int argc, sqlite3_value **argv) { + if (argc != 1) { sqlite3_result_null(context); return; } + gql_entity_accessor(context, argv[0], "$.type", false, + "TypeError: InvalidArgumentValue: type() requires a Relationship"); +} + /* Cypher's three-valued IN operator. * null IN [] -> false * null IN -> null diff --git a/src/backend/runtime/udf_register.c b/src/backend/runtime/udf_register.c index 1875df5d..371c29b3 100644 --- a/src/backend/runtime/udf_register.c +++ b/src/backend/runtime/udf_register.c @@ -88,6 +88,17 @@ int graphqlite_register_helper_udfs(sqlite3 *db) gql_order_rank_func, 0, 0); if (rc != SQLITE_OK) return rc; + /* labels()/type() over a statically-Any argument: return the labels/type for + * a node/relationship value, null for null, else raise a TypeError. */ + rc = sqlite3_create_function(db, "_gql_labels", 1, + SQLITE_UTF8 | SQLITE_DETERMINISTIC, 0, + gql_labels_func, 0, 0); + if (rc != SQLITE_OK) return rc; + rc = sqlite3_create_function(db, "_gql_type", 1, + SQLITE_UTF8 | SQLITE_DETERMINISTIC, 0, + gql_type_func, 0, 0); + if (rc != SQLITE_OK) return rc; + rc = sqlite3_create_function(db, "_gql_in", 2, SQLITE_UTF8 | SQLITE_DETERMINISTIC, 0, gql_in_func, 0, 0); diff --git a/src/backend/transform/transform_func_aggregate.c b/src/backend/transform/transform_func_aggregate.c index b4688c04..46452bc5 100644 --- a/src/backend/transform/transform_func_aggregate.c +++ b/src/backend/transform/transform_func_aggregate.c @@ -311,9 +311,14 @@ int transform_type_function(cypher_transform_context *ctx, cypher_function_call } if (arg->type != AST_NODE_IDENTIFIER) { - ctx->has_error = true; - ctx->error_message = strdup("type() function argument must be a relationship variable"); - return -1; + /* type() accepts type Any (Graph4 [5]): a non-identifier argument + * (e.g. `type(list[0])`) is resolved at runtime by the _gql_type UDF — + * a relationship value yields its type, null yields null, anything else + * raises a TypeError. */ + append_sql(ctx, "_gql_type("); + if (transform_expression(ctx, arg) < 0) return -1; + append_sql(ctx, ")"); + return 0; } cypher_identifier *id = (cypher_identifier*)arg; diff --git a/src/backend/transform/transform_func_entity.c b/src/backend/transform/transform_func_entity.c index 669e13f9..2b46ac9c 100644 --- a/src/backend/transform/transform_func_entity.c +++ b/src/backend/transform/transform_func_entity.c @@ -87,9 +87,14 @@ int transform_labels_function(cypher_transform_context *ctx, cypher_function_cal } if (arg->type != AST_NODE_IDENTIFIER) { - ctx->has_error = true; - ctx->error_message = strdup("labels() function argument must be a node variable"); - return -1; + /* labels() accepts type Any (Graph3 [6]): a non-identifier argument + * (e.g. `labels(list[0])`) is statically typed Any and resolved at + * runtime by the _gql_labels UDF — a node value yields its labels, null + * yields null, anything else raises a TypeError (Graph3 [9]). */ + append_sql(ctx, "_gql_labels("); + if (transform_expression(ctx, arg) < 0) return -1; + append_sql(ctx, ")"); + return 0; } cypher_identifier *id = (cypher_identifier*)arg; @@ -331,9 +336,20 @@ int transform_keys_function(cypher_transform_context *ctx, cypher_function_call } if (arg->type != AST_NODE_IDENTIFIER) { - ctx->has_error = true; - ctx->error_message = strdup("keys() function argument must be a node, relationship, or map"); - return -1; + /* keys() accepts type Any (Map3 [2] `keys($param)`): a parameter or + * other expression that resolves to a map at runtime. Compute the value + * once in a subquery, then json_each its keys; if it is a node/rel JSON + * object (carries a `properties` object) use that object's keys instead + * of the wrapper keys. NULL-guarded. */ + append_sql(ctx, + "(SELECT CASE WHEN _kv.v IS NULL THEN NULL ELSE " + "(SELECT json_group_array(key) FROM json_each(" + "CASE WHEN json_type(_kv.v, '$.properties') = 'object' " + "THEN json_extract(_kv.v, '$.properties') ELSE _kv.v END)) END " + "FROM (SELECT "); + if (transform_expression(ctx, arg) < 0) return -1; + append_sql(ctx, " AS v) _kv)"); + return 0; } cypher_identifier *id = (cypher_identifier*)arg; diff --git a/src/include/runtime/udf_helpers.h b/src/include/runtime/udf_helpers.h index d43ad3dd..62ac7e99 100644 --- a/src/include/runtime/udf_helpers.h +++ b/src/include/runtime/udf_helpers.h @@ -37,6 +37,8 @@ void gql_order_cmp_func(sqlite3_context *ctx, int argc, sqlite3_value **argv); /* Order key + namespace/timezone extractors */ void gql_order_key_func(sqlite3_context *ctx, int argc, sqlite3_value **argv); void gql_order_rank_func(sqlite3_context *ctx, int argc, sqlite3_value **argv); +void gql_labels_func(sqlite3_context *ctx, int argc, sqlite3_value **argv); +void gql_type_func(sqlite3_context *ctx, int argc, sqlite3_value **argv); void gql_extract_ns_func(sqlite3_context *ctx, int argc, sqlite3_value **argv); void gql_strip_tz_func(sqlite3_context *ctx, int argc, sqlite3_value **argv); void gql_extract_tz_func(sqlite3_context *ctx, int argc, sqlite3_value **argv);