From b835b5e8b24802d29538f847edb078fd999f87f9 Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Sat, 18 Jul 2026 19:16:09 -0400 Subject: [PATCH] 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 --- src/cypher/cypher.c | 83 +++++++++++++++++++++++++++- src/foundation/recursion_whitelist.h | 10 ++-- tests/test_cypher.c | 67 ++++++++++++++++++++++ 3 files changed, 154 insertions(+), 6 deletions(-) diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index 75686b90f..6d30389bf 100644 --- a/src/cypher/cypher.c +++ b/src/cypher/cypher.c @@ -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 { diff --git a/src/foundation/recursion_whitelist.h b/src/foundation/recursion_whitelist.h index c3e2b5c8c..c9d6fd2c1 100644 --- a/src/foundation/recursion_whitelist.h +++ b/src/foundation/recursion_whitelist.h @@ -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" diff --git a/tests/test_cypher.c b/tests/test_cypher.c index ee07ecc6c..b0001a33e 100644 --- a/tests/test_cypher.c +++ b/tests/test_cypher.c @@ -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);