Skip to content

Commit 8f64c36

Browse files
authored
Merge pull request DeusData#1918 from CaptainMittens/fix/return-star-respects-with-scope
fix(cypher): RETURN * must read the live scope, not the query pattern
2 parents 54a4347 + 7470006 commit 8f64c36

2 files changed

Lines changed: 152 additions & 6 deletions

File tree

‎src/cypher/cypher.c‎

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1802,8 +1802,16 @@ static int parse_return_or_with(parser_t *p, cbm_return_clause_t **out, bool is_
18021802
/* Projection is materialized per row into fixed-width stack arrays sized at
18031803
* CBM_SZ_32 columns (execute_return_simple and its siblings). Bound the
18041804
* parsed item count to that width so an over-wide RETURN is rejected here
1805-
* instead of writing past those arrays downstream. */
1806-
if (r->count > CBM_SZ_32) {
1805+
* instead of writing past those arrays downstream.
1806+
*
1807+
* WITH is bounded tighter, by CYP_MAX_VARS. Every item a WITH projects
1808+
* becomes one variable of the binding that carries the rest of the query,
1809+
* and binding_t holds exactly CYP_MAX_VARS variables. A wider WITH used to
1810+
* parse, then lose every alias past the 16th in with_add_vbinding_var and
1811+
* answer with silently blank columns. Refuse it here, the same way an
1812+
* over-wide RETURN is refused, so the caller sees an error instead of a
1813+
* short or empty result. */
1814+
if (r->count > (is_with ? CYP_MAX_VARS : CBM_SZ_32)) {
18071815
free_return_clause(r);
18081816
return CBM_NOT_FOUND;
18091817
}
@@ -4193,17 +4201,34 @@ static void execute_with_clause(cbm_query_t *q, binding_t **bindings_ptr, int *b
41934201

41944202
/* Project RETURN * — all bound variable properties */
41954203
/* Collect all variable names from query patterns */
4204+
/* Has this variable already been collected? A query may name the same variable
4205+
* in more than one pattern, and RETURN * must give it one set of columns. */
4206+
static bool star_var_seen(const char **vars, int vc, const char *name) {
4207+
for (int i = 0; i < vc; i++) {
4208+
if (strcmp(vars[i], name) == 0) {
4209+
return true;
4210+
}
4211+
}
4212+
return false;
4213+
}
4214+
4215+
/* Collect the variables a RETURN * projects, in the order the query names them
4216+
* and with no repeats. Without the repeat check, `MATCH (f) OPTIONAL MATCH
4217+
* (f)-[:CALLS]->(g)` names f in two patterns and f gets its four columns
4218+
* twice. */
41964219
static int collect_pattern_vars(cbm_query_t *q, const char **vars, int max_vars) {
41974220
int vc = 0;
41984221
for (int pi = 0; pi < q->pattern_count; pi++) {
41994222
for (int ni = 0; ni < q->patterns[pi].node_count && vc < max_vars; ni++) {
4200-
if (q->patterns[pi].nodes[ni].variable) {
4201-
vars[vc++] = q->patterns[pi].nodes[ni].variable;
4223+
const char *var = q->patterns[pi].nodes[ni].variable;
4224+
if (var && !star_var_seen(vars, vc, var)) {
4225+
vars[vc++] = var;
42024226
}
42034227
}
42044228
for (int ri = 0; ri < q->patterns[pi].rel_count && vc < max_vars; ri++) {
4205-
if (q->patterns[pi].rels[ri].variable) {
4206-
vars[vc++] = q->patterns[pi].rels[ri].variable;
4229+
const char *var = q->patterns[pi].rels[ri].variable;
4230+
if (var && !star_var_seen(vars, vc, var)) {
4231+
vars[vc++] = var;
42074232
}
42084233
}
42094234
}
@@ -4255,8 +4280,43 @@ static void project_star_row(binding_t *b, const char **vars, int vc, const char
42554280
}
42564281
}
42574282

4283+
/* RETURN * after a WITH.
4284+
*
4285+
* The pattern's variables are out of scope by this point — the WITH replaced
4286+
* them with the names it made. Each of those names holds one value, not a
4287+
* node, so each is ONE column rather than the four a node variable gets.
4288+
*
4289+
* Reading the pattern here instead is the fault this function exists to avoid:
4290+
* it named variables the bindings no longer hold, found nothing for every one
4291+
* of them, and answered a full result of empty strings with no error. */
4292+
static void execute_return_star_after_with(cbm_query_t *q, binding_t *bindings, int bind_count,
4293+
int max_rows, result_builder_t *rb) {
4294+
cbm_return_clause_t *wc = q->with_clause;
4295+
char name_bufs[CYP_MAX_VARS][CBM_SZ_128];
4296+
const char *cols[CYP_MAX_VARS];
4297+
/* parse_return_or_with refuses a WITH wider than CYP_MAX_VARS, so this
4298+
* clamp cannot fire. It stays as the bound this function relies on. */
4299+
int col_n = wc->count < CYP_MAX_VARS ? wc->count : CYP_MAX_VARS;
4300+
for (int i = 0; i < col_n; i++) {
4301+
cols[i] = resolve_item_alias(&wc->items[i], name_bufs[i], sizeof(name_bufs[i]));
4302+
}
4303+
rb_set_columns(rb, cols, col_n);
4304+
for (int bi = 0; bi < bind_count && rb->row_count < max_rows; bi++) {
4305+
const char *vals[CYP_MAX_VARS];
4306+
for (int i = 0; i < col_n; i++) {
4307+
cbm_node_t *vn = binding_get(&bindings[bi], cols[i]);
4308+
vals[i] = vn && vn->name ? vn->name : "";
4309+
}
4310+
rb_add_row(rb, vals);
4311+
}
4312+
}
4313+
42584314
static void execute_return_star(cbm_query_t *q, binding_t *bindings, int bind_count, int max_rows,
42594315
result_builder_t *rb) {
4316+
if (q->with_clause) {
4317+
execute_return_star_after_with(q, bindings, bind_count, max_rows, rb);
4318+
return;
4319+
}
42604320
const char *vars[CBM_SZ_32];
42614321
int vc = collect_pattern_vars(q, vars, CBM_SZ_32);
42624322
build_star_columns(rb, vars, vc);

‎tests/test_cypher.c‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2982,6 +2982,89 @@ TEST(cypher_exec_return_star) {
29822982
PASS();
29832983
}
29842984

2985+
TEST(cypher_return_star_dedups_repeated_pattern_var) {
2986+
/* RETURN * collected its column variables from every pattern in turn and
2987+
* never deduped, so a variable named in two patterns got its four columns
2988+
* twice. Here f is named in the MATCH and again in the OPTIONAL MATCH, so
2989+
* eight columns is right and twelve is the fault. */
2990+
cbm_store_t *s = setup_cypher_store();
2991+
cbm_cypher_result_t r = {0};
2992+
int rc = cbm_cypher_execute(s, "MATCH (f:Function) OPTIONAL MATCH (f)-[:CALLS]->(g) RETURN *",
2993+
"test", 0, &r);
2994+
ASSERT_EQ(rc, 0);
2995+
ASSERT_EQ(r.col_count, 8);
2996+
ASSERT_STR_EQ(r.columns[0], "f.name");
2997+
ASSERT_STR_EQ(r.columns[4], "g.name");
2998+
cbm_cypher_result_free(&r);
2999+
cbm_store_close(s);
3000+
PASS();
3001+
}
3002+
3003+
TEST(cypher_return_star_after_with_names_aliases) {
3004+
/* RETURN * built its columns from the query pattern, never from the
3005+
* bindings it was about to project. After a WITH the live scope is the
3006+
* aliases the WITH made, so the old code asked for f and g, found neither,
3007+
* and answered every value empty with no error. */
3008+
cbm_store_t *s = setup_cypher_store();
3009+
cbm_cypher_result_t r = {0};
3010+
int rc = cbm_cypher_execute(s,
3011+
"MATCH (f:Function)-[:CALLS]->(g) "
3012+
"WITH f.name AS caller, g.name AS callee RETURN *",
3013+
"test", 0, &r);
3014+
ASSERT_EQ(rc, 0);
3015+
ASSERT_EQ(r.col_count, 2);
3016+
ASSERT_STR_EQ(r.columns[0], "caller");
3017+
ASSERT_STR_EQ(r.columns[1], "callee");
3018+
/* Three CALLS edges in the fixture. */
3019+
ASSERT_EQ(r.row_count, 3);
3020+
for (int i = 0; i < r.row_count; i++) {
3021+
ASSERT_TRUE(r.rows[i][0][0] != '\0');
3022+
ASSERT_TRUE(r.rows[i][1][0] != '\0');
3023+
}
3024+
cbm_cypher_result_free(&r);
3025+
cbm_store_close(s);
3026+
PASS();
3027+
}
3028+
3029+
TEST(cypher_wide_with_refused_not_truncated) {
3030+
/* Every item a WITH projects becomes one variable of the binding that
3031+
* carries the rest of the query, and a binding holds CYP_MAX_VARS (16) of
3032+
* them. A 20-alias WITH used to parse, drop aliases 17 to 20 inside
3033+
* with_add_vbinding_var, and answer RETURN * with 16 columns and no error —
3034+
* a short result the caller could not tell from a complete one. It has to
3035+
* be refused at parse time instead. */
3036+
char query[1024];
3037+
int off = snprintf(query, sizeof(query), "MATCH (f:Function) WITH ");
3038+
for (int i = 0; i < 20; i++) { /* 20 > CYP_MAX_VARS (16) */
3039+
off += snprintf(query + off, sizeof(query) - (size_t)off, "%sf.name AS c%d", i ? ", " : "",
3040+
i);
3041+
}
3042+
snprintf(query + off, sizeof(query) - (size_t)off, " RETURN *");
3043+
3044+
cbm_store_t *s = setup_cypher_store();
3045+
cbm_cypher_result_t r = {0};
3046+
int rc = cbm_cypher_execute(s, query, "test", 0, &r);
3047+
ASSERT_TRUE(rc != 0); /* refused, not silently narrowed to 16 columns */
3048+
cbm_cypher_result_free(&r);
3049+
3050+
/* The width just under the bound still works, so the guard rejects only
3051+
* what the binding genuinely cannot carry. */
3052+
char ok_query[1024];
3053+
off = snprintf(ok_query, sizeof(ok_query), "MATCH (f:Function) WITH ");
3054+
for (int i = 0; i < 16; i++) {
3055+
off += snprintf(ok_query + off, sizeof(ok_query) - (size_t)off, "%sf.name AS c%d",
3056+
i ? ", " : "", i);
3057+
}
3058+
snprintf(ok_query + off, sizeof(ok_query) - (size_t)off, " RETURN *");
3059+
cbm_cypher_result_t r16 = {0};
3060+
ASSERT_EQ(cbm_cypher_execute(s, ok_query, "test", 0, &r16), 0);
3061+
ASSERT_EQ(r16.col_count, 16);
3062+
cbm_cypher_result_free(&r16);
3063+
3064+
cbm_store_close(s);
3065+
PASS();
3066+
}
3067+
29853068
TEST(cypher_parse_neq) {
29863069
cbm_query_t *q = NULL;
29873070
char *err = NULL;
@@ -4290,6 +4373,9 @@ SUITE(cypher) {
42904373
RUN_TEST(cypher_exec_where_is_null);
42914374
RUN_TEST(cypher_exec_where_is_not_null);
42924375
RUN_TEST(cypher_exec_return_star);
4376+
RUN_TEST(cypher_return_star_dedups_repeated_pattern_var);
4377+
RUN_TEST(cypher_return_star_after_with_names_aliases);
4378+
RUN_TEST(cypher_wide_with_refused_not_truncated);
42934379
RUN_TEST(cypher_parse_neq);
42944380
RUN_TEST(cypher_parse_in);
42954381
RUN_TEST(cypher_parse_is_null);

0 commit comments

Comments
 (0)