Skip to main content

vibesql_executor/
transaction.rs

1//! Transaction control statement execution (BEGIN, COMMIT, ROLLBACK)
2
3use vibesql_ast::{
4    BeginStmt, CommitStmt, DurabilityHint, ReleaseSavepointStmt, RollbackStmt,
5    RollbackToSavepointStmt, SavepointStmt,
6};
7use vibesql_storage::{Database, TransactionDurability};
8
9use crate::errors::ExecutorError;
10
11/// Convert AST DurabilityHint to storage TransactionDurability
12fn convert_durability_hint(hint: &DurabilityHint) -> TransactionDurability {
13    match hint {
14        DurabilityHint::Default => TransactionDurability::Default,
15        DurabilityHint::Durable => TransactionDurability::ForceDurable,
16        DurabilityHint::Lazy => TransactionDurability::AllowLazy,
17        DurabilityHint::Volatile => TransactionDurability::ForceVolatile,
18    }
19}
20
21/// Executor for BEGIN TRANSACTION statements
22pub struct BeginTransactionExecutor;
23
24impl BeginTransactionExecutor {
25    /// Execute a BEGIN TRANSACTION statement
26    pub fn execute(stmt: &BeginStmt, db: &mut Database) -> Result<String, ExecutorError> {
27        let durability = convert_durability_hint(&stmt.durability);
28        db.begin_transaction_with_durability(durability).map_err(|e| {
29            ExecutorError::StorageError(format!("Failed to begin transaction: {}", e))
30        })?;
31
32        let msg = match stmt.durability {
33            DurabilityHint::Default => "Transaction started".to_string(),
34            DurabilityHint::Durable => "Transaction started (durability: DURABLE)".to_string(),
35            DurabilityHint::Lazy => "Transaction started (durability: LAZY)".to_string(),
36            DurabilityHint::Volatile => "Transaction started (durability: VOLATILE)".to_string(),
37        };
38        Ok(msg)
39    }
40}
41
42/// Executor for COMMIT statements
43pub struct CommitExecutor;
44
45impl CommitExecutor {
46    /// Execute a COMMIT statement.
47    ///
48    /// Phase C2 of #5085: drains the transaction's deferred FK
49    /// violation queue and re-checks each entry against the current
50    /// parent state. If any deferred violation still holds, the COMMIT
51    /// fails and the transaction is rolled back, matching SQLite
52    /// semantics ("FOREIGN KEY constraint failed" raised at COMMIT).
53    pub fn execute(_stmt: &CommitStmt, db: &mut Database) -> Result<String, ExecutorError> {
54        // Drain and re-validate deferred FK violations before commit.
55        //
56        // Phase 1d of #5136 — FK deferred-replay coordination:
57        // Capture a fresh **commit-time** snapshot for the re-validation
58        // scan. The BEGIN-time snapshot (`db.current_snapshot()`) would
59        // miss writes committed by other transactions between BEGIN and
60        // COMMIT, which is exactly the wrong semantics for FK enforcement
61        // (we want the latest visible parent state, not a stale one).
62        //
63        // Under the current single-writer model, the commit-time snapshot
64        // treats every transaction id allocated so far as committed,
65        // making the committing transaction's own writes (stamped with
66        // `xmin = current_txn_id`) pass `visible_to`.
67        let pending = db.take_deferred_fk_violations();
68        let commit_snapshot = db.capture_commit_time_snapshot();
69        if let Some(err_msg) = check_deferred_fk_violations(db, &pending, &commit_snapshot) {
70            // Deferred violation still holds at COMMIT — abort the
71            // commit and roll back. SQLite raises "FOREIGN KEY
72            // constraint failed" here; auto-rollback follows the same
73            // convention as Bucket D (#5087, merged 17f23988).
74            //
75            // We ignore the rollback's own error: if rollback also
76            // fails, the original FK error is the more useful one.
77            let _ = db.rollback_transaction();
78            return Err(ExecutorError::ConstraintViolation(err_msg));
79        }
80
81        db.commit_transaction().map_err(|e| {
82            ExecutorError::StorageError(format!("Failed to commit transaction: {}", e))
83        })?;
84
85        Ok("Transaction committed".to_string())
86    }
87}
88
89/// Re-validate every deferred FK violation against the current parent
90/// state. Returns `Some(error_message)` for the first entry that still
91/// fails; returns `None` when every entry has been satisfied (because
92/// the parent row was inserted later, the child row was deleted, etc.).
93///
94/// # Phase 1d of #5136 — snapshot semantics
95///
96/// `snapshot` is the **commit-time** snapshot
97/// ([`Database::capture_commit_time_snapshot`]), not the BEGIN-time
98/// snapshot. Under MVCC, this ensures that:
99///
100/// - The child-side check sees the committing transaction's own
101///   INSERT/UPDATE (the row that triggered the deferred violation in the
102///   first place) — even though it was stamped with this txn's `xmin`,
103///   the commit-time snapshot treats this txn id as committed.
104/// - The parent-side check sees any parent rows committed by OTHER
105///   transactions between BEGIN and COMMIT — which is exactly what we
106///   want: deferred FK is checked against the latest visible state, not
107///   a stale BEGIN-time view.
108///
109/// With the `mvcc_enabled` feature OFF (default), the storage-layer
110/// `is_row_visible` reduces to a deletion-bitmap check and the snapshot
111/// argument is ignored, preserving pre-MVCC semantics.
112fn check_deferred_fk_violations(
113    db: &Database,
114    pending: &[vibesql_storage::DeferredFkViolation],
115    snapshot: &vibesql_storage::TxnSnapshot,
116) -> Option<String> {
117    use crate::foreign_key_check::{
118        fk_values_equal, parent_collations_for_fk, resolved_parent_indices_for_fk,
119    };
120
121    for violation in pending {
122        let child_schema = match db.catalog.get_table(&violation.child_table) {
123            Some(s) => s,
124            None => {
125                // Child table was dropped during the transaction —
126                // there is no row left to violate the constraint.
127                continue;
128            }
129        };
130        let fk = match child_schema.foreign_keys.get(violation.fk_index) {
131            Some(fk) => fk,
132            None => continue, // FK index out of range — schema changed
133        };
134
135        // 1) Child-side check: does a row matching this snapshot still
136        //    exist in the child table? If the user has since deleted or
137        //    updated the offending child row, the violation is moot.
138        let child_table = match db.get_table(&violation.child_table) {
139            Some(t) => t,
140            None => continue,
141        };
142
143        // Take FK column values from the snapshot of the child row.
144        let snapshot_fk_values: Vec<vibesql_types::SqlValue> = fk
145            .column_indices
146            .iter()
147            .map(|&idx| {
148                violation.child_row.get(idx).cloned().unwrap_or(vibesql_types::SqlValue::Null)
149            })
150            .collect();
151
152        // NULLs in the FK columns mean the constraint never applied.
153        if snapshot_fk_values.iter().any(|v| v.is_null()) {
154            continue;
155        }
156
157        // Does *any* visible child row still carry these FK values? If
158        // not, the conflict has been resolved (child deleted/updated).
159        // Phase 1d: use `scan_visible` so the committing txn's own
160        // child writes participate, and so a concurrent committed
161        // delete is honored.
162        let child_still_present = child_table.scan_visible(snapshot).any(|(_, child_row)| {
163            fk.column_indices.iter().enumerate().all(|(i, &col_idx)| {
164                match child_row.values.get(col_idx) {
165                    Some(v) => v == &snapshot_fk_values[i],
166                    None => false,
167                }
168            })
169        });
170        if !child_still_present {
171            continue;
172        }
173
174        // 2) Parent-side check: does a parent row now exist that
175        //    matches the FK columns?
176        let parent_table = match db.get_table(&fk.parent_table) {
177            Some(t) => t,
178            None => {
179                // Parent table missing — definitely a violation.
180                return Some(format!(
181                    "FOREIGN KEY constraint failed: parent table '{}' missing at COMMIT",
182                    fk.parent_table
183                ));
184            }
185        };
186
187        let parent_collations = parent_collations_for_fk(db, fk);
188        let parent_indices = resolved_parent_indices_for_fk(db, fk);
189
190        // Phase 1d: scan visible parent rows (commit-time snapshot).
191        // Under MVCC OFF this is equivalent to scanning all rows
192        // (matches previous behavior of `parent_table.scan().iter()`,
193        // which did not filter the deletion bitmap; we keep the
194        // pre-existing wider semantics by also looking at non-bitmap-
195        // deleted rows — the `scan_visible` iterator filters the
196        // bitmap, but pre-Phase-1d the parent scan included deleted
197        // rows too, so to preserve OFF-state semantics exactly we
198        // continue to use `scan()` when MVCC is off).
199        let key_exists = {
200            #[cfg(feature = "mvcc_enabled")]
201            {
202                parent_table.scan_visible(snapshot).any(|(_, parent_row)| {
203                    parent_indices.iter().zip(&snapshot_fk_values).enumerate().all(
204                        |(i, (&parent_idx, fk_val))| match parent_row.get(parent_idx) {
205                            Some(parent_val) => fk_values_equal(
206                                fk_val,
207                                parent_val,
208                                parent_collations.get(i).and_then(|c| c.as_deref()),
209                            ),
210                            None => false,
211                        },
212                    )
213                })
214            }
215            #[cfg(not(feature = "mvcc_enabled"))]
216            {
217                let _ = snapshot;
218                parent_table.scan().iter().any(|parent_row| {
219                    parent_indices.iter().zip(&snapshot_fk_values).enumerate().all(
220                        |(i, (&parent_idx, fk_val))| match parent_row.get(parent_idx) {
221                            Some(parent_val) => fk_values_equal(
222                                fk_val,
223                                parent_val,
224                                parent_collations.get(i).and_then(|c| c.as_deref()),
225                            ),
226                            None => false,
227                        },
228                    )
229                })
230            }
231        };
232
233        if !key_exists {
234            // Match SQLite's wording for deferred FK failure at commit.
235            return Some("FOREIGN KEY constraint failed".to_string());
236        }
237    }
238
239    None
240}
241
242/// Count deferred FK violations whose constraints would still fail
243/// against the current visible state of the database.
244///
245/// This mirrors SQLite's `DBSTATUS_DEFERRED_FKS` semantics: entries
246/// whose child row has since been deleted/updated, or whose missing
247/// parent row has since been (re)inserted, are *not* counted because
248/// they would no longer fail a COMMIT-time replay. Entries with NULL
249/// FK columns are also skipped because the FK never applied to them.
250///
251/// Returns 0 when no transaction is active.
252///
253/// # Relationship to [`check_deferred_fk_violations`]
254///
255/// Both walk the same `deferred_fk_violations` queue and apply the
256/// child-side / parent-side checks. The differences are:
257///
258/// 1. This function is purely read-only and never drains the queue.
259/// 2. Both child- and parent-side scans use `scan_live()` (bitmap-
260///    filtered) so that DELETEs performed earlier in the current
261///    transaction are honored. The COMMIT path's parent scan
262///    intentionally includes soft-deleted rows under the MVCC-OFF
263///    feature flag for backward-compatibility — for status reporting
264///    we want the SQLite-compatible "is there a live parent right
265///    now?" answer instead.
266///
267/// Backs the `PRAGMA deferred_fk_count` bridge that the TCL shim
268/// translates `sqlite3_db_status db DBSTATUS_DEFERRED_FKS` into
269/// (issue #5187).
270pub fn live_deferred_fk_violation_count(db: &Database) -> usize {
271    use crate::foreign_key_check::{
272        fk_values_equal, parent_collations_for_fk, resolved_parent_indices_for_fk,
273    };
274
275    let pending = db.deferred_fk_violations();
276    if pending.is_empty() {
277        return 0;
278    }
279
280    let mut live = 0usize;
281    for violation in pending {
282        let child_schema = match db.catalog.get_table(&violation.child_table) {
283            Some(s) => s,
284            None => continue, // child table dropped — violation no longer applies
285        };
286        let fk = match child_schema.foreign_keys.get(violation.fk_index) {
287            Some(fk) => fk,
288            None => continue, // schema changed — violation gone
289        };
290
291        let child_table = match db.get_table(&violation.child_table) {
292            Some(t) => t,
293            None => continue,
294        };
295
296        let snapshot_fk_values: Vec<vibesql_types::SqlValue> = fk
297            .column_indices
298            .iter()
299            .map(|&idx| {
300                violation.child_row.get(idx).cloned().unwrap_or(vibesql_types::SqlValue::Null)
301            })
302            .collect();
303
304        // NULLs in FK columns mean the constraint never applied.
305        if snapshot_fk_values.iter().any(|v| v.is_null()) {
306            continue;
307        }
308
309        // Child still present? Use `scan_live` (bitmap-filtered) so that a
310        // sibling DELETE in the same transaction is honored — that is the
311        // resolution path exercised by fkey6-1.21.
312        let child_still_present = child_table.scan_live().any(|(_, child_row)| {
313            fk.column_indices.iter().enumerate().all(
314                |(i, &col_idx)| match child_row.values.get(col_idx) {
315                    Some(v) => v == &snapshot_fk_values[i],
316                    None => false,
317                },
318            )
319        });
320        if !child_still_present {
321            continue;
322        }
323
324        // Parent now exists?
325        let parent_table = match db.get_table(&fk.parent_table) {
326            Some(t) => t,
327            None => {
328                // Parent table missing — definite violation.
329                live += 1;
330                continue;
331            }
332        };
333
334        let parent_collations = parent_collations_for_fk(db, fk);
335        let parent_indices = resolved_parent_indices_for_fk(db, fk);
336
337        // Parent-side check uses `scan_live` (bitmap-filtered) so that the
338        // parent DELETE that originally triggered the deferred violation
339        // is honored. Note this differs from the COMMIT-time replay path
340        // (`check_deferred_fk_violations`), which deliberately includes
341        // soft-deleted parent rows under the MVCC-OFF feature for
342        // backward-compatibility reasons; for status reporting we want
343        // SQLite-compatible "is there a live parent right now" semantics.
344        let key_exists = parent_table.scan_live().any(|(_, parent_row)| {
345            parent_indices.iter().zip(&snapshot_fk_values).enumerate().all(
346                |(i, (&parent_idx, fk_val))| match parent_row.values.get(parent_idx) {
347                    Some(parent_val) => fk_values_equal(
348                        fk_val,
349                        parent_val,
350                        parent_collations.get(i).and_then(|c| c.as_deref()),
351                    ),
352                    None => false,
353                },
354            )
355        });
356
357        if !key_exists {
358            live += 1;
359        }
360    }
361
362    live
363}
364
365/// Executor for ROLLBACK statements
366pub struct RollbackExecutor;
367
368impl RollbackExecutor {
369    /// Execute a ROLLBACK statement
370    pub fn execute(_stmt: &RollbackStmt, db: &mut Database) -> Result<String, ExecutorError> {
371        db.rollback_transaction().map_err(|e| {
372            ExecutorError::StorageError(format!("Failed to rollback transaction: {}", e))
373        })?;
374
375        Ok("Transaction rolled back".to_string())
376    }
377}
378
379/// Executor for SAVEPOINT statements
380pub struct SavepointExecutor;
381
382impl SavepointExecutor {
383    /// Execute a SAVEPOINT statement
384    pub fn execute(stmt: &SavepointStmt, db: &mut Database) -> Result<String, ExecutorError> {
385        db.create_savepoint(stmt.name.clone()).map_err(|e| {
386            ExecutorError::StorageError(format!("Failed to create savepoint: {}", e))
387        })?;
388
389        Ok(format!("Savepoint '{}' created", stmt.name))
390    }
391}
392
393/// Executor for ROLLBACK TO SAVEPOINT statements
394pub struct RollbackToSavepointExecutor;
395
396impl RollbackToSavepointExecutor {
397    /// Execute a ROLLBACK TO SAVEPOINT statement
398    pub fn execute(
399        stmt: &RollbackToSavepointStmt,
400        db: &mut Database,
401    ) -> Result<String, ExecutorError> {
402        db.rollback_to_savepoint(stmt.name.clone()).map_err(|e| {
403            ExecutorError::StorageError(format!("Failed to rollback to savepoint: {}", e))
404        })?;
405
406        Ok(format!("Rolled back to savepoint '{}'", stmt.name))
407    }
408}
409
410/// Executor for RELEASE SAVEPOINT statements
411pub struct ReleaseSavepointExecutor;
412
413impl ReleaseSavepointExecutor {
414    /// Execute a RELEASE SAVEPOINT statement
415    pub fn execute(
416        stmt: &ReleaseSavepointStmt,
417        db: &mut Database,
418    ) -> Result<String, ExecutorError> {
419        db.release_savepoint(stmt.name.clone()).map_err(|e| {
420            ExecutorError::StorageError(format!("Failed to release savepoint: {}", e))
421        })?;
422
423        Ok(format!("Savepoint '{}' released", stmt.name))
424    }
425}