fix(cypher): defer unbound relationship predicates

execute_single previously evaluated the complete WHERE tree while only the first MATCH alias was bound. A target-alias predicate such as NOT callee.file_path CONTAINS "tests/" therefore rejected every seed before CALLS expansion and returned zero rows.

Add allocation-free three-valued partial evaluation in src/cypher/cypher.c: bound leaves prune early, unbound node or edge aliases remain unknown, and AND/OR/NOT/XOR preserve boolean semantics and safe short-circuiting. Register eval_expr_partial in src/foundation/recursion_whitelist.h.

Add four relationship-target and mixed-alias regressions in tests/test_cypher.c. Verified 159/159 Cypher tests under ASan/UBSan, source-safety and whitelist checks, an -O2 production build, and the formerly empty hotspot query returning 20 bounded rows.

Signed-off-by: Andrew Hundt <ATHundt@gmail.com>
This commit is contained in:
Andrew Hundt
2026-07-18 19:16:09 -04:00
parent a4b3a71e81
commit b835b5e8b2
3 changed files with 154 additions and 6 deletions
+82 -1
View File
@@ -2619,6 +2619,87 @@ static bool eval_where(const cbm_where_clause_t *w, binding_t *b) {
return is_and;
}
typedef enum { CYP_PARTIAL_FALSE = 0, CYP_PARTIAL_TRUE, CYP_PARTIAL_UNKNOWN } cypher_partial_bool_t;
static cypher_partial_bool_t partial_and(cypher_partial_bool_t left, cypher_partial_bool_t right) {
if (left == CYP_PARTIAL_FALSE || right == CYP_PARTIAL_FALSE) {
return CYP_PARTIAL_FALSE;
}
return left == CYP_PARTIAL_TRUE && right == CYP_PARTIAL_TRUE ? CYP_PARTIAL_TRUE
: CYP_PARTIAL_UNKNOWN;
}
static cypher_partial_bool_t partial_or(cypher_partial_bool_t left, cypher_partial_bool_t right) {
if (left == CYP_PARTIAL_TRUE || right == CYP_PARTIAL_TRUE) {
return CYP_PARTIAL_TRUE;
}
return left == CYP_PARTIAL_FALSE && right == CYP_PARTIAL_FALSE ? CYP_PARTIAL_FALSE
: CYP_PARTIAL_UNKNOWN;
}
/* Evaluate the portion of a WHERE expression whose aliases are already
* bound. Unknown leaves keep the seed; definitively false source predicates
* still prune before relationship expansion. Per seed this is O(expression
* nodes) time, O(expression depth) stack, and allocation-free; AND/OR retain
* the full evaluator's safe short-circuit behavior. */
static cypher_partial_bool_t eval_expr_partial(const cbm_expr_t *e, // NOLINT(misc-no-recursion)
binding_t *b) {
if (!e) {
return CYP_PARTIAL_TRUE;
}
if (e->type == EXPR_CONDITION) {
if (!binding_get(b, e->cond.variable) && !binding_get_edge(b, e->cond.variable)) {
return CYP_PARTIAL_UNKNOWN;
}
return eval_condition(&e->cond, b) ? CYP_PARTIAL_TRUE : CYP_PARTIAL_FALSE;
}
cypher_partial_bool_t left = eval_expr_partial(e->left, b);
if (e->type == EXPR_NOT) {
return left == CYP_PARTIAL_UNKNOWN
? CYP_PARTIAL_UNKNOWN
: (left == CYP_PARTIAL_TRUE ? CYP_PARTIAL_FALSE : CYP_PARTIAL_TRUE);
}
if (e->type == EXPR_AND && left == CYP_PARTIAL_FALSE) {
return CYP_PARTIAL_FALSE;
}
if (e->type == EXPR_OR && left == CYP_PARTIAL_TRUE) {
return CYP_PARTIAL_TRUE;
}
cypher_partial_bool_t right = eval_expr_partial(e->right, b);
if (e->type == EXPR_AND) {
return partial_and(left, right);
}
if (e->type == EXPR_OR) {
return partial_or(left, right);
}
if (left == CYP_PARTIAL_UNKNOWN || right == CYP_PARTIAL_UNKNOWN) {
return CYP_PARTIAL_UNKNOWN;
}
return left != right ? CYP_PARTIAL_TRUE : CYP_PARTIAL_FALSE;
}
static cypher_partial_bool_t eval_where_partial(const cbm_where_clause_t *where, binding_t *b) {
if (!where) {
return CYP_PARTIAL_TRUE;
}
if (where->root) {
return eval_expr_partial(where->root, b);
}
cypher_partial_bool_t result =
where->op && strcmp(where->op, "AND") == 0 ? CYP_PARTIAL_TRUE : CYP_PARTIAL_FALSE;
for (int i = 0; i < where->count; i++) {
cypher_partial_bool_t item =
(!binding_get(b, where->conditions[i].variable) &&
!binding_get_edge(b, where->conditions[i].variable))
? CYP_PARTIAL_UNKNOWN
: (eval_condition(&where->conditions[i], b) ? CYP_PARTIAL_TRUE : CYP_PARTIAL_FALSE);
result = where->op && strcmp(where->op, "AND") == 0 ? partial_and(result, item)
: partial_or(result, item);
}
return result;
}
/* Check if a string value looks like a regex pattern. */
static bool looks_like_regex(const char *s) {
if (!s) {
@@ -4668,7 +4749,7 @@ static int execute_single(cbm_store_t *store, cbm_query_t *q, const char *projec
b.project = project;
b.use_active_overlay_edges = scan_mode == CYP_NODE_SCAN_ACTIVE_OVERLAY;
binding_set(&b, var_name, &scanned[i]);
bool pass = !q->where || eval_where(q->where, &b);
bool pass = eval_where_partial(q->where, &b) != CYP_PARTIAL_FALSE;
if (pass) {
bindings[bind_count++] = b;
} else {
+5 -5
View File
@@ -8,8 +8,8 @@
* - parse_or_expr, parse_xor_expr, parse_and_expr, parse_not_expr
* - parse_atom_expr, parse_post_where, cbm_parse
*
* Cypher expression evaluator (bounded by WHERE clause depth ~5):
* - eval_expr
* Cypher expression traversal (bounded by WHERE clause depth ~5):
* - eval_expr, eval_expr_partial
*
* Glob pattern matcher (bounded by pattern nesting ~3):
* - glob_match, glob_match_star, glob_match_doublestar
@@ -27,7 +27,7 @@
*/
#define CBM_RECURSION_WHITELIST \
"parse_or_expr", "parse_xor_expr", "parse_and_expr", "parse_not_expr", "parse_atom_expr", \
"parse_post_where", "cbm_parse", "eval_expr", "glob_match", "glob_match_star", \
"glob_match_doublestar", "glob_match_doublestar_slash", "glob_match_doublestar_any", \
"parse_bool_expr", "parse_bool_atom", "r_collect_imports", \
"parse_post_where", "cbm_parse", "eval_expr", "eval_expr_partial", "glob_match", \
"glob_match_star", "glob_match_doublestar", "glob_match_doublestar_slash", \
"glob_match_doublestar_any", "parse_bool_expr", "parse_bool_atom", "r_collect_imports", \
"find_first_descendant_by_kind", "find_first_descendant_of"
+67
View File
@@ -1929,6 +1929,69 @@ TEST(cypher_exec_where_not) {
PASS();
}
TEST(cypher_exec_where_not_on_relationship_target) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (a:Function)-[:CALLS]->(b:Function) "
"WHERE NOT b.name CONTAINS \"Order\" RETURN b.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "LogError");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_where_mixed_alias_and) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s,
"MATCH (a:Function)-[:CALLS]->(b:Function) "
"WHERE a.name = \"HandleOrder\" AND NOT b.name CONTAINS \"Order\" RETURN b.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "LogError");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_where_mixed_alias_or) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s,
"MATCH (a:Function)-[:CALLS]->(b:Function) "
"WHERE a.name = \"NoSuchFunction\" OR NOT b.name CONTAINS \"Order\" RETURN b.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "LogError");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_where_mixed_alias_xor) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc =
cbm_cypher_execute(s,
"MATCH (a:Function)-[:CALLS]->(b:Function) "
"WHERE a.name = \"HandleOrder\" XOR b.name = \"LogError\" RETURN b.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "ValidateOrder");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_where_in) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
@@ -3073,6 +3136,10 @@ SUITE(cypher) {
RUN_TEST(cypher_exec_where_neq_bang);
RUN_TEST(cypher_exec_where_ends_with);
RUN_TEST(cypher_exec_where_not);
RUN_TEST(cypher_exec_where_not_on_relationship_target);
RUN_TEST(cypher_exec_where_mixed_alias_and);
RUN_TEST(cypher_exec_where_mixed_alias_or);
RUN_TEST(cypher_exec_where_mixed_alias_xor);
RUN_TEST(cypher_exec_where_in);
RUN_TEST(cypher_exec_where_not_in);
RUN_TEST(cypher_exec_where_is_null);