1use std::collections::{HashMap, HashSet};
33
34use sqlx::postgres::{PgPoolOptions, PgRow};
35use sqlx::{PgPool, Postgres, QueryBuilder, Row};
36
37use super::schema::GRAPH_SCHEMA_VERSION;
38use super::types::{EdgeDirection, GraphEdge, GraphNode, escape_like, join_csv, split_csv};
39use crate::error::{KernelError, Result};
40
41const NODE_COLUMNS: &str = "id, node_type, title, tags, projects, agents, \
43 created, updated, body, importance, access_count, accessed_at";
44
45fn pg_err(e: sqlx::Error) -> KernelError {
47 KernelError::Store(format!("sqlx: {e:?}"))
48}
49
50fn row_to_node(row: &PgRow) -> GraphNode {
52 let tags: String = row.get("tags");
53 let projects: String = row.get("projects");
54 let agents: String = row.get("agents");
55 GraphNode {
56 id: row.get("id"),
57 node_type: row.get("node_type"),
58 title: row.get("title"),
59 tags: split_csv(&tags),
60 projects: split_csv(&projects),
61 agents: split_csv(&agents),
62 created: row.get("created"),
63 updated: row.get("updated"),
64 body: row.get("body"),
65 importance: row.get("importance"),
66 access_count: row.get("access_count"),
67 accessed_at: row.get("accessed_at"),
68 }
69}
70
71fn row_to_edge(row: &PgRow) -> GraphEdge {
73 GraphEdge {
74 id: row.get("id"),
75 source: row.get("source"),
76 target: row.get("target"),
77 relation: row.get("relation"),
78 weight: row.get("weight"),
79 ts: row.get("ts"),
80 }
81}
82
83fn search_patterns(query: &str) -> Vec<String> {
88 query
89 .split_whitespace()
90 .map(|t| format!("%{}%", escape_like(t)))
91 .collect()
92}
93
94fn is_identifier_safe(prefix: &str) -> bool {
101 let mut chars = prefix.chars();
102 match chars.next() {
103 None => true,
104 Some(first) if first == '_' || first.is_ascii_alphabetic() => {
105 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
106 }
107 Some(_) => false,
108 }
109}
110
111pub struct SqlxPgGraph {
129 pool: PgPool,
130 table_prefix: String,
131}
132
133impl SqlxPgGraph {
134 pub async fn from_pool(pool: PgPool) -> Result<Self> {
137 Self::from_pool_with_prefix(pool, "").await
138 }
139
140 pub async fn from_pool_with_prefix(pool: PgPool, prefix: &str) -> Result<Self> {
144 if !is_identifier_safe(prefix) {
145 return Err(KernelError::Store(format!(
146 "invalid table prefix {prefix:?}: only ASCII letters, digits, and underscore are allowed (and the first character must not be a digit)"
147 )));
148 }
149 let graph = Self {
150 pool,
151 table_prefix: prefix.to_string(),
152 };
153 graph.init_schema().await?;
154 graph.migrate().await?;
155 Ok(graph)
156 }
157
158 pub async fn connect(url: &str) -> Result<Self> {
162 Self::connect_with_prefix(url, "").await
163 }
164
165 pub async fn connect_with_prefix(url: &str, prefix: &str) -> Result<Self> {
168 let pool = PgPoolOptions::new()
169 .max_connections(8)
170 .connect(url)
171 .await
172 .map_err(pg_err)?;
173 Self::from_pool_with_prefix(pool, prefix).await
174 }
175
176 pub fn pool(&self) -> &PgPool {
184 &self.pool
185 }
186
187 fn nodes_tbl(&self) -> String {
190 format!("{}nodes", self.table_prefix)
191 }
192
193 fn edges_tbl(&self) -> String {
194 format!("{}edges", self.table_prefix)
195 }
196
197 fn meta_tbl(&self) -> String {
198 format!("{}_meta", self.table_prefix)
199 }
200
201 async fn init_schema(&self) -> Result<()> {
208 let p = &self.table_prefix;
209 let nodes = self.nodes_tbl();
210 let edges = self.edges_tbl();
211 let meta = self.meta_tbl();
212 let idx = |name: &str| format!("{p}{name}");
213
214 let stmts: Vec<String> = vec![
215 format!(
216 "CREATE TABLE IF NOT EXISTS {nodes} (
217 id TEXT PRIMARY KEY,
218 node_type TEXT NOT NULL,
219 title TEXT NOT NULL,
220 tags TEXT NOT NULL DEFAULT '',
221 projects TEXT NOT NULL DEFAULT '',
222 agents TEXT NOT NULL DEFAULT '',
223 created TEXT NOT NULL,
224 updated TEXT NOT NULL,
225 body TEXT NOT NULL DEFAULT '',
226 importance DOUBLE PRECISION NOT NULL DEFAULT 0.5,
227 access_count BIGINT NOT NULL DEFAULT 0,
228 accessed_at TEXT NOT NULL DEFAULT ''
229 )"
230 ),
231 format!(
232 "CREATE TABLE IF NOT EXISTS {edges} (
233 id TEXT PRIMARY KEY,
234 source TEXT NOT NULL,
235 target TEXT NOT NULL,
236 relation TEXT NOT NULL DEFAULT 'related',
237 weight DOUBLE PRECISION NOT NULL DEFAULT 1.0,
238 ts TEXT NOT NULL
239 )"
240 ),
241 format!(
242 "CREATE INDEX IF NOT EXISTS {} ON {edges}(source)",
243 idx("idx_edges_source")
244 ),
245 format!(
246 "CREATE INDEX IF NOT EXISTS {} ON {edges}(target)",
247 idx("idx_edges_target")
248 ),
249 format!(
250 "CREATE UNIQUE INDEX IF NOT EXISTS {} ON {edges}(source, target, relation)",
251 idx("idx_edges_src_tgt_rel")
252 ),
253 format!(
254 "CREATE INDEX IF NOT EXISTS {} ON {edges}(source, relation)",
255 idx("idx_edges_src_rel")
256 ),
257 format!(
258 "CREATE INDEX IF NOT EXISTS {} ON {edges}(target, relation)",
259 idx("idx_edges_tgt_rel")
260 ),
261 format!(
262 "CREATE INDEX IF NOT EXISTS {} ON {nodes}(node_type)",
263 idx("idx_nodes_type")
264 ),
265 format!(
266 "CREATE INDEX IF NOT EXISTS {} ON {nodes}(updated DESC)",
267 idx("idx_nodes_updated")
268 ),
269 format!(
270 "CREATE INDEX IF NOT EXISTS {} ON {nodes}(importance DESC)",
271 idx("idx_nodes_importance")
272 ),
273 format!(
274 "CREATE INDEX IF NOT EXISTS {} ON {nodes}(accessed_at DESC)",
275 idx("idx_nodes_accessed")
276 ),
277 format!(
278 "CREATE INDEX IF NOT EXISTS {} ON {nodes}(created)",
279 idx("idx_nodes_created")
280 ),
281 format!(
282 "CREATE TABLE IF NOT EXISTS {meta} (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
283 ),
284 format!(
285 "INSERT INTO {meta} (key, value) VALUES ('graph_schema_version', '{}')
286 ON CONFLICT (key) DO NOTHING",
287 GRAPH_SCHEMA_VERSION
288 ),
289 ];
290
291 for ddl in &stmts {
292 sqlx::query(ddl).execute(&self.pool).await.map_err(pg_err)?;
293 }
294 Ok(())
295 }
296
297 pub async fn current_version(&self) -> Result<u32> {
299 let meta = self.meta_tbl();
300 let row = sqlx::query(&format!(
301 "SELECT value FROM {meta} WHERE key = 'graph_schema_version'"
302 ))
303 .fetch_optional(&self.pool)
304 .await
305 .map_err(pg_err)?;
306 match row {
307 Some(r) => {
308 let s: String = r.get("value");
309 Ok(s.parse().unwrap_or(0))
310 }
311 None => Ok(0),
312 }
313 }
314
315 pub async fn migrate(&self) -> Result<u32> {
320 let current = self.current_version().await?;
321 if current >= GRAPH_SCHEMA_VERSION {
322 return Ok(current);
323 }
324 let p = &self.table_prefix;
325 let nodes = self.nodes_tbl();
326 let edges = self.edges_tbl();
327 let meta = self.meta_tbl();
328 let idx = |name: &str| format!("{p}{name}");
329
330 let mut tx = self.pool.begin().await.map_err(pg_err)?;
331 let mut v = current;
332 if v < 2 {
334 sqlx::query(&format!(
335 "CREATE INDEX IF NOT EXISTS {} ON {nodes}(created)",
336 idx("idx_nodes_created")
337 ))
338 .execute(&mut *tx)
339 .await
340 .map_err(pg_err)?;
341 v = 2;
342 }
343 if v < 3 {
345 sqlx::query(&format!(
346 "CREATE INDEX IF NOT EXISTS {} ON {edges}(source, relation)",
347 idx("idx_edges_src_rel")
348 ))
349 .execute(&mut *tx)
350 .await
351 .map_err(pg_err)?;
352 sqlx::query(&format!(
353 "CREATE INDEX IF NOT EXISTS {} ON {edges}(target, relation)",
354 idx("idx_edges_tgt_rel")
355 ))
356 .execute(&mut *tx)
357 .await
358 .map_err(pg_err)?;
359 v = 3;
360 }
361 sqlx::query(&format!(
362 "UPDATE {meta} SET value = $1 WHERE key = 'graph_schema_version'"
363 ))
364 .bind(v.to_string())
365 .execute(&mut *tx)
366 .await
367 .map_err(pg_err)?;
368 tx.commit().await.map_err(pg_err)?;
369 Ok(v)
370 }
371
372 pub async fn upsert_node(&self, node: &GraphNode) -> Result<()> {
376 let tags = join_csv(&node.tags);
377 let projects = join_csv(&node.projects);
378 let agents = join_csv(&node.agents);
379 let nodes = self.nodes_tbl();
380 sqlx::query(&format!(
381 "INSERT INTO {nodes} (id, node_type, title, tags, projects, agents, created, updated, body, importance, access_count, accessed_at)
382 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
383 ON CONFLICT (id) DO UPDATE SET
384 node_type=EXCLUDED.node_type, title=EXCLUDED.title, tags=EXCLUDED.tags,
385 projects=EXCLUDED.projects, agents=EXCLUDED.agents, created=EXCLUDED.created,
386 updated=EXCLUDED.updated, body=EXCLUDED.body, importance=EXCLUDED.importance,
387 access_count=EXCLUDED.access_count, accessed_at=EXCLUDED.accessed_at"
388 ))
389 .bind(node.id.as_str())
390 .bind(node.node_type.as_str())
391 .bind(node.title.as_str())
392 .bind(tags.as_str())
393 .bind(projects.as_str())
394 .bind(agents.as_str())
395 .bind(node.created.as_str())
396 .bind(node.updated.as_str())
397 .bind(node.body.as_str())
398 .bind(node.importance)
399 .bind(node.access_count)
400 .bind(node.accessed_at.as_str())
401 .execute(&self.pool)
402 .await
403 .map_err(pg_err)?;
404 Ok(())
405 }
406
407 pub async fn read_node(&self, id: &str) -> Result<Option<GraphNode>> {
409 let nodes = self.nodes_tbl();
410 let row = sqlx::query(&format!("SELECT {NODE_COLUMNS} FROM {nodes} WHERE id = $1"))
411 .bind(id)
412 .fetch_optional(&self.pool)
413 .await
414 .map_err(pg_err)?;
415 Ok(row.as_ref().map(row_to_node))
416 }
417
418 pub async fn delete_node(&self, id: &str) -> Result<bool> {
420 let nodes = self.nodes_tbl();
421 let res = sqlx::query(&format!("DELETE FROM {nodes} WHERE id = $1"))
422 .bind(id)
423 .execute(&self.pool)
424 .await
425 .map_err(pg_err)?;
426 Ok(res.rows_affected() > 0)
427 }
428
429 pub async fn append_edge(&self, edge: &GraphEdge) -> Result<()> {
431 let edges = self.edges_tbl();
432 sqlx::query(&format!(
433 "INSERT INTO {edges} (id, source, target, relation, weight, ts)
434 VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING"
435 ))
436 .bind(edge.id.as_str())
437 .bind(edge.source.as_str())
438 .bind(edge.target.as_str())
439 .bind(edge.relation.as_str())
440 .bind(edge.weight)
441 .bind(edge.ts.as_str())
442 .execute(&self.pool)
443 .await
444 .map_err(pg_err)?;
445 Ok(())
446 }
447
448 pub async fn edges_for_node(&self, node_id: &str) -> Result<Vec<GraphEdge>> {
450 let edges = self.edges_tbl();
451 let rows = sqlx::query(&format!(
452 "SELECT id, source, target, relation, weight, ts FROM {edges} \
453 WHERE source = $1 OR target = $1"
454 ))
455 .bind(node_id)
456 .fetch_all(&self.pool)
457 .await
458 .map_err(pg_err)?;
459 Ok(rows.iter().map(row_to_edge).collect())
460 }
461
462 pub async fn delete_edge(&self, id: &str) -> Result<bool> {
464 let edges = self.edges_tbl();
465 let res = sqlx::query(&format!("DELETE FROM {edges} WHERE id = $1"))
466 .bind(id)
467 .execute(&self.pool)
468 .await
469 .map_err(pg_err)?;
470 Ok(res.rows_affected() > 0)
471 }
472
473 pub async fn append_edges(&self, edges: &[GraphEdge]) -> Result<()> {
480 if edges.is_empty() {
481 return Ok(());
482 }
483 let edges_tbl = self.edges_tbl();
484 let sql = format!(
485 "INSERT INTO {edges_tbl} (id, source, target, relation, weight, ts)
486 VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING"
487 );
488 const CHUNK: usize = 5000;
489 for chunk in edges.chunks(CHUNK) {
490 let mut tx = self.pool.begin().await.map_err(pg_err)?;
491 for e in chunk {
492 sqlx::query(&sql)
493 .bind(e.id.as_str())
494 .bind(e.source.as_str())
495 .bind(e.target.as_str())
496 .bind(e.relation.as_str())
497 .bind(e.weight)
498 .bind(e.ts.as_str())
499 .execute(&mut *tx)
500 .await
501 .map_err(pg_err)?;
502 }
503 tx.commit().await.map_err(pg_err)?;
504 }
505 Ok(())
506 }
507
508 pub async fn append_edges_in_tx(
514 &self,
515 tx: &mut sqlx::PgConnection,
516 edges: &[GraphEdge],
517 ) -> Result<()> {
518 if edges.is_empty() {
519 return Ok(());
520 }
521 let edges_tbl = self.edges_tbl();
522 let sql = format!(
523 "INSERT INTO {edges_tbl} (id, source, target, relation, weight, ts)
524 VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING"
525 );
526 for e in edges {
527 sqlx::query(&sql)
528 .bind(e.id.as_str())
529 .bind(e.source.as_str())
530 .bind(e.target.as_str())
531 .bind(e.relation.as_str())
532 .bind(e.weight)
533 .bind(e.ts.as_str())
534 .execute(&mut *tx)
535 .await
536 .map_err(pg_err)?;
537 }
538 Ok(())
539 }
540
541 pub async fn edges_for_node_dir(
545 &self,
546 node_id: &str,
547 dir: EdgeDirection,
548 relation: Option<&str>,
549 ) -> Result<Vec<GraphEdge>> {
550 let edges = self.edges_tbl();
551 let dir_clause = match dir {
552 EdgeDirection::Out => "source = $1",
553 EdgeDirection::In => "target = $1",
554 EdgeDirection::Both => "(source = $1 OR target = $1)",
555 };
556 let rows = if let Some(r) = relation {
557 let sql = format!(
558 "SELECT id, source, target, relation, weight, ts FROM {edges} \
559 WHERE {dir_clause} AND relation = $2 ORDER BY weight DESC"
560 );
561 sqlx::query(&sql)
562 .bind(node_id)
563 .bind(r)
564 .fetch_all(&self.pool)
565 .await
566 .map_err(pg_err)?
567 } else {
568 let sql = format!(
569 "SELECT id, source, target, relation, weight, ts FROM {edges} \
570 WHERE {dir_clause} ORDER BY weight DESC"
571 );
572 sqlx::query(&sql)
573 .bind(node_id)
574 .fetch_all(&self.pool)
575 .await
576 .map_err(pg_err)?
577 };
578 Ok(rows.iter().map(row_to_edge).collect())
579 }
580
581 pub async fn neighbors_weighted(
587 &self,
588 seed_ids: &[String],
589 dir: EdgeDirection,
590 relation: Option<&str>,
591 ) -> Result<Vec<(String, f64)>> {
592 if seed_ids.is_empty() {
593 return Ok(vec![]);
594 }
595 let edges = self.edges_tbl();
596 const MAX_SEEDS: usize = 100;
597 let seed_ids = if seed_ids.len() > MAX_SEEDS {
598 &seed_ids[..MAX_SEEDS]
599 } else {
600 seed_ids
601 };
602 let seed_arr: Vec<String> = seed_ids.to_vec();
603 let seed_set: HashSet<&str> = seed_ids.iter().map(String::as_str).collect();
604
605 let halves: &[&str] = match dir {
606 EdgeDirection::Out => &["source"],
607 EdgeDirection::In => &["target"],
608 EdgeDirection::Both => &["source", "target"],
609 };
610 let mut weights: HashMap<String, f64> = HashMap::new();
611
612 for &follow in halves {
613 let select_col = if follow == "source" {
616 "target"
617 } else {
618 "source"
619 };
620 let rel_clause = relation.map(|_| " AND relation = $2").unwrap_or("");
621 let sql = format!(
622 "SELECT {select_col} AS nb, SUM(weight) AS w FROM {edges} \
623 WHERE {follow} = ANY($1){rel_clause} GROUP BY {select_col}"
624 );
625 let rows = if let Some(r) = relation {
626 sqlx::query(&sql)
627 .bind(&seed_arr)
628 .bind(r)
629 .fetch_all(&self.pool)
630 .await
631 .map_err(pg_err)?
632 } else {
633 sqlx::query(&sql)
634 .bind(&seed_arr)
635 .fetch_all(&self.pool)
636 .await
637 .map_err(pg_err)?
638 };
639 for row in &rows {
640 let nb: String = row.get("nb");
641 let w: f64 = row.get("w");
642 if !seed_set.contains(nb.as_str()) {
643 *weights.entry(nb).or_default() += w;
644 }
645 }
646 }
647
648 let mut result: Vec<(String, f64)> = weights.into_iter().collect();
649 result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
650 Ok(result)
651 }
652
653 pub async fn remove_edges_for_node(&self, node_id: &str) -> Result<()> {
656 let edges = self.edges_tbl();
657 sqlx::query(&format!(
658 "DELETE FROM {edges} WHERE source = $1 OR target = $1"
659 ))
660 .bind(node_id)
661 .execute(&self.pool)
662 .await
663 .map_err(pg_err)?;
664 Ok(())
665 }
666
667 pub async fn remove_edges_for_node_in_tx(
670 &self,
671 tx: &mut sqlx::PgConnection,
672 node_id: &str,
673 ) -> Result<()> {
674 let edges = self.edges_tbl();
675 sqlx::query(&format!(
676 "DELETE FROM {edges} WHERE source = $1 OR target = $1"
677 ))
678 .bind(node_id)
679 .execute(tx)
680 .await
681 .map_err(pg_err)?;
682 Ok(())
683 }
684
685 pub async fn search_nodes(&self, query: &str, limit: usize) -> Result<Vec<GraphNode>> {
691 let terms = search_patterns(query);
692 if terms.is_empty() {
693 return Ok(vec![]);
694 }
695 let nodes = self.nodes_tbl();
696 let mut qb = QueryBuilder::<Postgres>::new("SELECT ");
697 qb.push(NODE_COLUMNS);
698 qb.push(" FROM ");
699 qb.push(nodes.as_str());
700 qb.push(" WHERE ");
701 for (i, term) in terms.iter().enumerate() {
702 if i > 0 {
703 qb.push(" AND ");
704 }
705 qb.push("(title || ' ' || body || ' ' || tags) ILIKE ");
706 qb.push_bind(term.clone());
707 qb.push(" ESCAPE '\\'");
708 }
709 qb.push(" ORDER BY importance DESC, updated DESC LIMIT ");
710 qb.push_bind(limit as i64);
711 let rows = qb.build().fetch_all(&self.pool).await.map_err(pg_err)?;
712 Ok(rows.iter().map(row_to_node).collect())
713 }
714
715 pub async fn related_nodes(&self, start_id: &str, depth: usize) -> Result<Vec<String>> {
719 let edges = self.edges_tbl();
720 let sql = format!(
724 "WITH RECURSIVE bfs(node_id, lvl) AS (
725 SELECT nb.node_id, 1 FROM (
726 SELECT target AS node_id FROM {edges} WHERE source = $1
727 UNION
728 SELECT source AS node_id FROM {edges} WHERE target = $1
729 ) nb
730 UNION
731 SELECT CASE WHEN e.source = bfs.node_id THEN e.target ELSE e.source END,
732 bfs.lvl + 1
733 FROM bfs
734 JOIN {edges} e ON e.source = bfs.node_id OR e.target = bfs.node_id
735 WHERE bfs.lvl < $2
736 )
737 SELECT DISTINCT node_id FROM bfs WHERE node_id <> $1 LIMIT 500"
738 );
739 let rows = sqlx::query(&sql)
740 .bind(start_id)
741 .bind(depth as i32)
742 .fetch_all(&self.pool)
743 .await
744 .map_err(pg_err)?;
745 Ok(rows
746 .iter()
747 .map(|r| {
748 let id: String = r.get("node_id");
749 id
750 })
751 .collect())
752 }
753
754 }
758
759#[cfg(test)]
760mod tests {
761 use super::*;
762
763 fn pg_url() -> Option<String> {
765 std::env::var("LLMKERNEL_PG_URL").ok()
766 }
767
768 fn sample_node(id: &str) -> GraphNode {
769 GraphNode {
770 id: id.to_string(),
771 node_type: "concept".to_string(),
772 title: format!("Node {id}"),
773 body: "sqlx pg test body".to_string(),
774 tags: vec!["sqlx".to_string()],
775 projects: vec![],
776 agents: vec![],
777 created: "2026-01-01T00:00:00Z".to_string(),
778 updated: "2026-01-01T00:00:00Z".to_string(),
779 importance: 0.5,
780 access_count: 0,
781 accessed_at: String::new(),
782 }
783 }
784
785 fn sample_edge(id: &str, src: &str, tgt: &str, rel: &str, w: f64) -> GraphEdge {
786 GraphEdge {
787 id: id.to_string(),
788 source: src.to_string(),
789 target: tgt.to_string(),
790 relation: rel.to_string(),
791 weight: w,
792 ts: "2026-01-01T00:00:00Z".to_string(),
793 }
794 }
795
796 async fn cleanup(pool: &PgPool, prefix: &str) {
799 let _ = sqlx::query(&format!("DROP TABLE IF EXISTS {}nodes", prefix))
800 .execute(pool)
801 .await;
802 let _ = sqlx::query(&format!("DROP TABLE IF EXISTS {}edges", prefix))
803 .execute(pool)
804 .await;
805 let _ = sqlx::query(&format!("DROP TABLE IF EXISTS {}_meta", prefix))
806 .execute(pool)
807 .await;
808 }
809
810 #[test]
815 fn search_patterns_escapes_and_wraps() {
816 assert!(search_patterns("").is_empty());
817 assert_eq!(search_patterns("rust"), vec!["%rust%".to_string()]);
818 assert_eq!(search_patterns("100%"), vec!["%100\\%%".to_string()]);
819 assert_eq!(search_patterns("a_b"), vec!["%a\\_b%".to_string()]);
820 assert_eq!(
821 search_patterns("rust db"),
822 vec!["%rust%".to_string(), "%db%".to_string()]
823 );
824 }
825
826 #[tokio::test]
830 async fn basic_crud_roundtrip() {
831 let Some(url) = pg_url() else {
832 eprintln!("skip: LLMKERNEL_PG_URL unset");
833 return;
834 };
835 let prefix = "lk_sg1_";
836 let g = SqlxPgGraph::connect_with_prefix(&url, prefix)
837 .await
838 .expect("connect");
839
840 assert!(g.read_node("n1").await.unwrap().is_none());
841 g.upsert_node(&sample_node("n1")).await.unwrap();
842 let loaded = g.read_node("n1").await.unwrap().unwrap();
843 assert_eq!(loaded.title, "Node n1");
844 assert_eq!(loaded.tags, vec!["sqlx".to_string()]);
845
846 let mut updated = sample_node("n1");
848 updated.title = "Updated".into();
849 g.upsert_node(&updated).await.unwrap();
850 assert_eq!(g.read_node("n1").await.unwrap().unwrap().title, "Updated");
851
852 g.upsert_node(&sample_node("n2")).await.unwrap();
854 g.append_edge(&sample_edge("e1", "n1", "n2", "related", 1.0))
855 .await
856 .unwrap();
857 assert_eq!(g.edges_for_node("n1").await.unwrap().len(), 1);
858 assert!(g.delete_edge("e1").await.unwrap());
859 assert!(!g.delete_edge("e1").await.unwrap());
860 assert_eq!(g.edges_for_node("n1").await.unwrap().len(), 0);
861
862 assert!(g.delete_node("n1").await.unwrap());
864 assert!(!g.delete_node("n1").await.unwrap());
865 assert!(g.read_node("n1").await.unwrap().is_none());
866
867 cleanup(g.pool(), prefix).await;
868 }
869
870 #[tokio::test]
873 async fn batch_edges_dedup() {
874 let Some(url) = pg_url() else {
875 eprintln!("skip: LLMKERNEL_PG_URL unset");
876 return;
877 };
878 let prefix = "lk_sg2_";
879 let g = SqlxPgGraph::connect_with_prefix(&url, prefix)
880 .await
881 .expect("connect");
882
883 for n in &["a", "b", "c"] {
884 g.upsert_node(&sample_node(n)).await.unwrap();
885 }
886
887 let edges = vec![
888 sample_edge("e1", "a", "b", "cites", 1.0),
889 sample_edge("e2", "a", "c", "cites", 0.8),
890 sample_edge("e1dup", "a", "b", "cites", 2.0),
892 ];
893 g.append_edges(&edges).await.unwrap();
894
895 let out = g
897 .edges_for_node_dir("a", EdgeDirection::Out, Some("cites"))
898 .await
899 .unwrap();
900 assert_eq!(out.len(), 2, "duplicate (src,tgt,rel) edge ignored");
901
902 g.append_edges(&[]).await.unwrap();
904
905 cleanup(g.pool(), prefix).await;
906 }
907
908 #[tokio::test]
910 async fn edges_dir_and_neighbors() {
911 let Some(url) = pg_url() else {
912 eprintln!("skip: LLMKERNEL_PG_URL unset");
913 return;
914 };
915 let prefix = "lk_sg3_";
916 let g = SqlxPgGraph::connect_with_prefix(&url, prefix)
917 .await
918 .expect("connect");
919
920 for n in &["seed", "t1", "t2", "t3"] {
921 g.upsert_node(&sample_node(n)).await.unwrap();
922 }
923
924 g.append_edges(&[
926 sample_edge("e1", "seed", "t1", "cites", 1.0),
927 sample_edge("e2", "seed", "t2", "cites", 0.5),
928 sample_edge("e3", "seed", "t3", "cites", 0.3),
929 ])
930 .await
931 .unwrap();
932 g.append_edge(&sample_edge("e4", "t1", "seed", "cites", 2.0))
933 .await
934 .unwrap();
935 g.append_edge(&sample_edge("e5", "seed", "t1", "related", 1.0))
936 .await
937 .unwrap();
938
939 let out_cites = g
941 .edges_for_node_dir("seed", EdgeDirection::Out, Some("cites"))
942 .await
943 .unwrap();
944 assert_eq!(out_cites.len(), 3);
945
946 let in_cites = g
948 .edges_for_node_dir("seed", EdgeDirection::In, Some("cites"))
949 .await
950 .unwrap();
951 assert_eq!(in_cites.len(), 1);
952 assert_eq!(in_cites[0].source, "t1");
953
954 let neighbors = g
956 .neighbors_weighted(&["seed".to_string()], EdgeDirection::Out, Some("cites"))
957 .await
958 .unwrap();
959 assert_eq!(neighbors.len(), 3);
960 assert_eq!(neighbors[0].0, "t1");
961 assert!((neighbors[0].1 - 1.0).abs() < 1e-9);
962 assert_eq!(neighbors[2].0, "t3");
963
964 cleanup(g.pool(), prefix).await;
965 }
966
967 #[tokio::test]
969 async fn remove_edges_for_node_drops_only_touching() {
970 let Some(url) = pg_url() else {
971 eprintln!("skip: LLMKERNEL_PG_URL unset");
972 return;
973 };
974 let prefix = "lk_sg4_";
975 let g = SqlxPgGraph::connect_with_prefix(&url, prefix)
976 .await
977 .expect("connect");
978
979 for n in &["x", "y", "z"] {
980 g.upsert_node(&sample_node(n)).await.unwrap();
981 }
982 g.append_edges(&[
983 sample_edge("e1", "x", "y", "cites", 1.0),
984 sample_edge("e2", "z", "x", "cites", 1.0),
985 sample_edge("e3", "y", "z", "cites", 1.0),
986 ])
987 .await
988 .unwrap();
989 assert_eq!(g.edges_for_node("x").await.unwrap().len(), 2);
990
991 g.remove_edges_for_node("x").await.unwrap();
992 assert_eq!(g.edges_for_node("x").await.unwrap().len(), 0);
993 assert_eq!(g.edges_for_node("y").await.unwrap().len(), 1);
995
996 cleanup(g.pool(), prefix).await;
997 }
998
999 #[tokio::test]
1002 async fn tx_atomicity_commit_and_rollback() {
1003 let Some(url) = pg_url() else {
1004 eprintln!("skip: LLMKERNEL_PG_URL unset");
1005 return;
1006 };
1007 let prefix = "lk_sg5_";
1008 let g = SqlxPgGraph::connect_with_prefix(&url, prefix)
1009 .await
1010 .expect("connect");
1011
1012 for n in &["p", "q"] {
1013 g.upsert_node(&sample_node(n)).await.unwrap();
1014 }
1015
1016 let mut tx = g.pool().begin().await.expect("begin tx");
1018 g.append_edges_in_tx(&mut tx, &[sample_edge("e1", "p", "q", "cites", 1.0)])
1019 .await
1020 .unwrap();
1021 tx.commit().await.expect("commit");
1022 assert_eq!(g.edges_for_node("p").await.unwrap().len(), 1);
1023
1024 let mut tx2 = g.pool().begin().await.expect("begin tx2");
1026 g.append_edges_in_tx(&mut tx2, &[sample_edge("e2", "p", "q", "cites", 1.0)])
1027 .await
1028 .unwrap();
1029 tx2.rollback().await.expect("rollback");
1030 assert_eq!(
1031 g.edges_for_node("p").await.unwrap().len(),
1032 1,
1033 "rolled-back edge not visible"
1034 );
1035
1036 let mut tx3 = g.pool().begin().await.expect("begin tx3");
1038 g.remove_edges_for_node_in_tx(&mut tx3, "p").await.unwrap();
1039 tx3.rollback().await.expect("rollback tx3");
1040 assert_eq!(g.edges_for_node("p").await.unwrap().len(), 1);
1041
1042 let mut tx4 = g.pool().begin().await.expect("begin tx4");
1044 g.remove_edges_for_node_in_tx(&mut tx4, "p").await.unwrap();
1045 tx4.commit().await.expect("commit tx4");
1046 assert_eq!(g.edges_for_node("p").await.unwrap().len(), 0);
1047
1048 cleanup(g.pool(), prefix).await;
1049 }
1050
1051 #[tokio::test]
1053 async fn search_related_and_version() {
1054 let Some(url) = pg_url() else {
1055 eprintln!("skip: LLMKERNEL_PG_URL unset");
1056 return;
1057 };
1058 let prefix = "lk_sg6_";
1059 let g = SqlxPgGraph::connect_with_prefix(&url, prefix)
1060 .await
1061 .expect("connect");
1062
1063 let mut rust = sample_node("rust");
1064 rust.title = "Rust ownership".into();
1065 rust.body = "borrow checker".into();
1066 g.upsert_node(&rust).await.unwrap();
1067 g.upsert_node(&sample_node("py")).await.unwrap();
1068
1069 let hits = g.search_nodes("rust", 10).await.unwrap();
1071 assert_eq!(hits.len(), 1);
1072 assert_eq!(hits[0].id, "rust");
1073 assert!(g.search_nodes("", 10).await.unwrap().is_empty());
1074
1075 g.append_edge(&sample_edge("e1", "rust", "py", "related", 1.0))
1077 .await
1078 .unwrap();
1079 let related = g.related_nodes("rust", 2).await.unwrap();
1080 assert!(related.contains(&"py".to_string()));
1081
1082 assert_eq!(g.current_version().await.unwrap(), GRAPH_SCHEMA_VERSION);
1084 assert_eq!(g.migrate().await.unwrap(), GRAPH_SCHEMA_VERSION);
1085
1086 cleanup(g.pool(), prefix).await;
1087 }
1088
1089 #[tokio::test]
1091 async fn invalid_prefix_rejected() {
1092 let Some(url) = pg_url() else {
1093 eprintln!("skip: LLMKERNEL_PG_URL unset");
1094 return;
1095 };
1096 assert!(
1097 SqlxPgGraph::connect_with_prefix(&url, "lk; drop")
1098 .await
1099 .is_err()
1100 );
1101 assert!(SqlxPgGraph::connect_with_prefix(&url, "1lk").await.is_err());
1102 }
1103}