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, DirectedNeighborHit, Direction, Edge, EdgeFilter, EdgeSeekPage,
12 EdgeSortField, GraphPath, GuardedBatchOutcome, GuardedBatchRefusal, GuardedWriteOutcome,
13 MissingEndpoints, NeighborHit, NeighborQuery, Page, PageRequest, PathNode, SortDirection,
14 SortOrder, SqlStatement, SqlValue, TraversalRequest,
15};
16use khive_storage::GraphStore;
17use khive_storage::LinkId;
18use khive_storage::StorageCapability;
19use khive_types::EdgeRelation;
20
21use crate::error::SqliteError;
22use crate::pool::ConnectionPool;
23use crate::sql_bridge::bind_params;
24use crate::writer_task::WriterTaskHandle;
25
26fn map_err(e: rusqlite::Error, op: &'static str) -> StorageError {
28 StorageError::driver(StorageCapability::Graph, op, e)
29}
30
31fn map_sqlite_err(e: SqliteError, op: &'static str) -> StorageError {
32 StorageError::driver(StorageCapability::Graph, op, e)
33}
34
35const EDGE_NATURAL_KEY_CONFLICT_SET: &str = "weight = excluded.weight, \
51 updated_at = excluded.updated_at, \
52 deleted_at = NULL, \
53 metadata = excluded.metadata, \
54 target_backend = excluded.target_backend";
55
56fn endpoint_exists_clause(id_param: &str) -> String {
67 format!(
68 "EXISTS (SELECT 1 FROM entities WHERE id = {id_param} AND deleted_at IS NULL) \
69 OR EXISTS (SELECT 1 FROM notes WHERE id = {id_param} AND deleted_at IS NULL) \
70 OR EXISTS (SELECT 1 FROM events WHERE id = {id_param}) \
71 OR EXISTS (SELECT 1 FROM graph_edges WHERE id = {id_param} AND deleted_at IS NULL)"
72 )
73}
74
75pub fn edge_upsert_statement(edge: &Edge) -> SqlStatement {
79 let (source_id, target_id) =
80 canonical_edge_endpoints(edge.relation, edge.source_id, edge.target_id);
81 let metadata_str = edge
82 .metadata
83 .as_ref()
84 .map(|v| serde_json::to_string(v).unwrap_or_default());
85 SqlStatement {
86 sql: format!(
87 "INSERT INTO graph_edges \
88 (namespace, id, source_id, target_id, relation, weight, \
89 created_at, updated_at, deleted_at, metadata, target_backend) \
90 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) \
91 ON CONFLICT(namespace, id) DO UPDATE SET \
92 source_id = excluded.source_id, \
93 target_id = excluded.target_id, \
94 relation = excluded.relation, \
95 {EDGE_NATURAL_KEY_CONFLICT_SET} \
96 ON CONFLICT(namespace, source_id, target_id, relation) DO UPDATE SET \
97 {EDGE_NATURAL_KEY_CONFLICT_SET}"
98 ),
99 params: vec![
100 SqlValue::Text(edge.namespace.clone()),
101 SqlValue::Text(Uuid::from(edge.id).to_string()),
102 SqlValue::Text(source_id.to_string()),
103 SqlValue::Text(target_id.to_string()),
104 SqlValue::Text(edge.relation.to_string()),
105 SqlValue::Float(edge.weight),
106 SqlValue::Integer(edge.created_at.timestamp_micros()),
107 SqlValue::Integer(edge.updated_at.timestamp_micros()),
108 match edge.deleted_at {
109 Some(t) => SqlValue::Integer(t.timestamp_micros()),
110 None => SqlValue::Null,
111 },
112 match metadata_str {
113 Some(m) => SqlValue::Text(m),
114 None => SqlValue::Null,
115 },
116 match &edge.target_backend {
117 Some(b) => SqlValue::Text(b.clone()),
118 None => SqlValue::Null,
119 },
120 ],
121 label: Some("edge-upsert".to_string()),
122 }
123}
124
125#[allow(clippy::too_many_arguments)]
145pub fn edge_insert_guarded_by_endpoints_statement(
146 namespace: &str,
147 edge_id: Uuid,
148 source_id: Uuid,
149 target_id: Uuid,
150 relation: EdgeRelation,
151 weight: f64,
152 now: i64,
153 metadata: Option<&str>,
154) -> SqlStatement {
155 let src_exists = endpoint_exists_clause("?3");
156 let tgt_exists = endpoint_exists_clause("?4");
157 SqlStatement {
158 sql: format!(
159 "INSERT INTO graph_edges \
160 (namespace, id, source_id, target_id, relation, weight, \
161 created_at, updated_at, metadata) \
162 SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, ?8 \
163 WHERE ({src_exists}) AND ({tgt_exists}) \
164 ON CONFLICT(namespace, source_id, target_id, relation) DO UPDATE SET \
165 {EDGE_NATURAL_KEY_CONFLICT_SET}"
166 ),
167 params: vec![
168 SqlValue::Text(namespace.to_string()),
169 SqlValue::Text(edge_id.to_string()),
170 SqlValue::Text(source_id.to_string()),
171 SqlValue::Text(target_id.to_string()),
172 SqlValue::Text(relation.as_str().to_string()),
173 SqlValue::Float(weight),
174 SqlValue::Integer(now),
175 match metadata {
176 Some(m) => SqlValue::Text(m.to_string()),
177 None => SqlValue::Null,
178 },
179 ],
180 label: Some("atomic-link-insert-edge-where-exists".to_string()),
181 }
182}
183
184pub fn edge_soft_delete_statement(id: Uuid, now: i64) -> SqlStatement {
186 SqlStatement {
187 sql: "UPDATE graph_edges SET deleted_at = ?2, updated_at = ?2 \
188 WHERE id = ?1 AND deleted_at IS NULL"
189 .to_string(),
190 params: vec![SqlValue::Text(id.to_string()), SqlValue::Integer(now)],
191 label: Some("edge-delete-soft".to_string()),
192 }
193}
194
195pub fn edge_hard_delete_statement(id: Uuid) -> SqlStatement {
197 SqlStatement {
198 sql: "DELETE FROM graph_edges WHERE id = ?1".to_string(),
199 params: vec![SqlValue::Text(id.to_string())],
200 label: Some("edge-delete-hard".to_string()),
201 }
202}
203
204pub fn purge_incident_edges_statement(node_id: Uuid) -> SqlStatement {
206 SqlStatement {
207 sql: "DELETE FROM graph_edges WHERE source_id = ?1 OR target_id = ?1".to_string(),
208 params: vec![SqlValue::Text(node_id.to_string())],
209 label: Some("edge-purge-incident".to_string()),
210 }
211}
212
213pub const EDGE_SYMMETRIC_CONFLICT_PROBE_SQL: &str = "SELECT id FROM graph_edges \
234 WHERE namespace = ?1 AND source_id = ?2 AND target_id = ?3 \
235 AND relation = ?4 AND id != ?5";
236
237pub const EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL: &str =
238 "DELETE FROM graph_edges WHERE namespace = ?1 AND id = ?2";
239
240pub const EDGE_SYMMETRIC_REFRESH_CANONICAL_SQL: &str = "UPDATE graph_edges SET \
241 weight = ?1, updated_at = ?2, deleted_at = NULL, \
242 target_backend = ?3, metadata = ?4 \
243 WHERE namespace = ?5 AND id = ?6";
244
245pub const EDGE_SYMMETRIC_UPDATE_INPLACE_SQL: &str = "UPDATE graph_edges SET \
246 source_id = ?1, target_id = ?2, relation = ?3, \
247 weight = ?4, updated_at = ?5, metadata = ?6 \
248 WHERE namespace = ?7 AND id = ?8";
249
250pub fn edge_symmetric_conflict_probe_statement(
253 namespace: &str,
254 canon_src: Uuid,
255 canon_tgt: Uuid,
256 relation: EdgeRelation,
257 exclude_id: Uuid,
258) -> SqlStatement {
259 SqlStatement {
260 sql: EDGE_SYMMETRIC_CONFLICT_PROBE_SQL.to_string(),
261 params: vec![
262 SqlValue::Text(namespace.to_string()),
263 SqlValue::Text(canon_src.to_string()),
264 SqlValue::Text(canon_tgt.to_string()),
265 SqlValue::Text(relation.to_string()),
266 SqlValue::Text(exclude_id.to_string()),
267 ],
268 label: Some("edge-symmetric-conflict-probe".to_string()),
269 }
270}
271
272pub fn edge_symmetric_delete_noncanonical_statement(namespace: &str, id: Uuid) -> SqlStatement {
275 SqlStatement {
276 sql: EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL.to_string(),
277 params: vec![
278 SqlValue::Text(namespace.to_string()),
279 SqlValue::Text(id.to_string()),
280 ],
281 label: Some("edge-symmetric-delete-noncanonical".to_string()),
282 }
283}
284
285#[allow(clippy::too_many_arguments)]
288pub fn edge_symmetric_refresh_canonical_statement(
289 namespace: &str,
290 existing_id: Uuid,
291 weight: f64,
292 updated_at_micros: i64,
293 target_backend: Option<&str>,
294 metadata: Option<&str>,
295) -> SqlStatement {
296 SqlStatement {
297 sql: EDGE_SYMMETRIC_REFRESH_CANONICAL_SQL.to_string(),
298 params: vec![
299 SqlValue::Float(weight),
300 SqlValue::Integer(updated_at_micros),
301 match target_backend {
302 Some(b) => SqlValue::Text(b.to_string()),
303 None => SqlValue::Null,
304 },
305 match metadata {
306 Some(m) => SqlValue::Text(m.to_string()),
307 None => SqlValue::Null,
308 },
309 SqlValue::Text(namespace.to_string()),
310 SqlValue::Text(existing_id.to_string()),
311 ],
312 label: Some("edge-symmetric-refresh-canonical".to_string()),
313 }
314}
315
316#[allow(clippy::too_many_arguments)]
319pub fn edge_symmetric_update_inplace_statement(
320 namespace: &str,
321 id: Uuid,
322 canon_src: Uuid,
323 canon_tgt: Uuid,
324 relation: EdgeRelation,
325 weight: f64,
326 updated_at_micros: i64,
327 metadata: Option<&str>,
328) -> SqlStatement {
329 SqlStatement {
330 sql: EDGE_SYMMETRIC_UPDATE_INPLACE_SQL.to_string(),
331 params: vec![
332 SqlValue::Text(canon_src.to_string()),
333 SqlValue::Text(canon_tgt.to_string()),
334 SqlValue::Text(relation.to_string()),
335 SqlValue::Float(weight),
336 SqlValue::Integer(updated_at_micros),
337 match metadata {
338 Some(m) => SqlValue::Text(m.to_string()),
339 None => SqlValue::Null,
340 },
341 SqlValue::Text(namespace.to_string()),
342 SqlValue::Text(id.to_string()),
343 ],
344 label: Some("edge-symmetric-update-inplace".to_string()),
345 }
346}
347
348pub fn edge_symmetric_delete_if_conflict_statement(
421 namespace: &str,
422 id: Uuid,
423 canon_src: Uuid,
424 canon_tgt: Uuid,
425 relation: EdgeRelation,
426) -> SqlStatement {
427 SqlStatement {
428 sql: "DELETE FROM graph_edges \
429 WHERE namespace = ?1 AND id = ?2 \
430 AND EXISTS ( \
431 SELECT 1 FROM graph_edges \
432 WHERE namespace = ?1 AND source_id = ?3 AND target_id = ?4 \
433 AND relation = ?5 AND id != ?2 \
434 )"
435 .to_string(),
436 params: vec![
437 SqlValue::Text(namespace.to_string()),
438 SqlValue::Text(id.to_string()),
439 SqlValue::Text(canon_src.to_string()),
440 SqlValue::Text(canon_tgt.to_string()),
441 SqlValue::Text(relation.to_string()),
442 ],
443 label: Some("edge-symmetric-delete-if-conflict".to_string()),
444 }
445}
446
447#[allow(clippy::too_many_arguments)]
448pub fn edge_symmetric_refresh_or_update_inplace_statement(
449 namespace: &str,
450 id: Uuid,
451 canon_src: Uuid,
452 canon_tgt: Uuid,
453 relation: EdgeRelation,
454 weight: f64,
455 updated_at_micros: i64,
456 metadata: Option<&str>,
457 target_backend: Option<&str>,
458) -> SqlStatement {
459 SqlStatement {
460 sql: "UPDATE graph_edges SET \
461 source_id = ?3, target_id = ?4, relation = ?5, \
462 weight = ?6, updated_at = ?7, deleted_at = NULL, metadata = ?8, \
463 target_backend = CASE WHEN changes() = 1 THEN ?9 ELSE target_backend END \
464 WHERE namespace = ?1 \
465 AND ( \
466 (id = ?2 AND changes() = 0) \
467 OR (source_id = ?3 AND target_id = ?4 AND relation = ?5 \
468 AND id != ?2 AND changes() = 1) \
469 )"
470 .to_string(),
471 params: vec![
472 SqlValue::Text(namespace.to_string()),
473 SqlValue::Text(id.to_string()),
474 SqlValue::Text(canon_src.to_string()),
475 SqlValue::Text(canon_tgt.to_string()),
476 SqlValue::Text(relation.to_string()),
477 SqlValue::Float(weight),
478 SqlValue::Integer(updated_at_micros),
479 match metadata {
480 Some(m) => SqlValue::Text(m.to_string()),
481 None => SqlValue::Null,
482 },
483 match target_backend {
484 Some(b) => SqlValue::Text(b.to_string()),
485 None => SqlValue::Null,
486 },
487 ],
488 label: Some("edge-symmetric-refresh-or-update-inplace".to_string()),
489 }
490}
491
492pub struct SqlGraphStore {
494 pool: Arc<ConnectionPool>,
495 is_file_backed: bool,
496 namespace: String,
500 writer_task: Option<WriterTaskHandle>,
501}
502
503impl SqlGraphStore {
504 pub fn new_scoped(
512 pool: Arc<ConnectionPool>,
513 is_file_backed: bool,
514 namespace: impl Into<String>,
515 ) -> Self {
516 let writer_task = pool.writer_task_handle().ok().flatten();
520
521 Self {
522 pool,
523 is_file_backed,
524 namespace: namespace.into(),
525 writer_task,
526 }
527 }
528
529 fn open_standalone_writer(&self) -> Result<rusqlite::Connection, StorageError> {
530 self.pool
531 .open_standalone_writer()
532 .map_err(|e| map_sqlite_err(e, "open_graph_writer"))
533 }
534
535 fn open_standalone_reader(&self) -> Result<rusqlite::Connection, StorageError> {
536 self.pool
537 .open_standalone_reader()
538 .map_err(|e| map_sqlite_err(e, "open_graph_reader"))
539 }
540
541 async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
556 where
557 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
558 R: Send + 'static,
559 {
560 if let Some(writer_task) = &self.writer_task {
561 return writer_task
562 .send(move |conn| f(conn).map_err(|e| map_err(e, op)))
563 .await;
564 }
565
566 if self.is_file_backed {
567 let conn = self.open_standalone_writer()?;
568 tokio::task::spawn_blocking(move || f(&conn).map_err(|e| map_err(e, op)))
569 .await
570 .map_err(|e| StorageError::driver(StorageCapability::Graph, op, e))?
571 } else {
572 let pool = Arc::clone(&self.pool);
573 tokio::task::spawn_blocking(move || {
574 let guard = pool.try_writer().map_err(|e| map_sqlite_err(e, op))?;
575 f(guard.conn()).map_err(|e| map_err(e, op))
576 })
577 .await
578 .map_err(|e| StorageError::driver(StorageCapability::Graph, op, e))?
579 }
580 }
581
582 async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
583 where
584 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
585 R: Send + 'static,
586 {
587 if self.is_file_backed {
588 let conn = self.open_standalone_reader()?;
589 tokio::task::spawn_blocking(move || f(&conn).map_err(|e| map_err(e, op)))
590 .await
591 .map_err(|e| StorageError::driver(StorageCapability::Graph, op, e))?
592 } else {
593 let pool = Arc::clone(&self.pool);
594 tokio::task::spawn_blocking(move || {
595 let guard = pool.reader().map_err(|e| map_sqlite_err(e, op))?;
596 f(guard.conn()).map_err(|e| map_err(e, op))
597 })
598 .await
599 .map_err(|e| StorageError::driver(StorageCapability::Graph, op, e))?
600 }
601 }
602}
603
604fn read_edge(row: &rusqlite::Row<'_>) -> Result<Edge, rusqlite::Error> {
609 let namespace: String = row.get(0)?;
610 let id_str: String = row.get(1)?;
611 let source_str: String = row.get(2)?;
612 let target_str: String = row.get(3)?;
613 let relation_str: String = row.get(4)?;
614 let weight: f64 = row.get(5)?;
615 let created_micros: i64 = row.get(6)?;
616 let updated_micros: i64 = row.get(7)?;
617 let deleted_micros: Option<i64> = row.get(8)?;
618 let metadata_str: Option<String> = row.get(9)?;
619 let target_backend: Option<String> = row.get(10)?;
620
621 let id = parse_uuid(&id_str)?;
622 let source_id = parse_uuid(&source_str)?;
623 let target_id = parse_uuid(&target_str)?;
624 let created_at = micros_to_datetime(created_micros);
625 let relation = relation_str.parse::<EdgeRelation>().map_err(|e| {
626 rusqlite::Error::FromSqlConversionFailure(4, rusqlite::types::Type::Text, Box::new(e))
627 })?;
628 let metadata = match metadata_str {
629 Some(s) => {
630 let v = serde_json::from_str(&s).map_err(|e| {
631 rusqlite::Error::FromSqlConversionFailure(
632 9,
633 rusqlite::types::Type::Text,
634 Box::new(e),
635 )
636 })?;
637 Some(v)
638 }
639 None => None,
640 };
641
642 Ok(Edge {
643 id: id.into(),
644 namespace,
645 source_id,
646 target_id,
647 relation,
648 weight,
649 created_at,
650 updated_at: micros_to_datetime(updated_micros),
651 deleted_at: deleted_micros.map(micros_to_datetime),
652 metadata,
653 target_backend,
654 })
655}
656
657fn parse_uuid(s: &str) -> Result<Uuid, rusqlite::Error> {
658 Uuid::parse_str(s).map_err(|e| {
659 rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
660 })
661}
662
663fn neighbor_extra_clause(
669 query: &NeighborQuery,
670 start_param_idx: usize,
671) -> (String, String, Vec<Box<dyn rusqlite::types::ToSql>>) {
672 let mut conditions: Vec<String> = Vec::new();
673 let mut extra_params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
674 let mut param_idx = start_param_idx;
675
676 if let Some(ref rels) = query.relations {
677 if !rels.is_empty() {
678 let placeholders: Vec<String> = rels
679 .iter()
680 .map(|r| {
681 extra_params.push(Box::new(r.to_string()));
682 let p = format!("?{}", param_idx);
683 param_idx += 1;
684 p
685 })
686 .collect();
687 conditions.push(format!("relation IN ({})", placeholders.join(",")));
688 }
689 }
690
691 if let Some(min_w) = query.min_weight {
692 extra_params.push(Box::new(min_w));
693 conditions.push(format!("weight >= ?{}", param_idx));
694 param_idx += 1;
695 }
696
697 let where_extra = if conditions.is_empty() {
698 String::new()
699 } else {
700 format!(" WHERE {}", conditions.join(" AND "))
701 };
702
703 let limit_clause = if let Some(lim) = query.limit {
704 extra_params.push(Box::new(lim as i64));
705 format!(" LIMIT ?{}", param_idx)
706 } else {
707 String::new()
708 };
709
710 (where_extra, limit_clause, extra_params)
711}
712
713#[cfg(test)]
721static NEIGHBOR_SELECT_COUNT: std::sync::atomic::AtomicUsize =
722 std::sync::atomic::AtomicUsize::new(0);
723
724#[cfg(test)]
725fn count_neighbor_select() {
726 NEIGHBOR_SELECT_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
727}
728
729#[cfg(not(test))]
730fn count_neighbor_select() {}
731
732#[cfg(test)]
733pub(crate) fn reset_neighbor_select_count() {
734 NEIGHBOR_SELECT_COUNT.store(0, std::sync::atomic::Ordering::Relaxed);
735}
736
737#[cfg(test)]
738pub(crate) fn neighbor_select_count() -> usize {
739 NEIGHBOR_SELECT_COUNT.load(std::sync::atomic::Ordering::Relaxed)
740}
741
742fn micros_to_datetime(micros: i64) -> DateTime<Utc> {
743 Utc.timestamp_micros(micros)
744 .single()
745 .unwrap_or_else(Utc::now)
746}
747
748fn build_edge_filter_sql(
749 namespace: &str,
750 filter: &EdgeFilter,
751) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
752 let mut conditions: Vec<String> = vec![
753 "namespace = ?1".to_string(),
754 "deleted_at IS NULL".to_string(),
755 ];
756 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(namespace.to_string())];
757
758 if !filter.ids.is_empty() {
759 let placeholders: Vec<String> = filter
760 .ids
761 .iter()
762 .map(|id| {
763 params.push(Box::new(id.to_string()));
764 format!("?{}", params.len())
765 })
766 .collect();
767 conditions.push(format!("id IN ({})", placeholders.join(",")));
768 }
769
770 if !filter.source_ids.is_empty() {
771 let placeholders: Vec<String> = filter
772 .source_ids
773 .iter()
774 .map(|id| {
775 params.push(Box::new(id.to_string()));
776 format!("?{}", params.len())
777 })
778 .collect();
779 conditions.push(format!("source_id IN ({})", placeholders.join(",")));
780 }
781
782 if !filter.target_ids.is_empty() {
783 let placeholders: Vec<String> = filter
784 .target_ids
785 .iter()
786 .map(|id| {
787 params.push(Box::new(id.to_string()));
788 format!("?{}", params.len())
789 })
790 .collect();
791 conditions.push(format!("target_id IN ({})", placeholders.join(",")));
792 }
793
794 if !filter.relations.is_empty() {
795 let placeholders: Vec<String> = filter
796 .relations
797 .iter()
798 .map(|r| {
799 params.push(Box::new(r.to_string()));
800 format!("?{}", params.len())
801 })
802 .collect();
803 conditions.push(format!("relation IN ({})", placeholders.join(",")));
804 }
805
806 if let Some(min_w) = filter.min_weight {
807 params.push(Box::new(min_w));
808 conditions.push(format!("weight >= ?{}", params.len()));
809 }
810
811 if let Some(max_w) = filter.max_weight {
812 params.push(Box::new(max_w));
813 conditions.push(format!("weight <= ?{}", params.len()));
814 }
815
816 if let Some(ref time_range) = filter.created_at {
817 if let Some(start) = time_range.start {
818 params.push(Box::new(start.timestamp_micros()));
819 conditions.push(format!("created_at >= ?{}", params.len()));
820 }
821 if let Some(end) = time_range.end {
822 params.push(Box::new(end.timestamp_micros()));
823 conditions.push(format!("created_at < ?{}", params.len()));
824 }
825 }
826
827 let clause = format!(" WHERE {}", conditions.join(" AND "));
828 (clause, params)
829}
830
831fn edge_sort_col(field: &EdgeSortField) -> &'static str {
832 match field {
833 EdgeSortField::CreatedAt => "created_at",
834 EdgeSortField::Weight => "weight",
835 EdgeSortField::Relation => "relation",
836 }
837}
838
839fn canonical_edge_endpoints(
848 relation: EdgeRelation,
849 source_id: Uuid,
850 target_id: Uuid,
851) -> (Uuid, Uuid) {
852 if relation.is_symmetric() && target_id < source_id {
853 (target_id, source_id)
854 } else {
855 (source_id, target_id)
856 }
857}
858
859fn batch_upsert_edges(
879 conn: &rusqlite::Connection,
880 edges: &[Edge],
881 attempted: u64,
882) -> Result<BatchWriteSummary, rusqlite::Error> {
883 let mut affected = 0u64;
884
885 for edge in edges {
886 let statement = edge_upsert_statement(edge);
887 let mut stmt = conn.prepare(&statement.sql)?;
888 bind_params(&mut stmt, &statement.params)?;
889 stmt.raw_execute()?;
890 affected += 1;
891 }
892
893 Ok(BatchWriteSummary {
894 attempted,
895 affected,
896 failed: 0,
897 first_error: String::new(),
898 })
899}
900
901fn edge_endpoints_exist(
919 conn: &rusqlite::Connection,
920 source_id: Uuid,
921 target_id: Uuid,
922) -> Result<MissingEndpoints, rusqlite::Error> {
923 let src_exists = endpoint_exists_clause("?1");
924 let tgt_exists = endpoint_exists_clause("?2");
925 let sql = format!("SELECT ({src_exists}), ({tgt_exists})");
926 conn.query_row(
927 &sql,
928 rusqlite::params![source_id.to_string(), target_id.to_string()],
929 |row| {
930 let src_exists: bool = row.get(0)?;
931 let tgt_exists: bool = row.get(1)?;
932 Ok(MissingEndpoints {
933 source: !src_exists,
934 target: !tgt_exists,
935 })
936 },
937 )
938}
939
940fn edge_insert_guarded(
951 conn: &rusqlite::Connection,
952 statement: &SqlStatement,
953 source_id: Uuid,
954 target_id: Uuid,
955) -> Result<GuardedWriteOutcome, rusqlite::Error> {
956 let mut stmt = conn.prepare(&statement.sql)?;
957 bind_params(&mut stmt, &statement.params)?;
958 if stmt.raw_execute()? > 0 {
959 return Ok(GuardedWriteOutcome::Written);
960 }
961 #[cfg(test)]
969 tests::insert_probe_seam::hook((source_id, target_id));
970 let missing = edge_endpoints_exist(conn, source_id, target_id)?;
971 Ok(GuardedWriteOutcome::Refused(missing))
972}
973
974fn batch_upsert_edges_guarded(
990 conn: &rusqlite::Connection,
991 edges: &[Edge],
992 attempted: u64,
993) -> Result<GuardedBatchOutcome, rusqlite::Error> {
994 for (index, edge) in edges.iter().enumerate() {
995 let (source_id, target_id) =
996 canonical_edge_endpoints(edge.relation, edge.source_id, edge.target_id);
997 let missing = edge_endpoints_exist(conn, source_id, target_id)?;
998 if missing.any() {
999 return Ok(GuardedBatchOutcome {
1000 summary: BatchWriteSummary {
1001 attempted,
1002 affected: 0,
1003 failed: attempted,
1004 first_error: format!(
1005 "batch entry {index}: edge endpoint no longer exists at write time: source {source_id} or target {target_id}"
1006 ),
1007 },
1008 refused: Some(GuardedBatchRefusal {
1009 entry_index: index,
1010 missing,
1011 }),
1012 });
1013 }
1014 }
1015
1016 let mut affected = 0u64;
1017 for edge in edges {
1018 let statement = edge_upsert_statement(edge);
1019 let mut stmt = conn.prepare(&statement.sql)?;
1020 bind_params(&mut stmt, &statement.params)?;
1021 stmt.raw_execute()?;
1022 affected += 1;
1023 }
1024
1025 Ok(GuardedBatchOutcome {
1026 summary: BatchWriteSummary {
1027 attempted,
1028 affected,
1029 failed: 0,
1030 first_error: String::new(),
1031 },
1032 refused: None,
1033 })
1034}
1035
1036#[async_trait]
1037impl GraphStore for SqlGraphStore {
1038 async fn upsert_edge(&self, edge: Edge) -> Result<(), StorageError> {
1039 let statement = edge_upsert_statement(&edge);
1040 self.with_writer("upsert_edge", move |conn| {
1041 let mut stmt = conn.prepare(&statement.sql)?;
1042 bind_params(&mut stmt, &statement.params)?;
1043 stmt.raw_execute()?;
1044 Ok(())
1045 })
1046 .await
1047 }
1048
1049 async fn upsert_edges(&self, edges: Vec<Edge>) -> Result<BatchWriteSummary, StorageError> {
1050 let attempted = edges.len() as u64;
1051
1052 if let Some(writer_task) = &self.writer_task {
1058 return writer_task
1059 .send(move |conn| {
1060 batch_upsert_edges(conn, &edges, attempted)
1061 .map_err(|e| map_err(e, "upsert_edges"))
1062 })
1063 .await;
1064 }
1065
1066 self.with_writer("upsert_edges", move |conn| {
1070 conn.execute_batch("BEGIN IMMEDIATE")?;
1071 let _tx_handle =
1072 khive_storage::tx_registry::register(Some("graph_upsert_edges".to_string()));
1073
1074 let summary = match batch_upsert_edges(conn, &edges, attempted) {
1075 Ok(summary) => summary,
1076 Err(e) => {
1077 let _ = conn.execute_batch("ROLLBACK");
1078 return Err(e);
1079 }
1080 };
1081
1082 if let Err(e) = conn.execute_batch("COMMIT") {
1083 let _ = conn.execute_batch("ROLLBACK");
1084 return Err(e);
1085 }
1086 Ok(summary)
1087 })
1088 .await
1089 }
1090
1091 async fn upsert_edge_guarded(&self, edge: Edge) -> Result<GuardedWriteOutcome, StorageError> {
1092 let (source_id, target_id) =
1093 canonical_edge_endpoints(edge.relation, edge.source_id, edge.target_id);
1094 let metadata_str = edge
1095 .metadata
1096 .as_ref()
1097 .map(|v| serde_json::to_string(v).unwrap_or_default());
1098 let statement = edge_insert_guarded_by_endpoints_statement(
1099 &edge.namespace,
1100 Uuid::from(edge.id),
1101 source_id,
1102 target_id,
1103 edge.relation,
1104 edge.weight,
1105 edge.created_at.timestamp_micros(),
1106 metadata_str.as_deref(),
1107 );
1108
1109 if let Some(writer_task) = &self.writer_task {
1115 return writer_task
1116 .send(move |conn| {
1117 edge_insert_guarded(conn, &statement, source_id, target_id)
1118 .map_err(|e| map_err(e, "upsert_edge_guarded"))
1119 })
1120 .await;
1121 }
1122
1123 self.with_writer("upsert_edge_guarded", move |conn| {
1129 conn.execute_batch("BEGIN IMMEDIATE")?;
1130 let _tx_handle =
1131 khive_storage::tx_registry::register(Some("graph_upsert_edge_guarded".to_string()));
1132
1133 let outcome = match edge_insert_guarded(conn, &statement, source_id, target_id) {
1134 Ok(outcome) => outcome,
1135 Err(e) => {
1136 let _ = conn.execute_batch("ROLLBACK");
1137 return Err(e);
1138 }
1139 };
1140
1141 if let Err(e) = conn.execute_batch("COMMIT") {
1142 let _ = conn.execute_batch("ROLLBACK");
1143 return Err(e);
1144 }
1145 Ok(outcome)
1146 })
1147 .await
1148 }
1149
1150 async fn upsert_edges_guarded(
1151 &self,
1152 edges: Vec<Edge>,
1153 ) -> Result<GuardedBatchOutcome, StorageError> {
1154 let attempted = edges.len() as u64;
1155
1156 if let Some(writer_task) = &self.writer_task {
1160 return writer_task
1161 .send(move |conn| {
1162 batch_upsert_edges_guarded(conn, &edges, attempted)
1163 .map_err(|e| map_err(e, "upsert_edges_guarded"))
1164 })
1165 .await;
1166 }
1167
1168 self.with_writer("upsert_edges_guarded", move |conn| {
1169 conn.execute_batch("BEGIN IMMEDIATE")?;
1170 let _tx_handle = khive_storage::tx_registry::register(Some(
1171 "graph_upsert_edges_guarded".to_string(),
1172 ));
1173
1174 let summary = match batch_upsert_edges_guarded(conn, &edges, attempted) {
1175 Ok(summary) => summary,
1176 Err(e) => {
1177 let _ = conn.execute_batch("ROLLBACK");
1178 return Err(e);
1179 }
1180 };
1181
1182 if let Err(e) = conn.execute_batch("COMMIT") {
1183 let _ = conn.execute_batch("ROLLBACK");
1184 return Err(e);
1185 }
1186 Ok(summary)
1187 })
1188 .await
1189 }
1190
1191 async fn get_edge(&self, id: LinkId) -> Result<Option<Edge>, StorageError> {
1192 let id_str = Uuid::from(id).to_string();
1193
1194 self.with_reader("get_edge", move |conn| {
1195 let mut stmt = conn.prepare(
1196 "SELECT namespace, id, source_id, target_id, relation, weight, \
1197 created_at, updated_at, deleted_at, metadata, target_backend \
1198 FROM graph_edges WHERE id = ?1 AND deleted_at IS NULL",
1199 )?;
1200 let mut rows = stmt.query(rusqlite::params![id_str])?;
1201 match rows.next()? {
1202 Some(row) => Ok(Some(read_edge(row)?)),
1203 None => Ok(None),
1204 }
1205 })
1206 .await
1207 }
1208
1209 async fn get_edge_including_deleted(&self, id: LinkId) -> Result<Option<Edge>, StorageError> {
1210 let id_str = Uuid::from(id).to_string();
1211
1212 self.with_reader("get_edge_including_deleted", move |conn| {
1213 let mut stmt = conn.prepare(
1214 "SELECT namespace, id, source_id, target_id, relation, weight, \
1215 created_at, updated_at, deleted_at, metadata, target_backend \
1216 FROM graph_edges WHERE id = ?1",
1217 )?;
1218 let mut rows = stmt.query(rusqlite::params![id_str])?;
1219 match rows.next()? {
1220 Some(row) => Ok(Some(read_edge(row)?)),
1221 None => Ok(None),
1222 }
1223 })
1224 .await
1225 }
1226
1227 async fn get_edges(&self, ids: &[LinkId]) -> Result<Vec<Edge>, StorageError> {
1228 if ids.is_empty() {
1229 return Ok(Vec::new());
1230 }
1231 const CHUNK: usize = 900;
1233 let id_strs: Vec<String> = ids.iter().map(|id| Uuid::from(*id).to_string()).collect();
1234
1235 let mut result: Vec<Edge> = Vec::with_capacity(ids.len());
1236 for chunk in id_strs.chunks(CHUNK) {
1237 let chunk_owned: Vec<String> = chunk.to_vec();
1238 let edges = self
1239 .with_reader("get_edges", move |conn| {
1240 let placeholders: Vec<String> =
1241 (1..=chunk_owned.len()).map(|i| format!("?{}", i)).collect();
1242 let sql = format!(
1243 "SELECT namespace, id, source_id, target_id, relation, weight, \
1244 created_at, updated_at, deleted_at, metadata, target_backend \
1245 FROM graph_edges WHERE id IN ({}) AND deleted_at IS NULL",
1246 placeholders.join(",")
1247 );
1248 let mut stmt = conn.prepare(&sql)?;
1249 let params: Vec<&dyn rusqlite::types::ToSql> = chunk_owned
1250 .iter()
1251 .map(|s| s as &dyn rusqlite::types::ToSql)
1252 .collect();
1253 let rows = stmt.query_map(params.as_slice(), read_edge)?;
1254 let mut edges = Vec::new();
1255 for row in rows {
1256 edges.push(row?);
1257 }
1258 Ok(edges)
1259 })
1260 .await?;
1261 result.extend(edges);
1262 }
1263 Ok(result)
1264 }
1265
1266 async fn batch_neighbors(
1267 &self,
1268 sources: &[Uuid],
1269 query: NeighborQuery,
1270 ) -> Result<Vec<(Uuid, NeighborHit)>, StorageError> {
1271 use khive_storage::types::Direction;
1272
1273 if sources.is_empty() {
1274 return Ok(Vec::new());
1275 }
1276 let per_half_filter =
1291 query.relations.as_ref().map_or(0, |r| r.len()) + query.min_weight.is_some() as usize;
1292 let halves: usize = if query.direction == Direction::Both {
1293 2
1294 } else {
1295 1
1296 };
1297 let fixed = 1 + 1 + halves * per_half_filter;
1298 let max_src = (950usize.saturating_sub(fixed) / halves).max(1);
1299 let chunk_size = max_src.min(880);
1300
1301 let namespace = self.namespace.clone();
1302 let mut result: Vec<(Uuid, NeighborHit)> = Vec::new();
1303
1304 for chunk in sources.chunks(chunk_size) {
1305 let chunk_owned: Vec<Uuid> = chunk.to_vec();
1306 let query_clone = query.clone();
1307 let ns = namespace.clone();
1308
1309 let pairs = self
1310 .with_reader("batch_neighbors", move |conn| {
1311 let src_strs: Vec<String> = chunk_owned.iter().map(|u| u.to_string()).collect();
1312
1313 let build_inner_sql =
1318 |direction_out: bool,
1319 first_src_param: usize,
1320 q: &NeighborQuery|
1321 -> (String, Vec<String>, Option<f64>) {
1322 let placeholders: Vec<String> = (first_src_param
1323 ..first_src_param + src_strs.len())
1324 .map(|i| format!("?{i}"))
1325 .collect();
1326 let in_list = placeholders.join(",");
1327
1328 let (origin_col, filter_col, node_col) = if direction_out {
1329 ("source_id", "source_id", "target_id")
1330 } else {
1331 ("target_id", "target_id", "source_id")
1332 };
1333
1334 let mut rel_params: Vec<String> = Vec::new();
1335 let mut conditions: Vec<String> = Vec::new();
1336 let mut param_idx = first_src_param + src_strs.len();
1337
1338 if let Some(ref rels) = q.relations {
1339 if !rels.is_empty() {
1340 let ps: Vec<String> = rels
1341 .iter()
1342 .map(|r| {
1343 rel_params.push(r.to_string());
1344 let p = format!("?{param_idx}");
1345 param_idx += 1;
1346 p
1347 })
1348 .collect();
1349 conditions.push(format!("relation IN ({})", ps.join(",")));
1350 }
1351 }
1352
1353 let min_weight_val = if let Some(min_w) = q.min_weight {
1356 conditions.push(format!("weight >= ?{param_idx}"));
1357 Some(min_w)
1358 } else {
1359 None
1360 };
1361
1362 let where_extra = if conditions.is_empty() {
1363 String::new()
1364 } else {
1365 format!(" AND {}", conditions.join(" AND "))
1366 };
1367
1368 let sql = format!(
1369 "SELECT {origin_col} AS origin_id, {node_col} AS node_id, \
1370 id AS edge_id, relation, weight \
1371 FROM graph_edges \
1372 WHERE namespace = ?1 AND {filter_col} IN ({in_list}) \
1373 AND deleted_at IS NULL{where_extra}",
1374 );
1375 (sql, rel_params, min_weight_val)
1376 };
1377
1378 let mut all_params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
1392 all_params.push(Box::new(ns.to_string())); let combined_inner: String;
1395 let limit_param_idx: usize;
1396
1397 match query_clone.direction {
1398 Direction::Out | Direction::In => {
1399 let direction_out = matches!(query_clone.direction, Direction::Out);
1400 let (sql, rel_params, min_weight_val) =
1401 build_inner_sql(direction_out, 2, &query_clone);
1402 combined_inner = sql;
1403
1404 for s in &src_strs {
1406 all_params.push(Box::new(s.clone()));
1407 }
1408 for r in rel_params {
1409 all_params.push(Box::new(r));
1410 }
1411 if let Some(mw) = min_weight_val {
1412 all_params.push(Box::new(mw));
1413 }
1414 limit_param_idx = all_params.len() + 1;
1415 }
1416 Direction::Both => {
1417 let (out_sql, out_rels, out_mw) =
1419 build_inner_sql(true, 2, &query_clone);
1420 let after_out_srcs = 2 + src_strs.len();
1421 let after_out_rels = after_out_srcs + out_rels.len();
1422 let after_out_mw =
1423 after_out_rels + if out_mw.is_some() { 1 } else { 0 };
1424 let in_first = after_out_mw;
1425
1426 let (in_sql, in_rels, in_mw) =
1428 build_inner_sql(false, in_first, &query_clone);
1429
1430 combined_inner = format!("{out_sql} UNION ALL {in_sql}");
1431
1432 for s in &src_strs {
1434 all_params.push(Box::new(s.clone())); }
1436 for r in out_rels {
1437 all_params.push(Box::new(r));
1438 }
1439 if let Some(mw) = out_mw {
1440 all_params.push(Box::new(mw));
1441 }
1442 for s in &src_strs {
1443 all_params.push(Box::new(s.clone())); }
1445 for r in in_rels {
1446 all_params.push(Box::new(r));
1447 }
1448 if let Some(mw) = in_mw {
1449 all_params.push(Box::new(mw));
1450 }
1451 limit_param_idx = all_params.len() + 1;
1452 }
1453 }
1454
1455 let full_sql = if let Some(lim) = query_clone.limit {
1463 all_params.push(Box::new(lim as i64));
1464 format!(
1465 "SELECT origin_id, node_id, edge_id, relation, weight \
1466 FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY origin_id \
1467 ORDER BY weight DESC, node_id ASC) AS rn \
1468 FROM ({combined_inner})) WHERE rn <= ?{limit_param_idx}",
1469 )
1470 } else {
1471 format!(
1472 "SELECT origin_id, node_id, edge_id, relation, weight \
1473 FROM ({combined_inner})",
1474 )
1475 };
1476
1477 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1478 all_params.iter().map(|p| p.as_ref()).collect();
1479
1480 let mut stmt = conn.prepare(&full_sql)?;
1481 let rows = stmt.query_map(param_refs.as_slice(), |row| {
1482 let origin_str: String = row.get(0)?;
1483 let nid_str: String = row.get(1)?;
1484 let eid_str: String = row.get(2)?;
1485 let relation_str: String = row.get(3)?;
1486 let weight: f64 = row.get(4)?;
1487 Ok((origin_str, nid_str, eid_str, relation_str, weight))
1488 })?;
1489
1490 let mut pairs = Vec::new();
1491 for row in rows {
1492 let (origin_str, nid_str, eid_str, relation_str, weight) = row?;
1493 let origin = parse_uuid(&origin_str)?;
1494 let node_id = parse_uuid(&nid_str)?;
1495 let edge_id = parse_uuid(&eid_str)?;
1496 let relation = relation_str.parse::<EdgeRelation>().map_err(|e| {
1497 rusqlite::Error::FromSqlConversionFailure(
1498 3,
1499 rusqlite::types::Type::Text,
1500 Box::new(e),
1501 )
1502 })?;
1503 pairs.push((
1504 origin,
1505 NeighborHit {
1506 node_id,
1507 edge_id,
1508 relation,
1509 weight,
1510 name: None,
1511 kind: None,
1512 entity_type: None,
1513 },
1514 ));
1515 }
1516 Ok(pairs)
1517 })
1518 .await?;
1519 result.extend(pairs);
1520 }
1521 Ok(result)
1522 }
1523
1524 async fn delete_edge(&self, id: LinkId, mode: DeleteMode) -> Result<bool, StorageError> {
1525 let id = Uuid::from(id);
1526 let statement = match mode {
1527 DeleteMode::Soft => {
1528 edge_soft_delete_statement(id, chrono::Utc::now().timestamp_micros())
1529 }
1530 DeleteMode::Hard => edge_hard_delete_statement(id),
1531 };
1532 self.with_writer("delete_edge", move |conn| {
1533 let mut stmt = conn.prepare(&statement.sql)?;
1534 bind_params(&mut stmt, &statement.params)?;
1535 Ok(stmt.raw_execute()? > 0)
1536 })
1537 .await
1538 }
1539
1540 async fn query_edges(
1541 &self,
1542 filter: EdgeFilter,
1543 sort: Vec<SortOrder<EdgeSortField>>,
1544 page: PageRequest,
1545 ) -> Result<Page<Edge>, StorageError> {
1546 let namespace = self.namespace.clone();
1547 let limit_i64 = i64::from(page.limit);
1548 let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
1549 capability: StorageCapability::Graph,
1550 operation: "query_edges".into(),
1551 message: format!(
1552 "PageRequest: offset must be <= i64::MAX, got {}",
1553 page.offset
1554 ),
1555 })?;
1556 self.with_reader("query_edges", move |conn| {
1557 let (where_clause, filter_params) = build_edge_filter_sql(&namespace, &filter);
1558
1559 let count_sql = format!("SELECT COUNT(*) FROM graph_edges{}", where_clause);
1560 let total: i64 = {
1561 let mut stmt = conn.prepare(&count_sql)?;
1562 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1563 filter_params.iter().map(|p| p.as_ref()).collect();
1564 stmt.query_row(param_refs.as_slice(), |row| row.get(0))?
1565 };
1566
1567 let order_clause = if sort.is_empty() {
1568 " ORDER BY created_at DESC".to_string()
1569 } else {
1570 let parts: Vec<String> = sort
1571 .iter()
1572 .map(|s| {
1573 let dir = match s.direction {
1574 SortDirection::Asc => "ASC",
1575 SortDirection::Desc => "DESC",
1576 };
1577 format!("{} {}", edge_sort_col(&s.field), dir)
1578 })
1579 .collect();
1580 format!(" ORDER BY {}", parts.join(", "))
1581 };
1582
1583 let (_, data_filter_params) = build_edge_filter_sql(&namespace, &filter);
1584 let mut all_params: Vec<Box<dyn rusqlite::types::ToSql>> = data_filter_params;
1585 all_params.push(Box::new(limit_i64));
1586 all_params.push(Box::new(offset_i64));
1587
1588 let limit_idx = all_params.len() - 1;
1589 let offset_idx = all_params.len();
1590
1591 let data_sql = format!(
1592 "SELECT namespace, id, source_id, target_id, relation, weight, \
1593 created_at, updated_at, deleted_at, metadata, target_backend \
1594 FROM graph_edges{}{} LIMIT ?{} OFFSET ?{}",
1595 where_clause, order_clause, limit_idx, offset_idx,
1596 );
1597
1598 let mut stmt = conn.prepare(&data_sql)?;
1599 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1600 all_params.iter().map(|p| p.as_ref()).collect();
1601 let rows = stmt.query_map(param_refs.as_slice(), read_edge)?;
1602
1603 let mut items = Vec::new();
1604 for row in rows {
1605 items.push(row?);
1606 }
1607
1608 Ok(Page {
1609 items,
1610 total: Some(total as u64),
1611 })
1612 })
1613 .await
1614 }
1615
1616 async fn count_edges(&self, filter: EdgeFilter) -> Result<u64, StorageError> {
1617 let namespace = self.namespace.clone();
1618 self.with_reader("count_edges", move |conn| {
1619 let (where_clause, params) = build_edge_filter_sql(&namespace, &filter);
1620 let sql = format!("SELECT COUNT(*) FROM graph_edges{}", where_clause);
1621 let mut stmt = conn.prepare(&sql)?;
1622 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1623 params.iter().map(|p| p.as_ref()).collect();
1624 let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
1625 Ok(count as u64)
1626 })
1627 .await
1628 }
1629
1630 async fn count_edges_by_relation(&self) -> Result<Vec<(EdgeRelation, u64)>, StorageError> {
1631 let namespace = self.namespace.clone();
1632 self.with_reader("count_edges_by_relation", move |conn| {
1633 let sql = "SELECT relation, COUNT(*) FROM graph_edges \
1634 WHERE namespace = ?1 AND deleted_at IS NULL \
1635 GROUP BY relation";
1636 let mut stmt = conn.prepare(sql)?;
1637 let rows = stmt.query_map([&namespace], |row| {
1638 let relation_str: String = row.get(0)?;
1639 let count: i64 = row.get(1)?;
1640 Ok((relation_str, count))
1641 })?;
1642 let mut out = Vec::new();
1643 for row in rows {
1644 let (relation_str, count) = row?;
1645 let relation = relation_str.parse::<EdgeRelation>().map_err(|e| {
1646 rusqlite::Error::FromSqlConversionFailure(
1647 0,
1648 rusqlite::types::Type::Text,
1649 Box::new(e),
1650 )
1651 })?;
1652 out.push((relation, count as u64));
1653 }
1654 Ok(out)
1655 })
1656 .await
1657 }
1658
1659 async fn query_edges_after(
1660 &self,
1661 filter: EdgeFilter,
1662 after: Option<Uuid>,
1663 limit: u32,
1664 ) -> Result<EdgeSeekPage, StorageError> {
1665 let namespace = self.namespace.clone();
1666 let limit_usize = limit as usize;
1667 let probe_limit_i64 = i64::from(limit) + 1;
1668 self.with_reader("query_edges_after", move |conn| {
1669 let (mut where_clause, mut params) = build_edge_filter_sql(&namespace, &filter);
1670 if let Some(cursor) = after {
1671 params.push(Box::new(cursor.to_string()));
1672 where_clause.push_str(&format!(" AND id > ?{}", params.len()));
1673 }
1674 params.push(Box::new(probe_limit_i64));
1675 let limit_idx = params.len();
1676
1677 let data_sql = format!(
1683 "SELECT namespace, id, source_id, target_id, relation, weight, \
1684 created_at, updated_at, deleted_at, metadata, target_backend \
1685 FROM graph_edges{} ORDER BY id ASC LIMIT ?{}",
1686 where_clause, limit_idx,
1687 );
1688
1689 let mut stmt = conn.prepare(&data_sql)?;
1690 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1691 params.iter().map(|p| p.as_ref()).collect();
1692 let rows = stmt.query_map(param_refs.as_slice(), read_edge)?;
1693
1694 let mut items = Vec::new();
1695 for row in rows {
1696 items.push(row?);
1697 }
1698 let has_more = items.len() > limit_usize;
1699 if has_more {
1700 items.truncate(limit_usize);
1701 }
1702 let next_after = if has_more {
1703 items.last().map(|e| Uuid::from(e.id))
1704 } else {
1705 None
1706 };
1707
1708 Ok(EdgeSeekPage { items, next_after })
1709 })
1710 .await
1711 }
1712
1713 async fn neighbors(
1714 &self,
1715 node_id: Uuid,
1716 query: NeighborQuery,
1717 ) -> Result<Vec<NeighborHit>, StorageError> {
1718 count_neighbor_select();
1719
1720 let namespace = self.namespace.clone();
1721 let node_str = node_id.to_string();
1722
1723 self.with_reader("neighbors", move |conn| {
1724 let base_out = "SELECT target_id AS node_id, id AS edge_id, relation, weight \
1725 FROM graph_edges \
1726 WHERE namespace = ?1 AND source_id = ?2 AND deleted_at IS NULL";
1727 let base_in = "SELECT source_id AS node_id, id AS edge_id, relation, weight \
1728 FROM graph_edges \
1729 WHERE namespace = ?1 AND target_id = ?2 AND deleted_at IS NULL";
1730
1731 let sql = match query.direction {
1732 Direction::Out => base_out.to_string(),
1733 Direction::In => base_in.to_string(),
1734 Direction::Both => format!("{} UNION ALL {}", base_out, base_in),
1735 };
1736
1737 let (where_extra, limit_clause, extra_params) = neighbor_extra_clause(&query, 3);
1738
1739 let full_sql = format!(
1744 "SELECT node_id, edge_id, relation, weight FROM ({}){} \
1745 ORDER BY weight DESC, node_id ASC{}",
1746 sql, where_extra, limit_clause
1747 );
1748
1749 let mut stmt = conn.prepare(&full_sql)?;
1750
1751 let mut all_params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
1752 all_params.push(Box::new(namespace.clone()));
1753 all_params.push(Box::new(node_str.clone()));
1754 all_params.extend(extra_params);
1755
1756 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1757 all_params.iter().map(|p| p.as_ref()).collect();
1758
1759 let rows = stmt.query_map(param_refs.as_slice(), |row| {
1760 let nid_str: String = row.get(0)?;
1761 let eid_str: String = row.get(1)?;
1762 let relation_str: String = row.get(2)?;
1763 let weight: f64 = row.get(3)?;
1764 Ok((nid_str, eid_str, relation_str, weight))
1765 })?;
1766
1767 let mut hits = Vec::new();
1768 for row in rows {
1769 let (nid_str, eid_str, relation_str, weight) = row?;
1770 let relation = relation_str.parse::<EdgeRelation>().map_err(|e| {
1771 rusqlite::Error::FromSqlConversionFailure(
1772 2,
1773 rusqlite::types::Type::Text,
1774 Box::new(e),
1775 )
1776 })?;
1777 hits.push(NeighborHit {
1778 node_id: parse_uuid(&nid_str)?,
1779 edge_id: parse_uuid(&eid_str)?,
1780 relation,
1781 weight,
1782 name: None,
1783 kind: None,
1784 entity_type: None,
1785 });
1786 }
1787
1788 Ok(hits)
1789 })
1790 .await
1791 }
1792
1793 async fn neighbors_both_directions(
1798 &self,
1799 node_id: Uuid,
1800 query: NeighborQuery,
1801 ) -> Result<Vec<DirectedNeighborHit>, StorageError> {
1802 count_neighbor_select();
1803
1804 let namespace = self.namespace.clone();
1805 let node_str = node_id.to_string();
1806
1807 self.with_reader("neighbors_both_directions", move |conn| {
1808 let base_out = "SELECT target_id AS node_id, id AS edge_id, relation, weight, \
1809 'out' AS dir \
1810 FROM graph_edges \
1811 WHERE namespace = ?1 AND source_id = ?2 AND deleted_at IS NULL";
1812 let base_in = "SELECT source_id AS node_id, id AS edge_id, relation, weight, \
1813 'in' AS dir \
1814 FROM graph_edges \
1815 WHERE namespace = ?1 AND target_id = ?2 AND deleted_at IS NULL";
1816 let sql = format!("{} UNION ALL {}", base_out, base_in);
1817
1818 let (where_extra, limit_clause, extra_params) = neighbor_extra_clause(&query, 3);
1819
1820 let full_sql = format!(
1829 "SELECT node_id, edge_id, relation, weight, dir FROM ({}){} \
1830 ORDER BY weight DESC, node_id ASC, \
1831 CASE dir WHEN 'out' THEN 0 ELSE 1 END ASC, edge_id ASC{}",
1832 sql, where_extra, limit_clause
1833 );
1834
1835 let mut stmt = conn.prepare(&full_sql)?;
1836
1837 let mut all_params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
1838 all_params.push(Box::new(namespace.clone()));
1839 all_params.push(Box::new(node_str.clone()));
1840 all_params.extend(extra_params);
1841
1842 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1843 all_params.iter().map(|p| p.as_ref()).collect();
1844
1845 let rows = stmt.query_map(param_refs.as_slice(), |row| {
1846 let nid_str: String = row.get(0)?;
1847 let eid_str: String = row.get(1)?;
1848 let relation_str: String = row.get(2)?;
1849 let weight: f64 = row.get(3)?;
1850 let dir_str: String = row.get(4)?;
1851 Ok((nid_str, eid_str, relation_str, weight, dir_str))
1852 })?;
1853
1854 let mut hits = Vec::new();
1855 for row in rows {
1856 let (nid_str, eid_str, relation_str, weight, dir_str) = row?;
1857 let relation = relation_str.parse::<EdgeRelation>().map_err(|e| {
1858 rusqlite::Error::FromSqlConversionFailure(
1859 2,
1860 rusqlite::types::Type::Text,
1861 Box::new(e),
1862 )
1863 })?;
1864 let direction = if dir_str == "out" {
1865 Direction::Out
1866 } else {
1867 Direction::In
1868 };
1869 hits.push(DirectedNeighborHit {
1870 hit: NeighborHit {
1871 node_id: parse_uuid(&nid_str)?,
1872 edge_id: parse_uuid(&eid_str)?,
1873 relation,
1874 weight,
1875 name: None,
1876 kind: None,
1877 entity_type: None,
1878 },
1879 direction,
1880 });
1881 }
1882
1883 Ok(hits)
1884 })
1885 .await
1886 }
1887
1888 async fn traverse(&self, request: TraversalRequest) -> Result<Vec<GraphPath>, StorageError> {
1889 use std::collections::{HashMap, HashSet};
1890
1891 use khive_storage::types::Direction;
1892
1893 if request.roots.is_empty() {
1894 return Ok(Vec::new());
1895 }
1896
1897 let roots = request.roots.clone();
1898 let opts = request.options.clone();
1899 let include_roots = request.include_roots;
1900 let namespace = self.namespace.clone();
1901 let max_depth_i64 =
1902 i64::try_from(opts.max_depth).map_err(|_| StorageError::InvalidInput {
1903 capability: StorageCapability::Graph,
1904 operation: "traverse".into(),
1905 message: format!(
1906 "TraversalOptions: max_depth must be <= i64::MAX, got {}",
1907 opts.max_depth
1908 ),
1909 })?;
1910
1911 self.with_reader("traverse", move |conn| {
1912 const CHUNK_ROOTS: usize = 400;
1925
1926 let (join_condition, next_node) = match opts.direction {
1928 Direction::Out => ("e.source_id = t.node_id", "e.target_id"),
1929 Direction::In => ("e.target_id = t.node_id", "e.source_id"),
1930 Direction::Both => (
1931 "(e.source_id = t.node_id OR e.target_id = t.node_id)",
1932 "CASE WHEN e.source_id = t.node_id THEN e.target_id ELSE e.source_id END",
1933 ),
1934 };
1935
1936 let _tx_handle =
1947 khive_storage::tx_registry::register(Some("graph_traverse_read".to_string()));
1948 let tx = conn.unchecked_transaction()?;
1949
1950 let mut root_data: HashMap<Uuid, (Vec<(PathNode, f64)>, HashSet<Uuid>)> =
1955 HashMap::with_capacity(roots.len());
1956
1957 for root_id in &roots {
1959 let (nodes, seen) = root_data.entry(*root_id).or_default();
1960 if include_roots {
1961 seen.insert(*root_id);
1962 nodes.push((
1963 PathNode {
1964 node_id: *root_id,
1965 via_edge: None,
1966 depth: 0,
1967 name: None,
1968 kind: None,
1969 properties: None,
1970 },
1971 0.0,
1972 ));
1973 }
1974 }
1975
1976 for chunk in roots.chunks(CHUNK_ROOTS) {
1977 let n_chunk = chunk.len();
1978
1979 let ns_param = n_chunk + 1;
1985 let depth_param = n_chunk + 2;
1986 let mut extra_param_idx = n_chunk + 3;
1987
1988 let mut relation_cond = String::new();
1989 let mut extra_params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
1990
1991 if let Some(ref rels) = opts.relations {
1992 if !rels.is_empty() {
1993 let placeholders: Vec<String> = rels
1994 .iter()
1995 .map(|r| {
1996 extra_params.push(Box::new(r.to_string()));
1997 let p = format!("?{extra_param_idx}");
1998 extra_param_idx += 1;
1999 p
2000 })
2001 .collect();
2002 relation_cond = format!(" AND e.relation IN ({})", placeholders.join(","));
2003 }
2004 }
2005
2006 let mut weight_cond = String::new();
2007 if let Some(min_w) = opts.min_weight {
2008 extra_params.push(Box::new(min_w));
2009 weight_cond = format!(" AND e.weight >= ?{extra_param_idx}");
2010 }
2012
2013 let seed_rows: Vec<String> = (1..=n_chunk)
2016 .map(|i| format!("(?{i}, ?{i}, NULL, 0, ?{i}, 0.0)"))
2017 .collect();
2018 let seeds = seed_rows.join(", ");
2019
2020 let cte_sql = format!(
2024 "WITH RECURSIVE traversal(\
2025 root_id, node_id, edge_id, depth, path, total_weight\
2026 ) AS (\
2027 VALUES {seeds} \
2028 UNION ALL \
2029 SELECT t.root_id, {next_node}, e.id, t.depth + 1, \
2030 t.path || ',' || {next_node}, \
2031 t.total_weight + e.weight \
2032 FROM traversal t CROSS JOIN graph_edges e \
2033 ON {join_condition} \
2034 WHERE e.namespace = ?{ns} \
2035 AND e.deleted_at IS NULL \
2036 AND t.depth < ?{depth} \
2037 AND (',' || t.path || ',') NOT LIKE '%,' || {next_node} || ',%'\
2038 {rel_cond}{wt_cond} \
2039 ) \
2040 SELECT root_id, node_id, edge_id, depth, total_weight \
2041 FROM traversal WHERE depth > 0 \
2042 ORDER BY root_id, depth",
2043 seeds = seeds,
2044 next_node = next_node,
2045 join_condition = join_condition,
2046 ns = ns_param,
2047 depth = depth_param,
2048 rel_cond = relation_cond,
2049 wt_cond = weight_cond,
2050 );
2051
2052 let mut all_params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
2053 for root_id in chunk {
2054 all_params.push(Box::new(root_id.to_string()));
2055 }
2056 all_params.push(Box::new(namespace.clone()));
2057 all_params.push(Box::new(max_depth_i64));
2058 all_params.extend(extra_params);
2059
2060 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
2061 all_params.iter().map(|p| p.as_ref()).collect();
2062
2063 let mut stmt = conn.prepare(&cte_sql)?;
2066 let rows_iter = stmt.query_map(param_refs.as_slice(), |row| {
2067 let root_str: String = row.get(0)?;
2068 let node_str: String = row.get(1)?;
2069 let edge_str: Option<String> = row.get(2)?;
2070 let depth: i64 = row.get(3)?;
2071 let total_weight: f64 = row.get(4)?;
2072 Ok((root_str, node_str, edge_str, depth, total_weight))
2073 })?;
2074
2075 for row in rows_iter {
2079 let (root_str, node_str, edge_str, depth, total_weight) = row?;
2080 let root_id = parse_uuid(&root_str)?;
2081 let node_id = parse_uuid(&node_str)?;
2082 let (nodes, seen) = root_data.entry(root_id).or_default();
2083 if !seen.insert(node_id) {
2084 continue;
2085 }
2086 let via_edge = edge_str.map(|s| parse_uuid(&s)).transpose()?;
2087 nodes.push((
2088 PathNode {
2089 node_id,
2090 via_edge,
2091 depth: depth as usize,
2092 name: None,
2093 kind: None,
2094 properties: None,
2095 },
2096 total_weight,
2097 ));
2098 }
2099 }
2100
2101 tx.commit()?;
2102
2103 let mut all_paths: Vec<GraphPath> = Vec::with_capacity(roots.len());
2109 for root_id in &roots {
2110 if let Some((mut nw, _)) = root_data.remove(root_id) {
2111 if nw.is_empty() {
2112 continue;
2113 }
2114 if let Some(lim) = opts.limit {
2115 let root_count = usize::from(include_roots);
2116 nw.truncate(root_count + lim as usize);
2117 }
2118 if nw.is_empty() {
2121 continue;
2122 }
2123 let max_weight = nw.iter().map(|(_, w)| *w).fold(0.0_f64, f64::max);
2124 let nodes: Vec<PathNode> = nw.into_iter().map(|(n, _)| n).collect();
2125 all_paths.push(GraphPath {
2126 root_id: *root_id,
2127 nodes,
2128 total_weight: max_weight,
2129 });
2130 }
2131 }
2132
2133 Ok(all_paths)
2134 })
2135 .await
2136 }
2137
2138 async fn purge_incident_edges(&self, node_id: Uuid) -> Result<u64, StorageError> {
2139 let statement = purge_incident_edges_statement(node_id);
2143 self.with_writer("purge_incident_edges", move |conn| {
2144 let mut stmt = conn.prepare(&statement.sql)?;
2145 bind_params(&mut stmt, &statement.params)?;
2146 Ok(stmt.raw_execute()? as u64)
2147 })
2148 .await
2149 }
2150}
2151
2152const GRAPH_DDL: &str = include_str!("../../sql/graph-ddl.sql");
2157
2158pub(crate) fn ensure_graph_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
2159 conn.execute_batch(GRAPH_DDL)
2160}
2161
2162#[cfg(test)]
2163#[path = "graph_tests.rs"]
2164mod tests;