mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-10-21 05:59:35 +08:00
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:
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user