Skip to main content

velesdb_mobile/
graph.rs

1//! Graph bindings for VelesDB Mobile (UniFFI).
2//!
3//! Provides UniFFI bindings for graph operations on iOS and Android.
4
5use std::collections::HashMap;
6use std::sync::Arc;
7
8/// A graph node for knowledge graph construction.
9#[derive(Debug, Clone, uniffi::Record)]
10pub struct MobileGraphNode {
11    /// Unique identifier.
12    pub id: u64,
13    /// Node type/label.
14    pub label: String,
15    /// JSON properties as string.
16    pub properties_json: Option<String>,
17    /// Optional vector embedding.
18    pub vector: Option<Vec<f32>>,
19}
20
21/// A graph edge representing a relationship.
22#[derive(Debug, Clone, uniffi::Record)]
23pub struct MobileGraphEdge {
24    /// Unique identifier.
25    pub id: u64,
26    /// Source node ID.
27    pub source: u64,
28    /// Target node ID.
29    pub target: u64,
30    /// Relationship type.
31    pub label: String,
32    /// JSON properties as string.
33    pub properties_json: Option<String>,
34}
35
36/// Traversal result from BFS.
37#[derive(Debug, Clone, uniffi::Record)]
38pub struct TraversalResult {
39    /// Target node ID.
40    pub node_id: u64,
41    /// Depth from source.
42    pub depth: u32,
43}
44
45/// In-memory graph store for mobile knowledge graphs.
46#[derive(uniffi::Object)]
47pub struct MobileGraphStore {
48    nodes: std::sync::RwLock<HashMap<u64, MobileGraphNode>>,
49    edges: std::sync::RwLock<HashMap<u64, MobileGraphEdge>>,
50    outgoing: std::sync::RwLock<HashMap<u64, Vec<u64>>>,
51    incoming: std::sync::RwLock<HashMap<u64, Vec<u64>>>,
52}
53
54#[uniffi::export]
55impl MobileGraphStore {
56    /// Creates a new empty graph store.
57    #[uniffi::constructor]
58    pub fn new() -> Arc<Self> {
59        Arc::new(Self {
60            nodes: std::sync::RwLock::new(HashMap::new()),
61            edges: std::sync::RwLock::new(HashMap::new()),
62            outgoing: std::sync::RwLock::new(HashMap::new()),
63            incoming: std::sync::RwLock::new(HashMap::new()),
64        })
65    }
66
67    /// Adds a node to the graph.
68    pub fn add_node(&self, node: MobileGraphNode) {
69        let mut nodes = self.nodes.write().unwrap();
70        nodes.insert(node.id, node);
71    }
72
73    /// Adds an edge to the graph.
74    ///
75    /// # Lock Order
76    ///
77    /// Acquires locks in consistent order: edges → outgoing → incoming
78    /// WITHOUT dropping between operations to ensure atomicity.
79    /// This prevents race conditions with concurrent remove_node() calls.
80    pub fn add_edge(&self, edge: MobileGraphEdge) -> Result<(), crate::VelesError> {
81        // CRITICAL FIX: Acquire all locks BEFORE any mutation
82        // and hold them until the operation is complete.
83        // Lock order: edges → outgoing → incoming (consistent with remove_node)
84        let mut edges = self.edges.write().unwrap();
85        let mut outgoing = self.outgoing.write().unwrap();
86        let mut incoming = self.incoming.write().unwrap();
87
88        if edges.contains_key(&edge.id) {
89            return Err(crate::VelesError::Database {
90                message: format!("Edge with ID {} already exists", edge.id),
91            });
92        }
93
94        let source = edge.source;
95        let target = edge.target;
96        let id = edge.id;
97
98        // All mutations happen while holding all locks
99        edges.insert(id, edge);
100        outgoing.entry(source).or_default().push(id);
101        incoming.entry(target).or_default().push(id);
102
103        // Locks are released here (all at once) when guards go out of scope
104        Ok(())
105    }
106
107    /// Gets a node by ID.
108    pub fn get_node(&self, id: u64) -> Option<MobileGraphNode> {
109        let nodes = self.nodes.read().unwrap();
110        nodes.get(&id).cloned()
111    }
112
113    /// Gets an edge by ID.
114    pub fn get_edge(&self, id: u64) -> Option<MobileGraphEdge> {
115        let edges = self.edges.read().unwrap();
116        edges.get(&id).cloned()
117    }
118
119    /// Returns the number of nodes.
120    pub fn node_count(&self) -> u64 {
121        let nodes = self.nodes.read().unwrap();
122        nodes.len() as u64
123    }
124
125    /// Returns the number of edges.
126    pub fn edge_count(&self) -> u64 {
127        let edges = self.edges.read().unwrap();
128        edges.len() as u64
129    }
130
131    /// Gets outgoing edges from a node.
132    ///
133    /// # Lock Order
134    ///
135    /// Acquires locks in consistent order: edges → outgoing
136    /// to prevent ABBA deadlock with write operations.
137    pub fn get_outgoing(&self, node_id: u64) -> Vec<MobileGraphEdge> {
138        // CRITICAL: Lock order must match write operations (edges → outgoing → incoming)
139        let edges = self.edges.read().unwrap();
140        let outgoing = self.outgoing.read().unwrap();
141
142        outgoing
143            .get(&node_id)
144            .map(|ids| ids.iter().filter_map(|id| edges.get(id).cloned()).collect())
145            .unwrap_or_default()
146    }
147
148    /// Gets incoming edges to a node.
149    ///
150    /// # Lock Order
151    ///
152    /// Acquires locks in consistent order: edges → incoming
153    /// to prevent ABBA deadlock with write operations.
154    pub fn get_incoming(&self, node_id: u64) -> Vec<MobileGraphEdge> {
155        // CRITICAL: Lock order must match write operations (edges → outgoing → incoming)
156        let edges = self.edges.read().unwrap();
157        let incoming = self.incoming.read().unwrap();
158
159        incoming
160            .get(&node_id)
161            .map(|ids| ids.iter().filter_map(|id| edges.get(id).cloned()).collect())
162            .unwrap_or_default()
163    }
164
165    /// Gets outgoing edges filtered by label.
166    pub fn get_outgoing_by_label(&self, node_id: u64, label: String) -> Vec<MobileGraphEdge> {
167        self.get_outgoing(node_id)
168            .into_iter()
169            .filter(|e| e.label == label)
170            .collect()
171    }
172
173    /// Gets neighbors reachable from a node (1-hop).
174    pub fn get_neighbors(&self, node_id: u64) -> Vec<u64> {
175        self.get_outgoing(node_id)
176            .into_iter()
177            .map(|e| e.target)
178            .collect()
179    }
180
181    /// Performs BFS traversal from a source node.
182    ///
183    /// # Arguments
184    ///
185    /// * `source_id` - Starting node ID
186    /// * `max_depth` - Maximum traversal depth
187    /// * `limit` - Maximum number of results
188    pub fn bfs_traverse(&self, source_id: u64, max_depth: u32, limit: u32) -> Vec<TraversalResult> {
189        use std::collections::{HashSet, VecDeque};
190
191        let mut results: Vec<TraversalResult> = Vec::new();
192        let mut visited: HashSet<u64> = HashSet::new();
193        let mut queue: VecDeque<(u64, u32)> = VecDeque::new();
194
195        queue.push_back((source_id, 0));
196        visited.insert(source_id);
197
198        while let Some((node_id, depth)) = queue.pop_front() {
199            if results.len() >= limit as usize {
200                break;
201            }
202
203            if depth > 0 {
204                results.push(TraversalResult { node_id, depth });
205            }
206
207            if depth < max_depth {
208                for edge in self.get_outgoing(node_id) {
209                    if !visited.contains(&edge.target) {
210                        visited.insert(edge.target);
211                        queue.push_back((edge.target, depth + 1));
212                    }
213                }
214            }
215        }
216
217        results
218    }
219
220    /// Removes a node and all connected edges.
221    ///
222    /// # Lock Order
223    ///
224    /// Acquires locks in consistent order: edges → outgoing → incoming → nodes
225    /// to prevent deadlock with concurrent add_edge() calls.
226    pub fn remove_node(&self, node_id: u64) {
227        // CRITICAL: Acquire locks in consistent order (edges → outgoing → incoming → nodes)
228        // to prevent deadlock with add_edge() which uses (edges → outgoing → incoming)
229        let mut edges = self.edges.write().unwrap();
230        let mut outgoing = self.outgoing.write().unwrap();
231        let mut incoming = self.incoming.write().unwrap();
232        let mut nodes = self.nodes.write().unwrap();
233
234        nodes.remove(&node_id);
235
236        let outgoing_ids: Vec<u64> = outgoing.remove(&node_id).unwrap_or_default();
237        for edge_id in outgoing_ids {
238            if let Some(edge) = edges.remove(&edge_id) {
239                if let Some(ids) = incoming.get_mut(&edge.target) {
240                    ids.retain(|&id| id != edge_id);
241                }
242            }
243        }
244
245        let incoming_ids: Vec<u64> = incoming.remove(&node_id).unwrap_or_default();
246        for edge_id in incoming_ids {
247            if let Some(edge) = edges.remove(&edge_id) {
248                if let Some(ids) = outgoing.get_mut(&edge.source) {
249                    ids.retain(|&id| id != edge_id);
250                }
251            }
252        }
253    }
254
255    /// Removes an edge by ID.
256    ///
257    /// # Lock Order
258    ///
259    /// Acquires locks in consistent order: edges → outgoing → incoming
260    /// WITHOUT dropping between operations to ensure atomicity.
261    pub fn remove_edge(&self, edge_id: u64) {
262        // CRITICAL FIX: Acquire all locks BEFORE any mutation
263        let mut edges = self.edges.write().unwrap();
264        let mut outgoing = self.outgoing.write().unwrap();
265        let mut incoming = self.incoming.write().unwrap();
266
267        if let Some(edge) = edges.remove(&edge_id) {
268            if let Some(ids) = outgoing.get_mut(&edge.source) {
269                ids.retain(|&id| id != edge_id);
270            }
271            if let Some(ids) = incoming.get_mut(&edge.target) {
272                ids.retain(|&id| id != edge_id);
273            }
274        }
275        // All locks released here
276    }
277
278    /// Clears all nodes and edges.
279    ///
280    /// # Lock Order
281    ///
282    /// Acquires locks in consistent order: edges → outgoing → incoming → nodes
283    pub fn clear(&self) {
284        // Consistent lock order: edges → outgoing → incoming → nodes
285        let mut edges = self.edges.write().unwrap();
286        let mut outgoing = self.outgoing.write().unwrap();
287        let mut incoming = self.incoming.write().unwrap();
288        let mut nodes = self.nodes.write().unwrap();
289
290        edges.clear();
291        outgoing.clear();
292        incoming.clear();
293        nodes.clear();
294    }
295
296    /// Performs DFS traversal from a source node.
297    ///
298    /// # Arguments
299    ///
300    /// * `source_id` - Starting node ID
301    /// * `max_depth` - Maximum traversal depth
302    /// * `limit` - Maximum number of results
303    pub fn dfs_traverse(&self, source_id: u64, max_depth: u32, limit: u32) -> Vec<TraversalResult> {
304        use std::collections::HashSet;
305
306        let mut results: Vec<TraversalResult> = Vec::new();
307        let mut visited: HashSet<u64> = HashSet::new();
308        let mut stack: Vec<(u64, u32)> = vec![(source_id, 0)];
309
310        while let Some((node_id, depth)) = stack.pop() {
311            if results.len() >= limit as usize {
312                break;
313            }
314
315            if visited.contains(&node_id) {
316                continue;
317            }
318            visited.insert(node_id);
319
320            if depth > 0 {
321                results.push(TraversalResult { node_id, depth });
322            }
323
324            if depth < max_depth {
325                let neighbors: Vec<_> = self
326                    .get_outgoing(node_id)
327                    .into_iter()
328                    .filter(|e| !visited.contains(&e.target))
329                    .collect();
330
331                for edge in neighbors.into_iter().rev() {
332                    stack.push((edge.target, depth + 1));
333                }
334            }
335        }
336
337        results
338    }
339
340    /// Checks if a node exists.
341    pub fn has_node(&self, id: u64) -> bool {
342        let nodes = self.nodes.read().unwrap();
343        nodes.contains_key(&id)
344    }
345
346    /// Checks if an edge exists.
347    pub fn has_edge(&self, id: u64) -> bool {
348        let edges = self.edges.read().unwrap();
349        edges.contains_key(&id)
350    }
351
352    /// Gets the out-degree (number of outgoing edges) of a node.
353    #[allow(clippy::cast_possible_truncation)]
354    pub fn out_degree(&self, node_id: u64) -> u32 {
355        let outgoing = self.outgoing.read().unwrap();
356        // Safe: graph degree unlikely to exceed u32::MAX (4 billion edges from one node)
357        outgoing.get(&node_id).map_or(0, |v| v.len() as u32)
358    }
359
360    /// Gets the in-degree (number of incoming edges) of a node.
361    #[allow(clippy::cast_possible_truncation)]
362    pub fn in_degree(&self, node_id: u64) -> u32 {
363        let incoming = self.incoming.read().unwrap();
364        // Safe: graph degree unlikely to exceed u32::MAX (4 billion edges to one node)
365        incoming.get(&node_id).map_or(0, |v| v.len() as u32)
366    }
367
368    /// Gets all nodes with a specific label.
369    pub fn get_nodes_by_label(&self, label: String) -> Vec<MobileGraphNode> {
370        let nodes = self.nodes.read().unwrap();
371        nodes
372            .values()
373            .filter(|n| n.label == label)
374            .cloned()
375            .collect()
376    }
377
378    /// Gets all edges with a specific label.
379    pub fn get_edges_by_label(&self, label: String) -> Vec<MobileGraphEdge> {
380        let edges = self.edges.read().unwrap();
381        edges
382            .values()
383            .filter(|e| e.label == label)
384            .cloned()
385            .collect()
386    }
387}
388
389impl Default for MobileGraphStore {
390    fn default() -> Self {
391        Self {
392            nodes: std::sync::RwLock::new(HashMap::new()),
393            edges: std::sync::RwLock::new(HashMap::new()),
394            outgoing: std::sync::RwLock::new(HashMap::new()),
395            incoming: std::sync::RwLock::new(HashMap::new()),
396        }
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    #[test]
405    fn test_mobile_graph_node_creation() {
406        let node = MobileGraphNode {
407            id: 1,
408            label: "Person".to_string(),
409            properties_json: Some(r#"{"name": "John"}"#.to_string()),
410            vector: None,
411        };
412        assert_eq!(node.id, 1);
413        assert_eq!(node.label, "Person");
414    }
415
416    #[test]
417    fn test_mobile_graph_edge_creation() {
418        let edge = MobileGraphEdge {
419            id: 100,
420            source: 1,
421            target: 2,
422            label: "KNOWS".to_string(),
423            properties_json: None,
424        };
425        assert_eq!(edge.id, 100);
426        assert_eq!(edge.source, 1);
427        assert_eq!(edge.target, 2);
428    }
429
430    #[test]
431    fn test_mobile_graph_store_add_nodes() {
432        let store = MobileGraphStore::new();
433        store.add_node(MobileGraphNode {
434            id: 1,
435            label: "Person".to_string(),
436            properties_json: None,
437            vector: None,
438        });
439        assert_eq!(store.node_count(), 1);
440    }
441
442    #[test]
443    fn test_mobile_graph_store_add_edges() {
444        let store = MobileGraphStore::new();
445        store.add_node(MobileGraphNode {
446            id: 1,
447            label: "Person".to_string(),
448            properties_json: None,
449            vector: None,
450        });
451        store.add_node(MobileGraphNode {
452            id: 2,
453            label: "Person".to_string(),
454            properties_json: None,
455            vector: None,
456        });
457
458        let result = store.add_edge(MobileGraphEdge {
459            id: 100,
460            source: 1,
461            target: 2,
462            label: "KNOWS".to_string(),
463            properties_json: None,
464        });
465
466        assert!(result.is_ok());
467        assert_eq!(store.edge_count(), 1);
468    }
469
470    #[test]
471    fn test_mobile_graph_store_duplicate_edge_error() {
472        let store = MobileGraphStore::new();
473        store.add_node(MobileGraphNode {
474            id: 1,
475            label: "Person".to_string(),
476            properties_json: None,
477            vector: None,
478        });
479        store.add_node(MobileGraphNode {
480            id: 2,
481            label: "Person".to_string(),
482            properties_json: None,
483            vector: None,
484        });
485
486        let _ = store.add_edge(MobileGraphEdge {
487            id: 100,
488            source: 1,
489            target: 2,
490            label: "KNOWS".to_string(),
491            properties_json: None,
492        });
493
494        // Try to add same edge ID again
495        let result = store.add_edge(MobileGraphEdge {
496            id: 100,
497            source: 1,
498            target: 2,
499            label: "KNOWS".to_string(),
500            properties_json: None,
501        });
502
503        assert!(result.is_err());
504    }
505
506    #[test]
507    fn test_mobile_graph_store_get_outgoing() {
508        let store = MobileGraphStore::new();
509        store.add_node(MobileGraphNode {
510            id: 1,
511            label: "Person".to_string(),
512            properties_json: None,
513            vector: None,
514        });
515        store.add_node(MobileGraphNode {
516            id: 2,
517            label: "Person".to_string(),
518            properties_json: None,
519            vector: None,
520        });
521        store.add_node(MobileGraphNode {
522            id: 3,
523            label: "Person".to_string(),
524            properties_json: None,
525            vector: None,
526        });
527
528        let _ = store.add_edge(MobileGraphEdge {
529            id: 100,
530            source: 1,
531            target: 2,
532            label: "KNOWS".to_string(),
533            properties_json: None,
534        });
535        let _ = store.add_edge(MobileGraphEdge {
536            id: 101,
537            source: 1,
538            target: 3,
539            label: "KNOWS".to_string(),
540            properties_json: None,
541        });
542
543        let outgoing = store.get_outgoing(1);
544        assert_eq!(outgoing.len(), 2);
545    }
546
547    #[test]
548    fn test_mobile_graph_store_bfs_traverse() {
549        let store = MobileGraphStore::new();
550
551        // Create nodes
552        for i in 1..=4 {
553            store.add_node(MobileGraphNode {
554                id: i,
555                label: "Person".to_string(),
556                properties_json: None,
557                vector: None,
558            });
559        }
560
561        // Create chain: 1 -> 2 -> 3 -> 4
562        let _ = store.add_edge(MobileGraphEdge {
563            id: 100,
564            source: 1,
565            target: 2,
566            label: "KNOWS".to_string(),
567            properties_json: None,
568        });
569        let _ = store.add_edge(MobileGraphEdge {
570            id: 101,
571            source: 2,
572            target: 3,
573            label: "KNOWS".to_string(),
574            properties_json: None,
575        });
576        let _ = store.add_edge(MobileGraphEdge {
577            id: 102,
578            source: 3,
579            target: 4,
580            label: "KNOWS".to_string(),
581            properties_json: None,
582        });
583
584        let results = store.bfs_traverse(1, 3, 100);
585
586        // Should find nodes 2, 3, 4 at depths 1, 2, 3
587        assert_eq!(results.len(), 3);
588        assert!(results.iter().any(|r| r.node_id == 2 && r.depth == 1));
589        assert!(results.iter().any(|r| r.node_id == 3 && r.depth == 2));
590        assert!(results.iter().any(|r| r.node_id == 4 && r.depth == 3));
591    }
592
593    #[test]
594    fn test_mobile_graph_store_remove_node() {
595        let store = MobileGraphStore::new();
596
597        store.add_node(MobileGraphNode {
598            id: 1,
599            label: "Person".to_string(),
600            properties_json: None,
601            vector: None,
602        });
603        store.add_node(MobileGraphNode {
604            id: 2,
605            label: "Person".to_string(),
606            properties_json: None,
607            vector: None,
608        });
609
610        let _ = store.add_edge(MobileGraphEdge {
611            id: 100,
612            source: 1,
613            target: 2,
614            label: "KNOWS".to_string(),
615            properties_json: None,
616        });
617
618        assert_eq!(store.node_count(), 2);
619        assert_eq!(store.edge_count(), 1);
620
621        store.remove_node(1);
622
623        assert_eq!(store.node_count(), 1);
624        assert_eq!(store.edge_count(), 0); // Edge should be removed too
625    }
626
627    #[test]
628    fn test_mobile_graph_store_remove_edge() {
629        let store = MobileGraphStore::new();
630
631        store.add_node(MobileGraphNode {
632            id: 1,
633            label: "Person".to_string(),
634            properties_json: None,
635            vector: None,
636        });
637        store.add_node(MobileGraphNode {
638            id: 2,
639            label: "Person".to_string(),
640            properties_json: None,
641            vector: None,
642        });
643
644        let _ = store.add_edge(MobileGraphEdge {
645            id: 100,
646            source: 1,
647            target: 2,
648            label: "KNOWS".to_string(),
649            properties_json: None,
650        });
651
652        assert_eq!(store.edge_count(), 1);
653
654        store.remove_edge(100);
655
656        assert_eq!(store.edge_count(), 0);
657        assert!(store.get_outgoing(1).is_empty());
658        assert!(store.get_incoming(2).is_empty());
659    }
660
661    #[test]
662    fn test_mobile_graph_store_clear() {
663        let store = MobileGraphStore::new();
664
665        store.add_node(MobileGraphNode {
666            id: 1,
667            label: "Person".to_string(),
668            properties_json: None,
669            vector: None,
670        });
671        store.add_node(MobileGraphNode {
672            id: 2,
673            label: "Person".to_string(),
674            properties_json: None,
675            vector: None,
676        });
677
678        let _ = store.add_edge(MobileGraphEdge {
679            id: 100,
680            source: 1,
681            target: 2,
682            label: "KNOWS".to_string(),
683            properties_json: None,
684        });
685
686        store.clear();
687
688        assert_eq!(store.node_count(), 0);
689        assert_eq!(store.edge_count(), 0);
690    }
691}