Skip to content

Commit 7a71cdb

Browse files
devwhodevsclaude
andcommitted
feat: edges table with migration, CRUD, and cascade support
Add edges table to SQLite for vault graph. Supports wikilink and mention edge types. Bidirectional cleanup on delete_edges_for_file. ON DELETE CASCADE handles file deletion. INSERT OR IGNORE for dupes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 71a68e1 commit 7a71cdb

1 file changed

Lines changed: 236 additions & 0 deletions

File tree

src/store.rs

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,30 @@ impl Store {
133133
// Always ensure the index exists (safe for both fresh and migrated DBs).
134134
self.conn
135135
.execute_batch("CREATE INDEX IF NOT EXISTS idx_files_docid ON files(docid);")?;
136+
137+
// Check if edges table exists.
138+
let has_edges: bool = {
139+
let mut stmt = self
140+
.conn
141+
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='edges'")?;
142+
let mut rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
143+
rows.next().is_some()
144+
};
145+
if !has_edges {
146+
self.conn.execute_batch(
147+
"CREATE TABLE IF NOT EXISTS edges (
148+
id INTEGER PRIMARY KEY,
149+
from_file INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
150+
to_file INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
151+
edge_type TEXT NOT NULL,
152+
UNIQUE(from_file, to_file, edge_type)
153+
);
154+
CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_file);
155+
CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_file);
156+
CREATE INDEX IF NOT EXISTS idx_edges_type ON edges(edge_type);",
157+
)?;
158+
}
159+
136160
Ok(())
137161
}
138162

@@ -380,6 +404,100 @@ impl Store {
380404
Ok(())
381405
}
382406

407+
// ── Edges ──────────────────────────────────────────────────
408+
409+
/// Insert an edge. Uses INSERT OR IGNORE for the UNIQUE constraint.
410+
pub fn insert_edge(&self, from_file: i64, to_file: i64, edge_type: &str) -> Result<()> {
411+
self.conn.execute(
412+
"INSERT OR IGNORE INTO edges (from_file, to_file, edge_type) VALUES (?1, ?2, ?3)",
413+
params![from_file, to_file, edge_type],
414+
)?;
415+
Ok(())
416+
}
417+
418+
/// Delete all edges involving a file (both directions: from_file OR to_file).
419+
pub fn delete_edges_for_file(&self, file_id: i64) -> Result<()> {
420+
self.conn.execute(
421+
"DELETE FROM edges WHERE from_file = ?1 OR to_file = ?1",
422+
params![file_id],
423+
)?;
424+
Ok(())
425+
}
426+
427+
/// Clear all edges (used during --rebuild).
428+
pub fn clear_edges(&self) -> Result<()> {
429+
self.conn.execute("DELETE FROM edges", [])?;
430+
Ok(())
431+
}
432+
433+
/// Get outgoing edges, optionally filtered by type.
434+
pub fn get_outgoing(
435+
&self,
436+
file_id: i64,
437+
edge_type: Option<&str>,
438+
) -> Result<Vec<(i64, String)>> {
439+
let mut results = Vec::new();
440+
match edge_type {
441+
Some(et) => {
442+
let mut stmt = self.conn.prepare(
443+
"SELECT to_file, edge_type FROM edges WHERE from_file = ?1 AND edge_type = ?2",
444+
)?;
445+
let rows = stmt.query_map(params![file_id, et], |row| {
446+
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
447+
})?;
448+
for row in rows {
449+
results.push(row?);
450+
}
451+
}
452+
None => {
453+
let mut stmt = self
454+
.conn
455+
.prepare("SELECT to_file, edge_type FROM edges WHERE from_file = ?1")?;
456+
let rows = stmt.query_map(params![file_id], |row| {
457+
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
458+
})?;
459+
for row in rows {
460+
results.push(row?);
461+
}
462+
}
463+
}
464+
Ok(results)
465+
}
466+
467+
/// Get incoming edges, optionally filtered by type.
468+
pub fn get_incoming(
469+
&self,
470+
file_id: i64,
471+
edge_type: Option<&str>,
472+
) -> Result<Vec<(i64, String)>> {
473+
let mut results = Vec::new();
474+
match edge_type {
475+
Some(et) => {
476+
let mut stmt = self.conn.prepare(
477+
"SELECT from_file, edge_type FROM edges WHERE to_file = ?1 AND edge_type = ?2",
478+
)?;
479+
let rows = stmt.query_map(params![file_id, et], |row| {
480+
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
481+
})?;
482+
for row in rows {
483+
results.push(row?);
484+
}
485+
}
486+
None => {
487+
let mut stmt = self
488+
.conn
489+
.prepare("SELECT from_file, edge_type FROM edges WHERE to_file = ?1")?;
490+
let rows = stmt.query_map(params![file_id], |row| {
491+
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
492+
})?;
493+
for row in rows {
494+
results.push(row?);
495+
}
496+
}
497+
}
498+
Ok(results)
499+
}
500+
383501
// ── Stats ───────────────────────────────────────────────────
384502

385503
pub fn stats(&self) -> Result<StoreStats> {
@@ -773,4 +891,122 @@ mod tests {
773891
// Non-existent docid returns None.
774892
assert!(store.get_file_by_docid("ffffff").unwrap().is_none());
775893
}
894+
895+
// ── Edge tests ─────────────────────────────────────────────
896+
897+
/// Helper: create two files and return their IDs.
898+
fn setup_two_files(store: &Store) -> (i64, i64) {
899+
let a = store
900+
.insert_file("notes/a.md", "ha", 100, &[], &generate_docid("notes/a.md"))
901+
.unwrap();
902+
let b = store
903+
.insert_file("notes/b.md", "hb", 100, &[], &generate_docid("notes/b.md"))
904+
.unwrap();
905+
(a, b)
906+
}
907+
908+
#[test]
909+
fn test_insert_and_get_edges() {
910+
let store = Store::open_memory().unwrap();
911+
let (a, b) = setup_two_files(&store);
912+
913+
store.insert_edge(a, b, "wikilink").unwrap();
914+
915+
let out = store.get_outgoing(a, None).unwrap();
916+
assert_eq!(out.len(), 1);
917+
assert_eq!(out[0], (b, "wikilink".to_string()));
918+
919+
let inc = store.get_incoming(b, None).unwrap();
920+
assert_eq!(inc.len(), 1);
921+
assert_eq!(inc[0], (a, "wikilink".to_string()));
922+
923+
// No edges in the other direction.
924+
assert!(store.get_outgoing(b, None).unwrap().is_empty());
925+
assert!(store.get_incoming(a, None).unwrap().is_empty());
926+
}
927+
928+
#[test]
929+
fn test_delete_edges_for_file_both_directions() {
930+
let store = Store::open_memory().unwrap();
931+
let (a, b) = setup_two_files(&store);
932+
let c = store
933+
.insert_file("notes/c.md", "hc", 100, &[], &generate_docid("notes/c.md"))
934+
.unwrap();
935+
936+
// a -> b, c -> a
937+
store.insert_edge(a, b, "wikilink").unwrap();
938+
store.insert_edge(c, a, "mention").unwrap();
939+
940+
// Delete edges for file a — should remove both.
941+
store.delete_edges_for_file(a).unwrap();
942+
943+
assert!(store.get_outgoing(a, None).unwrap().is_empty());
944+
assert!(store.get_incoming(a, None).unwrap().is_empty());
945+
assert!(store.get_incoming(b, None).unwrap().is_empty());
946+
assert!(store.get_outgoing(c, None).unwrap().is_empty());
947+
}
948+
949+
#[test]
950+
fn test_edge_cascade_on_file_delete() {
951+
let store = Store::open_memory().unwrap();
952+
let (a, b) = setup_two_files(&store);
953+
let c = store
954+
.insert_file("notes/c.md", "hc", 100, &[], &generate_docid("notes/c.md"))
955+
.unwrap();
956+
957+
// a -> b, b -> c
958+
store.insert_edge(a, b, "wikilink").unwrap();
959+
store.insert_edge(b, c, "mention").unwrap();
960+
961+
// Delete file b — CASCADE should remove both edges.
962+
store.delete_file(b).unwrap();
963+
964+
assert!(store.get_outgoing(a, None).unwrap().is_empty());
965+
assert!(store.get_incoming(c, None).unwrap().is_empty());
966+
}
967+
968+
#[test]
969+
fn test_duplicate_edge_ignored() {
970+
let store = Store::open_memory().unwrap();
971+
let (a, b) = setup_two_files(&store);
972+
973+
store.insert_edge(a, b, "wikilink").unwrap();
974+
store.insert_edge(a, b, "wikilink").unwrap(); // duplicate
975+
976+
let out = store.get_outgoing(a, None).unwrap();
977+
assert_eq!(out.len(), 1);
978+
979+
// Same pair with different type is NOT a duplicate.
980+
store.insert_edge(a, b, "mention").unwrap();
981+
let out = store.get_outgoing(a, None).unwrap();
982+
assert_eq!(out.len(), 2);
983+
}
984+
985+
#[test]
986+
fn test_get_outgoing_filtered_by_type() {
987+
let store = Store::open_memory().unwrap();
988+
let (a, b) = setup_two_files(&store);
989+
let c = store
990+
.insert_file("notes/c.md", "hc", 100, &[], &generate_docid("notes/c.md"))
991+
.unwrap();
992+
993+
store.insert_edge(a, b, "wikilink").unwrap();
994+
store.insert_edge(a, c, "mention").unwrap();
995+
996+
let wikilinks = store.get_outgoing(a, Some("wikilink")).unwrap();
997+
assert_eq!(wikilinks.len(), 1);
998+
assert_eq!(wikilinks[0].0, b);
999+
1000+
let mentions = store.get_outgoing(a, Some("mention")).unwrap();
1001+
assert_eq!(mentions.len(), 1);
1002+
assert_eq!(mentions[0].0, c);
1003+
1004+
// Incoming filtered.
1005+
let inc = store.get_incoming(b, Some("wikilink")).unwrap();
1006+
assert_eq!(inc.len(), 1);
1007+
assert_eq!(inc[0].0, a);
1008+
1009+
let inc = store.get_incoming(b, Some("mention")).unwrap();
1010+
assert!(inc.is_empty());
1011+
}
7761012
}

0 commit comments

Comments
 (0)