Skip to main content

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}