Skip to content

Commit 4be937f

Browse files
author
Dylan Bobby Storey
committed
fix(varlen): T-0309 — RETURN of varlen rel emits JSON array of edges
Variable-length pattern bindings `[r*N..M]` reference a recursive CTE whose alias has columns start_id/end_id/depth/path_ids/visited — not edge fields. The previous RETURN projector emitted a single-edge JSON template (`json_object('id', alias.id, 'type', alias.type, ...)`), producing 'no such column: _gql_default_alias_N.id' for every varlen-rel projection. Three coordinated changes: 1. transform_match.c (line ~1145): when registering a varlen rel variable, stamp `transform_var->cte_name` with the CTE name. This marks the var as varlen-bound so downstream code branches appropriately. 2. transform_return.c (line ~956): in the AST_NODE_IDENTIFIER edge- variable projector, branch on `evar->cte_name`. When set, emit: (SELECT json_group_array(json_object('id', e.id, 'type', e.type, ...)) FROM edges e WHERE e.id IN (SELECT CAST(value AS INTEGER) FROM json_each('[' || alias.path_ids || ']')) ORDER BY instr(',' || alias.path_ids || ',', ',' || e.id || ',')) instead of the single-edge template. Path-order preservation via `instr` on the comma-separated path_ids string. 3. executor_match.c + extension.c: build_query_results identifies varlen edge vars (`evar->cte_name`) and skips the single-edge re- fetch (atoll(value) → fetch by id), leaving `result->data[row][col]` as the verbatim JSON array text. The result renderer in extension.c then falls back to text data when agtype_data is NULL (gated on the pointer being NULL, not on agtype_value_to_string returning NULL — that function returns 'null' for NULL input). Result rendering (verified manually): MATCH (a)-[r*1..1]->(b) RETURN r => [{"r": [{"id":1,"type":"T",...}]}] (was: error) MATCH (a)-[r*1..2]->(b) RETURN r over a->b->c => 4 rows: [[T1]], [[T1,T2]], [[T2]], [[]] (was: error) MATCH ()-[r:T]->() RETURN r => [{"r": {"id":1,"type":"T",...}}] (unchanged — non-varlen) TCK delta: pass=3458 -> 3461 (+3), errors=84 -> 79 (-5), fails=284 -> 286 (+2). The 5 `_gql_default_alias` errors that flipped: - Match4 [1] Handling fixed-length variable length pattern - Match9 [1] Variable length relationship variables are lists of relationships - (+ 3 others that moved error → pass or error → fail) 944/944 unit, functional clean.
1 parent 00b5ff4 commit 4be937f

4 files changed

Lines changed: 102 additions & 18 deletions

File tree

src/backend/executor/executor_match.c

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,16 @@ int build_query_results(cypher_executor *executor, sqlite3_stmt *stmt, cypher_re
418418
/* Parse the JSON array of element IDs and build path object */
419419
result->agtype_data[current_row][col] = build_path_from_ids(executor, ctx, ident->name, value);
420420
} else if (ctx && transform_var_is_edge(ctx->var_ctx, ident->name)) {
421+
/* T-0309: varlen-bound edge vars hold a JSON array
422+
* of edge objects (the RETURN projector emitted
423+
* json_group_array). Skip the single-edge
424+
* interpretation; leave the value as the text
425+
* column so the renderer outputs it verbatim. */
426+
transform_var *_evar = transform_var_lookup_edge(ctx->var_ctx, ident->name);
427+
if (_evar && _evar->cte_name && value[0] == '[') {
428+
/* No agtype conversion — text result will be
429+
* rendered as the JSON array. */
430+
} else
421431
/* Check if value is already a JSON object (from new RETURN format) */
422432
if (value[0] == '{') {
423433
/* Parse the JSON object directly */

src/backend/transform/transform_match.c

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1149,6 +1149,13 @@ static int generate_relationship_match(cypher_transform_context *ctx, cypher_rel
11491149
char cte_name[64];
11501150
snprintf(cte_name, sizeof(cte_name), "_varlen_path_%d", rel_index);
11511151

1152+
/* T-0309: mark this rel variable as varlen-bound so the RETURN
1153+
* projector emits a list-of-edges JSON array (and the result
1154+
* builder skips single-edge re-fetch). */
1155+
if (rel->variable) {
1156+
transform_var_set_cte(ctx->var_ctx, rel->variable, cte_name);
1157+
}
1158+
11521159
/* Generate the recursive CTE (added to unified builder) */
11531160
if (generate_varlen_cte(ctx, rel, source_alias, target_alias, cte_name) < 0) {
11541161
ctx->has_error = true;

src/backend/transform/transform_return.c

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -954,6 +954,39 @@ int transform_expression(cypher_transform_context *ctx, ast_node *expr)
954954
alias, alias, alias, alias, alias);
955955
}
956956
} else if (transform_var_is_edge(ctx->var_ctx, id->name)) {
957+
/* T-0309: varlen-bound edge variable holds a path
958+
* of edges (CTE alias has columns start_id/end_id/
959+
* depth/path_ids/visited, not edge fields). Emit
960+
* a JSON array constructed from path_ids — one
961+
* edge JSON per id, in path order. */
962+
transform_var *evar = transform_var_lookup_edge(ctx->var_ctx, id->name);
963+
if (evar && evar->cte_name) {
964+
append_sql(ctx,
965+
"(SELECT json_group_array(json_object("
966+
"'id', e.id, "
967+
"'type', e.type, "
968+
"'startNodeId', e.source_id, "
969+
"'endNodeId', e.target_id, "
970+
"'properties', COALESCE((SELECT json_group_object(pk.key, COALESCE("
971+
"(SELECT ept.value FROM edge_props_text ept WHERE ept.edge_id = e.id AND ept.key_id = pk.id), "
972+
"(SELECT epi.value FROM edge_props_int epi WHERE epi.edge_id = e.id AND epi.key_id = pk.id), "
973+
"(SELECT epr.value FROM edge_props_real epr WHERE epr.edge_id = e.id AND epr.key_id = pk.id), "
974+
"(SELECT epb.value FROM edge_props_bool epb WHERE epb.edge_id = e.id AND epb.key_id = pk.id), "
975+
"(SELECT json(epj.value) FROM edge_props_json epj WHERE epj.edge_id = e.id AND epj.key_id = pk.id))) "
976+
"FROM property_keys pk WHERE "
977+
"EXISTS (SELECT 1 FROM edge_props_text WHERE edge_id = e.id AND key_id = pk.id) OR "
978+
"EXISTS (SELECT 1 FROM edge_props_int WHERE edge_id = e.id AND key_id = pk.id) OR "
979+
"EXISTS (SELECT 1 FROM edge_props_real WHERE edge_id = e.id AND key_id = pk.id) OR "
980+
"EXISTS (SELECT 1 FROM edge_props_bool WHERE edge_id = e.id AND key_id = pk.id) OR "
981+
"EXISTS (SELECT 1 FROM edge_props_json WHERE edge_id = e.id AND key_id = pk.id)"
982+
"), json('{}'))"
983+
")) "
984+
"FROM edges e WHERE e.id IN ("
985+
"SELECT CAST(value AS INTEGER) FROM json_each('[' || %s.path_ids || ']')"
986+
") ORDER BY instr(',' || %s.path_ids || ',', ',' || e.id || ','))",
987+
alias, alias);
988+
goto edge_alias_projection_done;
989+
}
957990
/* Edge variable - return full relationship object,
958991
* or NULL when the row came from an OPTIONAL MATCH
959992
* miss (LEFT JOIN with no match → alias.id IS NULL). */
@@ -980,6 +1013,7 @@ int transform_expression(cypher_transform_context *ctx, ast_node *expr)
9801013
alias, alias, alias, alias,
9811014
alias, alias, alias, alias, alias,
9821015
alias, alias, alias, alias, alias);
1016+
edge_alias_projection_done: ;
9831017
} else {
9841018
/* This is a node variable - return full node object,
9851019
* or NULL when the row came from an OPTIONAL MATCH

src/extension.c

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -147,19 +147,26 @@ static void graphqlite_cypher_func(sqlite3_context *context, int argc, sqlite3_v
147147
if (result->row_count > 0 && result->use_agtype && result->agtype_data) {
148148
/* Use AGE-compatible format */
149149
if (result->row_count == 1 && result->column_count == 1) {
150-
/* Single result - wrap with column name for consistent format */
151-
char *agtype_str = agtype_value_to_string(result->agtype_data[0][0]);
152-
if (agtype_str) {
150+
/* Single result - wrap with column name for consistent format.
151+
* T-0309: when agtype cell is unset (varlen edge JSON array
152+
* lives in result->data verbatim), agtype_value_to_string(NULL)
153+
* returns 'null' — we want the text data instead. */
154+
int use_text_fallback = (result->agtype_data[0][0] == NULL &&
155+
result->data && result->data[0] && result->data[0][0]);
156+
char *agtype_str = use_text_fallback ? NULL : agtype_value_to_string(result->agtype_data[0][0]);
157+
const char *text_val = use_text_fallback ? result->data[0][0] : NULL;
158+
if (agtype_str || text_val) {
159+
const char *render = agtype_str ? agtype_str : text_val;
153160
const char *col_name = (result->column_names && result->column_names[0])
154161
? result->column_names[0] : "result";
155-
int json_size = strlen(agtype_str) + strlen(col_name) + 32;
162+
int json_size = strlen(render) + strlen(col_name) + 32;
156163
char *json_result = malloc(json_size);
157164
if (json_result) {
158-
snprintf(json_result, json_size, "[{\"%s\": %s}]", col_name, agtype_str);
165+
snprintf(json_result, json_size, "[{\"%s\": %s}]", col_name, render);
159166
sqlite3_result_text(context, json_result, -1, SQLITE_TRANSIENT);
160167
free(json_result);
161168
} else {
162-
sqlite3_result_text(context, agtype_str, -1, SQLITE_TRANSIENT);
169+
sqlite3_result_text(context, render, -1, SQLITE_TRANSIENT);
163170
}
164171
free(agtype_str);
165172
} else {
@@ -176,6 +183,9 @@ static void graphqlite_cypher_func(sqlite3_context *context, int argc, sqlite3_v
176183
buffer_size += strlen(temp_str) + 20;
177184
free(temp_str);
178185
}
186+
} else if (result->data && result->data[row] && result->data[row][col]) {
187+
/* T-0309: include text fallback data in sizing. */
188+
buffer_size += strlen(result->data[row][col]) + 20;
179189
}
180190
}
181191
}
@@ -203,13 +213,25 @@ static void graphqlite_cypher_func(sqlite3_context *context, int argc, sqlite3_v
203213
offset += snprintf(json_result + offset, buffer_size - offset, "result");
204214
}
205215
offset += snprintf(json_result + offset, buffer_size - offset, "\":");
206-
char *agtype_str = agtype_value_to_string(result->agtype_data[row][0]);
207-
if (agtype_str) {
208-
size_t slen = strlen(agtype_str);
209-
if (offset + slen < buffer_size) { memcpy(json_result + offset, agtype_str, slen); offset += slen; }
210-
free(agtype_str);
216+
/* T-0309: when agtype cell unset (varlen edge JSON
217+
* array in result->data), fall back to text data
218+
* verbatim. agtype_value_to_string(NULL) returns
219+
* 'null' — so the fallback must check the agtype
220+
* pointer itself, not the rendered string. */
221+
if (result->agtype_data[row][0] == NULL &&
222+
result->data && result->data[row] && result->data[row][0]) {
223+
const char *txt = result->data[row][0];
224+
size_t slen = strlen(txt);
225+
if (offset + slen < buffer_size) { memcpy(json_result + offset, txt, slen); offset += slen; }
211226
} else {
212-
offset += snprintf(json_result + offset, buffer_size - offset, "null");
227+
char *agtype_str = agtype_value_to_string(result->agtype_data[row][0]);
228+
if (agtype_str) {
229+
size_t slen = strlen(agtype_str);
230+
if (offset + slen < buffer_size) { memcpy(json_result + offset, agtype_str, slen); offset += slen; }
231+
free(agtype_str);
232+
} else {
233+
offset += snprintf(json_result + offset, buffer_size - offset, "null");
234+
}
213235
}
214236
offset += snprintf(json_result + offset, buffer_size - offset, "}");
215237
} else {
@@ -230,13 +252,24 @@ static void graphqlite_cypher_func(sqlite3_context *context, int argc, sqlite3_v
230252
}
231253
offset += snprintf(json_result + offset, buffer_size - offset, "\":");
232254

233-
char *agtype_str = agtype_value_to_string(result->agtype_data[row][col]);
234-
if (agtype_str) {
235-
size_t slen = strlen(agtype_str);
236-
if (offset + slen < buffer_size) { memcpy(json_result + offset, agtype_str, slen); offset += slen; }
237-
free(agtype_str);
255+
/* T-0309: agtype_value_to_string(NULL) returns
256+
* 'null' — so the text-fallback path must be
257+
* gated on agtype_data being NULL, not the
258+
* return value. */
259+
if (result->agtype_data[row][col] == NULL &&
260+
result->data && result->data[row] && result->data[row][col]) {
261+
const char *txt = result->data[row][col];
262+
size_t slen = strlen(txt);
263+
if (offset + slen < buffer_size) { memcpy(json_result + offset, txt, slen); offset += slen; }
238264
} else {
239-
offset += snprintf(json_result + offset, buffer_size - offset, "null");
265+
char *agtype_str = agtype_value_to_string(result->agtype_data[row][col]);
266+
if (agtype_str) {
267+
size_t slen = strlen(agtype_str);
268+
if (offset + slen < buffer_size) { memcpy(json_result + offset, agtype_str, slen); offset += slen; }
269+
free(agtype_str);
270+
} else {
271+
offset += snprintf(json_result + offset, buffer_size - offset, "null");
272+
}
240273
}
241274
}
242275
offset += snprintf(json_result + offset, buffer_size - offset, "}");

0 commit comments

Comments
 (0)