Skip to main content

sqry_core/graph/unified/build/
incremental.rs

1//! Incremental updates for the unified graph.
2//!
3//! This module implements incremental update operations:
4//! - File removal: Remove all nodes and edges from a deleted file
5//! - Edge addition: Add edges without full rebuild
6//! - Node removal: Remove specific nodes and their edges
7//!
8//! # Overview
9//!
10//! Incremental updates enable efficient partial modifications to the graph
11//! without requiring a full rebuild. This is critical for IDE integration
12//! where files change frequently.
13//!
14//! # Operations
15//!
16//! - [`remove_file_nodes`]: Remove all nodes from a file (and their edges)
17//! - [`add_edge_incremental`]: Add a single edge to the graph
18//! - [`remove_node`]: Remove a specific node and its connected edges
19//!
20//! # Thread Safety
21//!
22//! These operations acquire appropriate locks on the graph stores.
23//! They are designed to be safe for concurrent read access while
24//! performing mutations.
25
26use super::super::edge::EdgeKind;
27use super::super::file::FileId;
28use super::super::node::NodeId;
29use super::super::storage::{AuxiliaryIndices, NodeArena};
30use super::identity::IdentityIndex;
31use super::pass3_intra::PendingEdge;
32
33/// Statistics from incremental operations.
34#[derive(Debug, Clone, Default)]
35pub struct IncrementalStats {
36    /// Number of nodes removed.
37    pub nodes_removed: usize,
38    /// Number of edges removed.
39    pub edges_removed: usize,
40    /// Number of edges added.
41    pub edges_added: usize,
42    /// Number of identity index entries removed.
43    pub identity_entries_removed: usize,
44}
45
46/// Result of file node removal.
47#[derive(Debug)]
48pub struct FileRemovalResult {
49    /// Statistics from the removal.
50    pub stats: IncrementalStats,
51    /// List of removed node IDs.
52    pub removed_nodes: Vec<NodeId>,
53}
54
55/// Remove all nodes from a file.
56///
57/// This function removes all nodes belonging to a specific file,
58/// along with their associated edges. It also updates the identity
59/// index to remove the file's entries.
60///
61/// # Arguments
62///
63/// * `file_id` - The file to remove nodes from
64/// * `identity_index` - Identity index to update
65/// * `arena` - Node arena (for tombstoning nodes)
66/// * `indices` - Auxiliary indices to update
67///
68/// # Returns
69///
70/// Statistics about what was removed and list of removed node IDs.
71pub fn remove_file_nodes(
72    file_id: FileId,
73    identity_index: &mut IdentityIndex,
74    arena: &mut NodeArena,
75    indices: &mut AuxiliaryIndices,
76) -> FileRemovalResult {
77    let mut stats = IncrementalStats::default();
78
79    // Remove entries from identity index
80    let removed_entries = identity_index.remove_file(file_id);
81    stats.identity_entries_removed = removed_entries.len();
82
83    // Collect node IDs
84    let removed_nodes: Vec<NodeId> = removed_entries.iter().map(|(_, id)| *id).collect();
85    stats.nodes_removed = removed_nodes.len();
86
87    // Extract node metadata before removing from arena
88    let node_metadata: Vec<_> = removed_nodes
89        .iter()
90        .filter_map(|&node_id| {
91            arena
92                .get(node_id)
93                .map(|entry| (node_id, entry.kind, entry.name, entry.qualified_name))
94        })
95        .collect();
96
97    // Remove from auxiliary indices using metadata (O(N*B) performance)
98    indices.remove_file_with_info(file_id, node_metadata);
99
100    // Remove nodes from arena after indices are updated
101    for &node_id in &removed_nodes {
102        let _ = arena.remove(node_id);
103    }
104
105    // Note: Edge removal is handled separately through the edge store
106    // The cascade module handles edge cleanup when nodes are removed
107
108    FileRemovalResult {
109        stats,
110        removed_nodes,
111    }
112}
113
114/// Add a single edge to the graph incrementally.
115///
116/// This is a lightweight operation for adding edges discovered
117/// during incremental analysis (e.g., when a file is re-parsed).
118///
119/// # Arguments
120///
121/// * `edge` - The pending edge to add
122///
123/// # Returns
124///
125/// Statistics about the addition.
126///
127/// # Note
128///
129/// This function prepares the edge for addition but doesn't actually
130/// add it to the edge store. The caller should use the edge store's
131/// `add_edge` method with the returned `PendingEdge`.
132#[must_use]
133pub fn add_edge_incremental(
134    source: NodeId,
135    target: NodeId,
136    kind: EdgeKind,
137    file: FileId,
138) -> (IncrementalStats, PendingEdge) {
139    let stats = IncrementalStats {
140        edges_added: 1,
141        ..Default::default()
142    };
143
144    let edge = PendingEdge {
145        source,
146        target,
147        kind,
148        file,
149        spans: vec![], // Incremental edges don't have span info
150    };
151
152    (stats, edge)
153}
154
155/// Add multiple edges incrementally.
156///
157/// Batch version of `add_edge_incremental` for efficiency.
158#[must_use]
159pub fn add_edges_incremental(edges: &[PendingEdge]) -> IncrementalStats {
160    IncrementalStats {
161        edges_added: edges.len(),
162        ..Default::default()
163    }
164}
165
166/// Remove a specific node and update indices.
167///
168/// This function tombstones a node and removes it from the identity index.
169/// Edge cleanup should be handled separately through the cascade module.
170///
171/// # Arguments
172///
173/// * `node_id` - The node to remove
174/// * `identity_key` - Optional identity key for index cleanup
175/// * `arena` - Node arena for tombstoning
176/// * `identity_index` - Identity index to update
177///
178/// # Returns
179///
180/// Statistics about the removal.
181pub fn remove_node(
182    node_id: NodeId,
183    identity_index: &mut IdentityIndex,
184    arena: &mut NodeArena,
185) -> IncrementalStats {
186    let mut stats = IncrementalStats::default();
187
188    if identity_index.remove_node_id(node_id).is_some() {
189        stats.identity_entries_removed = 1;
190    }
191
192    // Remove the node from arena
193    if arena.remove(node_id).is_some() {
194        stats.nodes_removed = 1;
195    }
196
197    stats
198}
199
200/// Batch remove nodes from a file.
201///
202/// More efficient than calling `remove_node` multiple times.
203pub fn remove_nodes_batch(
204    node_ids: &[NodeId],
205    identity_index: &mut IdentityIndex,
206    arena: &mut NodeArena,
207) -> IncrementalStats {
208    let mut stats = IncrementalStats::default();
209
210    for &node_id in node_ids {
211        if identity_index.remove_node_id(node_id).is_some() {
212            stats.identity_entries_removed += 1;
213        }
214        if arena.remove(node_id).is_some() {
215            stats.nodes_removed += 1;
216        }
217    }
218
219    stats
220}
221
222/// Prepare edges for removal from a file.
223///
224/// Returns the edges that should be removed when a file is deleted.
225/// The actual removal should be done through the edge store.
226///
227/// # Arguments
228///
229/// * `file_id` - The file being removed
230/// * `node_ids` - Nodes in the file (for edge lookup)
231///
232/// # Returns
233///
234/// List of edge specifications to remove.
235#[derive(Debug, Clone)]
236pub struct EdgeToRemove {
237    /// Source node.
238    pub source: NodeId,
239    /// Target node.
240    pub target: NodeId,
241    /// Edge kind.
242    pub kind: EdgeKind,
243    /// File the edge belongs to.
244    pub file: FileId,
245}
246
247#[cfg(test)]
248mod tests {
249    use super::super::identity::IdentityKey;
250    use super::*;
251    use crate::graph::unified::StringId;
252    use crate::graph::unified::node::NodeKind;
253    use crate::graph::unified::storage::NodeEntry;
254
255    fn create_test_entry(name_id: StringId, file_id: FileId) -> NodeEntry {
256        NodeEntry::new(NodeKind::Function, name_id, file_id)
257    }
258
259    #[test]
260    fn test_remove_file_nodes() {
261        let mut arena = NodeArena::new();
262        let mut identity_index = IdentityIndex::new();
263        let mut indices = AuxiliaryIndices::new();
264        let file_id = FileId::new(5);
265
266        // Add some nodes
267        let name_id = StringId::new(1);
268        let entry1 = create_test_entry(name_id, file_id);
269        let entry2 = create_test_entry(name_id, file_id);
270        let node1 = arena.alloc(entry1).unwrap();
271        let node2 = arena.alloc(entry2).unwrap();
272
273        // Add to identity index
274        let key1 = IdentityKey::new(StringId::new(1), file_id, StringId::new(10));
275        let key2 = IdentityKey::new(StringId::new(1), file_id, StringId::new(11));
276        identity_index.insert(key1, node1);
277        identity_index.insert(key2, node2);
278
279        // Remove file
280        let result = remove_file_nodes(file_id, &mut identity_index, &mut arena, &mut indices);
281
282        assert_eq!(result.stats.nodes_removed, 2);
283        assert_eq!(result.stats.identity_entries_removed, 2);
284        assert_eq!(result.removed_nodes.len(), 2);
285
286        // Verify nodes are removed (no longer accessible)
287        assert!(arena.get(node1).is_none());
288        assert!(arena.get(node2).is_none());
289    }
290
291    #[test]
292    fn test_add_edge_incremental() {
293        let source = NodeId::new(0, 1);
294        let target = NodeId::new(1, 1);
295        let file_id = FileId::new(0);
296
297        let (stats, edge) = add_edge_incremental(
298            source,
299            target,
300            EdgeKind::Calls {
301                argument_count: 0,
302                is_async: false,
303            },
304            file_id,
305        );
306
307        assert_eq!(stats.edges_added, 1);
308        assert_eq!(edge.source, source);
309        assert_eq!(edge.target, target);
310        assert!(matches!(
311            edge.kind,
312            EdgeKind::Calls {
313                argument_count: 0,
314                is_async: false
315            }
316        ));
317    }
318
319    #[test]
320    fn test_add_edges_incremental_batch() {
321        let file_id = FileId::new(0);
322        let edges = vec![
323            PendingEdge {
324                source: NodeId::new(0, 1),
325                target: NodeId::new(1, 1),
326                kind: EdgeKind::Calls {
327                    argument_count: 0,
328                    is_async: false,
329                },
330                file: file_id,
331                spans: vec![],
332            },
333            PendingEdge {
334                source: NodeId::new(1, 1),
335                target: NodeId::new(2, 1),
336                kind: EdgeKind::References,
337                file: file_id,
338                spans: vec![],
339            },
340            PendingEdge {
341                source: NodeId::new(2, 1),
342                target: NodeId::new(0, 1),
343                kind: EdgeKind::Calls {
344                    argument_count: 0,
345                    is_async: false,
346                },
347                file: file_id,
348                spans: vec![],
349            },
350        ];
351
352        let stats = add_edges_incremental(&edges);
353
354        assert_eq!(stats.edges_added, 3);
355    }
356
357    #[test]
358    fn test_remove_node() {
359        let mut arena = NodeArena::new();
360        let mut identity_index = IdentityIndex::new();
361        let file_id = FileId::new(0);
362        let name_id = StringId::new(1);
363
364        let entry = create_test_entry(name_id, file_id);
365        let node_id = arena.alloc(entry).unwrap();
366
367        // Seed identity index
368        let key = IdentityKey::new(StringId::new(1), file_id, StringId::new(10));
369        identity_index.insert(key, node_id);
370
371        let stats = remove_node(node_id, &mut identity_index, &mut arena);
372
373        assert_eq!(stats.nodes_removed, 1);
374        assert_eq!(stats.identity_entries_removed, 1);
375        assert!(arena.get(node_id).is_none());
376    }
377
378    #[test]
379    fn test_remove_nodes_batch() {
380        let mut arena = NodeArena::new();
381        let mut identity_index = IdentityIndex::new();
382        let file_id = FileId::new(0);
383        let name_id = StringId::new(1);
384
385        let node1 = arena.alloc(create_test_entry(name_id, file_id)).unwrap();
386        let node2 = arena.alloc(create_test_entry(name_id, file_id)).unwrap();
387        let node3 = arena.alloc(create_test_entry(name_id, file_id)).unwrap();
388
389        identity_index.insert(
390            IdentityKey::new(StringId::new(1), file_id, StringId::new(10)),
391            node1,
392        );
393        identity_index.insert(
394            IdentityKey::new(StringId::new(1), file_id, StringId::new(11)),
395            node2,
396        );
397
398        let stats = remove_nodes_batch(&[node1, node2, node3], &mut identity_index, &mut arena);
399
400        assert_eq!(stats.nodes_removed, 3);
401        assert_eq!(stats.identity_entries_removed, 2);
402        assert!(arena.get(node1).is_none());
403        assert!(arena.get(node2).is_none());
404        assert!(arena.get(node3).is_none());
405    }
406
407    #[test]
408    fn test_remove_nonexistent_node() {
409        let mut arena = NodeArena::new();
410        let mut identity_index = IdentityIndex::new();
411
412        // Try to remove a node that doesn't exist
413        let fake_id = NodeId::new(999, 1);
414        let stats = remove_node(fake_id, &mut identity_index, &mut arena);
415
416        // Should report 0 removed since node didn't exist
417        assert_eq!(stats.nodes_removed, 0);
418        assert_eq!(stats.identity_entries_removed, 0);
419    }
420
421    #[test]
422    fn test_incremental_stats_default() {
423        let stats = IncrementalStats::default();
424
425        assert_eq!(stats.nodes_removed, 0);
426        assert_eq!(stats.edges_removed, 0);
427        assert_eq!(stats.edges_added, 0);
428        assert_eq!(stats.identity_entries_removed, 0);
429    }
430}