fix(graph): IMPORTS edges keyed by local_name across buffer, store and dump
Distills PR #770 (graph-buffer dedup key) and completes the edge-uniqueness contract it left partial: the contract lives in THREE places, and changing only the buffer ships databases that violate their own UNIQUE constraint (PRAGMA integrity_check: non-unique entry in sqlite_autoindex_edges_1). A single 'import { A, B } from ./lib' produced ONE IMPORTS edge: the graph buffer dedups edges on (source_id, target_id, type) and merge-replaces properties on collision, so the second symbol silently overwrote the first. pass_calls.c, pass_usages.c, pass_semantic.c and pass_lsp_cross.c parse one local_name per IMPORTS edge for cross-file resolution, so the dropped symbol's calls failed to resolve too — not just "who imports X" queries. Changes, kept in sync across all three sites: - graph_buffer.c (from #770): make_edge_key() folds local_name into the dedup key for IMPORTS edges only; all three key call-sites updated. Hardened beyond #770: EDGE_KEY_BUF bumped to 256 and oversized local_names are re-keyed with an FNV-1a hash of the full name instead of being silently truncated (two long names sharing a prefix must not collide back into one edge). - store.c: edges gains local_name_gen, a VIRTUAL generated column (IMPORTS -> coalesce(json_extract(properties,'$.local_name'),''), else '' — NOT NULL because NULLs never conflict in a UNIQUE index); uniqueness widened to UNIQUE(source_id, target_id, type, local_name_gen) and the insert upsert's conflict target matches. init_schema probes pre-#768 DBs (no local_name_gen) and fails the open: SQLite cannot ALTER a table constraint in place, and an unopenable DB already takes the existing repair path — full index deletes + rebuilds, artifact import refuses and falls back to a reindex. Read-only query opens skip init_schema and keep working. - sqlite_writer.c/.h: dump DDL matches the widened schema; the hand-built sqlite_autoindex_edges_1 comparator and entry builder include the local_name column; CBMDumpEdge carries local_name extracted via real JSON parsing (yyjson) so index entries match json_extract's unescaped values exactly, per the idx_edges_url_path precedent. - artifact.h: CBM_ARTIFACT_SCHEMA_VERSION 1 -> 2 so old binaries refuse artifacts carrying the widened schema (their 3-column conflict target can no longer prepare against it). Tests (reproduce-first, all red on the unfixed code): - gbuf tier: multi-symbol import -> 2 IMPORTS edges; long-local_name truncation guard. - store tier: distinct local_name coexists as 2 rows, same local_name still upserts, non-IMPORTS dedup unchanged. - writer tier: dumped DB passes integrity_check with 2 sibling imports and exposes matching local_name_gen values. - end-to-end: TS fixture through the real pipeline -> 2 queryable IMPORTS edges AND integrity_check ok; with only the buffer half of the fix this test still fails on integrity_check, proving the schema half is required. Closes #768. Co-authored-by: Alexandros Pappas <11921291+apappas1129@users.noreply.github.com> Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
@@ -738,7 +738,7 @@ static uint8_t *build_node_record(const CBMDumpNode *n, int *out_len) {
|
||||
}
|
||||
|
||||
// Build an edges table record: (id, project, source_id, target_id, type, properties)
|
||||
// url_path_gen is a VIRTUAL generated column — NOT stored in the record.
|
||||
// url_path_gen and local_name_gen are VIRTUAL generated columns — NOT stored in the record.
|
||||
static uint8_t *build_edge_record(const CBMDumpEdge *e, int *out_len) {
|
||||
RecordBuilder r;
|
||||
rec_init(&r);
|
||||
@@ -993,16 +993,17 @@ static uint8_t *build_index_entry_text_int_text_rowid(const char *t1, int64_t va
|
||||
return cell;
|
||||
}
|
||||
|
||||
// Build UNIQUE index entry for (text, text) + rowid (e.g., nodes unique(project, qualified_name))
|
||||
// Build UNIQUE index entry for (int64, int64, text) + rowid (edges unique(source_id, target_id,
|
||||
// type))
|
||||
static uint8_t *build_index_entry_unique_2int_text_rowid(int64_t v1, int64_t v2, const char *text,
|
||||
int64_t rowid, int *out_len) {
|
||||
// Build UNIQUE index entry for (int64, int64, text, text) + rowid — edges
|
||||
// unique(source_id, target_id, type, local_name_gen) (#768).
|
||||
static uint8_t *build_index_entry_unique_2int_2text_rowid(int64_t v1, int64_t v2, const char *text,
|
||||
const char *text2, int64_t rowid,
|
||||
int *out_len) {
|
||||
RecordBuilder r;
|
||||
rec_init(&r);
|
||||
rec_add_int(&r, v1);
|
||||
rec_add_int(&r, v2);
|
||||
rec_add_text(&r, text);
|
||||
rec_add_text(&r, text2);
|
||||
rec_add_int(&r, rowid);
|
||||
int payload_len = 0;
|
||||
uint8_t *payload = rec_finalize(&r, &payload_len);
|
||||
@@ -1515,7 +1516,7 @@ static int cmp_edge_by_url_path(const void *a, const void *b) {
|
||||
return cmp_i64(g_sort_edges[ia].id, g_sort_edges[ib].id);
|
||||
}
|
||||
|
||||
// autoindex_edges_1: UNIQUE(source_id, target_id, type) + rowid
|
||||
// autoindex_edges_1: UNIQUE(source_id, target_id, type, local_name_gen) + rowid (#768)
|
||||
static int cmp_edge_by_src_tgt_type(const void *a, const void *b) {
|
||||
int ia = *(const int *)a;
|
||||
int ib = *(const int *)b;
|
||||
@@ -1531,6 +1532,10 @@ static int cmp_edge_by_src_tgt_type(const void *a, const void *b) {
|
||||
if (c) {
|
||||
return c;
|
||||
}
|
||||
c = strcmp(safe_str(g_sort_edges[ia].local_name), safe_str(g_sort_edges[ib].local_name));
|
||||
if (c) {
|
||||
return c;
|
||||
}
|
||||
return cmp_i64(g_sort_edges[ia].id, g_sort_edges[ib].id);
|
||||
}
|
||||
|
||||
@@ -1567,8 +1572,8 @@ static uint8_t *ecell_proj_source_type(const CBMDumpEdge *e, int *out_len) {
|
||||
return build_index_entry_text_int_text_rowid(e->project, e->source_id, e->type, e->id, out_len);
|
||||
}
|
||||
static uint8_t *ecell_src_tgt_type(const CBMDumpEdge *e, int *out_len) {
|
||||
return build_index_entry_unique_2int_text_rowid(e->source_id, e->target_id, e->type, e->id,
|
||||
out_len);
|
||||
return build_index_entry_unique_2int_2text_rowid(e->source_id, e->target_id, e->type,
|
||||
safe_str(e->local_name), e->id, out_len);
|
||||
}
|
||||
static uint8_t *ecell_url_path(const CBMDumpEdge *e, int *out_len) {
|
||||
const char *url = (e->url_path && e->url_path[0] != '\0') ? e->url_path : NULL;
|
||||
@@ -2135,13 +2140,20 @@ static int write_db_after_nodes(write_db_ctx_t *w, uint32_t nodes_root) {
|
||||
"CREATE INDEX idx_nodes_name ON nodes(project, name)"},
|
||||
{"index", "idx_nodes_file", "nodes", idx_nodes_file_root,
|
||||
"CREATE INDEX idx_nodes_file ON nodes(project, file_path)"},
|
||||
// local_name_gen + widened UNIQUE (#768): must stay semantically
|
||||
// identical to init_schema in src/store/store.c, and the hand-built
|
||||
// sqlite_autoindex_edges_1 (cmp_edge_by_src_tgt_type +
|
||||
// ecell_src_tgt_type) must produce exactly the values SQLite computes
|
||||
// for local_name_gen, or integrity_check fails on the dumped DB.
|
||||
{"table", "edges", "edges", edges_root,
|
||||
"CREATE TABLE edges (\n\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\tproject TEXT NOT "
|
||||
"NULL REFERENCES projects(name) ON DELETE CASCADE,\n\t\tsource_id INTEGER NOT NULL "
|
||||
"REFERENCES nodes(id) ON DELETE CASCADE,\n\t\ttarget_id INTEGER NOT NULL REFERENCES "
|
||||
"nodes(id) ON DELETE CASCADE,\n\t\ttype TEXT NOT NULL,\n\t\tproperties TEXT DEFAULT "
|
||||
"'{}',\n\t\turl_path_gen TEXT GENERATED ALWAYS AS "
|
||||
"(json_extract(properties,'$.url_path')),\n\t\tUNIQUE(source_id, target_id, type)\n\t)"},
|
||||
"(json_extract(properties,'$.url_path')),\n\t\tlocal_name_gen TEXT GENERATED ALWAYS AS "
|
||||
"(CASE WHEN type='IMPORTS' THEN coalesce(json_extract(properties,'$.local_name'),'') "
|
||||
"ELSE '' END),\n\t\tUNIQUE(source_id, target_id, type, local_name_gen)\n\t)"},
|
||||
{"index", "sqlite_autoindex_edges_1", "edges", autoindex_edges_root, NULL},
|
||||
{"index", "idx_edges_source", "edges", idx_edges_source_root,
|
||||
"CREATE INDEX idx_edges_source ON edges(source_id, type)"},
|
||||
|
||||
@@ -25,6 +25,11 @@ typedef struct {
|
||||
const char *type;
|
||||
const char *properties; // JSON string
|
||||
const char *url_path; // extracted from properties by Go (for idx_edges_url_path)
|
||||
const char *local_name; // for IMPORTS edges: the UNESCAPED
|
||||
// json_extract(properties,'$.local_name') value; ""/NULL
|
||||
// otherwise. Feeds sqlite_autoindex_edges_1 — must match
|
||||
// what SQLite computes for the local_name_gen column or
|
||||
// integrity_check reports the row missing from the index.
|
||||
} CBMDumpEdge;
|
||||
|
||||
typedef struct {
|
||||
|
||||
@@ -49,8 +49,11 @@ static inline void *intptr_to_ptr(intptr_t v) {
|
||||
|
||||
/* ── Internal types ──────────────────────────────────────────────── */
|
||||
|
||||
/* Edge key for dedup hash table — composite key as string "srcID:tgtID:type" */
|
||||
#define EDGE_KEY_BUF CBM_SZ_128
|
||||
/* Edge key for dedup hash table — composite key as string "srcID:tgtID:type",
|
||||
* plus ":local_name" for IMPORTS edges (#768). 256 bytes fit two int64s, the
|
||||
* type and a ~200-char local_name verbatim; longer local_names are re-keyed
|
||||
* with a hash of the full name in make_edge_key (never silently truncated). */
|
||||
#define EDGE_KEY_BUF CBM_SZ_256
|
||||
|
||||
/* Per-type or per-key edge list stored in hash tables as values */
|
||||
typedef CBM_DYN_ARRAY(const cbm_gbuf_edge_t *) edge_ptr_array_t;
|
||||
@@ -135,7 +138,52 @@ static void make_id_key(char *buf, size_t bufsz, int64_t id) {
|
||||
snprintf(buf, bufsz, "%lld", (long long)id);
|
||||
}
|
||||
|
||||
static void make_edge_key(char *buf, size_t bufsz, int64_t src, int64_t tgt, const char *type) {
|
||||
/* FNV-1a 64-bit over a byte slice — for re-keying oversized local_names. */
|
||||
static uint64_t fnv1a64(const char *s, size_t len) {
|
||||
uint64_t h = 14695981039346656037ULL;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
h ^= (uint8_t)s[i];
|
||||
h *= 1099511628211ULL;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
/* IMPORTS edges carry exactly one imported symbol's local_name (#768): two
|
||||
* named imports from the same specifier resolve to the same (source,
|
||||
* target) pair but are distinct symbols. Key on local_name too so the
|
||||
* second import doesn't dedup-collide with and overwrite the first —
|
||||
* every pass that walks IMPORTS edges (pass_calls.c, pass_usages.c,
|
||||
* pass_semantic.c, pass_lsp_cross.c) expects one local_name per edge, so
|
||||
* losing an edge here silently breaks cross-file call resolution for
|
||||
* whichever symbol got dropped, not just "who imports X" queries. Other
|
||||
* edge types keep the plain (source,target,type) key: collapsing repeat
|
||||
* edges of the same type between the same two nodes (e.g. multiple call
|
||||
* sites) into one is the existing, intended dedup behavior there.
|
||||
*
|
||||
* A local_name too long for the key buffer is re-keyed with an FNV-1a hash
|
||||
* of the FULL name instead of being truncated — a truncated key would
|
||||
* collide two long names sharing a prefix and silently drop an edge again.
|
||||
* The hash key is prefixed with byte 0x01, which cannot appear in the raw
|
||||
* JSON slice (control characters must be \u-escaped in JSON), so hash keys
|
||||
* can never collide with verbatim keys. */
|
||||
static void make_edge_key(char *buf, size_t bufsz, int64_t src, int64_t tgt, const char *type,
|
||||
const char *properties_json) {
|
||||
if (properties_json && strcmp(type, "IMPORTS") == 0) {
|
||||
static const char local_name_key[] = "\"local_name\":\"";
|
||||
const char *ln = strstr(properties_json, local_name_key);
|
||||
if (ln) {
|
||||
ln += sizeof(local_name_key) - 1;
|
||||
const char *end = strchr(ln, '"');
|
||||
size_t ln_len = end ? (size_t)(end - ln) : strlen(ln);
|
||||
int n = snprintf(buf, bufsz, "%lld:%lld:%s:%.*s", (long long)src, (long long)tgt, type,
|
||||
(int)ln_len, ln);
|
||||
if (n < 0 || (size_t)n >= bufsz) {
|
||||
snprintf(buf, bufsz, "%lld:%lld:%s:\x01%016llx", (long long)src, (long long)tgt,
|
||||
type, (unsigned long long)fnv1a64(ln, ln_len));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
snprintf(buf, bufsz, "%lld:%lld:%s", (long long)src, (long long)tgt, type);
|
||||
}
|
||||
|
||||
@@ -244,7 +292,7 @@ static void remove_node_from_ptr_array(node_ptr_array_t *arr, int64_t node_id) {
|
||||
static void unindex_edge(cbm_gbuf_t *gb, const cbm_gbuf_edge_t *e) {
|
||||
char key[EDGE_KEY_BUF];
|
||||
|
||||
make_edge_key(key, sizeof(key), e->source_id, e->target_id, e->type);
|
||||
make_edge_key(key, sizeof(key), e->source_id, e->target_id, e->type, e->properties_json);
|
||||
const char *ekey = cbm_ht_get_key(gb->edge_by_key, key);
|
||||
cbm_ht_delete(gb->edge_by_key, key);
|
||||
free((void *)ekey);
|
||||
@@ -919,7 +967,7 @@ int64_t cbm_gbuf_insert_edge(cbm_gbuf_t *gb, int64_t source_id, int64_t target_i
|
||||
|
||||
/* Check for dedup */
|
||||
char key[EDGE_KEY_BUF];
|
||||
make_edge_key(key, sizeof(key), source_id, target_id, type);
|
||||
make_edge_key(key, sizeof(key), source_id, target_id, type, properties_json);
|
||||
|
||||
cbm_gbuf_edge_t *existing = cbm_ht_get(gb->edge_by_key, key);
|
||||
if (existing) {
|
||||
@@ -1032,7 +1080,8 @@ int cbm_gbuf_delete_edges_by_type(cbm_gbuf_t *gb, const char *type) {
|
||||
cbm_gbuf_edge_t *e = gb->edges.items[i];
|
||||
if (strcmp(e->type, type) == 0) {
|
||||
char key[EDGE_KEY_BUF];
|
||||
make_edge_key(key, sizeof(key), e->source_id, e->target_id, e->type);
|
||||
make_edge_key(key, sizeof(key), e->source_id, e->target_id, e->type,
|
||||
e->properties_json);
|
||||
const char *ekey = cbm_ht_get_key(gb->edge_by_key, key);
|
||||
cbm_ht_delete(gb->edge_by_key, key);
|
||||
free((void *)ekey);
|
||||
@@ -1182,15 +1231,16 @@ int cbm_gbuf_merge(cbm_gbuf_t *dst, cbm_gbuf_t *src) {
|
||||
|
||||
/* ── Dump / Flush ────────────────────────────────────────────────── */
|
||||
|
||||
/* Extract url_path value from a properties JSON string.
|
||||
/* Extract a string property from a properties JSON string.
|
||||
* Returns heap-allocated string or NULL. Caller must free.
|
||||
* Parses real JSON: the dump writer feeds this value into idx_edges_url_path,
|
||||
* whose backing column is GENERATED AS json_extract(properties,'$.url_path').
|
||||
* Parses real JSON: the dump writer feeds these values into indexes whose
|
||||
* backing columns are GENERATED AS json_extract(properties,'$.<key>').
|
||||
* Naive byte slicing returned the ESCAPED text (and cut at embedded \\")
|
||||
* while json_extract yields the unescaped value — the mismatch left rows
|
||||
* "missing from index idx_edges_url_path" under PRAGMA integrity_check. */
|
||||
static char *extract_url_path(const char *props) {
|
||||
if (!props || !strstr(props, "\"url_path\"")) {
|
||||
* "missing from index idx_edges_url_path" under PRAGMA integrity_check.
|
||||
* key_quoted ("\"key\"") is a fast pre-filter to skip the JSON parse. */
|
||||
static char *extract_prop_string(const char *props, const char *key_quoted, const char *key) {
|
||||
if (!props || !strstr(props, key_quoted)) {
|
||||
return NULL;
|
||||
}
|
||||
yyjson_doc *doc = yyjson_read(props, strlen(props), 0);
|
||||
@@ -1198,7 +1248,7 @@ static char *extract_url_path(const char *props) {
|
||||
return NULL;
|
||||
}
|
||||
char *out = NULL;
|
||||
yyjson_val *v = yyjson_obj_get(yyjson_doc_get_root(doc), "url_path");
|
||||
yyjson_val *v = yyjson_obj_get(yyjson_doc_get_root(doc), key);
|
||||
if (v && yyjson_is_str(v)) {
|
||||
const char *sv = yyjson_get_str(v);
|
||||
out = cbm_strndup(sv, strlen(sv));
|
||||
@@ -1207,6 +1257,16 @@ static char *extract_url_path(const char *props) {
|
||||
return out;
|
||||
}
|
||||
|
||||
static char *extract_url_path(const char *props) {
|
||||
return extract_prop_string(props, "\"url_path\"", "url_path");
|
||||
}
|
||||
|
||||
/* local_name feeds the hand-built sqlite_autoindex_edges_1 — its backing
|
||||
* column local_name_gen is GENERATED only for IMPORTS edges (#768). */
|
||||
static char *extract_local_name(const char *props) {
|
||||
return extract_prop_string(props, "\"local_name\"", "local_name");
|
||||
}
|
||||
|
||||
/* Remap a temp edge ID to its final sequential ID, or 0 if out of range. */
|
||||
static int64_t remap_id(const int64_t *temp_to_final, int64_t max_temp_id, int64_t temp_id) {
|
||||
return (temp_id < max_temp_id) ? temp_to_final[temp_id] : 0;
|
||||
@@ -1265,9 +1325,11 @@ static CBMDumpNode *build_dump_nodes(cbm_gbuf_t *gb, int live_count, int64_t *te
|
||||
return dump_nodes;
|
||||
}
|
||||
|
||||
/* Build dump-ready edge array with remapped IDs. Returns url_paths via out param. */
|
||||
/* Build dump-ready edge array with remapped IDs. Returns url_paths and
|
||||
* local_names (heap string arrays owned by the caller) via out params. */
|
||||
static CBMDumpEdge *build_dump_edges(cbm_gbuf_t *gb, const int64_t *temp_to_final,
|
||||
int64_t max_temp_id, int *out_count, char ***out_url_paths) {
|
||||
int64_t max_temp_id, int *out_count, char ***out_url_paths,
|
||||
char ***out_local_names) {
|
||||
/* Count valid edges (both endpoints resolved) */
|
||||
int valid_edges = 0;
|
||||
for (int i = 0; i < gb->edges.count; i++) {
|
||||
@@ -1281,6 +1343,7 @@ static CBMDumpEdge *build_dump_edges(cbm_gbuf_t *gb, const int64_t *temp_to_fina
|
||||
CBMDumpEdge *dump_edges =
|
||||
malloc((size_t)(valid_edges > 0 ? valid_edges : SKIP_ONE) * sizeof(CBMDumpEdge));
|
||||
char **url_paths = calloc((size_t)(valid_edges > 0 ? valid_edges : SKIP_ONE), sizeof(char *));
|
||||
char **local_names = calloc((size_t)(valid_edges > 0 ? valid_edges : SKIP_ONE), sizeof(char *));
|
||||
int idx = 0;
|
||||
|
||||
for (int i = 0; i < gb->edges.count; i++) {
|
||||
@@ -1294,6 +1357,12 @@ static CBMDumpEdge *build_dump_edges(cbm_gbuf_t *gb, const int64_t *temp_to_fina
|
||||
char *url_path = extract_url_path(e->properties_json);
|
||||
url_paths[idx] = url_path;
|
||||
|
||||
/* IMPORTS only — mirrors the local_name_gen CASE in the edges DDL. */
|
||||
char *local_name = (e->type && strcmp(e->type, "IMPORTS") == 0)
|
||||
? extract_local_name(e->properties_json)
|
||||
: NULL;
|
||||
local_names[idx] = local_name;
|
||||
|
||||
const char *props = e->properties_json ? e->properties_json : "{}";
|
||||
dump_edges[idx] = (CBMDumpEdge){
|
||||
.id = idx + SKIP_ONE,
|
||||
@@ -1303,12 +1372,14 @@ static CBMDumpEdge *build_dump_edges(cbm_gbuf_t *gb, const int64_t *temp_to_fina
|
||||
.type = e->type,
|
||||
.properties = props,
|
||||
.url_path = url_path ? url_path : "",
|
||||
.local_name = local_name ? local_name : "",
|
||||
};
|
||||
idx++;
|
||||
}
|
||||
|
||||
*out_count = idx;
|
||||
*out_url_paths = url_paths;
|
||||
*out_local_names = local_names;
|
||||
return dump_edges;
|
||||
}
|
||||
|
||||
@@ -1360,12 +1431,15 @@ static void log_dump_summary(int node_count, int edge_count) {
|
||||
cbm_log_info("gbuf.dump", "nodes", b1, "edges", b2);
|
||||
}
|
||||
|
||||
static void free_dump_resources(char **url_paths, int edge_count, CBMDumpEdge *dump_edges,
|
||||
CBMDumpNode *dump_nodes, int64_t *temp_to_final) {
|
||||
static void free_dump_resources(char **url_paths, char **local_names, int edge_count,
|
||||
CBMDumpEdge *dump_edges, CBMDumpNode *dump_nodes,
|
||||
int64_t *temp_to_final) {
|
||||
for (int i = 0; i < edge_count; i++) {
|
||||
free(url_paths[i]);
|
||||
free(local_names[i]);
|
||||
}
|
||||
free(url_paths);
|
||||
free(local_names);
|
||||
free(dump_edges);
|
||||
free(dump_nodes);
|
||||
free(temp_to_final);
|
||||
@@ -1469,10 +1543,12 @@ int cbm_gbuf_dump_to_sqlite(cbm_gbuf_t *gb, const char *path) {
|
||||
|
||||
int edge_idx = 0;
|
||||
char **url_paths = NULL;
|
||||
char **local_names = NULL;
|
||||
CBMDumpEdge *dump_edges = NULL;
|
||||
if (rc == 0) {
|
||||
CBM_PROF_START(t_build_edges);
|
||||
dump_edges = build_dump_edges(gb, temp_to_final, max_temp_id, &edge_idx, &url_paths);
|
||||
dump_edges =
|
||||
build_dump_edges(gb, temp_to_final, max_temp_id, &edge_idx, &url_paths, &local_names);
|
||||
CBM_PROF_END_N("dump", "3_build_dump_edges", t_build_edges, edge_idx);
|
||||
release_and_remap_vectors(gb, temp_to_final, max_temp_id);
|
||||
}
|
||||
@@ -1489,7 +1565,7 @@ int cbm_gbuf_dump_to_sqlite(cbm_gbuf_t *gb, const char *path) {
|
||||
}
|
||||
|
||||
log_dump_summary(node_idx, edge_idx);
|
||||
free_dump_resources(url_paths, edge_idx, dump_edges, dump_nodes, temp_to_final);
|
||||
free_dump_resources(url_paths, local_names, edge_idx, dump_edges, dump_nodes, temp_to_final);
|
||||
free(src_nodes);
|
||||
return rc;
|
||||
}
|
||||
|
||||
@@ -12,8 +12,11 @@
|
||||
#include <stdbool.h>
|
||||
|
||||
/* Schema version — increment when DB schema changes (new tables/indexes).
|
||||
* Import refuses artifacts with schema_version > current. */
|
||||
#define CBM_ARTIFACT_SCHEMA_VERSION 1
|
||||
* Import refuses artifacts with schema_version > current.
|
||||
* v2: edges uniqueness widened to (source_id, target_id, type,
|
||||
* local_name_gen) so sibling named imports coexist (#768) — old
|
||||
* binaries cannot upsert against the widened constraint. */
|
||||
#define CBM_ARTIFACT_SCHEMA_VERSION 2
|
||||
|
||||
#define CBM_ARTIFACT_FILENAME "graph.db.zst"
|
||||
#define CBM_ARTIFACT_META "artifact.json"
|
||||
|
||||
+34
-2
@@ -243,6 +243,14 @@ static int init_schema(cbm_store_t *s) {
|
||||
" properties TEXT DEFAULT '{}',"
|
||||
" UNIQUE(project, qualified_name)"
|
||||
");"
|
||||
/* local_name_gen (#768): IMPORTS edges carry one imported symbol's
|
||||
* local_name each, so uniqueness must discriminate on it — two named
|
||||
* imports from the same specifier are distinct edges. Non-IMPORTS
|
||||
* edges get '' (NOT NULL: NULLs never conflict in a UNIQUE index,
|
||||
* which would break their dedup entirely). Mirrors the graph-buffer
|
||||
* dedup key (make_edge_key) and the raw dump writer's DDL + hand-
|
||||
* built sqlite_autoindex_edges_1 (internal/cbm/sqlite_writer.c) —
|
||||
* keep all three in sync. */
|
||||
"CREATE TABLE IF NOT EXISTS edges ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" project TEXT NOT NULL REFERENCES projects(name) ON DELETE CASCADE,"
|
||||
@@ -251,7 +259,9 @@ static int init_schema(cbm_store_t *s) {
|
||||
" type TEXT NOT NULL,"
|
||||
" properties TEXT DEFAULT '{}',"
|
||||
" url_path_gen TEXT GENERATED ALWAYS AS (json_extract(properties,'$.url_path')),"
|
||||
" UNIQUE(source_id, target_id, type)"
|
||||
" local_name_gen TEXT GENERATED ALWAYS AS (CASE WHEN type='IMPORTS'"
|
||||
" THEN coalesce(json_extract(properties,'$.local_name'),'') ELSE '' END),"
|
||||
" UNIQUE(source_id, target_id, type, local_name_gen)"
|
||||
");"
|
||||
"CREATE TABLE IF NOT EXISTS project_summaries ("
|
||||
" project TEXT PRIMARY KEY,"
|
||||
@@ -266,6 +276,25 @@ static int init_schema(cbm_store_t *s) {
|
||||
return rc;
|
||||
}
|
||||
|
||||
/* Schema-compat probe (#768): DBs created before the local_name_gen
|
||||
* discriminator still enforce UNIQUE(source_id,target_id,type) and lack
|
||||
* the column — the widened upsert in cbm_store_insert_edge can neither
|
||||
* prepare nor let two named imports coexist against them. SQLite cannot
|
||||
* ALTER a table-level UNIQUE constraint in place, so fail the open:
|
||||
* callers already treat an unopenable DB as incompatible (a full index
|
||||
* deletes + rebuilds it, artifact import refuses and falls back to a
|
||||
* reindex). Read-only query opens skip init_schema and keep working. */
|
||||
{
|
||||
sqlite3_stmt *probe = NULL;
|
||||
if (sqlite3_prepare_v2(s->db, "SELECT local_name_gen FROM edges LIMIT 0;", CBM_NOT_FOUND,
|
||||
&probe, NULL) != SQLITE_OK) {
|
||||
cbm_log_warn("store.schema", "result", "incompatible", "missing",
|
||||
"edges.local_name_gen");
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
sqlite3_finalize(probe);
|
||||
}
|
||||
|
||||
/* FTS5 contentless virtual table for BM25 full-text search.
|
||||
* Contentless (content='') means FTS5 stores only the inverted index,
|
||||
* not a copy of the source text — required for camelCase tokenization
|
||||
@@ -1479,11 +1508,14 @@ int cbm_store_upsert_node_batch(cbm_store_t *s, const cbm_node_t *nodes, int cou
|
||||
/* ── Edge CRUD ──────────────────────────────────────────────────── */
|
||||
|
||||
int64_t cbm_store_insert_edge(cbm_store_t *s, const cbm_edge_t *e) {
|
||||
/* Conflict target includes local_name_gen (#768) so IMPORTS edges with
|
||||
* different local_name coexist while re-inserting the same import still
|
||||
* upserts. Must match the table's UNIQUE constraint in init_schema. */
|
||||
sqlite3_stmt *stmt =
|
||||
prepare_cached(s, &s->stmt_insert_edge,
|
||||
"INSERT INTO edges (project, source_id, target_id, type, properties) "
|
||||
"VALUES (?1, ?2, ?3, ?4, ?5) "
|
||||
"ON CONFLICT(source_id, target_id, type) DO UPDATE SET "
|
||||
"ON CONFLICT(source_id, target_id, type, local_name_gen) DO UPDATE SET "
|
||||
"properties = json_patch(properties, ?5) "
|
||||
"RETURNING id;");
|
||||
if (!stmt) {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "test_framework.h"
|
||||
#include "graph_buffer/graph_buffer.h"
|
||||
#include "store/store.h"
|
||||
#include <string.h>
|
||||
|
||||
/* ── Node operations ───────────────────────────────────────────── */
|
||||
|
||||
@@ -173,6 +174,85 @@ TEST(gbuf_edge_dedup) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* #768: two named imports from the same specifier (same source, same target
|
||||
* file) must produce two distinct IMPORTS edges, keyed apart by local_name --
|
||||
* not collapse into one edge that silently drops whichever import lost the
|
||||
* dedup race. Re-inserting the SAME local_name (e.g. an idempotent re-index)
|
||||
* must still dedup to one edge. */
|
||||
TEST(gbuf_imports_multi_symbol_dedup) {
|
||||
cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp");
|
||||
int64_t consumer =
|
||||
cbm_gbuf_upsert_node(gb, "File", "consumer.ts", "pkg.consumer", "consumer.ts", 1, 1, "{}");
|
||||
int64_t lib = cbm_gbuf_upsert_node(gb, "File", "lib.ts", "pkg.lib", "lib.ts", 1, 1, "{}");
|
||||
|
||||
int64_t eid_a = cbm_gbuf_insert_edge(gb, consumer, lib, "IMPORTS", "{\"local_name\":\"A\"}");
|
||||
int64_t eid_b = cbm_gbuf_insert_edge(gb, consumer, lib, "IMPORTS", "{\"local_name\":\"B\"}");
|
||||
ASSERT_GT(eid_a, 0);
|
||||
ASSERT_GT(eid_b, 0);
|
||||
ASSERT_NEQ(eid_a, eid_b); /* distinct symbols -> distinct edges */
|
||||
ASSERT_EQ(cbm_gbuf_edge_count(gb), 2);
|
||||
|
||||
const cbm_gbuf_edge_t **edges = NULL;
|
||||
int count = 0;
|
||||
cbm_gbuf_find_edges_by_source_type(gb, consumer, "IMPORTS", &edges, &count);
|
||||
ASSERT_EQ(count, 2);
|
||||
ASSERT_TRUE(strstr(edges[0]->properties_json, "\"local_name\":\"A\"") != NULL ||
|
||||
strstr(edges[1]->properties_json, "\"local_name\":\"A\"") != NULL);
|
||||
ASSERT_TRUE(strstr(edges[0]->properties_json, "\"local_name\":\"B\"") != NULL ||
|
||||
strstr(edges[1]->properties_json, "\"local_name\":\"B\"") != NULL);
|
||||
|
||||
/* Re-inserting the same symbol (idempotent re-index) still dedups. */
|
||||
int64_t eid_a_again =
|
||||
cbm_gbuf_insert_edge(gb, consumer, lib, "IMPORTS", "{\"local_name\":\"A\"}");
|
||||
ASSERT_EQ(eid_a_again, eid_a);
|
||||
ASSERT_EQ(cbm_gbuf_edge_count(gb), 2);
|
||||
|
||||
cbm_gbuf_free(gb);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* #768 hardening: the dedup key lives in a fixed-size stack buffer. Two long
|
||||
* local_names sharing a prefix must NOT silently collide when the verbatim
|
||||
* key would be truncated — the key builder re-keys oversized local_names with
|
||||
* a hash of the FULL name. Determinism must hold: re-inserting the same long
|
||||
* name still dedups to the same edge. */
|
||||
TEST(gbuf_imports_long_local_name_no_collision) {
|
||||
cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp");
|
||||
int64_t consumer =
|
||||
cbm_gbuf_upsert_node(gb, "File", "consumer.ts", "pkg.consumer", "consumer.ts", 1, 1, "{}");
|
||||
int64_t lib = cbm_gbuf_upsert_node(gb, "File", "lib.ts", "pkg.lib", "lib.ts", 1, 1, "{}");
|
||||
|
||||
/* 300-char shared prefix, distinct 4-char tails — a truncated verbatim
|
||||
* key keeps only the shared prefix and would merge the two edges. */
|
||||
enum { LONG_PREFIX = 300 };
|
||||
char name_a[LONG_PREFIX + 8];
|
||||
char name_b[LONG_PREFIX + 8];
|
||||
memset(name_a, 'x', LONG_PREFIX);
|
||||
memset(name_b, 'x', LONG_PREFIX);
|
||||
memcpy(name_a + LONG_PREFIX, "AAAA", 5);
|
||||
memcpy(name_b + LONG_PREFIX, "BBBB", 5);
|
||||
|
||||
char props_a[512];
|
||||
char props_b[512];
|
||||
snprintf(props_a, sizeof(props_a), "{\"local_name\":\"%s\"}", name_a);
|
||||
snprintf(props_b, sizeof(props_b), "{\"local_name\":\"%s\"}", name_b);
|
||||
|
||||
int64_t eid_a = cbm_gbuf_insert_edge(gb, consumer, lib, "IMPORTS", props_a);
|
||||
int64_t eid_b = cbm_gbuf_insert_edge(gb, consumer, lib, "IMPORTS", props_b);
|
||||
ASSERT_GT(eid_a, 0);
|
||||
ASSERT_GT(eid_b, 0);
|
||||
ASSERT_NEQ(eid_a, eid_b); /* prefix-sharing long names stay distinct */
|
||||
ASSERT_EQ(cbm_gbuf_edge_count(gb), 2);
|
||||
|
||||
/* Hash re-keying is deterministic: same long name dedups. */
|
||||
int64_t eid_a_again = cbm_gbuf_insert_edge(gb, consumer, lib, "IMPORTS", props_a);
|
||||
ASSERT_EQ(eid_a_again, eid_a);
|
||||
ASSERT_EQ(cbm_gbuf_edge_count(gb), 2);
|
||||
|
||||
cbm_gbuf_free(gb);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(gbuf_find_edges_by_source_type) {
|
||||
cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp");
|
||||
int64_t a = cbm_gbuf_upsert_node(gb, "Function", "a", "pkg.a", "f.go", 1, 5, "{}");
|
||||
@@ -367,9 +447,9 @@ TEST(gbuf_upsert_empty_qn) {
|
||||
TEST(gbuf_upsert_same_qn_updates_all_fields) {
|
||||
cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp");
|
||||
int64_t id1 = cbm_gbuf_upsert_node(gb, "Function", "old_name", "pkg.fn", "old.go", 1, 10,
|
||||
"{\"k\":\"v1\"}");
|
||||
"{\"k\":\"v1\"}");
|
||||
int64_t id2 = cbm_gbuf_upsert_node(gb, "Method", "new_name", "pkg.fn", "new.go", 20, 30,
|
||||
"{\"k\":\"v2\"}");
|
||||
"{\"k\":\"v2\"}");
|
||||
ASSERT_EQ(id1, id2);
|
||||
ASSERT_EQ(cbm_gbuf_node_count(gb), 1);
|
||||
|
||||
@@ -637,7 +717,8 @@ TEST(gbuf_merge_overlapping_qns) {
|
||||
cbm_gbuf_t *src = cbm_gbuf_new("test", "/tmp");
|
||||
|
||||
/* dst has node with QN "pkg.fn" */
|
||||
cbm_gbuf_upsert_node(dst, "Function", "fn_old", "pkg.fn", "old.go", 1, 10, "{\"from\":\"dst\"}");
|
||||
cbm_gbuf_upsert_node(dst, "Function", "fn_old", "pkg.fn", "old.go", 1, 10,
|
||||
"{\"from\":\"dst\"}");
|
||||
cbm_gbuf_upsert_node(dst, "Function", "unique_dst", "pkg.unique_dst", "u.go", 1, 5, "{}");
|
||||
|
||||
/* src has same QN with different fields — src should win */
|
||||
@@ -941,6 +1022,8 @@ SUITE(graph_buffer) {
|
||||
RUN_TEST(gbuf_delete_by_label);
|
||||
RUN_TEST(gbuf_insert_edge);
|
||||
RUN_TEST(gbuf_edge_dedup);
|
||||
RUN_TEST(gbuf_imports_multi_symbol_dedup);
|
||||
RUN_TEST(gbuf_imports_long_local_name_no_collision);
|
||||
RUN_TEST(gbuf_find_edges_by_source_type);
|
||||
RUN_TEST(gbuf_find_edges_by_target_type);
|
||||
RUN_TEST(gbuf_find_edges_by_type);
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
#include <unistd.h>
|
||||
#include "graph_buffer/graph_buffer.h"
|
||||
#include "yyjson/yyjson.h"
|
||||
#include "sqlite3.h" /* vendored/sqlite3 — PRAGMA integrity_check on dumped DBs */
|
||||
|
||||
/* ── Helper: create temp test repo with known layout ───────────── */
|
||||
|
||||
@@ -1693,6 +1694,61 @@ TEST(pipeline_python_project) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* #768 end-to-end: `import { A, B } from './lib'` must survive the REAL
|
||||
* pipeline (extract -> gbuf dedup -> raw SQLite dump) as TWO IMPORTS edges
|
||||
* with distinct local_name — and the dumped DB must satisfy its own schema.
|
||||
* With only the graph-buffer half of the fix, both edges reach the dump but
|
||||
* violate an unwidened UNIQUE(source_id,target_id,type), which PRAGMA
|
||||
* integrity_check flags as a non-unique autoindex entry. */
|
||||
TEST(pipeline_imports_multi_symbol_edges) {
|
||||
const char *files[] = {"consumer.ts", "lib.ts"};
|
||||
const char *contents[] = {
|
||||
"import { A, B } from './lib';\n\nexport function useBoth() {\n return A() + B();\n}\n",
|
||||
"export function A() {\n return 1;\n}\nexport function B() {\n return 2;\n}\n"};
|
||||
|
||||
if (setup_lang_repo(files, contents, 2) != 0)
|
||||
FAIL("tmpdir");
|
||||
char db[512];
|
||||
snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir);
|
||||
|
||||
cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL);
|
||||
ASSERT_NOT_NULL(p);
|
||||
ASSERT_EQ(cbm_pipeline_run(p), 0);
|
||||
|
||||
/* The dumped DB must pass SQLite's own full integrity check — this is
|
||||
* what catches a buffer-only fix shipping DBs that violate their own
|
||||
* UNIQUE constraint. */
|
||||
sqlite3 *raw = NULL;
|
||||
ASSERT_EQ(sqlite3_open(db, &raw), SQLITE_OK);
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
sqlite3_prepare_v2(raw, "PRAGMA integrity_check", -1, &stmt, NULL);
|
||||
ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW);
|
||||
const char *integrity = (const char *)sqlite3_column_text(stmt, 0);
|
||||
ASSERT_STR_EQ(integrity, "ok");
|
||||
sqlite3_finalize(stmt);
|
||||
sqlite3_close(raw);
|
||||
|
||||
/* Both named imports must be queryable as separate IMPORTS edges. */
|
||||
cbm_store_t *s = cbm_store_open_path(db);
|
||||
ASSERT_NOT_NULL(s);
|
||||
const char *proj = cbm_pipeline_project_name(p);
|
||||
|
||||
cbm_edge_t *edges = NULL;
|
||||
int count = 0;
|
||||
ASSERT_EQ(cbm_store_find_edges_by_type(s, proj, "IMPORTS", &edges, &count), CBM_STORE_OK);
|
||||
ASSERT_EQ(count, 2);
|
||||
ASSERT_TRUE(strstr(edges[0].properties_json, "\"local_name\":\"A\"") != NULL ||
|
||||
strstr(edges[1].properties_json, "\"local_name\":\"A\"") != NULL);
|
||||
ASSERT_TRUE(strstr(edges[0].properties_json, "\"local_name\":\"B\"") != NULL ||
|
||||
strstr(edges[1].properties_json, "\"local_name\":\"B\"") != NULL);
|
||||
cbm_store_free_edges(edges, count);
|
||||
|
||||
cbm_store_close(s);
|
||||
cbm_pipeline_free(p);
|
||||
teardown_lang_repo();
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(pipeline_go_cross_package_call) {
|
||||
/* Port of TestGoCrossPackageCallViaImport */
|
||||
const char *files[] = {"main.go", "svc/handler.go"};
|
||||
@@ -6197,6 +6253,7 @@ SUITE(pipeline) {
|
||||
RUN_TEST(usages_kotlin_no_duplicate_calls);
|
||||
/* Language integration tests */
|
||||
RUN_TEST(pipeline_python_project);
|
||||
RUN_TEST(pipeline_imports_multi_symbol_edges);
|
||||
RUN_TEST(pipeline_go_cross_package_call);
|
||||
RUN_TEST(pipeline_python_cross_module_call);
|
||||
RUN_TEST(pipeline_go_type_classification);
|
||||
|
||||
@@ -121,6 +121,96 @@ TEST(sw_minimal_data) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* #768 schema half: two IMPORTS edges between the same two files, keyed apart
|
||||
* by local_name, must be representable in the dumped DB. The dump's edges DDL
|
||||
* and the hand-built sqlite_autoindex_edges_1 must carry the local_name
|
||||
* discriminator — with the old UNIQUE(source_id,target_id,type) the two rows
|
||||
* violate the dump's own constraint and PRAGMA integrity_check flags a
|
||||
* non-unique autoindex entry. */
|
||||
TEST(sw_imports_local_name_unique) {
|
||||
char path[256];
|
||||
ASSERT_EQ(make_temp_db(path, sizeof(path)), 0);
|
||||
|
||||
CBMDumpNode nodes[2] = {
|
||||
{.id = 1,
|
||||
.project = "test",
|
||||
.label = "File",
|
||||
.name = "consumer.ts",
|
||||
.qualified_name = "test.consumer.ts.__file__",
|
||||
.file_path = "consumer.ts",
|
||||
.start_line = 1,
|
||||
.end_line = 3,
|
||||
.properties = "{}"},
|
||||
{.id = 2,
|
||||
.project = "test",
|
||||
.label = "File",
|
||||
.name = "lib.ts",
|
||||
.qualified_name = "test.lib.ts.__file__",
|
||||
.file_path = "lib.ts",
|
||||
.start_line = 1,
|
||||
.end_line = 2,
|
||||
.properties = "{}"},
|
||||
};
|
||||
CBMDumpEdge edges[2] = {
|
||||
{.id = 1,
|
||||
.project = "test",
|
||||
.source_id = 1,
|
||||
.target_id = 2,
|
||||
.type = "IMPORTS",
|
||||
.properties = "{\"local_name\":\"A\"}",
|
||||
.url_path = "",
|
||||
.local_name = "A"},
|
||||
{.id = 2,
|
||||
.project = "test",
|
||||
.source_id = 1,
|
||||
.target_id = 2,
|
||||
.type = "IMPORTS",
|
||||
.properties = "{\"local_name\":\"B\"}",
|
||||
.url_path = "",
|
||||
.local_name = "B"},
|
||||
};
|
||||
|
||||
int rc = cbm_write_db(path, "test", "/tmp/test", "2026-03-14T00:00:00Z", nodes, 2, edges, 2,
|
||||
NULL, 0, NULL, 0);
|
||||
ASSERT_EQ(rc, 0);
|
||||
|
||||
sqlite3 *db = NULL;
|
||||
rc = sqlite3_open(path, &db);
|
||||
ASSERT_EQ(rc, SQLITE_OK);
|
||||
|
||||
/* The dumped DB must satisfy its own UNIQUE constraint. */
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
sqlite3_prepare_v2(db, "PRAGMA integrity_check", -1, &stmt, NULL);
|
||||
rc = sqlite3_step(stmt);
|
||||
ASSERT_EQ(rc, SQLITE_ROW);
|
||||
const char *integrity = (const char *)sqlite3_column_text(stmt, 0);
|
||||
ASSERT_STR_EQ(integrity, "ok");
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
/* Both sibling imports are queryable. */
|
||||
sqlite3_prepare_v2(db,
|
||||
"SELECT COUNT(*) FROM edges "
|
||||
"WHERE source_id=1 AND target_id=2 AND type='IMPORTS'",
|
||||
-1, &stmt, NULL);
|
||||
sqlite3_step(stmt);
|
||||
ASSERT_EQ(sqlite3_column_int(stmt, 0), 2);
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
/* The generated discriminator matches what the writer hand-indexed. */
|
||||
sqlite3_prepare_v2(db, "SELECT local_name_gen FROM edges ORDER BY id", -1, &stmt, NULL);
|
||||
rc = sqlite3_step(stmt);
|
||||
ASSERT_EQ(rc, SQLITE_ROW);
|
||||
ASSERT_STR_EQ((const char *)sqlite3_column_text(stmt, 0), "A");
|
||||
rc = sqlite3_step(stmt);
|
||||
ASSERT_EQ(rc, SQLITE_ROW);
|
||||
ASSERT_STR_EQ((const char *)sqlite3_column_text(stmt, 0), "B");
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
sqlite3_close(db);
|
||||
unlink(path);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(sw_scale_and_indexes) {
|
||||
char path[256];
|
||||
ASSERT_EQ(make_temp_db(path, sizeof(path)), 0);
|
||||
@@ -519,6 +609,7 @@ TEST(sw_oversized_node) {
|
||||
|
||||
SUITE(sqlite_writer) {
|
||||
RUN_TEST(sw_minimal_data);
|
||||
RUN_TEST(sw_imports_local_name_unique);
|
||||
RUN_TEST(sw_scale_and_indexes);
|
||||
RUN_TEST(sw_long_index_keys_overflow);
|
||||
RUN_TEST(sw_empty);
|
||||
|
||||
@@ -78,6 +78,67 @@ TEST(store_edge_dedup) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* #768 schema half: the edges UNIQUE constraint must discriminate IMPORTS
|
||||
* edges by local_name so two named imports from the same specifier coexist
|
||||
* as two rows, while re-inserting the SAME (src,tgt,IMPORTS,local_name)
|
||||
* still upserts onto one row. Non-IMPORTS uniqueness is unchanged. */
|
||||
TEST(store_imports_edge_local_name_coexist) {
|
||||
int64_t ids[2];
|
||||
cbm_store_t *s = setup_store_with_nodes(2, ids);
|
||||
|
||||
cbm_edge_t ea = {.project = "test",
|
||||
.source_id = ids[0],
|
||||
.target_id = ids[1],
|
||||
.type = "IMPORTS",
|
||||
.properties_json = "{\"local_name\":\"A\"}"};
|
||||
cbm_edge_t eb = {.project = "test",
|
||||
.source_id = ids[0],
|
||||
.target_id = ids[1],
|
||||
.type = "IMPORTS",
|
||||
.properties_json = "{\"local_name\":\"B\"}"};
|
||||
|
||||
int64_t ida = cbm_store_insert_edge(s, &ea);
|
||||
int64_t idb = cbm_store_insert_edge(s, &eb);
|
||||
ASSERT_GT(ida, 0);
|
||||
ASSERT_GT(idb, 0);
|
||||
ASSERT_NEQ(ida, idb); /* distinct local_name -> distinct rows */
|
||||
|
||||
/* Same (src,tgt,IMPORTS,local_name) again -> upsert onto the same row. */
|
||||
int64_t ida_again = cbm_store_insert_edge(s, &ea);
|
||||
ASSERT_EQ(ida_again, ida);
|
||||
|
||||
cbm_edge_t *edges = NULL;
|
||||
int count = 0;
|
||||
int rc = cbm_store_find_edges_by_type(s, "test", "IMPORTS", &edges, &count);
|
||||
ASSERT_EQ(rc, CBM_STORE_OK);
|
||||
ASSERT_EQ(count, 2);
|
||||
ASSERT_TRUE(strstr(edges[0].properties_json, "\"local_name\":\"A\"") != NULL ||
|
||||
strstr(edges[1].properties_json, "\"local_name\":\"A\"") != NULL);
|
||||
ASSERT_TRUE(strstr(edges[0].properties_json, "\"local_name\":\"B\"") != NULL ||
|
||||
strstr(edges[1].properties_json, "\"local_name\":\"B\"") != NULL);
|
||||
cbm_store_free_edges(edges, count);
|
||||
|
||||
/* Non-IMPORTS dedup is unchanged: differing properties (no local_name)
|
||||
* must still upsert onto one row, not fan out via a NULL discriminator. */
|
||||
cbm_edge_t c1 = {.project = "test",
|
||||
.source_id = ids[0],
|
||||
.target_id = ids[1],
|
||||
.type = "CALLS",
|
||||
.properties_json = "{}"};
|
||||
cbm_edge_t c2 = {.project = "test",
|
||||
.source_id = ids[0],
|
||||
.target_id = ids[1],
|
||||
.type = "CALLS",
|
||||
.properties_json = "{\"weight\":5}"};
|
||||
int64_t idc1 = cbm_store_insert_edge(s, &c1);
|
||||
int64_t idc2 = cbm_store_insert_edge(s, &c2);
|
||||
ASSERT_GT(idc1, 0);
|
||||
ASSERT_EQ(idc1, idc2);
|
||||
|
||||
cbm_store_close(s);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(store_edge_find_by_source_type) {
|
||||
int64_t ids[3];
|
||||
cbm_store_t *s = setup_store_with_nodes(3, ids);
|
||||
@@ -478,10 +539,8 @@ TEST(store_edge_delete_by_project_preserves_others) {
|
||||
int64_t idC = cbm_store_upsert_node(s, &nc);
|
||||
int64_t idD = cbm_store_upsert_node(s, &nd);
|
||||
|
||||
cbm_edge_t e1 = {
|
||||
.project = "alpha", .source_id = idA, .target_id = idB, .type = "CALLS"};
|
||||
cbm_edge_t e2 = {
|
||||
.project = "beta", .source_id = idC, .target_id = idD, .type = "CALLS"};
|
||||
cbm_edge_t e1 = {.project = "alpha", .source_id = idA, .target_id = idB, .type = "CALLS"};
|
||||
cbm_edge_t e2 = {.project = "beta", .source_id = idC, .target_id = idD, .type = "CALLS"};
|
||||
cbm_store_insert_edge(s, &e1);
|
||||
cbm_store_insert_edge(s, &e2);
|
||||
|
||||
@@ -538,10 +597,7 @@ TEST(store_edge_long_type_string) {
|
||||
memset(long_type, 'X', 200);
|
||||
long_type[200] = '\0';
|
||||
|
||||
cbm_edge_t e = {.project = "test",
|
||||
.source_id = ids[0],
|
||||
.target_id = ids[1],
|
||||
.type = long_type};
|
||||
cbm_edge_t e = {.project = "test", .source_id = ids[0], .target_id = ids[1], .type = long_type};
|
||||
int64_t eid = cbm_store_insert_edge(s, &e);
|
||||
ASSERT_GT(eid, 0);
|
||||
|
||||
@@ -581,6 +637,7 @@ TEST(store_edge_find_source_type_nonexistent) {
|
||||
SUITE(store_edges) {
|
||||
RUN_TEST(store_edge_insert_find);
|
||||
RUN_TEST(store_edge_dedup);
|
||||
RUN_TEST(store_imports_edge_local_name_coexist);
|
||||
RUN_TEST(store_edge_find_by_source_type);
|
||||
RUN_TEST(store_edge_find_by_target_type);
|
||||
RUN_TEST(store_edge_find_by_type);
|
||||
|
||||
Reference in New Issue
Block a user