diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 45e278ae3..29a034398 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -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") diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index a15aea7ad..abc1ef7de 100644 --- a/src/cypher/cypher.c +++ b/src/cypher/cypher.c @@ -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; diff --git a/src/cypher/cypher.h b/src/cypher/cypher.h index dec5ca80c..5966c2076 100644 --- a/src/cypher/cypher.h +++ b/src/cypher/cypher.h @@ -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 */ diff --git a/tests/test_cypher.c b/tests/test_cypher.c index 9ef325a6d..42f8f59a7 100644 --- a/tests/test_cypher.c +++ b/tests/test_cypher.c @@ -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);