fix: count INHERITS/IMPLEMENTS edges in search_graph degree

search_graph reported in_degree/out_degree of 0 for Class nodes that
have real INHERITS edges, because the degree computation in both
cbm_store_node_degree (Cypher virtual properties) and cbm_store_search
(result population and min_degree/max_degree filter) only counted CALLS
(and USAGE) edges. A min_degree filter therefore silently excluded
well-connected classes.

Broaden the edge-type predicate in both surfaces to
IN ('CALLS', 'USAGE', 'INHERITS', 'IMPLEMENTS') so search_graph and
Cypher report the same edge-type-agnostic degree and stay consistent.

Add regression tests covering: a parent class with N INHERITS children,
combined CALLS+INHERITS degree without double counting, min_degree now
including inheritance-only classes, and isolated nodes still reporting
zero.

Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
This commit is contained in:
Matt Van Horn committed 2026-06-23 04:08:36 -07:00
1 parent 684e35b0e2
commit 65b8425648
2 files changed
+185 -5

No files matched your search

+6 -4
View File
@@ -1789,7 +1789,8 @@ void cbm_store_node_degree(cbm_store_t *s, int64_t node_id, int *in_deg, int *ou
*in_deg = 0;
*out_deg = 0;
const char *in_sql = "SELECT COUNT(*) FROM edges WHERE target_id = ?1 AND type = 'CALLS'";
const char *in_sql = "SELECT COUNT(*) FROM edges WHERE target_id = ?1 AND "
"type IN ('CALLS', 'USAGE', 'INHERITS', 'IMPLEMENTS')";
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, in_sql, CBM_NOT_FOUND, &stmt, NULL) == SQLITE_OK) {
sqlite3_bind_int64(stmt, SKIP_ONE, node_id);
@@ -1799,7 +1800,8 @@ void cbm_store_node_degree(cbm_store_t *s, int64_t node_id, int *in_deg, int *ou
sqlite3_finalize(stmt);
}
const char *out_sql = "SELECT COUNT(*) FROM edges WHERE source_id = ?1 AND type = 'CALLS'";
const char *out_sql = "SELECT COUNT(*) FROM edges WHERE source_id = ?1 AND "
"type IN ('CALLS', 'USAGE', 'INHERITS', 'IMPLEMENTS')";
if (sqlite3_prepare_v2(s->db, out_sql, CBM_NOT_FOUND, &stmt, NULL) == SQLITE_OK) {
sqlite3_bind_int64(stmt, SKIP_ONE, node_id);
if (sqlite3_step(stmt) == SQLITE_ROW) {
@@ -2452,9 +2454,9 @@ int cbm_store_search(cbm_store_t *s, const cbm_search_params_t *params, cbm_sear
const char *select_cols = "SELECT n.id, n.project, n.label, n.name, n.qualified_name, "
"n.file_path, n.start_line, n.end_line, n.properties, "
"(SELECT COUNT(*) FROM edges e WHERE e.target_id = n.id AND "
"e.type IN ('CALLS', 'USAGE')) AS in_deg, "
"e.type IN ('CALLS', 'USAGE', 'INHERITS', 'IMPLEMENTS')) AS in_deg, "
"(SELECT COUNT(*) FROM edges e WHERE e.source_id = n.id AND "
"e.type IN ('CALLS', 'USAGE')) AS out_deg ";
"e.type IN ('CALLS', 'USAGE', 'INHERITS', 'IMPLEMENTS')) AS out_deg ";
char where[CBM_SZ_2K] = "";
search_bind_t binds[ST_SEARCH_MAX_BINDS];
+179 -1
View File
@@ -224,6 +224,15 @@ TEST(store_search_pagination) {
/* ── Search with degree filter ──────────────────────────────────── */
static int search_result_index_by_name(const cbm_search_output_t *out, const char *name) {
for (int i = 0; i < out->count; i++) {
if (out->results[i].node.name && strcmp(out->results[i].node.name, name) == 0) {
return i;
}
}
return -1;
}
TEST(store_search_degree_filter) {
int64_t ids[3];
cbm_store_t *s = setup_search_store(ids);
@@ -239,7 +248,7 @@ TEST(store_search_degree_filter) {
ASSERT_EQ(out.count, 2);
cbm_store_search_free(&out);
/* max_degree = 0 should find nodes with no CALLS edges */
/* max_degree = 0 should find nodes with no counted degree edges */
params.min_degree = -1; /* no min */
params.max_degree = 0; /* only zero-degree nodes */
params.label = "Function";
@@ -253,6 +262,171 @@ TEST(store_search_degree_filter) {
PASS();
}
TEST(store_search_degree_counts_inherits) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t parent = {.project = "test",
.label = "Class",
.name = "AbstractAttachmentDto",
.qualified_name = "test.AbstractAttachmentDto"};
int64_t parent_id = cbm_store_upsert_node(s, &parent);
ASSERT_GT(parent_id, 0);
const char *child_names[] = {"AttachmentDtoA", "AttachmentDtoB", "AttachmentDtoC"};
const char *child_qns[] = {"test.AttachmentDtoA", "test.AttachmentDtoB",
"test.AttachmentDtoC"};
for (int i = 0; i < 3; i++) {
cbm_node_t child = {.project = "test",
.label = "Class",
.name = child_names[i],
.qualified_name = child_qns[i]};
int64_t child_id = cbm_store_upsert_node(s, &child);
ASSERT_GT(child_id, 0);
cbm_edge_t edge = {.project = "test",
.source_id = child_id,
.target_id = parent_id,
.type = "INHERITS"};
ASSERT_GT(cbm_store_insert_edge(s, &edge), 0);
}
int in_deg = 0;
int out_deg = 0;
cbm_store_node_degree(s, parent_id, &in_deg, &out_deg);
ASSERT_EQ(in_deg, 3);
ASSERT_EQ(out_deg, 0);
cbm_search_params_t params = {
.project = "test", .label = "Class", .min_degree = -1, .max_degree = -1};
cbm_search_output_t out = {0};
int rc = cbm_store_search(s, &params, &out);
ASSERT_EQ(rc, CBM_STORE_OK);
int parent_idx = search_result_index_by_name(&out, "AbstractAttachmentDto");
ASSERT_GTE(parent_idx, 0);
ASSERT_EQ(out.results[parent_idx].in_degree, 3);
ASSERT_EQ(out.results[parent_idx].out_degree, 0);
cbm_store_search_free(&out);
cbm_store_close(s);
PASS();
}
TEST(store_search_degree_calls_plus_inherits_no_double_count) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t child = {.project = "test",
.label = "Class",
.name = "AttachmentDto",
.qualified_name = "test.AttachmentDto"};
cbm_node_t parent = {.project = "test",
.label = "Class",
.name = "BaseAttachmentDto",
.qualified_name = "test.BaseAttachmentDto"};
cbm_node_t fn = {.project = "test",
.label = "Function",
.name = "normalizeAttachment",
.qualified_name = "test.normalizeAttachment"};
int64_t child_id = cbm_store_upsert_node(s, &child);
int64_t parent_id = cbm_store_upsert_node(s, &parent);
int64_t fn_id = cbm_store_upsert_node(s, &fn);
ASSERT_GT(child_id, 0);
ASSERT_GT(parent_id, 0);
ASSERT_GT(fn_id, 0);
cbm_edge_t calls = {
.project = "test", .source_id = child_id, .target_id = fn_id, .type = "CALLS"};
cbm_edge_t inherits = {
.project = "test", .source_id = child_id, .target_id = parent_id, .type = "INHERITS"};
ASSERT_GT(cbm_store_insert_edge(s, &calls), 0);
ASSERT_GT(cbm_store_insert_edge(s, &inherits), 0);
int in_deg = 0;
int out_deg = 0;
cbm_store_node_degree(s, child_id, &in_deg, &out_deg);
ASSERT_EQ(in_deg, 0);
ASSERT_EQ(out_deg, 2);
cbm_search_params_t params = {
.project = "test", .label = "Class", .min_degree = -1, .max_degree = -1};
cbm_search_output_t out = {0};
int rc = cbm_store_search(s, &params, &out);
ASSERT_EQ(rc, CBM_STORE_OK);
int child_idx = search_result_index_by_name(&out, "AttachmentDto");
ASSERT_GTE(child_idx, 0);
ASSERT_EQ(out.results[child_idx].in_degree, 0);
ASSERT_EQ(out.results[child_idx].out_degree, 2);
cbm_store_search_free(&out);
cbm_store_close(s);
PASS();
}
TEST(store_search_min_degree_includes_inherits_only) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t child = {.project = "test",
.label = "Class",
.name = "InheritanceOnlyChild",
.qualified_name = "test.InheritanceOnlyChild"};
cbm_node_t parent = {.project = "test",
.label = "Class",
.name = "InheritanceOnlyParent",
.qualified_name = "test.InheritanceOnlyParent"};
int64_t child_id = cbm_store_upsert_node(s, &child);
int64_t parent_id = cbm_store_upsert_node(s, &parent);
ASSERT_GT(child_id, 0);
ASSERT_GT(parent_id, 0);
cbm_edge_t edge = {
.project = "test", .source_id = child_id, .target_id = parent_id, .type = "INHERITS"};
ASSERT_GT(cbm_store_insert_edge(s, &edge), 0);
cbm_search_params_t params = {
.project = "test", .label = "Class", .min_degree = 1, .max_degree = -1};
cbm_search_output_t out = {0};
int rc = cbm_store_search(s, &params, &out);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_GTE(search_result_index_by_name(&out, "InheritanceOnlyParent"), 0);
cbm_store_search_free(&out);
cbm_store_close(s);
PASS();
}
TEST(store_search_isolated_node_zero_degree) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t node = {.project = "test",
.label = "Class",
.name = "LonelyClass",
.qualified_name = "test.LonelyClass"};
int64_t node_id = cbm_store_upsert_node(s, &node);
ASSERT_GT(node_id, 0);
int in_deg = 0;
int out_deg = 0;
cbm_store_node_degree(s, node_id, &in_deg, &out_deg);
ASSERT_EQ(in_deg, 0);
ASSERT_EQ(out_deg, 0);
cbm_search_params_t params = {
.project = "test", .label = "Class", .min_degree = -1, .max_degree = -1};
cbm_search_output_t out = {0};
int rc = cbm_store_search(s, &params, &out);
ASSERT_EQ(rc, CBM_STORE_OK);
int idx = search_result_index_by_name(&out, "LonelyClass");
ASSERT_GTE(idx, 0);
ASSERT_EQ(out.results[idx].in_degree, 0);
ASSERT_EQ(out.results[idx].out_degree, 0);
cbm_store_search_free(&out);
cbm_store_close(s);
PASS();
}
/* ── Search all (no filters) ────────────────────────────────────── */
TEST(store_search_all) {
@@ -1295,6 +1469,10 @@ SUITE(store_search) {
RUN_TEST(store_search_file_pattern_substring_issue200);
RUN_TEST(store_search_pagination);
RUN_TEST(store_search_degree_filter);
RUN_TEST(store_search_degree_counts_inherits);
RUN_TEST(store_search_degree_calls_plus_inherits_no_double_count);
RUN_TEST(store_search_min_degree_includes_inherits_only);
RUN_TEST(store_search_isolated_node_zero_degree);
RUN_TEST(store_search_all);
RUN_TEST(store_search_exclude_labels);
RUN_TEST(store_search_case_insensitive);