Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/cypher/cypher.c
Original file line number Diff line number Diff line change
Expand Up @@ -2972,12 +2972,18 @@ static void expand_var_length(cbm_store_t *store, cbm_rel_pattern_t *rel,
}
cbm_traverse_result_t tr = {0};
const char *dir = rel->direction ? rel->direction : "outbound";
cbm_store_bfs(store, src->id, dir, rel->types, rel->type_count, max_depth, CBM_PERCENT, &tr);
cbm_store_bfs_trail(store, src->id, dir, rel->types, rel->type_count, max_depth, CBM_PERCENT,
&tr);
cbm_node_t *bound_to = binding_get(b, to_var);
int64_t bound_to_id = bound_to ? bound_to->id : 0;
for (int v = 0; v < tr.visited_count && *new_count < max_new; v++) {
cbm_node_hop_t *hop = &tr.visited[v];
if (hop->hop < rel->min_hops) {
continue;
}
if (bound_to && hop->node.id != bound_to_id) {
continue;
}
if (target_node->label && !label_alt_matches(hop->node.label, target_node->label)) {
continue;
}
Expand Down
106 changes: 80 additions & 26 deletions src/store/store.c
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ enum {
ST_COL_7 = 7,
ST_COL_8 = 8,
ST_COL_9 = 9,
ST_COL_10 = 10,
ST_FOUND = -1,
ST_BUF_16 = 16,
ST_BUF_64 = 64,
Expand Down Expand Up @@ -57,6 +58,8 @@ enum {
ST_METHOD_PROP_LEN = 8,
ST_PATH_PROP_LEN = 6,
ST_HANDLER_PROP_LEN = 9,
ST_BFS_CTE_ROW_MULTIPLIER = 8,
ST_BFS_MAX_CTE_ROWS = 4096,
};

#define SLEN(s) (sizeof(s) - 1)
Expand Down Expand Up @@ -3245,8 +3248,21 @@ static void bfs_build_types_clause(int edge_type_count, char *buf, int buf_sz) {
}
}

int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const char **edge_types,
int edge_type_count, int max_depth, int max_results, cbm_traverse_result_t *out) {
static int bfs_cte_row_limit(int max_results) {
int result_budget = max_results > 0 ? max_results : ST_INIT_CAP_16;
long row_budget = (long)result_budget * ST_BFS_CTE_ROW_MULTIPLIER + SKIP_ONE;
if (row_budget > ST_BFS_MAX_CTE_ROWS) {
return ST_BFS_MAX_CTE_ROWS;
}
if (row_budget < ST_INIT_CAP_16) {
return ST_INIT_CAP_16;
}
return (int)row_budget;
}

static int store_bfs(cbm_store_t *s, int64_t start_id, const char *direction,
const char **edge_types, int edge_type_count, int max_depth, int max_results,
bool trail, cbm_traverse_result_t *out) {
memset(out, 0, sizeof(*out));

cbm_node_t root = {0};
Expand All @@ -3273,30 +3289,45 @@ int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const
next_id = "e.target_id";
}

snprintf(sql, sizeof(sql),
/* SHORTEST-PATH semantics: the UNION dedupes (node, hop) PAIRS,
* so a single self-loop minted every hop level for every node it
* could reach — walk-padding that fabricated *k..k Cypher matches
* of arbitrary length and exploded the row set to nodes x depth
* (#797). MIN(hop) GROUP BY node returns each node once at its
* minimal distance. */
"WITH RECURSIVE bfs(node_id, hop) AS ("
" SELECT %lld, 0"
" UNION"
" SELECT %s, bfs.hop + 1"
" FROM bfs"
" JOIN edges e ON %s"
" WHERE e.type IN (%s) AND bfs.hop < %d"
")"
"SELECT n.id, n.project, n.label, n.name, n.qualified_name, "
"n.file_path, n.start_line, n.end_line, n.properties, MIN(bfs.hop) AS hop "
"FROM bfs "
"JOIN nodes n ON n.id = bfs.node_id "
"WHERE bfs.hop > 0 " /* exclude root at hop 0 (self via a loop still appears) */
"GROUP BY n.id "
"ORDER BY hop "
"LIMIT %d;",
(long long)start_id, next_id, join_cond, types_clause, max_depth, max_results);
int cte_row_limit = 0;
if (trail) {
cte_row_limit = bfs_cte_row_limit(max_results);
snprintf(sql, sizeof(sql),
"WITH RECURSIVE bfs(node_id, hop, edge_path) AS ("
" SELECT %lld, 0, ''"
" UNION"
" SELECT %s, bfs.hop + 1,"
" CASE WHEN bfs.edge_path = '' THEN CAST(e.id AS TEXT)"
" ELSE bfs.edge_path || ',' || e.id END"
" FROM bfs"
" JOIN edges e ON %s"
" WHERE e.type IN (%s) AND bfs.hop < %d"
" AND instr(',' || bfs.edge_path || ',', ',' || e.id || ',') = 0"
" LIMIT %d"
")"
"SELECT DISTINCT n.id, n.project, n.label, n.name, n.qualified_name, "
"n.file_path, n.start_line, n.end_line, n.properties, bfs.hop, "
"(SELECT count(*) FROM bfs) "
"FROM bfs JOIN nodes n ON n.id = bfs.node_id "
"WHERE bfs.hop > 0 ORDER BY bfs.hop LIMIT %d;",
(long long)start_id, next_id, join_cond, types_clause, max_depth,
cte_row_limit + SKIP_ONE, max_results);
} else {
snprintf(sql, sizeof(sql),
"WITH RECURSIVE bfs(node_id, hop) AS ("
" SELECT %lld, 0"
" UNION"
" SELECT %s, bfs.hop + 1"
" FROM bfs"
" JOIN edges e ON %s"
" WHERE e.type IN (%s) AND bfs.hop < %d"
")"
"SELECT DISTINCT n.id, n.project, n.label, n.name, n.qualified_name, "
"n.file_path, n.start_line, n.end_line, n.properties, bfs.hop "
"FROM bfs JOIN nodes n ON n.id = bfs.node_id "
"WHERE bfs.hop > 0 ORDER BY bfs.hop LIMIT %d;",
(long long)start_id, next_id, join_cond, types_clause, max_depth, max_results);
}

sqlite3_stmt *stmt = NULL;
rc = sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL);
Expand All @@ -3316,6 +3347,7 @@ int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const

int cap = ST_INIT_CAP_16;
int n = 0;
int cte_rows = 0;
cbm_node_hop_t *visited = malloc(cap * sizeof(cbm_node_hop_t));

int scan_rc15;
Expand All @@ -3326,6 +3358,9 @@ int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const
}
scan_node(stmt, &visited[n].node);
visited[n].hop = sqlite3_column_int(stmt, ST_COL_9);
if (trail) {
cte_rows = sqlite3_column_int(stmt, ST_COL_10);
}
n++;
}
if (scan_rc15 != SQLITE_DONE) { /* SCANCHK:15:stmt */
Expand All @@ -3338,6 +3373,12 @@ int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const

sqlite3_finalize(stmt);

if (trail && cte_rows > cte_row_limit) {
char limit_buf[16];
snprintf(limit_buf, sizeof(limit_buf), "%d", cte_row_limit);
cbm_log_warn("cypher.trail_truncated", "cte_rows", limit_buf, "result", "partial");
}

out->visited = visited;
out->visited_count = n;

Expand All @@ -3353,6 +3394,19 @@ int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const
return CBM_STORE_OK;
}

int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const char **edge_types,
int edge_type_count, int max_depth, int max_results, cbm_traverse_result_t *out) {
return store_bfs(s, start_id, direction, edge_types, edge_type_count, max_depth, max_results,
false, out);
}

int cbm_store_bfs_trail(cbm_store_t *s, int64_t start_id, const char *direction,
const char **edge_types, int edge_type_count, int max_depth,
int max_results, cbm_traverse_result_t *out) {
return store_bfs(s, start_id, direction, edge_types, edge_type_count, max_depth, max_results,
true, out);
}

void cbm_store_traverse_free(cbm_traverse_result_t *out) {
if (!out) {
return;
Expand Down
5 changes: 5 additions & 0 deletions src/store/store.h
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,11 @@ void cbm_store_search_free(cbm_search_output_t *out);
int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const char **edge_types,
int edge_type_count, int max_depth, int max_results, cbm_traverse_result_t *out);

/* Variable-length Cypher traversal with relationship-trail semantics. */
int cbm_store_bfs_trail(cbm_store_t *s, int64_t start_id, const char *direction,
const char **edge_types, int edge_type_count, int max_depth,
int max_results, cbm_traverse_result_t *out);

/* Free a traverse result's allocated memory. */
void cbm_store_traverse_free(cbm_traverse_result_t *out);

Expand Down
59 changes: 56 additions & 3 deletions tests/test_cypher.c
Original file line number Diff line number Diff line change
Expand Up @@ -559,15 +559,24 @@ TEST(cypher_exec_varlength_path_semantics_issue797) {
ASSERT_EQ(r1.row_count, 1);
cbm_cypher_result_free(&r1);

/* Bug 2: *2..2 from loopy — only the REAL 2-chain (leaf); the self-loop
* must not be reused to pad paths (relationship uniqueness). */
/* 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, 1); /* leaf only */
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. */
Expand Down Expand Up @@ -1053,6 +1062,48 @@ TEST(cypher_exec_var_length_explicit_bound_capped) {
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();
}

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_defines_edge) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
Expand Down Expand Up @@ -2893,6 +2944,8 @@ SUITE(cypher) {
RUN_TEST(cypher_exec_order_by);
RUN_TEST(cypher_exec_variable_length);
RUN_TEST(cypher_exec_var_length_explicit_bound_capped);
RUN_TEST(cypher_exec_variable_length_repeated_node_var_unifies);
RUN_TEST(cypher_exec_var_length_no_reuse_self_loop);
RUN_TEST(cypher_exec_defines_edge);
RUN_TEST(cypher_exec_no_results);
RUN_TEST(cypher_exec_where_numeric);
Expand Down
87 changes: 87 additions & 0 deletions tests/test_store_search.c
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,24 @@
* Ported from internal/store/store_test.go (TestSearch, TestBFS, etc.)
*/
#include "../src/foundation/compat.h"
#include "../src/foundation/log.h"
#include "test_framework.h"
#include "test_helpers.h"
#include <store/store.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>

static char trail_log[1024];

static void capture_trail_log(const char *line) {
size_t used = strlen(trail_log);
if (used < sizeof(trail_log) - 1) {
snprintf(trail_log + used, sizeof(trail_log) - used, "%s\n", line);
}
}

/* Helper: create a typical graph for search/traversal tests.
*
* Nodes: SubmitOrder (Function), ProcessOrder (Function), OrderService (Class)
Expand Down Expand Up @@ -991,6 +1002,80 @@ TEST(store_bfs_with_risk_labels) {
PASS();
}

TEST(store_bfs_reachability_is_not_trail_capped) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");

cbm_node_t root = {.project = "test",
.label = "Function",
.name = "Root",
.qualified_name = "test.Root"};
int64_t root_id = cbm_store_upsert_node(s, &root);

enum { NODE_COUNT = 4200 };
for (int i = 0; i < NODE_COUNT; i++) {
char name[32];
snprintf(name, sizeof(name), "Leaf%d", i);
cbm_node_t leaf = {
.project = "test", .label = "Function", .name = name, .qualified_name = name};
int64_t leaf_id = cbm_store_upsert_node(s, &leaf);
cbm_edge_t edge = {
.project = "test", .source_id = root_id, .target_id = leaf_id, .type = "CALLS"};
cbm_store_insert_edge(s, &edge);
}

const char *types[] = {"CALLS"};
cbm_traverse_result_t result = {0};
int rc = cbm_store_bfs(s, root_id, "outbound", types, 1, 1, 5000, &result);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_EQ(result.visited_count, NODE_COUNT);

cbm_store_traverse_free(&result);
cbm_store_close(s);
PASS();
}

TEST(store_bfs_trail_warns_when_path_rows_are_truncated) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");

int64_t ids[14];
for (int i = 0; i < 14; 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 < 13; source++) {
for (int target = source + 1; target < 14; target++) {
cbm_edge_t edge = {.project = "test",
.source_id = ids[source],
.target_id = ids[target],
.type = "CALLS"};
cbm_store_insert_edge(s, &edge);
}
}

trail_log[0] = '\0';
cbm_log_set_sink(capture_trail_log);
const char *types[] = {"CALLS"};
cbm_traverse_result_t result = {0};
int rc = cbm_store_bfs_trail(s, ids[0], "outbound", types, 1, 10, 5000, &result);
cbm_log_set_sink(NULL);

ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_TRUE(strstr(trail_log, "cypher.trail_truncated") != NULL);
ASSERT_TRUE(strstr(trail_log, "result=partial") != NULL);

cbm_store_traverse_free(&result);
cbm_store_close(s);
PASS();
}

/* ── BFS cross-service summary ─────────────────────────────────── */

TEST(store_bfs_cross_service_summary) {
Expand Down Expand Up @@ -1487,6 +1572,8 @@ SUITE(store_search) {
RUN_TEST(store_cross_service_detection);
RUN_TEST(store_deduplicate_hops);
RUN_TEST(store_bfs_with_risk_labels);
RUN_TEST(store_bfs_reachability_is_not_trail_capped);
RUN_TEST(store_bfs_trail_warns_when_path_rows_are_truncated);
RUN_TEST(store_bfs_cross_service_summary);
RUN_TEST(store_glob_to_like);
RUN_TEST(store_extract_like_hints);
Expand Down
Loading