Files
codebase-memory-mcp/tests/test_cypher.c
Martin Vogel 951b2fddf6 fix(cypher): carry the real node through WITH, not a name-only stub (#2208)
A node variable carried in scope through WITH (`WITH f`, `WITH f AS g`,
`WITH f, count(r) AS refs`) was projected as a scalar virtual var: the
node's name in .name and the variable name in .qualified_name (used as
the owned storage for the binding's name), start_line 0. RETURN
f.qualified_name therefore returned the literal variable name "f" and
f.start_line "0"; only f.name was right. The aggregate path tagged the
stub with the node id so node_prop could re-fetch some fields, but the
re-fetch only ran for empty string fields, so the non-empty
qualified_name and the numeric start_line never reached it.

The aggregate path also grouped a node by its display name, so several
same-named functions collapsed into one row carrying one node's
properties and the summed count.

Fix:
- A bare, bound node item in WITH is now carried as a deep copy of the
  node, bound under its AST-owned alias, on both the simple and the
  aggregate path; every property then resolves exactly as it does
  without the WITH. Unbound OPTIONAL vars and scalar items stay scalar.
- Aggregate WITH groups node items by node id, not name.
- Scalar virtual vars keep their owned alias string in .project (never
  exposed as a property) instead of .qualified_name, so
  n.qualified_name / keys(n) no longer return the variable name.
- WITH ... ORDER BY g.<prop> resolves the property on the carried node.
- The node_prop stub re-fetch heuristic is removed: nothing produces
  id-tagged stubs anymore.

Tests (cypher suite): cypher_issue2208_with_agg_carried_node_props,
cypher_issue2208_with_agg_groups_by_node_identity,
cypher_issue2208_with_simple_carried_node_props.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-09-25 22:13:18 +02:00

5053 lines
192 KiB
C
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* test_cypher.c — Tests for the Cypher query engine.
*
* Ported from internal/cypher/cypher_test.go (1016 LOC).
* Covers lexer, parser, and end-to-end execution.
*/
#include "test_framework.h"
#include "../src/foundation/compat.h"
#include "../src/foundation/compat_thread.h"
#include <cypher/cypher.h>
#include <store/store.h>
#include <stdatomic.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#ifndef _WIN32
#include <sys/wait.h> /* fork/waitpid crash-isolation for the projection-width guard */
#include <unistd.h>
#endif
/* ══════════════════════════════════════════════════════════════════
* LEXER TESTS
* ══════════════════════════════════════════════════════════════════ */
TEST(cypher_lex_simple_match) {
cbm_lex_result_t r = {0};
int rc = cbm_lex("MATCH (n:Function)", &r);
ASSERT_EQ(rc, 0);
ASSERT_NULL(r.error);
/* MATCH ( n : Function ) EOF */
ASSERT_GTE(r.count, 6);
ASSERT_EQ(r.tokens[0].type, TOK_MATCH);
ASSERT_EQ(r.tokens[1].type, TOK_LPAREN);
ASSERT_EQ(r.tokens[2].type, TOK_IDENT);
ASSERT_STR_EQ(r.tokens[2].text, "n");
ASSERT_EQ(r.tokens[3].type, TOK_COLON);
ASSERT_EQ(r.tokens[4].type, TOK_IDENT);
ASSERT_STR_EQ(r.tokens[4].text, "Function");
ASSERT_EQ(r.tokens[5].type, TOK_RPAREN);
cbm_lex_free(&r);
PASS();
}
TEST(cypher_lex_relationship) {
cbm_lex_result_t r = {0};
int rc = cbm_lex("-[:CALLS]->", &r);
ASSERT_EQ(rc, 0);
/* - [ : CALLS ] - > EOF */
ASSERT_GTE(r.count, 7);
ASSERT_EQ(r.tokens[0].type, TOK_DASH);
ASSERT_EQ(r.tokens[1].type, TOK_LBRACKET);
ASSERT_EQ(r.tokens[2].type, TOK_COLON);
ASSERT_EQ(r.tokens[3].type, TOK_IDENT);
ASSERT_STR_EQ(r.tokens[3].text, "CALLS");
ASSERT_EQ(r.tokens[4].type, TOK_RBRACKET);
ASSERT_EQ(r.tokens[5].type, TOK_DASH);
ASSERT_EQ(r.tokens[6].type, TOK_GT);
cbm_lex_free(&r);
PASS();
}
TEST(cypher_lex_string_literal) {
cbm_lex_result_t r = {0};
int rc = cbm_lex("\"hello world\"", &r);
ASSERT_EQ(rc, 0);
ASSERT_GTE(r.count, 1);
ASSERT_EQ(r.tokens[0].type, TOK_STRING);
ASSERT_STR_EQ(r.tokens[0].text, "hello world");
cbm_lex_free(&r);
PASS();
}
TEST(cypher_lex_single_quote_string) {
cbm_lex_result_t r = {0};
int rc = cbm_lex("'hello'", &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.tokens[0].type, TOK_STRING);
ASSERT_STR_EQ(r.tokens[0].text, "hello");
cbm_lex_free(&r);
PASS();
}
TEST(cypher_lex_string_overflow) {
/* Build a string literal longer than 4096 bytes to verify we don't
* overflow the stack buffer in lex_string_literal. */
const int big = 5000;
/* query: "AAAA...A" (quotes included) */
char *query = malloc(big + 3); /* quote + big chars + quote + NUL */
ASSERT_NOT_NULL(query);
query[0] = '"';
memset(query + 1, 'A', big);
query[big + 1] = '"';
query[big + 2] = '\0';
cbm_lex_result_t r = {0};
int rc = cbm_lex(query, &r);
ASSERT_EQ(rc, 0);
ASSERT_NULL(r.error);
ASSERT_GTE(r.count, 1);
ASSERT_EQ(r.tokens[0].type, TOK_STRING);
/* The string should be truncated to CBM_SZ_4K - 1 (4095) characters. */
ASSERT_EQ((int)strlen(r.tokens[0].text), 4095);
cbm_lex_free(&r);
free(query);
PASS();
}
TEST(cypher_lex_number) {
cbm_lex_result_t r = {0};
int rc = cbm_lex("42 3.14", &r);
ASSERT_EQ(rc, 0);
ASSERT_GTE(r.count, 2);
ASSERT_EQ(r.tokens[0].type, TOK_NUMBER);
ASSERT_STR_EQ(r.tokens[0].text, "42");
ASSERT_EQ(r.tokens[1].type, TOK_NUMBER);
ASSERT_STR_EQ(r.tokens[1].text, "3.14");
cbm_lex_free(&r);
PASS();
}
TEST(cypher_lex_operators) {
cbm_lex_result_t r = {0};
int rc = cbm_lex("= =~ >= <= ..", &r);
ASSERT_EQ(rc, 0);
ASSERT_GTE(r.count, 5);
ASSERT_EQ(r.tokens[0].type, TOK_EQ);
ASSERT_EQ(r.tokens[1].type, TOK_EQTILDE);
ASSERT_EQ(r.tokens[2].type, TOK_GTE);
ASSERT_EQ(r.tokens[3].type, TOK_LTE);
ASSERT_EQ(r.tokens[4].type, TOK_DOTDOT);
cbm_lex_free(&r);
PASS();
}
TEST(cypher_lex_keywords_case_insensitive) {
cbm_lex_result_t r = {0};
int rc = cbm_lex("match WHERE Return limit", &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.tokens[0].type, TOK_MATCH);
ASSERT_EQ(r.tokens[1].type, TOK_WHERE);
ASSERT_EQ(r.tokens[2].type, TOK_RETURN);
ASSERT_EQ(r.tokens[3].type, TOK_LIMIT);
cbm_lex_free(&r);
PASS();
}
TEST(cypher_lex_pipe_and_star) {
cbm_lex_result_t r = {0};
int rc = cbm_lex("[:TYPE1|TYPE2*1..3]", &r);
ASSERT_EQ(rc, 0);
/* [ : TYPE1 | TYPE2 * 1 .. 3 ] */
ASSERT_GTE(r.count, 9);
ASSERT_EQ(r.tokens[3].type, TOK_PIPE);
ASSERT_EQ(r.tokens[5].type, TOK_STAR);
ASSERT_EQ(r.tokens[6].type, TOK_NUMBER);
ASSERT_STR_EQ(r.tokens[6].text, "1");
ASSERT_EQ(r.tokens[7].type, TOK_DOTDOT);
ASSERT_EQ(r.tokens[8].type, TOK_NUMBER);
ASSERT_STR_EQ(r.tokens[8].text, "3");
cbm_lex_free(&r);
PASS();
}
TEST(cypher_lex_full_query) {
const char *q = "MATCH (f:Function)-[:CALLS]->(g:Function) "
"WHERE f.name =~ \".*Order.*\" "
"RETURN f.name, g.name LIMIT 10";
cbm_lex_result_t r = {0};
int rc = cbm_lex(q, &r);
ASSERT_EQ(rc, 0);
ASSERT_NULL(r.error);
/* Should have many tokens; just check it doesn't crash */
ASSERT_GT(r.count, 20);
cbm_lex_free(&r);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* PARSER TESTS
* ══════════════════════════════════════════════════════════════════ */
TEST(cypher_parse_simple_node) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MATCH (f:Function)", &q, &err);
ASSERT_EQ(rc, 0);
ASSERT_NULL(err);
ASSERT_NOT_NULL(q);
ASSERT_EQ(cbm_query_pattern(q).node_count, 1);
ASSERT_EQ(cbm_query_pattern(q).rel_count, 0);
ASSERT_STR_EQ(cbm_query_pattern(q).nodes[0].variable, "f");
ASSERT_STR_EQ(cbm_query_pattern(q).nodes[0].label, "Function");
cbm_query_free(q);
PASS();
}
/* Trailing input must be an error, never a silent drop. The parser used to
* stop at the first thing it did not understand and report success, so the
* engine answered from the fragment it had parsed. */
TEST(cypher_parse_rejects_trailing_tokens) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MATCH (f:Function) RETURN f.name AS n BANANA SPLIT 99", &q, &err);
ASSERT_NEQ(rc, 0);
ASSERT_NOT_NULL(err);
ASSERT_NULL(q);
/* The message must name what actually stopped the parse, and must not
* mention WITH: this query has no WITH in it anywhere. */
ASSERT(strstr(err, "BANANA") != NULL);
ASSERT(strstr(err, "WITH") == NULL);
free(err);
PASS();
}
/* Only one WITH is supported. A second one used to take the rest of the
* query with it — the filter and the RETURN both vanished, and every row
* came back unfiltered under the default projection. */
TEST(cypher_parse_rejects_second_with_clause) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MATCH (f:Function) "
"OPTIONAL MATCH (a)-[:CALLS]->(f) "
"WITH f, count(a) AS calls "
"OPTIONAL MATCH (b)-[:USAGE]->(f) "
"WITH f, calls, count(b) AS usages "
"WHERE calls = 0 AND usages = 0 "
"RETURN f.name AS n",
&q, &err);
ASSERT_NEQ(rc, 0);
ASSERT_NOT_NULL(err);
ASSERT_NULL(q);
/* Here the note earns its place. The parse stops at OPTIONAL, and the
* reason is the second WITH further along, which the reader cannot see
* from the stopping point alone. */
ASSERT(strstr(err, "only one WITH clause is supported") != NULL);
free(err);
PASS();
}
/* The guard must not reject a query that is simply finished. One WITH, a
* WHERE after it and a RETURN is the shape the grammar does support. */
TEST(cypher_parse_accepts_single_with_clause) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MATCH (f:Function) "
"WITH f, f.name AS n "
"WHERE n = 'buildTree' "
"RETURN n",
&q, &err);
ASSERT_EQ(rc, 0);
ASSERT_NULL(err);
ASSERT_NOT_NULL(q);
cbm_query_free(q);
PASS();
}
TEST(cypher_parse_relationship_outbound) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MATCH (f:Function)-[:CALLS]->(g:Function)", &q, &err);
ASSERT_EQ(rc, 0);
ASSERT_NOT_NULL(q);
ASSERT_EQ(cbm_query_pattern(q).node_count, 2);
ASSERT_EQ(cbm_query_pattern(q).rel_count, 1);
ASSERT_STR_EQ(cbm_query_pattern(q).rels[0].types[0], "CALLS");
ASSERT_STR_EQ(cbm_query_pattern(q).rels[0].direction, "outbound");
ASSERT_EQ(cbm_query_pattern(q).rels[0].min_hops, 1);
ASSERT_EQ(cbm_query_pattern(q).rels[0].max_hops, 1);
cbm_query_free(q);
PASS();
}
TEST(cypher_parse_relationship_inbound) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MATCH (f:Function)<-[:CALLS]-(g:Function)", &q, &err);
ASSERT_EQ(rc, 0);
ASSERT_NOT_NULL(q);
ASSERT_STR_EQ(cbm_query_pattern(q).rels[0].direction, "inbound");
cbm_query_free(q);
PASS();
}
TEST(cypher_parse_relationship_any) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MATCH (f:Function)-[:CALLS]-(g:Function)", &q, &err);
ASSERT_EQ(rc, 0);
ASSERT_NOT_NULL(q);
ASSERT_STR_EQ(cbm_query_pattern(q).rels[0].direction, "any");
cbm_query_free(q);
PASS();
}
TEST(cypher_parse_variable_length) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MATCH (f:Function)-[:CALLS*1..3]->(g:Function)", &q, &err);
ASSERT_EQ(rc, 0);
ASSERT_NOT_NULL(q);
ASSERT_EQ(cbm_query_pattern(q).rels[0].min_hops, 1);
ASSERT_EQ(cbm_query_pattern(q).rels[0].max_hops, 3);
cbm_query_free(q);
PASS();
}
TEST(cypher_parse_variable_length_unbounded) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MATCH (f)-[:CALLS*]->(g)", &q, &err);
ASSERT_EQ(rc, 0);
ASSERT_NOT_NULL(q);
ASSERT_EQ(cbm_query_pattern(q).rels[0].min_hops, 1);
ASSERT_EQ(cbm_query_pattern(q).rels[0].max_hops, 0); /* 0 = unbounded */
cbm_query_free(q);
PASS();
}
TEST(cypher_parse_multiple_edge_types) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MATCH (f)-[:CALLS|HTTP_CALLS]->(g)", &q, &err);
ASSERT_EQ(rc, 0);
ASSERT_NOT_NULL(q);
ASSERT_EQ(cbm_query_pattern(q).rels[0].type_count, 2);
ASSERT_STR_EQ(cbm_query_pattern(q).rels[0].types[0], "CALLS");
ASSERT_STR_EQ(cbm_query_pattern(q).rels[0].types[1], "HTTP_CALLS");
cbm_query_free(q);
PASS();
}
TEST(cypher_parse_where_clause) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MATCH (f:Function) WHERE f.name = \"Foo\"", &q, &err);
ASSERT_EQ(rc, 0);
ASSERT_NOT_NULL(q);
ASSERT_NOT_NULL(q->where);
ASSERT_NOT_NULL(q->where->root);
ASSERT_EQ(q->where->root->type, EXPR_CONDITION);
ASSERT_STR_EQ(q->where->root->cond.variable, "f");
ASSERT_STR_EQ(q->where->root->cond.property, "name");
ASSERT_STR_EQ(q->where->root->cond.op, "=");
ASSERT_STR_EQ(q->where->root->cond.value, "Foo");
cbm_query_free(q);
PASS();
}
TEST(cypher_parse_where_regex) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MATCH (f:Function) WHERE f.name =~ \".*Order.*\"", &q, &err);
ASSERT_EQ(rc, 0);
ASSERT_NOT_NULL(q->where->root);
ASSERT_EQ(q->where->root->type, EXPR_CONDITION);
ASSERT_STR_EQ(q->where->root->cond.op, "=~");
ASSERT_STR_EQ(q->where->root->cond.value, ".*Order.*");
cbm_query_free(q);
PASS();
}
TEST(cypher_parse_where_and) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MATCH (f:Function) WHERE f.name = \"A\" AND f.label = \"Function\"",
&q, &err);
ASSERT_EQ(rc, 0);
ASSERT_NOT_NULL(q->where->root);
ASSERT_EQ(q->where->root->type, EXPR_AND);
ASSERT_NOT_NULL(q->where->root->left);
ASSERT_NOT_NULL(q->where->root->right);
cbm_query_free(q);
PASS();
}
TEST(cypher_parse_return_simple) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MATCH (f:Function) RETURN f.name, f.qualified_name", &q, &err);
ASSERT_EQ(rc, 0);
ASSERT_NOT_NULL(q->ret);
ASSERT_EQ(q->ret->count, 2);
ASSERT_STR_EQ(q->ret->items[0].variable, "f");
ASSERT_STR_EQ(q->ret->items[0].property, "name");
ASSERT_STR_EQ(q->ret->items[1].property, "qualified_name");
cbm_query_free(q);
PASS();
}
TEST(cypher_parse_return_count) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MATCH (f)-[:CALLS]->(g) RETURN f.name, COUNT(g) AS cnt", &q, &err);
ASSERT_EQ(rc, 0);
ASSERT_EQ(q->ret->count, 2);
ASSERT_NOT_NULL(q->ret->items[1].func);
ASSERT_STR_EQ(q->ret->items[1].func, "COUNT");
ASSERT_STR_EQ(q->ret->items[1].alias, "cnt");
cbm_query_free(q);
PASS();
}
TEST(cypher_parse_return_order_limit) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc =
cbm_cypher_parse("MATCH (f:Function) RETURN f.name ORDER BY f.name DESC LIMIT 5", &q, &err);
ASSERT_EQ(rc, 0);
ASSERT_EQ(q->ret->order_key_count, 1);
ASSERT_STR_EQ(q->ret->order_keys[0], "f.name");
ASSERT(q->ret->order_descs[0]);
ASSERT_EQ(q->ret->limit, 5);
cbm_query_free(q);
PASS();
}
/* #1334: every ORDER BY key is parsed (per-key direction) and the LIMIT that
* follows the key list is consumed instead of silently dropped. */
TEST(cypher_parse_multikey_order_by_issue1334) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse(
"MATCH (f:Function) RETURN f.name ORDER BY f.complexity DESC, f.name ASC LIMIT 5", &q,
&err);
ASSERT_EQ(rc, 0);
ASSERT_EQ(q->ret->order_key_count, 2);
ASSERT_STR_EQ(q->ret->order_keys[0], "f.complexity");
ASSERT(q->ret->order_descs[0]);
ASSERT_STR_EQ(q->ret->order_keys[1], "f.name");
ASSERT_FALSE(q->ret->order_descs[1]);
ASSERT_EQ(q->ret->limit, 5);
cbm_query_free(q);
PASS();
}
/* #1334: more keys than the modeled maximum is a loud parse error - the old
* failure mode (ignore the remainder, drop the LIMIT) must never come back. */
TEST(cypher_parse_order_by_over_cap_rejected_issue1334) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MATCH (f:Function) RETURN f.name ORDER BY "
"f.a, f.b, f.c, f.d, f.e, f.f, f.g, f.h, f.i LIMIT 5",
&q, &err);
ASSERT(rc != 0);
free(err);
PASS();
}
/* #1994: a non-numeric SKIP/LIMIT operand must be a loud parse error too. The old
* failure mode was the same one #1334 banned by a different route: expect()
* returned NULL, the clause was dropped, and the query still reported success
* with limit left at its -1 "no LIMIT" sentinel - so query_graph answered a
* bounded query with the entire result set. Cypher parameters ($limit) are the
* everyday trigger: they are what a Neo4j-shaped client writes by default. */
TEST(cypher_parse_nonnumeric_limit_rejected_issue1994) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MATCH (f:Function) RETURN f.name LIMIT $limit", &q, &err);
ASSERT(rc != 0);
free(err);
PASS();
}
TEST(cypher_parse_nonnumeric_skip_rejected_issue1994) {
cbm_query_t *q = NULL;
char *err = NULL;
/* The orphaned operand also swallowed the LIMIT that followed it. */
int rc = cbm_cypher_parse("MATCH (f:Function) RETURN f.name SKIP $offset LIMIT 10", &q, &err);
ASSERT(rc != 0);
free(err);
PASS();
}
TEST(cypher_parse_word_limit_operand_rejected_issue1994) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MATCH (f:Function) RETURN f.name LIMIT abc", &q, &err);
ASSERT(rc != 0);
free(err);
PASS();
}
/* Control: a well-formed SKIP/LIMIT still parses and still carries its values. */
TEST(cypher_parse_numeric_skip_limit_still_accepted) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MATCH (f:Function) RETURN f.name SKIP 2 LIMIT 10", &q, &err);
ASSERT_EQ(rc, 0);
ASSERT_EQ(q->ret->skip, 2);
ASSERT_EQ(q->ret->limit, 10);
cbm_query_free(q);
PASS();
}
TEST(cypher_parse_return_distinct) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MATCH (f:Function) RETURN DISTINCT f.label", &q, &err);
ASSERT_EQ(rc, 0);
ASSERT(q->ret->distinct);
cbm_query_free(q);
PASS();
}
TEST(cypher_parse_inline_props) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MATCH (f:Function {name: \"Foo\"})", &q, &err);
ASSERT_EQ(rc, 0);
ASSERT_EQ(cbm_query_pattern(q).nodes[0].prop_count, 1);
ASSERT_STR_EQ(cbm_query_pattern(q).nodes[0].props[0].key, "name");
ASSERT_STR_EQ(cbm_query_pattern(q).nodes[0].props[0].value, "Foo");
cbm_query_free(q);
PASS();
}
TEST(cypher_parse_error) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("INVALID QUERY", &q, &err);
ASSERT_EQ(rc, -1);
ASSERT_NOT_NULL(err);
free(err);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* EXECUTION TESTS (end-to-end against store)
* ══════════════════════════════════════════════════════════════════ */
/* Helper: set up the standard test graph.
* Nodes: HandleOrder, ValidateOrder, SubmitOrder (Function), main (Module), LogError (Function)
* Edges: HandleOrder→ValidateOrder (CALLS), ValidateOrder→SubmitOrder (CALLS),
* HandleOrder→LogError (CALLS), main→HandleOrder (DEFINES)
*/
static cbm_store_t *setup_cypher_store(void) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t n1 = {.project = "test",
.label = "Function",
.name = "HandleOrder",
.qualified_name = "test.HandleOrder",
.file_path = "handler.go",
.start_line = 10,
.end_line = 30};
cbm_node_t n2 = {.project = "test",
.label = "Function",
.name = "ValidateOrder",
.qualified_name = "test.ValidateOrder",
.file_path = "validate.go",
.start_line = 5,
.end_line = 15};
cbm_node_t n3 = {.project = "test",
.label = "Function",
.name = "SubmitOrder",
.qualified_name = "test.SubmitOrder",
.file_path = "submit.go"};
cbm_node_t n4 = {
.project = "test", .label = "Module", .name = "main", .qualified_name = "test.main"};
cbm_node_t n5 = {.project = "test",
.label = "Function",
.name = "LogError",
.qualified_name = "test.LogError",
.file_path = "log.go"};
int64_t id1 = cbm_store_upsert_node(s, &n1);
int64_t id2 = cbm_store_upsert_node(s, &n2);
int64_t id3 = cbm_store_upsert_node(s, &n3);
int64_t id4 = cbm_store_upsert_node(s, &n4);
int64_t id5 = cbm_store_upsert_node(s, &n5);
cbm_edge_t e1 = {.project = "test", .source_id = id1, .target_id = id2, .type = "CALLS"};
cbm_edge_t e2 = {.project = "test", .source_id = id2, .target_id = id3, .type = "CALLS"};
cbm_edge_t e3 = {.project = "test", .source_id = id1, .target_id = id5, .type = "CALLS"};
cbm_edge_t e4 = {.project = "test", .source_id = id4, .target_id = id1, .type = "DEFINES"};
cbm_store_insert_edge(s, &e1);
cbm_store_insert_edge(s, &e2);
cbm_store_insert_edge(s, &e3);
cbm_store_insert_edge(s, &e4);
return s;
}
/* The query string is caller-supplied and the WHERE grammar recurses once per
* nested '(' and once per NOT, with no depth counter between the MCP entry point
* and the recursive descent. A few tens of KB of '(' therefore exhausted the
* stack at parse time. Parse in a forked child so the crash surfaces as a
* killing signal rather than taking the test runner with it; a bounded parser
* must reject the query cleanly instead. */
TEST(cypher_deep_nesting_rejected_not_crash) {
#ifdef _WIN32
SKIP_PLATFORM("fork crash-isolation is POSIX-only; the depth cap is platform-agnostic");
#else
enum { NEST = 30000 };
fflush(NULL);
pid_t pid = fork();
if (pid == 0) {
char *q = malloc(NEST * 2 + 64);
if (!q) {
_exit(2);
}
int n = snprintf(q, 64, "MATCH (f:Function) WHERE ");
for (int i = 0; i < NEST; i++) {
q[n++] = '(';
}
n += snprintf(q + n, 32, "f.name = \"x\"");
for (int i = 0; i < NEST; i++) {
q[n++] = ')';
}
q[n] = '\0';
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
/* Any clean outcome is acceptable — success or a parse error. Only a
* crash is a failure, and that is what the signal check below catches. */
(void)cbm_cypher_execute(s, q, "test", 0, &r);
cbm_cypher_result_free(&r);
cbm_store_close(s);
free(q);
_exit(0);
}
ASSERT_TRUE(pid > 0);
int status = 0;
(void)waitpid(pid, &status, 0);
if (WIFSIGNALED(status)) {
char m[96];
snprintf(m, sizeof(m), "parser killed by signal %d — unbounded recursion depth",
WTERMSIG(status));
FAIL(m);
}
ASSERT_TRUE(WIFEXITED(status));
ASSERT_EQ(WEXITSTATUS(status), 0);
PASS();
#endif
}
TEST(cypher_exec_match_all_functions) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (f:Function)", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 4); /* HandleOrder, ValidateOrder, SubmitOrder, LogError */
ASSERT_GT(r.col_count, 0);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* Regression: an OPTIONAL MATCH whose label matches zero nodes drove
* cross_join_nodes with extra_count == 0. The old allocation
* (bind_count * 0 + 1) reserved a single binding slot, but the OPTIONAL
* fallback then wrote one binding per existing row — a heap buffer overflow
* once the first MATCH bound more than one node (ASan: heap-buffer-overflow).
* (The same function also used a plain-int bind_count*extra_count product,
* which wraps to a tiny malloc on large graphs; the count is now computed and
* bounds-checked in size_t by cbm_cypher_cross_join_alloc — exercised at its
* arithmetic boundary by cypher_cross_join_alloc_rejects_overflow below.)
* The query text is agent-controlled via the MCP query tool. */
TEST(cypher_exec_optional_empty_label_no_overflow) {
cbm_store_t *s = setup_cypher_store(); /* 4 Function nodes */
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (a:Function) OPTIONAL MATCH (b:NoSuchLabel) RETURN a.name", "test", 0, &r);
ASSERT_EQ(rc, 0);
/* One row per Function, each with b left unbound (dead-code semantics). */
ASSERT_EQ(r.row_count, 4);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* Regression: expand_pattern_rels sized its OPTIONAL-expansion output buffer as
* bind_cap*10 + 1 — room for the bounded expansion (max_new = bind_cap*10) plus
* a SINGLE OPTIONAL fallback row. When one source saturated the expansion to
* max_new and two or more later sources took the OPTIONAL (no-match) path, the
* second fallback write ran past the allocation (ASan: heap-buffer-overflow).
*
* The fix sizes the buffer for both writers losslessly (max_new + *bind_count),
* so every OPTIONAL no-match row keeps its slot — no overflow AND no dropped
* rows. Here the hub saturates the expansion to max_new while all 20 leaves take
* the fallback; the buffer holds them all, and the max_rows LIMIT bounds only the
* OUTPUT. Query text is agent-controlled via the MCP query tool. */
TEST(cypher_exec_optional_rel_saturated_no_overflow) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
/* 1 hub + 20 leaf Function nodes → bind_cap = 21, max_new = 210, so the hop
* buffer holds max_new + *bind_count = 231 rows. The hub is inserted first so
* it is expanded before the leaves; it saturates the expansion to max_new,
* then each of the 20 leaves adds one OPTIONAL fallback row — under the old
* alloc (211 slots) the 2nd such write overflowed; now all 230 fit. */
cbm_node_t hub = {
.project = "test", .label = "Function", .name = "hub", .qualified_name = "test.hub"};
int64_t hub_id = cbm_store_upsert_node(s, &hub);
for (int i = 0; i < 20; i++) {
char nm[32];
char qn[48];
snprintf(nm, sizeof(nm), "leaf%02d", i);
snprintf(qn, sizeof(qn), "test.leaf%02d", i);
cbm_node_t leaf = {
.project = "test", .label = "Function", .name = nm, .qualified_name = qn};
cbm_store_upsert_node(s, &leaf);
}
/* Give the hub 300 CALLS edges (> max_new = 210) so its expansion saturates
* max_new; targets are non-Function so they don't inflate bind_cap. */
for (int i = 0; i < 300; i++) {
char nm[32];
char qn[48];
snprintf(nm, sizeof(nm), "callee%d", i);
snprintf(qn, sizeof(qn), "test.callee%d", i);
cbm_node_t callee = {.project = "test", .label = "Var", .name = nm, .qualified_name = qn};
int64_t cid = cbm_store_upsert_node(s, &callee);
cbm_edge_t e = {.project = "test", .source_id = hub_id, .target_id = cid, .type = "CALLS"};
cbm_store_insert_edge(s, &e);
}
/* max_rows below the Function count (21) so bind_cap tracks scan_count (21)
* rather than the 100000 result ceiling — the same regime a large repo
* (> ceiling functions) or an agent-supplied small limit hits. */
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (a:Function) OPTIONAL MATCH (a)-[:CALLS]->(b) RETURN a.name", "test", 5, &r);
/* Bounded success, no overflow (ASan proves the buffer holds every row); the
* LIMIT caps the output rows rather than the query crashing. */
ASSERT_EQ(rc, 0);
ASSERT_GT(r.row_count, 0);
ASSERT_TRUE(r.row_count <= 5);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* Reproduce-first: after the expansion budget is exhausted, OPTIONAL MATCH must
* not FABRICATE a "no match" row for a source that genuinely has matches.
*
* process_edges / expand_var_length used to carry the budget in the LOOP
* condition (`ei < edge_count && *new_count < max_new`), so once new_count hit
* max_new they stopped iterating entirely and never incremented match_count —
* even though neighbours existed. expand_pattern_rels' ungated fallback then saw
* match_count == 0 and emitted an unbound row. `WHERE b IS NULL` reads that as
* "nothing points here", so a dead-code query reported LIVE code as dead.
*
* Shape: A saturates the budget, B genuinely has no callees, C has 5. Only B may
* appear with an unbound b. Asserting on C specifically is what discriminates —
* the pre-fix code emits C with an empty b, and a `row_count` check would not
* notice. Deterministic: insertion order fixes the scan order (rowid), and every
* count is exact. */
TEST(cypher_exec_optional_saturated_does_not_fabricate_no_match) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
/* 3 Function nodes → scan_count = 3; max_rows = 3 → bind_cap = 3 and
* max_new = 30. A alone exceeds that, so B and C are reached with the
* budget already spent — the regime that produced the fabrication. */
cbm_node_t a = {
.project = "test", .label = "Function", .name = "A", .qualified_name = "test.A"};
cbm_node_t b = {
.project = "test", .label = "Function", .name = "B", .qualified_name = "test.B"};
cbm_node_t c = {
.project = "test", .label = "Function", .name = "C", .qualified_name = "test.C"};
int64_t a_id = cbm_store_upsert_node(s, &a);
(void)cbm_store_upsert_node(s, &b); /* B: no outgoing CALLS at all */
int64_t c_id = cbm_store_upsert_node(s, &c);
ASSERT_GT(a_id, 0);
ASSERT_GT(c_id, 0);
/* Callees are label Var so they do not inflate scan_count/bind_cap. */
for (int i = 0; i < 40; i++) {
char nm[32];
char qn[48];
snprintf(nm, sizeof(nm), "acallee%d", i);
snprintf(qn, sizeof(qn), "test.acallee%d", i);
cbm_node_t t = {.project = "test", .label = "Var", .name = nm, .qualified_name = qn};
int64_t tid = cbm_store_upsert_node(s, &t);
cbm_edge_t e = {.project = "test", .source_id = a_id, .target_id = tid, .type = "CALLS"};
cbm_store_insert_edge(s, &e);
}
for (int i = 0; i < 5; i++) {
char nm[32];
char qn[48];
snprintf(nm, sizeof(nm), "ccallee%d", i);
snprintf(qn, sizeof(qn), "test.ccallee%d", i);
cbm_node_t t = {.project = "test", .label = "Var", .name = nm, .qualified_name = qn};
int64_t tid = cbm_store_upsert_node(s, &t);
cbm_edge_t e = {.project = "test", .source_id = c_id, .target_id = tid, .type = "CALLS"};
cbm_store_insert_edge(s, &e);
}
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (f:Function) OPTIONAL MATCH (f)-[:CALLS]->(g) WHERE g IS NULL RETURN f.name",
"test", 3, &r);
ASSERT_EQ(rc, 0);
/* Positive control: B genuinely has no callees, so the query must still find
* it. Without this a "C is absent" assertion could pass on an empty result. */
bool saw_b = false;
bool saw_c = false;
for (int i = 0; i < r.row_count; i++) {
const char *name = r.rows[i][0];
if (name && strcmp(name, "B") == 0) {
saw_b = true;
}
if (name && strcmp(name, "C") == 0) {
saw_c = true;
}
}
ASSERT_TRUE(saw_b);
/* The discriminator: C has 5 callees, so claiming it has none is a
* fabrication. Pre-fix this is exactly what the saturated path emitted. */
ASSERT_FALSE(saw_c);
/* Relationship expansion materializes every matched row (a cap here once
* falsified aggregates), so nothing was left unevaluated. */
ASSERT_FALSE(r.truncated);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* Arithmetic-boundary companion to the zero-label overflow above: the node
* cross-join sizes its buffer from bind_count * extra_count. As a plain int that
* product wraps past INT_MAX to a negative/garbage malloc size (the large-graph
* #627 failure mode). cbm_cypher_cross_join_alloc now computes it in size_t and
* rejects a count that would not fit the int binding counter or overflow the
* byte size. Tested directly so the boundary is exercised without allocating
* billions of bindings. */
TEST(cypher_cross_join_alloc_rejects_overflow) {
size_t n = 0;
/* 46341 * 46341 = 2147488281 > INT_MAX (2147483647): pre-fix the int product
* wrapped negative -> tiny malloc -> heap OOB. Now rejected. */
ASSERT_TRUE(cbm_cypher_cross_join_alloc(46341, 46341, false, &n) != 0);
/* A normal join still succeeds: bind_count * extra_count + 1 slots. */
ASSERT_EQ(cbm_cypher_cross_join_alloc(4, 3, false, &n), 0);
ASSERT_EQ(n, (size_t)13);
/* OPTIONAL with no extra nodes reserves one fallback row per binding + 1. */
ASSERT_EQ(cbm_cypher_cross_join_alloc(4, 0, true, &n), 0);
ASSERT_EQ(n, (size_t)5);
/* Non-OPTIONAL with no extra nodes: just the sentinel slot. */
ASSERT_EQ(cbm_cypher_cross_join_alloc(4, 0, false, &n), 0);
ASSERT_EQ(n, (size_t)1);
PASS();
}
/* Companion to the truncation regression: when the expansion does NOT saturate
* the ceiling, every leaf's OPTIONAL fallback row must survive with its target
* unbound. A `row_count > 0` check is too weak — it can pass on hub rows alone —
* so this asserts a specific leaf appears with an empty b.name, and that a real
* expanded hub row is present too. */
TEST(cypher_exec_optional_rel_leaf_fallback_survives) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
/* 1 hub (2 CALLS edges) + 3 leaves (no edges). max_rows 0 is defaulted to
* CYPHER_RESULT_CEILING (100000) in cbm_cypher_execute before bind_cap is
* computed, so bind_cap = max(scan_count, 100000) = 100000 and the buffer is
* far larger than needed here — this exercises the fallback rows, not the
* saturation edge. */
cbm_node_t hub = {
.project = "test", .label = "Function", .name = "hub", .qualified_name = "test.hub"};
int64_t hub_id = cbm_store_upsert_node(s, &hub);
for (int i = 0; i < 3; i++) {
char nm[32];
char qn[48];
snprintf(nm, sizeof(nm), "leaf%d", i);
snprintf(qn, sizeof(qn), "test.leaf%d", i);
cbm_node_t leaf = {
.project = "test", .label = "Function", .name = nm, .qualified_name = qn};
cbm_store_upsert_node(s, &leaf);
}
for (int i = 0; i < 2; i++) {
char nm[32];
char qn[48];
snprintf(nm, sizeof(nm), "callee%d", i);
snprintf(qn, sizeof(qn), "test.callee%d", i);
cbm_node_t callee = {.project = "test", .label = "Var", .name = nm, .qualified_name = qn};
int64_t cid = cbm_store_upsert_node(s, &callee);
cbm_edge_t e = {.project = "test", .source_id = hub_id, .target_id = cid, .type = "CALLS"};
cbm_store_insert_edge(s, &e);
}
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (a:Function) OPTIONAL MATCH (a)-[:CALLS]->(b) RETURN a.name, b.name", "test", 0,
&r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.col_count, 2);
/* Scan for a leaf fallback row (a.name = "leaf0", b.name unbound = "") and a
* real expanded hub row (a.name = "hub", b.name non-empty). */
bool leaf_fallback = false;
bool hub_expanded = false;
for (int i = 0; i < r.row_count; i++) {
const char *a = r.rows[i][0];
const char *b = r.rows[i][1];
if (strcmp(a, "leaf0") == 0 && b[0] == '\0') {
leaf_fallback = true;
}
if (strcmp(a, "hub") == 0 && b[0] != '\0') {
hub_expanded = true;
}
}
ASSERT_TRUE(leaf_fallback); /* the OPTIONAL no-match row survived */
ASSERT_TRUE(hub_expanded); /* the expansion still produced bound rows */
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* Sibling of the leaf-fallback test, but for the BOUND-TERMINAL expansion path
* (expand_from_bound_terminal): the OPTIONAL start var is unbound and the
* terminal is bound, e.g. `MATCH (f) OPTIONAL MATCH (c)-[:CALLS]->(f)`. That
* function sized its hop buffer bind_count*10 + 1 and gated the OPTIONAL
* fallback on `new_count < max_new`, so once one terminal's expansion saturated
* the buffer, every LATER terminal's no-match row was silently dropped — the
* rows `WHERE c IS NULL` is meant to surface. Not an overflow (the guard kept
* the write in bounds) but real data loss. Lossless sizing (bind_count*10 +
* bind_count, fallback ungated) preserves them. */
TEST(cypher_exec_bound_terminal_optional_fallback_survives) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
/* 2 Function nodes (hub + leaf) → bind_count = 2, max_new = 20. The hub has
* 21 incoming CALLS edges (> max_new), so its expansion saturates the write
* buffer; the leaf has none, so it must still yield its OPTIONAL no-match
* row. Under the original "+ SKIP_ONE" sizing that row was dropped once the
* buffer filled; the lossless sizing preserves it regardless of the order in
* which the (unordered) label scan visits the two terminals. */
cbm_node_t hub = {
.project = "test", .label = "Function", .name = "hub", .qualified_name = "test.hub"};
int64_t hub_id = cbm_store_upsert_node(s, &hub);
cbm_node_t leaf = {
.project = "test", .label = "Function", .name = "leaf", .qualified_name = "test.leaf"};
cbm_store_upsert_node(s, &leaf);
/* Callers are non-Function so they do not inflate bind_count. Each CALLS the
* hub (source = caller, target = hub), so from the bound terminal `hub` the
* expansion binds the unbound start `c` to each caller. */
for (int i = 0; i < 21; i++) {
char nm[32];
char qn[48];
snprintf(nm, sizeof(nm), "caller%02d", i);
snprintf(qn, sizeof(qn), "test.caller%02d", i);
cbm_node_t caller = {.project = "test", .label = "Var", .name = nm, .qualified_name = qn};
int64_t cid = cbm_store_upsert_node(s, &caller);
cbm_edge_t e = {.project = "test", .source_id = cid, .target_id = hub_id, .type = "CALLS"};
cbm_store_insert_edge(s, &e);
}
/* max_rows 0 → the 100000 result ceiling, so the output LIMIT does not hide
* the leaf row; we are testing the hop buffer, not the output cap. */
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (f:Function) OPTIONAL MATCH (c)-[:CALLS]->(f) RETURN f.name, c.name", "test", 0,
&r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.col_count, 2);
/* The leaf terminal has no incoming CALLS edge, so it must yield one row with
* the start var `c` unbound (""). Before the fix this row was dropped once the
* hub saturated the buffer. Also confirm the hub still expanded to bound rows. */
bool leaf_fallback = false;
bool hub_expanded = false;
for (int i = 0; i < r.row_count; i++) {
const char *f = r.rows[i][0];
const char *c = r.rows[i][1];
if (strcmp(f, "leaf") == 0 && c[0] == '\0') {
leaf_fallback = true;
}
if (strcmp(f, "hub") == 0 && c[0] != '\0') {
hub_expanded = true;
}
}
ASSERT_TRUE(leaf_fallback); /* the bound-terminal OPTIONAL no-match row survived */
ASSERT_TRUE(hub_expanded); /* the expansion still produced bound rows */
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* Discriminating companion to the test above: preserving OPTIONAL no-match rows
* under saturation must NOT be bought by fabricating them. If match detection is
* gated on the same ceiling as the write, then once one terminal fills the
* buffer, another terminal that genuinely HAS callers is never scanned, its
* match_count stays 0, and the fallback invents an unbound "dead code" row for it
* — reporting live code as dead, which is worse than dropping a row.
*
* The construction is deliberately order-independent: BOTH hubs have enough
* callers to saturate the buffer on their own, so whichever the scan visits
* second is guaranteed to be processed after saturation. Under the gated variant
* that second hub is fabricated as dead; the assertion "no hub with callers is
* dead" then fails no matter which order `find_nodes_by_label` returns (its query
* has no ORDER BY, so the test must not depend on one). The fix makes it pass. */
TEST(cypher_exec_bound_terminal_saturation_no_false_deadcode) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
/* 3 Function terminals → bind_count = 3, max_new = 30. */
cbm_node_t hubA = {
.project = "test", .label = "Function", .name = "hubA", .qualified_name = "test.hubA"};
int64_t hubA_id = cbm_store_upsert_node(s, &hubA);
cbm_node_t hubB = {
.project = "test", .label = "Function", .name = "hubB", .qualified_name = "test.hubB"};
int64_t hubB_id = cbm_store_upsert_node(s, &hubB);
cbm_node_t leaf = {
.project = "test", .label = "Function", .name = "leaf", .qualified_name = "test.leaf"};
cbm_store_upsert_node(s, &leaf);
/* BOTH hubs get 35 callers (> max_new = 30), so either one saturates the write
* buffer by itself; leaf gets none. Callers are non-Function so they don't
* inflate bind_count. */
for (int i = 0; i < 70; i++) {
char nm[32];
char qn[48];
snprintf(nm, sizeof(nm), "caller%02d", i);
snprintf(qn, sizeof(qn), "test.caller%02d", i);
cbm_node_t caller = {.project = "test", .label = "Var", .name = nm, .qualified_name = qn};
int64_t cid = cbm_store_upsert_node(s, &caller);
int64_t tgt = i < 35 ? hubA_id : hubB_id; /* 35 -> hubA, 35 -> hubB */
cbm_edge_t e = {.project = "test", .source_id = cid, .target_id = tgt, .type = "CALLS"};
cbm_store_insert_edge(s, &e);
}
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (f:Function) OPTIONAL MATCH (c)-[:CALLS]->(f) RETURN f.name, c.name", "test", 0,
&r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.col_count, 2);
bool hub_expanded = false; /* sanity: the buffer really did fill from a hub */
bool hub_false_deadcode = false; /* the bug: a hub with callers invented as dead */
bool leaf_deadcode = false; /* the lossless property: genuine dead code kept */
for (int i = 0; i < r.row_count; i++) {
const char *f = r.rows[i][0];
const char *c = r.rows[i][1];
bool is_hub = strcmp(f, "hubA") == 0 || strcmp(f, "hubB") == 0;
if (is_hub && c[0] != '\0') {
hub_expanded = true;
}
if (is_hub && c[0] == '\0') {
hub_false_deadcode = true;
}
if (strcmp(f, "leaf") == 0 && c[0] == '\0') {
leaf_deadcode = true;
}
}
ASSERT_TRUE(hub_expanded);
ASSERT_FALSE(hub_false_deadcode); /* live code with callers must never appear as dead */
ASSERT_TRUE(leaf_deadcode); /* genuine no-match row still survives saturation */
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_where_eq) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc =
cbm_cypher_execute(s, "MATCH (f:Function) WHERE f.name = \"HandleOrder\"", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* #1196: max_rows limits projected results, not the source-node candidates
* considered before WHERE. The old unlabeled scan searched only
* 10 * max_rows nodes, so a valid match later in search order disappeared. */
TEST(cypher_exec_unlabeled_where_beyond_result_limit_issue1196) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK);
for (int i = 0; i < 11; i++) {
char name[32];
char qn[64];
snprintf(name, sizeof(name), "early_%02d", i);
snprintf(qn, sizeof(qn), "test.%s", name);
cbm_node_t distractor = {.project = "test",
.label = "Function",
.name = name,
.qualified_name = qn,
.file_path = "early.py"};
ASSERT_GT(cbm_store_upsert_node(s, &distractor), 0);
}
cbm_node_t late = {.project = "test",
.label = "Function",
.name = "zz_late_match",
.qualified_name = "test.zz_late_match",
.file_path = "late.py"};
ASSERT_GT(cbm_store_upsert_node(s, &late), 0);
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (n) WHERE n.name = \"zz_late_match\" RETURN n.name",
"test", 1, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "zz_late_match");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* #1196 (second mechanism): relationship expansion capped this hop's TOTAL
* output at bind_cap*10, so edges past the cap were silently dropped BEFORE
* WHERE and aggregation — a count() then reported the scanned prefix as if
* it were a fact (field-measured: 9,360 of 13,691 DEFINES with a labeled
* source and --max-rows 1000). max_rows is an OUTPUT-row limit (projection
* already enforces it); expansion must see every matched edge. Fixture: 2
* labeled sources with 30 edges each; max_rows=2 makes the old cap 20. */
TEST(cypher_exec_aggregate_sees_all_edges_beyond_expansion_cap_issue1196) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK);
int64_t src_ids[2];
for (int i = 0; i < 2; i++) {
char name[32];
char qn[64];
snprintf(name, sizeof(name), "file_%d", i);
snprintf(qn, sizeof(qn), "test.%s", name);
cbm_node_t src = {.project = "test",
.label = "File",
.name = name,
.qualified_name = qn,
.file_path = name};
src_ids[i] = cbm_store_upsert_node(s, &src);
ASSERT_GT(src_ids[i], 0);
}
for (int i = 0; i < 2; i++) {
for (int j = 0; j < 30; j++) {
char name[32];
char qn[64];
snprintf(name, sizeof(name), "def_%d_%02d", i, j);
snprintf(qn, sizeof(qn), "test.%s", name);
cbm_node_t target = {.project = "test",
.label = "Function",
.name = name,
.qualified_name = qn,
.file_path = "defs.py"};
int64_t tid = cbm_store_upsert_node(s, &target);
ASSERT_GT(tid, 0);
cbm_edge_t e = {
.project = "test", .source_id = src_ids[i], .target_id = tid, .type = "DEFINES"};
cbm_store_insert_edge(s, &e);
}
}
/* Aggregate: one output row, so max_rows=2 never limits the OUTPUT —
* only the (buggy) expansion. Ground truth: 60 edges. */
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (a:File)-[rel]->(b) RETURN count(rel)", "test", 2, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "60");
cbm_cypher_result_free(&r);
/* The list form must saturate at the output limit, not at the scan:
* max_rows=25 returns exactly 25 rows (old cap: bind_cap=25 -> 250,
* fine here — but max_rows=2 must return 2 rows, not 2-of-20-scanned). */
cbm_cypher_result_t r2 = {0};
rc = cbm_cypher_execute(s, "MATCH (a:File)-[rel]->(b) RETURN b.name", "test", 2, &r2);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r2.row_count, 2);
cbm_cypher_result_free(&r2);
cbm_store_close(s);
PASS();
}
/* #874: coalesce(var.prop, literal) in WHERE — null-safe numeric filters
* for audit queries over OPTIONAL graph properties. The parser rejected the
* call outright ("unexpected operator"); RETURN-side coalesce already
* worked, so only the WHERE leaf needs it. Semantics: when the property is
* missing/empty, the literal default is compared instead. */
/* #797: variable-length / repeated-variable path semantics. Fixture:
* loopy has a SELF-LOOP as one of its outbound CALLS edges plus a real
* 2-chain loopy->mid->leaf. Correct openCypher semantics:
* - a repeated node variable must unify: (a)-[:CALLS]->(a) matches ONLY
* the self-loop, not every edge;
* - relationship uniqueness within a path: the self-loop cannot be
* traversed repeatedly, so no *k..k path exists beyond the real chain;
* - the engine hop cap must not fabricate or silently truncate results. */
TEST(cypher_exec_varlength_path_semantics_issue797) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t loopy = {.project = "test",
.label = "Function",
.name = "loopy",
.qualified_name = "test.mod.loopy",
.file_path = "mod.go",
.start_line = 1,
.end_line = 2};
cbm_node_t mid = {.project = "test",
.label = "Function",
.name = "mid",
.qualified_name = "test.mod.mid",
.file_path = "mod.go",
.start_line = 3,
.end_line = 4};
cbm_node_t leaf = {.project = "test",
.label = "Function",
.name = "leaf",
.qualified_name = "test.mod.leaf",
.file_path = "mod.go",
.start_line = 5,
.end_line = 6};
int64_t id_loopy = cbm_store_upsert_node(s, &loopy);
int64_t id_mid = cbm_store_upsert_node(s, &mid);
int64_t id_leaf = cbm_store_upsert_node(s, &leaf);
ASSERT_GT(id_loopy, 0);
cbm_edge_t self_loop = {
.project = "test", .source_id = id_loopy, .target_id = id_loopy, .type = "CALLS"};
cbm_edge_t e1 = {
.project = "test", .source_id = id_loopy, .target_id = id_mid, .type = "CALLS"};
cbm_edge_t e2 = {.project = "test", .source_id = id_mid, .target_id = id_leaf, .type = "CALLS"};
cbm_store_insert_edge(s, &self_loop);
cbm_store_insert_edge(s, &e1);
cbm_store_insert_edge(s, &e2);
/* Bug 1: repeated variable must unify — only the self-loop matches. */
cbm_cypher_result_t r1 = {0};
ASSERT_EQ(cbm_cypher_execute(s, "MATCH (a)-[:CALLS]->(a) RETURN a.name", "test", 0, &r1), 0);
ASSERT_EQ(r1.row_count, 1);
cbm_cypher_result_free(&r1);
/* Bug 2: *2..2 from loopy has two relationship-unique trails: the
* self-loop followed by e1 reaches mid, and e1 followed by e2 reaches leaf.
* Reusing the self-loop within one trail remains forbidden. */
cbm_cypher_result_t r2 = {0};
ASSERT_EQ(cbm_cypher_execute(s,
"MATCH (a {name: \"loopy\"})-[:CALLS*2..2]->(b) "
"RETURN DISTINCT b.name",
"test", 0, &r2),
0);
ASSERT_EQ(r2.row_count, 2);
bool saw_mid = false;
bool saw_leaf = false;
for (int i = 0; i < r2.row_count; i++) {
saw_mid |= strcmp(r2.rows[i][0], "mid") == 0;
saw_leaf |= strcmp(r2.rows[i][0], "leaf") == 0;
}
ASSERT_TRUE(saw_mid);
ASSERT_TRUE(saw_leaf);
cbm_cypher_result_free(&r2);
/* Bug 2 amplifier: no directed path of length 5 exists at all. */
cbm_cypher_result_t r3 = {0};
ASSERT_EQ(cbm_cypher_execute(s,
"MATCH (a {name: \"loopy\"})-[:CALLS*5..5]->(b) "
"RETURN b.name",
"test", 0, &r3),
0);
ASSERT_EQ(r3.row_count, 0);
cbm_cypher_result_free(&r3);
/* Bug 3: a hop range beyond the engine ceiling must be an ADVERTISED
* clamp, not silently indistinguishable from "no such path". */
cbm_cypher_result_t r4 = {0};
ASSERT_EQ(
cbm_cypher_execute(s, "MATCH (a)-[:CALLS*150..150]->(b) RETURN b.name", "test", 0, &r4), 0);
ASSERT_EQ(r4.row_count, 0);
ASSERT_NOT_NULL(r4.warning);
ASSERT_NOT_NULL(strstr(r4.warning, "clamped"));
/* The warning reports the policy clamp, but this shallow fixture has no
* candidate beyond the ceiling, so evaluation was still exhaustive. */
ASSERT_FALSE(r4.truncated);
cbm_cypher_result_free(&r4);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_where_coalesce_issue874) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t a = {.project = "test",
.label = "Function",
.name = "deep_a",
.qualified_name = "test.mod.deep_a",
.file_path = "mod.py",
.start_line = 1,
.end_line = 2,
.properties_json = "{\"transitive_loop_depth\":3}"};
cbm_node_t b = {.project = "test",
.label = "Function",
.name = "deep_b",
.qualified_name = "test.mod.deep_b",
.file_path = "mod.py",
.start_line = 3,
.end_line = 4,
.properties_json = "{\"transitive_loop_depth\":1}"};
cbm_node_t c = {.project = "test",
.label = "Function",
.name = "plain_c",
.qualified_name = "test.mod.plain_c",
.file_path = "mod.py",
.start_line = 5,
.end_line = 6};
ASSERT_GT(cbm_store_upsert_node(s, &a), 0);
ASSERT_GT(cbm_store_upsert_node(s, &b), 0);
ASSERT_GT(cbm_store_upsert_node(s, &c), 0);
/* Default FAILS the predicate: only the node with depth 3 matches. */
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function) WHERE "
"coalesce(f.transitive_loop_depth, 0) >= 2 "
"RETURN f.qualified_name LIMIT 10",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
cbm_cypher_result_free(&r);
/* Default PASSES: the property-less node is included via the default. */
cbm_cypher_result_t r2 = {0};
rc = cbm_cypher_execute(s,
"MATCH (f:Function) WHERE "
"coalesce(f.transitive_loop_depth, 9) >= 2 "
"RETURN f.qualified_name LIMIT 10",
"test", 0, &r2);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r2.row_count, 2); /* deep_a (3) + plain_c (default 9) */
cbm_cypher_result_free(&r2);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_where_regex) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc =
cbm_cypher_execute(s, "MATCH (f:Function) WHERE f.name =~ \".*Order.*\"", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 3); /* HandleOrder, ValidateOrder, SubmitOrder */
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_where_contains) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc =
cbm_cypher_execute(s, "MATCH (f:Function) WHERE f.name CONTAINS \"Order\"", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 3);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_where_starts_with) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (f:Function) WHERE f.name STARTS WITH \"Handle\"", "test",
0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_return_properties) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function) WHERE f.name = \"HandleOrder\" "
"RETURN f.name, f.qualified_name, f.file_path",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_EQ(r.col_count, 3);
/* Columns should be f.name, f.qualified_name, f.file_path */
ASSERT_STR_EQ(r.columns[0], "f.name");
ASSERT_STR_EQ(r.rows[0][0], "HandleOrder");
ASSERT_STR_EQ(r.rows[0][1], "test.HandleOrder");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* ── Scalar / introspection functions (full-suite Tier 1) ──────── */
TEST(cypher_func_labels) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (f:Function) WHERE f.name = \"HandleOrder\" RETURN labels(f)", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "[\"Function\"]");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_func_type) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (f:Function)-[r:CALLS]->(g:Function) RETURN type(r) LIMIT 1", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "CALLS");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_func_id) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (f:Function) WHERE f.name = \"HandleOrder\" RETURN id(f)",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
/* id is a non-empty numeric string */
ASSERT_TRUE(r.rows[0][0][0] >= '0' && r.rows[0][0][0] <= '9');
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_func_keys) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (f:Function) WHERE f.name = \"HandleOrder\" RETURN keys(f)", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_TRUE(strstr(r.rows[0][0], "\"name\"") != NULL);
ASSERT_TRUE(strstr(r.rows[0][0], "\"qualified_name\"") != NULL);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_func_properties) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (f:Function) WHERE f.name = \"HandleOrder\" RETURN properties(f)", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_EQ(r.rows[0][0][0], '{'); /* a JSON object */
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_func_tointeger_tofloat) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function) WHERE f.name = \"HandleOrder\" "
"RETURN toInteger(f.start_line), toFloat(f.start_line)",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "10"); /* start_line = 10 */
ASSERT_STR_EQ(r.rows[0][1], "10");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_func_size_reverse) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function) WHERE f.name = \"LogError\" "
"RETURN size(f.name), length(f.name), reverse(f.name)",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "8"); /* "LogError" has 8 chars */
ASSERT_STR_EQ(r.rows[0][1], "8");
ASSERT_STR_EQ(r.rows[0][2], "rorrEgoL");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_func_multiarg) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function) WHERE f.name = \"HandleOrder\" "
"RETURN substring(f.name, 0, 6), left(f.name, 6), "
"right(f.name, 5), replace(f.name, \"Order\", \"Req\"), "
"coalesce(f.missing, \"fallback\")",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "Handle"); /* substring("HandleOrder",0,6) */
ASSERT_STR_EQ(r.rows[0][1], "Handle"); /* left(...,6) */
ASSERT_STR_EQ(r.rows[0][2], "Order"); /* right("HandleOrder",5) */
ASSERT_STR_EQ(r.rows[0][3], "HandleReq"); /* replace Order->Req */
ASSERT_STR_EQ(r.rows[0][4], "fallback"); /* coalesce: f.missing empty -> literal */
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* issue #874: coalesce() in WHERE — null-safe numeric filter over an optional
* JSON property. Exact repro shape from the issue: nodes lacking the property
* fall back to the literal default instead of failing to parse. */
TEST(cypher_issue874_where_coalesce_numeric) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t n1 = {.project = "test",
.label = "Function",
.name = "DeepLoop",
.qualified_name = "test.DeepLoop",
.file_path = "deep.go",
.properties_json = "{\"transitive_loop_depth\":5}"};
cbm_node_t n2 = {.project = "test",
.label = "Function",
.name = "NoMetrics",
.qualified_name = "test.NoMetrics",
.file_path = "flat.go"};
cbm_store_upsert_node(s, &n1);
cbm_store_upsert_node(s, &n2);
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function) "
"WHERE coalesce(f.transitive_loop_depth, 0) >= 2 "
"RETURN f.qualified_name LIMIT 10",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1); /* only DeepLoop; NoMetrics coalesces to 0 */
ASSERT_STR_EQ(r.rows[0][0], "test.DeepLoop");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* issue #874: coalesce() in WHERE with a string fallback and first-arg-wins. */
TEST(cypher_issue874_where_coalesce_string) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
/* Missing property on every node → fallback literal matches all 4 Functions */
int rc = cbm_cypher_execute(
s,
"MATCH (f:Function) WHERE coalesce(f.missing, \"fallback\") = \"fallback\" "
"RETURN f.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 4);
cbm_cypher_result_free(&r);
/* Present first arg wins over the fallback */
cbm_cypher_result_t r2 = {0};
rc = cbm_cypher_execute(
s, "MATCH (f:Function) WHERE coalesce(f.name, \"zz\") = \"HandleOrder\" RETURN f.name",
"test", 0, &r2);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r2.row_count, 1);
ASSERT_STR_EQ(r2.rows[0][0], "HandleOrder");
cbm_cypher_result_free(&r2);
cbm_store_close(s);
PASS();
}
/* issue #874: function LHS composes with NOT and AND like any other condition. */
TEST(cypher_issue874_where_coalesce_not_and) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (f:Function) WHERE NOT coalesce(f.missing, \"x\") = \"x\" RETURN f.name", "test",
0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 0); /* every node coalesces to "x" — NOT filters all */
cbm_cypher_result_free(&r);
cbm_cypher_result_t r2 = {0};
rc = cbm_cypher_execute(s,
"MATCH (f:Function) WHERE coalesce(f.missing, \"1\") = \"1\" "
"AND f.name CONTAINS \"Order\" RETURN f.name",
"test", 0, &r2);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r2.row_count, 3); /* HandleOrder, ValidateOrder, SubmitOrder */
cbm_cypher_result_free(&r2);
cbm_store_close(s);
PASS();
}
/* issue #874: the other multi-arg scalar functions work in WHERE too. */
TEST(cypher_issue874_where_substring) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (f:Function) WHERE substring(f.name, 0, 6) = \"Handle\" 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();
}
/* issue #874: an unrecognised function in WHERE must fail loudly with the
* supported set, not the misleading "unexpected operator". */
TEST(cypher_issue874_where_unsupported_func_error) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc =
cbm_cypher_parse("MATCH (f:Function) WHERE foo(f.name) = \"x\" RETURN f.name", &q, &err);
ASSERT_EQ(rc, -1);
ASSERT_NOT_NULL(err);
ASSERT_TRUE(strstr(err, "unsupported function 'foo'") != NULL);
free(err);
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};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function)-[:CALLS]->(g:Function) "
"RETURN f.name, g.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
/* HandleOrder→ValidateOrder, HandleOrder→LogError, ValidateOrder→SubmitOrder */
ASSERT_EQ(r.row_count, 3);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_calls_with_where) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function)-[:CALLS]->(g:Function) "
"WHERE f.name = \"HandleOrder\" "
"RETURN f.name, g.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 2); /* →ValidateOrder, →LogError */
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_inbound) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function)<-[:CALLS]-(g:Function) "
"WHERE f.name = \"ValidateOrder\" "
"RETURN g.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1); /* HandleOrder calls ValidateOrder */
ASSERT_STR_EQ(r.rows[0][0], "HandleOrder");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_count) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function)-[:CALLS]->(g:Function) "
"RETURN f.name, COUNT(g) AS cnt",
"test", 0, &r);
ASSERT_EQ(rc, 0);
/* HandleOrder→2, ValidateOrder→1 */
ASSERT_EQ(r.row_count, 2);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_optional_bound_terminal_count_ignores_unbound) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function) OPTIONAL MATCH (t)-[:CALLS]->(f) "
"RETURN f.name, COUNT(t) AS cnt",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 4);
for (int i = 0; i < r.row_count; i++) {
const char *name = r.rows[i][0];
const char *count = r.rows[i][1];
if (strcmp(name, "HandleOrder") == 0) {
ASSERT_STR_EQ(count, "0");
} else if (strcmp(name, "ValidateOrder") == 0 || strcmp(name, "SubmitOrder") == 0 ||
strcmp(name, "LogError") == 0) {
ASSERT_STR_EQ(count, "1");
} else {
ASSERT_TRUE(false);
}
}
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_limit) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN f.name LIMIT 2", "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_order_by) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN f.name ORDER BY f.name ASC", "test",
0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 4);
/* Alphabetical: HandleOrder, LogError, SubmitOrder, ValidateOrder */
ASSERT_STR_EQ(r.rows[0][0], "HandleOrder");
ASSERT_STR_EQ(r.rows[1][0], "LogError");
ASSERT_STR_EQ(r.rows[2][0], "SubmitOrder");
ASSERT_STR_EQ(r.rows[3][0], "ValidateOrder");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_variable_length) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
/* HandleOrder →CALLS→ ValidateOrder →CALLS→ SubmitOrder (2 hops) */
int rc = cbm_cypher_execute(s,
"MATCH (f:Function)-[:CALLS*1..3]->(g:Function) "
"WHERE f.name = \"HandleOrder\" "
"RETURN g.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
/* Should find: ValidateOrder (1 hop), SubmitOrder (2 hops), LogError (1 hop) */
ASSERT_GTE(r.row_count, 3);
ASSERT_FALSE(r.truncated);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_variable_length_repeated_node_var_unifies) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function)-[:CALLS*1..2]->(f:Function) "
"RETURN f.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 0);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* Reproduce-first (#887): an EXPLICIT variable-length upper bound must still be
* capped at the engine ceiling (cbm_cypher_max_depth(), default 10). On
* origin/main, expand_var_length honoured an explicit `*1..N` verbatim (only the
* unbounded `*` / `*..m` forms were capped), so `[:CALLS*1..N]` passed N straight
* to cbm_store_bfs — an unbounded traversal (a DoS on cyclic graphs). RED before
* the clamp: a *1..12 walk over a 13-node chain
* returns all 12 hops (N01..N12). GREEN after: it stops at the depth-10 ceiling
* (N01..N10); N11/N12 are never emitted. max_rows=64 keeps the binding-expansion
* cap (bind_cap*10) well above the hop count, so DEPTH — not the binding cap — is
* the bound under test. */
TEST(cypher_exec_var_length_explicit_bound_capped) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
/* Linear chain N00 -CALLS-> N01 -> ... -> N12 (13 nodes, one node per hop). */
int64_t ids[13];
for (int i = 0; i < 13; i++) {
char name[8];
char qn[24];
snprintf(name, sizeof(name), "N%02d", i);
snprintf(qn, sizeof(qn), "test.N%02d", i);
cbm_node_t n = {.project = "test",
.label = "Function",
.name = name,
.qualified_name = qn,
.file_path = "chain.go"};
ids[i] = cbm_store_upsert_node(s, &n);
}
for (int i = 0; i < 12; i++) {
cbm_edge_t e = {
.project = "test", .source_id = ids[i], .target_id = ids[i + 1], .type = "CALLS"};
cbm_store_insert_edge(s, &e);
}
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (a:Function {name: \"N00\"})-[:CALLS*1..12]->"
"(x:Function) RETURN x.name",
"test", 64, &r);
ASSERT_EQ(rc, 0);
/* Capped at 10 hops → exactly N01..N10; N11/N12 are beyond the ceiling. */
ASSERT_EQ(r.row_count, 10);
bool saw_n10 = false;
bool saw_n11 = false;
bool saw_n12 = false;
for (int i = 0; i < r.row_count; i++) {
const char *v = r.rows[i][0];
if (v && strcmp(v, "N10") == 0) {
saw_n10 = true;
}
if (v && strcmp(v, "N11") == 0) {
saw_n11 = true;
}
if (v && strcmp(v, "N12") == 0) {
saw_n12 = true;
}
}
ASSERT_TRUE(saw_n10); /* within the ceiling — proves the traversal really ran */
ASSERT_FALSE(saw_n11); /* clamped away */
ASSERT_FALSE(saw_n12);
ASSERT_TRUE(r.truncated);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* Pin the relationship-trail contract: a self-loop edge cannot be reused
* within one variable-length trail, so *2..2 yields no fabricated match. */
TEST(cypher_exec_var_length_no_reuse_self_loop) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t n = {.project = "test",
.label = "Function",
.name = "Recursive",
.qualified_name = "test.Recursive",
.file_path = "recursive.go"};
int64_t id = cbm_store_upsert_node(s, &n);
cbm_edge_t e = {.project = "test", .source_id = id, .target_id = id, .type = "CALLS"};
cbm_store_insert_edge(s, &e);
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function {name: \"Recursive\"})-[:CALLS*2..2]"
"->(g:Function) RETURN g.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 0);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_var_length_truncation_surfaces_warning) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
int64_t ids[18];
for (int i = 0; i < 18; i++) {
char name[16];
snprintf(name, sizeof(name), "node-%d", i);
cbm_node_t node = {.project = "test",
.label = "Function",
.name = name,
.qualified_name = name,
.file_path = "graph.c"};
ids[i] = cbm_store_upsert_node(s, &node);
}
for (int source = 0; source < 17; source++) {
for (int target = source + 1; target < 18; target++) {
cbm_edge_t edge = {.project = "test",
.source_id = ids[source],
.target_id = ids[target],
.type = "CALLS"};
cbm_store_insert_edge(s, &edge);
}
}
cbm_cypher_result_t result = {0};
int rc = cbm_cypher_execute(s,
"MATCH (a:Function {name: \"node-0\"})-[:CALLS*10..10]->"
"(b:Function) RETURN b.name",
"test", 0, &result);
ASSERT_EQ(rc, 0);
ASSERT_NOT_NULL(result.warning);
ASSERT_TRUE(strstr(result.warning, "traversal budget") != NULL);
ASSERT_TRUE(result.row_count > 0);
cbm_cypher_result_free(&result);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_defines_edge) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (m:Module)-[:DEFINES]->(f:Function) "
"RETURN m.name, f.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "main");
ASSERT_STR_EQ(r.rows[0][1], "HandleOrder");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_no_results) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc =
cbm_cypher_execute(s, "MATCH (f:Function) WHERE f.name = \"NonExistent\"", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 0);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_where_numeric) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function) WHERE f.start_line > \"8\" "
"RETURN f.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
/* HandleOrder starts at 10 */
ASSERT_GTE(r.row_count, 1);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* --- Ported from cypher_test.go: TestExecuteDistinct --- */
TEST(cypher_exec_distinct) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN DISTINCT f.label", "test", 0, &r);
ASSERT_EQ(rc, 0);
/* All 4 Function nodes share label "Function" → 1 distinct row */
ASSERT_EQ(r.row_count, 1);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* issue #238: WITH DISTINCT must deduplicate projected rows (previously the
* DISTINCT keyword on WITH was parsed but silently ignored). */
TEST(cypher_exec_with_distinct_issue238) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
/* 4 Function nodes all share label "Function" → WITH DISTINCT collapses to
* one row; without dedup this returned 4. */
int rc = cbm_cypher_execute(s, "MATCH (f:Function) WITH DISTINCT f.label AS lbl RETURN lbl",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
cbm_cypher_result_free(&r);
/* Control: without DISTINCT, all 4 rows flow through. */
cbm_cypher_result_t r2 = {0};
rc = cbm_cypher_execute(s, "MATCH (f:Function) WITH f.label AS lbl RETURN lbl", "test", 0, &r2);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r2.row_count, 4);
cbm_cypher_result_free(&r2);
cbm_store_close(s);
PASS();
}
/* issue #241: label tests in WHERE clauses (openCypher `WHERE n:Label`) —
* previously a parse error. */
TEST(cypher_exec_where_label_test_issue241) {
cbm_store_t *s = setup_cypher_store();
/* f:Function is true for all 4 Function nodes. */
cbm_cypher_result_t r = {0};
int rc =
cbm_cypher_execute(s, "MATCH (f:Function) WHERE f:Function RETURN f.name", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 4);
cbm_cypher_result_free(&r);
/* f:Class matches none of the functions. */
cbm_cypher_result_t r2 = {0};
rc = cbm_cypher_execute(s, "MATCH (f:Function) WHERE f:Class RETURN f.name", "test", 0, &r2);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r2.row_count, 0);
cbm_cypher_result_free(&r2);
/* Negated label test: NOT f:Class is always true. */
cbm_cypher_result_t r3 = {0};
rc =
cbm_cypher_execute(s, "MATCH (f:Function) WHERE NOT f:Class RETURN f.name", "test", 0, &r3);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r3.row_count, 4);
cbm_cypher_result_free(&r3);
cbm_store_close(s);
PASS();
}
/* issue #239: COUNT(DISTINCT x) — previously a parse error. */
TEST(cypher_exec_count_distinct_issue239) {
cbm_store_t *s = setup_cypher_store();
/* 4 functions all share label "Function" → COUNT(DISTINCT f.label) = 1. */
cbm_cypher_result_t r = {0};
int rc =
cbm_cypher_execute(s, "MATCH (f:Function) RETURN count(DISTINCT f.label)", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "1");
cbm_cypher_result_free(&r);
/* Non-distinct COUNT counts all 4 occurrences. */
cbm_cypher_result_t r2 = {0};
rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN count(f.label)", "test", 0, &r2);
ASSERT_EQ(rc, 0);
ASSERT_STR_EQ(r2.rows[0][0], "4");
cbm_cypher_result_free(&r2);
/* DISTINCT over the 4 unique function names = 4. */
cbm_cypher_result_t r3 = {0};
rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN count(DISTINCT f.name)", "test", 0, &r3);
ASSERT_EQ(rc, 0);
ASSERT_STR_EQ(r3.rows[0][0], "4");
cbm_cypher_result_free(&r3);
cbm_store_close(s);
PASS();
}
/* issue #373: an unsupported computed expression in WITH/RETURN (an unknown
* function like split(...) or list indexing [..]) must FAIL LOUDLY with a clear
* "unsupported function" error rather than silently projecting an empty column
* (which looks like a valid-but-blank result and hides the real problem). */
/* issue #1919: a variable the WITH clause dropped must be REFUSED, not projected
* as an empty column. `g` does not survive `WITH f.name AS caller`, so naming it
* afterwards is a query fault. The old code answered NULL for the unbound name,
* rendered it "", and exited clean — a column of nothing that reads as "the graph
* holds no such data" rather than "your query named something out of scope".
* Same principle as #373: fail loudly instead of projecting a blank column. */
TEST(cypher_rejects_projection_of_dropped_with_var_issue1919) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (f:Function)-[:CALLS]->(g) WITH f.name AS caller RETURN caller, g.name", "test",
0, &r);
ASSERT_TRUE(rc != 0);
ASSERT_NOT_NULL(r.error);
/* The message has to name the offending variable — an error that does not
* say which name is wrong sends the reader back to guessing. */
ASSERT_TRUE(strstr(r.error, "g") != NULL);
ASSERT_TRUE(strstr(r.error, "scope") != NULL);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* The guard must NOT reject an OPTIONAL MATCH target that simply did not match.
* `g` is a declared pattern variable there, so it stays in scope; it is merely
* unbound at run time, and projecting "" for it is the documented convention.
* This is the line between the two cases, and the reason the check reads the
* query's declared variables rather than the run-time bindings. */
TEST(cypher_optional_match_target_still_allowed_issue1919) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (f:Function) OPTIONAL MATCH (f)-[:CALLS]->(g) RETURN f.name, g.name", "test", 0,
&r);
ASSERT_EQ(rc, 0);
ASSERT_NULL(r.error);
ASSERT_TRUE(r.row_count > 0);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* An alias the WITH created is in scope afterwards, and a name carried through
* unchanged is too. Both must keep working. */
TEST(cypher_with_alias_stays_in_scope_issue1919) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function)-[:CALLS]->(g) WITH f.name AS caller, g AS "
"callee RETURN caller, callee.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_NULL(r.error);
ASSERT_TRUE(r.row_count > 0);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_unsupported_func_errors_issue373) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (f:Function) WITH split(f.name)[0] AS top, count(*) AS c RETURN top, c", "test",
0, &r);
ASSERT_TRUE(rc != 0); /* unsupported function now fails loudly */
ASSERT_NOT_NULL(r.error);
ASSERT_TRUE(strstr(r.error, "unsupported") != NULL);
ASSERT_TRUE(strstr(r.error, "split") != NULL);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* A recognised function still works, and an unknown one in plain RETURN errors. */
TEST(cypher_exec_unknown_func_return_errors) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN nosuchfunc(f.name)", "test", 0, &r);
ASSERT_TRUE(rc != 0);
ASSERT_NOT_NULL(r.error);
ASSERT_TRUE(strstr(r.error, "unsupported function") != NULL);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* issue #242: openCypher label alternation in MATCH — (n:A|B). */
TEST(cypher_exec_label_alternation_issue242) {
cbm_store_t *s = setup_cypher_store();
/* Store has 4 Function + 1 Module node → alternation seeds all 5. */
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (n:Function|Module) RETURN n.name", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 5);
cbm_cypher_result_free(&r);
/* Alternation with a non-existent label still returns the existing one. */
cbm_cypher_result_t r2 = {0};
rc = cbm_cypher_execute(s, "MATCH (n:Function|Class) RETURN n.name", "test", 0, &r2);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r2.row_count, 4);
cbm_cypher_result_free(&r2);
cbm_store_close(s);
PASS();
}
/* --- Ported from cypher_test.go: TestExecuteInlinePropertyFilter --- */
TEST(cypher_exec_inline_props) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function {name: \"SubmitOrder\"}) "
"RETURN f.name, f.qualified_name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* --- Ported from cypher_test.go: TestParseWhereStartsWith --- */
TEST(cypher_parse_where_starts_with) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc =
cbm_cypher_parse("MATCH (f:Function) WHERE f.name STARTS WITH \"Send\" RETURN f", &q, &err);
ASSERT_EQ(rc, 0);
ASSERT_NOT_NULL(q);
ASSERT_NOT_NULL(q->where);
ASSERT_NOT_NULL(q->where->root);
ASSERT_EQ(q->where->root->type, EXPR_CONDITION);
ASSERT_STR_EQ(q->where->root->cond.op, "STARTS WITH");
ASSERT_STR_EQ(q->where->root->cond.value, "Send");
cbm_query_free(q);
PASS();
}
/* --- Ported from cypher_test.go: TestParseWhereContains --- */
TEST(cypher_parse_where_contains) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc =
cbm_cypher_parse("MATCH (f:Function) WHERE f.name CONTAINS \"Handler\" RETURN f", &q, &err);
ASSERT_EQ(rc, 0);
ASSERT_NOT_NULL(q);
ASSERT_NOT_NULL(q->where);
ASSERT_NOT_NULL(q->where->root);
ASSERT_EQ(q->where->root->type, EXPR_CONDITION);
ASSERT_STR_EQ(q->where->root->cond.op, "CONTAINS");
ASSERT_STR_EQ(q->where->root->cond.value, "Handler");
cbm_query_free(q);
PASS();
}
/* --- Ported from cypher_test.go: TestParseWhereNumericComparison --- */
TEST(cypher_parse_where_numeric) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MATCH (f:Function) WHERE f.start_line > 10 RETURN f", &q, &err);
ASSERT_EQ(rc, 0);
ASSERT_NOT_NULL(q);
ASSERT_NOT_NULL(q->where);
ASSERT_NOT_NULL(q->where->root);
ASSERT_EQ(q->where->root->type, EXPR_CONDITION);
ASSERT_STR_EQ(q->where->root->cond.op, ">");
ASSERT_STR_EQ(q->where->root->cond.value, "10");
cbm_query_free(q);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* EDGE PROPERTY TESTS (ported from cypher_test.go Feature 2)
* ══════════════════════════════════════════════════════════════════ */
/* Helper: set up store with HTTP_CALLS edge having properties.
* Creates same graph as setup_cypher_store + one HTTP_CALLS edge. */
static cbm_store_t *setup_cypher_http_store(void) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t n1 = {.project = "test",
.label = "Function",
.name = "HandleOrder",
.qualified_name = "test.main.HandleOrder",
.file_path = "main.go",
.start_line = 10,
.end_line = 30};
cbm_node_t n2 = {.project = "test",
.label = "Function",
.name = "ValidateOrder",
.qualified_name = "test.service.ValidateOrder",
.file_path = "service.go",
.start_line = 5,
.end_line = 20};
cbm_node_t n3 = {.project = "test",
.label = "Function",
.name = "SubmitOrder",
.qualified_name = "test.service.SubmitOrder",
.file_path = "service.go",
.start_line = 25,
.end_line = 50};
int64_t id1 = cbm_store_upsert_node(s, &n1);
cbm_store_upsert_node(s, &n2);
int64_t id3 = cbm_store_upsert_node(s, &n3);
cbm_edge_t http = {
.project = "test",
.source_id = id1,
.target_id = id3,
.type = "HTTP_CALLS",
.properties_json =
"{\"url_path\":\"/api/orders\",\"confidence\":0.85,\"method\":\"POST\"}"};
cbm_store_insert_edge(s, &http);
return s;
}
/* Helper: set up store with TWO HTTP_CALLS edges for filtering tests. */
static cbm_store_t *setup_cypher_multi_edge_store(void) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "testproj", "/tmp/test");
cbm_node_t n1 = {.project = "testproj",
.label = "Function",
.name = "SendOrder",
.qualified_name = "testproj.caller.SendOrder",
.file_path = "caller/client.go"};
cbm_node_t n2 = {.project = "testproj",
.label = "Function",
.name = "HandleOrder",
.qualified_name = "testproj.handler.HandleOrder",
.file_path = "handler/routes.go"};
cbm_node_t n3 = {.project = "testproj",
.label = "Function",
.name = "HandleHealth",
.qualified_name = "testproj.handler.HandleHealth",
.file_path = "handler/health.go"};
int64_t id1 = cbm_store_upsert_node(s, &n1);
int64_t id2 = cbm_store_upsert_node(s, &n2);
int64_t id3 = cbm_store_upsert_node(s, &n3);
cbm_edge_t e1 = {.project = "testproj",
.source_id = id1,
.target_id = id2,
.type = "HTTP_CALLS",
.properties_json =
"{\"url_path\":\"/api/orders\",\"confidence\":0.85,\"method\":\"POST\"}"};
cbm_edge_t e2 = {.project = "testproj",
.source_id = id1,
.target_id = id3,
.type = "HTTP_CALLS",
.properties_json = "{\"url_path\":\"/health\",\"confidence\":0.45}"};
cbm_store_insert_edge(s, &e1);
cbm_store_insert_edge(s, &e2);
return s;
}
/* Helper: find a column value in a cypher result row */
static const char *cypher_get_col(const cbm_cypher_result_t *r, int row, const char *col) {
for (int c = 0; c < r->col_count; c++) {
if (strcmp(r->columns[c], col) == 0)
return r->rows[row][c];
}
return NULL;
}
/* Helper: check if any row has a column matching a value */
static bool cypher_has_row_with(const cbm_cypher_result_t *r, const char *col, const char *val) {
int ci = -1;
for (int c = 0; c < r->col_count; c++) {
if (strcmp(r->columns[c], col) == 0) {
ci = c;
break;
}
}
if (ci < 0)
return false;
for (int row = 0; row < r->row_count; row++) {
if (strcmp(r->rows[row][ci], val) == 0)
return true;
}
return false;
}
TEST(cypher_edge_prop_access) {
cbm_store_t *s = setup_cypher_http_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (a:Function)-[r:HTTP_CALLS]->(b:Function) "
"RETURN a.name, b.name, r.url_path, r.confidence",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(cypher_get_col(&r, 0, "a.name"), "HandleOrder");
ASSERT_STR_EQ(cypher_get_col(&r, 0, "b.name"), "SubmitOrder");
ASSERT_STR_EQ(cypher_get_col(&r, 0, "r.url_path"), "/api/orders");
ASSERT_STR_EQ(cypher_get_col(&r, 0, "r.confidence"), "0.85");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
typedef struct {
atomic_int *ready;
atomic_int *start;
bool succeeded;
} cypher_edge_thread_ctx_t;
static void *cypher_edge_props_concurrently(void *opaque) {
cypher_edge_thread_ctx_t *ctx = opaque;
cbm_store_t *store = setup_cypher_http_store();
if (!store) {
return NULL;
}
/* Keep projection busy after the store scan has completed. A single-edge
* query can be incidentally ordered by SQLite's internal mutexes, masking
* the independent Cypher scratch-buffer race from TSan. */
cbm_node_t source = {.project = "test",
.label = "Function",
.name = "HandleOrder",
.qualified_name = "test.main.HandleOrder",
.file_path = "main.go",
.start_line = 10,
.end_line = 30};
int64_t source_id = cbm_store_upsert_node(store, &source);
for (int i = 0; i < 256; i++) {
char name[64];
char qualified_name[96];
snprintf(name, sizeof(name), "ConcurrentTarget%d", i);
snprintf(qualified_name, sizeof(qualified_name), "test.concurrent.%s", name);
cbm_node_t target = {.project = "test",
.label = "Function",
.name = name,
.qualified_name = qualified_name,
.file_path = "concurrent.go"};
int64_t target_id = cbm_store_upsert_node(store, &target);
cbm_edge_t edge = {
.project = "test",
.source_id = source_id,
.target_id = target_id,
.type = "HTTP_CALLS",
.properties_json =
"{\"url_path\":\"/api/orders\",\"confidence\":0.85,\"method\":\"POST\"}"};
if (source_id < 0 || target_id < 0 || cbm_store_insert_edge(store, &edge) < 0) {
cbm_store_close(store);
return NULL;
}
}
atomic_fetch_add_explicit(ctx->ready, 1, memory_order_release);
while (atomic_load_explicit(ctx->start, memory_order_acquire) == 0) {
cbm_usleep(1000);
}
ctx->succeeded = true;
for (int i = 0; i < 128; i++) {
cbm_cypher_result_t result = {0};
int rc = cbm_cypher_execute(store,
"MATCH (a:Function)-[r:HTTP_CALLS]->(b:Function) "
"RETURN r.url_path, r.confidence, r.method",
"test", 0, &result);
if (rc != 0 || result.row_count != 257 ||
strcmp(cypher_get_col(&result, 0, "r.url_path"), "/api/orders") != 0 ||
strcmp(cypher_get_col(&result, 0, "r.confidence"), "0.85") != 0 ||
strcmp(cypher_get_col(&result, 0, "r.method"), "POST") != 0) {
ctx->succeeded = false;
}
cbm_cypher_result_free(&result);
if (!ctx->succeeded) {
break;
}
}
cbm_store_close(store);
return NULL;
}
/* Daemon sessions execute independent graph queries concurrently. TSan must
* see no shared rotating edge-property scratch buffer between those threads. */
TEST(cypher_edge_prop_storage_is_per_thread) {
atomic_int ready;
atomic_int start;
atomic_init(&ready, 0);
atomic_init(&start, 0);
cypher_edge_thread_ctx_t ctx[2] = {
{.ready = &ready, .start = &start},
{.ready = &ready, .start = &start},
};
cbm_thread_t threads[2];
bool started0 = cbm_thread_create(&threads[0], 0, cypher_edge_props_concurrently, &ctx[0]) == 0;
bool started1 = cbm_thread_create(&threads[1], 0, cypher_edge_props_concurrently, &ctx[1]) == 0;
for (int spins = 0; started0 && started1 && spins < 5000 &&
atomic_load_explicit(&ready, memory_order_acquire) < 2;
spins++) {
cbm_usleep(1000);
}
bool both_ready = atomic_load_explicit(&ready, memory_order_acquire) == 2;
atomic_store_explicit(&start, 1, memory_order_release);
if (started0) {
(void)cbm_thread_join(&threads[0]);
}
if (started1) {
(void)cbm_thread_join(&threads[1]);
}
ASSERT_TRUE(started0);
ASSERT_TRUE(started1);
ASSERT_TRUE(both_ready);
ASSERT_TRUE(ctx[0].succeeded);
ASSERT_TRUE(ctx[1].succeeded);
PASS();
}
TEST(cypher_edge_prop_in_where) {
cbm_store_t *s = setup_cypher_http_store();
cbm_cypher_result_t r = {0};
/* confidence > 0.8 → should match */
int rc = cbm_cypher_execute(s,
"MATCH (a)-[r:HTTP_CALLS]->(b) WHERE r.confidence > 0.8 "
"RETURN a.name, b.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
cbm_cypher_result_free(&r);
/* confidence > 0.9 → should NOT match */
memset(&r, 0, sizeof(r));
rc = cbm_cypher_execute(s,
"MATCH (a)-[r:HTTP_CALLS]->(b) WHERE r.confidence > 0.9 "
"RETURN a.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 0);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_edge_type_prop) {
cbm_store_t *s = setup_cypher_http_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (a)-[r:HTTP_CALLS]->(b) RETURN r.type", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(cypher_get_col(&r, 0, "r.type"), "HTTP_CALLS");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_edge_filter_contains) {
cbm_store_t *s = setup_cypher_multi_edge_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (a)-[r:HTTP_CALLS]->(b) WHERE r.url_path CONTAINS 'orders' "
"RETURN a.name, b.name, r.url_path",
"testproj", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(cypher_get_col(&r, 0, "a.name"), "SendOrder");
ASSERT_STR_EQ(cypher_get_col(&r, 0, "b.name"), "HandleOrder");
ASSERT_STR_EQ(cypher_get_col(&r, 0, "r.url_path"), "/api/orders");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_edge_filter_numeric_gte) {
cbm_store_t *s = setup_cypher_multi_edge_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (a)-[r:HTTP_CALLS]->(b) WHERE r.confidence >= 0.6 "
"RETURN a.name, b.name, r.confidence LIMIT 20",
"testproj", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(cypher_get_col(&r, 0, "b.name"), "HandleOrder");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_bare_edge_return_exposes_properties_json) {
/* `RETURN r` on an edge variable, with no property accessor, should
* surface the edge's full properties JSON (or "{}"). Before the fix,
* binding_get_virtual returned an empty string, which made bare edge
* returns useless for callers that wanted to inspect timestamps,
* weights, etc. without naming each property up front. */
cbm_store_t *s = setup_cypher_multi_edge_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (a)-[r:HTTP_CALLS]->(b) WHERE r.method = 'POST' RETURN r",
"testproj", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
const char *r_val = cypher_get_col(&r, 0, "r");
ASSERT_NOT_NULL(r_val);
/* Expect JSON object content rather than the previous empty string. */
ASSERT_NOT_NULL(strstr(r_val, "url_path"));
ASSERT_NOT_NULL(strstr(r_val, "/api/orders"));
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_edge_return_without_filter) {
cbm_store_t *s = setup_cypher_multi_edge_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (a)-[r:HTTP_CALLS]->(b) "
"RETURN a.name, b.name, r.url_path, r.confidence LIMIT 20",
"testproj", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_GTE(r.row_count, 2);
ASSERT(cypher_has_row_with(&r, "r.url_path", "/api/orders"));
ASSERT(cypher_has_row_with(&r, "r.url_path", "/health"));
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_edge_filter_equals) {
cbm_store_t *s = setup_cypher_multi_edge_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (a)-[r:HTTP_CALLS]->(b) WHERE r.method = 'POST' "
"RETURN a.name, b.name",
"testproj", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(cypher_get_col(&r, 0, "b.name"), "HandleOrder");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_edge_filter_starts_with) {
cbm_store_t *s = setup_cypher_multi_edge_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (a)-[r:HTTP_CALLS]->(b) WHERE r.url_path STARTS WITH '/api' "
"RETURN a.name, b.name, r.url_path",
"testproj", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(cypher_get_col(&r, 0, "r.url_path"), "/api/orders");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_edge_combined_node_and_edge_filter) {
cbm_store_t *s = setup_cypher_multi_edge_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (a:Function)-[r:HTTP_CALLS]->(b:Function) "
"WHERE a.name = 'SendOrder' AND r.confidence >= 0.6 "
"RETURN b.name, r.url_path",
"testproj", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(cypher_get_col(&r, 0, "b.name"), "HandleOrder");
ASSERT_STR_EQ(cypher_get_col(&r, 0, "r.url_path"), "/api/orders");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_edge_filter_no_match) {
cbm_store_t *s = setup_cypher_multi_edge_store();
cbm_cypher_result_t r = {0};
/* No edge has method = 'DELETE' */
int rc = cbm_cypher_execute(s,
"MATCH (a)-[r:HTTP_CALLS]->(b) WHERE r.method = 'DELETE' "
"RETURN a.name",
"testproj", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 0);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_edge_filter_numeric_lt) {
cbm_store_t *s = setup_cypher_multi_edge_store();
cbm_cypher_result_t r = {0};
/* Only health edge (0.45) should match confidence < 0.5 */
int rc = cbm_cypher_execute(s,
"MATCH (a)-[r:HTTP_CALLS]->(b) WHERE r.confidence < 0.5 "
"RETURN b.name, r.confidence",
"testproj", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(cypher_get_col(&r, 0, "b.name"), "HandleHealth");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_edge_filter_regex) {
cbm_store_t *s = setup_cypher_multi_edge_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (a)-[r:HTTP_CALLS]->(b) WHERE r.url_path =~ \"/api/.*\" "
"RETURN b.name",
"testproj", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(cypher_get_col(&r, 0, "b.name"), "HandleOrder");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_edge_builtin_type_filter) {
cbm_store_t *s = setup_cypher_multi_edge_store();
cbm_cypher_result_t r = {0};
/* Untyped rel [r] — filter on r.type in WHERE */
int rc = cbm_cypher_execute(s,
"MATCH (a)-[r]->(b) WHERE r.type = 'HTTP_CALLS' "
"RETURN a.name, b.name LIMIT 20",
"testproj", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 2); /* Both HTTP_CALLS edges */
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* Ported from cypher_test.go: TestApplyLimitRespectsExplicit */
TEST(cypher_apply_limit) {
/* Create store with many nodes */
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "lim", "/tmp/lim");
for (int i = 0; i < 50; i++) {
char name[32], qn[64];
snprintf(name, sizeof(name), "func%d", i);
snprintf(qn, sizeof(qn), "lim.func%d", i);
cbm_node_t n = {.project = "lim",
.label = "Function",
.name = name,
.qualified_name = qn,
.file_path = "test.go"};
cbm_store_upsert_node(s, &n);
}
/* LIMIT 5 → 5 rows */
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN f.name LIMIT 5", "lim", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 5);
ASSERT_FALSE(r.truncated);
cbm_cypher_result_free(&r);
/* No LIMIT, max_rows=10 → capped at 10 */
memset(&r, 0, sizeof(r));
rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN f.name", "lim", 10, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 10);
ASSERT_TRUE(r.truncated);
cbm_cypher_result_free(&r);
/* LIMIT above max_rows → explicit limit wins */
memset(&r, 0, sizeof(r));
rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN f.name LIMIT 30", "lim", 10, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 30);
ASSERT_FALSE(r.truncated);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* max_rows is an engine output budget, not Cypher query semantics. Callers
* need to distinguish a complete short result from one cut off by that budget.
* An explicit LIMIT remains complete because it is part of the query itself. */
TEST(cypher_result_reports_max_rows_saturation) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "sat", "/tmp/sat");
for (int i = 0; i < 7; i++) {
char name[32];
char qn[64];
snprintf(name, sizeof(name), "func%d", i);
snprintf(qn, sizeof(qn), "sat.func%d", i);
cbm_node_t n = {.project = "sat",
.label = "Function",
.name = name,
.qualified_name = qn,
.file_path = "test.c"};
cbm_store_upsert_node(s, &n);
}
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN f.name", "sat", 3, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 3);
ASSERT_TRUE(r.truncated);
cbm_cypher_result_free(&r);
memset(&r, 0, sizeof(r));
rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN f.name", "sat", 10, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 7);
ASSERT_FALSE(r.truncated);
cbm_cypher_result_free(&r);
memset(&r, 0, sizeof(r));
rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN f.name LIMIT 3", "sat", 10, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 3);
ASSERT_FALSE(r.truncated);
cbm_cypher_result_free(&r);
/* RETURN * historically keeps max_rows as its output cap even when a
* larger Cypher LIMIT is present; preserve that behavior and report it. */
memset(&r, 0, sizeof(r));
rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN * LIMIT 5", "sat", 3, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 3);
ASSERT_TRUE(r.truncated);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* An unlabeled MATCH scans every candidate before aggregation: the count is
* exact for any max_rows, and no internal ceiling is reported (a bounded scan
* here once made count(*) depend on the requested row budget). */
TEST(cypher_result_unlabeled_scan_is_exhaustive_before_aggregation) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "scan_sat", "/tmp/scan_sat");
for (int i = 0; i < 11; i++) {
char name[32];
char qn[64];
snprintf(name, sizeof(name), "node%d", i);
snprintf(qn, sizeof(qn), "scan_sat.node%d", i);
cbm_node_t n = {
.project = "scan_sat", .label = "Function", .name = name, .qualified_name = qn};
cbm_store_upsert_node(s, &n);
}
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (n) RETURN count(*) AS n", "scan_sat", 1, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "11");
ASSERT_FALSE(r.truncated);
cbm_cypher_result_free(&r);
memset(&r, 0, sizeof(r));
rc = cbm_cypher_execute(s, "MATCH (n) RETURN count(*) AS n", "scan_sat", 2, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "11");
ASSERT_FALSE(r.truncated);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* Variable-length traversal historically materializes at most 100 visited
* nodes. Probe one extra candidate so an exact 100 is distinguishable from a
* graph with additional reachable nodes, while preserving the 100-row output. */
TEST(cypher_result_reports_variable_length_candidate_saturation) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "bfs_sat", "/tmp/bfs_sat");
cbm_node_t root = {.project = "bfs_sat",
.label = "Function",
.name = "root",
.qualified_name = "bfs_sat.root"};
int64_t root_id = cbm_store_upsert_node(s, &root);
for (int i = 0; i < 101; i++) {
char name[32];
char qn[64];
snprintf(name, sizeof(name), "target%03d", i);
snprintf(qn, sizeof(qn), "bfs_sat.target%03d", i);
cbm_node_t target = {
.project = "bfs_sat", .label = "Var", .name = name, .qualified_name = qn};
int64_t target_id = cbm_store_upsert_node(s, &target);
cbm_edge_t edge = {
.project = "bfs_sat", .source_id = root_id, .target_id = target_id, .type = "CALLS"};
cbm_store_insert_edge(s, &edge);
}
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (a:Function {name: \"root\"})-[:CALLS*1..2]->(b:Var) "
"RETURN b.name",
"bfs_sat", 1000, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 100);
ASSERT_TRUE(r.truncated);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* PHASE 1: SIMPLE OPERATORS
* ══════════════════════════════════════════════════════════════════ */
TEST(cypher_lex_neq_operators) {
cbm_lex_result_t r = {0};
int rc = cbm_lex("<> !=", &r);
ASSERT_EQ(rc, 0);
ASSERT_GTE(r.count, 2);
ASSERT_EQ(r.tokens[0].type, TOK_NEQ);
ASSERT_EQ(r.tokens[1].type, TOK_NEQ);
cbm_lex_free(&r);
PASS();
}
TEST(cypher_lex_ends_keyword) {
cbm_lex_result_t r = {0};
int rc = cbm_lex("ENDS WITH", &r);
ASSERT_EQ(rc, 0);
ASSERT_GTE(r.count, 2);
ASSERT_EQ(r.tokens[0].type, TOK_ENDS);
ASSERT_EQ(r.tokens[1].type, TOK_WITH);
cbm_lex_free(&r);
PASS();
}
TEST(cypher_lex_in_is_null) {
cbm_lex_result_t r = {0};
int rc = cbm_lex("IN IS NULL", &r);
ASSERT_EQ(rc, 0);
ASSERT_GTE(r.count, 3);
ASSERT_EQ(r.tokens[0].type, TOK_IN);
ASSERT_EQ(r.tokens[1].type, TOK_IS);
ASSERT_EQ(r.tokens[2].type, TOK_NULL_KW);
cbm_lex_free(&r);
PASS();
}
TEST(cypher_exec_where_neq) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (f:Function) WHERE f.name <> \"HandleOrder\" RETURN f.name", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 3); /* ValidateOrder, SubmitOrder, LogError */
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_where_neq_bang) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (f:Function) WHERE f.name != \"HandleOrder\" RETURN f.name", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 3);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_where_ends_with) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (f:Function) WHERE f.name ENDS WITH \"Order\" RETURN f.name", "test", 0, &r);
ASSERT_EQ(rc, 0);
/* HandleOrder, ValidateOrder, SubmitOrder */
ASSERT_EQ(r.row_count, 3);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_where_not) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (f:Function) WHERE NOT f.name = \"HandleOrder\" RETURN f.name", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 3);
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};
int rc = cbm_cypher_execute(
s, "MATCH (f) WHERE f.label IN [\"Function\", \"Module\"] RETURN f.name", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 5); /* 4 Functions + 1 Module */
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_where_not_in) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (f) WHERE NOT f.label IN [\"Module\"] RETURN f.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 4); /* 4 Functions only */
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_where_is_null) {
/* SubmitOrder has no start_line (defaults to 0, so start_line prop = "0") */
/* But file_path is set for all. Use a node with missing data. */
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t n1 = {.project = "test",
.label = "Function",
.name = "WithFile",
.qualified_name = "test.WithFile",
.file_path = "a.go"};
cbm_node_t n2 = {.project = "test",
.label = "Function",
.name = "NoFile",
.qualified_name = "test.NoFile",
.file_path = NULL};
cbm_store_upsert_node(s, &n1);
cbm_store_upsert_node(s, &n2);
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (f:Function) WHERE f.file_path IS NULL RETURN f.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1); /* NoFile has NULL file_path */
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_where_is_not_null) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t n1 = {.project = "test",
.label = "Function",
.name = "WithFile",
.qualified_name = "test.WithFile",
.file_path = "a.go"};
cbm_node_t n2 = {.project = "test",
.label = "Function",
.name = "NoFile",
.qualified_name = "test.NoFile",
.file_path = NULL};
cbm_store_upsert_node(s, &n1);
cbm_store_upsert_node(s, &n2);
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (f:Function) WHERE f.file_path IS NOT NULL RETURN f.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1); /* WithFile */
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_return_star) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN * LIMIT 3", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 3);
/* Should have columns: f.name, f.qualified_name, f.label, f.file_path */
ASSERT_EQ(r.col_count, 4);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_return_star_dedups_repeated_pattern_var) {
/* RETURN * collected its column variables from every pattern in turn and
* never deduped, so a variable named in two patterns got its four columns
* twice. Here f is named in the MATCH and again in the OPTIONAL MATCH, so
* eight columns is right and twelve is the fault. */
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (f:Function) OPTIONAL MATCH (f)-[:CALLS]->(g) RETURN *",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.col_count, 8);
ASSERT_STR_EQ(r.columns[0], "f.name");
ASSERT_STR_EQ(r.columns[4], "g.name");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_return_star_after_with_names_aliases) {
/* RETURN * built its columns from the query pattern, never from the
* bindings it was about to project. After a WITH the live scope is the
* aliases the WITH made, so the old code asked for f and g, found neither,
* and answered every value empty with no error. */
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function)-[:CALLS]->(g) "
"WITH f.name AS caller, g.name AS callee RETURN *",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.col_count, 2);
ASSERT_STR_EQ(r.columns[0], "caller");
ASSERT_STR_EQ(r.columns[1], "callee");
/* Three CALLS edges in the fixture. */
ASSERT_EQ(r.row_count, 3);
for (int i = 0; i < r.row_count; i++) {
ASSERT_TRUE(r.rows[i][0][0] != '\0');
ASSERT_TRUE(r.rows[i][1][0] != '\0');
}
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_wide_with_refused_not_truncated) {
/* Every item a WITH projects becomes one variable of the binding that
* carries the rest of the query, and a binding holds CYP_MAX_VARS (16) of
* them. A 20-alias WITH used to parse, drop aliases 17 to 20 inside
* with_add_vbinding_var, and answer RETURN * with 16 columns and no error —
* a short result the caller could not tell from a complete one. It has to
* be refused at parse time instead. */
char query[1024];
int off = snprintf(query, sizeof(query), "MATCH (f:Function) WITH ");
for (int i = 0; i < 20; i++) { /* 20 > CYP_MAX_VARS (16) */
off +=
snprintf(query + off, sizeof(query) - (size_t)off, "%sf.name AS c%d", i ? ", " : "", i);
}
snprintf(query + off, sizeof(query) - (size_t)off, " RETURN *");
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, query, "test", 0, &r);
ASSERT_TRUE(rc != 0); /* refused, not silently narrowed to 16 columns */
cbm_cypher_result_free(&r);
/* The width just under the bound still works, so the guard rejects only
* what the binding genuinely cannot carry. */
char ok_query[1024];
off = snprintf(ok_query, sizeof(ok_query), "MATCH (f:Function) WITH ");
for (int i = 0; i < 16; i++) {
off += snprintf(ok_query + off, sizeof(ok_query) - (size_t)off, "%sf.name AS c%d",
i ? ", " : "", i);
}
snprintf(ok_query + off, sizeof(ok_query) - (size_t)off, " RETURN *");
cbm_cypher_result_t r16 = {0};
ASSERT_EQ(cbm_cypher_execute(s, ok_query, "test", 0, &r16), 0);
ASSERT_EQ(r16.col_count, 16);
cbm_cypher_result_free(&r16);
cbm_store_close(s);
PASS();
}
/* Build "MATCH (a0:NoSuchLabelXYZ)-[:CALLS]->(a1)-…->(aN-1)" into buf. The label
* matches nothing, so any query built on it is instant and needs no fixture. */
static void build_node_chain(char *buf, size_t buf_sz, int nodes) {
int off = snprintf(buf, buf_sz, "MATCH (a0:NoSuchLabelXYZ)");
for (int i = 1; i < nodes; i++) {
off += snprintf(buf + off, buf_sz - (size_t)off, "-[:CALLS]->(a%d)", i);
}
}
TEST(cypher_wide_pattern_refused) {
/* A binding holds CYP_MAX_VARS (16) node variables, and binding_set drops
* the 17th without a word. The query then answers a column of empty strings
* for every name it could not bind, which reads as "the graph holds no such
* data". Refuse the query instead of answering it wrong. */
cbm_store_t *s = setup_cypher_store();
char query[2048];
build_node_chain(query, sizeof(query), 20); /* 20 > CYP_MAX_VARS */
strncat(query, " RETURN a0.name", sizeof(query) - strlen(query) - 1);
cbm_cypher_result_t wide = {0};
ASSERT_TRUE(cbm_cypher_execute(s, query, "test", 0, &wide) != 0);
ASSERT_NOT_NULL(wide.error);
ASSERT_TRUE(strstr(wide.error, "node") != NULL); /* says which limit was passed */
cbm_cypher_result_free(&wide);
/* The width right at the bound still runs, so the guard refuses only what a
* binding genuinely cannot hold. */
build_node_chain(query, sizeof(query), 16);
strncat(query, " RETURN a0.name", sizeof(query) - strlen(query) - 1);
cbm_cypher_result_t ok = {0};
ASSERT_EQ(cbm_cypher_execute(s, query, "test", 0, &ok), 0);
cbm_cypher_result_free(&ok);
cbm_store_close(s);
PASS();
}
TEST(cypher_wide_edge_pattern_refused) {
/* Same shape on the edge table, where binding_set_edge stops at
* CYP_MAX_EDGE_VARS (8). Only NAMED relationships take a slot. */
cbm_store_t *s = setup_cypher_store();
char query[2048];
int off = snprintf(query, sizeof(query), "MATCH (a0:NoSuchLabelXYZ)");
for (int i = 1; i <= 9; i++) { /* 9 > CYP_MAX_EDGE_VARS */
off += snprintf(query + off, sizeof(query) - (size_t)off, "-[r%d:CALLS]->(a%d)", i, i);
}
snprintf(query + off, sizeof(query) - (size_t)off, " RETURN a0.name");
cbm_cypher_result_t wide = {0};
ASSERT_TRUE(cbm_cypher_execute(s, query, "test", 0, &wide) != 0);
ASSERT_NOT_NULL(wide.error);
ASSERT_TRUE(strstr(wide.error, "edge") != NULL);
cbm_cypher_result_free(&wide);
cbm_store_close(s);
PASS();
}
TEST(cypher_unnamed_head_takes_a_slot) {
/* The head of the first pattern is bound whether the query names it or not:
* execute_single falls back to the synthetic name "_n0". So an unnamed head
* plus CYP_MAX_VARS (16) named nodes needs 17 slots and only 16 exist. Before
* the fix, the capacity check counted names alone, let this query through,
* and binding_set dropped the 16th name without a word — a0..a14 answered and
* a15 came back empty. Refuse it instead. */
cbm_store_t *s = setup_cypher_store();
char query[2048];
int off = snprintf(query, sizeof(query), "MATCH (:NoSuchLabelXYZ)");
for (int i = 0; i < 16; i++) { /* 16 named + the unnamed head = 17 */
off += snprintf(query + off, sizeof(query) - (size_t)off, "-[:CALLS]->(a%d)", i);
}
snprintf(query + off, sizeof(query) - (size_t)off, " RETURN a0.name");
cbm_cypher_result_t wide = {0};
ASSERT_TRUE(cbm_cypher_execute(s, query, "test", 0, &wide) != 0);
ASSERT_NOT_NULL(wide.error);
ASSERT_TRUE(strstr(wide.error, "node") != NULL);
cbm_cypher_result_free(&wide);
/* One name fewer fits exactly, so the guard still refuses only what a
* binding genuinely cannot hold. */
off = snprintf(query, sizeof(query), "MATCH (:NoSuchLabelXYZ)");
for (int i = 0; i < 15; i++) {
off += snprintf(query + off, sizeof(query) - (size_t)off, "-[:CALLS]->(a%d)", i);
}
snprintf(query + off, sizeof(query) - (size_t)off, " RETURN a0.name");
cbm_cypher_result_t ok = {0};
ASSERT_EQ(cbm_cypher_execute(s, query, "test", 0, &ok), 0);
cbm_cypher_result_free(&ok);
cbm_store_close(s);
PASS();
}
TEST(cypher_scope_check_survives_wide_pattern) {
/* Regression test for #1995. check_projection_scope models declared names in
* a fixed array and used to skip the check entirely when a query declared
* more than it held. So the same out-of-scope name was refused on a narrow
* query and quietly accepted on a wide one. Both must now be refused. */
cbm_store_t *s = setup_cypher_store();
char query[4096];
build_node_chain(query, sizeof(query), 10);
strncat(query, " RETURN zzz.name", sizeof(query) - strlen(query) - 1);
cbm_cypher_result_t narrow = {0};
ASSERT_TRUE(cbm_cypher_execute(s, query, "test", 0, &narrow) != 0);
ASSERT_NOT_NULL(narrow.error);
ASSERT_TRUE(strstr(narrow.error, "zzz") != NULL);
cbm_cypher_result_free(&narrow);
/* 35 declared names — this one used to answer a zzz.name column of nothing. */
build_node_chain(query, sizeof(query), 35);
strncat(query, " RETURN zzz.name", sizeof(query) - strlen(query) - 1);
cbm_cypher_result_t wide = {0};
ASSERT_TRUE(cbm_cypher_execute(s, query, "test", 0, &wide) != 0);
ASSERT_NOT_NULL(wide.error);
cbm_cypher_result_free(&wide);
cbm_store_close(s);
PASS();
}
TEST(cypher_parse_neq) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MATCH (f:Function) WHERE f.name <> \"X\"", &q, &err);
ASSERT_EQ(rc, 0);
ASSERT_NOT_NULL(q->where);
ASSERT_NOT_NULL(q->where->root);
ASSERT_EQ(q->where->root->type, EXPR_CONDITION);
ASSERT_STR_EQ(q->where->root->cond.op, "<>");
cbm_query_free(q);
PASS();
}
TEST(cypher_parse_in) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MATCH (f) WHERE f.label IN [\"Function\", \"Module\"]", &q, &err);
ASSERT_EQ(rc, 0);
ASSERT_NOT_NULL(q->where->root);
ASSERT_EQ(q->where->root->type, EXPR_CONDITION);
ASSERT_STR_EQ(q->where->root->cond.op, "IN");
ASSERT_EQ(q->where->root->cond.in_value_count, 2);
ASSERT_STR_EQ(q->where->root->cond.in_values[0], "Function");
ASSERT_STR_EQ(q->where->root->cond.in_values[1], "Module");
cbm_query_free(q);
PASS();
}
TEST(cypher_parse_is_null) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MATCH (f) WHERE f.file_path IS NULL", &q, &err);
ASSERT_EQ(rc, 0);
ASSERT_NOT_NULL(q->where->root);
ASSERT_STR_EQ(q->where->root->cond.op, "IS NULL");
cbm_query_free(q);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* PHASE 2: EXPRESSION TREE
* ══════════════════════════════════════════════════════════════════ */
TEST(cypher_exec_where_or) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s,
"MATCH (f:Function) WHERE f.name = \"HandleOrder\" OR f.name = \"LogError\" 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_where_complex_bool) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
/* (name CONTAINS "Order" OR name = "LogError") AND label = "Function" */
int rc = cbm_cypher_execute(s,
"MATCH (f) WHERE (f.name CONTAINS \"Order\" OR f.name = "
"\"LogError\") AND f.label = \"Function\" "
"RETURN f.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 4); /* HandleOrder, ValidateOrder, SubmitOrder, LogError */
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_where_xor) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
/* name CONTAINS "Handle" XOR name CONTAINS "Order" → XOR = true when exactly one is true
* HandleOrder: both true → false
* ValidateOrder: false, true → true
* SubmitOrder: false, true → true
* LogError: false, false → false */
int rc = cbm_cypher_execute(s,
"MATCH (f:Function) WHERE f.name CONTAINS \"Handle\" XOR f.name "
"CONTAINS \"Order\" RETURN f.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 2); /* ValidateOrder, SubmitOrder */
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_where_not_prefix) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (f:Function) WHERE NOT (f.name CONTAINS \"Order\") RETURN f.name", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1); /* LogError */
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_parse_expr_tree_and_or) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc =
cbm_cypher_parse("MATCH (f) WHERE f.a = \"1\" AND f.b = \"2\" OR f.c = \"3\"", &q, &err);
ASSERT_EQ(rc, 0);
/* Precedence: AND binds tighter than OR → root is OR */
ASSERT_NOT_NULL(q->where->root);
ASSERT_EQ(q->where->root->type, EXPR_OR);
ASSERT_EQ(q->where->root->left->type, EXPR_AND);
ASSERT_EQ(q->where->root->right->type, EXPR_CONDITION);
cbm_query_free(q);
PASS();
}
TEST(cypher_parse_expr_tree_nested) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc =
cbm_cypher_parse("MATCH (f) WHERE (f.a = \"1\" OR f.b = \"2\") AND f.c = \"3\"", &q, &err);
ASSERT_EQ(rc, 0);
/* Parens override precedence: root is AND, left is OR */
ASSERT_NOT_NULL(q->where->root);
ASSERT_EQ(q->where->root->type, EXPR_AND);
ASSERT_EQ(q->where->root->left->type, EXPR_OR);
ASSERT_EQ(q->where->root->right->type, EXPR_CONDITION);
cbm_query_free(q);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* PHASE 3: UNSUPPORTED KEYWORD ERRORS
* ══════════════════════════════════════════════════════════════════ */
TEST(cypher_error_create) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("CREATE (n:Node {name: \"X\"})", &q, &err);
ASSERT_EQ(rc, -1);
ASSERT_NOT_NULL(err);
ASSERT(strstr(err, "CREATE") != NULL);
free(err);
PASS();
}
TEST(cypher_error_delete) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("DELETE n", &q, &err);
ASSERT_EQ(rc, -1);
ASSERT_NOT_NULL(err);
ASSERT(strstr(err, "DELETE") != NULL);
free(err);
PASS();
}
TEST(cypher_error_set) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("SET n.name = \"X\"", &q, &err);
ASSERT_EQ(rc, -1);
ASSERT_NOT_NULL(err);
ASSERT(strstr(err, "SET") != NULL);
free(err);
PASS();
}
TEST(cypher_error_merge) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MERGE (n:Node)", &q, &err);
ASSERT_EQ(rc, -1);
ASSERT_NOT_NULL(err);
ASSERT(strstr(err, "MERGE") != NULL);
free(err);
PASS();
}
TEST(cypher_error_call) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("CALL db.labels()", &q, &err);
ASSERT_EQ(rc, -1);
ASSERT_NOT_NULL(err);
ASSERT(strstr(err, "CALL") != NULL);
free(err);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* PHASE 4: SKIP + GENERALIZED AGGREGATION
* ══════════════════════════════════════════════════════════════════ */
TEST(cypher_exec_skip) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN f.name ORDER BY f.name ASC SKIP 2",
"test", 0, &r);
ASSERT_EQ(rc, 0);
/* 4 functions ordered: HandleOrder, LogError, SubmitOrder, ValidateOrder → skip 2 = 2 */
ASSERT_EQ(r.row_count, 2);
ASSERT_STR_EQ(r.rows[0][0], "SubmitOrder");
ASSERT_STR_EQ(r.rows[1][0], "ValidateOrder");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_skip_limit) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (f:Function) RETURN f.name ORDER BY f.name ASC SKIP 1 LIMIT 2", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 2);
ASSERT_STR_EQ(r.rows[0][0], "LogError");
ASSERT_STR_EQ(r.rows[1][0], "SubmitOrder");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* Regression for #1334: a LIMIT must survive a multi-key ORDER BY. The parser
* used to stop at the first sort key, leaving ", key2 ... LIMIT n" unconsumed —
* the whole result set came back (6326 rows instead of 5 on the reporter's
* graph: a token-flood into agent context). */
TEST(cypher_exec_multikey_order_by_keeps_limit_issue1334) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
/* start_lines: HandleOrder=10, ValidateOrder=5, SubmitOrder=0, LogError=0 */
int rc = cbm_cypher_execute(s,
"MATCH (f:Function) RETURN f.name, f.start_line "
"ORDER BY f.start_line DESC, f.name ASC LIMIT 2",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 2);
ASSERT_STR_EQ(r.rows[0][0], "HandleOrder");
ASSERT_STR_EQ(r.rows[1][0], "ValidateOrder");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* #1334: the secondary key must actually break ties, per-key direction. */
TEST(cypher_exec_multikey_order_by_tiebreak_issue1334) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
/* start_line ASC puts the two 0-line functions first; name DESC breaks the
* tie: SubmitOrder before LogError. */
int rc = cbm_cypher_execute(s,
"MATCH (f:Function) RETURN f.name, f.start_line "
"ORDER BY f.start_line ASC, f.name DESC LIMIT 2",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 2);
ASSERT_STR_EQ(r.rows[0][0], "SubmitOrder");
ASSERT_STR_EQ(r.rows[1][0], "LogError");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* #1334: the WITH-clause pipeline has the same multi-key ORDER BY contract. */
TEST(cypher_exec_with_multikey_order_by_keeps_limit_issue1334) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function) WITH f.name AS n, f.start_line AS sl "
"ORDER BY sl DESC, n ASC LIMIT 2 RETURN n",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 2);
ASSERT_STR_EQ(r.rows[0][0], "HandleOrder");
ASSERT_STR_EQ(r.rows[1][0], "ValidateOrder");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_sum) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
/* start_lines: HandleOrder=10, ValidateOrder=5, SubmitOrder=0, LogError=0 → sum=15 */
int rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN SUM(f.start_line) AS total", "test",
0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "15");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_avg) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
/* start_lines: 10, 5, 0, 0 → avg = 3.75 */
int rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN AVG(f.start_line) AS avg_line",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "3.75");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_min) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
/* Among functions with nonzero: HandleOrder=10, ValidateOrder=5 → but MIN is 0 from others */
int rc =
cbm_cypher_execute(s, "MATCH (f:Function) RETURN MIN(f.start_line) AS mn", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "0");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_max) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc =
cbm_cypher_execute(s, "MATCH (f:Function) RETURN MAX(f.start_line) AS mx", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "10");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_collect) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function)-[:CALLS]->(g:Function) "
"WHERE f.name = \"HandleOrder\" "
"RETURN f.name, COLLECT(g.name) AS callees",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
/* Should be a JSON array like ["ValidateOrder","LogError"] */
ASSERT_STR_EQ(r.rows[0][0], "HandleOrder");
ASSERT(strstr(r.rows[0][1], "ValidateOrder") != NULL);
ASSERT(strstr(r.rows[0][1], "LogError") != NULL);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_count_star) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN COUNT(*) AS n", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "4");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* #1111: type(r) grouped with count(*) must return the actual relationship type,
* not the row count. ret_agg_build_key/ret_agg_emit_row classified aggregate vs.
* scalar columns with a bare `item->func` truthy check, so type(r) (a non-aggregate
* function, func != NULL) was misrouted into the aggregate-value branch and
* formatted via format_agg_value's default case, silently substituting the row
* count for the relationship type. */
TEST(cypher_issue1111_return_type_count_group) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (a)-[r]->(b) RETURN type(r) AS t, count(*) AS n ORDER BY n DESC", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 2);
ASSERT_STR_EQ(r.rows[0][0], "CALLS");
ASSERT_STR_EQ(r.rows[0][1], "3");
ASSERT_STR_EQ(r.rows[1][0], "DEFINES");
ASSERT_STR_EQ(r.rows[1][1], "1");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_parse_skip) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MATCH (f) RETURN f.name SKIP 5 LIMIT 10", &q, &err);
ASSERT_EQ(rc, 0);
ASSERT_NOT_NULL(q->ret);
ASSERT_EQ(q->ret->skip, 5);
ASSERT_EQ(q->ret->limit, 10);
cbm_query_free(q);
PASS();
}
TEST(cypher_parse_sum_avg) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MATCH (f) RETURN SUM(f.x) AS s, AVG(f.y) AS a", &q, &err);
ASSERT_EQ(rc, 0);
ASSERT_EQ(q->ret->count, 2);
ASSERT_STR_EQ(q->ret->items[0].func, "SUM");
ASSERT_STR_EQ(q->ret->items[0].alias, "s");
ASSERT_STR_EQ(q->ret->items[1].func, "AVG");
ASSERT_STR_EQ(q->ret->items[1].alias, "a");
cbm_query_free(q);
PASS();
}
TEST(cypher_parse_collect) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MATCH (f)-[:CALLS]->(g) RETURN f.name, COLLECT(g.name) AS names", &q,
&err);
ASSERT_EQ(rc, 0);
ASSERT_EQ(q->ret->count, 2);
ASSERT_STR_EQ(q->ret->items[1].func, "COLLECT");
ASSERT_STR_EQ(q->ret->items[1].alias, "names");
cbm_query_free(q);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* PHASE 5: STRING FUNCTIONS + CASE
* ══════════════════════════════════════════════════════════════════ */
TEST(cypher_exec_tolower) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (f:Function) WHERE f.name = \"HandleOrder\" RETURN toLower(f.name) AS lower_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_exec_toupper) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (f:Function) WHERE f.name = \"HandleOrder\" RETURN toUpper(f.name) AS upper_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_exec_tostring) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (f:Function) WHERE f.name = \"HandleOrder\" RETURN toString(f.start_line) AS sl",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "10");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_case) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s,
"MATCH (f:Function) WHERE f.name = \"HandleOrder\" "
"RETURN CASE WHEN f.start_line > \"5\" THEN \"high\" ELSE \"low\" END AS pos",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "high");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_parse_tolower) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MATCH (f) RETURN toLower(f.name) AS n", &q, &err);
ASSERT_EQ(rc, 0);
ASSERT_STR_EQ(q->ret->items[0].func, "toLower");
ASSERT_STR_EQ(q->ret->items[0].alias, "n");
cbm_query_free(q);
PASS();
}
TEST(cypher_parse_case) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse(
"MATCH (f) RETURN CASE WHEN f.x = \"1\" THEN \"a\" ELSE \"b\" END AS val", &q, &err);
ASSERT_EQ(rc, 0);
ASSERT_NOT_NULL(q->ret->items[0].kase);
ASSERT_EQ(q->ret->items[0].kase->branch_count, 1);
ASSERT_STR_EQ(q->ret->items[0].kase->branches[0].then_val, "a");
ASSERT_STR_EQ(q->ret->items[0].kase->else_val, "b");
cbm_query_free(q);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* PHASE 6: WITH CLAUSE
* ══════════════════════════════════════════════════════════════════ */
TEST(cypher_exec_with_rename) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function) WHERE f.name = \"HandleOrder\" "
"WITH f.name AS fname RETURN fname",
"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();
}
/* #2208: a node carried IN SCOPE through WITH must project every property
* exactly as the same node does without the WITH. The carried var used to be a
* name-only stub whose qualified_name slot held the variable name and whose
* start_line was 0, so f.qualified_name returned "f" and f.start_line "0". */
static int64_t issue2208_id_of(cbm_store_t *s, const char *qn) {
cbm_node_t n = {0};
int64_t id = 0;
if (cbm_store_find_node_by_qn(s, "test", qn, &n) == CBM_STORE_OK) {
id = n.id;
cbm_node_free_fields(&n);
}
return id;
}
static cbm_store_t *setup_issue2208_store(void) {
cbm_store_t *s = setup_cypher_store();
cbm_node_t twins[] = {
{.project = "test",
.label = "Function",
.name = "ProjectionTwin",
.qualified_name = "test.ProjectionA",
.file_path = "projection_a.go",
.start_line = 30,
.end_line = 40,
.properties_json = "{\"marker\":\"alpha\"}"},
{.project = "test",
.label = "Function",
.name = "ProjectionTwin",
.qualified_name = "test.ProjectionB",
.file_path = "projection_b.go",
.start_line = 10,
.end_line = 20,
.properties_json = "{\"marker\":\"beta\"}"},
{.project = "test",
.label = "Function",
.name = "ProjectionTwin",
.qualified_name = "test.ProjectionC",
.file_path = "projection_c.go",
.start_line = 20,
.end_line = 30,
.properties_json = "{\"marker\":\"gamma\"}"},
};
int64_t ids[3];
for (int i = 0; i < 3; i++) {
ids[i] = cbm_store_upsert_node(s, &twins[i]);
}
/* Callers: A has 2 (HandleOrder, ValidateOrder), B has 1 (HandleOrder), C none. */
int64_t handle = issue2208_id_of(s, "test.HandleOrder");
int64_t validate = issue2208_id_of(s, "test.ValidateOrder");
cbm_edge_t e1 = {.project = "test", .source_id = handle, .target_id = ids[0], .type = "CALLS"};
cbm_edge_t e2 = {
.project = "test", .source_id = validate, .target_id = ids[0], .type = "CALLS"};
cbm_edge_t e3 = {.project = "test", .source_id = handle, .target_id = ids[1], .type = "CALLS"};
cbm_store_insert_edge(s, &e1);
cbm_store_insert_edge(s, &e2);
cbm_store_insert_edge(s, &e3);
return s;
}
/* The reporter's exact shape: aggregate WITH carrying the node beside count(). */
TEST(cypher_issue2208_with_agg_carried_node_props) {
cbm_store_t *s = setup_issue2208_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function {qualified_name: 'test.ProjectionA'}) "
"OPTIONAL MATCH (caller)-[r:CALLS]->(f) "
"WITH f, count(r) AS refs "
"RETURN f.qualified_name, f.file_path, f.start_line, "
"f.end_line, f.label, f.marker, f.name, refs",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "test.ProjectionA");
ASSERT_STR_EQ(r.rows[0][1], "projection_a.go");
ASSERT_STR_EQ(r.rows[0][2], "30");
ASSERT_STR_EQ(r.rows[0][3], "40");
ASSERT_STR_EQ(r.rows[0][4], "Function");
ASSERT_STR_EQ(r.rows[0][5], "alpha");
ASSERT_STR_EQ(r.rows[0][6], "ProjectionTwin");
ASSERT_STR_EQ(r.rows[0][7], "2");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* Aggregate WITH groups by node IDENTITY, not display name: three same-named
* functions stay three rows, each with its own properties and caller count
* (they used to collapse into one row with one node's props and summed refs). */
TEST(cypher_issue2208_with_agg_groups_by_node_identity) {
cbm_store_t *s = setup_issue2208_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function {name: 'ProjectionTwin'}) "
"OPTIONAL MATCH (caller)-[r:CALLS]->(f) "
"WITH f, count(r) AS refs "
"RETURN f.qualified_name, f.start_line, refs "
"ORDER BY f.qualified_name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 3);
ASSERT_STR_EQ(r.rows[0][0], "test.ProjectionA");
ASSERT_STR_EQ(r.rows[0][1], "30");
ASSERT_STR_EQ(r.rows[0][2], "2");
ASSERT_STR_EQ(r.rows[1][0], "test.ProjectionB");
ASSERT_STR_EQ(r.rows[1][1], "10");
ASSERT_STR_EQ(r.rows[1][2], "1");
ASSERT_STR_EQ(r.rows[2][0], "test.ProjectionC");
ASSERT_STR_EQ(r.rows[2][1], "20");
/* rows[2][2] (count(r) for the caller-less C) is not asserted here: count()
* of an unbound OPTIONAL var is a separate, pre-existing defect. */
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* Simple (non-aggregate) WITH: a bare node stays a node (renamed or not);
* scalar items stay scalars; OPTIONAL null stays null; post-WITH WHERE and
* WITH ... ORDER BY see the real node properties. */
TEST(cypher_issue2208_with_simple_carried_node_props) {
cbm_store_t *s = setup_issue2208_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function) WHERE f.qualified_name = 'test.ProjectionA' "
"WITH f "
"RETURN f.name, f.qualified_name, f.file_path, f.label, "
"f.start_line, f.marker",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "ProjectionTwin");
ASSERT_STR_EQ(r.rows[0][1], "test.ProjectionA");
ASSERT_STR_EQ(r.rows[0][2], "projection_a.go");
ASSERT_STR_EQ(r.rows[0][3], "Function");
ASSERT_STR_EQ(r.rows[0][4], "30");
ASSERT_STR_EQ(r.rows[0][5], "alpha");
cbm_cypher_result_free(&r);
memset(&r, 0, sizeof(r));
rc = cbm_cypher_execute(s,
"MATCH (f:Function) WHERE f.qualified_name = 'test.ProjectionA' "
"WITH f AS g, f.name AS n, labels(f) AS ls "
"RETURN g.qualified_name, g.file_path, n, ls, n.file_path, "
"ls.file_path",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "test.ProjectionA");
ASSERT_STR_EQ(r.rows[0][1], "projection_a.go");
ASSERT_STR_EQ(r.rows[0][2], "ProjectionTwin");
ASSERT_STR_EQ(r.rows[0][3], "[\"Function\"]");
ASSERT_STR_EQ(r.rows[0][4], "");
ASSERT_STR_EQ(r.rows[0][5], "");
cbm_cypher_result_free(&r);
/* OPTIONAL null carried through WITH stays null (no fabricated node). */
memset(&r, 0, sizeof(r));
rc = cbm_cypher_execute(s,
"MATCH (f:Function) WHERE f.name = 'LogError' "
"OPTIONAL MATCH (f)-[:CALLS]->(g:Function) "
"WITH g AS projected "
"RETURN projected, projected.file_path, projected.qualified_name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "");
ASSERT_STR_EQ(r.rows[0][1], "");
ASSERT_STR_EQ(r.rows[0][2], "");
cbm_cypher_result_free(&r);
memset(&r, 0, sizeof(r));
rc = cbm_cypher_execute(s,
"MATCH (f:Function) WITH f AS g "
"WHERE g.file_path = 'projection_b.go' "
"RETURN g.qualified_name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "test.ProjectionB");
cbm_cypher_result_free(&r);
/* A(30), B(10), C(20): ascending then SKIP 1 LIMIT 1 selects C. */
memset(&r, 0, sizeof(r));
rc = cbm_cypher_execute(s,
"MATCH (f:Function) WHERE f.name = 'ProjectionTwin' "
"WITH f AS g ORDER BY g.start_line ASC SKIP 1 LIMIT 1 "
"RETURN g.qualified_name, g.start_line",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "test.ProjectionC");
ASSERT_STR_EQ(r.rows[0][1], "20");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_with_count) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function)-[:CALLS]->(g:Function) "
"WITH f.name AS caller, COUNT(g) AS cnt "
"RETURN caller, cnt ORDER BY cnt DESC",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_GTE(r.row_count, 1);
/* HandleOrder calls 2 (ValidateOrder, LogError), ValidateOrder calls 1 (SubmitOrder) */
ASSERT_STR_EQ(r.rows[0][0], "HandleOrder");
ASSERT_STR_EQ(r.rows[0][1], "2");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* Regression: a bare node group-var carried through WITH aggregation must project
* its real properties (not blank). Pre-fix, the carried var held only the node
* name, so RETURN g.file_path returned "". */
TEST(cypher_exec_with_node_groupvar_prop) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function)-[:CALLS]->(g:Function) "
"WHERE g.name = \"ValidateOrder\" "
"WITH g, COUNT(*) AS c "
"RETURN g.file_path, g.name, c",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "validate.go"); /* was "" before the fix */
ASSERT_STR_EQ(r.rows[0][1], "ValidateOrder");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* #1111, WITH variant: the same misrouting in with_agg_build_key/with_agg_accumulate/
* execute_with_aggregate's per-column func check. */
TEST(cypher_issue1111_with_type_count_group) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (a)-[r]->(b) WITH type(r) AS t, count(*) AS n RETURN t, n ORDER BY n DESC",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 2);
ASSERT_STR_EQ(r.rows[0][0], "CALLS");
ASSERT_STR_EQ(r.rows[0][1], "3");
ASSERT_STR_EQ(r.rows[1][0], "DEFINES");
ASSERT_STR_EQ(r.rows[1][1], "1");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* #1111 follow-up (review from DeusData on #1221): with_agg_find_or_create's
* bare-node-carry check only tested `!property && variable`, so an entity-
* introspection alias like `labels(f) AS l` (variable set, property NULL, func
* set) was ALSO tagged with the source node's id. A later `l.file_path` then
* hit node_prop's stub re-fetch heuristic (id set, file_path/label both NULL on
* the virtual stub) and silently returned HandleOrder's real file_path instead
* of "" for the non-node alias `l`. */
TEST(cypher_issue1111_with_scalar_func_alias_no_node_leak) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function) WHERE f.name = \"HandleOrder\" "
"WITH labels(f) AS l, COUNT(*) AS c "
"RETURN l, l.file_path, c",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "[\"Function\"]");
ASSERT_STR_EQ(r.rows[0][1], ""); /* was "handler.go" before the fix */
ASSERT_STR_EQ(r.rows[0][2], "1");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_with_where) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function)-[:CALLS]->(g:Function) "
"WITH f.name AS caller, COUNT(g) AS cnt "
"WHERE cnt > \"1\" "
"RETURN caller, cnt",
"test", 0, &r);
ASSERT_EQ(rc, 0);
/* Only HandleOrder has cnt > 1 (cnt=2) */
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_exec_with_orderby_limit) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function)-[:CALLS]->(g:Function) "
"WITH f.name AS caller, COUNT(g) AS cnt "
"ORDER BY cnt DESC LIMIT 1 "
"RETURN caller, cnt",
"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_parse_with) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse(
"MATCH (f)-[:CALLS]->(g) WITH f.name AS caller, COUNT(g) AS cnt RETURN caller, cnt", &q,
&err);
ASSERT_EQ(rc, 0);
ASSERT_NOT_NULL(q->with_clause);
ASSERT_EQ(q->with_clause->count, 2);
ASSERT_STR_EQ(q->with_clause->items[0].alias, "caller");
ASSERT_STR_EQ(q->with_clause->items[1].func, "COUNT");
ASSERT_NOT_NULL(q->ret);
cbm_query_free(q);
PASS();
}
TEST(cypher_parse_with_where) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("MATCH (f)-[:CALLS]->(g) WITH f.name AS caller, COUNT(g) AS cnt "
"WHERE cnt > \"1\" RETURN caller",
&q, &err);
ASSERT_EQ(rc, 0);
ASSERT_NOT_NULL(q->with_clause);
ASSERT_NOT_NULL(q->post_with_where);
ASSERT_NOT_NULL(q->post_with_where->root);
ASSERT_NOT_NULL(q->ret);
cbm_query_free(q);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* PHASE 7: OPTIONAL MATCH + MULTIPLE MATCH
* ══════════════════════════════════════════════════════════════════ */
TEST(cypher_exec_optional_match_no_result) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
/* LogError has no CALLS outbound edges → OPTIONAL MATCH keeps binding with empty target */
int rc = cbm_cypher_execute(s,
"MATCH (f:Function) WHERE f.name = \"LogError\" "
"OPTIONAL MATCH (f)-[:CALLS]->(g:Function) "
"RETURN f.name, g.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "LogError");
/* g.name should be empty since OPTIONAL MATCH found nothing */
ASSERT_STR_EQ(r.rows[0][1], "");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_optional_match_has_result) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function) WHERE f.name = \"HandleOrder\" "
"OPTIONAL MATCH (f)-[:CALLS]->(g:Function) "
"RETURN f.name, g.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 2); /* ValidateOrder, LogError */
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_multi_match) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
/* Two MATCH clauses: first finds a module, second finds functions */
int rc =
cbm_cypher_execute(s,
"MATCH (m:Module) MATCH (f:Function) WHERE f.name CONTAINS \"Order\" "
"RETURN m.name, f.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
/* 1 module × 3 *Order functions = 3 */
ASSERT_EQ(r.row_count, 3);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_parse_optional_match) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse(
"MATCH (f:Function) OPTIONAL MATCH (f)-[:CALLS]->(g) RETURN f.name, g.name", &q, &err);
ASSERT_EQ(rc, 0);
ASSERT_EQ(q->pattern_count, 2);
ASSERT(!q->pattern_optional[0]);
ASSERT(q->pattern_optional[1]);
cbm_query_free(q);
PASS();
}
TEST(cypher_parse_multi_match) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc =
cbm_cypher_parse("MATCH (a:Module) MATCH (b:Function) RETURN a.name, b.name", &q, &err);
ASSERT_EQ(rc, 0);
ASSERT_EQ(q->pattern_count, 2);
ASSERT(!q->pattern_optional[0]);
ASSERT(!q->pattern_optional[1]);
cbm_query_free(q);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* PHASE 8: UNION / UNION ALL
* ══════════════════════════════════════════════════════════════════ */
TEST(cypher_exec_union) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function) WHERE f.name = \"HandleOrder\" RETURN f.name "
"UNION "
"MATCH (f:Function) WHERE f.name = \"HandleOrder\" RETURN f.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
/* UNION deduplicates → 1 row */
ASSERT_EQ(r.row_count, 1);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_union_all) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function) WHERE f.name = \"HandleOrder\" RETURN f.name "
"UNION ALL "
"MATCH (f:Function) WHERE f.name = \"HandleOrder\" RETURN f.name",
"test", 0, &r);
ASSERT_EQ(rc, 0);
/* UNION ALL keeps duplicates → 2 rows */
ASSERT_EQ(r.row_count, 2);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_parse_union) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc =
cbm_cypher_parse("MATCH (f) RETURN f.name UNION ALL MATCH (g) RETURN g.name", &q, &err);
ASSERT_EQ(rc, 0);
ASSERT(q->union_all);
ASSERT_NOT_NULL(q->union_next);
cbm_query_free(q);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* PHASE 9: UNWIND
* ══════════════════════════════════════════════════════════════════ */
TEST(cypher_parse_unwind) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc =
cbm_cypher_parse("UNWIND [\"a\", \"b\", \"c\"] AS x MATCH (f) RETURN f.name", &q, &err);
ASSERT_EQ(rc, 0);
ASSERT_NOT_NULL(q->unwind_expr);
ASSERT_STR_EQ(q->unwind_alias, "x");
cbm_query_free(q);
PASS();
}
TEST(cypher_parse_unwind_var) {
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse("UNWIND items AS item MATCH (f) RETURN f.name", &q, &err);
ASSERT_EQ(rc, 0);
ASSERT_STR_EQ(q->unwind_expr, "items");
ASSERT_STR_EQ(q->unwind_alias, "item");
cbm_query_free(q);
PASS();
}
/* Regression: an UNWIND literal list whose element is longer than the 2KB
* assembly buffer used to overflow the stack. snprintf reports the length it
* WOULD have written, so blen ran past sizeof(buf) and the trailing
* buf[blen++]=']' / buf[blen]='\0' wrote out of bounds (ASan: stack-buffer-
* overflow). The query text is agent-controlled via the MCP query tool. */
TEST(cypher_parse_unwind_oversized_literal_no_overflow) {
char query[4096];
char big[3000];
memset(big, 'a', sizeof(big) - 1);
big[sizeof(big) - 1] = '\0';
snprintf(query, sizeof(query), "UNWIND [\"%s\"] AS x MATCH (f) RETURN f.name", big);
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse(query, &q, &err);
/* Must not crash and must produce a NUL-terminated, in-bounds expression. */
ASSERT_EQ(rc, 0);
ASSERT_NOT_NULL(q->unwind_expr);
ASSERT_STR_EQ(q->unwind_alias, "x");
cbm_query_free(q);
PASS();
}
/* Regression: many oversized elements accumulate blen well past the buffer,
* which also underflowed the (size_t)(cap - blen) length passed to snprintf. */
TEST(cypher_parse_unwind_many_elements_no_overflow) {
/* 200 elements (~20 chars each) accumulate well past the 2KB assembly
* buffer, which also underflowed the (size_t)(cap - blen) length. */
char query[8192];
int off = snprintf(query, sizeof(query), "UNWIND [");
for (int i = 0; i < 200; i++) {
off += snprintf(query + off, sizeof(query) - (size_t)off, "%s\"element_value_%d\"",
i ? "," : "", i);
}
snprintf(query + off, sizeof(query) - (size_t)off, "] AS x MATCH (f) RETURN f.name");
cbm_query_t *q = NULL;
char *err = NULL;
int rc = cbm_cypher_parse(query, &q, &err);
ASSERT_EQ(rc, 0);
ASSERT_NOT_NULL(q->unwind_expr);
cbm_query_free(q);
PASS();
}
/* ── Issue #389 group: Cypher feature reproductions ─────────────────
* Each asserts the CORRECT behavior; a failure reproduces the bug. */
/* #240: labels() function */
TEST(cypher_issue240_labels_function) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (n:Module) RETURN labels(n) AS lbl", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_NULL(r.error);
ASSERT_EQ(r.row_count, 1);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* #237: DISTINCT applied before ORDER BY + LIMIT */
TEST(cypher_issue237_distinct_order_limit) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (f:Function) RETURN DISTINCT f.label AS l ORDER BY l LIMIT 10", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_NULL(r.error);
ASSERT_EQ(r.row_count, 1);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* #873: duplicate projected rows must be deduped before ORDER BY + LIMIT */
TEST(cypher_issue873_distinct_order_limit_dedupes_before_limit) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (n) RETURN DISTINCT n.label AS label ORDER BY label LIMIT 2", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_NULL(r.error);
ASSERT_EQ(r.row_count, 2);
ASSERT_STR_EQ(r.rows[0][0], "Function");
ASSERT_STR_EQ(r.rows[1][0], "Module");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* #873: early LIMIT must not truncate rows before DISTINCT for simple RETURN */
TEST(cypher_issue873_distinct_limit_dedupes_before_limit) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc =
cbm_cypher_execute(s, "MATCH (n) RETURN DISTINCT n.label AS label LIMIT 2", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_NULL(r.error);
ASSERT_EQ(r.row_count, 2);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* #873: SKIP is applied after DISTINCT and ORDER BY, not before dedupe */
TEST(cypher_issue873_distinct_order_skip_limit_dedupes_before_skip) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (n) RETURN DISTINCT n.label AS label ORDER BY label SKIP 1 LIMIT 1", "test", 0,
&r);
ASSERT_EQ(rc, 0);
ASSERT_NULL(r.error);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "Module");
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* #252: toInteger() */
TEST(cypher_issue252_tointeger) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN toInteger(f.start_line) AS ln",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_NULL(r.error);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* #305: count(*) + AS alias */
TEST(cypher_issue305_count_star_alias) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (n) RETURN count(*) AS total", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_NULL(r.error);
ASSERT_EQ(r.row_count, 1);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* Regression: projecting several computed/JSON properties in one row must yield
* DISTINCT values. node_prop previously returned a single shared static buffer,
* so every such column aliased the last property read — and because the search
* key is matched in the JSON, `loop_depth` must not be confused with its suffix
* `transitive_loop_depth`. Exercises the bottleneck metrics end-to-end. */
TEST(cypher_multi_prop_projection_no_alias) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t n = {.project = "test",
.label = "Function",
.name = "Hot",
.qualified_name = "test.Hot",
.file_path = "hot.go",
.start_line = 10,
.end_line = 42,
.properties_json = "{\"complexity\":3,\"cognitive\":7,\"loop_count\":2,"
"\"loop_depth\":1,\"self_recursive\":false,"
"\"transitive_loop_depth\":5,\"recursive\":true}"};
cbm_store_upsert_node(s, &n);
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (f:Function) RETURN f.loop_depth, f.transitive_loop_depth, "
"f.cognitive, f.complexity, f.start_line, f.end_line",
"test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_EQ(r.col_count, 6);
ASSERT_STR_EQ(r.rows[0][0], "1"); /* loop_depth — NOT the suffix transitive_loop_depth */
ASSERT_STR_EQ(r.rows[0][1], "5"); /* transitive_loop_depth */
ASSERT_STR_EQ(r.rows[0][2], "7"); /* cognitive */
ASSERT_STR_EQ(r.rows[0][3], "3"); /* complexity */
ASSERT_STR_EQ(r.rows[0][4], "10"); /* start_line (computed) */
ASSERT_STR_EQ(r.rows[0][5], "42"); /* end_line (computed) — distinct from start_line */
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* Result projection writes into fixed-width per-row stack arrays
* (vals[CBM_SZ_32] / func_bufs[CBM_SZ_32][…] in execute_return_simple and its
* siblings), indexed by the parsed RETURN item count. The parser must bound
* that count to the array width; an over-wide RETURN has to be rejected, not
* allowed to run and write past the arrays. Drive a >32-column RETURN in a
* forked child so a stack overwrite (ASan abort, or a raw segfault) shows up
* as a killing signal instead of taking down the whole runner; the bounded
* path returns an ordinary error and the child exits cleanly. */
/* Property projection must return the WHOLE value of composite properties.
* json_extract_prop() scanned a non-string value up to the first ',' — so an
* array/object property was truncated at its first INTERNAL comma. Real-world
* hit: a NestJS handler's decorators
* ["@Roles('OWNER', 'ADMIN')","@Get()"]
* projected as ["@Roles('OWNER' — unusable for route/authz queries. */
TEST(cypher_exec_prop_array_with_internal_commas) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t n = {.project = "test",
.label = "Method",
.name = "findAll",
.qualified_name = "test.PacienteController.findAll",
.file_path = "paciente.controller.ts",
.properties_json =
"{\"decorators\":[\"@Roles('OWNER', 'ADMIN')\",\"@Get()\"],\"lines\":3}"};
cbm_store_upsert_node(s, &n);
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (m:Method) RETURN m.decorators, m.lines", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
/* whole array, commas and all — was ["@Roles('OWNER' before the fix */
ASSERT_STR_EQ(r.rows[0][0], "[\"@Roles('OWNER', 'ADMIN')\",\"@Get()\"]");
ASSERT_STR_EQ(r.rows[0][1], "3"); /* scalar sibling still parses */
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* A string property must not end at an ESCAPED quote: the scan stopped at the
* first '"' regardless of a preceding backslash, cutting the value short. */
/* Planner (2026-09-16): a single-hop pattern whose FAR node carries the
* selective filter is walked from that end. `MATCH (a)-[:CALLS]->(b) WHERE
* b.name = 'X'` used to scan every node before the filter on b could act — on
* the 8.5 M node kernel graph it hit the execution-time limit while the
* anchored spelling answered in seconds. The rows must be the same in every
* spelling, including the inline-property and inbound-direction forms, and
* the caller-side variable must still bind the right nodes after the swap. */
TEST(cypher_single_hop_seeds_from_selective_far_node) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t where_form = {0};
ASSERT_EQ(cbm_cypher_execute(s,
"MATCH (a)-[:CALLS]->(b) WHERE b.name = 'ValidateOrder' "
"RETURN a.name ORDER BY a.name",
"test", 0, &where_form),
0);
ASSERT_EQ(where_form.row_count, 1);
ASSERT_STR_EQ(where_form.rows[0][0], "HandleOrder");
cbm_cypher_result_free(&where_form);
cbm_cypher_result_t inline_form = {0};
ASSERT_EQ(cbm_cypher_execute(s,
"MATCH (a)-[:CALLS]->(b {name: 'SubmitOrder'}) "
"RETURN a.name",
"test", 0, &inline_form),
0);
ASSERT_EQ(inline_form.row_count, 1);
ASSERT_STR_EQ(inline_form.rows[0][0], "ValidateOrder");
cbm_cypher_result_free(&inline_form);
/* Inbound spelling: the far node is now the CALLER; direction inverts back. */
cbm_cypher_result_t inbound_form = {0};
ASSERT_EQ(cbm_cypher_execute(s,
"MATCH (callee)<-[:CALLS]-(caller) WHERE caller.name = "
"'HandleOrder' RETURN callee.name ORDER BY callee.name",
"test", 0, &inbound_form),
0);
ASSERT_EQ(inbound_form.row_count, 2);
ASSERT_STR_EQ(inbound_form.rows[0][0], "LogError");
ASSERT_STR_EQ(inbound_form.rows[1][0], "ValidateOrder");
cbm_cypher_result_free(&inbound_form);
/* Count aggregation through the swapped seed. */
cbm_cypher_result_t count_form = {0};
ASSERT_EQ(cbm_cypher_execute(s,
"MATCH (a)-[:CALLS]->(b) WHERE b.name = 'LogError' "
"RETURN count(a) AS callers",
"test", 0, &count_form),
0);
ASSERT_EQ(count_form.row_count, 1);
ASSERT_STR_EQ(count_form.rows[0][0], "1");
cbm_cypher_result_free(&count_form);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_prop_string_with_escaped_quote) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t n = {.project = "test",
.label = "Function",
.name = "parse",
.qualified_name = "test.parse",
.file_path = "p.ts",
.properties_json = "{\"signature\":\"(sep: \\\"a,b\\\") => void\"}"};
cbm_store_upsert_node(s, &n);
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (f:Function) RETURN f.signature", "test", 0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
ASSERT_STR_EQ(r.rows[0][0], "(sep: \\\"a,b\\\") => void"); /* was: (sep: \ */
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_wide_return_projection_bounded) {
#ifdef _WIN32
SKIP_PLATFORM("fork crash-isolation is POSIX-only; the parse-time bound is platform-agnostic");
#else
char query[4096];
int off = snprintf(query, sizeof(query), "MATCH (f:Function) RETURN ");
for (int i = 0; i < 48; i++) { /* 48 > CBM_SZ_32 (32) fixed columns */
off += snprintf(query + off, sizeof(query) - (size_t)off, "%sf.p%d", i ? ", " : "", i);
}
fflush(NULL);
pid_t pid = fork();
if (pid == 0) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
(void)cbm_cypher_execute(s, query, "test", 0, &r);
cbm_cypher_result_free(&r);
cbm_store_close(s);
_exit(0); /* reached only when execution did NOT overflow */
}
ASSERT_TRUE(pid > 0);
int status = 0;
(void)waitpid(pid, &status, 0);
if (WIFSIGNALED(status)) {
char msg[96];
snprintf(msg, sizeof(msg), "wide RETURN killed by signal %d — projection stack overflow",
WTERMSIG(status));
FAIL(msg);
}
ASSERT_TRUE(WIFEXITED(status));
PASS();
#endif
}
/* #601: an unbounded whole-graph OPTIONAL MATCH / GROUP BY does
* O(bindings x groups) work and can run for minutes with no wall-clock guard —
* the 100k row ceiling never fires because no rows are produced, so query_graph
* just hangs. With the execution deadline armed to trip immediately (budget 0),
* the runaway query must abort with a clear error instead of returning a
* (misleading, possibly partial) result.
*
* RED on unfixed code: no deadline exists, so the query completes and returns
* rc==0 with rows and no error — the assertions below fail. */
TEST(cypher_exec_deadline_aborts_runaway_query_issue601) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
cbm_cypher_test_set_deadline_ms(0); /* trip on the first hot-loop check */
int rc = cbm_cypher_execute(
s, "MATCH (a) OPTIONAL MATCH (a)-[:CALLS]->(b) RETURN a.qualified_name, count(b)", "test",
0, &r);
cbm_cypher_test_set_deadline_ms(-1); /* restore default before asserting (thread-local) */
ASSERT_TRUE(rc != 0); /* CBM_NOT_FOUND (-1) — query aborted, not success */
ASSERT_NOT_NULL(r.error);
ASSERT_TRUE(strstr(r.error, "time limit") != NULL);
ASSERT_EQ(r.row_count, 0);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* #601 companion: the default (ample) budget must NOT false-positive on a
* normal small query — it still returns its rows. */
TEST(cypher_exec_deadline_allows_normal_query_issue601) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (a) OPTIONAL MATCH (a)-[:CALLS]->(b) RETURN a.qualified_name, count(b)", "test",
0, &r);
ASSERT_EQ(rc, 0);
ASSERT_TRUE(r.error == NULL);
ASSERT_GT(r.row_count, 0);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* ══════════════════════════════════════════════════════════════════ */
SUITE(cypher) {
/* Lexer */
RUN_TEST(cypher_lex_simple_match);
RUN_TEST(cypher_lex_relationship);
RUN_TEST(cypher_lex_string_literal);
RUN_TEST(cypher_lex_single_quote_string);
RUN_TEST(cypher_lex_string_overflow);
RUN_TEST(cypher_lex_number);
RUN_TEST(cypher_lex_operators);
RUN_TEST(cypher_lex_keywords_case_insensitive);
RUN_TEST(cypher_lex_pipe_and_star);
RUN_TEST(cypher_lex_full_query);
/* Parser */
RUN_TEST(cypher_parse_simple_node);
RUN_TEST(cypher_parse_rejects_trailing_tokens);
RUN_TEST(cypher_parse_rejects_second_with_clause);
RUN_TEST(cypher_parse_accepts_single_with_clause);
RUN_TEST(cypher_parse_relationship_outbound);
RUN_TEST(cypher_parse_relationship_inbound);
RUN_TEST(cypher_parse_relationship_any);
RUN_TEST(cypher_parse_variable_length);
RUN_TEST(cypher_parse_variable_length_unbounded);
RUN_TEST(cypher_parse_multiple_edge_types);
RUN_TEST(cypher_parse_where_clause);
RUN_TEST(cypher_parse_where_regex);
RUN_TEST(cypher_parse_where_and);
RUN_TEST(cypher_parse_return_simple);
RUN_TEST(cypher_parse_return_count);
RUN_TEST(cypher_parse_return_order_limit);
RUN_TEST(cypher_parse_multikey_order_by_issue1334);
RUN_TEST(cypher_parse_order_by_over_cap_rejected_issue1334);
RUN_TEST(cypher_parse_nonnumeric_limit_rejected_issue1994);
RUN_TEST(cypher_parse_nonnumeric_skip_rejected_issue1994);
RUN_TEST(cypher_parse_word_limit_operand_rejected_issue1994);
RUN_TEST(cypher_parse_numeric_skip_limit_still_accepted);
RUN_TEST(cypher_parse_return_distinct);
RUN_TEST(cypher_parse_inline_props);
RUN_TEST(cypher_parse_error);
/* Execution */
RUN_TEST(cypher_exec_deadline_aborts_runaway_query_issue601);
RUN_TEST(cypher_exec_deadline_allows_normal_query_issue601);
RUN_TEST(cypher_deep_nesting_rejected_not_crash);
RUN_TEST(cypher_exec_match_all_functions);
RUN_TEST(cypher_exec_optional_empty_label_no_overflow);
RUN_TEST(cypher_cross_join_alloc_rejects_overflow);
RUN_TEST(cypher_exec_optional_rel_saturated_no_overflow);
RUN_TEST(cypher_exec_optional_saturated_does_not_fabricate_no_match);
RUN_TEST(cypher_exec_optional_rel_leaf_fallback_survives);
RUN_TEST(cypher_exec_bound_terminal_optional_fallback_survives);
RUN_TEST(cypher_exec_bound_terminal_saturation_no_false_deadcode);
RUN_TEST(cypher_issue240_labels_function);
RUN_TEST(cypher_issue237_distinct_order_limit);
RUN_TEST(cypher_issue873_distinct_order_limit_dedupes_before_limit);
RUN_TEST(cypher_issue873_distinct_limit_dedupes_before_limit);
RUN_TEST(cypher_issue873_distinct_order_skip_limit_dedupes_before_skip);
RUN_TEST(cypher_issue252_tointeger);
RUN_TEST(cypher_issue305_count_star_alias);
RUN_TEST(cypher_exec_where_eq);
RUN_TEST(cypher_exec_unlabeled_where_beyond_result_limit_issue1196);
RUN_TEST(cypher_exec_aggregate_sees_all_edges_beyond_expansion_cap_issue1196);
RUN_TEST(cypher_exec_varlength_path_semantics_issue797);
RUN_TEST(cypher_exec_where_coalesce_issue874);
RUN_TEST(cypher_exec_where_regex);
RUN_TEST(cypher_exec_where_contains);
RUN_TEST(cypher_exec_where_starts_with);
RUN_TEST(cypher_exec_return_properties);
RUN_TEST(cypher_func_labels);
RUN_TEST(cypher_func_type);
RUN_TEST(cypher_func_id);
RUN_TEST(cypher_func_keys);
RUN_TEST(cypher_func_properties);
RUN_TEST(cypher_func_tointeger_tofloat);
RUN_TEST(cypher_func_size_reverse);
RUN_TEST(cypher_func_multiarg);
RUN_TEST(cypher_issue874_where_coalesce_numeric);
RUN_TEST(cypher_issue874_where_coalesce_string);
RUN_TEST(cypher_issue874_where_coalesce_not_and);
RUN_TEST(cypher_issue874_where_substring);
RUN_TEST(cypher_issue874_where_unsupported_func_error);
RUN_TEST(cypher_multi_prop_projection_no_alias);
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);
RUN_TEST(cypher_exec_count);
RUN_TEST(cypher_exec_optional_bound_terminal_count_ignores_unbound);
RUN_TEST(cypher_exec_limit);
RUN_TEST(cypher_exec_order_by);
RUN_TEST(cypher_exec_variable_length);
RUN_TEST(cypher_exec_variable_length_repeated_node_var_unifies);
RUN_TEST(cypher_exec_var_length_explicit_bound_capped);
RUN_TEST(cypher_exec_var_length_no_reuse_self_loop);
RUN_TEST(cypher_exec_var_length_truncation_surfaces_warning);
RUN_TEST(cypher_exec_defines_edge);
RUN_TEST(cypher_exec_no_results);
RUN_TEST(cypher_exec_where_numeric);
/* Go test ports */
RUN_TEST(cypher_exec_distinct);
RUN_TEST(cypher_exec_with_distinct_issue238);
RUN_TEST(cypher_exec_where_label_test_issue241);
RUN_TEST(cypher_exec_label_alternation_issue242);
RUN_TEST(cypher_exec_count_distinct_issue239);
RUN_TEST(cypher_exec_unsupported_func_errors_issue373);
RUN_TEST(cypher_rejects_projection_of_dropped_with_var_issue1919);
RUN_TEST(cypher_optional_match_target_still_allowed_issue1919);
RUN_TEST(cypher_with_alias_stays_in_scope_issue1919);
RUN_TEST(cypher_exec_unknown_func_return_errors);
RUN_TEST(cypher_exec_inline_props);
RUN_TEST(cypher_parse_where_starts_with);
RUN_TEST(cypher_parse_where_contains);
RUN_TEST(cypher_parse_where_numeric);
/* Edge property tests (ported from cypher_test.go Feature 2) */
RUN_TEST(cypher_edge_prop_access);
RUN_TEST(cypher_edge_prop_storage_is_per_thread);
RUN_TEST(cypher_edge_prop_in_where);
RUN_TEST(cypher_edge_type_prop);
RUN_TEST(cypher_edge_filter_contains);
RUN_TEST(cypher_edge_filter_numeric_gte);
RUN_TEST(cypher_bare_edge_return_exposes_properties_json);
RUN_TEST(cypher_edge_return_without_filter);
RUN_TEST(cypher_edge_filter_equals);
RUN_TEST(cypher_edge_filter_starts_with);
RUN_TEST(cypher_edge_combined_node_and_edge_filter);
RUN_TEST(cypher_edge_filter_no_match);
RUN_TEST(cypher_edge_filter_numeric_lt);
RUN_TEST(cypher_edge_filter_regex);
RUN_TEST(cypher_edge_builtin_type_filter);
RUN_TEST(cypher_apply_limit);
RUN_TEST(cypher_result_reports_max_rows_saturation);
RUN_TEST(cypher_result_unlabeled_scan_is_exhaustive_before_aggregation);
RUN_TEST(cypher_result_reports_variable_length_candidate_saturation);
/* Phase 1: Simple operators */
RUN_TEST(cypher_lex_neq_operators);
RUN_TEST(cypher_lex_ends_keyword);
RUN_TEST(cypher_lex_in_is_null);
RUN_TEST(cypher_exec_where_neq);
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_in);
RUN_TEST(cypher_exec_where_not_in);
RUN_TEST(cypher_exec_where_is_null);
RUN_TEST(cypher_exec_where_is_not_null);
RUN_TEST(cypher_exec_return_star);
RUN_TEST(cypher_return_star_dedups_repeated_pattern_var);
RUN_TEST(cypher_return_star_after_with_names_aliases);
RUN_TEST(cypher_wide_with_refused_not_truncated);
RUN_TEST(cypher_wide_pattern_refused);
RUN_TEST(cypher_wide_edge_pattern_refused);
RUN_TEST(cypher_unnamed_head_takes_a_slot);
RUN_TEST(cypher_scope_check_survives_wide_pattern);
RUN_TEST(cypher_parse_neq);
RUN_TEST(cypher_parse_in);
RUN_TEST(cypher_parse_is_null);
/* Phase 2: Expression tree */
RUN_TEST(cypher_exec_where_or);
RUN_TEST(cypher_exec_where_complex_bool);
RUN_TEST(cypher_exec_where_xor);
RUN_TEST(cypher_exec_where_not_prefix);
RUN_TEST(cypher_parse_expr_tree_and_or);
RUN_TEST(cypher_parse_expr_tree_nested);
/* Phase 3: Unsupported keyword errors */
RUN_TEST(cypher_error_create);
RUN_TEST(cypher_error_delete);
RUN_TEST(cypher_error_set);
RUN_TEST(cypher_error_merge);
RUN_TEST(cypher_error_call);
/* Phase 4: SKIP + aggregation */
RUN_TEST(cypher_exec_skip);
RUN_TEST(cypher_exec_skip_limit);
RUN_TEST(cypher_exec_multikey_order_by_keeps_limit_issue1334);
RUN_TEST(cypher_exec_multikey_order_by_tiebreak_issue1334);
RUN_TEST(cypher_exec_with_multikey_order_by_keeps_limit_issue1334);
RUN_TEST(cypher_exec_sum);
RUN_TEST(cypher_exec_avg);
RUN_TEST(cypher_exec_min);
RUN_TEST(cypher_exec_max);
RUN_TEST(cypher_exec_collect);
RUN_TEST(cypher_exec_count_star);
RUN_TEST(cypher_issue1111_return_type_count_group);
RUN_TEST(cypher_parse_skip);
RUN_TEST(cypher_parse_sum_avg);
RUN_TEST(cypher_parse_collect);
/* Phase 5: String functions + CASE */
RUN_TEST(cypher_exec_tolower);
RUN_TEST(cypher_exec_toupper);
RUN_TEST(cypher_exec_tostring);
RUN_TEST(cypher_exec_case);
RUN_TEST(cypher_parse_tolower);
RUN_TEST(cypher_parse_case);
/* Phase 6: WITH clause */
RUN_TEST(cypher_exec_with_rename);
RUN_TEST(cypher_issue2208_with_agg_carried_node_props);
RUN_TEST(cypher_issue2208_with_agg_groups_by_node_identity);
RUN_TEST(cypher_issue2208_with_simple_carried_node_props);
RUN_TEST(cypher_exec_with_count);
RUN_TEST(cypher_issue1111_with_type_count_group);
RUN_TEST(cypher_issue1111_with_scalar_func_alias_no_node_leak);
RUN_TEST(cypher_exec_with_node_groupvar_prop);
RUN_TEST(cypher_exec_with_where);
RUN_TEST(cypher_exec_with_orderby_limit);
RUN_TEST(cypher_parse_with);
RUN_TEST(cypher_parse_with_where);
/* Phase 7: OPTIONAL MATCH + multiple MATCH */
RUN_TEST(cypher_exec_optional_match_no_result);
RUN_TEST(cypher_exec_optional_match_has_result);
RUN_TEST(cypher_exec_multi_match);
RUN_TEST(cypher_parse_optional_match);
RUN_TEST(cypher_parse_multi_match);
/* Phase 8: UNION */
RUN_TEST(cypher_exec_union);
RUN_TEST(cypher_exec_union_all);
RUN_TEST(cypher_parse_union);
/* Phase 9: UNWIND */
RUN_TEST(cypher_parse_unwind);
RUN_TEST(cypher_parse_unwind_var);
RUN_TEST(cypher_parse_unwind_oversized_literal_no_overflow);
RUN_TEST(cypher_parse_unwind_many_elements_no_overflow);
RUN_TEST(cypher_wide_return_projection_bounded);
/* Composite property projection (arrays/objects, escaped quotes) */
RUN_TEST(cypher_exec_prop_array_with_internal_commas);
RUN_TEST(cypher_exec_prop_string_with_escaped_quote);
RUN_TEST(cypher_single_hop_seeds_from_selective_far_node);
}