Skip to main content

formualizer_eval/engine/graph/editor/
transaction_context.rs

1//! Transaction orchestration for coordinating graph mutations with rollback support
2//!
3//! This module provides:
4//! - TransactionContext: Orchestrates ChangeLog, TransactionManager, and VertexEditor
5//! - Rollback logic for undoing changes
6//! - Savepoint support for partial rollback
7
8use crate::engine::graph::DependencyGraph;
9use crate::engine::graph::editor::transaction_manager::{
10    TransactionError, TransactionId, TransactionManager,
11};
12use crate::engine::graph::editor::{EditorError, VertexEditor};
13use crate::engine::{ChangeEvent, ChangeLog};
14
15/// Orchestrates transactions across graph mutations, change logging, and rollback
16pub struct TransactionContext<'g> {
17    graph: &'g mut DependencyGraph,
18    change_log: ChangeLog,
19    tx_manager: TransactionManager,
20}
21
22impl<'g> TransactionContext<'g> {
23    /// Create a new transaction context for the given graph
24    pub fn new(graph: &'g mut DependencyGraph) -> Self {
25        Self {
26            graph,
27            change_log: ChangeLog::new(),
28            tx_manager: TransactionManager::new(),
29        }
30    }
31
32    /// Create a transaction context with custom max transaction size
33    pub fn with_max_size(graph: &'g mut DependencyGraph, max_size: usize) -> Self {
34        Self {
35            graph,
36            change_log: ChangeLog::new(),
37            tx_manager: TransactionManager::with_max_size(max_size),
38        }
39    }
40
41    /// Begin a new transaction
42    ///
43    /// # Returns
44    /// The ID of the newly created transaction
45    ///
46    /// # Errors
47    /// Returns `AlreadyActive` if a transaction is already in progress
48    pub fn begin(&mut self) -> Result<TransactionId, TransactionError> {
49        self.tx_manager.begin(self.change_log.len())
50    }
51
52    /// Create an editor that logs changes to this context
53    ///
54    /// # Safety
55    /// This uses unsafe code to work around the borrow checker.
56    /// It's safe because:
57    /// 1. We control the lifetime of both the graph and change_log
58    /// 2. The editor's lifetime is tied to the TransactionContext
59    /// 3. We ensure no aliasing occurs
60    pub fn editor(&mut self) -> VertexEditor<'_> {
61        // We need to create two mutable references: one to graph, one to change_log
62        // This is safe because VertexEditor doesn't expose the graph reference
63        // and we control the lifetimes
64        unsafe {
65            let graph_ptr = self.graph as *mut DependencyGraph;
66            let logger_ptr = &mut self.change_log as *mut ChangeLog;
67            VertexEditor::with_logger(&mut *graph_ptr, &mut *logger_ptr)
68        }
69    }
70
71    /// Commit the current transaction
72    ///
73    /// # Returns
74    /// The ID of the committed transaction
75    ///
76    /// # Errors
77    /// Returns `NoActiveTransaction` if no transaction is active
78    pub fn commit(&mut self) -> Result<TransactionId, TransactionError> {
79        // Check size limit before committing
80        self.tx_manager.check_size(self.change_log.len())?;
81        self.tx_manager.commit()
82    }
83
84    /// Rollback the current transaction
85    ///
86    /// # Errors
87    /// Returns `NoActiveTransaction` if no transaction is active
88    /// Returns `RollbackFailed` if the rollback operation fails
89    pub fn rollback(&mut self) -> Result<(), TransactionError> {
90        let (_tx_id, start_index) = self.tx_manager.rollback_info()?;
91
92        // Extract changes to rollback
93        let changes = self.change_log.take_from(start_index);
94
95        // Apply inverse operations
96        self.apply_rollback(changes)?;
97
98        Ok(())
99    }
100
101    /// Add a named savepoint to the current transaction
102    ///
103    /// # Arguments
104    /// * `name` - Name for the savepoint
105    ///
106    /// # Errors
107    /// Returns `NoActiveTransaction` if no transaction is active
108    pub fn savepoint(&mut self, name: &str) -> Result<(), TransactionError> {
109        self.tx_manager
110            .add_savepoint(name.to_string(), self.change_log.len())
111    }
112
113    /// Rollback to a named savepoint
114    ///
115    /// # Arguments
116    /// * `name` - Name of the savepoint to rollback to
117    ///
118    /// # Errors
119    /// Returns `NoActiveTransaction` if no transaction is active
120    /// Returns `SavepointNotFound` if the savepoint doesn't exist
121    /// Returns `RollbackFailed` if the rollback operation fails
122    pub fn rollback_to_savepoint(&mut self, name: &str) -> Result<(), TransactionError> {
123        let savepoint_index = self.tx_manager.get_savepoint(name)?;
124
125        // Extract changes after the savepoint
126        let changes = self.change_log.take_from(savepoint_index);
127
128        // Truncate savepoints that are being rolled back
129        self.tx_manager.truncate_savepoints(savepoint_index);
130
131        // Apply inverse operations
132        self.apply_rollback(changes)?;
133
134        Ok(())
135    }
136
137    /// Check if a transaction is currently active
138    pub fn is_active(&self) -> bool {
139        self.tx_manager.is_active()
140    }
141
142    /// Get the ID of the active transaction if any
143    pub fn active_id(&self) -> Option<TransactionId> {
144        self.tx_manager.active_id()
145    }
146
147    /// Get the current size of the change log
148    pub fn change_count(&self) -> usize {
149        self.change_log.len()
150    }
151
152    /// Get reference to the change log (for testing/debugging)
153    pub fn change_log(&self) -> &ChangeLog {
154        &self.change_log
155    }
156
157    /// Clear the change log (useful between transactions)
158    pub fn clear_change_log(&mut self) {
159        self.change_log.clear();
160    }
161
162    /// Apply rollback for a list of changes
163    fn apply_rollback(&mut self, changes: Vec<ChangeEvent>) -> Result<(), TransactionError> {
164        // Disable logging during rollback to avoid recording rollback operations
165        self.change_log.set_enabled(false);
166
167        // Track compound operation depth for proper rollback
168        let mut compound_stack = Vec::new();
169
170        // Apply changes in reverse order
171        for change in changes.into_iter().rev() {
172            match change {
173                ChangeEvent::CompoundEnd { depth } => {
174                    // Starting to rollback a compound operation (remember, we're going backwards)
175                    compound_stack.push(depth);
176                }
177                ChangeEvent::CompoundStart { depth, .. } => {
178                    // Finished rolling back a compound operation
179                    if compound_stack.last() == Some(&depth) {
180                        compound_stack.pop();
181                    }
182                }
183                _ => {
184                    // Apply inverse for actual changes
185                    if let Err(e) = self.apply_inverse(change) {
186                        self.change_log.set_enabled(true);
187                        return Err(TransactionError::RollbackFailed(e.to_string()));
188                    }
189                }
190            }
191        }
192
193        self.change_log.set_enabled(true);
194        Ok(())
195    }
196
197    /// Apply the inverse of a single change event
198    fn apply_inverse(&mut self, change: ChangeEvent) -> Result<(), EditorError> {
199        let mut editor = VertexEditor::new(self.graph);
200        editor.apply_inverse(change)
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::engine::EvalConfig;
208    use crate::{CellRef, reference::Coord};
209    use formualizer_common::LiteralValue;
210    use formualizer_parse::parse;
211
212    fn create_test_graph() -> DependencyGraph {
213        DependencyGraph::new_with_config(EvalConfig::default())
214    }
215
216    fn cell_ref(sheet_id: u16, row: u32, col: u32) -> CellRef {
217        // Test helpers use Excel 1-based coords.
218        // Graph/editor keys store absolute ("$A$1") coords.
219        CellRef::new(sheet_id, Coord::from_excel(row, col, true, true))
220    }
221
222    #[test]
223    fn test_transaction_context_basic() {
224        let mut graph = create_test_graph();
225        let mut ctx = TransactionContext::new(&mut graph);
226
227        // Begin transaction
228        let tx_id = ctx.begin().unwrap();
229        assert!(ctx.is_active());
230        assert_eq!(ctx.active_id(), Some(tx_id));
231
232        // Make changes
233        {
234            let mut editor = ctx.editor();
235            editor.set_cell_value(cell_ref(0, 1, 1), LiteralValue::Number(42.0));
236        }
237
238        // Verify change was logged
239        assert_eq!(ctx.change_count(), 1);
240
241        // Commit transaction
242        let committed_id = ctx.commit().unwrap();
243        assert_eq!(tx_id, committed_id);
244        assert!(!ctx.is_active());
245    }
246
247    #[test]
248    fn test_transaction_context_rollback_new_value() {
249        let mut graph = create_test_graph();
250
251        {
252            let mut ctx = TransactionContext::new(&mut graph);
253
254            ctx.begin().unwrap();
255
256            // Add a new value
257            {
258                let mut editor = ctx.editor();
259                editor.set_cell_value(cell_ref(0, 1, 1), LiteralValue::Number(20.0));
260            }
261
262            // Rollback
263            ctx.rollback().unwrap();
264            assert_eq!(ctx.change_count(), 0);
265        }
266
267        // Verify value was removed after context is dropped
268        assert!(
269            graph
270                .get_vertex_id_for_address(&cell_ref(0, 1, 1))
271                .is_none()
272        );
273    }
274
275    #[test]
276    fn test_transaction_context_rollback_value_update() {
277        let mut graph = create_test_graph();
278
279        // Set initial formula outside transaction (formulas are graph-owned and should rollback).
280        let original = parse("=1").unwrap();
281        let _ = graph.set_cell_formula("Sheet1", 1, 1, original.clone());
282        let vid = *graph
283            .get_vertex_id_for_address(&cell_ref(0, 1, 1))
284            .expect("vertex for A1");
285
286        {
287            let mut ctx = TransactionContext::new(&mut graph);
288            ctx.begin().unwrap();
289
290            // Update formula
291            {
292                let mut editor = ctx.editor();
293                editor.set_cell_formula(cell_ref(0, 1, 1), parse("=2").unwrap());
294            }
295
296            // Rollback
297            ctx.rollback().unwrap();
298        }
299
300        // Verify original formula restored after context is dropped
301        let f = graph.get_formula(vid).expect("formula restored");
302        assert_eq!(f.node_type, original.node_type);
303    }
304
305    #[test]
306    fn test_transaction_context_multiple_changes() {
307        let mut graph = create_test_graph();
308
309        {
310            let mut ctx = TransactionContext::new(&mut graph);
311
312            ctx.begin().unwrap();
313
314            // Make multiple changes
315            {
316                let mut editor = ctx.editor();
317                editor.set_cell_formula(cell_ref(0, 1, 1), parse("=1").unwrap());
318                editor.set_cell_formula(cell_ref(0, 2, 1), parse("=2").unwrap());
319                editor.set_cell_formula(cell_ref(0, 3, 1), parse("=A1+A2").unwrap());
320            }
321
322            assert_eq!(ctx.change_count(), 3);
323
324            // Commit
325            ctx.commit().unwrap();
326        }
327
328        // Changes should persist after context is dropped
329        assert!(
330            graph
331                .get_vertex_id_for_address(&cell_ref(0, 1, 1))
332                .is_some()
333        );
334        assert!(
335            graph
336                .get_vertex_id_for_address(&cell_ref(0, 2, 1))
337                .is_some()
338        );
339        assert!(
340            graph
341                .get_vertex_id_for_address(&cell_ref(0, 3, 1))
342                .is_some()
343        );
344    }
345
346    #[test]
347    fn test_transaction_context_savepoints() {
348        let mut graph = create_test_graph();
349
350        {
351            let mut ctx = TransactionContext::new(&mut graph);
352
353            ctx.begin().unwrap();
354
355            // First change
356            {
357                let mut editor = ctx.editor();
358                editor.set_cell_formula(cell_ref(0, 1, 1), parse("=1").unwrap());
359            }
360
361            // Create savepoint
362            ctx.savepoint("after_first").unwrap();
363
364            // More changes
365            {
366                let mut editor = ctx.editor();
367                editor.set_cell_formula(cell_ref(0, 2, 1), parse("=2").unwrap());
368                editor.set_cell_formula(cell_ref(0, 3, 1), parse("=3").unwrap());
369            }
370
371            assert_eq!(ctx.change_count(), 3);
372
373            // Rollback to savepoint
374            ctx.rollback_to_savepoint("after_first").unwrap();
375
376            // First change remains, others rolled back
377            assert_eq!(ctx.change_count(), 1);
378
379            // Can still commit the remaining changes
380            ctx.commit().unwrap();
381        }
382
383        // Verify state after context is dropped
384        assert!(
385            graph
386                .get_vertex_id_for_address(&cell_ref(0, 1, 1))
387                .is_some()
388        );
389        assert!(
390            graph
391                .get_vertex_id_for_address(&cell_ref(0, 2, 1))
392                .is_none()
393        );
394        assert!(
395            graph
396                .get_vertex_id_for_address(&cell_ref(0, 3, 1))
397                .is_none()
398        );
399    }
400
401    #[test]
402    fn test_transaction_context_size_limit() {
403        let mut graph = create_test_graph();
404        let mut ctx = TransactionContext::with_max_size(&mut graph, 2);
405
406        ctx.begin().unwrap();
407
408        // Add changes up to limit
409        {
410            let mut editor = ctx.editor();
411            editor.set_cell_value(cell_ref(0, 1, 1), LiteralValue::Number(1.0));
412            editor.set_cell_value(cell_ref(0, 2, 1), LiteralValue::Number(2.0));
413        }
414
415        // Should succeed at limit
416        assert!(ctx.commit().is_ok());
417
418        ctx.begin().unwrap();
419
420        // Exceed limit
421        {
422            let mut editor = ctx.editor();
423            editor.set_cell_value(cell_ref(0, 3, 1), LiteralValue::Number(3.0));
424            editor.set_cell_value(cell_ref(0, 4, 1), LiteralValue::Number(4.0));
425            editor.set_cell_value(cell_ref(0, 5, 1), LiteralValue::Number(5.0));
426        }
427
428        // Should fail when exceeding limit
429        match ctx.commit() {
430            Err(TransactionError::TransactionTooLarge { size, max }) => {
431                assert_eq!(size, 3);
432                assert_eq!(max, 2);
433            }
434            _ => panic!("Expected TransactionTooLarge error"),
435        }
436    }
437
438    #[test]
439    fn test_transaction_context_no_active_transaction() {
440        let mut graph = create_test_graph();
441        let mut ctx = TransactionContext::new(&mut graph);
442
443        // Operations without active transaction should fail
444        assert!(ctx.commit().is_err());
445        assert!(ctx.rollback().is_err());
446        assert!(ctx.savepoint("test").is_err());
447        assert!(ctx.rollback_to_savepoint("test").is_err());
448    }
449
450    #[test]
451    fn test_transaction_context_clear_change_log() {
452        let mut graph = create_test_graph();
453        let mut ctx = TransactionContext::new(&mut graph);
454
455        // Make changes without transaction (for testing)
456        {
457            let mut editor = ctx.editor();
458            editor.set_cell_value(cell_ref(0, 1, 1), LiteralValue::Number(1.0));
459            editor.set_cell_value(cell_ref(0, 2, 1), LiteralValue::Number(2.0));
460        }
461
462        assert_eq!(ctx.change_count(), 2);
463
464        // Clear change log
465        ctx.clear_change_log();
466        assert_eq!(ctx.change_count(), 0);
467    }
468
469    #[test]
470    fn test_transaction_context_compound_operations() {
471        let mut graph = create_test_graph();
472        let mut ctx = TransactionContext::new(&mut graph);
473
474        ctx.begin().unwrap();
475
476        // Simulate a compound operation using the change_log directly
477        ctx.change_log.begin_compound("test_compound".to_string());
478
479        {
480            let mut editor = ctx.editor();
481            editor.set_cell_value(cell_ref(0, 1, 1), LiteralValue::Number(1.0));
482            editor.set_cell_value(cell_ref(0, 2, 1), LiteralValue::Number(2.0));
483        }
484
485        ctx.change_log.end_compound();
486
487        // Should have 4 events: CompoundStart, 2 SetValue, CompoundEnd
488        assert_eq!(ctx.change_count(), 4);
489
490        // Rollback should handle compound operations
491        ctx.rollback().unwrap();
492        assert_eq!(ctx.change_count(), 0);
493    }
494}