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<()> {
26 conn.execute(
27 "INSERT INTO nodes
28 (id, type, title, tags, projects, agents, created, updated, body,
29 importance, access_count, accessed_at)
30 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
31 ON CONFLICT(id) DO UPDATE SET
32 type = excluded.type,
33 title = excluded.title,
34 tags = excluded.tags,
35 projects = excluded.projects,
36 agents = excluded.agents,
37 updated = excluded.updated,
38 body = excluded.body,
39 importance = excluded.importance,
40 created = nodes.created,
41 access_count = MAX(nodes.access_count, excluded.access_count),
42 accessed_at = MAX(nodes.accessed_at, excluded.accessed_at)",
43 params![
44 node.id,
45 node.node_type,
46 node.title,
47 join_csv(&node.tags),
48 join_csv(&node.projects),
49 join_csv(&node.agents),
50 node.created,
51 node.updated,
52 node.body,
53 node.importance,
54 node.access_count,
55 node.accessed_at,
56 ],
57 )
58 .map_err(|e| KernelError::Store(e.to_string()))?;
59 Ok(())
60}
61
62pub fn read_node(conn: &Connection, id: &str) -> Result<Option<GraphNode>> {
64 let sql = format!("SELECT {NODE_COLUMNS} FROM nodes WHERE id = ?1");
65 match conn.query_row(&sql, params![id], row_to_node) {
66 Ok(node) => Ok(Some(node)),
67 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
68 Err(e) => Err(KernelError::Store(e.to_string())),
69 }
70}
71
72pub fn read_nodes(conn: &Connection, ids: &[&str]) -> Result<Vec<GraphNode>> {
74 if ids.is_empty() {
75 return Ok(vec![]);
76 }
77 let ph = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
78 let sql = format!("SELECT {NODE_COLUMNS} FROM nodes WHERE id IN ({ph})");
79 let mut stmt = conn
80 .prepare(&sql)
81 .map_err(|e| KernelError::Store(e.to_string()))?;
82 let nodes: Vec<GraphNode> = stmt
83 .query_map(rusqlite::params_from_iter(ids.iter()), row_to_node)
84 .map_err(|e| KernelError::Store(e.to_string()))?
85 .filter_map(|r| r.ok())
86 .collect();
87 Ok(nodes)
88}
89
90pub fn delete_node(conn: &Connection, id: &str) -> Result<bool> {
96 let tx = conn
101 .unchecked_transaction()
102 .map_err(|e| KernelError::Store(e.to_string()))?;
103 remove_edges_for_node(&tx, id)?;
104 let changed = tx
105 .execute("DELETE FROM nodes WHERE id = ?1", params![id])
106 .map_err(|e| KernelError::Store(e.to_string()))?;
107 tx.commit().map_err(|e| KernelError::Store(e.to_string()))?;
108 Ok(changed > 0)
109}
110
111pub fn list_node_ids(conn: &Connection) -> Result<Vec<String>> {
113 let mut stmt = conn
114 .prepare("SELECT id FROM nodes")
115 .map_err(|e| KernelError::Store(e.to_string()))?;
116 let ids: Vec<String> = stmt
117 .query_map([], |row| row.get(0))
118 .map_err(|e| KernelError::Store(e.to_string()))?
119 .filter_map(|r| r.ok())
120 .collect();
121 Ok(ids)
122}
123
124pub fn read_nodes_limited(conn: &Connection, limit: usize) -> Result<Vec<GraphNode>> {
126 let sql = format!("SELECT {NODE_COLUMNS} FROM nodes ORDER BY updated DESC LIMIT ?");
127 let mut stmt = conn
128 .prepare(&sql)
129 .map_err(|e| KernelError::Store(e.to_string()))?;
130 let nodes: Vec<GraphNode> = stmt
131 .query_map(params![limit as i64], row_to_node)
132 .map_err(|e| KernelError::Store(e.to_string()))?
133 .filter_map(|r| r.ok())
134 .collect();
135 Ok(nodes)
136}
137
138pub fn append_edge(conn: &Connection, edge: &GraphEdge) -> Result<()> {
142 conn.execute(
143 "INSERT OR IGNORE INTO edges (id, source, target, relation, weight, ts)
144 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
145 params![
146 edge.id,
147 edge.source,
148 edge.target,
149 edge.relation,
150 edge.weight,
151 edge.ts
152 ],
153 )
154 .map_err(|e| KernelError::Store(e.to_string()))?;
155 Ok(())
156}
157
158pub fn append_edges(conn: &Connection, edges: &[GraphEdge]) -> Result<()> {
165 if edges.is_empty() {
166 return Ok(());
167 }
168 let tx = conn
169 .unchecked_transaction()
170 .map_err(|e| KernelError::Store(e.to_string()))?;
171 {
172 let mut stmt = tx
173 .prepare(
174 "INSERT OR IGNORE INTO edges (id, source, target, relation, weight, ts)
175 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
176 )
177 .map_err(|e| KernelError::Store(e.to_string()))?;
178 for edge in edges {
179 stmt.execute(params![
180 edge.id,
181 edge.source,
182 edge.target,
183 edge.relation,
184 edge.weight,
185 edge.ts
186 ])
187 .map_err(|e| KernelError::Store(e.to_string()))?;
188 }
189 }
190 tx.commit().map_err(|e| KernelError::Store(e.to_string()))?;
191 Ok(())
192}
193
194pub(crate) fn edges_for_node_dir(
204 conn: &Connection,
205 node_id: &str,
206 dir: EdgeDirection,
207 relation: Option<&str>,
208) -> Result<Vec<GraphEdge>> {
209 let mut sql = String::from("SELECT id, source, target, relation, weight, ts FROM edges WHERE ");
210 match dir {
211 EdgeDirection::Out => sql.push_str("source = ?1"),
212 EdgeDirection::In => sql.push_str("target = ?1"),
213 EdgeDirection::Both => sql.push_str("(source = ?1 OR target = ?1)"),
214 }
215 if relation.is_some() {
216 sql.push_str(" AND relation = ?2");
217 }
218 sql.push_str(" ORDER BY weight DESC");
219 let mut stmt = conn
220 .prepare(&sql)
221 .map_err(|e| KernelError::Store(e.to_string()))?;
222 let mapper = |row: &rusqlite::Row<'_>| {
223 Ok(GraphEdge {
224 id: row.get(0)?,
225 source: row.get(1)?,
226 target: row.get(2)?,
227 relation: row.get(3)?,
228 weight: row.get(4)?,
229 ts: row.get(5)?,
230 })
231 };
232 let edges: Vec<GraphEdge> = if let Some(r) = relation {
233 stmt.query_map(params![node_id, r], mapper)
234 } else {
235 stmt.query_map(params![node_id], mapper)
236 }
237 .map_err(|e| KernelError::Store(e.to_string()))?
238 .filter_map(|r| r.ok())
239 .collect();
240 Ok(edges)
241}
242
243pub fn read_edges(conn: &Connection, limit: usize) -> Result<Vec<GraphEdge>> {
245 let mut stmt = conn
246 .prepare("SELECT id, source, target, relation, weight, ts FROM edges LIMIT ?1")
247 .map_err(|e| KernelError::Store(e.to_string()))?;
248 let edges: Vec<GraphEdge> = stmt
249 .query_map(params![limit as i64], |row| {
250 Ok(GraphEdge {
251 id: row.get(0)?,
252 source: row.get(1)?,
253 target: row.get(2)?,
254 relation: row.get(3)?,
255 weight: row.get(4)?,
256 ts: row.get(5)?,
257 })
258 })
259 .map_err(|e| KernelError::Store(e.to_string()))?
260 .filter_map(|r| r.ok())
261 .collect();
262 Ok(edges)
263}
264
265pub(crate) fn edges_among(conn: &Connection, ids: &[&str]) -> Result<Vec<GraphEdge>> {
272 if ids.is_empty() {
273 return Ok(Vec::new());
274 }
275 let ph = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
276 let sql = format!(
277 "SELECT id, source, target, relation, weight, ts FROM edges \
278 WHERE source IN ({ph}) AND target IN ({ph})"
279 );
280 let mut stmt = conn
281 .prepare(&sql)
282 .map_err(|e| KernelError::Store(e.to_string()))?;
283 let edges: Vec<GraphEdge> = stmt
284 .query_map(
285 rusqlite::params_from_iter(ids.iter().chain(ids.iter()).copied()),
286 |row| {
287 Ok(GraphEdge {
288 id: row.get(0)?,
289 source: row.get(1)?,
290 target: row.get(2)?,
291 relation: row.get(3)?,
292 weight: row.get(4)?,
293 ts: row.get(5)?,
294 })
295 },
296 )
297 .map_err(|e| KernelError::Store(e.to_string()))?
298 .filter_map(|r| r.ok())
299 .collect();
300 Ok(edges)
301}
302
303pub fn delete_edge(conn: &Connection, id: &str) -> Result<bool> {
305 let changed = conn
306 .execute("DELETE FROM edges WHERE id = ?1", params![id])
307 .map_err(|e| KernelError::Store(e.to_string()))?;
308 Ok(changed > 0)
309}
310
311pub(crate) fn remove_edges_for_node(conn: &Connection, node_id: &str) -> Result<()> {
313 conn.execute(
314 "DELETE FROM edges WHERE source = ?1 OR target = ?1",
315 params![node_id],
316 )
317 .map_err(|e| KernelError::Store(e.to_string()))?;
318 Ok(())
319}
320
321pub(crate) fn edges_for_node(conn: &Connection, node_id: &str) -> Result<Vec<GraphEdge>> {
323 let mut stmt = conn
324 .prepare(
325 "SELECT id, source, target, relation, weight, ts FROM edges WHERE source = ?1 OR target = ?1",
326 )
327 .map_err(|e| KernelError::Store(e.to_string()))?;
328 let edges: Vec<GraphEdge> = stmt
329 .query_map(params![node_id], |row| {
330 Ok(GraphEdge {
331 id: row.get(0)?,
332 source: row.get(1)?,
333 target: row.get(2)?,
334 relation: row.get(3)?,
335 weight: row.get(4)?,
336 ts: row.get(5)?,
337 })
338 })
339 .map_err(|e| KernelError::Store(e.to_string()))?
340 .filter_map(|r| r.ok())
341 .collect();
342 Ok(edges)
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use crate::graph::schema::init_graph_schema;
349 use rusqlite::Connection;
350
351 fn mem_db() -> Connection {
352 let conn = Connection::open_in_memory().unwrap();
353 init_graph_schema(&conn).unwrap();
354 conn
355 }
356
357 fn test_node(id: &str) -> GraphNode {
358 GraphNode {
359 id: id.to_string(),
360 node_type: "concept".to_string(),
361 title: format!("Node {id}"),
362 body: "test body".to_string(),
363 tags: vec!["test".to_string()],
364 projects: vec![],
365 agents: vec![],
366 created: "2026-01-01T00:00:00Z".to_string(),
367 updated: "2026-01-01T00:00:00Z".to_string(),
368 importance: 0.7,
369 access_count: 0,
370 accessed_at: String::new(),
371 }
372 }
373
374 #[test]
375 fn upsert_and_read_node() {
376 let conn = mem_db();
377 let node = test_node("n1");
378 upsert_node(&conn, &node).unwrap();
379 let loaded = read_node(&conn, "n1").unwrap().unwrap();
380 assert_eq!(loaded.id, "n1");
381 assert_eq!(loaded.title, "Node n1");
382 assert_eq!(loaded.tags, vec!["test"]);
383 }
384
385 #[test]
386 fn read_missing_node_returns_none() {
387 let conn = mem_db();
388 assert!(read_node(&conn, "nope").unwrap().is_none());
389 }
390
391 #[test]
392 fn delete_node_returns_true_when_exists() {
393 let conn = mem_db();
394 upsert_node(&conn, &test_node("n1")).unwrap();
395 assert!(delete_node(&conn, "n1").unwrap());
396 assert!(!delete_node(&conn, "n1").unwrap());
397 }
398
399 #[test]
402 fn upsert_preserves_created_and_access_on_conflict() {
403 let conn = mem_db();
404 let mut n = test_node("fixed");
405 n.created = "2026-01-01T00:00:00Z".to_string();
406 n.access_count = 42;
407 upsert_node(&conn, &n).unwrap();
408
409 let mut again = test_node("fixed");
411 again.created = "2026-12-31T00:00:00Z".to_string();
412 again.updated = "2026-12-31T00:00:00Z".to_string();
413 again.access_count = 0;
414 upsert_node(&conn, &again).unwrap();
415
416 let got = read_node(&conn, "fixed").unwrap().unwrap();
417 assert_eq!(got.created, "2026-01-01T00:00:00Z", "created must persist");
418 assert_eq!(
419 got.access_count, 42,
420 "access_count must not be zeroed on re-upsert"
421 );
422 assert_eq!(got.updated, "2026-12-31T00:00:00Z", "updated must refresh");
423 }
424
425 #[test]
427 fn delete_node_removes_edges() {
428 let conn = mem_db();
429 upsert_node(&conn, &test_node("a")).unwrap();
430 upsert_node(&conn, &test_node("b")).unwrap();
431 append_edge(
432 &conn,
433 &GraphEdge {
434 id: "e1".into(),
435 source: "a".into(),
436 target: "b".into(),
437 relation: "rel".into(),
438 weight: 1.0,
439 ts: "2026-01-01T00:00:00Z".into(),
440 },
441 )
442 .unwrap();
443
444 assert!(delete_node(&conn, "a").unwrap());
445 let edges = edges_for_node(&conn, "a").unwrap();
446 assert!(edges.is_empty(), "orphan edges remain: {edges:?}");
447 }
448
449 #[test]
450 fn list_node_ids_returns_all() {
451 let conn = mem_db();
452 upsert_node(&conn, &test_node("a")).unwrap();
453 upsert_node(&conn, &test_node("b")).unwrap();
454 let ids = list_node_ids(&conn).unwrap();
455 assert_eq!(ids.len(), 2);
456 }
457
458 #[test]
459 fn append_and_read_edges() {
460 let conn = mem_db();
461 let edge = GraphEdge {
462 id: "e1".to_string(),
463 source: "a".to_string(),
464 target: "b".to_string(),
465 relation: "related".to_string(),
466 weight: 1.0,
467 ts: "2026-01-01T00:00:00Z".to_string(),
468 };
469 append_edge(&conn, &edge).unwrap();
470 let edges = read_edges(&conn, 10).unwrap();
471 assert_eq!(edges.len(), 1);
472 assert_eq!(edges[0].source, "a");
473 }
474
475 #[test]
476 fn edges_for_node_returns_both_directions() {
477 let conn = mem_db();
478 append_edge(
479 &conn,
480 &GraphEdge {
481 id: "e1".into(),
482 source: "a".into(),
483 target: "b".into(),
484 relation: "related".into(),
485 weight: 1.0,
486 ts: "2026-01-01T00:00:00Z".into(),
487 },
488 )
489 .unwrap();
490 append_edge(
491 &conn,
492 &GraphEdge {
493 id: "e2".into(),
494 source: "c".into(),
495 target: "a".into(),
496 relation: "related".into(),
497 weight: 1.0,
498 ts: "2026-01-01T00:00:00Z".into(),
499 },
500 )
501 .unwrap();
502 let edges = edges_for_node(&conn, "a").unwrap();
503 assert_eq!(edges.len(), 2);
504 }
505
506 #[test]
507 fn test_remove_edges_for_node() {
508 let conn = mem_db();
509 append_edge(
510 &conn,
511 &GraphEdge {
512 id: "e1".into(),
513 source: "a".into(),
514 target: "b".into(),
515 relation: "related".into(),
516 weight: 1.0,
517 ts: "2026-01-01T00:00:00Z".into(),
518 },
519 )
520 .unwrap();
521 remove_edges_for_node(&conn, "a").unwrap();
522 assert!(read_edges(&conn, 10).unwrap().is_empty());
523 }
524
525 #[test]
526 fn append_edges_inserts_batch() {
527 let conn = mem_db();
528 let edges = vec![
529 GraphEdge {
530 id: "e1".into(),
531 source: "a".into(),
532 target: "b".into(),
533 relation: "cites".into(),
534 weight: 1.0,
535 ts: "2026-01-01T00:00:00Z".into(),
536 },
537 GraphEdge {
538 id: "e2".into(),
539 source: "a".into(),
540 target: "c".into(),
541 relation: "cites".into(),
542 weight: 0.5,
543 ts: "2026-01-01T00:00:00Z".into(),
544 },
545 ];
546 append_edges(&conn, &edges).unwrap();
547 assert_eq!(read_edges(&conn, 10).unwrap().len(), 2);
548 }
549
550 #[test]
551 fn append_edges_empty_is_noop() {
552 let conn = mem_db();
553 append_edges(&conn, &[]).unwrap();
554 assert!(read_edges(&conn, 10).unwrap().is_empty());
555 }
556
557 #[test]
558 fn append_edges_ignores_duplicate_id_and_unique() {
559 let conn = mem_db();
560 let e = GraphEdge {
561 id: "e1".into(),
562 source: "a".into(),
563 target: "b".into(),
564 relation: "cites".into(),
565 weight: 1.0,
566 ts: "2026-01-01T00:00:00Z".into(),
567 };
568 append_edges(&conn, std::slice::from_ref(&e)).unwrap();
569 append_edges(
572 &conn,
573 &[
574 e,
575 GraphEdge {
576 id: "e2".into(),
577 source: "a".into(),
578 target: "b".into(),
579 relation: "cites".into(),
580 weight: 1.0,
581 ts: "2026-01-01T00:00:00Z".into(),
582 },
583 ],
584 )
585 .unwrap();
586 assert_eq!(read_edges(&conn, 10).unwrap().len(), 1);
587 }
588
589 #[test]
590 fn edges_for_node_dir_filters_direction_and_relation() {
591 let conn = mem_db();
592 append_edges(
593 &conn,
594 &[
595 GraphEdge {
596 id: "e1".into(),
597 source: "a".into(),
598 target: "b".into(),
599 relation: "cites".into(),
600 weight: 1.0,
601 ts: "t".into(),
602 },
603 GraphEdge {
604 id: "e2".into(),
605 source: "c".into(),
606 target: "a".into(),
607 relation: "cites".into(),
608 weight: 1.0,
609 ts: "t".into(),
610 },
611 GraphEdge {
612 id: "e3".into(),
613 source: "a".into(),
614 target: "d".into(),
615 relation: "see_also".into(),
616 weight: 1.0,
617 ts: "t".into(),
618 },
619 ],
620 )
621 .unwrap();
622 assert_eq!(
624 edges_for_node_dir(&conn, "a", EdgeDirection::Out, None)
625 .unwrap()
626 .len(),
627 2
628 );
629 let out_cites = edges_for_node_dir(&conn, "a", EdgeDirection::Out, Some("cites")).unwrap();
631 assert_eq!(out_cites.len(), 1);
632 assert_eq!(out_cites[0].target, "b");
633 let inc = edges_for_node_dir(&conn, "a", EdgeDirection::In, None).unwrap();
635 assert_eq!(inc.len(), 1);
636 assert_eq!(inc[0].source, "c");
637 assert_eq!(
639 edges_for_node_dir(&conn, "a", EdgeDirection::Both, None)
640 .unwrap()
641 .len(),
642 3
643 );
644 }
645}