feat(cypher): bounded EXISTS { } pattern predicate in WHERE

Add a single-hop, anchored existence predicate:
  WHERE NOT EXISTS { (f)<-[:CALLS]-() }   -- functions with no caller
  WHERE EXISTS { (f)-[:CALLS]->() }       -- functions that call something

Parsed via parse_exists_predicate (reusing parse_node/parse_rel) into a leaf
condition (op=EXISTS, anchor variable, edge type, direction); evaluated against
the bound node with cbm_store_find_edges_by_source_type / _by_target_type. This
is edge-type-specific, so it finds true orphans that in_degree/out_degree miss
(e.g. a node with only a DEFINES edge but no CALLS caller). Multi-hop / nested-
WHERE EXISTS is intentionally unsupported and errors clearly.

Tests cover the dead-code (NOT EXISTS) and has-outgoing (EXISTS) cases; smoke
coverage added. ASan-clean.

Refs: Cypher read suite (EXISTS predicate)
This commit is contained in:
Martin Vogel
2026-06-01 21:17:56 +02:00
parent efec2ceffe
commit 08b62f03b9
4 changed files with 142 additions and 0 deletions
+8
View File
@@ -263,6 +263,14 @@ COALV=$(cyp_first_cell 'MATCH (f:Function) RETURN coalesce(f.nonesuch, f.name) A
if [ -z "$COALV" ]; then echo "FAIL: query_graph coalesce(...) returned empty"; exit 1; fi
echo "OK: query_graph coalesce(f.nonesuch, f.name) = $COALV"
# EXISTS { } pattern predicate (edge-type-specific existence)
CYPHER_EX=$(cli query_graph "{\"project\":\"$PROJECT\",\"query\":\"MATCH (f:Function) WHERE EXISTS { (f)-[:CALLS]->() } RETURN f.name\"}")
EX_ROWS=$(echo "$CYPHER_EX" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(len(d.get('rows',[])))" 2>/dev/null || echo "0")
if [ "$EX_ROWS" -lt 1 ]; then
echo "FAIL: query_graph EXISTS{} predicate returned 0 rows"; echo "$CYPHER_EX"; exit 1
fi
echo "OK: query_graph EXISTS { (f)-[:CALLS]->() } returned $EX_ROWS row(s)"
# =~ regex match in WHERE
CYPHER_RX=$(cli query_graph "{\"project\":\"$PROJECT\",\"query\":\"MATCH (f:Function) WHERE f.name =~ \\\".+\\\" RETURN f.name\"}")
RX_ROWS=$(echo "$CYPHER_RX" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(len(d.get('rows',[])))" 2>/dev/null || echo "0")
+98
View File
@@ -945,10 +945,76 @@ static char *parse_comparison_op(parser_t *p) {
}
/* Parse a single condition: var.prop OP value | var.prop IS [NOT] NULL | var.prop IN [...] */
/* Free the heap fields of a standalone node pattern (not owned by a pattern). */
static void free_one_node_pattern(cbm_node_pattern_t *n) {
safe_str_free(&n->variable);
safe_str_free(&n->label);
for (int j = 0; j < n->prop_count; j++) {
safe_str_free(&n->props[j].key);
safe_str_free(&n->props[j].value);
}
free(n->props);
memset(n, 0, sizeof(*n));
}
/* Free the heap fields of a standalone relationship pattern. */
static void free_one_rel_pattern(cbm_rel_pattern_t *r) {
safe_str_free(&r->variable);
for (int j = 0; j < r->type_count; j++) {
safe_str_free(&r->types[j]);
}
free(r->types);
safe_str_free(&r->direction);
memset(r, 0, sizeof(*r));
}
/* Parse a bounded EXISTS predicate: EXISTS { (anchor)-[:TYPE]->() } — a
* single-hop, edge-type-specific existence check anchored on a bound variable
* (e.g. WHERE NOT EXISTS { (f)<-[:CALLS]-() } finds functions with no callers).
* Multi-hop / nested-WHERE EXISTS is intentionally unsupported. */
static cbm_expr_t *parse_exists_predicate(parser_t *p, bool negated) {
advance(p); /* EXISTS */
if (!match(p, TOK_LBRACE)) {
snprintf(p->error, sizeof(p->error), "expected '{' after EXISTS at pos %d", peek(p)->pos);
return NULL;
}
cbm_node_pattern_t anchor = {0};
cbm_rel_pattern_t rel = {0};
cbm_node_pattern_t far = {0};
if (parse_node(p, &anchor) < 0 || parse_rel(p, &rel) < 0 || parse_node(p, &far) < 0) {
free_one_node_pattern(&anchor);
free_one_rel_pattern(&rel);
free_one_node_pattern(&far);
snprintf(p->error, sizeof(p->error),
"unsupported EXISTS pattern — only the single-hop form "
"'(var)-[:TYPE]->()' is supported");
return NULL;
}
expect(p, TOK_RBRACE);
cbm_condition_t c = {0};
c.negated = negated;
c.op = heap_strdup("EXISTS");
c.variable = heap_strdup(anchor.variable ? anchor.variable : "");
c.value = (rel.type_count > 0 && rel.types[0]) ? heap_strdup(rel.types[0]) : NULL;
c.exists_dir = (rel.direction && strcmp(rel.direction, "inbound") == 0) ? 1
: (rel.direction && strcmp(rel.direction, "any") == 0) ? 2
: 0;
free_one_node_pattern(&anchor);
free_one_rel_pattern(&rel);
free_one_node_pattern(&far);
return expr_leaf(c);
}
static cbm_expr_t *parse_condition_expr(parser_t *p) {
/* Check for NOT prefix at condition level (e.g. NOT n.name CONTAINS "x") */
bool negated = match(p, TOK_NOT);
/* EXISTS { pattern } predicate (anchored single-hop existence). */
if (check(p, TOK_EXISTS)) {
return parse_exists_predicate(p, negated);
}
const cbm_token_t *var = expect(p, TOK_IDENT);
if (!var) {
return NULL;
@@ -2274,6 +2340,38 @@ static bool eval_condition(const cbm_condition_t *c, binding_t *b) {
return c->negated ? !result : result;
}
/* EXISTS { (var)-[:TYPE]->() }: does the bound node have any edge of the
* given type in the requested direction? (dir 0=out, 1=in, 2=any) */
if (strcmp(c->op, "EXISTS") == 0) {
cbm_node_t *n = binding_get(b, c->variable);
bool result = false;
if (n && b->store) {
cbm_edge_t *edges = NULL;
int cnt = 0;
if (c->exists_dir != 1) { /* outbound or any */
if (c->value) {
cbm_store_find_edges_by_source_type(b->store, n->id, c->value, &edges, &cnt);
} else {
cbm_store_find_edges_by_source(b->store, n->id, &edges, &cnt);
}
result = cnt > 0;
cbm_store_free_edges(edges, cnt);
}
if (!result && c->exists_dir != 0) { /* inbound or any */
edges = NULL;
cnt = 0;
if (c->value) {
cbm_store_find_edges_by_target_type(b->store, n->id, c->value, &edges, &cnt);
} else {
cbm_store_find_edges_by_target(b->store, n->id, &edges, &cnt);
}
result = cnt > 0;
cbm_store_free_edges(edges, cnt);
}
}
return c->negated ? !result : result;
}
const char *actual = resolve_condition_value(c, b);
if (!actual) {
return true;
+4
View File
@@ -191,6 +191,10 @@ typedef struct {
bool negated; /* NOT prefix */
const char **in_values; /* IN [...] list */
int in_value_count;
/* EXISTS { (var)-[:value]->() } predicate (op=="EXISTS"): `variable` is the
* anchor, `value` the edge type (NULL = any), `exists_dir` the direction
* (0 = outbound, 1 = inbound, 2 = any). */
int exists_dir;
} cbm_condition_t;
/* Expression tree for WHERE clause */
+32
View File
@@ -684,6 +684,36 @@ TEST(cypher_func_multiarg) {
PASS();
}
TEST(cypher_exists_no_callers) {
/* NOT EXISTS { (f)<-[:CALLS]-() } → functions with no CALLS caller.
* HandleOrder has only an incoming DEFINES edge (not CALLS), so it is the
* sole match — proving EXISTS is edge-type-specific (in_degree=1 here). */
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (f:Function) WHERE NOT EXISTS { (f)<-[:CALLS]-() } RETURN f.name", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "HandleOrder");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exists_has_outgoing_calls) {
/* EXISTS { (f)-[:CALLS]->() } → functions that call something.
* HandleOrder (→ValidateOrder, →LogError) and ValidateOrder (→SubmitOrder). */
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (f:Function) WHERE EXISTS { (f)-[:CALLS]->() } RETURN f.name", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 2);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_calls_relationship) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
@@ -2481,6 +2511,8 @@ SUITE(cypher) {
RUN_TEST(cypher_func_tointeger_tofloat);
RUN_TEST(cypher_func_size_reverse);
RUN_TEST(cypher_func_multiarg);
RUN_TEST(cypher_exists_no_callers);
RUN_TEST(cypher_exists_has_outgoing_calls);
RUN_TEST(cypher_exec_calls_relationship);
RUN_TEST(cypher_exec_calls_with_where);
RUN_TEST(cypher_exec_inbound);