vibesql_executor/trigger_execution.rs
1//! Trigger execution logic for firing triggers on DML operations
2
3use std::cell::{Cell, RefCell};
4use std::collections::HashMap;
5
6use vibesql_ast::{PseudoTable, TriggerEvent, TriggerGranularity, TriggerTiming};
7use vibesql_catalog::{TableSchema, TriggerDefinition};
8use vibesql_storage::{Database, Row};
9use vibesql_types::SqlValue;
10
11use crate::errors::ExecutorError;
12
13/// Maximum trigger recursion depth before VibeSQL aborts with
14/// "too many levels of trigger recursion".
15///
16/// VibeSQL previously used a far stricter cap of 16, which wrongly rejected
17/// legitimate recursive-trigger programs at depth 17..=N that SQLite accepts
18/// (e.g. triggerC-2.2/2.3 drive ~500 levels). SQLite's compile-time default is
19/// `SQLITE_MAX_TRIGGER_DEPTH = 1000` (sqlite3 3.51.0). See #5479.
20///
21/// On native targets this now matches SQLite exactly: **1000** (#5534). It used
22/// to be 700, capped for stack safety because trigger recursion is implemented
23/// as native Rust call-stack recursion (~8.7 KiB/level in release) and an 8 MiB
24/// thread overflows at depth ~960 — so a static 1000 would CRASH before the cap
25/// check fired. We removed that stack dependency by growing the native stack
26/// on demand at the trigger-recursion entry point (`stacker::maybe_grow` in
27/// [`grow_stack_for_trigger`]): each nested level ensures a large red-zone of
28/// free stack before re-entering DML, allocating a fresh heap-backed stack
29/// segment when the current thread's stack runs low. Reaching the 1000 cap is
30/// therefore a clean "too many levels of trigger recursion" error, never an
31/// overflow, regardless of the thread's fixed stack size.
32///
33/// WASM has no `stacker` backend, so trigger recursion there stays on the fixed
34/// native stack and must keep the lower stack-safe cap; see
35/// [`MAX_TRIGGER_RECURSION_DEPTH`]'s wasm definition below.
36///
37/// The runtime `sqlite3_limit(SQLITE_LIMIT_TRIGGER_DEPTH)` clamp
38/// ([`effective_trigger_depth_limit`]) clamps a connection's limit into
39/// `[1, MAX_TRIGGER_RECURSION_DEPTH]`, so raising this cap automatically raises
40/// the runtime ceiling too while preserving the downward-clamp safety (#5536).
41#[cfg(not(target_arch = "wasm32"))]
42const MAX_TRIGGER_RECURSION_DEPTH: usize = 1000;
43
44/// WASM trigger recursion cap.
45///
46/// `stacker` has no wasm backend, so on wasm trigger recursion stays on the
47/// fixed native stack and we keep the previously-proven stack-safe value of 700
48/// (the analysis in the native variant above applies: native call-stack
49/// recursion at ~8.7 KiB/level, 700 keeps headroom below an 8 MiB overflow
50/// cliff). Reaching SQLite's full 1000 on wasm would require an explicit
51/// heap-allocated work stack instead of native recursion (Option B in #5534).
52#[cfg(target_arch = "wasm32")]
53const MAX_TRIGGER_RECURSION_DEPTH: usize = 700;
54
55/// Free-stack red zone required before entering one more trigger-recursion
56/// level, and the size of each fresh stack segment `stacker` allocates when the
57/// red zone is not met. A single trigger level (parse + plan + DML + fire) costs
58/// ~8.7 KiB in release and ~50 KiB in debug; 1 MiB of red zone is comfortably
59/// more than one level needs, and a 8 MiB new-segment keeps allocations rare
60/// (one segment covers ~900 release / ~160 debug further levels) so the 1000-cap
61/// recursion grows at most a couple of heap segments. Native targets only.
62#[cfg(not(target_arch = "wasm32"))]
63const TRIGGER_STACK_RED_ZONE: usize = 1024 * 1024;
64#[cfg(not(target_arch = "wasm32"))]
65const TRIGGER_STACK_GROW_SIZE: usize = 8 * 1024 * 1024;
66
67/// Ensure there is enough native stack to descend one more trigger-recursion
68/// level, growing the stack on demand, then run `f`.
69///
70/// This is the load-bearing stack-safety primitive for #5534: it lets nested
71/// trigger firing reach SQLite's full `SQLITE_MAX_TRIGGER_DEPTH = 1000` without
72/// depending on the (fixed) worker-thread stack being large enough for 1000
73/// native frames. `stacker::maybe_grow` is a no-op when ample stack remains, so
74/// the common shallow case has negligible overhead.
75///
76/// On wasm (`stacker` unavailable) this is a transparent pass-through; the lower
77/// wasm cap keeps native recursion within the fixed wasm stack.
78#[cfg(not(target_arch = "wasm32"))]
79#[inline]
80fn grow_stack_for_trigger<R>(f: impl FnOnce() -> R) -> R {
81 stacker::maybe_grow(TRIGGER_STACK_RED_ZONE, TRIGGER_STACK_GROW_SIZE, f)
82}
83
84#[cfg(target_arch = "wasm32")]
85#[inline]
86fn grow_stack_for_trigger<R>(f: impl FnOnce() -> R) -> R {
87 f()
88}
89
90/// Resolve the effective trigger recursion-depth limit for a connection.
91///
92/// SQLite lets a connection lower its trigger-recursion limit at runtime via
93/// `sqlite3_limit(db, SQLITE_LIMIT_TRIGGER_DEPTH, N)` (exercised by
94/// triggerC-3.5.x / 3.6.x). VibeSQL honors that here (#5536):
95/// - no per-connection limit set: the compile-time stack-safe cap
96/// `MAX_TRIGGER_RECURSION_DEPTH`,
97/// - a per-connection limit `N`: `N` clamped into the stack-safe range
98/// `[1, MAX_TRIGGER_RECURSION_DEPTH]`.
99///
100/// Clamping matches SQLite, which never lets a runtime limit exceed the
101/// compile-time `SQLITE_MAX_TRIGGER_DEPTH`, and never lets it drop below 1
102/// (a value <= 0 is treated as "query only" by `sqlite3_limit` and leaves the
103/// existing limit unchanged; here a non-positive stored value falls back to the
104/// cap). Lowering the limit can only make recursion abort *sooner*, so it is
105/// always stack-safe.
106fn effective_trigger_depth_limit(db: &Database) -> usize {
107 match db.trigger_depth_limit() {
108 Some(n) if n >= 1 => (n as usize).min(MAX_TRIGGER_RECURSION_DEPTH),
109 _ => MAX_TRIGGER_RECURSION_DEPTH,
110 }
111}
112
113thread_local! {
114 /// Current trigger recursion depth for this thread
115 static TRIGGER_RECURSION_DEPTH: Cell<usize> = const { Cell::new(0) };
116}
117
118/// RAII guard for managing trigger recursion depth
119/// Increments depth on creation, decrements on drop
120struct RecursionGuard;
121
122impl RecursionGuard {
123 /// Create a new recursion guard, incrementing the depth
124 ///
125 /// The effective limit is the connection's runtime
126 /// `SQLITE_LIMIT_TRIGGER_DEPTH` (when lowered via `sqlite3_limit`), clamped
127 /// to the stack-safe compile-time cap. See [`effective_trigger_depth_limit`]
128 /// and #5536.
129 ///
130 /// # Returns
131 /// Ok(RecursionGuard) if depth is within limits, Err if limit exceeded
132 fn new(db: &Database) -> Result<Self, ExecutorError> {
133 let limit = effective_trigger_depth_limit(db);
134 TRIGGER_RECURSION_DEPTH.with(|depth| {
135 let current = depth.get();
136 if current >= limit {
137 // SQLite emits exactly "too many levels of trigger recursion"
138 // (sqlite3 3.51.0; see triggerC.test 2.x / 6.x and `sqlite3VdbeError`).
139 // Use `Other` so the message surfaces verbatim (no "Unsupported
140 // expression:" wrapper) and the TCL conformance shim passes it
141 // through unchanged. The depth cap now matches SQLite's full
142 // SQLITE_MAX_TRIGGER_DEPTH (1000) on native targets via on-demand
143 // stack growth; see the stack-safety note on
144 // MAX_TRIGGER_RECURSION_DEPTH.
145 Err(ExecutorError::Other(
146 "too many levels of trigger recursion".to_string(),
147 ))
148 } else {
149 depth.set(current + 1);
150 Ok(RecursionGuard)
151 }
152 })
153 }
154}
155
156impl Drop for RecursionGuard {
157 fn drop(&mut self) {
158 TRIGGER_RECURSION_DEPTH.with(|depth| {
159 depth.set(depth.get().saturating_sub(1));
160 });
161 }
162}
163
164thread_local! {
165 /// Names of triggers currently executing on this thread, keyed by the
166 /// lowercased trigger name with a reference count. Used to honor
167 /// `PRAGMA recursive_triggers = off`: when that pragma is off, a trigger is
168 /// not re-fired by DML performed while the same trigger is already running.
169 ///
170 /// A count (rather than a set) is required because with
171 /// `recursive_triggers = on` the *same* trigger can legitimately appear on
172 /// the stack multiple times (recursive firing), and each level must be
173 /// balanced by its own pop. Trigger names are SQLite-unique per schema, so
174 /// the lowercased name is a stable identity key.
175 static ACTIVE_TRIGGERS: RefCell<HashMap<String, usize>> =
176 RefCell::new(HashMap::new());
177}
178
179/// RAII guard that records a trigger as "currently executing" for the duration
180/// of its action, so the `recursive_triggers = off` suppression check can tell
181/// whether re-entering a trigger would be recursive.
182struct ActiveTriggerGuard {
183 key: String,
184}
185
186impl ActiveTriggerGuard {
187 fn new(trigger_name: &str) -> Self {
188 let key = trigger_name.to_lowercase();
189 ACTIVE_TRIGGERS.with(|active| {
190 *active.borrow_mut().entry(key.clone()).or_insert(0) += 1;
191 });
192 ActiveTriggerGuard { key }
193 }
194}
195
196impl Drop for ActiveTriggerGuard {
197 fn drop(&mut self) {
198 ACTIVE_TRIGGERS.with(|active| {
199 let mut map = active.borrow_mut();
200 if let Some(count) = map.get_mut(&self.key) {
201 *count -= 1;
202 if *count == 0 {
203 map.remove(&self.key);
204 }
205 }
206 });
207 }
208}
209
210/// Returns true if a trigger with this name is already executing on the current
211/// thread (i.e. firing it now would be a recursive re-entry).
212fn is_trigger_active(trigger_name: &str) -> bool {
213 let key = trigger_name.to_lowercase();
214 ACTIVE_TRIGGERS.with(|active| active.borrow().get(&key).is_some_and(|&c| c > 0))
215}
216
217/// Execution context for triggers with OLD/NEW row access
218/// Provides pseudo-variable resolution for trigger bodies
219pub struct TriggerContext<'a> {
220 /// OLD row - available for UPDATE and DELETE triggers
221 pub old_row: Option<&'a Row>,
222 /// NEW row - available for INSERT and UPDATE triggers
223 pub new_row: Option<&'a Row>,
224 /// Table schema for column lookups
225 pub table_schema: &'a TableSchema,
226 /// True when the trigger target is a VIEW (INSTEAD OF trigger). Views have
227 /// no rowid, so `NEW.rowid` / `OLD.rowid` must error there — matching both
228 /// sqlite3 and the view-rowid rejection from #5492. The pseudo-schema built
229 /// from a view definition does not carry `without_rowid`, so we track the
230 /// view-ness explicitly rather than inferring it from the schema.
231 pub is_view: bool,
232}
233
234impl<'a> TriggerContext<'a> {
235 /// Resolve a pseudo-variable reference to a SqlValue
236 ///
237 /// # Arguments
238 /// * `pseudo_table` - Which pseudo-table (OLD or NEW)
239 /// * `column` - Column name to retrieve
240 ///
241 /// # Returns
242 /// Ok(SqlValue) with the column value, or Err if invalid
243 ///
244 /// # Errors
245 /// - If OLD/NEW is not available for this trigger type
246 /// - If column doesn't exist in table schema
247 pub fn resolve_pseudo_var(
248 &self,
249 pseudo_table: PseudoTable,
250 column: &str,
251 ) -> Result<SqlValue, ExecutorError> {
252 // Get the appropriate row
253 let row = match pseudo_table {
254 PseudoTable::Old => self.old_row.ok_or_else(|| {
255 ExecutorError::UnsupportedExpression(
256 "OLD pseudo-variable not available in this trigger context".to_string(),
257 )
258 })?,
259 PseudoTable::New => self.new_row.ok_or_else(|| {
260 ExecutorError::UnsupportedExpression(
261 "NEW pseudo-variable not available in this trigger context".to_string(),
262 )
263 })?,
264 };
265
266 // Find column index in schema. A *real* column always wins over the
267 // rowid pseudo-column — including a real column literally named
268 // `rowid` / `oid` / `_rowid_` (triggerD-1.x). Only when no real column
269 // matches do we fall back to the rowid pseudo-column below.
270 // `get_column_index` matches the case-insensitive resolution the column
271 // evaluator uses (the parser uppercases unquoted identifiers).
272 if let Some(col_idx) = self.table_schema.get_column_index(column) {
273 return Ok(row.values[col_idx].clone());
274 }
275
276 // SQLite compatibility: resolve the ROWID pseudo-column on the firing
277 // OLD/NEW row. `rowid`, `_rowid_`, and `oid` are aliases that yield the
278 // row's unique identifier (#5485). This mirrors the rowid resolution in
279 // the column evaluator (`evaluator/expressions/eval.rs`): real columns
280 // already took precedence above.
281 let column_lower = column.to_lowercase();
282 if column_lower == "rowid" || column_lower == "_rowid_" || column_lower == "oid" {
283 // A VIEW has no rowid. An INSTEAD OF trigger fires on a view, so
284 // `NEW.rowid` / `OLD.rowid` there must error — consistent with the
285 // view-rowid rejection in #5492 (no such column: rowid).
286 //
287 // WITHOUT ROWID tables likewise do not expose the rowid
288 // pseudo-column (Issue #4953): error to match sqlite3.
289 if !self.is_view && !self.table_schema.without_rowid {
290 // INTEGER PRIMARY KEY acts as a rowid alias: its column value IS
291 // the rowid, so return that column's value (#4536).
292 if let Some(ipk_col_idx) = self.table_schema.rowid_alias_column {
293 return row
294 .values
295 .get(ipk_col_idx)
296 .cloned()
297 .ok_or(ExecutorError::ColumnIndexOutOfBounds { index: ipk_col_idx });
298 }
299
300 // Otherwise return the firing row's rowid. For a row that already
301 // has a rowid (AFTER INSERT, BEFORE/AFTER UPDATE/DELETE, or an
302 // explicit `INSERT INTO t(rowid,...)`), that's `row.row_id`. For a
303 // BEFORE INSERT on an auto-allocated rowid the row is not yet
304 // written and SQLite reports `new.rowid` as -1 (triggerC-4.1.2):
305 // mirror that sentinel here.
306 return Ok(SqlValue::Bigint(row.row_id.map(|id| id as i64).unwrap_or(-1)));
307 }
308 }
309
310 // Not a real column and not a resolvable rowid pseudo-column.
311 Err(ExecutorError::ColumnNotFound {
312 column_name: column.to_string(),
313 table_name: self.table_schema.name.clone(),
314 searched_tables: vec![self.table_schema.name.clone()],
315 available_columns: self
316 .table_schema
317 .columns
318 .iter()
319 .map(|c| c.name.clone())
320 .collect(),
321 })
322 }
323}
324
325/// Outcome of firing a trigger (or set of triggers) for a row.
326///
327/// `RAISE(IGNORE)` inside a trigger body asks SQLite to abandon the current
328/// row's DML operation without raising an error and continue with the rest of
329/// the statement. The trigger firing functions translate the internal
330/// [`ExecutorError::RaiseIgnore`] signal into [`TriggerOutcome::SkipRow`] so the
331/// DML caller can drop that row; every other (real) error still propagates via
332/// `Err`. `RAISE(ABORT|FAIL|ROLLBACK, ..)` are *not* represented here — they are
333/// genuine aborts and propagate as [`ExecutorError::Raise`].
334///
335/// Marked `#[must_use]` (#5418): the trigger-firing helpers return this so the
336/// DML caller can drop the current row on [`TriggerOutcome::SkipRow`]. A call
337/// site that writes `fire(..)?;` and discards the value would silently swallow
338/// a `RAISE(IGNORE)` and apply the row anyway. The attribute turns every such
339/// drop into a compile-time warning so new call sites must decide explicitly
340/// what to do with `SkipRow`.
341#[must_use = "a TriggerOutcome::SkipRow must be honored (skip the row); discarding it silently applies a RAISE(IGNORE)'d row"]
342#[derive(Debug, Clone, Copy, PartialEq, Eq)]
343pub enum TriggerOutcome {
344 /// Triggers completed normally; the DML operation should proceed.
345 Proceed,
346 /// A `RAISE(IGNORE)` fired; the current row should be skipped, and the
347 /// surrounding statement should continue with the next row.
348 SkipRow,
349}
350
351/// Helper struct for trigger firing (execution during DML operations)
352pub struct TriggerFirer;
353
354impl TriggerFirer {
355 /// Find triggers for a table and event
356 ///
357 /// # Arguments
358 /// * `db` - Database reference
359 /// * `table_name` - Name of the table to find triggers for
360 /// * `timing` - Trigger timing (BEFORE, AFTER, INSTEAD OF)
361 /// * `event` - Trigger event (INSERT, UPDATE, DELETE)
362 ///
363 /// # Returns
364 /// Vector of trigger definitions matching the criteria, sorted by creation order
365 pub fn find_triggers(
366 db: &Database,
367 table_name: &str,
368 timing: TriggerTiming,
369 event: TriggerEvent,
370 ) -> Vec<TriggerDefinition> {
371 Self::find_triggers_in_schema(db, table_name, timing, event, None)
372 }
373
374 /// Find triggers for a table and event, restricted to the schema the DML
375 /// target table resolved to.
376 ///
377 /// `dml_schema` is the internal schema name (`main` or `temp_<id>`) the target
378 /// table resolved to for the current statement (see
379 /// [`Catalog::resolve_table_schema_name`]). When `None`, no schema filtering
380 /// is applied (legacy schema-unaware behavior). Schema-aware firing keeps a
381 /// `main` trigger from firing on a same-named `temp` table and vice versa
382 /// (triggerD-3.1/3.2).
383 pub fn find_triggers_in_schema(
384 db: &Database,
385 table_name: &str,
386 timing: TriggerTiming,
387 event: TriggerEvent,
388 dml_schema: Option<&str>,
389 ) -> Vec<TriggerDefinition> {
390 db.catalog
391 .get_triggers_for_table_in_schema(table_name, Some(event.clone()), dml_schema)
392 .filter(|trigger| trigger.timing == timing && trigger.enabled) // Skip disabled triggers
393 .cloned()
394 .collect()
395 }
396
397 /// Check if an UPDATE OF trigger should fire based on which columns changed
398 ///
399 /// # Arguments
400 /// * `trigger` - Trigger definition
401 /// * `old_row` - OLD row values
402 /// * `new_row` - NEW row values
403 /// * `table_schema` - Table schema for column lookup
404 ///
405 /// # Returns
406 /// true if the trigger should fire, false otherwise
407 fn should_fire_update_of(
408 trigger: &TriggerDefinition,
409 old_row: &Row,
410 new_row: &Row,
411 table_schema: &TableSchema,
412 ) -> bool {
413 match &trigger.event {
414 TriggerEvent::Update(Some(columns)) => {
415 // Check if any of the specified columns changed
416 for col_name in columns {
417 if let Some(col_idx) =
418 table_schema.columns.iter().position(|c| &c.name == col_name)
419 {
420 if col_idx < old_row.values.len()
421 && col_idx < new_row.values.len()
422 && old_row.values[col_idx] != new_row.values[col_idx]
423 {
424 return true; // At least one monitored column changed
425 }
426 }
427 }
428 false // None of the monitored columns changed
429 }
430 _ => true, // Not an UPDATE OF trigger, always fire
431 }
432 }
433
434 /// Execute a single trigger
435 ///
436 /// # Arguments
437 /// * `db` - Mutable database reference
438 /// * `trigger` - Trigger definition to execute
439 /// * `old_row` - OLD row for UPDATE/DELETE (None for INSERT)
440 /// * `new_row` - NEW row for INSERT/UPDATE (None for DELETE)
441 ///
442 /// # Returns
443 /// Ok(()) if trigger executed successfully, Err if execution failed
444 ///
445 /// # Notes
446 /// - For ROW-level triggers, this is called once per affected row
447 /// - For STATEMENT-level triggers, this is called once per statement
448 /// - WHEN conditions are evaluated here
449 pub fn execute_trigger(
450 db: &mut Database,
451 trigger: &TriggerDefinition,
452 old_row: Option<&Row>,
453 new_row: Option<&Row>,
454 ) -> Result<TriggerOutcome, ExecutorError> {
455 // Grow the native stack on demand before descending one more trigger
456 // level (#5534). Firing a trigger re-enters the full DML path (parse +
457 // plan + execute + fire), which is deep native recursion; without this
458 // the 1000-deep recursion SQLite allows would overflow a fixed
459 // worker-thread stack. `maybe_grow` is ~free when stack is plentiful, so
460 // the non-recursive common case is unaffected. The depth *limit* is
461 // still enforced by `RecursionGuard` in the per-timing entry points; this
462 // only guarantees we have stack to reach that limit cleanly.
463 grow_stack_for_trigger(|| Self::execute_trigger_inner(db, trigger, old_row, new_row))
464 }
465
466 /// Inner body of [`Self::execute_trigger`], run on a guaranteed-sufficient
467 /// native stack (see `grow_stack_for_trigger`).
468 fn execute_trigger_inner(
469 db: &mut Database,
470 trigger: &TriggerDefinition,
471 old_row: Option<&Row>,
472 new_row: Option<&Row>,
473 ) -> Result<TriggerOutcome, ExecutorError> {
474 // 1. Evaluate WHEN condition (if present)
475 if let Some(when_expr) = &trigger.when_condition {
476 let condition_result = Self::evaluate_when_condition(
477 db,
478 &trigger.table_name,
479 when_expr,
480 old_row,
481 new_row,
482 )?;
483
484 // Skip trigger execution if WHEN condition is false
485 if !condition_result {
486 return Ok(TriggerOutcome::Proceed);
487 }
488 }
489
490 // Mark this trigger as executing for the duration of its action so that
491 // `PRAGMA recursive_triggers = off` can suppress re-entry into the same
492 // trigger from nested DML (#5535). The guard is dropped — and the
493 // trigger un-marked — when the action completes or errors out.
494 let _active = ActiveTriggerGuard::new(&trigger.name);
495
496 // 2. Execute trigger action
497 Self::execute_trigger_action(db, trigger, old_row, new_row)
498 }
499
500 /// Evaluate WHEN condition for a trigger
501 ///
502 /// # Arguments
503 /// * `db` - Database reference
504 /// * `table_name` - Name of the table
505 /// * `when_expr` - WHEN condition expression
506 /// * `old_row` - OLD row (for UPDATE/DELETE)
507 /// * `new_row` - NEW row (for INSERT/UPDATE)
508 ///
509 /// # Returns
510 /// Ok(true) if condition evaluates to true, Ok(false) otherwise
511 fn evaluate_when_condition(
512 db: &Database,
513 table_name: &str,
514 when_expr: &vibesql_ast::Expression,
515 old_row: Option<&Row>,
516 new_row: Option<&Row>,
517 ) -> Result<bool, ExecutorError> {
518 // Resolve the schema for OLD/NEW column resolution. For a normal table
519 // trigger this is the base table's schema. For an INSTEAD OF trigger the
520 // target is a VIEW (views are not tables, so `get_table` returns None),
521 // in which case we build a pseudo-schema from the view definition —
522 // mirroring `execute_trigger_action`. Without this fallback, an INSTEAD
523 // OF trigger carrying a WHEN clause always failed with TableNotFound.
524 let schema = Self::resolve_trigger_schema(db, table_name)?;
525 // INSTEAD OF triggers fire on a view (not a table); the rowid
526 // pseudo-column is unavailable there (#5485 / #5492).
527 let is_view = db.catalog.get_table(table_name).is_none();
528
529 // Use NEW row as the base row for evaluation (prefer NEW over OLD)
530 // The trigger context will handle OLD/NEW pseudo-variable references
531 let row = new_row.or(old_row).ok_or_else(|| {
532 ExecutorError::UnsupportedExpression(
533 "WHEN condition requires a row context".to_string(),
534 )
535 })?;
536
537 // Create trigger context for OLD/NEW pseudo-variable resolution
538 let trigger_context = TriggerContext { old_row, new_row, table_schema: &schema, is_view };
539
540 // Create evaluator with trigger context
541 let evaluator =
542 crate::ExpressionEvaluator::with_trigger_context(&schema, db, &trigger_context);
543 let result = evaluator.eval(when_expr, row)?;
544
545 // Convert to boolean
546 match result {
547 vibesql_types::SqlValue::Boolean(b) => Ok(b),
548 vibesql_types::SqlValue::Null => Ok(false),
549 _ => Err(ExecutorError::UnsupportedExpression(
550 "WHEN condition must evaluate to boolean".to_string(),
551 )),
552 }
553 }
554
555 /// Execute trigger action statements
556 ///
557 /// # Arguments
558 /// * `db` - Mutable database reference
559 /// * `trigger` - Trigger definition
560 /// * `old_row` - OLD row (for UPDATE/DELETE)
561 /// * `new_row` - NEW row (for INSERT/UPDATE)
562 ///
563 /// # Returns
564 /// Ok(()) if action executed successfully, Err if execution failed
565 fn execute_trigger_action(
566 db: &mut Database,
567 trigger: &TriggerDefinition,
568 old_row: Option<&Row>,
569 new_row: Option<&Row>,
570 ) -> Result<TriggerOutcome, ExecutorError> {
571 // Extract SQL from trigger action
572 let sql = match &trigger.triggered_action {
573 vibesql_ast::TriggerAction::RawSql(sql) => sql.clone(),
574 };
575
576 // Parse the trigger action SQL
577 let statements = Self::parse_trigger_sql(&sql)?;
578
579 // Get table schema for trigger context.
580 // For INSTEAD OF triggers on views, build schema from the view definition.
581 let schema = Self::resolve_trigger_schema(db, &trigger.table_name)?;
582 // INSTEAD OF triggers fire on a view (not a table); the rowid
583 // pseudo-column is unavailable there (#5485 / #5492).
584 let is_view = db.catalog.get_table(&trigger.table_name).is_none();
585
586 // Create trigger context for OLD/NEW pseudo-variable resolution
587 let trigger_context = TriggerContext { old_row, new_row, table_schema: &schema, is_view };
588
589 // Execute each statement in the trigger body with trigger context.
590 // A RAISE(IGNORE) inside any statement abandons the rest of this
591 // trigger's action for the current row and asks the caller to skip the
592 // row (SQLite semantics), without raising a user-visible error.
593 for statement in statements {
594 match Self::execute_statement(db, &statement, &trigger_context) {
595 Ok(()) => {}
596 Err(ExecutorError::RaiseIgnore) => return Ok(TriggerOutcome::SkipRow),
597 Err(e) => return Err(e),
598 }
599 }
600
601 Ok(TriggerOutcome::Proceed)
602 }
603
604 /// Resolve the schema used for OLD/NEW column resolution when a trigger fires.
605 ///
606 /// For a regular table trigger this is the base table's schema. For an
607 /// INSTEAD OF trigger the target is a VIEW — views are not tables, so
608 /// `catalog.get_table` returns None and we fall back to a pseudo-schema
609 /// built from the view definition. The schema is returned by value (owned)
610 /// because the view path constructs it on the fly; the base-table path
611 /// clones the catalog entry.
612 fn resolve_trigger_schema(
613 db: &Database,
614 table_name: &str,
615 ) -> Result<TableSchema, ExecutorError> {
616 if let Some(table_schema) = db.catalog.get_table(table_name) {
617 Ok(table_schema.clone())
618 } else if let Some(view_def) = db.catalog.get_view(table_name) {
619 Self::build_view_schema(db, view_def)
620 } else {
621 Err(ExecutorError::TableNotFound(table_name.to_string()))
622 }
623 }
624
625 /// Build a pseudo TableSchema from a view definition for trigger OLD/NEW column resolution
626 fn build_view_schema(
627 db: &Database,
628 view_def: &vibesql_catalog::ViewDefinition,
629 ) -> Result<TableSchema, ExecutorError> {
630 // Execute the view's SELECT query to get column names
631 let select_executor = crate::SelectExecutor::new(db);
632 let result = select_executor.execute_with_columns(&view_def.query)?;
633
634 // Use explicit column names if provided, otherwise derive from SELECT
635 let column_names: Vec<String> = if let Some(ref cols) = view_def.columns {
636 cols.clone()
637 } else {
638 result.columns.clone()
639 };
640
641 // Build columns with a generic data type
642 let columns: Vec<vibesql_catalog::ColumnSchema> = column_names
643 .into_iter()
644 .map(|name| {
645 vibesql_catalog::ColumnSchema::new(
646 name,
647 vibesql_types::DataType::Varchar { max_length: None },
648 true,
649 )
650 })
651 .collect();
652
653 Ok(TableSchema::new(view_def.name.clone(), columns))
654 }
655
656 /// Parse trigger SQL into statements
657 ///
658 /// Public so that validation layers (e.g. the Raft replication
659 /// freeze pass in `vibesql-consensus::freeze`, #5381) can inspect a
660 /// trigger body with **exactly** the parsing this executor will use
661 /// when the trigger fires — validating against a different parse
662 /// would silently let nondeterminism through.
663 ///
664 /// # Arguments
665 /// * `sql` - Raw SQL string from trigger action
666 ///
667 /// # Returns
668 /// Vector of parsed statements
669 pub fn parse_trigger_sql(sql: &str) -> Result<Vec<vibesql_ast::Statement>, ExecutorError> {
670 // Split the BEGIN ... END body into statements with the shared,
671 // string-literal- and comment-aware splitter. This is the same
672 // splitter `Parser::validate_trigger_body` uses at create time, so the
673 // create-time and fire-time paths cannot drift, and a `;` inside a
674 // string literal (e.g. `INSERT INTO log VALUES('a;b')`) is no longer
675 // mistaken for a statement separator. The splitter strips the
676 // BEGIN/END wrapper and drops empty / comment-only fragments.
677 let mut statements = Vec::new();
678 for stmt_sql in vibesql_parser::split_trigger_body_statements(sql) {
679 // Parse each body statement as a trigger-program statement so
680 // `RAISE()` is admitted at fire time. SQLite only permits RAISE()
681 // within a trigger-program, so the general `parse_sql` entry point
682 // rejects it at parse time; a trigger body legitimately contains
683 // it (see `Parser::parse_sql_in_trigger_body`).
684 match vibesql_parser::Parser::parse_sql_in_trigger_body(&stmt_sql) {
685 Ok(stmt) => statements.push(stmt),
686 Err(e) => {
687 return Err(ExecutorError::UnsupportedExpression(format!(
688 "Failed to parse trigger SQL: {}",
689 e.message
690 )))
691 }
692 }
693 }
694
695 // If no statements parsed (e.g., trigger body was only comments), that's OK
696 // Just return empty vector
697 Ok(statements)
698 }
699
700 /// Execute a single statement from trigger body
701 ///
702 /// # Arguments
703 /// * `db` - Mutable database reference
704 /// * `statement` - Statement to execute
705 /// * `trigger_context` - Trigger context with OLD/NEW row data
706 ///
707 /// # Returns
708 /// Ok(()) if statement executed successfully
709 fn execute_statement(
710 db: &mut Database,
711 statement: &vibesql_ast::Statement,
712 trigger_context: &TriggerContext,
713 ) -> Result<(), ExecutorError> {
714 use vibesql_ast::Statement;
715
716 match statement {
717 Statement::Insert(insert_stmt) => {
718 // Execute INSERT with trigger context support
719 crate::insert::execute_insert_with_trigger_context(
720 db,
721 insert_stmt,
722 trigger_context,
723 )?;
724 Ok(())
725 }
726 Statement::Update(update_stmt) => {
727 // Execute UPDATE with trigger context support
728 crate::update::execute_update_with_trigger_context(
729 db,
730 update_stmt,
731 trigger_context,
732 )?;
733 Ok(())
734 }
735 Statement::Delete(delete_stmt) => {
736 // Execute DELETE with trigger context support
737 crate::delete::execute_delete_with_trigger_context(
738 db,
739 delete_stmt,
740 trigger_context,
741 )?;
742 Ok(())
743 }
744 Statement::Select(select_stmt) => {
745 // Execute SELECT but ignore results (useful for side effects, e.g.
746 // a body statement of the form `SELECT CASE WHEN NEW.x ... THEN
747 // raise(IGNORE) END`).
748 //
749 // The SELECT must be evaluated with the trigger's NEW/OLD
750 // pseudo-row context (#5445): a from-less SELECT that references
751 // NEW/OLD has no FROM clause to resolve them from, so the
752 // evaluator needs the firing row's context — exactly as the WHEN
753 // clause and the body's DML statements already receive it. Before
754 // this, the SELECT was executed with a plain `SelectExecutor::new`
755 // and a body like `SELECT CASE WHEN NEW.id = 1 THEN raise(IGNORE)
756 // END` failed at fire time with "Column reference requires FROM
757 // clause".
758 let executor =
759 crate::SelectExecutor::new_with_trigger_context(db, trigger_context);
760 executor.execute_with_columns(select_stmt)?;
761 Ok(())
762 }
763 _ => Err(ExecutorError::UnsupportedExpression(format!(
764 "Statement type not supported in triggers: {:?}",
765 statement
766 ))),
767 }
768 }
769
770 /// Whether firing `trigger` right now must be suppressed because
771 /// `PRAGMA recursive_triggers` is off and the same trigger is already
772 /// executing on this thread.
773 ///
774 /// SQLite's `recursive_triggers = off` does not blanket-disable nested
775 /// trigger firing; it only prevents a trigger from being re-entered while it
776 /// is already running. A *different* trigger reached via nested DML still
777 /// fires, and a trigger fired at the top level (e.g. the DELETE trigger run
778 /// for a REPLACE-induced delete) still fires because it is not yet on the
779 /// stack. This per-trigger rule is what `trigger3-6` and `triggerC-5.3.*`
780 /// depend on (#5535). When `recursive_triggers = on` (the default) this
781 /// always returns false and the depth cap (#5479) governs recursion instead.
782 fn suppressed_by_recursive_triggers_off(db: &Database, trigger: &TriggerDefinition) -> bool {
783 !db.recursive_triggers() && is_trigger_active(&trigger.name)
784 }
785
786 /// Execute all BEFORE ROW-level triggers for an operation
787 ///
788 /// # Arguments
789 /// * `db` - Mutable database reference
790 /// * `table_name` - Name of the table
791 /// * `event` - Trigger event (INSERT, UPDATE, DELETE)
792 /// * `old_row` - OLD row (for UPDATE/DELETE)
793 /// * `new_row` - NEW row (for INSERT/UPDATE)
794 ///
795 /// # Returns
796 /// Ok(()) if all triggers executed successfully
797 pub fn execute_before_triggers(
798 db: &mut Database,
799 table_name: &str,
800 event: TriggerEvent,
801 old_row: Option<&Row>,
802 new_row: Option<&Row>,
803 ) -> Result<TriggerOutcome, ExecutorError> {
804 Self::execute_before_triggers_in_schema(db, table_name, event, old_row, new_row, None)
805 }
806
807 /// Schema-aware variant of [`Self::execute_before_triggers`].
808 ///
809 /// `dml_schema` is the internal schema name the DML target resolved to; only
810 /// triggers bound to that schema fire (triggerD-3.1/3.2). `None` preserves the
811 /// legacy schema-unaware behavior.
812 pub fn execute_before_triggers_in_schema(
813 db: &mut Database,
814 table_name: &str,
815 event: TriggerEvent,
816 old_row: Option<&Row>,
817 new_row: Option<&Row>,
818 dml_schema: Option<&str>,
819 ) -> Result<TriggerOutcome, ExecutorError> {
820 // Check recursion depth before executing any triggers
821 let _guard = RecursionGuard::new(db)?;
822
823 let triggers =
824 Self::find_triggers_in_schema(db, table_name, TriggerTiming::Before, event, dml_schema);
825
826 // Get table schema for UPDATE OF checking
827 let table_schema = db
828 .catalog
829 .get_table(table_name)
830 .ok_or_else(|| ExecutorError::TableNotFound(table_name.to_string()))?
831 .clone();
832
833 for trigger in triggers {
834 // Only execute ROW-level triggers in this method
835 if trigger.granularity == TriggerGranularity::Row {
836 // PRAGMA recursive_triggers = off: don't re-enter a running trigger.
837 if Self::suppressed_by_recursive_triggers_off(db, &trigger) {
838 continue;
839 }
840
841 // For UPDATE OF triggers, check if monitored columns changed
842 if let (Some(old), Some(new)) = (old_row, new_row) {
843 if !Self::should_fire_update_of(&trigger, old, new, &table_schema) {
844 continue; // Skip this trigger
845 }
846 }
847
848 // A RAISE(IGNORE) in any trigger abandons the current row.
849 if Self::execute_trigger(db, &trigger, old_row, new_row)?
850 == TriggerOutcome::SkipRow
851 {
852 return Ok(TriggerOutcome::SkipRow);
853 }
854 }
855 }
856
857 Ok(TriggerOutcome::Proceed)
858 }
859
860 /// Execute all BEFORE STATEMENT-level triggers for an operation
861 ///
862 /// # Arguments
863 /// * `db` - Mutable database reference
864 /// * `table_name` - Name of the table
865 /// * `event` - Trigger event (INSERT, UPDATE, DELETE)
866 ///
867 /// # Returns
868 /// Ok(()) if all triggers executed successfully
869 pub fn execute_before_statement_triggers(
870 db: &mut Database,
871 table_name: &str,
872 event: TriggerEvent,
873 ) -> Result<TriggerOutcome, ExecutorError> {
874 Self::execute_before_statement_triggers_in_schema(db, table_name, event, None)
875 }
876
877 /// Schema-aware variant of [`Self::execute_before_statement_triggers`].
878 pub fn execute_before_statement_triggers_in_schema(
879 db: &mut Database,
880 table_name: &str,
881 event: TriggerEvent,
882 dml_schema: Option<&str>,
883 ) -> Result<TriggerOutcome, ExecutorError> {
884 // Check recursion depth before executing any triggers
885 let _guard = RecursionGuard::new(db)?;
886
887 let triggers =
888 Self::find_triggers_in_schema(db, table_name, TriggerTiming::Before, event, dml_schema);
889
890 for trigger in triggers {
891 // Only execute STATEMENT-level triggers in this method
892 if trigger.granularity == TriggerGranularity::Statement {
893 // PRAGMA recursive_triggers = off: don't re-enter a running trigger.
894 if Self::suppressed_by_recursive_triggers_off(db, &trigger) {
895 continue;
896 }
897 // Statement-level triggers don't have OLD/NEW row access
898 if Self::execute_trigger(db, &trigger, None, None)? == TriggerOutcome::SkipRow {
899 return Ok(TriggerOutcome::SkipRow);
900 }
901 }
902 }
903
904 Ok(TriggerOutcome::Proceed)
905 }
906
907 /// Execute all AFTER ROW-level triggers for an operation
908 ///
909 /// # Arguments
910 /// * `db` - Mutable database reference
911 /// * `table_name` - Name of the table
912 /// * `event` - Trigger event (INSERT, UPDATE, DELETE)
913 /// * `old_row` - OLD row (for UPDATE/DELETE)
914 /// * `new_row` - NEW row (for INSERT/UPDATE)
915 ///
916 /// # Returns
917 /// Ok(()) if all triggers executed successfully
918 pub fn execute_after_triggers(
919 db: &mut Database,
920 table_name: &str,
921 event: TriggerEvent,
922 old_row: Option<&Row>,
923 new_row: Option<&Row>,
924 ) -> Result<TriggerOutcome, ExecutorError> {
925 Self::execute_after_triggers_in_schema(db, table_name, event, old_row, new_row, None)
926 }
927
928 /// Schema-aware variant of [`Self::execute_after_triggers`].
929 pub fn execute_after_triggers_in_schema(
930 db: &mut Database,
931 table_name: &str,
932 event: TriggerEvent,
933 old_row: Option<&Row>,
934 new_row: Option<&Row>,
935 dml_schema: Option<&str>,
936 ) -> Result<TriggerOutcome, ExecutorError> {
937 // Check recursion depth before executing any triggers
938 let _guard = RecursionGuard::new(db)?;
939
940 let triggers =
941 Self::find_triggers_in_schema(db, table_name, TriggerTiming::After, event, dml_schema);
942
943 // Get table schema for UPDATE OF checking
944 let table_schema = db
945 .catalog
946 .get_table(table_name)
947 .ok_or_else(|| ExecutorError::TableNotFound(table_name.to_string()))?
948 .clone();
949
950 for trigger in triggers {
951 // Only execute ROW-level triggers in this method
952 if trigger.granularity == TriggerGranularity::Row {
953 // PRAGMA recursive_triggers = off: don't re-enter a running trigger.
954 if Self::suppressed_by_recursive_triggers_off(db, &trigger) {
955 continue;
956 }
957
958 // For UPDATE OF triggers, check if monitored columns changed
959 if let (Some(old), Some(new)) = (old_row, new_row) {
960 if !Self::should_fire_update_of(&trigger, old, new, &table_schema) {
961 continue; // Skip this trigger
962 }
963 }
964
965 if Self::execute_trigger(db, &trigger, old_row, new_row)?
966 == TriggerOutcome::SkipRow
967 {
968 return Ok(TriggerOutcome::SkipRow);
969 }
970 }
971 }
972
973 Ok(TriggerOutcome::Proceed)
974 }
975
976 /// Execute all AFTER STATEMENT-level triggers for an operation
977 ///
978 /// # Arguments
979 /// * `db` - Mutable database reference
980 /// * `table_name` - Name of the table
981 /// * `event` - Trigger event (INSERT, UPDATE, DELETE)
982 ///
983 /// # Returns
984 /// Ok(()) if all triggers executed successfully
985 pub fn execute_after_statement_triggers(
986 db: &mut Database,
987 table_name: &str,
988 event: TriggerEvent,
989 ) -> Result<TriggerOutcome, ExecutorError> {
990 Self::execute_after_statement_triggers_in_schema(db, table_name, event, None)
991 }
992
993 /// Schema-aware variant of [`Self::execute_after_statement_triggers`].
994 pub fn execute_after_statement_triggers_in_schema(
995 db: &mut Database,
996 table_name: &str,
997 event: TriggerEvent,
998 dml_schema: Option<&str>,
999 ) -> Result<TriggerOutcome, ExecutorError> {
1000 // Check recursion depth before executing any triggers
1001 let _guard = RecursionGuard::new(db)?;
1002
1003 let triggers =
1004 Self::find_triggers_in_schema(db, table_name, TriggerTiming::After, event, dml_schema);
1005
1006 for trigger in triggers {
1007 // Only execute STATEMENT-level triggers in this method
1008 if trigger.granularity == TriggerGranularity::Statement {
1009 // PRAGMA recursive_triggers = off: don't re-enter a running trigger.
1010 if Self::suppressed_by_recursive_triggers_off(db, &trigger) {
1011 continue;
1012 }
1013 // Statement-level triggers don't have OLD/NEW row access
1014 if Self::execute_trigger(db, &trigger, None, None)? == TriggerOutcome::SkipRow {
1015 return Ok(TriggerOutcome::SkipRow);
1016 }
1017 }
1018 }
1019
1020 Ok(TriggerOutcome::Proceed)
1021 }
1022}