Skip to content

Commit a6468ed

Browse files
authored
Merge pull request DeusData#1922 from CaptainMittens/fix/reject-out-of-scope-projection
fix(cypher): refuse a name that is not in scope instead of projecting a blank column
2 parents 20e86c0 + afc948b commit a6468ed

2 files changed

Lines changed: 228 additions & 0 deletions

File tree

‎src/cypher/cypher.c‎

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4894,6 +4894,164 @@ static int execute_single(cbm_store_t *store, cbm_query_t *q, const char *projec
48944894

48954895
/* ── Main entry point ─────────────────────────────────────────── */
48964896

4897+
/* ── Is every name a RETURN or WITH item uses actually in scope? ──
4898+
*
4899+
* An unbound variable resolves to NULL and renders as "" on purpose: an
4900+
* OPTIONAL MATCH target that found no row has to project a blank rather than
4901+
* drop the row. The cost of that convention is that a name the query NEVER
4902+
* carried through looks exactly the same on screen. A typo, or a variable a
4903+
* WITH dropped, then reads as "the graph holds no such data" instead of "your
4904+
* query named something that is not there" (#1919). Same failure shape as
4905+
* #373, and the same answer: say so out loud.
4906+
*
4907+
* The check runs on the parsed query, not on the run-time bindings, and that
4908+
* is the whole trick. It asks whether the query DECLARED the name — a
4909+
* different question from whether a row happened to bind it. So the OPTIONAL
4910+
* MATCH convention above is untouched: a target that did not match is still
4911+
* declared, still legal, and still projects "".
4912+
*/
4913+
4914+
/* Cap for one query's declared names. CYP_MAX_VARS is the binding cap for
4915+
* nodes alone; a pattern also names edges, so this is the roomier of the two.
4916+
* A query that overruns it skips the check rather than guessing — a wrong
4917+
* refusal costs the caller a working query, which is worse than the silence
4918+
* this guard removes. */
4919+
enum { CYP_SCOPE_MAX_NAMES = 32 };
4920+
4921+
static bool scope_holds(const char *const *names, int count, const char *want) {
4922+
for (int i = 0; i < count; i++) {
4923+
if (names[i] && strcmp(names[i], want) == 0) {
4924+
return true;
4925+
}
4926+
}
4927+
return false;
4928+
}
4929+
4930+
/* Every name the query's patterns declare, plus an UNWIND alias.
4931+
* Answers -1 when there are more names than the cap holds. */
4932+
static int collect_declared_names(const cbm_query_t *q, const char **out, int cap) {
4933+
int n = 0;
4934+
for (int pi = 0; pi < q->pattern_count; pi++) {
4935+
const cbm_pattern_t *pat = &q->patterns[pi];
4936+
for (int ni = 0; ni < pat->node_count; ni++) {
4937+
const char *var = pat->nodes[ni].variable;
4938+
if (var && !scope_holds(out, n, var)) {
4939+
if (n >= cap) {
4940+
return -1;
4941+
}
4942+
out[n++] = var;
4943+
}
4944+
}
4945+
for (int ri = 0; ri < pat->rel_count; ri++) {
4946+
const char *var = pat->rels[ri].variable;
4947+
if (var && !scope_holds(out, n, var)) {
4948+
if (n >= cap) {
4949+
return -1;
4950+
}
4951+
out[n++] = var;
4952+
}
4953+
}
4954+
}
4955+
if (q->unwind_alias && !scope_holds(out, n, q->unwind_alias)) {
4956+
if (n >= cap) {
4957+
return -1;
4958+
}
4959+
out[n++] = q->unwind_alias;
4960+
}
4961+
return n;
4962+
}
4963+
4964+
/* What a WITH leaves behind: its alias where it made one, and the plain
4965+
* variable where it carried one through whole. `WITH f.name AS caller` leaves
4966+
* `caller` and nothing else — `f` is gone, which is the case #1919 is about. */
4967+
static int collect_with_names(const cbm_return_clause_t *wc, const char **out, int cap) {
4968+
int n = 0;
4969+
for (int i = 0; i < wc->count; i++) {
4970+
const cbm_return_item_t *item = &wc->items[i];
4971+
const char *name = NULL;
4972+
if (item->alias) {
4973+
name = item->alias;
4974+
} else if (item->variable && !item->property && !item->func) {
4975+
name = item->variable;
4976+
}
4977+
if (name && !scope_holds(out, n, name)) {
4978+
if (n >= cap) {
4979+
return -1;
4980+
}
4981+
out[n++] = name;
4982+
}
4983+
}
4984+
return n;
4985+
}
4986+
4987+
/* Build the message. It names the variable and the clause, because an error
4988+
* that does not say WHICH name is wrong sends the reader back to guessing. */
4989+
static char *scope_error(const char *var, const char *clause, const char *why) {
4990+
char buf[CBM_SZ_256];
4991+
snprintf(buf, sizeof(buf), "variable '%s' is not in scope for %s — %s", var, clause, why);
4992+
return heap_strdup(buf);
4993+
}
4994+
4995+
/* The variable this item really references, or NULL when it references none.
4996+
* Two items carry a placeholder in that field rather than a name the query
4997+
* declared: `count(*)` stores "*", and a CASE expression stores "CASE". Both
4998+
* would read as an unknown variable, so neither is checkable here. */
4999+
static const char *scope_checkable_var(const cbm_return_item_t *item) {
5000+
if (item->kase || !item->variable) {
5001+
return NULL;
5002+
}
5003+
if (strcmp(item->variable, "*") == 0) {
5004+
return NULL;
5005+
}
5006+
return item->variable;
5007+
}
5008+
5009+
/* Answers NULL when the query is fine, or a heap message naming the first
5010+
* variable that is not in scope. Checks one query; the caller walks a UNION. */
5011+
static char *check_projection_scope(const cbm_query_t *q) {
5012+
const char *declared[CYP_SCOPE_MAX_NAMES];
5013+
int declared_n = collect_declared_names(q, declared, CYP_SCOPE_MAX_NAMES);
5014+
if (declared_n < 0) {
5015+
return NULL; /* too many names to model — stay quiet rather than guess */
5016+
}
5017+
5018+
/* A WITH still reads the pattern variables. */
5019+
if (q->with_clause && !q->with_clause->star) {
5020+
for (int i = 0; i < q->with_clause->count; i++) {
5021+
const char *var = scope_checkable_var(&q->with_clause->items[i]);
5022+
if (var && !scope_holds(declared, declared_n, var)) {
5023+
return scope_error(var, "WITH", "no pattern in this query names it");
5024+
}
5025+
}
5026+
}
5027+
5028+
if (!q->ret || q->ret->star) {
5029+
return NULL;
5030+
}
5031+
5032+
/* A RETURN after a WITH reads only what the WITH left behind. */
5033+
const char *after_with[CYP_SCOPE_MAX_NAMES];
5034+
const char *const *scope = declared;
5035+
int scope_n = declared_n;
5036+
if (q->with_clause) {
5037+
scope_n = collect_with_names(q->with_clause, after_with, CYP_SCOPE_MAX_NAMES);
5038+
if (scope_n < 0) {
5039+
return NULL;
5040+
}
5041+
scope = after_with;
5042+
}
5043+
5044+
for (int i = 0; i < q->ret->count; i++) {
5045+
const char *var = scope_checkable_var(&q->ret->items[i]);
5046+
if (var && !scope_holds(scope, scope_n, var)) {
5047+
return scope_error(var, "RETURN",
5048+
q->with_clause ? "the WITH clause did not carry it through"
5049+
: "no pattern in this query names it");
5050+
}
5051+
}
5052+
return NULL;
5053+
}
5054+
48975055
int cbm_cypher_execute(cbm_store_t *store, const char *query, const char *project, int max_rows,
48985056
cbm_cypher_result_t *out) {
48995057
memset(out, 0, sizeof(*out));
@@ -4911,6 +5069,15 @@ int cbm_cypher_execute(cbm_store_t *store, const char *query, const char *projec
49115069
return CBM_NOT_FOUND;
49125070
}
49135071

5072+
for (const cbm_query_t *sq = q; sq; sq = sq->union_next) {
5073+
char *scope_err = check_projection_scope(sq);
5074+
if (scope_err) {
5075+
cbm_query_free(q);
5076+
out->error = scope_err;
5077+
return CBM_NOT_FOUND;
5078+
}
5079+
}
5080+
49145081
result_builder_t rb = {0};
49155082
if (execute_single(store, q, project, max_rows, &rb) < 0) {
49165083
rb_free(&rb);

‎tests/test_cypher.c‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2031,6 +2031,64 @@ TEST(cypher_exec_count_distinct_issue239) {
20312031
* function like split(...) or list indexing [..]) must FAIL LOUDLY with a clear
20322032
* "unsupported function" error rather than silently projecting an empty column
20332033
* (which looks like a valid-but-blank result and hides the real problem). */
2034+
/* issue #1919: a variable the WITH clause dropped must be REFUSED, not projected
2035+
* as an empty column. `g` does not survive `WITH f.name AS caller`, so naming it
2036+
* afterwards is a query fault. The old code answered NULL for the unbound name,
2037+
* rendered it "", and exited clean — a column of nothing that reads as "the graph
2038+
* holds no such data" rather than "your query named something out of scope".
2039+
* Same principle as #373: fail loudly instead of projecting a blank column. */
2040+
TEST(cypher_rejects_projection_of_dropped_with_var_issue1919) {
2041+
cbm_store_t *s = setup_cypher_store();
2042+
cbm_cypher_result_t r = {0};
2043+
int rc = cbm_cypher_execute(
2044+
s, "MATCH (f:Function)-[:CALLS]->(g) WITH f.name AS caller RETURN caller, g.name", "test",
2045+
0, &r);
2046+
ASSERT_TRUE(rc != 0);
2047+
ASSERT_NOT_NULL(r.error);
2048+
/* The message has to name the offending variable — an error that does not
2049+
* say which name is wrong sends the reader back to guessing. */
2050+
ASSERT_TRUE(strstr(r.error, "g") != NULL);
2051+
ASSERT_TRUE(strstr(r.error, "scope") != NULL);
2052+
cbm_cypher_result_free(&r);
2053+
cbm_store_close(s);
2054+
PASS();
2055+
}
2056+
2057+
/* The guard must NOT reject an OPTIONAL MATCH target that simply did not match.
2058+
* `g` is a declared pattern variable there, so it stays in scope; it is merely
2059+
* unbound at run time, and projecting "" for it is the documented convention.
2060+
* This is the line between the two cases, and the reason the check reads the
2061+
* query's declared variables rather than the run-time bindings. */
2062+
TEST(cypher_optional_match_target_still_allowed_issue1919) {
2063+
cbm_store_t *s = setup_cypher_store();
2064+
cbm_cypher_result_t r = {0};
2065+
int rc = cbm_cypher_execute(
2066+
s, "MATCH (f:Function) OPTIONAL MATCH (f)-[:CALLS]->(g) RETURN f.name, g.name", "test", 0,
2067+
&r);
2068+
ASSERT_EQ(rc, 0);
2069+
ASSERT_NULL(r.error);
2070+
ASSERT_TRUE(r.row_count > 0);
2071+
cbm_cypher_result_free(&r);
2072+
cbm_store_close(s);
2073+
PASS();
2074+
}
2075+
2076+
/* An alias the WITH created is in scope afterwards, and a name carried through
2077+
* unchanged is too. Both must keep working. */
2078+
TEST(cypher_with_alias_stays_in_scope_issue1919) {
2079+
cbm_store_t *s = setup_cypher_store();
2080+
cbm_cypher_result_t r = {0};
2081+
int rc = cbm_cypher_execute(
2082+
s, "MATCH (f:Function)-[:CALLS]->(g) WITH f.name AS caller, g AS callee RETURN caller, callee.name",
2083+
"test", 0, &r);
2084+
ASSERT_EQ(rc, 0);
2085+
ASSERT_NULL(r.error);
2086+
ASSERT_TRUE(r.row_count > 0);
2087+
cbm_cypher_result_free(&r);
2088+
cbm_store_close(s);
2089+
PASS();
2090+
}
2091+
20342092
TEST(cypher_exec_unsupported_func_errors_issue373) {
20352093
cbm_store_t *s = setup_cypher_store();
20362094

@@ -4126,6 +4184,9 @@ SUITE(cypher) {
41264184
RUN_TEST(cypher_exec_label_alternation_issue242);
41274185
RUN_TEST(cypher_exec_count_distinct_issue239);
41284186
RUN_TEST(cypher_exec_unsupported_func_errors_issue373);
4187+
RUN_TEST(cypher_rejects_projection_of_dropped_with_var_issue1919);
4188+
RUN_TEST(cypher_optional_match_target_still_allowed_issue1919);
4189+
RUN_TEST(cypher_with_alias_stays_in_scope_issue1919);
41294190
RUN_TEST(cypher_exec_unknown_func_return_errors);
41304191
RUN_TEST(cypher_exec_inline_props);
41314192
RUN_TEST(cypher_parse_where_starts_with);

0 commit comments

Comments
 (0)