1use std::sync::Arc;
4
5use async_trait::async_trait;
6use chrono::{DateTime, TimeZone, Utc};
7use uuid::Uuid;
8
9use khive_storage::error::StorageError;
10use khive_storage::types::{
11 BatchWriteSummary, DeleteMode, Edge, EdgeFilter, EdgeSortField, GraphPath, NeighborHit,
12 NeighborQuery, Page, PageRequest, PathNode, SortDirection, SortOrder, TraversalRequest,
13};
14use khive_storage::GraphStore;
15use khive_storage::LinkId;
16use khive_storage::StorageCapability;
17use khive_types::EdgeRelation;
18
19use crate::error::SqliteError;
20use crate::pool::ConnectionPool;
21
22fn map_err(e: rusqlite::Error, op: &'static str) -> StorageError {
24 StorageError::driver(StorageCapability::Graph, op, e)
25}
26
27fn map_sqlite_err(e: SqliteError, op: &'static str) -> StorageError {
28 StorageError::driver(StorageCapability::Graph, op, e)
29}
30
31pub struct SqlGraphStore {
33 pool: Arc<ConnectionPool>,
34 is_file_backed: bool,
35 namespace: String,
39}
40
41impl SqlGraphStore {
42 pub fn new_scoped(
50 pool: Arc<ConnectionPool>,
51 is_file_backed: bool,
52 namespace: impl Into<String>,
53 ) -> Self {
54 Self {
55 pool,
56 is_file_backed,
57 namespace: namespace.into(),
58 }
59 }
60
61 fn open_standalone_writer(&self) -> Result<rusqlite::Connection, StorageError> {
62 let config = self.pool.config();
63 let path = config.path.as_ref().ok_or_else(|| StorageError::Pool {
64 operation: "graph_writer".into(),
65 message: "in-memory databases do not support standalone connections".into(),
66 })?;
67
68 let conn = rusqlite::Connection::open_with_flags(
69 path,
70 rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE
71 | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX
72 | rusqlite::OpenFlags::SQLITE_OPEN_URI,
73 )
74 .map_err(|e| map_err(e, "open_graph_writer"))?;
75
76 conn.busy_timeout(config.busy_timeout)
77 .map_err(|e| map_err(e, "open_graph_writer"))?;
78 conn.pragma_update(None, "foreign_keys", "ON")
79 .map_err(|e| map_err(e, "open_graph_writer"))?;
80 conn.pragma_update(None, "synchronous", "NORMAL")
81 .map_err(|e| map_err(e, "open_graph_writer"))?;
82
83 Ok(conn)
84 }
85
86 fn open_standalone_reader(&self) -> Result<rusqlite::Connection, StorageError> {
87 let config = self.pool.config();
88 let path = config.path.as_ref().ok_or_else(|| StorageError::Pool {
89 operation: "graph_reader".into(),
90 message: "in-memory databases do not support standalone connections".into(),
91 })?;
92
93 let conn = rusqlite::Connection::open_with_flags(
94 path,
95 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY
96 | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX
97 | rusqlite::OpenFlags::SQLITE_OPEN_URI,
98 )
99 .map_err(|e| map_err(e, "open_graph_reader"))?;
100
101 conn.busy_timeout(config.busy_timeout)
102 .map_err(|e| map_err(e, "open_graph_reader"))?;
103 conn.pragma_update(None, "foreign_keys", "ON")
104 .map_err(|e| map_err(e, "open_graph_reader"))?;
105 conn.pragma_update(None, "synchronous", "NORMAL")
106 .map_err(|e| map_err(e, "open_graph_reader"))?;
107
108 Ok(conn)
109 }
110
111 async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
112 where
113 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
114 R: Send + 'static,
115 {
116 if self.is_file_backed {
117 let conn = self.open_standalone_writer()?;
118 tokio::task::spawn_blocking(move || f(&conn).map_err(|e| map_err(e, op)))
119 .await
120 .map_err(|e| StorageError::driver(StorageCapability::Graph, op, e))?
121 } else {
122 let pool = Arc::clone(&self.pool);
123 tokio::task::spawn_blocking(move || {
124 let guard = pool.try_writer().map_err(|e| map_sqlite_err(e, op))?;
125 f(guard.conn()).map_err(|e| map_err(e, op))
126 })
127 .await
128 .map_err(|e| StorageError::driver(StorageCapability::Graph, op, e))?
129 }
130 }
131
132 async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
133 where
134 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
135 R: Send + 'static,
136 {
137 if self.is_file_backed {
138 let conn = self.open_standalone_reader()?;
139 tokio::task::spawn_blocking(move || f(&conn).map_err(|e| map_err(e, op)))
140 .await
141 .map_err(|e| StorageError::driver(StorageCapability::Graph, op, e))?
142 } else {
143 let pool = Arc::clone(&self.pool);
144 tokio::task::spawn_blocking(move || {
145 let guard = pool.reader().map_err(|e| map_sqlite_err(e, op))?;
146 f(guard.conn()).map_err(|e| map_err(e, op))
147 })
148 .await
149 .map_err(|e| StorageError::driver(StorageCapability::Graph, op, e))?
150 }
151 }
152}
153
154fn read_edge(row: &rusqlite::Row<'_>) -> Result<Edge, rusqlite::Error> {
159 let namespace: String = row.get(0)?;
160 let id_str: String = row.get(1)?;
161 let source_str: String = row.get(2)?;
162 let target_str: String = row.get(3)?;
163 let relation_str: String = row.get(4)?;
164 let weight: f64 = row.get(5)?;
165 let created_micros: i64 = row.get(6)?;
166 let updated_micros: i64 = row.get(7)?;
167 let deleted_micros: Option<i64> = row.get(8)?;
168 let metadata_str: Option<String> = row.get(9)?;
169 let target_backend: Option<String> = row.get(10)?;
170
171 let id = parse_uuid(&id_str)?;
172 let source_id = parse_uuid(&source_str)?;
173 let target_id = parse_uuid(&target_str)?;
174 let created_at = micros_to_datetime(created_micros);
175 let relation = relation_str.parse::<EdgeRelation>().map_err(|e| {
176 rusqlite::Error::FromSqlConversionFailure(4, rusqlite::types::Type::Text, Box::new(e))
177 })?;
178 let metadata = match metadata_str {
179 Some(s) => {
180 let v = serde_json::from_str(&s).map_err(|e| {
181 rusqlite::Error::FromSqlConversionFailure(
182 9,
183 rusqlite::types::Type::Text,
184 Box::new(e),
185 )
186 })?;
187 Some(v)
188 }
189 None => None,
190 };
191
192 Ok(Edge {
193 id: id.into(),
194 namespace,
195 source_id,
196 target_id,
197 relation,
198 weight,
199 created_at,
200 updated_at: micros_to_datetime(updated_micros),
201 deleted_at: deleted_micros.map(micros_to_datetime),
202 metadata,
203 target_backend,
204 })
205}
206
207fn parse_uuid(s: &str) -> Result<Uuid, rusqlite::Error> {
208 Uuid::parse_str(s).map_err(|e| {
209 rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
210 })
211}
212
213fn micros_to_datetime(micros: i64) -> DateTime<Utc> {
214 Utc.timestamp_micros(micros)
215 .single()
216 .unwrap_or_else(Utc::now)
217}
218
219fn build_edge_filter_sql(
220 namespace: &str,
221 filter: &EdgeFilter,
222) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
223 let mut conditions: Vec<String> = vec![
224 "namespace = ?1".to_string(),
225 "deleted_at IS NULL".to_string(),
226 ];
227 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(namespace.to_string())];
228
229 if !filter.ids.is_empty() {
230 let placeholders: Vec<String> = filter
231 .ids
232 .iter()
233 .map(|id| {
234 params.push(Box::new(id.to_string()));
235 format!("?{}", params.len())
236 })
237 .collect();
238 conditions.push(format!("id IN ({})", placeholders.join(",")));
239 }
240
241 if !filter.source_ids.is_empty() {
242 let placeholders: Vec<String> = filter
243 .source_ids
244 .iter()
245 .map(|id| {
246 params.push(Box::new(id.to_string()));
247 format!("?{}", params.len())
248 })
249 .collect();
250 conditions.push(format!("source_id IN ({})", placeholders.join(",")));
251 }
252
253 if !filter.target_ids.is_empty() {
254 let placeholders: Vec<String> = filter
255 .target_ids
256 .iter()
257 .map(|id| {
258 params.push(Box::new(id.to_string()));
259 format!("?{}", params.len())
260 })
261 .collect();
262 conditions.push(format!("target_id IN ({})", placeholders.join(",")));
263 }
264
265 if !filter.relations.is_empty() {
266 let placeholders: Vec<String> = filter
267 .relations
268 .iter()
269 .map(|r| {
270 params.push(Box::new(r.to_string()));
271 format!("?{}", params.len())
272 })
273 .collect();
274 conditions.push(format!("relation IN ({})", placeholders.join(",")));
275 }
276
277 if let Some(min_w) = filter.min_weight {
278 params.push(Box::new(min_w));
279 conditions.push(format!("weight >= ?{}", params.len()));
280 }
281
282 if let Some(max_w) = filter.max_weight {
283 params.push(Box::new(max_w));
284 conditions.push(format!("weight <= ?{}", params.len()));
285 }
286
287 if let Some(ref time_range) = filter.created_at {
288 if let Some(start) = time_range.start {
289 params.push(Box::new(start.timestamp_micros()));
290 conditions.push(format!("created_at >= ?{}", params.len()));
291 }
292 if let Some(end) = time_range.end {
293 params.push(Box::new(end.timestamp_micros()));
294 conditions.push(format!("created_at < ?{}", params.len()));
295 }
296 }
297
298 let clause = format!(" WHERE {}", conditions.join(" AND "));
299 (clause, params)
300}
301
302fn edge_sort_col(field: &EdgeSortField) -> &'static str {
303 match field {
304 EdgeSortField::CreatedAt => "created_at",
305 EdgeSortField::Weight => "weight",
306 EdgeSortField::Relation => "relation",
307 }
308}
309
310fn canonical_edge_endpoints(
319 relation: EdgeRelation,
320 source_id: Uuid,
321 target_id: Uuid,
322) -> (Uuid, Uuid) {
323 if relation.is_symmetric() && target_id < source_id {
324 (target_id, source_id)
325 } else {
326 (source_id, target_id)
327 }
328}
329
330#[async_trait]
331impl GraphStore for SqlGraphStore {
332 async fn upsert_edge(&self, edge: Edge) -> Result<(), StorageError> {
333 let namespace = edge.namespace.clone();
334 let id_str = Uuid::from(edge.id).to_string();
335 let (source_id, target_id) =
336 canonical_edge_endpoints(edge.relation, edge.source_id, edge.target_id);
337 let src_str = source_id.to_string();
338 let tgt_str = target_id.to_string();
339 let relation_str = edge.relation.to_string();
340 let metadata_str = edge
341 .metadata
342 .as_ref()
343 .map(serde_json::to_string)
344 .transpose()
345 .map_err(|e| StorageError::driver(StorageCapability::Graph, "upsert_edge", e))?;
346 self.with_writer("upsert_edge", move |conn| {
347 conn.execute(
348 "INSERT INTO graph_edges \
349 (namespace, id, source_id, target_id, relation, weight, \
350 created_at, updated_at, deleted_at, metadata, target_backend) \
351 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) \
352 ON CONFLICT(namespace, id) DO UPDATE SET \
353 source_id = excluded.source_id, \
354 target_id = excluded.target_id, \
355 relation = excluded.relation, \
356 weight = excluded.weight, \
357 updated_at = excluded.updated_at, \
358 deleted_at = NULL, \
359 metadata = excluded.metadata, \
360 target_backend = excluded.target_backend \
361 ON CONFLICT(namespace, source_id, target_id, relation) DO UPDATE SET \
362 weight = excluded.weight, \
363 updated_at = excluded.updated_at, \
364 deleted_at = NULL, \
365 metadata = excluded.metadata, \
366 target_backend = excluded.target_backend",
367 rusqlite::params![
368 namespace,
369 id_str,
370 src_str,
371 tgt_str,
372 relation_str,
373 edge.weight,
374 edge.created_at.timestamp_micros(),
375 edge.updated_at.timestamp_micros(),
376 edge.deleted_at.map(|t| t.timestamp_micros()),
377 metadata_str,
378 edge.target_backend,
379 ],
380 )?;
381 Ok(())
382 })
383 .await
384 }
385
386 async fn upsert_edges(&self, edges: Vec<Edge>) -> Result<BatchWriteSummary, StorageError> {
387 let attempted = edges.len() as u64;
388
389 self.with_writer("upsert_edges", move |conn| {
390 conn.execute_batch("BEGIN IMMEDIATE")?;
391 let mut affected = 0u64;
392
393 for edge in &edges {
394 let id_str = Uuid::from(edge.id).to_string();
395 let (canon_src, canon_tgt) =
396 canonical_edge_endpoints(edge.relation, edge.source_id, edge.target_id);
397 let src_str = canon_src.to_string();
398 let tgt_str = canon_tgt.to_string();
399 let relation_str = edge.relation.to_string();
400 let metadata_str = edge
401 .metadata
402 .as_ref()
403 .map(serde_json::to_string)
404 .transpose()
405 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
406 if let Err(e) = conn.execute(
407 "INSERT INTO graph_edges \
408 (namespace, id, source_id, target_id, relation, weight, \
409 created_at, updated_at, deleted_at, metadata, target_backend) \
410 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) \
411 ON CONFLICT(namespace, id) DO UPDATE SET \
412 source_id = excluded.source_id, \
413 target_id = excluded.target_id, \
414 relation = excluded.relation, \
415 weight = excluded.weight, \
416 updated_at = excluded.updated_at, \
417 deleted_at = NULL, \
418 metadata = excluded.metadata, \
419 target_backend = excluded.target_backend \
420 ON CONFLICT(namespace, source_id, target_id, relation) DO UPDATE SET \
421 weight = excluded.weight, \
422 updated_at = excluded.updated_at, \
423 deleted_at = NULL, \
424 metadata = excluded.metadata, \
425 target_backend = excluded.target_backend",
426 rusqlite::params![
427 edge.namespace.as_str(),
428 id_str,
429 src_str,
430 tgt_str,
431 relation_str,
432 edge.weight,
433 edge.created_at.timestamp_micros(),
434 edge.updated_at.timestamp_micros(),
435 edge.deleted_at.map(|t| t.timestamp_micros()),
436 metadata_str,
437 edge.target_backend.as_deref(),
438 ],
439 ) {
440 let _ = conn.execute_batch("ROLLBACK");
441 return Err(e);
442 }
443 affected += 1;
444 }
445
446 if let Err(e) = conn.execute_batch("COMMIT") {
447 let _ = conn.execute_batch("ROLLBACK");
448 return Err(e);
449 }
450 Ok(BatchWriteSummary {
451 attempted,
452 affected,
453 failed: 0,
454 first_error: String::new(),
455 })
456 })
457 .await
458 }
459
460 async fn get_edge(&self, id: LinkId) -> Result<Option<Edge>, StorageError> {
461 let id_str = Uuid::from(id).to_string();
462
463 self.with_reader("get_edge", move |conn| {
464 let mut stmt = conn.prepare(
465 "SELECT namespace, id, source_id, target_id, relation, weight, \
466 created_at, updated_at, deleted_at, metadata, target_backend \
467 FROM graph_edges WHERE id = ?1 AND deleted_at IS NULL",
468 )?;
469 let mut rows = stmt.query(rusqlite::params![id_str])?;
470 match rows.next()? {
471 Some(row) => Ok(Some(read_edge(row)?)),
472 None => Ok(None),
473 }
474 })
475 .await
476 }
477
478 async fn get_edge_including_deleted(&self, id: LinkId) -> Result<Option<Edge>, StorageError> {
479 let id_str = Uuid::from(id).to_string();
480
481 self.with_reader("get_edge_including_deleted", move |conn| {
482 let mut stmt = conn.prepare(
483 "SELECT namespace, id, source_id, target_id, relation, weight, \
484 created_at, updated_at, deleted_at, metadata, target_backend \
485 FROM graph_edges WHERE id = ?1",
486 )?;
487 let mut rows = stmt.query(rusqlite::params![id_str])?;
488 match rows.next()? {
489 Some(row) => Ok(Some(read_edge(row)?)),
490 None => Ok(None),
491 }
492 })
493 .await
494 }
495
496 async fn get_edges(&self, ids: &[LinkId]) -> Result<Vec<Edge>, StorageError> {
497 if ids.is_empty() {
498 return Ok(Vec::new());
499 }
500 const CHUNK: usize = 900;
502 let id_strs: Vec<String> = ids.iter().map(|id| Uuid::from(*id).to_string()).collect();
503
504 let mut result: Vec<Edge> = Vec::with_capacity(ids.len());
505 for chunk in id_strs.chunks(CHUNK) {
506 let chunk_owned: Vec<String> = chunk.to_vec();
507 let edges = self
508 .with_reader("get_edges", move |conn| {
509 let placeholders: Vec<String> =
510 (1..=chunk_owned.len()).map(|i| format!("?{}", i)).collect();
511 let sql = format!(
512 "SELECT namespace, id, source_id, target_id, relation, weight, \
513 created_at, updated_at, deleted_at, metadata, target_backend \
514 FROM graph_edges WHERE id IN ({}) AND deleted_at IS NULL",
515 placeholders.join(",")
516 );
517 let mut stmt = conn.prepare(&sql)?;
518 let params: Vec<&dyn rusqlite::types::ToSql> = chunk_owned
519 .iter()
520 .map(|s| s as &dyn rusqlite::types::ToSql)
521 .collect();
522 let rows = stmt.query_map(params.as_slice(), read_edge)?;
523 let mut edges = Vec::new();
524 for row in rows {
525 edges.push(row?);
526 }
527 Ok(edges)
528 })
529 .await?;
530 result.extend(edges);
531 }
532 Ok(result)
533 }
534
535 async fn batch_neighbors(
536 &self,
537 sources: &[Uuid],
538 query: NeighborQuery,
539 ) -> Result<Vec<(Uuid, NeighborHit)>, StorageError> {
540 use khive_storage::types::Direction;
541
542 if sources.is_empty() {
543 return Ok(Vec::new());
544 }
545 let per_half_filter =
560 query.relations.as_ref().map_or(0, |r| r.len()) + query.min_weight.is_some() as usize;
561 let halves: usize = if query.direction == Direction::Both {
562 2
563 } else {
564 1
565 };
566 let fixed = 1 + 1 + halves * per_half_filter;
567 let max_src = (950usize.saturating_sub(fixed) / halves).max(1);
568 let chunk_size = max_src.min(880);
569
570 let namespace = self.namespace.clone();
571 let mut result: Vec<(Uuid, NeighborHit)> = Vec::new();
572
573 for chunk in sources.chunks(chunk_size) {
574 let chunk_owned: Vec<Uuid> = chunk.to_vec();
575 let query_clone = query.clone();
576 let ns = namespace.clone();
577
578 let pairs = self
579 .with_reader("batch_neighbors", move |conn| {
580 let src_strs: Vec<String> = chunk_owned.iter().map(|u| u.to_string()).collect();
581
582 let build_inner_sql =
587 |direction_out: bool,
588 first_src_param: usize,
589 q: &NeighborQuery|
590 -> (String, Vec<String>, Option<f64>) {
591 let placeholders: Vec<String> = (first_src_param
592 ..first_src_param + src_strs.len())
593 .map(|i| format!("?{i}"))
594 .collect();
595 let in_list = placeholders.join(",");
596
597 let (origin_col, filter_col, node_col) = if direction_out {
598 ("source_id", "source_id", "target_id")
599 } else {
600 ("target_id", "target_id", "source_id")
601 };
602
603 let mut rel_params: Vec<String> = Vec::new();
604 let mut conditions: Vec<String> = Vec::new();
605 let mut param_idx = first_src_param + src_strs.len();
606
607 if let Some(ref rels) = q.relations {
608 if !rels.is_empty() {
609 let ps: Vec<String> = rels
610 .iter()
611 .map(|r| {
612 rel_params.push(r.to_string());
613 let p = format!("?{param_idx}");
614 param_idx += 1;
615 p
616 })
617 .collect();
618 conditions.push(format!("relation IN ({})", ps.join(",")));
619 }
620 }
621
622 let min_weight_val = if let Some(min_w) = q.min_weight {
625 conditions.push(format!("weight >= ?{param_idx}"));
626 Some(min_w)
627 } else {
628 None
629 };
630
631 let where_extra = if conditions.is_empty() {
632 String::new()
633 } else {
634 format!(" AND {}", conditions.join(" AND "))
635 };
636
637 let sql = format!(
638 "SELECT {origin_col} AS origin_id, {node_col} AS node_id, \
639 id AS edge_id, relation, weight \
640 FROM graph_edges \
641 WHERE namespace = ?1 AND {filter_col} IN ({in_list}) \
642 AND deleted_at IS NULL{where_extra}",
643 );
644 (sql, rel_params, min_weight_val)
645 };
646
647 let mut all_params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
661 all_params.push(Box::new(ns.to_string())); let combined_inner: String;
664 let limit_param_idx: usize;
665
666 match query_clone.direction {
667 Direction::Out | Direction::In => {
668 let direction_out = matches!(query_clone.direction, Direction::Out);
669 let (sql, rel_params, min_weight_val) =
670 build_inner_sql(direction_out, 2, &query_clone);
671 combined_inner = sql;
672
673 for s in &src_strs {
675 all_params.push(Box::new(s.clone()));
676 }
677 for r in rel_params {
678 all_params.push(Box::new(r));
679 }
680 if let Some(mw) = min_weight_val {
681 all_params.push(Box::new(mw));
682 }
683 limit_param_idx = all_params.len() + 1;
684 }
685 Direction::Both => {
686 let (out_sql, out_rels, out_mw) =
688 build_inner_sql(true, 2, &query_clone);
689 let after_out_srcs = 2 + src_strs.len();
690 let after_out_rels = after_out_srcs + out_rels.len();
691 let after_out_mw =
692 after_out_rels + if out_mw.is_some() { 1 } else { 0 };
693 let in_first = after_out_mw;
694
695 let (in_sql, in_rels, in_mw) =
697 build_inner_sql(false, in_first, &query_clone);
698
699 combined_inner = format!("{out_sql} UNION ALL {in_sql}");
700
701 for s in &src_strs {
703 all_params.push(Box::new(s.clone())); }
705 for r in out_rels {
706 all_params.push(Box::new(r));
707 }
708 if let Some(mw) = out_mw {
709 all_params.push(Box::new(mw));
710 }
711 for s in &src_strs {
712 all_params.push(Box::new(s.clone())); }
714 for r in in_rels {
715 all_params.push(Box::new(r));
716 }
717 if let Some(mw) = in_mw {
718 all_params.push(Box::new(mw));
719 }
720 limit_param_idx = all_params.len() + 1;
721 }
722 }
723
724 let full_sql = if let Some(lim) = query_clone.limit {
726 all_params.push(Box::new(lim as i64));
727 format!(
728 "SELECT origin_id, node_id, edge_id, relation, weight \
729 FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY origin_id) AS rn \
730 FROM ({combined_inner})) WHERE rn <= ?{limit_param_idx}",
731 )
732 } else {
733 format!(
734 "SELECT origin_id, node_id, edge_id, relation, weight \
735 FROM ({combined_inner})",
736 )
737 };
738
739 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
740 all_params.iter().map(|p| p.as_ref()).collect();
741
742 let mut stmt = conn.prepare(&full_sql)?;
743 let rows = stmt.query_map(param_refs.as_slice(), |row| {
744 let origin_str: String = row.get(0)?;
745 let nid_str: String = row.get(1)?;
746 let eid_str: String = row.get(2)?;
747 let relation_str: String = row.get(3)?;
748 let weight: f64 = row.get(4)?;
749 Ok((origin_str, nid_str, eid_str, relation_str, weight))
750 })?;
751
752 let mut pairs = Vec::new();
753 for row in rows {
754 let (origin_str, nid_str, eid_str, relation_str, weight) = row?;
755 let origin = parse_uuid(&origin_str)?;
756 let node_id = parse_uuid(&nid_str)?;
757 let edge_id = parse_uuid(&eid_str)?;
758 let relation = relation_str.parse::<EdgeRelation>().map_err(|e| {
759 rusqlite::Error::FromSqlConversionFailure(
760 3,
761 rusqlite::types::Type::Text,
762 Box::new(e),
763 )
764 })?;
765 pairs.push((
766 origin,
767 NeighborHit {
768 node_id,
769 edge_id,
770 relation,
771 weight,
772 name: None,
773 kind: None,
774 entity_type: None,
775 },
776 ));
777 }
778 Ok(pairs)
779 })
780 .await?;
781 result.extend(pairs);
782 }
783 Ok(result)
784 }
785
786 async fn delete_edge(&self, id: LinkId, mode: DeleteMode) -> Result<bool, StorageError> {
787 let id_str = Uuid::from(id).to_string();
788
789 self.with_writer("delete_edge", move |conn| {
790 let affected = match mode {
791 DeleteMode::Soft => conn.execute(
792 "UPDATE graph_edges SET deleted_at = ?2, updated_at = ?2 \
793 WHERE id = ?1 AND deleted_at IS NULL",
794 rusqlite::params![id_str, chrono::Utc::now().timestamp_micros()],
795 )?,
796 DeleteMode::Hard => conn.execute(
797 "DELETE FROM graph_edges WHERE id = ?1",
798 rusqlite::params![id_str],
799 )?,
800 };
801 Ok(affected > 0)
802 })
803 .await
804 }
805
806 async fn query_edges(
807 &self,
808 filter: EdgeFilter,
809 sort: Vec<SortOrder<EdgeSortField>>,
810 page: PageRequest,
811 ) -> Result<Page<Edge>, StorageError> {
812 let namespace = self.namespace.clone();
813 self.with_reader("query_edges", move |conn| {
814 let (where_clause, filter_params) = build_edge_filter_sql(&namespace, &filter);
815
816 let count_sql = format!("SELECT COUNT(*) FROM graph_edges{}", where_clause);
817 let total: i64 = {
818 let mut stmt = conn.prepare(&count_sql)?;
819 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
820 filter_params.iter().map(|p| p.as_ref()).collect();
821 stmt.query_row(param_refs.as_slice(), |row| row.get(0))?
822 };
823
824 let order_clause = if sort.is_empty() {
825 " ORDER BY created_at DESC".to_string()
826 } else {
827 let parts: Vec<String> = sort
828 .iter()
829 .map(|s| {
830 let dir = match s.direction {
831 SortDirection::Asc => "ASC",
832 SortDirection::Desc => "DESC",
833 };
834 format!("{} {}", edge_sort_col(&s.field), dir)
835 })
836 .collect();
837 format!(" ORDER BY {}", parts.join(", "))
838 };
839
840 let (_, data_filter_params) = build_edge_filter_sql(&namespace, &filter);
841 let mut all_params: Vec<Box<dyn rusqlite::types::ToSql>> = data_filter_params;
842 all_params.push(Box::new(page.limit as i64));
843 all_params.push(Box::new(page.offset as i64));
844
845 let limit_idx = all_params.len() - 1;
846 let offset_idx = all_params.len();
847
848 let data_sql = format!(
849 "SELECT namespace, id, source_id, target_id, relation, weight, \
850 created_at, updated_at, deleted_at, metadata, target_backend \
851 FROM graph_edges{}{} LIMIT ?{} OFFSET ?{}",
852 where_clause, order_clause, limit_idx, offset_idx,
853 );
854
855 let mut stmt = conn.prepare(&data_sql)?;
856 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
857 all_params.iter().map(|p| p.as_ref()).collect();
858 let rows = stmt.query_map(param_refs.as_slice(), read_edge)?;
859
860 let mut items = Vec::new();
861 for row in rows {
862 items.push(row?);
863 }
864
865 Ok(Page {
866 items,
867 total: Some(total as u64),
868 })
869 })
870 .await
871 }
872
873 async fn count_edges(&self, filter: EdgeFilter) -> Result<u64, StorageError> {
874 let namespace = self.namespace.clone();
875 self.with_reader("count_edges", move |conn| {
876 let (where_clause, params) = build_edge_filter_sql(&namespace, &filter);
877 let sql = format!("SELECT COUNT(*) FROM graph_edges{}", where_clause);
878 let mut stmt = conn.prepare(&sql)?;
879 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
880 params.iter().map(|p| p.as_ref()).collect();
881 let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
882 Ok(count as u64)
883 })
884 .await
885 }
886
887 async fn neighbors(
888 &self,
889 node_id: Uuid,
890 query: NeighborQuery,
891 ) -> Result<Vec<NeighborHit>, StorageError> {
892 use khive_storage::types::Direction;
893
894 let namespace = self.namespace.clone();
895 let node_str = node_id.to_string();
896
897 self.with_reader("neighbors", move |conn| {
898 let base_out = "SELECT target_id AS node_id, id AS edge_id, relation, weight \
899 FROM graph_edges \
900 WHERE namespace = ?1 AND source_id = ?2 AND deleted_at IS NULL";
901 let base_in = "SELECT source_id AS node_id, id AS edge_id, relation, weight \
902 FROM graph_edges \
903 WHERE namespace = ?1 AND target_id = ?2 AND deleted_at IS NULL";
904
905 let sql = match query.direction {
906 Direction::Out => base_out.to_string(),
907 Direction::In => base_in.to_string(),
908 Direction::Both => format!("{} UNION ALL {}", base_out, base_in),
909 };
910
911 let mut conditions: Vec<String> = Vec::new();
912 let mut extra_params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
913 let mut param_idx = 3;
914
915 if let Some(ref rels) = query.relations {
916 if !rels.is_empty() {
917 let placeholders: Vec<String> = rels
918 .iter()
919 .map(|r| {
920 extra_params.push(Box::new(r.to_string()));
921 let p = format!("?{}", param_idx);
922 param_idx += 1;
923 p
924 })
925 .collect();
926 conditions.push(format!("relation IN ({})", placeholders.join(",")));
927 }
928 }
929
930 if let Some(min_w) = query.min_weight {
931 extra_params.push(Box::new(min_w));
932 conditions.push(format!("weight >= ?{}", param_idx));
933 param_idx += 1;
934 }
935
936 let where_extra = if conditions.is_empty() {
937 String::new()
938 } else {
939 format!(" WHERE {}", conditions.join(" AND "))
940 };
941
942 let limit_clause = if let Some(lim) = query.limit {
943 extra_params.push(Box::new(lim as i64));
944 format!(" LIMIT ?{}", param_idx)
945 } else {
946 String::new()
947 };
948
949 let full_sql = format!(
950 "SELECT node_id, edge_id, relation, weight FROM ({}){}{}",
951 sql, where_extra, limit_clause
952 );
953
954 let mut stmt = conn.prepare(&full_sql)?;
955
956 let mut all_params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
957 all_params.push(Box::new(namespace.clone()));
958 all_params.push(Box::new(node_str.clone()));
959 all_params.extend(extra_params);
960
961 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
962 all_params.iter().map(|p| p.as_ref()).collect();
963
964 let rows = stmt.query_map(param_refs.as_slice(), |row| {
965 let nid_str: String = row.get(0)?;
966 let eid_str: String = row.get(1)?;
967 let relation_str: String = row.get(2)?;
968 let weight: f64 = row.get(3)?;
969 Ok((nid_str, eid_str, relation_str, weight))
970 })?;
971
972 let mut hits = Vec::new();
973 for row in rows {
974 let (nid_str, eid_str, relation_str, weight) = row?;
975 let relation = relation_str.parse::<EdgeRelation>().map_err(|e| {
976 rusqlite::Error::FromSqlConversionFailure(
977 2,
978 rusqlite::types::Type::Text,
979 Box::new(e),
980 )
981 })?;
982 hits.push(NeighborHit {
983 node_id: parse_uuid(&nid_str)?,
984 edge_id: parse_uuid(&eid_str)?,
985 relation,
986 weight,
987 name: None,
988 kind: None,
989 entity_type: None,
990 });
991 }
992
993 Ok(hits)
994 })
995 .await
996 }
997
998 async fn traverse(&self, request: TraversalRequest) -> Result<Vec<GraphPath>, StorageError> {
999 use std::collections::{HashMap, HashSet};
1000
1001 use khive_storage::types::Direction;
1002
1003 if request.roots.is_empty() {
1004 return Ok(Vec::new());
1005 }
1006
1007 let roots = request.roots.clone();
1008 let opts = request.options.clone();
1009 let include_roots = request.include_roots;
1010 let namespace = self.namespace.clone();
1011
1012 self.with_reader("traverse", move |conn| {
1013 const CHUNK_ROOTS: usize = 400;
1026
1027 let (join_condition, next_node) = match opts.direction {
1029 Direction::Out => ("e.source_id = t.node_id", "e.target_id"),
1030 Direction::In => ("e.target_id = t.node_id", "e.source_id"),
1031 Direction::Both => (
1032 "(e.source_id = t.node_id OR e.target_id = t.node_id)",
1033 "CASE WHEN e.source_id = t.node_id THEN e.target_id ELSE e.source_id END",
1034 ),
1035 };
1036
1037 let tx = conn.unchecked_transaction()?;
1042
1043 let mut root_data: HashMap<Uuid, (Vec<(PathNode, f64)>, HashSet<Uuid>)> =
1048 HashMap::with_capacity(roots.len());
1049
1050 for root_id in &roots {
1052 let (nodes, seen) = root_data.entry(*root_id).or_default();
1053 if include_roots {
1054 seen.insert(*root_id);
1055 nodes.push((
1056 PathNode {
1057 node_id: *root_id,
1058 via_edge: None,
1059 depth: 0,
1060 name: None,
1061 kind: None,
1062 properties: None,
1063 },
1064 0.0,
1065 ));
1066 }
1067 }
1068
1069 for chunk in roots.chunks(CHUNK_ROOTS) {
1070 let n_chunk = chunk.len();
1071
1072 let ns_param = n_chunk + 1;
1078 let depth_param = n_chunk + 2;
1079 let mut extra_param_idx = n_chunk + 3;
1080
1081 let mut relation_cond = String::new();
1082 let mut extra_params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
1083
1084 if let Some(ref rels) = opts.relations {
1085 if !rels.is_empty() {
1086 let placeholders: Vec<String> = rels
1087 .iter()
1088 .map(|r| {
1089 extra_params.push(Box::new(r.to_string()));
1090 let p = format!("?{extra_param_idx}");
1091 extra_param_idx += 1;
1092 p
1093 })
1094 .collect();
1095 relation_cond = format!(" AND e.relation IN ({})", placeholders.join(","));
1096 }
1097 }
1098
1099 let mut weight_cond = String::new();
1100 if let Some(min_w) = opts.min_weight {
1101 extra_params.push(Box::new(min_w));
1102 weight_cond = format!(" AND e.weight >= ?{extra_param_idx}");
1103 }
1105
1106 let seed_rows: Vec<String> = (1..=n_chunk)
1109 .map(|i| format!("(?{i}, ?{i}, NULL, 0, ?{i}, 0.0)"))
1110 .collect();
1111 let seeds = seed_rows.join(", ");
1112
1113 let cte_sql = format!(
1117 "WITH RECURSIVE traversal(\
1118 root_id, node_id, edge_id, depth, path, total_weight\
1119 ) AS (\
1120 VALUES {seeds} \
1121 UNION ALL \
1122 SELECT t.root_id, {next_node}, e.id, t.depth + 1, \
1123 t.path || ',' || {next_node}, \
1124 t.total_weight + e.weight \
1125 FROM traversal t CROSS JOIN graph_edges e \
1126 ON {join_condition} \
1127 WHERE e.namespace = ?{ns} \
1128 AND e.deleted_at IS NULL \
1129 AND t.depth < ?{depth} \
1130 AND (',' || t.path || ',') NOT LIKE '%,' || {next_node} || ',%'\
1131 {rel_cond}{wt_cond} \
1132 ) \
1133 SELECT root_id, node_id, edge_id, depth, total_weight \
1134 FROM traversal WHERE depth > 0 \
1135 ORDER BY root_id, depth",
1136 seeds = seeds,
1137 next_node = next_node,
1138 join_condition = join_condition,
1139 ns = ns_param,
1140 depth = depth_param,
1141 rel_cond = relation_cond,
1142 wt_cond = weight_cond,
1143 );
1144
1145 let mut all_params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
1146 for root_id in chunk {
1147 all_params.push(Box::new(root_id.to_string()));
1148 }
1149 all_params.push(Box::new(namespace.clone()));
1150 all_params.push(Box::new(opts.max_depth as i64));
1151 all_params.extend(extra_params);
1152
1153 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1154 all_params.iter().map(|p| p.as_ref()).collect();
1155
1156 let mut stmt = conn.prepare(&cte_sql)?;
1159 let rows_iter = stmt.query_map(param_refs.as_slice(), |row| {
1160 let root_str: String = row.get(0)?;
1161 let node_str: String = row.get(1)?;
1162 let edge_str: Option<String> = row.get(2)?;
1163 let depth: i64 = row.get(3)?;
1164 let total_weight: f64 = row.get(4)?;
1165 Ok((root_str, node_str, edge_str, depth, total_weight))
1166 })?;
1167
1168 for row in rows_iter {
1172 let (root_str, node_str, edge_str, depth, total_weight) = row?;
1173 let root_id = parse_uuid(&root_str)?;
1174 let node_id = parse_uuid(&node_str)?;
1175 let (nodes, seen) = root_data.entry(root_id).or_default();
1176 if !seen.insert(node_id) {
1177 continue;
1178 }
1179 let via_edge = edge_str.map(|s| parse_uuid(&s)).transpose()?;
1180 nodes.push((
1181 PathNode {
1182 node_id,
1183 via_edge,
1184 depth: depth as usize,
1185 name: None,
1186 kind: None,
1187 properties: None,
1188 },
1189 total_weight,
1190 ));
1191 }
1192 }
1193
1194 tx.commit()?;
1195
1196 let mut all_paths: Vec<GraphPath> = Vec::with_capacity(roots.len());
1202 for root_id in &roots {
1203 if let Some((mut nw, _)) = root_data.remove(root_id) {
1204 if nw.is_empty() {
1205 continue;
1206 }
1207 if let Some(lim) = opts.limit {
1208 let root_count = usize::from(include_roots);
1209 nw.truncate(root_count + lim as usize);
1210 }
1211 if nw.is_empty() {
1214 continue;
1215 }
1216 let max_weight = nw.iter().map(|(_, w)| *w).fold(0.0_f64, f64::max);
1217 let nodes: Vec<PathNode> = nw.into_iter().map(|(n, _)| n).collect();
1218 all_paths.push(GraphPath {
1219 root_id: *root_id,
1220 nodes,
1221 total_weight: max_weight,
1222 });
1223 }
1224 }
1225
1226 Ok(all_paths)
1227 })
1228 .await
1229 }
1230
1231 async fn purge_incident_edges(&self, node_id: Uuid) -> Result<u64, StorageError> {
1232 let id_str = node_id.to_string();
1233 self.with_writer("purge_incident_edges", move |conn| {
1237 let affected = conn.execute(
1238 "DELETE FROM graph_edges WHERE source_id = ?1 OR target_id = ?1",
1239 rusqlite::params![id_str],
1240 )?;
1241 Ok(affected as u64)
1242 })
1243 .await
1244 }
1245}
1246
1247const GRAPH_DDL: &str = include_str!("../../sql/graph-ddl.sql");
1252
1253pub(crate) fn ensure_graph_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
1254 conn.execute_batch(GRAPH_DDL)
1255}
1256
1257#[cfg(test)]
1258#[path = "graph_tests.rs"]
1259mod tests;