Skip to main content

sqry_core/graph/unified/
txn.rs

1//! Graph write transactions for plugin safety.
2//!
3//! This module provides RAII transaction semantics for plugins writing to the
4//! unified graph. Changes are buffered and applied atomically on commit.
5
6use std::path::Path;
7
8use anyhow::{Context, Result};
9
10use crate::graph::node::Language;
11use crate::graph::unified::{CodeGraph, EdgeKind, NodeKind};
12
13use super::edge::EdgeId;
14use super::node::NodeId;
15
16/// RAII transaction wrapper for graph writes.
17///
18/// `GraphWriteTxn` provides a safe interface for plugins to add nodes and edges
19/// to the unified graph. Changes are buffered and applied atomically when
20/// `commit()` is called.
21///
22/// # Design
23///
24/// - **RAII semantics**: Transaction must be explicitly committed or changes are lost
25/// - **Buffered writes**: Changes accumulate in memory until commit
26/// - **Atomic application**: All changes applied together or none at all
27/// - **Error recovery**: Failed commits leave graph unchanged
28///
29/// # Example
30///
31/// ```ignore
32/// use sqry_core::graph::unified::txn::GraphWriteTxn;
33/// use sqry_core::graph::unified::CodeGraph;
34///
35/// let mut graph = CodeGraph::new();
36/// let mut txn = GraphWriteTxn::new(&mut graph);
37///
38/// // Add nodes
39/// let node_id = txn.add_node(
40///     Language::Rust,
41///     "main.rs",
42///     "main",
43///     NodeKind::Function,
44///     Some("fn main() { ... }"),
45/// )?;
46///
47/// // Add edges
48/// txn.add_edge(caller_id, callee_id, EdgeKind::Call)?;
49///
50/// // Commit changes
51/// txn.commit()?;
52/// ```
53pub struct GraphWriteTxn<'a> {
54    /// Mutable reference to the graph being modified.
55    graph: &'a mut CodeGraph,
56
57    /// Buffered node additions (language, file, symbol, kind, signature).
58    pending_nodes: Vec<(Language, String, String, NodeKind, Option<String>)>,
59
60    /// Buffered edge additions (source, target, kind).
61    pending_edges: Vec<(NodeId, NodeId, EdgeKind)>,
62
63    /// Whether this transaction has been committed.
64    committed: bool,
65}
66
67impl<'a> GraphWriteTxn<'a> {
68    /// Creates a new write transaction.
69    ///
70    /// The transaction holds a mutable reference to the graph for its lifetime.
71    /// Changes are not visible until `commit()` is called.
72    ///
73    /// # Example
74    ///
75    /// ```ignore
76    /// let mut graph = CodeGraph::new();
77    /// let mut txn = GraphWriteTxn::new(&mut graph);
78    /// ```
79    #[must_use]
80    pub fn new(graph: &'a mut CodeGraph) -> Self {
81        Self {
82            graph,
83            pending_nodes: Vec::new(),
84            pending_edges: Vec::new(),
85            committed: false,
86        }
87    }
88
89    /// Adds a node to the transaction.
90    ///
91    /// The node is not added to the graph until `commit()` is called.
92    ///
93    /// # Arguments
94    ///
95    /// * `language` - Programming language of the node
96    /// * `file` - Source file path
97    /// * `symbol` - Node name (e.g., "main", "`MyClass::method`")
98    /// * `kind` - Node kind (Function, Class, etc.)
99    /// * `signature` - Optional signature string
100    ///
101    /// # Returns
102    ///
103    /// Temporary `NodeId` that will be valid after commit. This ID is a
104    /// placeholder and should not be used until after `commit()` succeeds.
105    ///
106    /// # Errors
107    ///
108    /// Returns `GraphResult` error if node creation fails (e.g., invalid parameters).
109    ///
110    /// # Panics
111    ///
112    /// Panics if the pending node count exceeds `u32::MAX`.
113    ///
114    /// # Example
115    ///
116    /// ```ignore
117    /// let node_id = txn.add_node(
118    ///     Language::Rust,
119    ///     "src/main.rs",
120    ///     "main",
121    ///     NodeKind::Function,
122    ///     Some("fn main()"),
123    /// )?;
124    /// ```
125    pub fn add_node(
126        &mut self,
127        language: Language,
128        file: impl Into<String>,
129        symbol: impl Into<String>,
130        kind: NodeKind,
131        signature: Option<String>,
132    ) -> Result<NodeId> {
133        // Generate a temporary node ID (index is pending_nodes.len())
134        let node_index =
135            u32::try_from(self.pending_nodes.len()).expect("pending node index exceeds u32::MAX");
136        let temp_id = NodeId::new(node_index, 0);
137
138        // Buffer the node addition
139        self.pending_nodes
140            .push((language, file.into(), symbol.into(), kind, signature));
141
142        Ok(temp_id)
143    }
144
145    /// Adds an edge to the transaction.
146    ///
147    /// The edge is not added to the graph until `commit()` is called.
148    ///
149    /// # Arguments
150    ///
151    /// * `source` - Source node ID (must exist after commit)
152    /// * `target` - Target node ID (must exist after commit)
153    /// * `kind` - Edge kind (Call, Import, etc.)
154    ///
155    /// # Returns
156    ///
157    /// Temporary `EdgeId` that will be valid after commit.
158    ///
159    /// # Errors
160    ///
161    /// Returns `GraphResult` error if edge creation fails or nodes don't exist.
162    ///
163    /// # Panics
164    ///
165    /// Panics if the pending edge count exceeds `u32::MAX`.
166    ///
167    /// # Example
168    ///
169    /// ```ignore
170    /// txn.add_edge(caller_id, callee_id, EdgeKind::Call)?;
171    /// ```
172    pub fn add_edge(&mut self, source: NodeId, target: NodeId, kind: EdgeKind) -> Result<EdgeId> {
173        // Generate a temporary edge ID
174        let edge_index =
175            u32::try_from(self.pending_edges.len()).expect("pending edge index exceeds u32::MAX");
176        let temp_id = EdgeId::new(edge_index);
177
178        // Buffer the edge addition
179        self.pending_edges.push((source, target, kind));
180
181        Ok(temp_id)
182    }
183
184    /// Commits all buffered changes to the graph.
185    ///
186    /// This method applies all pending nodes and edges atomically. If any
187    /// operation fails, the entire transaction is rolled back and the graph
188    /// remains unchanged.
189    ///
190    /// # Returns
191    ///
192    /// `Ok(())` if all changes were successfully applied.
193    ///
194    /// # Errors
195    ///
196    /// Returns `GraphResult` error if any node or edge addition fails.
197    /// On error, all changes are rolled back.
198    ///
199    /// # Panics
200    ///
201    /// Panics if called twice on the same transaction.
202    ///
203    /// # Example
204    ///
205    /// ```ignore
206    /// txn.commit()?;
207    /// ```
208    pub fn commit(mut self) -> Result<()> {
209        assert!(!self.committed, "Transaction already committed");
210        self.committed = true;
211
212        // Map from temporary IDs to real IDs
213        let mut node_id_map = Vec::new();
214
215        // Apply all node additions
216        for (_language, file, symbol, kind, signature) in &self.pending_nodes {
217            use crate::graph::unified::storage::arena::NodeEntry;
218
219            // Intern/register strings and files
220            let file_id = self
221                .graph
222                .files_mut()
223                .register(Path::new(file))
224                .with_context(|| format!("Failed to register file: {file}"))?;
225            let name_id = self.graph.strings_mut().intern(symbol)?;
226
227            // Create node entry with minimal required fields
228            let mut entry = NodeEntry::new(*kind, name_id, file_id);
229
230            // Add signature if provided
231            if let Some(sig) = signature {
232                let signature_id = self.graph.strings_mut().intern(sig)?;
233                entry = entry.with_signature(signature_id);
234            }
235
236            // Allocate node in arena
237            let node_id = self
238                .graph
239                .nodes_mut()
240                .alloc(entry)
241                .with_context(|| format!("Failed to allocate node for symbol: {symbol}"))?;
242
243            node_id_map.push(node_id);
244        }
245
246        // Apply all edge additions
247        for (source_temp, target_temp, kind) in &self.pending_edges {
248            // Map temporary IDs to real IDs
249            let source = *node_id_map
250                .get(source_temp.index() as usize)
251                .ok_or_else(|| {
252                    anyhow::anyhow!(
253                        "Source node {} not found in transaction",
254                        source_temp.index()
255                    )
256                })?;
257
258            let target = *node_id_map
259                .get(target_temp.index() as usize)
260                .ok_or_else(|| {
261                    anyhow::anyhow!(
262                        "Target node {} not found in transaction",
263                        target_temp.index()
264                    )
265                })?;
266
267            // Get the file ID from the source node for edge attribution
268            let file_id = if let Some(entry) = self.graph.nodes().get(source) {
269                entry.file
270            } else {
271                // Fallback: register empty path
272                self.graph.files_mut().register(Path::new(""))?
273            };
274
275            // Add edge to bidirectional store
276            self.graph
277                .edges_mut()
278                .add_edge(source, target, kind.clone(), file_id);
279        }
280
281        // Bump epoch to invalidate cursors
282        self.graph.bump_epoch();
283
284        Ok(())
285    }
286
287    /// Returns the number of pending nodes.
288    ///
289    /// # Example
290    ///
291    /// ```ignore
292    /// assert_eq!(txn.pending_nodes(), 5);
293    /// ```
294    #[must_use]
295    pub fn pending_nodes(&self) -> usize {
296        self.pending_nodes.len()
297    }
298
299    /// Returns the number of pending edges.
300    ///
301    /// # Example
302    ///
303    /// ```ignore
304    /// assert_eq!(txn.pending_edges(), 3);
305    /// ```
306    #[must_use]
307    pub fn pending_edges(&self) -> usize {
308        self.pending_edges.len()
309    }
310}
311
312impl Drop for GraphWriteTxn<'_> {
313    fn drop(&mut self) {
314        if !self.committed && (!self.pending_nodes.is_empty() || !self.pending_edges.is_empty()) {
315            // Log warning about uncommitted changes
316            log::warn!(
317                "GraphWriteTxn dropped without commit ({} nodes, {} edges discarded)",
318                self.pending_nodes.len(),
319                self.pending_edges.len()
320            );
321        }
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    #[test]
330    fn test_txn_new() {
331        let mut graph = CodeGraph::new();
332        let txn = GraphWriteTxn::new(&mut graph);
333        assert_eq!(txn.pending_nodes(), 0);
334        assert_eq!(txn.pending_edges(), 0);
335    }
336
337    #[test]
338    fn test_txn_add_node() {
339        let mut graph = CodeGraph::new();
340        let mut txn = GraphWriteTxn::new(&mut graph);
341
342        let node_id = txn
343            .add_node(
344                Language::Rust,
345                "main.rs",
346                "main",
347                NodeKind::Function,
348                Some("fn main()".to_string()),
349            )
350            .expect("add_node");
351
352        assert_eq!(txn.pending_nodes(), 1);
353        assert_eq!(node_id.index(), 0);
354    }
355
356    #[test]
357    fn test_txn_add_edge() {
358        let mut graph = CodeGraph::new();
359        let mut txn = GraphWriteTxn::new(&mut graph);
360
361        let source = txn
362            .add_node(
363                Language::Rust,
364                "main.rs",
365                "caller",
366                NodeKind::Function,
367                None,
368            )
369            .expect("add_node");
370        let target = txn
371            .add_node(
372                Language::Rust,
373                "main.rs",
374                "callee",
375                NodeKind::Function,
376                None,
377            )
378            .expect("add_node");
379
380        let _edge_id = txn
381            .add_edge(
382                source,
383                target,
384                EdgeKind::Calls {
385                    argument_count: 0,
386                    is_async: false,
387                },
388            )
389            .expect("add_edge");
390
391        assert_eq!(txn.pending_nodes(), 2);
392        assert_eq!(txn.pending_edges(), 1);
393    }
394
395    #[test]
396    fn test_txn_commit_empty() {
397        let mut graph = CodeGraph::new();
398        let txn = GraphWriteTxn::new(&mut graph);
399        txn.commit().expect("commit empty txn");
400    }
401
402    #[test]
403    fn test_txn_commit_nodes() {
404        let mut graph = CodeGraph::new();
405        let initial_epoch = graph.epoch();
406
407        let mut txn = GraphWriteTxn::new(&mut graph);
408
409        txn.add_node(
410            Language::Rust,
411            "main.rs",
412            "main",
413            NodeKind::Function,
414            Some("fn main()".to_string()),
415        )
416        .expect("add_node");
417
418        txn.commit().expect("commit");
419
420        // Epoch should be incremented
421        assert_eq!(graph.epoch(), initial_epoch + 1);
422
423        // Node should exist in graph
424        assert_eq!(graph.nodes().len(), 1);
425    }
426
427    #[test]
428    fn test_txn_commit_edges() {
429        let mut graph = CodeGraph::new();
430        let mut txn = GraphWriteTxn::new(&mut graph);
431
432        let source = txn
433            .add_node(
434                Language::Rust,
435                "main.rs",
436                "caller",
437                NodeKind::Function,
438                None,
439            )
440            .expect("add_node");
441        let target = txn
442            .add_node(
443                Language::Rust,
444                "main.rs",
445                "callee",
446                NodeKind::Function,
447                None,
448            )
449            .expect("add_node");
450
451        txn.add_edge(
452            source,
453            target,
454            EdgeKind::Calls {
455                argument_count: 0,
456                is_async: false,
457            },
458        )
459        .expect("add_edge");
460
461        txn.commit().expect("commit");
462
463        assert_eq!(graph.nodes().len(), 2);
464    }
465}