1use rusqlite::{Connection, params};
4
5use crate::error::{KernelError, Result};
6
7use super::types::{EdgeDirection, GraphEdge, GraphNode, NODE_COLUMNS, join_csv, row_to_node};
8
9pub fn upsert_node(conn: &Connection, node: &GraphNode) -> Result<()> {
13 conn.execute(
14 "INSERT OR REPLACE INTO nodes
15 (id, type, title, tags, projects, agents, created, updated, body, importance, access_count, accessed_at)
16 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
17 params![
18 node.id,
19 node.node_type,
20 node.title,
21 join_csv(&node.tags),
22 join_csv(&node.projects),
23 join_csv(&node.agents),
24 node.created,
25 node.updated,
26 node.body,
27 node.importance,
28 node.access_count,
29 node.accessed_at,
30 ],
31 )
32 .map_err(|e| KernelError::Store(e.to_string()))?;
33 Ok(())
34}
35
36pub fn read_node(conn: &Connection, id: &str) -> Result<Option<GraphNode>> {
38 let sql = format!("SELECT {NODE_COLUMNS} FROM nodes WHERE id = ?1");
39 match conn.query_row(&sql, params![id], row_to_node) {
40 Ok(node) => Ok(Some(node)),
41 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
42 Err(e) => Err(KernelError::Store(e.to_string())),
43 }
44}
45
46pub fn read_nodes(conn: &Connection, ids: &[&str]) -> Result<Vec<GraphNode>> {
48 if ids.is_empty() {
49 return Ok(vec![]);
50 }
51 let ph = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
52 let sql = format!("SELECT {NODE_COLUMNS} FROM nodes WHERE id IN ({ph})");
53 let mut stmt = conn
54 .prepare(&sql)
55 .map_err(|e| KernelError::Store(e.to_string()))?;
56 let nodes: Vec<GraphNode> = stmt
57 .query_map(rusqlite::params_from_iter(ids.iter()), row_to_node)
58 .map_err(|e| KernelError::Store(e.to_string()))?
59 .filter_map(|r| r.ok())
60 .collect();
61 Ok(nodes)
62}
63
64pub fn delete_node(conn: &Connection, id: &str) -> Result<bool> {
66 let changed = conn
67 .execute("DELETE FROM nodes WHERE id = ?1", params![id])
68 .map_err(|e| KernelError::Store(e.to_string()))?;
69 Ok(changed > 0)
70}
71
72pub fn list_node_ids(conn: &Connection) -> Result<Vec<String>> {
74 let mut stmt = conn
75 .prepare("SELECT id FROM nodes")
76 .map_err(|e| KernelError::Store(e.to_string()))?;
77 let ids: Vec<String> = stmt
78 .query_map([], |row| row.get(0))
79 .map_err(|e| KernelError::Store(e.to_string()))?
80 .filter_map(|r| r.ok())
81 .collect();
82 Ok(ids)
83}
84
85pub fn read_nodes_limited(conn: &Connection, limit: usize) -> Result<Vec<GraphNode>> {
87 let sql = format!("SELECT {NODE_COLUMNS} FROM nodes ORDER BY updated DESC LIMIT ?");
88 let mut stmt = conn
89 .prepare(&sql)
90 .map_err(|e| KernelError::Store(e.to_string()))?;
91 let nodes: Vec<GraphNode> = stmt
92 .query_map(params![limit as i64], row_to_node)
93 .map_err(|e| KernelError::Store(e.to_string()))?
94 .filter_map(|r| r.ok())
95 .collect();
96 Ok(nodes)
97}
98
99pub fn append_edge(conn: &Connection, edge: &GraphEdge) -> Result<()> {
103 conn.execute(
104 "INSERT OR IGNORE INTO edges (id, source, target, relation, weight, ts)
105 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
106 params![
107 edge.id,
108 edge.source,
109 edge.target,
110 edge.relation,
111 edge.weight,
112 edge.ts
113 ],
114 )
115 .map_err(|e| KernelError::Store(e.to_string()))?;
116 Ok(())
117}
118
119pub fn append_edges(conn: &Connection, edges: &[GraphEdge]) -> Result<()> {
126 if edges.is_empty() {
127 return Ok(());
128 }
129 let tx = conn
130 .unchecked_transaction()
131 .map_err(|e| KernelError::Store(e.to_string()))?;
132 {
133 let mut stmt = tx
134 .prepare(
135 "INSERT OR IGNORE INTO edges (id, source, target, relation, weight, ts)
136 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
137 )
138 .map_err(|e| KernelError::Store(e.to_string()))?;
139 for edge in edges {
140 stmt.execute(params![
141 edge.id,
142 edge.source,
143 edge.target,
144 edge.relation,
145 edge.weight,
146 edge.ts
147 ])
148 .map_err(|e| KernelError::Store(e.to_string()))?;
149 }
150 }
151 tx.commit().map_err(|e| KernelError::Store(e.to_string()))?;
152 Ok(())
153}
154
155pub(crate) fn edges_for_node_dir(
165 conn: &Connection,
166 node_id: &str,
167 dir: EdgeDirection,
168 relation: Option<&str>,
169) -> Result<Vec<GraphEdge>> {
170 let mut sql = String::from("SELECT id, source, target, relation, weight, ts FROM edges WHERE ");
171 match dir {
172 EdgeDirection::Out => sql.push_str("source = ?1"),
173 EdgeDirection::In => sql.push_str("target = ?1"),
174 EdgeDirection::Both => sql.push_str("(source = ?1 OR target = ?1)"),
175 }
176 if relation.is_some() {
177 sql.push_str(" AND relation = ?2");
178 }
179 sql.push_str(" ORDER BY weight DESC");
180 let mut stmt = conn
181 .prepare(&sql)
182 .map_err(|e| KernelError::Store(e.to_string()))?;
183 let mapper = |row: &rusqlite::Row<'_>| {
184 Ok(GraphEdge {
185 id: row.get(0)?,
186 source: row.get(1)?,
187 target: row.get(2)?,
188 relation: row.get(3)?,
189 weight: row.get(4)?,
190 ts: row.get(5)?,
191 })
192 };
193 let edges: Vec<GraphEdge> = if let Some(r) = relation {
194 stmt.query_map(params![node_id, r], mapper)
195 } else {
196 stmt.query_map(params![node_id], mapper)
197 }
198 .map_err(|e| KernelError::Store(e.to_string()))?
199 .filter_map(|r| r.ok())
200 .collect();
201 Ok(edges)
202}
203
204pub fn read_edges(conn: &Connection, limit: usize) -> Result<Vec<GraphEdge>> {
206 let mut stmt = conn
207 .prepare("SELECT id, source, target, relation, weight, ts FROM edges LIMIT ?1")
208 .map_err(|e| KernelError::Store(e.to_string()))?;
209 let edges: Vec<GraphEdge> = stmt
210 .query_map(params![limit as i64], |row| {
211 Ok(GraphEdge {
212 id: row.get(0)?,
213 source: row.get(1)?,
214 target: row.get(2)?,
215 relation: row.get(3)?,
216 weight: row.get(4)?,
217 ts: row.get(5)?,
218 })
219 })
220 .map_err(|e| KernelError::Store(e.to_string()))?
221 .filter_map(|r| r.ok())
222 .collect();
223 Ok(edges)
224}
225
226pub(crate) fn edges_among(conn: &Connection, ids: &[&str]) -> Result<Vec<GraphEdge>> {
233 if ids.is_empty() {
234 return Ok(Vec::new());
235 }
236 let ph = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
237 let sql = format!(
238 "SELECT id, source, target, relation, weight, ts FROM edges \
239 WHERE source IN ({ph}) AND target IN ({ph})"
240 );
241 let mut stmt = conn
242 .prepare(&sql)
243 .map_err(|e| KernelError::Store(e.to_string()))?;
244 let edges: Vec<GraphEdge> = stmt
245 .query_map(
246 rusqlite::params_from_iter(ids.iter().chain(ids.iter()).copied()),
247 |row| {
248 Ok(GraphEdge {
249 id: row.get(0)?,
250 source: row.get(1)?,
251 target: row.get(2)?,
252 relation: row.get(3)?,
253 weight: row.get(4)?,
254 ts: row.get(5)?,
255 })
256 },
257 )
258 .map_err(|e| KernelError::Store(e.to_string()))?
259 .filter_map(|r| r.ok())
260 .collect();
261 Ok(edges)
262}
263
264pub fn delete_edge(conn: &Connection, id: &str) -> Result<bool> {
266 let changed = conn
267 .execute("DELETE FROM edges WHERE id = ?1", params![id])
268 .map_err(|e| KernelError::Store(e.to_string()))?;
269 Ok(changed > 0)
270}
271
272pub(crate) fn remove_edges_for_node(conn: &Connection, node_id: &str) -> Result<()> {
274 conn.execute(
275 "DELETE FROM edges WHERE source = ?1 OR target = ?1",
276 params![node_id],
277 )
278 .map_err(|e| KernelError::Store(e.to_string()))?;
279 Ok(())
280}
281
282pub(crate) fn edges_for_node(conn: &Connection, node_id: &str) -> Result<Vec<GraphEdge>> {
284 let mut stmt = conn
285 .prepare(
286 "SELECT id, source, target, relation, weight, ts FROM edges WHERE source = ?1 OR target = ?1",
287 )
288 .map_err(|e| KernelError::Store(e.to_string()))?;
289 let edges: Vec<GraphEdge> = stmt
290 .query_map(params![node_id], |row| {
291 Ok(GraphEdge {
292 id: row.get(0)?,
293 source: row.get(1)?,
294 target: row.get(2)?,
295 relation: row.get(3)?,
296 weight: row.get(4)?,
297 ts: row.get(5)?,
298 })
299 })
300 .map_err(|e| KernelError::Store(e.to_string()))?
301 .filter_map(|r| r.ok())
302 .collect();
303 Ok(edges)
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use crate::graph::schema::init_graph_schema;
310 use rusqlite::Connection;
311
312 fn mem_db() -> Connection {
313 let conn = Connection::open_in_memory().unwrap();
314 init_graph_schema(&conn).unwrap();
315 conn
316 }
317
318 fn test_node(id: &str) -> GraphNode {
319 GraphNode {
320 id: id.to_string(),
321 node_type: "concept".to_string(),
322 title: format!("Node {id}"),
323 body: "test body".to_string(),
324 tags: vec!["test".to_string()],
325 projects: vec![],
326 agents: vec![],
327 created: "2026-01-01T00:00:00Z".to_string(),
328 updated: "2026-01-01T00:00:00Z".to_string(),
329 importance: 0.7,
330 access_count: 0,
331 accessed_at: String::new(),
332 }
333 }
334
335 #[test]
336 fn upsert_and_read_node() {
337 let conn = mem_db();
338 let node = test_node("n1");
339 upsert_node(&conn, &node).unwrap();
340 let loaded = read_node(&conn, "n1").unwrap().unwrap();
341 assert_eq!(loaded.id, "n1");
342 assert_eq!(loaded.title, "Node n1");
343 assert_eq!(loaded.tags, vec!["test"]);
344 }
345
346 #[test]
347 fn read_missing_node_returns_none() {
348 let conn = mem_db();
349 assert!(read_node(&conn, "nope").unwrap().is_none());
350 }
351
352 #[test]
353 fn delete_node_returns_true_when_exists() {
354 let conn = mem_db();
355 upsert_node(&conn, &test_node("n1")).unwrap();
356 assert!(delete_node(&conn, "n1").unwrap());
357 assert!(!delete_node(&conn, "n1").unwrap());
358 }
359
360 #[test]
361 fn list_node_ids_returns_all() {
362 let conn = mem_db();
363 upsert_node(&conn, &test_node("a")).unwrap();
364 upsert_node(&conn, &test_node("b")).unwrap();
365 let ids = list_node_ids(&conn).unwrap();
366 assert_eq!(ids.len(), 2);
367 }
368
369 #[test]
370 fn append_and_read_edges() {
371 let conn = mem_db();
372 let edge = GraphEdge {
373 id: "e1".to_string(),
374 source: "a".to_string(),
375 target: "b".to_string(),
376 relation: "related".to_string(),
377 weight: 1.0,
378 ts: "2026-01-01T00:00:00Z".to_string(),
379 };
380 append_edge(&conn, &edge).unwrap();
381 let edges = read_edges(&conn, 10).unwrap();
382 assert_eq!(edges.len(), 1);
383 assert_eq!(edges[0].source, "a");
384 }
385
386 #[test]
387 fn edges_for_node_returns_both_directions() {
388 let conn = mem_db();
389 append_edge(
390 &conn,
391 &GraphEdge {
392 id: "e1".into(),
393 source: "a".into(),
394 target: "b".into(),
395 relation: "related".into(),
396 weight: 1.0,
397 ts: "2026-01-01T00:00:00Z".into(),
398 },
399 )
400 .unwrap();
401 append_edge(
402 &conn,
403 &GraphEdge {
404 id: "e2".into(),
405 source: "c".into(),
406 target: "a".into(),
407 relation: "related".into(),
408 weight: 1.0,
409 ts: "2026-01-01T00:00:00Z".into(),
410 },
411 )
412 .unwrap();
413 let edges = edges_for_node(&conn, "a").unwrap();
414 assert_eq!(edges.len(), 2);
415 }
416
417 #[test]
418 fn test_remove_edges_for_node() {
419 let conn = mem_db();
420 append_edge(
421 &conn,
422 &GraphEdge {
423 id: "e1".into(),
424 source: "a".into(),
425 target: "b".into(),
426 relation: "related".into(),
427 weight: 1.0,
428 ts: "2026-01-01T00:00:00Z".into(),
429 },
430 )
431 .unwrap();
432 remove_edges_for_node(&conn, "a").unwrap();
433 assert!(read_edges(&conn, 10).unwrap().is_empty());
434 }
435
436 #[test]
437 fn append_edges_inserts_batch() {
438 let conn = mem_db();
439 let edges = vec![
440 GraphEdge {
441 id: "e1".into(),
442 source: "a".into(),
443 target: "b".into(),
444 relation: "cites".into(),
445 weight: 1.0,
446 ts: "2026-01-01T00:00:00Z".into(),
447 },
448 GraphEdge {
449 id: "e2".into(),
450 source: "a".into(),
451 target: "c".into(),
452 relation: "cites".into(),
453 weight: 0.5,
454 ts: "2026-01-01T00:00:00Z".into(),
455 },
456 ];
457 append_edges(&conn, &edges).unwrap();
458 assert_eq!(read_edges(&conn, 10).unwrap().len(), 2);
459 }
460
461 #[test]
462 fn append_edges_empty_is_noop() {
463 let conn = mem_db();
464 append_edges(&conn, &[]).unwrap();
465 assert!(read_edges(&conn, 10).unwrap().is_empty());
466 }
467
468 #[test]
469 fn append_edges_ignores_duplicate_id_and_unique() {
470 let conn = mem_db();
471 let e = GraphEdge {
472 id: "e1".into(),
473 source: "a".into(),
474 target: "b".into(),
475 relation: "cites".into(),
476 weight: 1.0,
477 ts: "2026-01-01T00:00:00Z".into(),
478 };
479 append_edges(&conn, &[e.clone()]).unwrap();
480 append_edges(
483 &conn,
484 &[
485 e,
486 GraphEdge {
487 id: "e2".into(),
488 source: "a".into(),
489 target: "b".into(),
490 relation: "cites".into(),
491 weight: 1.0,
492 ts: "2026-01-01T00:00:00Z".into(),
493 },
494 ],
495 )
496 .unwrap();
497 assert_eq!(read_edges(&conn, 10).unwrap().len(), 1);
498 }
499
500 #[test]
501 fn edges_for_node_dir_filters_direction_and_relation() {
502 let conn = mem_db();
503 append_edges(
504 &conn,
505 &[
506 GraphEdge {
507 id: "e1".into(),
508 source: "a".into(),
509 target: "b".into(),
510 relation: "cites".into(),
511 weight: 1.0,
512 ts: "t".into(),
513 },
514 GraphEdge {
515 id: "e2".into(),
516 source: "c".into(),
517 target: "a".into(),
518 relation: "cites".into(),
519 weight: 1.0,
520 ts: "t".into(),
521 },
522 GraphEdge {
523 id: "e3".into(),
524 source: "a".into(),
525 target: "d".into(),
526 relation: "see_also".into(),
527 weight: 1.0,
528 ts: "t".into(),
529 },
530 ],
531 )
532 .unwrap();
533 assert_eq!(
535 edges_for_node_dir(&conn, "a", EdgeDirection::Out, None)
536 .unwrap()
537 .len(),
538 2
539 );
540 let out_cites = edges_for_node_dir(&conn, "a", EdgeDirection::Out, Some("cites")).unwrap();
542 assert_eq!(out_cites.len(), 1);
543 assert_eq!(out_cites[0].target, "b");
544 let inc = edges_for_node_dir(&conn, "a", EdgeDirection::In, None).unwrap();
546 assert_eq!(inc.len(), 1);
547 assert_eq!(inc[0].source, "c");
548 assert_eq!(
550 edges_for_node_dir(&conn, "a", EdgeDirection::Both, None)
551 .unwrap()
552 .len(),
553 3
554 );
555 }
556}