mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
On the kernel index (8.5 M nodes, 1.9 M CALLS edges) `MATCH (a)-[:CALLS]->(b)
RETURN count(*)` peaked at 18-25 GB RSS (killed in a 12 GB container): the
store materialised every start node with all its strings (4.9 GB), every one
was deep-copied into a 1.6 KB binding, and every expanded row was materialised
again before projection.
cypher: execute_single now runs the start nodes through the pipeline in chunks
of 1024 (pattern-0 hops, late WHERE, WITH, RETURN). Each hop emits a binding's
expansions in input order and every consumer (RETURN rows / star / default
projection / aggregation, WITH aggregation / projection) keeps its state
between chunks, so the concatenated output is the single-pass output: same
rows, row order, first-seen group order, ORDER BY ties, DISTINCT, LIMIT and
max_rows truncation, OPTIONAL fallback rows, warnings. Queries with additional
MATCH patterns keep one pass (their join strategy depends on the first binding
of the whole set). Start nodes and fetched neighbours move their strings into
the binding instead of being deep-copied and freed.
store: cbm_store_node_scan_open/_next/_close hand out the rows of
cbm_store_find_nodes / _by_label a page at a time. Pages are keyset queries
along the index the eager query walks (idx_nodes_file: NULL file_path rows by
id, then the rest of the last file by id, then the files after it;
idx_nodes_label: by id), each reset before it returns, so no read lock is held
between pages. open verifies the eager plan with EXPLAIN QUERY PLAN and falls
back to the eager rows for any other plan.
Kernel, same index, medians of 3 (macOS arm64):
count(*) peak RSS 24.9 GB -> 256 MB, 23.0 s -> 13.0 s
top-callee peak RSS 12.4 GB -> 1.26 GB, 11.7 s -> 10.9 s
Responses byte-identical to the baseline on django, redis and the kernel
(19 Cypher + 24 tool cases each); the streamed scan equals the eager scan
field by field, in order, for every project and label of all three indexes.
A scan that fails part-way fails the whole query ("query aborted: reading the graph failed") instead of answering from the pages read so far:
The streamed start-node scan answered from the rows of the pages it had read
when a later page failed (a SQLite step error or a page re-prepare failure):
a partial result that looked complete. A failed page, a scan that cannot
open, or a failed eager fallback now marks the start source failed;
execute_chunk returns an error and cbm_cypher_execute reports
"query aborted: reading the graph failed (<store error>)" with no rows. The
store error is logged as cypher.scan_failed.
The test seam cbm_store_node_scan_test_fail_page_after (CBM_ENABLE_TEST_SEAMS)
fails one page after a given number of successful pages; the new test fails
the second page of a label scan (rows and count(*)) and a later page of the
all-nodes scan, and asserts an error result with no rows.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2683 lines
100 KiB
C
2683 lines
100 KiB
C
/*
|
|
* test_store_nodes.c — Tests for store schema, project CRUD, and node CRUD.
|
|
*
|
|
* Ported from internal/store/store_test.go (TestOpenMemory, TestNodeCRUD,
|
|
* TestNodeDedup, TestProjectCRUD, TestUpsertNodeBatch, etc.)
|
|
*/
|
|
#include "test_framework.h"
|
|
#include <store/store.h>
|
|
#include <foundation/constants.h>
|
|
#include <cbm.h>
|
|
#include <sqlite3.h>
|
|
#include <string.h>
|
|
#include <stdlib.h>
|
|
#include <stdio.h>
|
|
|
|
typedef struct {
|
|
int calls;
|
|
int cancel_on_call;
|
|
} file_outline_cancel_probe_t;
|
|
|
|
static bool file_outline_cancel_probe(void *context) {
|
|
file_outline_cancel_probe_t *probe = context;
|
|
probe->calls++;
|
|
return probe->calls >= probe->cancel_on_call;
|
|
}
|
|
|
|
/* ── Label allowlist / SQL drift guard ──────────────────────────── */
|
|
|
|
/* CONTRACT PIN. `cbm_label_is_type_like()` is documented in cbm.h as the single
|
|
* source of truth for type-like labels, "instead of scattering
|
|
* `|| strcmp(label,\"Struct\")==0` across the tree". A SQL string literal cannot
|
|
* call it, so four queries in store.c and the BM25 ranking in mcp.c hardcoded
|
|
* their own label lists — and silently stopped matching once Struct (Rust, Go,
|
|
* Swift, D) began being emitted. get_architecture and vector search dropped
|
|
* every struct in the project; search_code under-ranked them.
|
|
*
|
|
* This pins the SQL mirrors to the C predicate in BOTH directions, so the next
|
|
* type-like label fails here instead of quietly shrinking query results. */
|
|
TEST(sql_label_allowlists_match_cbm_label_is_type_like) {
|
|
/* Every label the C predicate accepts must appear in the SQL fragment. */
|
|
static const char *const type_like[] = {"Class", "Struct", "Interface",
|
|
"Enum", "Type", "Trait"};
|
|
for (size_t i = 0; i < sizeof(type_like) / sizeof(type_like[0]); i++) {
|
|
ASSERT_TRUE(cbm_label_is_type_like(type_like[i]));
|
|
char quoted[64];
|
|
snprintf(quoted, sizeof(quoted), "'%s'", type_like[i]);
|
|
ASSERT_NOT_NULL(strstr(CBM_SQL_TYPE_LIKE_LABELS, quoted));
|
|
ASSERT_NOT_NULL(strstr(CBM_SQL_CALLABLE_OR_TYPE_LABELS, quoted));
|
|
}
|
|
/* And nothing the predicate rejects may be smuggled into the type-like
|
|
* fragment — otherwise the SQL would widen past the C contract. */
|
|
static const char *const not_type_like[] = {"Function", "Method", "Module",
|
|
"File", "Folder", "Variable"};
|
|
for (size_t i = 0; i < sizeof(not_type_like) / sizeof(not_type_like[0]); i++) {
|
|
ASSERT_FALSE(cbm_label_is_type_like(not_type_like[i]));
|
|
char quoted[64];
|
|
snprintf(quoted, sizeof(quoted), "'%s'", not_type_like[i]);
|
|
ASSERT_NULL(strstr(CBM_SQL_TYPE_LIKE_LABELS, quoted));
|
|
}
|
|
/* The callable fragment carries exactly Function and Method on top. */
|
|
ASSERT_NOT_NULL(strstr(CBM_SQL_CALLABLE_OR_TYPE_LABELS, "'Function'"));
|
|
ASSERT_NOT_NULL(strstr(CBM_SQL_CALLABLE_OR_TYPE_LABELS, "'Method'"));
|
|
PASS();
|
|
}
|
|
|
|
/* Same drift guard for the relation labels (Table/View — SQL data lineage).
|
|
* Relations are registry symbols but deliberately NOT type-like: the default
|
|
* cbm_registry_resolve vetoes them, so a code identifier sharing a table's
|
|
* name never binds into the lineage layer. */
|
|
TEST(sql_relation_labels_match_cbm_label_is_relation) {
|
|
static const char *const relations[] = {"Table", "View", "Model"};
|
|
for (size_t i = 0; i < sizeof(relations) / sizeof(relations[0]); i++) {
|
|
ASSERT_TRUE(cbm_label_is_relation(relations[i]));
|
|
ASSERT_TRUE(cbm_label_is_registry_symbol(relations[i]));
|
|
ASSERT_FALSE(cbm_label_is_type_like(relations[i]));
|
|
char quoted[64];
|
|
snprintf(quoted, sizeof(quoted), "'%s'", relations[i]);
|
|
ASSERT_NOT_NULL(strstr(CBM_SQL_RELATION_LABELS, quoted));
|
|
/* Relations must NOT ride in the callable/type fragments — the arch
|
|
* queries opt in explicitly by appending CBM_SQL_RELATION_LABELS. */
|
|
ASSERT_NULL(strstr(CBM_SQL_CALLABLE_OR_TYPE_LABELS, quoted));
|
|
}
|
|
/* cbm_label_is_registry_symbol covers exactly the seeded families. */
|
|
ASSERT_TRUE(cbm_label_is_registry_symbol("Function"));
|
|
ASSERT_TRUE(cbm_label_is_registry_symbol("Method"));
|
|
ASSERT_TRUE(cbm_label_is_registry_symbol("Class"));
|
|
ASSERT_TRUE(cbm_label_is_registry_symbol("Variable"));
|
|
ASSERT_TRUE(cbm_label_is_registry_symbol("Field"));
|
|
ASSERT_FALSE(cbm_label_is_registry_symbol("Module"));
|
|
ASSERT_FALSE(cbm_label_is_registry_symbol("File"));
|
|
ASSERT_FALSE(cbm_label_is_relation("Class"));
|
|
ASSERT_FALSE(cbm_label_is_relation(NULL));
|
|
PASS();
|
|
}
|
|
|
|
/* ── Schema / Open / Close ──────────────────────────────────────── */
|
|
|
|
TEST(store_open_memory) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
ASSERT_NOT_NULL(s);
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_close_null) {
|
|
cbm_store_close(NULL); /* should not crash */
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_open_memory_twice) {
|
|
cbm_store_t *s1 = cbm_store_open_memory();
|
|
cbm_store_t *s2 = cbm_store_open_memory();
|
|
ASSERT_NOT_NULL(s1);
|
|
ASSERT_NOT_NULL(s2);
|
|
/* independent databases */
|
|
cbm_store_close(s1);
|
|
cbm_store_close(s2);
|
|
PASS();
|
|
}
|
|
|
|
/* ── Project CRUD ───────────────────────────────────────────────── */
|
|
|
|
TEST(store_project_crud) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
ASSERT_NOT_NULL(s);
|
|
|
|
/* Create */
|
|
int rc = cbm_store_upsert_project(s, "myproject", "/home/user/myproject");
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
|
|
/* Get */
|
|
cbm_project_t p = {0};
|
|
rc = cbm_store_get_project(s, "myproject", &p);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_STR_EQ(p.name, "myproject");
|
|
ASSERT_STR_EQ(p.root_path, "/home/user/myproject");
|
|
ASSERT_NOT_NULL(p.indexed_at);
|
|
cbm_project_free_fields(&p);
|
|
|
|
/* List */
|
|
cbm_project_t *projects = NULL;
|
|
int count = 0;
|
|
rc = cbm_store_list_projects(s, &projects, &count);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_EQ(count, 1);
|
|
ASSERT_STR_EQ(projects[0].name, "myproject");
|
|
cbm_store_free_projects(projects, count);
|
|
|
|
/* Get non-existent */
|
|
cbm_project_t p2 = {0};
|
|
rc = cbm_store_get_project(s, "nonexistent", &p2);
|
|
ASSERT_EQ(rc, CBM_STORE_NOT_FOUND);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_project_update) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "test", "/old/path");
|
|
|
|
/* Update root path */
|
|
cbm_store_upsert_project(s, "test", "/new/path");
|
|
|
|
cbm_project_t p = {0};
|
|
cbm_store_get_project(s, "test", &p);
|
|
ASSERT_STR_EQ(p.root_path, "/new/path");
|
|
cbm_project_free_fields(&p);
|
|
|
|
/* Should still be 1 project */
|
|
cbm_project_t *projects = NULL;
|
|
int count = 0;
|
|
cbm_store_list_projects(s, &projects, &count);
|
|
ASSERT_EQ(count, 1);
|
|
cbm_store_free_projects(projects, count);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_project_delete) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
|
|
int rc = cbm_store_delete_project(s, "test");
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
|
|
cbm_project_t p = {0};
|
|
rc = cbm_store_get_project(s, "test", &p);
|
|
ASSERT_EQ(rc, CBM_STORE_NOT_FOUND);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
/* ── QN suffix / callable-base lookups (#2061) ─────────────────── */
|
|
|
|
static void qn_lookup_node(cbm_store_t *s, const char *name, const char *qn) {
|
|
cbm_node_t n = {.project = "test",
|
|
.label = "Method",
|
|
.name = name,
|
|
.qualified_name = qn,
|
|
.file_path = "a.java",
|
|
.start_line = 1,
|
|
.end_line = 2};
|
|
cbm_store_upsert_node(s, &n);
|
|
}
|
|
|
|
/* The suffix tier matches its text literally: '_' is not a LIKE wildcard,
|
|
* so `my_func` must not also find `myXfunc`. */
|
|
TEST(store_qn_suffix_is_literal) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
qn_lookup_node(s, "my_func", "test.a.my_func");
|
|
qn_lookup_node(s, "myXfunc", "test.a.myXfunc");
|
|
cbm_node_t *nodes = NULL;
|
|
int count = 0;
|
|
ASSERT_EQ(cbm_store_find_nodes_by_qn_suffix(s, "test", "my_func", &nodes, &count),
|
|
CBM_STORE_OK);
|
|
ASSERT_EQ(count, 1);
|
|
ASSERT_STR_EQ(nodes[0].qualified_name, "test.a.my_func");
|
|
cbm_store_free_nodes(nodes, count);
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
/* A suffix longer than the old 512-byte pattern buffer is not truncated into
|
|
* a pattern that matches nothing. */
|
|
TEST(store_qn_suffix_is_unbounded) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
char leaf[601];
|
|
memset(leaf, 'a', sizeof(leaf) - 1);
|
|
leaf[sizeof(leaf) - 1] = '\0';
|
|
char long_qn[640];
|
|
char long_suffix[640];
|
|
snprintf(long_qn, sizeof(long_qn), "test.m.%s", leaf);
|
|
snprintf(long_suffix, sizeof(long_suffix), "m.%s", leaf);
|
|
qn_lookup_node(s, leaf, long_qn);
|
|
|
|
cbm_node_t *nodes = NULL;
|
|
int count = 0;
|
|
ASSERT_EQ(cbm_store_find_nodes_by_qn_suffix(s, "test", long_suffix, &nodes, &count),
|
|
CBM_STORE_OK);
|
|
ASSERT_EQ(count, 1);
|
|
ASSERT_STR_EQ(nodes[0].qualified_name, long_qn);
|
|
cbm_store_free_nodes(nodes, count);
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
/* A bare base QN (exact, or as a dotted suffix) finds every signature-
|
|
* qualified overload of it, and never an unsuffixed QN — so the tier is inert
|
|
* until a language mints suffixes. */
|
|
TEST(store_qn_base_finds_overloads_only) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
qn_lookup_node(s, "work", "test.S.work(int)");
|
|
qn_lookup_node(s, "work", "test.S.work(String)");
|
|
qn_lookup_node(s, "work", "test.S.work");
|
|
qn_lookup_node(s, "workx", "test.S.workx(int)");
|
|
qn_lookup_node(s, "operator()", "test.S.operator()");
|
|
qn_lookup_node(s, "Work", "test.C.Work<T>(T)");
|
|
|
|
cbm_node_t *nodes = NULL;
|
|
int count = 0;
|
|
ASSERT_EQ(cbm_store_find_nodes_by_qn_base(s, "test", "test.S.work", false, &nodes, &count),
|
|
CBM_STORE_OK);
|
|
ASSERT_EQ(count, 2);
|
|
cbm_store_free_nodes(nodes, count);
|
|
|
|
ASSERT_EQ(cbm_store_find_nodes_by_qn_base(s, "test", "S.work", true, &nodes, &count),
|
|
CBM_STORE_OK);
|
|
ASSERT_EQ(count, 2);
|
|
cbm_store_free_nodes(nodes, count);
|
|
|
|
ASSERT_EQ(cbm_store_find_nodes_by_qn_base(s, "test", "test.C.Work", false, &nodes, &count),
|
|
CBM_STORE_OK);
|
|
ASSERT_EQ(count, 1);
|
|
cbm_store_free_nodes(nodes, count);
|
|
|
|
/* `operator()` is a bare C++ leaf, not "operator" + "()". */
|
|
ASSERT_EQ(cbm_store_find_nodes_by_qn_base(s, "test", "test.S.operator", false, &nodes, &count),
|
|
CBM_STORE_OK);
|
|
ASSERT_EQ(count, 0);
|
|
cbm_store_free_nodes(nodes, count);
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
/* ── Node CRUD ──────────────────────────────────────────────────── */
|
|
|
|
TEST(store_node_crud) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
|
|
/* Insert node */
|
|
cbm_node_t n = {.project = "test",
|
|
.label = "Function",
|
|
.name = "Foo",
|
|
.qualified_name = "test.main.Foo",
|
|
.file_path = "main.go",
|
|
.start_line = 10,
|
|
.end_line = 20,
|
|
.properties_json = "{\"signature\":\"func Foo(x int) error\"}"};
|
|
int64_t id = cbm_store_upsert_node(s, &n);
|
|
ASSERT_GT(id, 0);
|
|
|
|
/* Find by QN */
|
|
cbm_node_t found = {0};
|
|
int rc = cbm_store_find_node_by_qn(s, "test", "test.main.Foo", &found);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_STR_EQ(found.name, "Foo");
|
|
ASSERT_STR_EQ(found.label, "Function");
|
|
ASSERT_STR_EQ(found.file_path, "main.go");
|
|
ASSERT_EQ(found.start_line, 10);
|
|
ASSERT_EQ(found.end_line, 20);
|
|
ASSERT_NOT_NULL(found.properties_json);
|
|
cbm_node_free_fields(&found);
|
|
|
|
/* Find by ID */
|
|
cbm_node_t found2 = {0};
|
|
rc = cbm_store_find_node_by_id(s, id, &found2);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_STR_EQ(found2.qualified_name, "test.main.Foo");
|
|
cbm_node_free_fields(&found2);
|
|
|
|
/* Find by name */
|
|
cbm_node_t *nodes = NULL;
|
|
int count = 0;
|
|
rc = cbm_store_find_nodes_by_name(s, "test", "Foo", &nodes, &count);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_EQ(count, 1);
|
|
ASSERT_STR_EQ(nodes[0].name, "Foo");
|
|
cbm_store_free_nodes(nodes, count);
|
|
|
|
/* Count */
|
|
int cnt = cbm_store_count_nodes(s, "test");
|
|
ASSERT_EQ(cnt, 1);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_node_dedup) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
|
|
/* Insert same QN twice — should update, not duplicate */
|
|
cbm_node_t n1 = {
|
|
.project = "test", .label = "Function", .name = "Foo", .qualified_name = "test.main.Foo"};
|
|
cbm_node_t n2 = {.project = "test",
|
|
.label = "Function",
|
|
.name = "Foo",
|
|
.qualified_name = "test.main.Foo",
|
|
.properties_json = "{\"updated\":true}"};
|
|
cbm_store_upsert_node(s, &n1);
|
|
cbm_store_upsert_node(s, &n2);
|
|
|
|
int cnt = cbm_store_count_nodes(s, "test");
|
|
ASSERT_EQ(cnt, 1);
|
|
|
|
/* Verify it was updated */
|
|
cbm_node_t found = {0};
|
|
cbm_store_find_node_by_qn(s, "test", "test.main.Foo", &found);
|
|
ASSERT_NOT_NULL(found.properties_json);
|
|
/* Should contain "updated" */
|
|
ASSERT(strstr(found.properties_json, "updated") != NULL);
|
|
cbm_node_free_fields(&found);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_node_find_by_label) {
|
|
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 = "A", .qualified_name = "test.A"};
|
|
cbm_node_t n2 = {.project = "test", .label = "Class", .name = "B", .qualified_name = "test.B"};
|
|
cbm_node_t n3 = {
|
|
.project = "test", .label = "Function", .name = "C", .qualified_name = "test.C"};
|
|
cbm_store_upsert_node(s, &n1);
|
|
cbm_store_upsert_node(s, &n2);
|
|
cbm_store_upsert_node(s, &n3);
|
|
|
|
cbm_node_t *nodes = NULL;
|
|
int count = 0;
|
|
int rc = cbm_store_find_nodes_by_label(s, "test", "Function", &nodes, &count);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_EQ(count, 2);
|
|
cbm_store_free_nodes(nodes, count);
|
|
|
|
rc = cbm_store_find_nodes_by_label(s, "test", "Class", &nodes, &count);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_EQ(count, 1);
|
|
ASSERT_STR_EQ(nodes[0].name, "B");
|
|
cbm_store_free_nodes(nodes, count);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
static bool scan_str_same(const char *a, const char *b) {
|
|
return (!a && !b) || (a && b && strcmp(a, b) == 0);
|
|
}
|
|
|
|
static bool scan_node_same(const cbm_node_t *a, const cbm_node_t *b) {
|
|
return a->id == b->id && scan_str_same(a->project, b->project) &&
|
|
scan_str_same(a->label, b->label) && scan_str_same(a->name, b->name) &&
|
|
scan_str_same(a->qualified_name, b->qualified_name) &&
|
|
scan_str_same(a->file_path, b->file_path) && a->start_line == b->start_line &&
|
|
a->end_line == b->end_line && scan_str_same(a->properties_json, b->properties_json);
|
|
}
|
|
|
|
/* The streamed scan gives the eager scan's rows, field for field and in the
|
|
* same order, at any batch size, while other lookups (including the eager
|
|
* scans' own cached statements) run between batches. Returns the row count,
|
|
* or -1 on the first difference; *paged tells whether the scan paged. */
|
|
static int scan_matches_eager(cbm_store_t *s, const char *project, const char *label, int batch,
|
|
bool *paged) {
|
|
cbm_node_t *eager = NULL;
|
|
int n = 0;
|
|
int rc = label ? cbm_store_find_nodes_by_label(s, project, label, &eager, &n)
|
|
: cbm_store_find_nodes(s, project, &eager, &n);
|
|
if (rc != CBM_STORE_OK) {
|
|
return -1;
|
|
}
|
|
cbm_store_node_scan_t *scan = cbm_store_node_scan_open(s, project, label);
|
|
*paged = cbm_store_node_scan_paged(scan);
|
|
cbm_node_t buf[64];
|
|
int seen = 0;
|
|
bool same = scan != NULL && batch <= 64;
|
|
int got = 0;
|
|
while (same && cbm_store_node_scan_next(scan, buf, batch, &got) == CBM_STORE_OK && got > 0) {
|
|
for (int i = 0; i < got; i++, seen++) {
|
|
same = same && seen < n && scan_node_same(&buf[i], &eager[seen]);
|
|
cbm_node_free_fields(&buf[i]);
|
|
}
|
|
cbm_node_t *other = NULL;
|
|
int other_n = 0;
|
|
(void)cbm_store_find_nodes(s, project, &other, &other_n);
|
|
cbm_store_free_nodes(other, other_n);
|
|
(void)cbm_store_find_nodes_by_label(s, project, "Class", &other, &other_n);
|
|
cbm_store_free_nodes(other, other_n);
|
|
}
|
|
/* An exhausted scan stays exhausted. */
|
|
same = same && cbm_store_node_scan_next(scan, buf, batch, &got) == CBM_STORE_OK && got == 0;
|
|
cbm_store_node_scan_close(scan);
|
|
cbm_store_free_nodes(eager, n);
|
|
return same && seen == n ? n : -1;
|
|
}
|
|
|
|
/* Pages of 1, 3 and 64 rows end inside files, at file boundaries and inside
|
|
* the NULL file_path rows, which the eager all-nodes order puts first. The
|
|
* upsert API stores "" for a missing path; other writers may store NULL. */
|
|
TEST(store_node_scan_streams_eager_scan_order) {
|
|
enum { SCAN_NODES = 60 };
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
cbm_store_upsert_project(s, "other", "/tmp/other");
|
|
static const char *const labels[] = {"Function", "Class", "Method"};
|
|
static const char *const files[] = {"b.c", "a.c", "", "c.c"};
|
|
int in_test = 0;
|
|
int test_functions = 0;
|
|
int other_methods = 0;
|
|
for (int i = 0; i < SCAN_NODES; i++) {
|
|
bool other = i % 4 == 3;
|
|
char name[32];
|
|
char qn[64];
|
|
char props[64];
|
|
snprintf(name, sizeof(name), "n%d", (i * 17) % 40);
|
|
snprintf(qn, sizeof(qn), "%s.n%d", other ? "other" : "test", i);
|
|
snprintf(props, sizeof(props), "{\"complexity\":%d}", i);
|
|
cbm_node_t n = {.project = other ? "other" : "test",
|
|
.label = labels[i % 3],
|
|
.name = name,
|
|
.qualified_name = qn,
|
|
.file_path = files[(i / 2) % 4],
|
|
.start_line = i,
|
|
.end_line = i + 5,
|
|
.properties_json = i % 5 ? props : NULL};
|
|
ASSERT_GT(cbm_store_upsert_node(s, &n), 0);
|
|
in_test += other ? 0 : 1;
|
|
test_functions += !other && i % 3 == 0 ? 1 : 0;
|
|
other_methods += other && i % 3 == 2 ? 1 : 0;
|
|
}
|
|
ASSERT_EQ(cbm_store_exec(s, "UPDATE nodes SET file_path = NULL WHERE start_line % 7 = 0;"), 0);
|
|
cbm_node_t *eager = NULL;
|
|
int eager_n = 0;
|
|
ASSERT_EQ(cbm_store_find_nodes(s, "test", &eager, &eager_n), CBM_STORE_OK);
|
|
ASSERT_TRUE(eager_n > 0 && eager[0].file_path == NULL); /* NULL rows come first */
|
|
cbm_store_free_nodes(eager, eager_n);
|
|
|
|
static const int batches[] = {1, 3, 64};
|
|
bool paged = false;
|
|
for (size_t b = 0; b < sizeof(batches) / sizeof(batches[0]); b++) {
|
|
ASSERT_EQ(scan_matches_eager(s, "test", NULL, batches[b], &paged), in_test);
|
|
ASSERT_TRUE(paged);
|
|
ASSERT_EQ(scan_matches_eager(s, "test", "Function", batches[b], &paged), test_functions);
|
|
ASSERT_TRUE(paged);
|
|
ASSERT_EQ(scan_matches_eager(s, "other", NULL, batches[b], &paged),
|
|
SCAN_NODES - in_test); /* project filter */
|
|
ASSERT_EQ(scan_matches_eager(s, "other", "Method", batches[b], &paged), other_methods);
|
|
ASSERT_EQ(scan_matches_eager(s, "test", "NoSuchLabel", batches[b], &paged), 0);
|
|
ASSERT_EQ(scan_matches_eager(s, "missing", NULL, batches[b], &paged), 0);
|
|
}
|
|
|
|
/* Without the node indexes the eager queries take other plans, which the
|
|
* pages would not reproduce: the scan hands out the eager rows instead. */
|
|
ASSERT_EQ(cbm_store_drop_indexes(s), 0);
|
|
ASSERT_EQ(scan_matches_eager(s, "test", NULL, 3, &paged), in_test);
|
|
ASSERT_FALSE(paged);
|
|
ASSERT_EQ(scan_matches_eager(s, "test", "Function", 3, &paged), test_functions);
|
|
ASSERT_FALSE(paged);
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_node_find_by_file) {
|
|
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 = "A",
|
|
.qualified_name = "test.A",
|
|
.file_path = "main.go"};
|
|
cbm_node_t n2 = {.project = "test",
|
|
.label = "Function",
|
|
.name = "B",
|
|
.qualified_name = "test.B",
|
|
.file_path = "util.go"};
|
|
cbm_node_t n3 = {.project = "test",
|
|
.label = "Function",
|
|
.name = "C",
|
|
.qualified_name = "test.C",
|
|
.file_path = "main.go"};
|
|
cbm_store_upsert_node(s, &n1);
|
|
cbm_store_upsert_node(s, &n2);
|
|
cbm_store_upsert_node(s, &n3);
|
|
|
|
cbm_node_t *nodes = NULL;
|
|
int count = 0;
|
|
int rc = cbm_store_find_nodes_by_file(s, "test", "main.go", &nodes, &count);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_EQ(count, 2);
|
|
cbm_store_free_nodes(nodes, count);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_file_outline_is_filtered_stable_bounded_and_cancellable_issue469) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
ASSERT_NOT_NULL(s);
|
|
ASSERT_EQ(cbm_store_upsert_project(s, "outline", "/tmp/outline"), CBM_STORE_OK);
|
|
|
|
cbm_node_t nodes[] = {
|
|
{.project = "outline",
|
|
.label = "Method",
|
|
.name = "omega",
|
|
.qualified_name = "outline.main.omega",
|
|
.file_path = "main.c",
|
|
.start_line = 30,
|
|
.end_line = 33},
|
|
{.project = "outline",
|
|
.label = "Module",
|
|
.name = "main",
|
|
.qualified_name = "outline.main",
|
|
.file_path = "main.c",
|
|
.start_line = 1,
|
|
.end_line = 80},
|
|
{.project = "outline",
|
|
.label = "Function",
|
|
.name = "zeta",
|
|
.qualified_name = "outline.main.zeta",
|
|
.file_path = "main.c",
|
|
.start_line = 10,
|
|
.end_line = 20},
|
|
{.project = "outline",
|
|
.label = "Function",
|
|
.name = "alpha",
|
|
.qualified_name = "outline.main.alpha",
|
|
.file_path = "main.c",
|
|
.start_line = 10,
|
|
.end_line = 15},
|
|
{.project = "outline",
|
|
.label = "Class",
|
|
.name = "VisibleWithoutFilter",
|
|
.qualified_name = "outline.main.VisibleWithoutFilter",
|
|
.file_path = "main.c",
|
|
.start_line = 5,
|
|
.end_line = 40},
|
|
{.project = "outline",
|
|
.label = "Function",
|
|
.name = "other",
|
|
.qualified_name = "outline.other.other",
|
|
.file_path = "other.c",
|
|
.start_line = 1,
|
|
.end_line = 2},
|
|
};
|
|
for (size_t i = 0; i < sizeof(nodes) / sizeof(nodes[0]); i++) {
|
|
ASSERT_GT(cbm_store_upsert_node(s, &nodes[i]), 0);
|
|
}
|
|
|
|
static const char *const labels[] = {"Function", "Method"};
|
|
cbm_file_outline_row_t *rows = NULL;
|
|
int count = 0;
|
|
int total = 0;
|
|
ASSERT_EQ(cbm_store_get_file_outline(s, "outline", "main.c", labels, 2, 2, 0, NULL, NULL, &rows,
|
|
&count, &total),
|
|
CBM_STORE_OK);
|
|
ASSERT_EQ(total, 3);
|
|
ASSERT_EQ(count, 2);
|
|
ASSERT_STR_EQ(rows[0].name, "alpha");
|
|
ASSERT_STR_EQ(rows[1].name, "zeta");
|
|
cbm_store_free_file_outline(rows, count);
|
|
|
|
rows = NULL;
|
|
count = 0;
|
|
total = 0;
|
|
ASSERT_EQ(cbm_store_get_file_outline(s, "outline", "main.c", labels, 2, 2, 2, NULL, NULL, &rows,
|
|
&count, &total),
|
|
CBM_STORE_OK);
|
|
ASSERT_EQ(total, 3);
|
|
ASSERT_EQ(count, 1);
|
|
ASSERT_STR_EQ(rows[0].name, "omega");
|
|
cbm_store_free_file_outline(rows, count);
|
|
|
|
rows = NULL;
|
|
count = 0;
|
|
total = 0;
|
|
ASSERT_EQ(cbm_store_get_file_outline(s, "outline", "main.c", NULL, 0, 10, 0, NULL, NULL, &rows,
|
|
&count, &total),
|
|
CBM_STORE_OK);
|
|
ASSERT_EQ(total, 4);
|
|
ASSERT_EQ(count, 4);
|
|
ASSERT_STR_EQ(rows[0].name, "VisibleWithoutFilter");
|
|
ASSERT_STR_EQ(rows[1].name, "alpha");
|
|
ASSERT_STR_EQ(rows[2].name, "zeta");
|
|
ASSERT_STR_EQ(rows[3].name, "omega");
|
|
cbm_store_free_file_outline(rows, count);
|
|
|
|
file_outline_cancel_probe_t probe = {.cancel_on_call = 3};
|
|
rows = (cbm_file_outline_row_t *)(uintptr_t)1;
|
|
count = 99;
|
|
total = 99;
|
|
ASSERT_EQ(cbm_store_get_file_outline(s, "outline", "main.c", labels, 2, 2, 0,
|
|
file_outline_cancel_probe, &probe, &rows, &count, &total),
|
|
CBM_STORE_CANCELLED);
|
|
ASSERT_NULL(rows);
|
|
ASSERT_EQ(count, 0);
|
|
ASSERT_EQ(total, 0);
|
|
ASSERT_TRUE(probe.calls >= 3);
|
|
|
|
const char *empty_label[] = {""};
|
|
ASSERT_EQ(cbm_store_get_file_outline(s, "outline", "main.c", empty_label, 1, 1, 0, NULL, NULL,
|
|
&rows, &count, &total),
|
|
CBM_STORE_ERR);
|
|
ASSERT_EQ(cbm_store_get_file_outline(s, "outline", "main.c", NULL, 0,
|
|
CBM_STORE_FILE_OUTLINE_MAX_LIMIT + 1, 0, NULL, NULL, &rows,
|
|
&count, &total),
|
|
CBM_STORE_ERR);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_file_outline_fails_closed_on_text_budget_issue469) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
ASSERT_NOT_NULL(s);
|
|
ASSERT_EQ(cbm_store_upsert_project(s, "outline-budget", "/tmp/outline-budget"), CBM_STORE_OK);
|
|
|
|
size_t qn_size = CBM_STORE_FILE_OUTLINE_MAX_TEXT_BYTES + 64U;
|
|
char *large_qn = malloc(qn_size + 1U);
|
|
ASSERT_NOT_NULL(large_qn);
|
|
memset(large_qn, 'q', qn_size);
|
|
large_qn[qn_size] = '\0';
|
|
cbm_node_t node = {.project = "outline-budget",
|
|
.label = "Function",
|
|
.name = "oversized",
|
|
.qualified_name = large_qn,
|
|
.file_path = "large.c",
|
|
.start_line = 1,
|
|
.end_line = 2};
|
|
ASSERT_GT(cbm_store_upsert_node(s, &node), 0);
|
|
free(large_qn);
|
|
|
|
cbm_file_outline_row_t *rows = (cbm_file_outline_row_t *)(uintptr_t)1;
|
|
int count = 99;
|
|
int total = 99;
|
|
ASSERT_EQ(cbm_store_get_file_outline(s, "outline-budget", "large.c", NULL, 0, 1, 0, NULL, NULL,
|
|
&rows, &count, &total),
|
|
CBM_STORE_SCAN_LIMIT);
|
|
ASSERT_NULL(rows);
|
|
ASSERT_EQ(count, 0);
|
|
ASSERT_EQ(total, 0);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_node_find_not_found) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
|
|
cbm_node_t found = {0};
|
|
int rc = cbm_store_find_node_by_qn(s, "test", "nonexistent", &found);
|
|
ASSERT_EQ(rc, CBM_STORE_NOT_FOUND);
|
|
|
|
rc = cbm_store_find_node_by_id(s, 99999, &found);
|
|
ASSERT_EQ(rc, CBM_STORE_NOT_FOUND);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_node_count_empty) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
|
|
int cnt = cbm_store_count_nodes(s, "test");
|
|
ASSERT_EQ(cnt, 0);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_node_delete_by_file) {
|
|
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 = "A",
|
|
.qualified_name = "test.A",
|
|
.file_path = "main.go"};
|
|
cbm_node_t n2 = {.project = "test",
|
|
.label = "Function",
|
|
.name = "B",
|
|
.qualified_name = "test.B",
|
|
.file_path = "util.go"};
|
|
cbm_store_upsert_node(s, &n1);
|
|
cbm_store_upsert_node(s, &n2);
|
|
|
|
cbm_store_delete_nodes_by_file(s, "test", "main.go");
|
|
int cnt = cbm_store_count_nodes(s, "test");
|
|
ASSERT_EQ(cnt, 1);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_node_delete_by_label) {
|
|
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 = "A", .qualified_name = "test.A"};
|
|
cbm_node_t n2 = {.project = "test", .label = "Class", .name = "B", .qualified_name = "test.B"};
|
|
cbm_store_upsert_node(s, &n1);
|
|
cbm_store_upsert_node(s, &n2);
|
|
|
|
cbm_store_delete_nodes_by_label(s, "test", "Function");
|
|
int cnt = cbm_store_count_nodes(s, "test");
|
|
ASSERT_EQ(cnt, 1);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
/* ── Batch operations ───────────────────────────────────────────── */
|
|
|
|
TEST(store_node_batch_upsert) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
|
|
/* Create 150 nodes */
|
|
cbm_node_t nodes[150];
|
|
int64_t ids[150];
|
|
char names[150][32];
|
|
char qns[150][64];
|
|
|
|
for (int i = 0; i < 150; i++) {
|
|
snprintf(names[i], sizeof(names[i]), "func_%d", i);
|
|
snprintf(qns[i], sizeof(qns[i]), "test.pkg.func_%d", i);
|
|
nodes[i] = (cbm_node_t){
|
|
.project = "test",
|
|
.label = "Function",
|
|
.name = names[i],
|
|
.qualified_name = qns[i],
|
|
.file_path = "pkg.go",
|
|
.start_line = i * 10,
|
|
.end_line = i * 10 + 9,
|
|
};
|
|
}
|
|
|
|
int rc = cbm_store_upsert_node_batch(s, nodes, 150, ids);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
|
|
/* Verify all IDs are non-zero */
|
|
for (int i = 0; i < 150; i++) {
|
|
ASSERT_GT(ids[i], 0);
|
|
}
|
|
|
|
/* Verify count */
|
|
int cnt = cbm_store_count_nodes(s, "test");
|
|
ASSERT_EQ(cnt, 150);
|
|
|
|
/* Re-upsert should not duplicate */
|
|
int64_t ids2[150];
|
|
rc = cbm_store_upsert_node_batch(s, nodes, 150, ids2);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
cnt = cbm_store_count_nodes(s, "test");
|
|
ASSERT_EQ(cnt, 150);
|
|
|
|
/* IDs should be the same */
|
|
for (int i = 0; i < 150; i++) {
|
|
ASSERT_EQ(ids[i], ids2[i]);
|
|
}
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_node_batch_empty) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
int rc = cbm_store_upsert_node_batch(s, NULL, 0, NULL);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
/* ── Cascade delete ─────────────────────────────────────────────── */
|
|
|
|
TEST(store_cascade_delete) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
|
|
/* Create nodes and an edge */
|
|
cbm_node_t n1 = {
|
|
.project = "test", .label = "Function", .name = "A", .qualified_name = "test.A"};
|
|
cbm_node_t n2 = {
|
|
.project = "test", .label = "Function", .name = "B", .qualified_name = "test.B"};
|
|
int64_t id1 = cbm_store_upsert_node(s, &n1);
|
|
int64_t id2 = cbm_store_upsert_node(s, &n2);
|
|
|
|
cbm_edge_t e = {.project = "test", .source_id = id1, .target_id = id2, .type = "CALLS"};
|
|
cbm_store_insert_edge(s, &e);
|
|
|
|
/* Delete project — should cascade */
|
|
cbm_store_delete_project(s, "test");
|
|
|
|
int ncnt = cbm_store_count_nodes(s, "test");
|
|
int ecnt = cbm_store_count_edges(s, "test");
|
|
ASSERT_EQ(ncnt, 0);
|
|
ASSERT_EQ(ecnt, 0);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
/* ── File hashes ────────────────────────────────────────────────── */
|
|
|
|
TEST(store_file_hash_crud) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
|
|
/* Upsert */
|
|
int rc = cbm_store_upsert_file_hash(s, "test", "main.go", "abc123", 1000000, 512);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
|
|
/* Get */
|
|
cbm_file_hash_t *hashes = NULL;
|
|
int count = 0;
|
|
rc = cbm_store_get_file_hashes(s, "test", &hashes, &count);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_EQ(count, 1);
|
|
ASSERT_STR_EQ(hashes[0].rel_path, "main.go");
|
|
ASSERT_STR_EQ(hashes[0].sha256, "abc123");
|
|
ASSERT_EQ(hashes[0].mtime_ns, 1000000);
|
|
ASSERT_EQ(hashes[0].size, 512);
|
|
cbm_store_free_file_hashes(hashes, count);
|
|
|
|
/* Update */
|
|
rc = cbm_store_upsert_file_hash(s, "test", "main.go", "def456", 2000000, 1024);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
rc = cbm_store_get_file_hashes(s, "test", &hashes, &count);
|
|
ASSERT_EQ(count, 1);
|
|
ASSERT_STR_EQ(hashes[0].sha256, "def456");
|
|
ASSERT_EQ(hashes[0].mtime_ns, 2000000);
|
|
cbm_store_free_file_hashes(hashes, count);
|
|
|
|
/* Delete single */
|
|
rc = cbm_store_delete_file_hash(s, "test", "main.go");
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
rc = cbm_store_get_file_hashes(s, "test", &hashes, &count);
|
|
ASSERT_EQ(count, 0);
|
|
cbm_store_free_file_hashes(hashes, count);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_lsp_surface_round_trip) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
ASSERT_NOT_NULL(s);
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
|
|
/* Empty project: OK + zero rows — the "no surface data, route to full
|
|
* rebuild" signal, and the upgrade path for pre-table databases. */
|
|
cbm_lsp_surface_row_t *rows = NULL;
|
|
int count = CBM_NOT_FOUND;
|
|
ASSERT_EQ(cbm_store_get_lsp_surfaces(s, "test", &rows, &count), CBM_STORE_OK);
|
|
ASSERT_EQ(count, 0);
|
|
cbm_store_free_lsp_surfaces(rows, count);
|
|
|
|
/* Batch with the field shapes the codec will produce: a real defs array,
|
|
* an empty surface, a binary bloom (with embedded NUL), and no bloom. */
|
|
const unsigned char bloom[] = {0x01, 0x00, 0xfe, 0x7f, 0x00, 0xab};
|
|
cbm_lsp_surface_row_t in[] = {
|
|
{.project = "test",
|
|
.rel_path = "pkg/a.go",
|
|
.surface_sha = "sha-a",
|
|
.defs_json = "[{\"qn\":\"pkg.A\",\"sn\":\"A\",\"label\":\"Function\"}]",
|
|
.ref_bloom = bloom,
|
|
.ref_bloom_len = (int)sizeof(bloom),
|
|
.config_ctx = "cfg-1"},
|
|
{.project = "test",
|
|
.rel_path = "pkg/b.go",
|
|
.surface_sha = "sha-b",
|
|
.defs_json = "[]",
|
|
.ref_bloom = NULL,
|
|
.ref_bloom_len = 0,
|
|
.config_ctx = ""},
|
|
};
|
|
ASSERT_EQ(cbm_store_upsert_lsp_surface_batch(s, in, 2), CBM_STORE_OK);
|
|
|
|
ASSERT_EQ(cbm_store_get_lsp_surfaces(s, "test", &rows, &count), CBM_STORE_OK);
|
|
ASSERT_EQ(count, 2);
|
|
/* ORDER BY rel_path: a.go before b.go. */
|
|
ASSERT_STR_EQ(rows[0].rel_path, "pkg/a.go");
|
|
ASSERT_STR_EQ(rows[0].surface_sha, "sha-a");
|
|
ASSERT_STR_EQ(rows[0].defs_json, "[{\"qn\":\"pkg.A\",\"sn\":\"A\",\"label\":\"Function\"}]");
|
|
ASSERT_STR_EQ(rows[0].config_ctx, "cfg-1");
|
|
ASSERT_EQ(rows[0].ref_bloom_len, (int)sizeof(bloom));
|
|
ASSERT_TRUE(rows[0].ref_bloom != NULL && memcmp(rows[0].ref_bloom, bloom, sizeof(bloom)) == 0);
|
|
ASSERT_STR_EQ(rows[1].rel_path, "pkg/b.go");
|
|
ASSERT_STR_EQ(rows[1].defs_json, "[]");
|
|
ASSERT_EQ(rows[1].ref_bloom_len, 0);
|
|
ASSERT_TRUE(rows[1].ref_bloom == NULL);
|
|
cbm_store_free_lsp_surfaces(rows, count);
|
|
|
|
/* Upsert-on-conflict replaces the whole row, including dropping a bloom. */
|
|
cbm_lsp_surface_row_t update = {.project = "test",
|
|
.rel_path = "pkg/a.go",
|
|
.surface_sha = "sha-a2",
|
|
.defs_json = "[]",
|
|
.ref_bloom = NULL,
|
|
.ref_bloom_len = 0,
|
|
.config_ctx = ""};
|
|
ASSERT_EQ(cbm_store_upsert_lsp_surface_batch(s, &update, 1), CBM_STORE_OK);
|
|
ASSERT_EQ(cbm_store_get_lsp_surfaces(s, "test", &rows, &count), CBM_STORE_OK);
|
|
ASSERT_EQ(count, 2);
|
|
ASSERT_STR_EQ(rows[0].surface_sha, "sha-a2");
|
|
ASSERT_EQ(rows[0].ref_bloom_len, 0);
|
|
cbm_store_free_lsp_surfaces(rows, count);
|
|
|
|
/* Project-scoped delete removes everything. */
|
|
ASSERT_EQ(cbm_store_delete_lsp_surfaces(s, "test"), CBM_STORE_OK);
|
|
ASSERT_EQ(cbm_store_get_lsp_surfaces(s, "test", &rows, &count), CBM_STORE_OK);
|
|
ASSERT_EQ(count, 0);
|
|
cbm_store_free_lsp_surfaces(rows, count);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_dependent_files_lookup) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
ASSERT_NOT_NULL(s);
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
|
|
/* a.py and c.py each call into b.py; b.py also references itself, and
|
|
* d.py sits apart. Dependents of {b.py} must be exactly {a.py, c.py}:
|
|
* the self-reference is not a dependent, d.py is unrelated. */
|
|
const char *files[] = {"a.py", "b.py", "c.py", "d.py"};
|
|
int64_t ids[4] = {0};
|
|
for (int i = 0; i < 4; i++) {
|
|
char qn[64];
|
|
snprintf(qn, sizeof(qn), "test.%s.fn", files[i]);
|
|
cbm_node_t n = {.project = "test",
|
|
.label = "Function",
|
|
.name = "fn",
|
|
.qualified_name = qn,
|
|
.file_path = files[i],
|
|
.start_line = 1,
|
|
.end_line = 2,
|
|
.properties_json = "{}"};
|
|
ids[i] = cbm_store_upsert_node(s, &n);
|
|
ASSERT_TRUE(ids[i] > 0);
|
|
}
|
|
const int64_t edge_pairs[][2] = {{ids[0], ids[1]}, {ids[2], ids[1]}, {ids[1], ids[1]}};
|
|
for (int i = 0; i < 3; i++) {
|
|
cbm_edge_t e = {.project = "test",
|
|
.source_id = edge_pairs[i][0],
|
|
.target_id = edge_pairs[i][1],
|
|
.type = "CALLS",
|
|
.properties_json = "{}"};
|
|
ASSERT_TRUE(cbm_store_insert_edge(s, &e) > 0);
|
|
}
|
|
|
|
/* Structural container noise: a Folder node (placeholder file_path)
|
|
* with a CONTAINS_FILE edge into b.py must never surface as a
|
|
* dependent — it is not a source file and cannot be re-resolved. */
|
|
cbm_node_t folder = {.project = "test",
|
|
.label = "Folder",
|
|
.name = "pkg",
|
|
.qualified_name = "test.pkg",
|
|
.file_path = "{}",
|
|
.start_line = 0,
|
|
.end_line = 0,
|
|
.properties_json = "{}"};
|
|
int64_t folder_id = cbm_store_upsert_node(s, &folder);
|
|
ASSERT_TRUE(folder_id > 0);
|
|
cbm_edge_t contains = {.project = "test",
|
|
.source_id = folder_id,
|
|
.target_id = ids[1],
|
|
.type = "CONTAINS_FILE",
|
|
.properties_json = "{}"};
|
|
ASSERT_TRUE(cbm_store_insert_edge(s, &contains) > 0);
|
|
|
|
const char *targets[] = {"b.py"};
|
|
char **deps = NULL;
|
|
int dep_count = 0;
|
|
ASSERT_EQ(cbm_store_get_dependent_files(s, "test", targets, 1, &deps, &dep_count),
|
|
CBM_STORE_OK);
|
|
ASSERT_EQ(dep_count, 2);
|
|
bool saw_a = false;
|
|
bool saw_c = false;
|
|
for (int i = 0; i < dep_count; i++) {
|
|
saw_a = saw_a || strcmp(deps[i], "a.py") == 0;
|
|
saw_c = saw_c || strcmp(deps[i], "c.py") == 0;
|
|
}
|
|
ASSERT_TRUE(saw_a && saw_c);
|
|
cbm_store_free_dependent_files(deps, dep_count);
|
|
|
|
/* No inbound edges: an isolated target has no dependents. */
|
|
const char *targets_d[] = {"d.py"};
|
|
ASSERT_EQ(cbm_store_get_dependent_files(s, "test", targets_d, 1, &deps, &dep_count),
|
|
CBM_STORE_OK);
|
|
ASSERT_EQ(dep_count, 0);
|
|
cbm_store_free_dependent_files(deps, dep_count);
|
|
|
|
/* A target list already containing the dependent excludes it: asking for
|
|
* dependents of {a.py, b.py} returns only c.py. */
|
|
const char *targets_ab[] = {"a.py", "b.py"};
|
|
ASSERT_EQ(cbm_store_get_dependent_files(s, "test", targets_ab, 2, &deps, &dep_count),
|
|
CBM_STORE_OK);
|
|
ASSERT_EQ(dep_count, 1);
|
|
ASSERT_STR_EQ(deps[0], "c.py");
|
|
cbm_store_free_dependent_files(deps, dep_count);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_file_hash_upsert_rejects_null_required_fields) {
|
|
/* Pins the API contract that `cbm_store_upsert_file_hash` returns
|
|
* CBM_STORE_ERR (not silent OK) when a NOT NULL column would receive
|
|
* SQL NULL. This is the failure mode that
|
|
* `pipeline_incremental.c:persist_hashes` checks for and logs as
|
|
* `incremental.persist_hash_failed`. If this contract ever changes
|
|
* (e.g. the schema relaxes NOT NULL on rel_path or sha256), the
|
|
* downstream warning becomes silent and the orphaned-node bug class
|
|
* can re-emerge. Track that change here, not just in the consumer. */
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
ASSERT_NOT_NULL(s);
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
|
|
/* Sanity: a fully-valid upsert returns OK. */
|
|
int rc = cbm_store_upsert_file_hash(s, "test", "main.go", "abc123", 1000000, 512);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
|
|
/* NULL sha256 violates NOT NULL on file_hashes.sha256 → must return ERR. */
|
|
rc = cbm_store_upsert_file_hash(s, "test", "other.go", NULL, 2000000, 1024);
|
|
ASSERT_EQ(rc, CBM_STORE_ERR);
|
|
|
|
/* NULL rel_path violates NOT NULL on file_hashes.rel_path → must return ERR. */
|
|
rc = cbm_store_upsert_file_hash(s, "test", NULL, "deadbeef", 3000000, 2048);
|
|
ASSERT_EQ(rc, CBM_STORE_ERR);
|
|
|
|
/* NULL project violates NOT NULL on file_hashes.project → must return ERR. */
|
|
rc = cbm_store_upsert_file_hash(s, NULL, "third.go", "cafebabe", 4000000, 4096);
|
|
ASSERT_EQ(rc, CBM_STORE_ERR);
|
|
|
|
/* The valid row from earlier must still be present — partial-failure
|
|
* policy: a single bad upsert does not corrupt or remove other rows. */
|
|
cbm_file_hash_t *hashes = NULL;
|
|
int count = 0;
|
|
cbm_store_get_file_hashes(s, "test", &hashes, &count);
|
|
ASSERT_EQ(count, 1);
|
|
ASSERT_STR_EQ(hashes[0].rel_path, "main.go");
|
|
cbm_store_free_file_hashes(hashes, count);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
/* ── Properties JSON round-trip ─────────────────────────────────── */
|
|
|
|
TEST(store_node_properties_json) {
|
|
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 = "Bar",
|
|
.qualified_name = "test.Bar",
|
|
.properties_json = "{\"visibility\":\"public\",\"is_entry_point\":true}"};
|
|
cbm_store_upsert_node(s, &n);
|
|
|
|
cbm_node_t found = {0};
|
|
cbm_store_find_node_by_qn(s, "test", "test.Bar", &found);
|
|
ASSERT_NOT_NULL(found.properties_json);
|
|
ASSERT(strstr(found.properties_json, "visibility") != NULL);
|
|
ASSERT(strstr(found.properties_json, "public") != NULL);
|
|
cbm_node_free_fields(&found);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_node_null_properties) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
|
|
/* NULL properties should default to "{}" */
|
|
cbm_node_t n = {.project = "test",
|
|
.label = "Function",
|
|
.name = "Baz",
|
|
.qualified_name = "test.Baz",
|
|
.properties_json = NULL};
|
|
cbm_store_upsert_node(s, &n);
|
|
|
|
cbm_node_t found = {0};
|
|
cbm_store_find_node_by_qn(s, "test", "test.Baz", &found);
|
|
ASSERT_NOT_NULL(found.properties_json);
|
|
ASSERT_STR_EQ(found.properties_json, "{}");
|
|
cbm_node_free_fields(&found);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
/* ── File overlap ───────────────────────────────────────────────── */
|
|
|
|
TEST(store_find_by_file_overlap) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
|
|
cbm_node_t na = {.project = "test",
|
|
.label = "Function",
|
|
.name = "funcA",
|
|
.qualified_name = "test.main.funcA",
|
|
.file_path = "main.go",
|
|
.start_line = 1,
|
|
.end_line = 10};
|
|
cbm_node_t nb = {.project = "test",
|
|
.label = "Function",
|
|
.name = "funcB",
|
|
.qualified_name = "test.main.funcB",
|
|
.file_path = "main.go",
|
|
.start_line = 12,
|
|
.end_line = 25};
|
|
cbm_node_t nc = {.project = "test",
|
|
.label = "Function",
|
|
.name = "funcC",
|
|
.qualified_name = "test.main.funcC",
|
|
.file_path = "other.go",
|
|
.start_line = 1,
|
|
.end_line = 50};
|
|
/* Module node should be excluded from overlap results */
|
|
cbm_node_t nm = {.project = "test",
|
|
.label = "Module",
|
|
.name = "main",
|
|
.qualified_name = "test.main",
|
|
.file_path = "main.go",
|
|
.start_line = 1,
|
|
.end_line = 100};
|
|
cbm_store_upsert_node(s, &na);
|
|
cbm_store_upsert_node(s, &nb);
|
|
cbm_store_upsert_node(s, &nc);
|
|
cbm_store_upsert_node(s, &nm);
|
|
|
|
/* Overlap with funcA (lines 5-8) */
|
|
cbm_node_t *nodes = NULL;
|
|
int count = 0;
|
|
int rc = cbm_store_find_nodes_by_file_overlap(s, "test", "main.go", 5, 8, &nodes, &count);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_EQ(count, 1);
|
|
ASSERT_STR_EQ(nodes[0].name, "funcA");
|
|
cbm_store_free_nodes(nodes, count);
|
|
|
|
/* Overlap spanning funcA and funcB (lines 8-15) */
|
|
rc = cbm_store_find_nodes_by_file_overlap(s, "test", "main.go", 8, 15, &nodes, &count);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_EQ(count, 2);
|
|
cbm_store_free_nodes(nodes, count);
|
|
|
|
/* No overlap (lines 26-30) */
|
|
rc = cbm_store_find_nodes_by_file_overlap(s, "test", "main.go", 26, 30, &nodes, &count);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_EQ(count, 0);
|
|
cbm_store_free_nodes(nodes, count);
|
|
|
|
/* Different file */
|
|
rc = cbm_store_find_nodes_by_file_overlap(s, "test", "other.go", 1, 50, &nodes, &count);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_EQ(count, 1);
|
|
ASSERT_STR_EQ(nodes[0].name, "funcC");
|
|
cbm_store_free_nodes(nodes, count);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
/* ── QN suffix ─────────────────────────────────────────────────── */
|
|
|
|
TEST(store_find_by_qn_suffix_single) {
|
|
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 = "HandleRequest",
|
|
.qualified_name = "test.cmd.server.main.HandleRequest"};
|
|
cbm_store_upsert_node(s, &n);
|
|
|
|
cbm_node_t *nodes = NULL;
|
|
int count = 0;
|
|
int rc = cbm_store_find_nodes_by_qn_suffix(s, "test", "main.HandleRequest", &nodes, &count);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_EQ(count, 1);
|
|
ASSERT_STR_EQ(nodes[0].name, "HandleRequest");
|
|
cbm_store_free_nodes(nodes, count);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_find_by_qn_suffix_no_match) {
|
|
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 = "Foo", .qualified_name = "test.main.Foo"};
|
|
cbm_store_upsert_node(s, &n);
|
|
|
|
cbm_node_t *nodes = NULL;
|
|
int count = 0;
|
|
int rc = cbm_store_find_nodes_by_qn_suffix(s, "test", "main.Bar", &nodes, &count);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_EQ(count, 0);
|
|
cbm_store_free_nodes(nodes, count);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_find_by_qn_suffix_multiple) {
|
|
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 = "Run",
|
|
.qualified_name = "test.cmd.server.Run"};
|
|
cbm_node_t n2 = {.project = "test",
|
|
.label = "Function",
|
|
.name = "Run",
|
|
.qualified_name = "test.cmd.worker.Run"};
|
|
cbm_store_upsert_node(s, &n1);
|
|
cbm_store_upsert_node(s, &n2);
|
|
|
|
cbm_node_t *nodes = NULL;
|
|
int count = 0;
|
|
int rc = cbm_store_find_nodes_by_qn_suffix(s, "test", "Run", &nodes, &count);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_EQ(count, 2);
|
|
cbm_store_free_nodes(nodes, count);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_find_by_qn_suffix_dot_boundary) {
|
|
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 = "HandleRequest",
|
|
.qualified_name = "test.main.HandleRequest"};
|
|
cbm_node_t n2 = {.project = "test",
|
|
.label = "Function",
|
|
.name = "MyHandleRequestHelper",
|
|
.qualified_name = "test.main.MyHandleRequestHelper"};
|
|
cbm_store_upsert_node(s, &n1);
|
|
cbm_store_upsert_node(s, &n2);
|
|
|
|
/* Should only match the one with ".HandleRequest" suffix, not partial word */
|
|
cbm_node_t *nodes = NULL;
|
|
int count = 0;
|
|
int rc = cbm_store_find_nodes_by_qn_suffix(s, "test", "HandleRequest", &nodes, &count);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_EQ(count, 1);
|
|
ASSERT_STR_EQ(nodes[0].name, "HandleRequest");
|
|
cbm_store_free_nodes(nodes, count);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
/* ── Node degree ───────────────────────────────────────────────── */
|
|
|
|
TEST(store_node_degree) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
|
|
cbm_node_t na = {
|
|
.project = "test", .label = "Function", .name = "A", .qualified_name = "test.A"};
|
|
cbm_node_t nb = {
|
|
.project = "test", .label = "Function", .name = "B", .qualified_name = "test.B"};
|
|
cbm_node_t nc = {
|
|
.project = "test", .label = "Function", .name = "C", .qualified_name = "test.C"};
|
|
int64_t idA = cbm_store_upsert_node(s, &na);
|
|
int64_t idB = cbm_store_upsert_node(s, &nb);
|
|
int64_t idC = cbm_store_upsert_node(s, &nc);
|
|
|
|
/* A->B (CALLS), A->C (CALLS), B->C (CALLS), A->C (USAGE — not counted) */
|
|
cbm_edge_t e1 = {.project = "test", .source_id = idA, .target_id = idB, .type = "CALLS"};
|
|
cbm_edge_t e2 = {.project = "test", .source_id = idA, .target_id = idC, .type = "CALLS"};
|
|
cbm_edge_t e3 = {.project = "test", .source_id = idB, .target_id = idC, .type = "CALLS"};
|
|
cbm_edge_t e4 = {.project = "test", .source_id = idA, .target_id = idC, .type = "USAGE"};
|
|
cbm_store_insert_edge(s, &e1);
|
|
cbm_store_insert_edge(s, &e2);
|
|
cbm_store_insert_edge(s, &e3);
|
|
cbm_store_insert_edge(s, &e4);
|
|
|
|
int inA, outA, inB, outB, inC, outC;
|
|
cbm_store_node_degree(s, idA, &inA, &outA);
|
|
ASSERT_EQ(inA, 0);
|
|
ASSERT_EQ(outA, 2);
|
|
|
|
cbm_store_node_degree(s, idB, &inB, &outB);
|
|
ASSERT_EQ(inB, 1);
|
|
ASSERT_EQ(outB, 1);
|
|
|
|
cbm_store_node_degree(s, idC, &inC, &outC);
|
|
ASSERT_EQ(inC, 2);
|
|
ASSERT_EQ(outC, 0);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
/* ── File hash batch ───────────────────────────────────────────── */
|
|
|
|
TEST(store_file_hash_batch) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
|
|
cbm_file_hash_t hashes[3] = {
|
|
{.project = "test", .rel_path = "a.go", .sha256 = "h1", .mtime_ns = 1000, .size = 100},
|
|
{.project = "test", .rel_path = "b.go", .sha256 = "h2", .mtime_ns = 2000, .size = 200},
|
|
{.project = "test", .rel_path = "c.go", .sha256 = "h3", .mtime_ns = 3000, .size = 300},
|
|
};
|
|
int rc = cbm_store_upsert_file_hash_batch(s, hashes, 3);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
|
|
cbm_file_hash_t *stored = NULL;
|
|
int count = 0;
|
|
rc = cbm_store_get_file_hashes(s, "test", &stored, &count);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_EQ(count, 3);
|
|
cbm_store_free_file_hashes(stored, count);
|
|
|
|
/* Update hashes (should not duplicate) */
|
|
hashes[0].sha256 = "updated";
|
|
hashes[0].mtime_ns = 9000;
|
|
rc = cbm_store_upsert_file_hash_batch(s, hashes, 3);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
|
|
rc = cbm_store_get_file_hashes(s, "test", &stored, &count);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_EQ(count, 3);
|
|
/* Verify updated value */
|
|
int found = 0;
|
|
for (int i = 0; i < count; i++) {
|
|
if (strcmp(stored[i].rel_path, "a.go") == 0) {
|
|
ASSERT_STR_EQ(stored[i].sha256, "updated");
|
|
ASSERT_EQ(stored[i].mtime_ns, 9000);
|
|
found = 1;
|
|
}
|
|
}
|
|
ASSERT_TRUE(found);
|
|
cbm_store_free_file_hashes(stored, count);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
/* Guard for the persist-tail switch (pipeline.c dump_and_persist_hashes) from a
|
|
* per-file cbm_store_upsert_file_hash loop to a single cbm_store_upsert_file_hash_batch:
|
|
* both paths must yield IDENTICAL file_hashes rows (same tuples, same upsert/replace
|
|
* semantics — only the transaction boundary differs). Uses sha256="" exactly as the
|
|
* persist path does. */
|
|
TEST(store_file_hash_batch_equals_loop) {
|
|
cbm_file_hash_t rows[4] = {
|
|
{.project = "p", .rel_path = "a.c", .sha256 = "", .mtime_ns = 111, .size = 10},
|
|
{.project = "p", .rel_path = "b/c.c", .sha256 = "", .mtime_ns = 222, .size = 20},
|
|
{.project = "p", .rel_path = "d.rs", .sha256 = "", .mtime_ns = 333, .size = 30},
|
|
{.project = "p", .rel_path = "e.py", .sha256 = "", .mtime_ns = 444, .size = 40},
|
|
};
|
|
|
|
/* Store A: the original per-file loop. */
|
|
cbm_store_t *a = cbm_store_open_memory();
|
|
cbm_store_upsert_project(a, "p", "/tmp/p");
|
|
for (int i = 0; i < 4; i++) {
|
|
ASSERT_EQ(cbm_store_upsert_file_hash(a, rows[i].project, rows[i].rel_path, rows[i].sha256,
|
|
rows[i].mtime_ns, rows[i].size),
|
|
CBM_STORE_OK);
|
|
}
|
|
|
|
/* Store B: the batched path. */
|
|
cbm_store_t *b = cbm_store_open_memory();
|
|
cbm_store_upsert_project(b, "p", "/tmp/p");
|
|
ASSERT_EQ(cbm_store_upsert_file_hash_batch(b, rows, 4), CBM_STORE_OK);
|
|
|
|
cbm_file_hash_t *ha = NULL, *hb = NULL;
|
|
int ca = 0, cb = 0;
|
|
ASSERT_EQ(cbm_store_get_file_hashes(a, "p", &ha, &ca), CBM_STORE_OK);
|
|
ASSERT_EQ(cbm_store_get_file_hashes(b, "p", &hb, &cb), CBM_STORE_OK);
|
|
ASSERT_EQ(ca, 4);
|
|
ASSERT_EQ(cb, 4);
|
|
|
|
/* Compare as sets (get_file_hashes has no ORDER BY). */
|
|
for (int i = 0; i < ca; i++) {
|
|
int found = 0;
|
|
for (int j = 0; j < cb; j++) {
|
|
if (strcmp(ha[i].rel_path, hb[j].rel_path) == 0) {
|
|
ASSERT_STR_EQ(ha[i].sha256, hb[j].sha256);
|
|
ASSERT_EQ(ha[i].mtime_ns, hb[j].mtime_ns);
|
|
ASSERT_EQ(ha[i].size, hb[j].size);
|
|
found = 1;
|
|
break;
|
|
}
|
|
}
|
|
ASSERT_TRUE(found);
|
|
}
|
|
|
|
cbm_store_free_file_hashes(ha, ca);
|
|
cbm_store_free_file_hashes(hb, cb);
|
|
cbm_store_close(a);
|
|
cbm_store_close(b);
|
|
PASS();
|
|
}
|
|
|
|
/* ── Find edges by URL path ────────────────────────────────────── */
|
|
|
|
TEST(store_find_edges_by_url_path) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
|
|
cbm_node_t ns = {
|
|
.project = "test", .label = "Function", .name = "caller", .qualified_name = "test.caller"};
|
|
cbm_node_t nt = {.project = "test",
|
|
.label = "Function",
|
|
.name = "handler",
|
|
.qualified_name = "test.handler"};
|
|
int64_t srcID = cbm_store_upsert_node(s, &ns);
|
|
int64_t tgtID = cbm_store_upsert_node(s, &nt);
|
|
|
|
cbm_edge_t e = {.project = "test",
|
|
.source_id = srcID,
|
|
.target_id = tgtID,
|
|
.type = "HTTP_CALLS",
|
|
.properties_json = "{\"url_path\":\"/api/orders/create\",\"confidence\":0.8}"};
|
|
cbm_store_insert_edge(s, &e);
|
|
|
|
/* Search for edges containing "orders" */
|
|
cbm_edge_t *edges = NULL;
|
|
int count = 0;
|
|
int rc = cbm_store_find_edges_by_url_path(s, "test", "orders", &edges, &count);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_EQ(count, 1);
|
|
ASSERT(strstr(edges[0].properties_json, "/api/orders/create") != NULL);
|
|
cbm_store_free_edges(edges, count);
|
|
|
|
/* Search for non-matching */
|
|
rc = cbm_store_find_edges_by_url_path(s, "test", "users", &edges, &count);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_EQ(count, 0);
|
|
cbm_store_free_edges(edges, count);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
/* ── Restore from ──────────────────────────────────────────────── */
|
|
|
|
TEST(store_restore_from) {
|
|
/* Create in-memory source store with data */
|
|
cbm_store_t *src = cbm_store_open_memory();
|
|
cbm_store_upsert_project(src, "test", "/tmp/test");
|
|
for (int i = 0; i < 10; i++) {
|
|
char name[32], qn[64];
|
|
snprintf(name, sizeof(name), "Func%d", i);
|
|
snprintf(qn, sizeof(qn), "test.main.Func%d", i);
|
|
cbm_node_t n = {.project = "test",
|
|
.label = "Function",
|
|
.name = name,
|
|
.qualified_name = qn,
|
|
.file_path = "main.go",
|
|
.start_line = i * 10,
|
|
.end_line = i * 10 + 5};
|
|
cbm_store_upsert_node(src, &n);
|
|
}
|
|
|
|
/* Create destination store */
|
|
cbm_store_t *dst = cbm_store_open_memory();
|
|
|
|
/* Restore: copy from src → dst */
|
|
int rc = cbm_store_restore_from(dst, src);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
|
|
/* Verify data survived */
|
|
cbm_node_t found = {0};
|
|
rc = cbm_store_find_node_by_qn(dst, "test", "test.main.Func5", &found);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_STR_EQ(found.name, "Func5");
|
|
cbm_node_free_fields(&found);
|
|
|
|
int cnt = cbm_store_count_nodes(dst, "test");
|
|
ASSERT_EQ(cnt, 10);
|
|
|
|
cbm_store_close(src);
|
|
cbm_store_close(dst);
|
|
PASS();
|
|
}
|
|
|
|
/* ── Pragma settings ───────────────────────────────────────────── */
|
|
|
|
TEST(store_pragma_settings) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
ASSERT_NOT_NULL(s);
|
|
/* Just verify we can open and the store works — pragma settings
|
|
* are verified by the fact that the store functions correctly. */
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
cbm_node_t n = {
|
|
.project = "test", .label = "Function", .name = "X", .qualified_name = "test.X"};
|
|
int64_t id = cbm_store_upsert_node(s, &n);
|
|
ASSERT_TRUE(id > 0);
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_find_node_ids_by_qns) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
ASSERT_NOT_NULL(s);
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
|
|
/* Insert two nodes */
|
|
cbm_node_t na = {
|
|
.project = "test", .label = "Function", .name = "A", .qualified_name = "test.A"};
|
|
cbm_node_t nb = {
|
|
.project = "test", .label = "Function", .name = "B", .qualified_name = "test.B"};
|
|
int64_t id1 = cbm_store_upsert_node(s, &na);
|
|
int64_t id2 = cbm_store_upsert_node(s, &nb);
|
|
ASSERT_TRUE(id1 > 0);
|
|
ASSERT_TRUE(id2 > 0);
|
|
|
|
/* Batch lookup: 2 found + 1 missing */
|
|
const char *qns[] = {"test.A", "test.B", "test.missing"};
|
|
int64_t ids[3];
|
|
int found = cbm_store_find_node_ids_by_qns(s, "test", qns, 3, ids);
|
|
ASSERT_EQ(found, 2);
|
|
ASSERT_EQ(ids[0], id1);
|
|
ASSERT_EQ(ids[1], id2);
|
|
ASSERT_EQ(ids[2], 0); /* missing → 0 */
|
|
|
|
/* Empty batch */
|
|
int found2 = cbm_store_find_node_ids_by_qns(s, "test", NULL, 0, ids);
|
|
ASSERT_EQ(found2, 0);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
/* ── Integrity check tests ──────────────────────────────────────── */
|
|
|
|
TEST(store_integrity_clean) {
|
|
/* A fresh store with correct data should pass integrity check */
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
ASSERT_NOT_NULL(s);
|
|
cbm_store_upsert_project(s, "test-proj", "/tmp/test");
|
|
ASSERT_TRUE(cbm_store_check_integrity(s));
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_integrity_empty) {
|
|
/* An empty store (no project rows) should pass — 0 rows is fine */
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
ASSERT_NOT_NULL(s);
|
|
ASSERT_TRUE(cbm_store_check_integrity(s));
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_integrity_corrupt_bad_path) {
|
|
/* Simulate corruption: root_path is a numeric string (not a real path).
|
|
* This matches the real corruption where node IDs ended up in root_path. */
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
ASSERT_NOT_NULL(s);
|
|
sqlite3 *db = cbm_store_get_db(s);
|
|
sqlite3_exec(db,
|
|
"INSERT INTO projects (name, indexed_at, root_path) "
|
|
"VALUES ('some-project', '2024-01-01', '826');",
|
|
NULL, NULL, NULL);
|
|
ASSERT_FALSE(cbm_store_check_integrity(s));
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_integrity_windows_lowercase_drive_issue367) {
|
|
/* Windows drive letters may be lower- or upper-case; a lowercase drive
|
|
* path must NOT be treated as corrupt. Previously the check only accepted
|
|
* 'A'..'Z', so "c:/repo" was flagged and the DB auto-deleted (#227/#367). */
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
ASSERT_NOT_NULL(s);
|
|
cbm_store_upsert_project(s, "lc-drive", "c:/Users/dev/repo");
|
|
ASSERT_TRUE(cbm_store_check_integrity(s));
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_integrity_corrupt_too_many_rows) {
|
|
/* Simulate corruption: >5 rows in projects table */
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
ASSERT_NOT_NULL(s);
|
|
sqlite3 *db = cbm_store_get_db(s);
|
|
for (int i = 0; i < 10; i++) {
|
|
char sql[256];
|
|
snprintf(sql, sizeof(sql),
|
|
"INSERT INTO projects (name, indexed_at, root_path) "
|
|
"VALUES ('proj-%d', '2024-01-01', '/tmp/%d');",
|
|
i, i);
|
|
sqlite3_exec(db, sql, NULL, NULL, NULL);
|
|
}
|
|
ASSERT_FALSE(cbm_store_check_integrity(s));
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
/* ── Quarantine verdict (#1206, #1037) ──────────────────────────────
|
|
*
|
|
* The bool check above cannot answer the only question the quarantine path
|
|
* actually asks: "is this database damaged, or did I just lose a lock race?"
|
|
* Answering "damaged" to the second question is what made concurrent instances
|
|
* rename each other's HEALTHY databases to .corrupt (#1206). These bind the
|
|
* three-way verdict so that behaviour cannot come back. */
|
|
|
|
TEST(store_integrity_verdict_healthy_is_ok) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
ASSERT_NOT_NULL(s);
|
|
cbm_store_upsert_project(s, "healthy-proj", "/tmp/healthy");
|
|
ASSERT_EQ(cbm_store_check_integrity_verdict(s), CBM_INTEGRITY_OK);
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_integrity_verdict_real_corruption_is_corrupt) {
|
|
/* Structural damage the shallow check can see: node IDs landing in
|
|
* root_path. This one MUST be quarantinable — a verdict that never says
|
|
* CORRUPT would protect broken databases instead of users. */
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
ASSERT_NOT_NULL(s);
|
|
sqlite3 *db = cbm_store_get_db(s);
|
|
sqlite3_exec(db,
|
|
"INSERT INTO projects (name, indexed_at, root_path) "
|
|
"VALUES ('broken', '2024-01-01', '826');",
|
|
NULL, NULL, NULL);
|
|
ASSERT_EQ(cbm_store_check_integrity_verdict(s), CBM_INTEGRITY_CORRUPT);
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_integrity_verdict_unopenable_is_transient_not_corrupt) {
|
|
/* A handle we could not open tells us NOTHING about the file's contents.
|
|
* Reporting CORRUPT here is how a database nobody could read got renamed
|
|
* and rebuilt from scratch (#1206) — the destructive answer to a question
|
|
* that was never asked. */
|
|
ASSERT_EQ(cbm_store_check_integrity_verdict(NULL), CBM_INTEGRITY_TRANSIENT);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_integrity_null_check) {
|
|
/* NULL store should return false (not crash) */
|
|
ASSERT_FALSE(cbm_store_check_integrity(NULL));
|
|
PASS();
|
|
}
|
|
|
|
/* ── Edge case: NULL / empty field handling ────────────────────── */
|
|
|
|
TEST(store_node_null_project) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
ASSERT_NOT_NULL(s);
|
|
|
|
/* Upsert with NULL project — should fail gracefully */
|
|
cbm_node_t n = {
|
|
.project = NULL, .label = "Function", .name = "Foo", .qualified_name = "null.Foo"};
|
|
int64_t id = cbm_store_upsert_node(s, &n);
|
|
/* Either returns error or silently succeeds; must not crash */
|
|
(void)id;
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_node_null_qn) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
|
|
/* Upsert with NULL qualified_name */
|
|
cbm_node_t n = {.project = "test", .label = "Function", .name = "Bar", .qualified_name = NULL};
|
|
int64_t id = cbm_store_upsert_node(s, &n);
|
|
/* Must not crash regardless of return value */
|
|
(void)id;
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_node_empty_strings) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
|
|
/* Upsert with all fields as empty strings */
|
|
cbm_node_t n = {.project = "test",
|
|
.label = "",
|
|
.name = "",
|
|
.qualified_name = "",
|
|
.file_path = "",
|
|
.start_line = 0,
|
|
.end_line = 0,
|
|
.properties_json = ""};
|
|
int64_t id = cbm_store_upsert_node(s, &n);
|
|
/* Should succeed — empty strings are valid */
|
|
ASSERT_GT(id, 0);
|
|
|
|
cbm_node_t found = {0};
|
|
int rc = cbm_store_find_node_by_qn(s, "test", "", &found);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_STR_EQ(found.name, "");
|
|
ASSERT_STR_EQ(found.label, "");
|
|
cbm_node_free_fields(&found);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
/* ── Edge case: not-found lookups ──────────────────────────────── */
|
|
|
|
TEST(store_find_by_id_not_found) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
|
|
cbm_node_t found = {0};
|
|
int rc = cbm_store_find_node_by_id(s, 999999, &found);
|
|
ASSERT_EQ(rc, CBM_STORE_NOT_FOUND);
|
|
|
|
/* Negative ID should also be not found */
|
|
rc = cbm_store_find_node_by_id(s, -1, &found);
|
|
ASSERT_EQ(rc, CBM_STORE_NOT_FOUND);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_find_by_qn_not_found) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
|
|
/* Insert a node so the store is non-empty */
|
|
cbm_node_t n = {
|
|
.project = "test", .label = "Function", .name = "Exists", .qualified_name = "test.Exists"};
|
|
cbm_store_upsert_node(s, &n);
|
|
|
|
/* Search for a non-existent QN */
|
|
cbm_node_t found = {0};
|
|
int rc = cbm_store_find_node_by_qn(s, "test", "test.DoesNotExist", &found);
|
|
ASSERT_EQ(rc, CBM_STORE_NOT_FOUND);
|
|
|
|
/* Wrong project */
|
|
rc = cbm_store_find_node_by_qn(s, "other-project", "test.Exists", &found);
|
|
ASSERT_EQ(rc, CBM_STORE_NOT_FOUND);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
/* ── Edge case: cross-project lookups ──────────────────────────── */
|
|
|
|
TEST(store_find_by_qn_any_cross_project) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "proj-a", "/tmp/a");
|
|
cbm_store_upsert_project(s, "proj-b", "/tmp/b");
|
|
|
|
cbm_node_t na = {.project = "proj-a",
|
|
.label = "Function",
|
|
.name = "SharedFunc",
|
|
.qualified_name = "proj-a.main.SharedFunc"};
|
|
cbm_node_t nb = {.project = "proj-b",
|
|
.label = "Class",
|
|
.name = "Widget",
|
|
.qualified_name = "proj-b.pkg.Widget"};
|
|
cbm_store_upsert_node(s, &na);
|
|
cbm_store_upsert_node(s, &nb);
|
|
|
|
/* find_node_by_qn_any finds without project filter */
|
|
cbm_node_t found = {0};
|
|
int rc = cbm_store_find_node_by_qn_any(s, "proj-a.main.SharedFunc", &found);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_STR_EQ(found.name, "SharedFunc");
|
|
ASSERT_STR_EQ(found.project, "proj-a");
|
|
cbm_node_free_fields(&found);
|
|
|
|
rc = cbm_store_find_node_by_qn_any(s, "proj-b.pkg.Widget", &found);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_STR_EQ(found.name, "Widget");
|
|
ASSERT_STR_EQ(found.project, "proj-b");
|
|
cbm_node_free_fields(&found);
|
|
|
|
/* Non-existent QN */
|
|
rc = cbm_store_find_node_by_qn_any(s, "nonexistent.Nope", &found);
|
|
ASSERT_EQ(rc, CBM_STORE_NOT_FOUND);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_find_by_name_any_cross_project) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "proj-a", "/tmp/a");
|
|
cbm_store_upsert_project(s, "proj-b", "/tmp/b");
|
|
|
|
/* Same name in two projects */
|
|
cbm_node_t na = {.project = "proj-a",
|
|
.label = "Function",
|
|
.name = "Init",
|
|
.qualified_name = "proj-a.main.Init"};
|
|
cbm_node_t nb = {.project = "proj-b",
|
|
.label = "Function",
|
|
.name = "Init",
|
|
.qualified_name = "proj-b.main.Init"};
|
|
cbm_node_t nc = {.project = "proj-b",
|
|
.label = "Function",
|
|
.name = "Other",
|
|
.qualified_name = "proj-b.main.Other"};
|
|
cbm_store_upsert_node(s, &na);
|
|
cbm_store_upsert_node(s, &nb);
|
|
cbm_store_upsert_node(s, &nc);
|
|
|
|
/* find_nodes_by_name_any should find both "Init" across projects */
|
|
cbm_node_t *nodes = NULL;
|
|
int count = 0;
|
|
int rc = cbm_store_find_nodes_by_name_any(s, "Init", &nodes, &count);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_EQ(count, 2);
|
|
cbm_store_free_nodes(nodes, count);
|
|
|
|
/* Name that doesn't exist */
|
|
rc = cbm_store_find_nodes_by_name_any(s, "Nonexistent", &nodes, &count);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_EQ(count, 0);
|
|
cbm_store_free_nodes(nodes, count);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
/* ── Edge case: empty result sets ──────────────────────────────── */
|
|
|
|
TEST(store_find_by_file_no_match) {
|
|
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 = "Foo",
|
|
.qualified_name = "test.Foo",
|
|
.file_path = "main.go"};
|
|
cbm_store_upsert_node(s, &n);
|
|
|
|
/* Search for a file that has no nodes */
|
|
cbm_node_t *nodes = NULL;
|
|
int count = 0;
|
|
int rc = cbm_store_find_nodes_by_file(s, "test", "nonexistent.go", &nodes, &count);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_EQ(count, 0);
|
|
cbm_store_free_nodes(nodes, count);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
/* ── Edge case: batch upsert boundary ─────────────────────────── */
|
|
|
|
TEST(store_node_batch_upsert_zero) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
|
|
/* Batch upsert with count=0 should succeed, do nothing */
|
|
int rc = cbm_store_upsert_node_batch(s, NULL, 0, NULL);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
|
|
int cnt = cbm_store_count_nodes(s, "test");
|
|
ASSERT_EQ(cnt, 0);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_node_batch_upsert_100) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
|
|
cbm_node_t nodes[100];
|
|
int64_t ids[100];
|
|
char names[100][32];
|
|
char qns[100][64];
|
|
|
|
for (int i = 0; i < 100; i++) {
|
|
snprintf(names[i], sizeof(names[i]), "stress_%d", i);
|
|
snprintf(qns[i], sizeof(qns[i]), "test.stress.stress_%d", i);
|
|
nodes[i] = (cbm_node_t){.project = "test",
|
|
.label = "Function",
|
|
.name = names[i],
|
|
.qualified_name = qns[i],
|
|
.file_path = "stress.go",
|
|
.start_line = i,
|
|
.end_line = i + 1};
|
|
}
|
|
|
|
int rc = cbm_store_upsert_node_batch(s, nodes, 100, ids);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
|
|
/* All IDs should be positive */
|
|
for (int i = 0; i < 100; i++)
|
|
ASSERT_GT(ids[i], 0);
|
|
|
|
/* IDs should all be unique */
|
|
for (int i = 0; i < 100; i++)
|
|
for (int j = i + 1; j < 100; j++)
|
|
ASSERT_NEQ(ids[i], ids[j]);
|
|
|
|
int cnt = cbm_store_count_nodes(s, "test");
|
|
ASSERT_EQ(cnt, 100);
|
|
|
|
/* Verify a few random lookups */
|
|
cbm_node_t found = {0};
|
|
rc = cbm_store_find_node_by_qn(s, "test", "test.stress.stress_0", &found);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_STR_EQ(found.name, "stress_0");
|
|
cbm_node_free_fields(&found);
|
|
|
|
rc = cbm_store_find_node_by_qn(s, "test", "test.stress.stress_99", &found);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_STR_EQ(found.name, "stress_99");
|
|
cbm_node_free_fields(&found);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
/* ── Edge case: delete then verify remaining ──────────────────── */
|
|
|
|
TEST(store_delete_by_label_verify_remaining) {
|
|
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 = "FuncA", .qualified_name = "test.FuncA"};
|
|
cbm_node_t n2 = {
|
|
.project = "test", .label = "Class", .name = "ClassB", .qualified_name = "test.ClassB"};
|
|
cbm_node_t n3 = {
|
|
.project = "test", .label = "Function", .name = "FuncC", .qualified_name = "test.FuncC"};
|
|
cbm_node_t n4 = {
|
|
.project = "test", .label = "Method", .name = "MethodD", .qualified_name = "test.MethodD"};
|
|
cbm_store_upsert_node(s, &n1);
|
|
cbm_store_upsert_node(s, &n2);
|
|
cbm_store_upsert_node(s, &n3);
|
|
cbm_store_upsert_node(s, &n4);
|
|
|
|
/* Delete all Functions */
|
|
int rc = cbm_store_delete_nodes_by_label(s, "test", "Function");
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
|
|
int cnt = cbm_store_count_nodes(s, "test");
|
|
ASSERT_EQ(cnt, 2);
|
|
|
|
/* Class and Method should remain */
|
|
cbm_node_t found = {0};
|
|
rc = cbm_store_find_node_by_qn(s, "test", "test.ClassB", &found);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_STR_EQ(found.label, "Class");
|
|
cbm_node_free_fields(&found);
|
|
|
|
rc = cbm_store_find_node_by_qn(s, "test", "test.MethodD", &found);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_STR_EQ(found.label, "Method");
|
|
cbm_node_free_fields(&found);
|
|
|
|
/* Deleted ones should be gone */
|
|
rc = cbm_store_find_node_by_qn(s, "test", "test.FuncA", &found);
|
|
ASSERT_EQ(rc, CBM_STORE_NOT_FOUND);
|
|
rc = cbm_store_find_node_by_qn(s, "test", "test.FuncC", &found);
|
|
ASSERT_EQ(rc, CBM_STORE_NOT_FOUND);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_delete_by_file_verify_remaining) {
|
|
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 = "A",
|
|
.qualified_name = "test.A",
|
|
.file_path = "delete_me.go"};
|
|
cbm_node_t n2 = {.project = "test",
|
|
.label = "Function",
|
|
.name = "B",
|
|
.qualified_name = "test.B",
|
|
.file_path = "keep_me.go"};
|
|
cbm_node_t n3 = {.project = "test",
|
|
.label = "Function",
|
|
.name = "C",
|
|
.qualified_name = "test.C",
|
|
.file_path = "delete_me.go"};
|
|
cbm_store_upsert_node(s, &n1);
|
|
cbm_store_upsert_node(s, &n2);
|
|
cbm_store_upsert_node(s, &n3);
|
|
|
|
int rc = cbm_store_delete_nodes_by_file(s, "test", "delete_me.go");
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
|
|
int cnt = cbm_store_count_nodes(s, "test");
|
|
ASSERT_EQ(cnt, 1);
|
|
|
|
/* Only keep_me.go node should remain */
|
|
cbm_node_t found = {0};
|
|
rc = cbm_store_find_node_by_qn(s, "test", "test.B", &found);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_STR_EQ(found.file_path, "keep_me.go");
|
|
cbm_node_free_fields(&found);
|
|
|
|
/* Deleted nodes should be gone */
|
|
rc = cbm_store_find_node_by_qn(s, "test", "test.A", &found);
|
|
ASSERT_EQ(rc, CBM_STORE_NOT_FOUND);
|
|
rc = cbm_store_find_node_by_qn(s, "test", "test.C", &found);
|
|
ASSERT_EQ(rc, CBM_STORE_NOT_FOUND);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
/* ── Edge case: upsert dedup with field changes ───────────────── */
|
|
|
|
TEST(store_node_upsert_updates_fields) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
|
|
/* Insert initial node */
|
|
cbm_node_t n1 = {.project = "test",
|
|
.label = "Function",
|
|
.name = "MyFunc",
|
|
.qualified_name = "test.MyFunc",
|
|
.file_path = "old.go",
|
|
.start_line = 1,
|
|
.end_line = 10,
|
|
.properties_json = "{\"version\":1}"};
|
|
int64_t id1 = cbm_store_upsert_node(s, &n1);
|
|
ASSERT_GT(id1, 0);
|
|
|
|
/* Upsert same QN with changed fields */
|
|
cbm_node_t n2 = {.project = "test",
|
|
.label = "Method",
|
|
.name = "MyFunc",
|
|
.qualified_name = "test.MyFunc",
|
|
.file_path = "new.go",
|
|
.start_line = 50,
|
|
.end_line = 60,
|
|
.properties_json = "{\"version\":2}"};
|
|
int64_t id2 = cbm_store_upsert_node(s, &n2);
|
|
ASSERT_EQ(id1, id2); /* Same ID — updated, not duplicated */
|
|
|
|
/* Count should still be 1 */
|
|
int cnt = cbm_store_count_nodes(s, "test");
|
|
ASSERT_EQ(cnt, 1);
|
|
|
|
/* Verify fields were updated */
|
|
cbm_node_t found = {0};
|
|
int rc = cbm_store_find_node_by_id(s, id1, &found);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_STR_EQ(found.label, "Method");
|
|
ASSERT_STR_EQ(found.file_path, "new.go");
|
|
ASSERT_EQ(found.start_line, 50);
|
|
ASSERT_EQ(found.end_line, 60);
|
|
ASSERT(strstr(found.properties_json, "version") != NULL);
|
|
ASSERT(strstr(found.properties_json, "2") != NULL);
|
|
cbm_node_free_fields(&found);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
/* ── Edge case: long qualified name ───────────────────────────── */
|
|
|
|
TEST(store_node_long_qn) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
|
|
/* Build a 1200-char qualified name */
|
|
char long_qn[1201];
|
|
memset(long_qn, 'a', 1200);
|
|
long_qn[0] = 't'; /* make it look like a dotted path */
|
|
for (int i = 50; i < 1200; i += 50)
|
|
long_qn[i] = '.';
|
|
long_qn[1200] = '\0';
|
|
|
|
cbm_node_t n = {.project = "test",
|
|
.label = "Function",
|
|
.name = "LongName",
|
|
.qualified_name = long_qn,
|
|
.file_path = "big.go"};
|
|
int64_t id = cbm_store_upsert_node(s, &n);
|
|
ASSERT_GT(id, 0);
|
|
|
|
/* Should be retrievable by QN */
|
|
cbm_node_t found = {0};
|
|
int rc = cbm_store_find_node_by_qn(s, "test", long_qn, &found);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_STR_EQ(found.qualified_name, long_qn);
|
|
ASSERT_STR_EQ(found.name, "LongName");
|
|
cbm_node_free_fields(&found);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
/* ── Edge case: properties JSON with special characters ────────── */
|
|
|
|
TEST(store_node_properties_special_chars) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
|
|
/* JSON with quotes, backslashes, unicode, newlines */
|
|
const char *props = "{\"desc\":\"line1\\nline2\","
|
|
"\"path\":\"C:\\\\Users\\\\test\","
|
|
"\"emoji\":\"\\u2603\","
|
|
"\"nested\":{\"key\":\"val with \\\"quotes\\\"\"}}";
|
|
|
|
cbm_node_t n = {.project = "test",
|
|
.label = "Function",
|
|
.name = "SpecialFunc",
|
|
.qualified_name = "test.SpecialFunc",
|
|
.properties_json = props};
|
|
int64_t id = cbm_store_upsert_node(s, &n);
|
|
ASSERT_GT(id, 0);
|
|
|
|
cbm_node_t found = {0};
|
|
int rc = cbm_store_find_node_by_id(s, id, &found);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_NOT_NULL(found.properties_json);
|
|
/* Round-trip should preserve the JSON exactly */
|
|
ASSERT_STR_EQ(found.properties_json, props);
|
|
cbm_node_free_fields(&found);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
/* ── Edge case: delete from non-existent project/file ─────────── */
|
|
|
|
TEST(store_delete_nodes_nonexistent) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
|
|
/* Insert one node */
|
|
cbm_node_t n = {.project = "test",
|
|
.label = "Function",
|
|
.name = "Survivor",
|
|
.qualified_name = "test.Survivor",
|
|
.file_path = "main.go"};
|
|
cbm_store_upsert_node(s, &n);
|
|
|
|
/* Delete by non-existent file — should succeed but delete nothing */
|
|
int rc = cbm_store_delete_nodes_by_file(s, "test", "ghost.go");
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_EQ(cbm_store_count_nodes(s, "test"), 1);
|
|
|
|
/* Delete by non-existent label */
|
|
rc = cbm_store_delete_nodes_by_label(s, "test", "Interface");
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_EQ(cbm_store_count_nodes(s, "test"), 1);
|
|
|
|
/* Delete by non-existent project */
|
|
rc = cbm_store_delete_nodes_by_project(s, "no-such-project");
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_EQ(cbm_store_count_nodes(s, "test"), 1);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
/* ── Edge case: count nodes for unknown project ───────────────── */
|
|
|
|
TEST(store_count_nodes_unknown_project) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
/* No project created — count should be 0 */
|
|
int cnt = cbm_store_count_nodes(s, "ghost-project");
|
|
ASSERT_EQ(cnt, 0);
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
/* A COUNT(*) that cannot be read must not be reported as a count of zero:
|
|
* index_status renders that as the positive assertion status "empty", so a
|
|
* corrupt project looks like one that was never indexed. Dropping the table
|
|
* after the statement is cached makes the step fail deterministically. */
|
|
TEST(store_count_failed_read_is_not_zero) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
|
|
cbm_node_t n = {.project = "test",
|
|
.label = "File",
|
|
.name = "main.c",
|
|
.qualified_name = "test.main.c",
|
|
.file_path = "main.c"};
|
|
cbm_store_upsert_node(s, &n);
|
|
|
|
/* Sanity: a readable table still counts normally. */
|
|
ASSERT_EQ(cbm_store_count_nodes(s, "test"), 1);
|
|
ASSERT_TRUE(cbm_store_count_edges(s, "test") >= 0);
|
|
|
|
/* Make the read fail. The statements are cached by the calls above, so the
|
|
* step (not the prepare) is what fails once the tables are gone. */
|
|
ASSERT_EQ(cbm_store_exec(s, "DROP TABLE nodes;"), 0);
|
|
ASSERT_EQ(cbm_store_exec(s, "DROP TABLE edges;"), 0);
|
|
|
|
ASSERT_TRUE(cbm_store_count_nodes(s, "test") < 0);
|
|
ASSERT_TRUE(cbm_store_count_edges(s, "test") < 0);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
/* ── Index coverage (#963) ──────────────────────────────────────── */
|
|
|
|
/* Round-trip + deleted-file prune + shadow miss-graph materialization +
|
|
* empty-replace wipe. The prune keys off file_hashes (the live-file set), so
|
|
* a row for a file with no hash row must not survive the replace. */
|
|
TEST(store_coverage_roundtrip_prune_shadow) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
cbm_store_upsert_project(s, "test", "/tmp/test");
|
|
cbm_store_upsert_file_hash(s, "test", "src/a.py", "", 1, 10);
|
|
|
|
cbm_coverage_row_t rows[] = {
|
|
{.rel_path = "src/a.py", .kind = "parse_partial", .detail = "4-7"},
|
|
{.rel_path = "gone.py", .kind = "oversized", .detail = "too big"},
|
|
/* By-design rows (#963): neither has a file_hashes row, yet both must
|
|
* SURVIVE the deleted-file prune (deliberately-unindexed paths never
|
|
* have hashes) — and must stay OUT of the shadow miss graph. */
|
|
{.rel_path = "secret.py", .kind = "not_indexed_file", .detail = "gitignore"},
|
|
{.rel_path = "generated", .kind = "not_indexed_dir", .detail = "excluded subtree"},
|
|
};
|
|
ASSERT_EQ(cbm_store_coverage_replace(s, "test", rows, 4), CBM_STORE_OK);
|
|
|
|
cbm_coverage_row_t *got = NULL;
|
|
int n = 0;
|
|
ASSERT_EQ(cbm_store_coverage_get(s, "test", &got, &n), CBM_STORE_OK);
|
|
ASSERT_EQ(n, 3); /* gone.py pruned — no file_hashes row; by-design rows kept */
|
|
int saw_partial = 0;
|
|
int saw_by_design = 0;
|
|
for (int i = 0; i < n; i++) {
|
|
if (strcmp(got[i].kind, "parse_partial") == 0) {
|
|
saw_partial++;
|
|
}
|
|
if (strncmp(got[i].kind, "not_indexed", 11) == 0) {
|
|
saw_by_design++;
|
|
}
|
|
}
|
|
ASSERT_EQ(saw_partial, 1);
|
|
ASSERT_EQ(saw_by_design, 2);
|
|
cbm_store_free_coverage(got, n);
|
|
|
|
/* Shadow miss-graph materialized under "test::missed": FAILURES only —
|
|
* Project → Folder(src) → File(a.py){kind,detail}; the by-design rows do
|
|
* not appear. */
|
|
cbm_node_t *nodes = NULL;
|
|
int nc = 0;
|
|
ASSERT_EQ(cbm_store_find_nodes_by_label(s, "test::missed", "File", &nodes, &nc), CBM_STORE_OK);
|
|
ASSERT_EQ(nc, 1);
|
|
ASSERT_STR_EQ(nodes[0].file_path, "src/a.py");
|
|
ASSERT_NOT_NULL(strstr(nodes[0].properties_json, "\"kind\":\"parse_partial\""));
|
|
ASSERT_NOT_NULL(strstr(nodes[0].properties_json, "\"detail\":\"4-7\""));
|
|
cbm_store_free_nodes(nodes, nc);
|
|
nodes = NULL;
|
|
nc = 0;
|
|
ASSERT_EQ(cbm_store_find_nodes_by_label(s, "test::missed", "Folder", &nodes, &nc),
|
|
CBM_STORE_OK);
|
|
ASSERT_EQ(nc, 1);
|
|
ASSERT_STR_EQ(nodes[0].name, "src");
|
|
cbm_store_free_nodes(nodes, nc);
|
|
|
|
/* Empty replace clears the table AND wipes the shadow graph. */
|
|
ASSERT_EQ(cbm_store_coverage_replace(s, "test", NULL, 0), CBM_STORE_OK);
|
|
got = NULL;
|
|
n = 0;
|
|
ASSERT_EQ(cbm_store_coverage_get(s, "test", &got, &n), CBM_STORE_OK);
|
|
ASSERT_EQ(n, 0);
|
|
free(got);
|
|
nodes = NULL;
|
|
nc = 0;
|
|
ASSERT_EQ(cbm_store_find_nodes_by_label(s, "test::missed", "File", &nodes, &nc), CBM_STORE_OK);
|
|
ASSERT_EQ(nc, 0);
|
|
cbm_store_free_nodes(nodes, nc);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_coverage_targeted_path_and_scope_lookup) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
ASSERT_NOT_NULL(s);
|
|
ASSERT_EQ(cbm_store_upsert_project(s, "coverage-targeted", "/tmp/coverage-targeted"),
|
|
CBM_STORE_OK);
|
|
|
|
/* Failure rows need live-file hash records so coverage_replace does not
|
|
* correctly prune them as deleted. By-design exclusions have no hashes. */
|
|
ASSERT_EQ(cbm_store_upsert_file_hash(s, "coverage-targeted", "src/partial.c", "", 10, 20),
|
|
CBM_STORE_OK);
|
|
ASSERT_EQ(cbm_store_upsert_file_hash(s, "coverage-targeted", "src/skipped.c", "", 11, 21),
|
|
CBM_STORE_OK);
|
|
ASSERT_EQ(cbm_store_upsert_file_hash(s, "coverage-targeted", "src2/other.c", "", 12, 22),
|
|
CBM_STORE_OK);
|
|
|
|
cbm_coverage_row_t rows[] = {
|
|
{.rel_path = "src/partial.c", .kind = "parse_partial", .detail = "7-9"},
|
|
{.rel_path = "src/skipped.c", .kind = "oversized", .detail = "too large"},
|
|
{.rel_path = "src2/other.c", .kind = "extract", .detail = "parser failed"},
|
|
{.rel_path = "generated", .kind = "not_indexed_dir", .detail = "excluded subtree"},
|
|
{.rel_path = "secret.c", .kind = "not_indexed_file", .detail = "gitignore"},
|
|
};
|
|
ASSERT_EQ(cbm_store_coverage_replace(s, "coverage-targeted", rows, 5), CBM_STORE_OK);
|
|
|
|
cbm_coverage_row_t *got = NULL;
|
|
int count = 0;
|
|
ASSERT_EQ(cbm_store_coverage_get_path(s, "coverage-targeted", "src/partial.c", &got, &count),
|
|
CBM_STORE_OK);
|
|
ASSERT_EQ(count, 1);
|
|
ASSERT_STR_EQ(got[0].rel_path, "src/partial.c");
|
|
ASSERT_STR_EQ(got[0].kind, "parse_partial");
|
|
ASSERT_STR_EQ(got[0].detail, "7-9");
|
|
cbm_store_free_coverage(got, count);
|
|
|
|
/* A file below an excluded directory inherits that directory row. */
|
|
got = NULL;
|
|
count = 0;
|
|
ASSERT_EQ(cbm_store_coverage_get_path(s, "coverage-targeted", "generated/nested/file.c", &got,
|
|
&count),
|
|
CBM_STORE_OK);
|
|
ASSERT_EQ(count, 1);
|
|
ASSERT_STR_EQ(got[0].rel_path, "generated");
|
|
ASSERT_STR_EQ(got[0].kind, "not_indexed_dir");
|
|
cbm_store_free_coverage(got, count);
|
|
|
|
/* Prefixes must stop at path-segment boundaries. */
|
|
got = NULL;
|
|
count = 0;
|
|
ASSERT_EQ(
|
|
cbm_store_coverage_get_path(s, "coverage-targeted", "generated2/file.c", &got, &count),
|
|
CBM_STORE_OK);
|
|
ASSERT_EQ(count, 0);
|
|
cbm_store_free_coverage(got, count);
|
|
|
|
got = NULL;
|
|
count = 0;
|
|
ASSERT_EQ(cbm_store_coverage_get_scope(s, "coverage-targeted", "src", &got, &count),
|
|
CBM_STORE_OK);
|
|
ASSERT_EQ(count, 2);
|
|
ASSERT_STR_EQ(got[0].rel_path, "src/partial.c");
|
|
ASSERT_STR_EQ(got[1].rel_path, "src/skipped.c");
|
|
cbm_store_free_coverage(got, count);
|
|
|
|
/* A scope nested below an excluded directory still reports its ancestor. */
|
|
got = NULL;
|
|
count = 0;
|
|
ASSERT_EQ(
|
|
cbm_store_coverage_get_scope(s, "coverage-targeted", "generated/nested", &got, &count),
|
|
CBM_STORE_OK);
|
|
ASSERT_EQ(count, 1);
|
|
ASSERT_STR_EQ(got[0].rel_path, "generated");
|
|
cbm_store_free_coverage(got, count);
|
|
|
|
cbm_file_hash_t hash = {0};
|
|
ASSERT_EQ(cbm_store_get_file_hash(s, "coverage-targeted", "src/partial.c", &hash),
|
|
CBM_STORE_OK);
|
|
ASSERT_STR_EQ(hash.project, "coverage-targeted");
|
|
ASSERT_STR_EQ(hash.rel_path, "src/partial.c");
|
|
ASSERT_EQ(hash.mtime_ns, 10);
|
|
ASSERT_EQ(hash.size, 20);
|
|
cbm_store_clear_file_hash(&hash);
|
|
ASSERT_NULL(hash.project);
|
|
ASSERT_NULL(hash.rel_path);
|
|
ASSERT_NULL(hash.sha256);
|
|
|
|
ASSERT_EQ(cbm_store_get_file_hash(s, "coverage-targeted", "missing.c", &hash),
|
|
CBM_STORE_NOT_FOUND);
|
|
cbm_store_clear_file_hash(&hash);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_coverage_meta_zero_row_truncation_and_delete) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
ASSERT_NOT_NULL(s);
|
|
ASSERT_EQ(cbm_store_upsert_project(s, "coverage-meta", "/tmp/coverage-meta"), CBM_STORE_OK);
|
|
|
|
cbm_coverage_meta_t write_meta = {
|
|
.generation = "generation-42",
|
|
.index_mode = "fast",
|
|
.recording_status = "truncated",
|
|
.ignored_files_stored = 2000,
|
|
.ignored_files_total = 2501,
|
|
.coverage_version = 1,
|
|
.hash_records_complete = false,
|
|
};
|
|
/* Metadata is meaningful even when the authoritative miss set is empty. */
|
|
ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-meta", NULL, 0, &write_meta),
|
|
CBM_STORE_OK);
|
|
|
|
cbm_coverage_row_t *rows = NULL;
|
|
int count = 0;
|
|
ASSERT_EQ(cbm_store_coverage_get(s, "coverage-meta", &rows, &count), CBM_STORE_OK);
|
|
ASSERT_EQ(count, 0);
|
|
cbm_store_free_coverage(rows, count);
|
|
|
|
cbm_coverage_meta_t got = {0};
|
|
ASSERT_EQ(cbm_store_coverage_meta_get(s, "coverage-meta", &got), CBM_STORE_OK);
|
|
ASSERT_STR_EQ(got.project, "coverage-meta");
|
|
ASSERT_STR_EQ(got.generation, "generation-42");
|
|
ASSERT_NOT_NULL(got.recorded_at);
|
|
ASSERT_STR_EQ(got.index_mode, "fast");
|
|
ASSERT_STR_EQ(got.recording_status, "truncated");
|
|
ASSERT_EQ(got.ignored_files_stored, 2000);
|
|
ASSERT_EQ(got.ignored_files_total, 2501);
|
|
ASSERT_EQ(got.coverage_version, 1);
|
|
ASSERT_FALSE(got.hash_records_complete);
|
|
cbm_store_coverage_meta_clear(&got);
|
|
ASSERT_NULL(got.project);
|
|
ASSERT_NULL(got.generation);
|
|
ASSERT_NULL(got.recorded_at);
|
|
ASSERT_NULL(got.index_mode);
|
|
ASSERT_NULL(got.recording_status);
|
|
|
|
/* Replacing through the compatibility wrapper clears possibly-stale meta. */
|
|
ASSERT_EQ(cbm_store_coverage_replace(s, "coverage-meta", NULL, 0), CBM_STORE_OK);
|
|
ASSERT_EQ(cbm_store_coverage_meta_get(s, "coverage-meta", &got), CBM_STORE_NOT_FOUND);
|
|
cbm_store_coverage_meta_clear(&got);
|
|
|
|
/* Recreate metadata and a missed-graph node, then project deletion must
|
|
* remove the table rows, metadata, and the derived ::missed project. */
|
|
ASSERT_EQ(cbm_store_upsert_file_hash(s, "coverage-meta", "bad.c", "", 1, 1), CBM_STORE_OK);
|
|
cbm_coverage_row_t failure = {.rel_path = "bad.c", .kind = "parse_partial", .detail = "1-2"};
|
|
ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-meta", &failure, 1, &write_meta),
|
|
CBM_STORE_OK);
|
|
ASSERT_EQ(cbm_store_delete_project(s, "coverage-meta"), CBM_STORE_OK);
|
|
|
|
rows = NULL;
|
|
count = 0;
|
|
ASSERT_EQ(cbm_store_coverage_get(s, "coverage-meta", &rows, &count), CBM_STORE_OK);
|
|
ASSERT_EQ(count, 0);
|
|
cbm_store_free_coverage(rows, count);
|
|
ASSERT_EQ(cbm_store_coverage_meta_get(s, "coverage-meta", &got), CBM_STORE_NOT_FOUND);
|
|
cbm_store_coverage_meta_clear(&got);
|
|
cbm_project_t shadow = {0};
|
|
ASSERT_EQ(cbm_store_get_project(s, "coverage-meta::missed", &shadow), CBM_STORE_NOT_FOUND);
|
|
cbm_project_free_fields(&shadow);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_coverage_replace_rejects_invalid_row_arguments) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
ASSERT_NOT_NULL(s);
|
|
ASSERT_EQ(cbm_store_upsert_project(s, "coverage-invalid", "/tmp/coverage-invalid"),
|
|
CBM_STORE_OK);
|
|
ASSERT_EQ(cbm_store_upsert_file_hash(s, "coverage-invalid", "kept.c", "", 1, 1), CBM_STORE_OK);
|
|
cbm_coverage_row_t kept = {.rel_path = "kept.c", .kind = "parse_partial", .detail = "3-4"};
|
|
cbm_coverage_meta_t original_meta = {
|
|
.generation = "before-invalid-call",
|
|
.index_mode = "full",
|
|
.recording_status = "complete",
|
|
.coverage_version = 1,
|
|
.hash_records_complete = true,
|
|
};
|
|
ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-invalid", &kept, 1, &original_meta),
|
|
CBM_STORE_OK);
|
|
|
|
cbm_coverage_meta_t replacement_meta = original_meta;
|
|
replacement_meta.generation = "must-not-commit";
|
|
ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-invalid", &kept, -1, &replacement_meta),
|
|
CBM_STORE_ERR);
|
|
ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-invalid", NULL, 1, &replacement_meta),
|
|
CBM_STORE_ERR);
|
|
|
|
cbm_coverage_row_t *rows = NULL;
|
|
int count = 0;
|
|
ASSERT_EQ(cbm_store_coverage_get(s, "coverage-invalid", &rows, &count), CBM_STORE_OK);
|
|
ASSERT_EQ(count, 1);
|
|
ASSERT_STR_EQ(rows[0].rel_path, "kept.c");
|
|
ASSERT_STR_EQ(rows[0].detail, "3-4");
|
|
cbm_store_free_coverage(rows, count);
|
|
cbm_coverage_meta_t got = {0};
|
|
ASSERT_EQ(cbm_store_coverage_meta_get(s, "coverage-invalid", &got), CBM_STORE_OK);
|
|
ASSERT_STR_EQ(got.generation, "before-invalid-call");
|
|
cbm_store_coverage_meta_clear(&got);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
TEST(store_coverage_replace_rolls_back_when_shadow_rebuild_fails) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
ASSERT_NOT_NULL(s);
|
|
ASSERT_EQ(cbm_store_upsert_project(s, "coverage-shadow", "/tmp/coverage-shadow"), CBM_STORE_OK);
|
|
ASSERT_EQ(cbm_store_upsert_file_hash(s, "coverage-shadow", "old.c", "", 1, 1), CBM_STORE_OK);
|
|
ASSERT_EQ(cbm_store_upsert_file_hash(s, "coverage-shadow", "new.c", "", 2, 2), CBM_STORE_OK);
|
|
|
|
cbm_coverage_row_t old_row = {
|
|
.rel_path = "old.c", .kind = "parse_partial", .detail = "old-detail"};
|
|
cbm_coverage_meta_t old_meta = {
|
|
.generation = "old-generation",
|
|
.index_mode = "full",
|
|
.recording_status = "complete",
|
|
.coverage_version = 1,
|
|
.hash_records_complete = true,
|
|
};
|
|
ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-shadow", &old_row, 1, &old_meta),
|
|
CBM_STORE_OK);
|
|
int old_shadow_nodes = cbm_store_count_nodes(s, "coverage-shadow::missed");
|
|
ASSERT_GT(old_shadow_nodes, 0);
|
|
|
|
/* Force only the derived-view rebuild to fail. The authoritative rows,
|
|
* metadata, and prior shadow graph must remain one atomic generation. */
|
|
ASSERT_EQ(cbm_store_exec(s, "CREATE TRIGGER fail_missed_insert BEFORE INSERT ON nodes "
|
|
"WHEN NEW.project = 'coverage-shadow::missed' "
|
|
"BEGIN SELECT RAISE(ABORT, 'forced missed shadow failure'); END;"),
|
|
CBM_STORE_OK);
|
|
|
|
cbm_coverage_row_t new_row = {
|
|
.rel_path = "new.c", .kind = "parse_partial", .detail = "new-detail"};
|
|
cbm_coverage_meta_t new_meta = old_meta;
|
|
new_meta.generation = "new-generation";
|
|
ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-shadow", &new_row, 1, &new_meta),
|
|
CBM_STORE_ERR);
|
|
|
|
cbm_coverage_row_t *rows = NULL;
|
|
int count = 0;
|
|
ASSERT_EQ(cbm_store_coverage_get(s, "coverage-shadow", &rows, &count), CBM_STORE_OK);
|
|
ASSERT_EQ(count, 1);
|
|
ASSERT_STR_EQ(rows[0].rel_path, "old.c");
|
|
ASSERT_STR_EQ(rows[0].detail, "old-detail");
|
|
cbm_store_free_coverage(rows, count);
|
|
|
|
cbm_coverage_meta_t got = {0};
|
|
ASSERT_EQ(cbm_store_coverage_meta_get(s, "coverage-shadow", &got), CBM_STORE_OK);
|
|
ASSERT_STR_EQ(got.generation, "old-generation");
|
|
cbm_store_coverage_meta_clear(&got);
|
|
ASSERT_EQ(cbm_store_count_nodes(s, "coverage-shadow::missed"), old_shadow_nodes);
|
|
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
SUITE(store_nodes) {
|
|
RUN_TEST(store_qn_suffix_is_literal);
|
|
RUN_TEST(store_qn_suffix_is_unbounded);
|
|
RUN_TEST(store_qn_base_finds_overloads_only);
|
|
RUN_TEST(store_coverage_roundtrip_prune_shadow);
|
|
RUN_TEST(store_coverage_targeted_path_and_scope_lookup);
|
|
RUN_TEST(store_coverage_meta_zero_row_truncation_and_delete);
|
|
RUN_TEST(store_coverage_replace_rejects_invalid_row_arguments);
|
|
RUN_TEST(store_coverage_replace_rolls_back_when_shadow_rebuild_fails);
|
|
RUN_TEST(sql_label_allowlists_match_cbm_label_is_type_like);
|
|
RUN_TEST(sql_relation_labels_match_cbm_label_is_relation);
|
|
RUN_TEST(store_open_memory);
|
|
RUN_TEST(store_close_null);
|
|
RUN_TEST(store_open_memory_twice);
|
|
RUN_TEST(store_integrity_clean);
|
|
RUN_TEST(store_integrity_empty);
|
|
RUN_TEST(store_integrity_corrupt_bad_path);
|
|
RUN_TEST(store_integrity_windows_lowercase_drive_issue367);
|
|
RUN_TEST(store_integrity_corrupt_too_many_rows);
|
|
RUN_TEST(store_integrity_verdict_healthy_is_ok);
|
|
RUN_TEST(store_integrity_verdict_real_corruption_is_corrupt);
|
|
RUN_TEST(store_integrity_verdict_unopenable_is_transient_not_corrupt);
|
|
RUN_TEST(store_integrity_null_check);
|
|
RUN_TEST(store_project_crud);
|
|
RUN_TEST(store_project_update);
|
|
RUN_TEST(store_project_delete);
|
|
RUN_TEST(store_node_crud);
|
|
RUN_TEST(store_node_dedup);
|
|
RUN_TEST(store_node_find_by_label);
|
|
RUN_TEST(store_node_scan_streams_eager_scan_order);
|
|
RUN_TEST(store_node_find_by_file);
|
|
RUN_TEST(store_file_outline_is_filtered_stable_bounded_and_cancellable_issue469);
|
|
RUN_TEST(store_file_outline_fails_closed_on_text_budget_issue469);
|
|
RUN_TEST(store_node_find_not_found);
|
|
RUN_TEST(store_node_count_empty);
|
|
RUN_TEST(store_node_delete_by_file);
|
|
RUN_TEST(store_node_delete_by_label);
|
|
RUN_TEST(store_node_batch_upsert);
|
|
RUN_TEST(store_node_batch_empty);
|
|
RUN_TEST(store_cascade_delete);
|
|
RUN_TEST(store_file_hash_crud);
|
|
RUN_TEST(store_lsp_surface_round_trip);
|
|
RUN_TEST(store_dependent_files_lookup);
|
|
RUN_TEST(store_file_hash_upsert_rejects_null_required_fields);
|
|
RUN_TEST(store_node_properties_json);
|
|
RUN_TEST(store_node_null_properties);
|
|
RUN_TEST(store_find_by_file_overlap);
|
|
RUN_TEST(store_find_by_qn_suffix_single);
|
|
RUN_TEST(store_find_by_qn_suffix_no_match);
|
|
RUN_TEST(store_find_by_qn_suffix_multiple);
|
|
RUN_TEST(store_find_by_qn_suffix_dot_boundary);
|
|
RUN_TEST(store_node_degree);
|
|
RUN_TEST(store_file_hash_batch);
|
|
RUN_TEST(store_file_hash_batch_equals_loop);
|
|
RUN_TEST(store_find_edges_by_url_path);
|
|
RUN_TEST(store_restore_from);
|
|
RUN_TEST(store_pragma_settings);
|
|
RUN_TEST(store_find_node_ids_by_qns);
|
|
RUN_TEST(store_node_null_project);
|
|
RUN_TEST(store_node_null_qn);
|
|
RUN_TEST(store_node_empty_strings);
|
|
RUN_TEST(store_find_by_id_not_found);
|
|
RUN_TEST(store_find_by_qn_not_found);
|
|
RUN_TEST(store_find_by_qn_any_cross_project);
|
|
RUN_TEST(store_find_by_name_any_cross_project);
|
|
RUN_TEST(store_find_by_file_no_match);
|
|
RUN_TEST(store_node_batch_upsert_zero);
|
|
RUN_TEST(store_node_batch_upsert_100);
|
|
RUN_TEST(store_delete_by_label_verify_remaining);
|
|
RUN_TEST(store_delete_by_file_verify_remaining);
|
|
RUN_TEST(store_node_upsert_updates_fields);
|
|
RUN_TEST(store_node_long_qn);
|
|
RUN_TEST(store_node_properties_special_chars);
|
|
RUN_TEST(store_delete_nodes_nonexistent);
|
|
RUN_TEST(store_count_nodes_unknown_project);
|
|
RUN_TEST(store_count_failed_read_is_not_zero);
|
|
}
|