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}