Skip to main content

spg_engine/
triggers.rs

1//! v7.12.4 — PL/pgSQL row-level trigger executor.
2//!
3//! The catalogued [`spg_storage::FunctionDef`] carries the trigger
4//! function's source body as raw text (between the original
5//! `$$ ... $$`). Each time a trigger fires we re-parse the body
6//! via `spg_sql::parse_function_body` and walk the resulting
7//! [`spg_sql::ast::PlPgSqlBlock`] against a NEW / OLD row context.
8//!
9//! v7.12.4 surface (the minimum that lets a mailrs-shape
10//! `update_search_vector` trigger run end-to-end):
11//!
12//!   * `NEW.col := <expr>;`     — mutate a NEW cell. BEFORE only.
13//!   * `RETURN NEW;`            — pass the (possibly-mutated) row
14//!                                back to the row writer.
15//!   * `RETURN OLD;`            — return the pre-change row.
16//!   * `RETURN NULL;` / `RETURN;` — skip the write (BEFORE) or
17//!                                no-op the notification (AFTER).
18//!   * sub-expression eval recurses through the regular
19//!     [`crate::eval::eval_expr`] so anything the SELECT executor
20//!     can compute is fair game inside a trigger body.
21//!
22//! Out of scope for v7.12.4 (land in v7.12.5+):
23//!
24//!   * `DECLARE`'d local variables
25//!   * `IF / ELSIF / ELSE / END IF;` control flow
26//!   * Embedded SQL statements (`UPDATE … WHERE …`, `SELECT … INTO var`)
27//!   * `RAISE NOTICE / RAISE EXCEPTION`
28//!   * Loop constructs
29
30use alloc::collections::BTreeMap;
31use alloc::format;
32use alloc::string::String;
33use alloc::vec::Vec;
34use core::fmt;
35
36use spg_sql::ast::{AssignTarget, Expr, PlPgSqlDeclare, PlPgSqlStmt, RaiseLevel, ReturnTarget};
37use spg_storage::{ColumnSchema, FunctionDef, Row, StorageError, TriggerDef, Value};
38
39use crate::eval::{self, EvalContext, EvalError};
40use crate::{CancelToken, Engine, EngineError, MAX_TRIGGER_RECURSION};
41
42/// v7.12.7 — embedded SQL statement collected during a trigger
43/// fire, queued for execution after the firing DML completes.
44/// NEW / OLD / DECLARE-local references inside the statement's
45/// Expr tree have already been substituted with literals; the
46/// engine just feeds it to `execute_stmt_with_cancel`.
47#[derive(Debug, Clone, PartialEq)]
48pub struct DeferredEmbeddedStmt {
49    /// Trigger function the embedded SQL came from. Used to
50    /// label recursion errors precisely.
51    pub function: String,
52    /// Substituted statement, ready to execute.
53    pub stmt: spg_sql::ast::Statement,
54}
55
56/// What the trigger function returned. Drives the row-write path
57/// the trigger fired from.
58#[derive(Debug, Clone, PartialEq)]
59pub enum TriggerOutcome {
60    /// `RETURN NEW;` (or `RETURN OLD;`) — write this row.
61    /// For BEFORE triggers, the row may differ from the input
62    /// (e.g. `NEW.search_vector := …` rewrote a cell). For AFTER
63    /// triggers, the value is currently ignored — but we still
64    /// surface it for symmetric callers / future v7.12.5 use.
65    Row(Row<'static>),
66    /// `RETURN NULL;` or trigger fell off the end. For a BEFORE
67    /// trigger, the row writer must skip the affected row. For
68    /// an AFTER trigger, no-op.
69    Skip,
70}
71
72/// Result type the trigger executor exposes. Wraps `EvalError`
73/// at the eval-of-expressions layer and adds trigger-specific
74/// failure modes (`OLD.col := …`, unsupported PL/pgSQL feature,
75/// body that fails to re-parse, …).
76#[derive(Debug, Clone, PartialEq)]
77pub enum TriggerError {
78    /// Body source stored in the catalog can't be re-parsed.
79    /// Usually means the function was created against a newer
80    /// PL/pgSQL surface than the running engine knows about.
81    UnparseableBody { function: String, detail: String },
82    /// Trigger function uses a v7.12.5+ language feature
83    /// (DECLARE, IF, embedded SQL, RAISE, …). The error names
84    /// the construct so the operator can plan around it until
85    /// the feature lands.
86    UnsupportedConstruct { function: String, detail: String },
87    /// `OLD.col := <expr>` inside the body. PG itself rejects
88    /// this; we surface a clear message rather than silently
89    /// dropping the assignment.
90    OldIsReadOnly { function: String, column: String },
91    /// `NEW.col := <expr>` in an AFTER trigger — same rationale
92    /// as OLD: PG enforces "NEW is read-only after the row has
93    /// been written" and we mirror.
94    NewReadOnlyInAfterTrigger { function: String, column: String },
95    /// `NEW.col := <expr>` against a non-existent column.
96    /// Usually a schema-drift bug.
97    UnknownColumn {
98        function: String,
99        column: String,
100        table: String,
101    },
102    /// Sub-expression eval inside the trigger body failed. The
103    /// wrapped [`EvalError`] explains the underlying cause
104    /// (`ColumnNotFound`, `TypeMismatch`, …).
105    EvalFailed { function: String, cause: EvalError },
106    /// v7.12.6 — `RAISE EXCEPTION '<message>' [, args]*` in the
107    /// trigger body. The interpreter formats the args into the
108    /// message via PG-style `%` substitution and surfaces the
109    /// resolved text up to the caller.
110    RaiseException { function: String, message: String },
111}
112
113impl fmt::Display for TriggerError {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        match self {
116            Self::UnparseableBody { function, detail } => {
117                write!(
118                    f,
119                    "trigger function {function:?} body did not parse: {detail}"
120                )
121            }
122            Self::UnsupportedConstruct { function, detail } => {
123                write!(
124                    f,
125                    "trigger function {function:?} uses an unsupported PL/pgSQL construct: {detail}"
126                )
127            }
128            Self::OldIsReadOnly { function, column } => {
129                write!(
130                    f,
131                    "trigger function {function:?}: cannot assign to OLD.{column} (OLD is read-only — PG rule)"
132                )
133            }
134            Self::NewReadOnlyInAfterTrigger { function, column } => {
135                write!(
136                    f,
137                    "trigger function {function:?}: cannot assign to NEW.{column} inside an AFTER trigger \
138                     (NEW is read-only post-write — use BEFORE triggers for mutation, or an embedded UPDATE statement \
139                      in v7.12.5+)"
140                )
141            }
142            Self::UnknownColumn {
143                function,
144                column,
145                table,
146            } => {
147                write!(
148                    f,
149                    "trigger function {function:?}: target column {column:?} not in table {table:?} schema"
150                )
151            }
152            Self::EvalFailed { function, cause } => {
153                write!(
154                    f,
155                    "trigger function {function:?}: expression eval failed: {cause}"
156                )
157            }
158            Self::RaiseException { function, message } => {
159                write!(
160                    f,
161                    "trigger function {function:?}: RAISE EXCEPTION {message:?}"
162                )
163            }
164        }
165    }
166}
167
168/// v7.39 (read01 round 82) — the firing trigger's identity, for the TG_* magic
169/// variables. `op` is `INSERT` / `UPDATE` / `DELETE`; `level` is `ROW` (SPG
170/// fires row-level triggers only). `TG_WHEN` derives from `is_after`.
171#[derive(Debug)]
172pub struct TgMeta<'a> {
173    pub op: &'a str,
174    pub name: &'a str,
175    pub level: &'a str,
176}
177
178/// Fire a single row-level trigger.
179///
180/// `is_after` is true for AFTER triggers; the executor enforces
181/// "NEW is read-only" by rejecting NEW.col assignments in that
182/// case. AFTER trigger return values are ignored by callers; the
183/// returned [`TriggerOutcome`] just carries the (possibly
184/// untouched) NEW row for symmetry.
185#[allow(clippy::too_many_arguments)] // the table_name / columns / params /
186// ts-config trio are independent; folding
187// them into a struct just shuffles the
188// boilerplate to the call sites without
189// material gain.
190pub fn fire_row_trigger(
191    function: &FunctionDef,
192    new_row: Option<Row<'static>>,
193    old_row: Option<&Row<'static>>,
194    table_name: &str,
195    columns: &[ColumnSchema],
196    params: &[Value<'static>],
197    default_text_search_config: Option<&str>,
198    is_after: bool,
199    // v7.39 (read01 round 82) — the firing trigger's identity, for the TG_*
200    // magic variables (`TG_OP`, `TG_NAME`, `TG_WHEN`, `TG_LEVEL`,
201    // `TG_TABLE_NAME`, `TG_NARGS`). PG exposes these to every trigger function;
202    // SPG bound none, so any function that read `TG_OP` died on
203    // "column tg_op does not exist" — most audit / dispatch triggers do.
204    tg: &TgMeta<'_>,
205    // v7.39 (round 757, F31-B3) — see [`NoticeSink`].
206    notice_sink: Option<&NoticeSink>,
207) -> Result<(TriggerOutcome, Vec<DeferredEmbeddedStmt>), TriggerError> {
208    if !function.language.eq_ignore_ascii_case("plpgsql") {
209        return Err(TriggerError::UnsupportedConstruct {
210            function: function.name.clone(),
211            detail: format!(
212                "v7.12.4 only invokes LANGUAGE plpgsql trigger functions; \
213                 {:?} declares LANGUAGE {}",
214                function.name, function.language
215            ),
216        });
217    }
218    let block = spg_sql::parse_function_body(&function.body).map_err(|e| {
219        TriggerError::UnparseableBody {
220            function: function.name.clone(),
221            detail: format!("{e}"),
222        }
223    })?;
224    // v7.12.6 — initialise local variable scope from the DECLARE
225    // block. Each init expr (if any) evaluates against the
226    // so-far-bound scope + the NEW/OLD context, so later DECLAREs
227    // can reference earlier ones.
228    let mut locals: BTreeMap<String, Value<'static>> = BTreeMap::new();
229    // v7.39 (read01 round 82) — the TG_* magic variables, bound before the
230    // DECLARE block so an initialiser may reference them. PG names them
231    // case-insensitively; the interpreter lowercases identifiers, so lowercase
232    // keys are what a `TG_OP` reference resolves to.
233    locals.insert(
234        "tg_op".into(),
235        Value::text::<alloc::string::String>(tg.op.into()),
236    );
237    locals.insert(
238        "tg_when".into(),
239        Value::text::<alloc::string::String>(if is_after { "AFTER" } else { "BEFORE" }.into()),
240    );
241    locals.insert(
242        "tg_level".into(),
243        Value::text::<alloc::string::String>(tg.level.into()),
244    );
245    locals.insert(
246        "tg_name".into(),
247        Value::text::<alloc::string::String>(tg.name.into()),
248    );
249    locals.insert(
250        "tg_table_name".into(),
251        Value::text::<alloc::string::String>(table_name.into()),
252    );
253    locals.insert(
254        "tg_table_schema".into(),
255        Value::text::<alloc::string::String>("public".into()),
256    );
257    locals.insert(
258        "tg_relname".into(),
259        Value::text::<alloc::string::String>(table_name.into()),
260    );
261    locals.insert("tg_nargs".into(), Value::Int(0));
262    init_locals_from_declarations(
263        &block.declarations,
264        &mut locals,
265        new_row.as_ref(),
266        old_row,
267        columns,
268        table_name,
269        params,
270        default_text_search_config,
271        &function.name,
272        None,
273    )?;
274    let mut current_new = new_row;
275    let ctx = BodyCtx {
276        function: &function.name,
277        table_name,
278        columns,
279        params,
280        default_text_search_config,
281        is_after,
282        select_into_resolver: None,
283        notice_sink,
284        for_query_resolver: None,
285        // A trigger function is not set-returning.
286        set_sink: None,
287    };
288    let mut deferred: Vec<DeferredEmbeddedStmt> = Vec::new();
289    let outcome = match execute_stmts(
290        &block.statements,
291        &mut current_new,
292        old_row,
293        &mut locals,
294        &ctx,
295        &mut deferred,
296    )? {
297        BodyOutcome::Return(target) => resolve_return(target, current_new, old_row),
298        // Body fell off without an explicit RETURN. PL/pgSQL
299        // default is `RETURN NULL`; we mirror — the BEFORE
300        // trigger then skips the row.
301        BodyOutcome::FellThrough | BodyOutcome::Break | BodyOutcome::Continue => {
302            TriggerOutcome::Skip
303        }
304    };
305    Ok((outcome, deferred))
306}
307
308/// v7.12.6 — body-walk return signal. `Return(target)` short-
309/// circuits the caller; `FellThrough` means the statement list
310/// completed without a RETURN, equivalent to PL/pgSQL's implicit
311/// `RETURN NULL`.
312enum BodyOutcome {
313    Return(ReturnTarget),
314    FellThrough,
315    /// v7.37.20 (20.2) — `EXIT [WHEN <cond>];` bubbled up through
316    /// the current loop body's execute_stmts. WHILE / FOR / bare
317    /// LOOP catch this at their iteration point and break; any
318    /// non-loop caller treats it as a benign no-op.
319    Break,
320    /// v7.37.20 (20.2) — `CONTINUE [WHEN <cond>];` bubbled up
321    /// through the current loop body. WHILE / FOR / bare LOOP
322    /// catch this and jump to the next iteration.
323    Continue,
324}
325
326/// v7.39 (round 757, F31-B3) — where `RAISE NOTICE / WARNING / INFO`
327/// deliver their rendered messages. The caller drains it into the
328/// session's pending notices, and pgwire ships one NoticeResponse per
329/// entry; `None` (the SELECT-path scalar-function caller, which holds
330/// the engine immutably) drops them — ledgered as the B3 residual.
331pub type NoticeSink = core::cell::RefCell<Vec<(crate::NoticeSeverity, String)>>;
332
333/// Shared parameters every body-stmt evaluation needs. Bundled so
334/// the recursive `execute_stmts` doesn't have to thread eight
335/// individual `&str` / `&[…]` args around.
336struct BodyCtx<'a> {
337    function: &'a str,
338    table_name: &'a str,
339    columns: &'a [ColumnSchema],
340    params: &'a [Value<'static>],
341    default_text_search_config: Option<&'a str>,
342    is_after: bool,
343    /// v7.16.2 — synchronous SELECT … INTO resolver. Provided
344    /// by `Engine::exec_do_block` so the walker can run a
345    /// SELECT against the engine right when SelectInto is
346    /// reached (so subsequent IF reads of the local see the
347    /// fresh value). `None` for trigger paths where SelectInto
348    /// isn't yet supported.
349    select_into_resolver: Option<&'a SelectIntoResolver<'a>>,
350    /// v7.39 (round 757, F31-B3) — see [`NoticeSink`].
351    notice_sink: Option<&'a NoticeSink>,
352    /// v7.37.20 (20.5) — synchronous SELECT-to-rows resolver used
353    /// by `FOR <var> IN <SELECT> LOOP`. Provided by the DO block
354    /// executor; runs the SELECT once and returns every row.
355    for_query_resolver: Option<&'a ForQueryResolver<'a>>,
356    /// v7.39 (read01 round 66) — where `RETURN NEXT` / `RETURN QUERY` append
357    /// their rows. `None` outside a SETOF function, which makes either statement
358    /// an error there — as in PG.
359    set_sink: Option<&'a core::cell::RefCell<Vec<Vec<Value<'static>>>>>,
360}
361
362/// v7.16.2 — callback shape the DO-block executor registers
363/// on `BodyCtx`. Runs the supplied SELECT statement against
364/// the engine, returns the first row's first column.
365pub type SelectIntoResolver<'a> =
366    dyn Fn(&spg_sql::ast::Statement) -> Result<Value<'static>, TriggerError> + 'a;
367
368/// v7.37.20 (20.5) — callback shape the DO-block executor registers
369/// on `BodyCtx` for FOR-IN-SELECT loops. Runs the supplied SELECT
370/// statement against the engine and returns every row's values.
371/// v7.39 (read01 round 64) — the COLUMN NAMES ride along now, so the loop can
372/// bind a record variable's fields (`rec.v`), not just its first cell.
373pub type ForQueryResolver<'a> = dyn Fn(
374        &spg_sql::ast::Statement,
375    ) -> Result<
376        (
377            alloc::vec::Vec<String>,
378            alloc::vec::Vec<alloc::vec::Vec<Value<'static>>>,
379        ),
380        TriggerError,
381    > + 'a;
382
383fn execute_stmts(
384    stmts: &[PlPgSqlStmt],
385    current_new: &mut Option<Row<'static>>,
386    old_row: Option<&Row<'static>>,
387    locals: &mut BTreeMap<String, Value<'static>>,
388    ctx: &BodyCtx<'_>,
389    deferred: &mut Vec<DeferredEmbeddedStmt>,
390) -> Result<BodyOutcome, TriggerError> {
391    for stmt in stmts {
392        match stmt {
393            PlPgSqlStmt::Assign { target, value } => {
394                let evaluated = eval_with_new_old_and_locals(
395                    value,
396                    current_new.as_ref(),
397                    old_row,
398                    locals,
399                    ctx.columns,
400                    ctx.table_name,
401                    ctx.params,
402                    ctx.default_text_search_config,
403                    ctx.select_into_resolver,
404                )
405                .map_err(|cause| TriggerError::EvalFailed {
406                    function: ctx.function.into(),
407                    cause,
408                })?;
409                match target {
410                    AssignTarget::NewColumn(col) => {
411                        // v7.39 (round 767, F31-D4) — PG treats NEW as a
412                        // plain plpgsql record variable: assigning to it
413                        // inside an AFTER trigger is ACCEPTED and simply
414                        // has no effect on the row (measured — the old
415                        // hard refusal broke PG-valid triggers reused
416                        // across BEFORE/AFTER). The local copy mutates
417                        // (later reads in the body see it); the AFTER
418                        // caller discards the outcome as before.
419                        let pos = ctx
420                            .columns
421                            .iter()
422                            .position(|c| c.name.eq_ignore_ascii_case(col))
423                            .ok_or_else(|| TriggerError::UnknownColumn {
424                                function: ctx.function.into(),
425                                column: col.clone(),
426                                table: alloc::string::ToString::to_string(&ctx.table_name),
427                            })?;
428                        let row = current_new.as_mut().ok_or_else(|| {
429                            TriggerError::UnsupportedConstruct {
430                                function: ctx.function.into(),
431                                detail: format!(
432                                    "NEW.{col} := … requires a NEW row context \
433                                     (BEFORE INSERT / UPDATE only — not available on DELETE)"
434                                ),
435                            }
436                        })?;
437                        row.values[pos] = evaluated;
438                    }
439                    AssignTarget::OldColumn(col) => {
440                        // v7.39 (round 767, F31-D4) — PG accepts an
441                        // assignment to OLD too (same record-variable
442                        // rule; measured: AFTER UPDATE body running
443                        // `OLD.id := 5` succeeds and the table keeps
444                        // the real update). SPG has no owned OLD copy
445                        // on this path, so the write is accepted and
446                        // discarded — a later read of OLD.<col> in the
447                        // SAME body sees the original value, a niche
448                        // divergence ledgered in the F31 audit.
449                        let _ = col;
450                        let _ = evaluated;
451                    }
452                    AssignTarget::Local(name) => {
453                        // v7.12.6 — write into the DECLARE scope.
454                        // Loose-typing: we don't enforce the
455                        // declared type at runtime (PG's INTO
456                        // coerces; v7.12.6 just stores the
457                        // evaluated Value as-is). Type coercion
458                        // tightens in a later release.
459                        locals.insert(name.clone(), evaluated);
460                    }
461                }
462            }
463            PlPgSqlStmt::Return(target) => {
464                return Ok(BodyOutcome::Return(target.clone()));
465            }
466            // v7.39 (read01 round 66) — `RETURN NEXT <expr>`: append one row and
467            // KEEP GOING. It is not a return.
468            PlPgSqlStmt::ReturnNext(e) => {
469                let sink = ctx
470                    .set_sink
471                    .ok_or_else(|| TriggerError::UnsupportedConstruct {
472                        function: ctx.function.into(),
473                        // PG's wording.
474                        detail: alloc::string::String::from(
475                            "cannot use RETURN NEXT in a non-SETOF function",
476                        ),
477                    })?;
478                let v = eval_with_new_old_and_locals(
479                    e,
480                    current_new.as_ref(),
481                    old_row,
482                    locals,
483                    ctx.columns,
484                    ctx.table_name,
485                    ctx.params,
486                    ctx.default_text_search_config,
487                    ctx.select_into_resolver,
488                )
489                .map_err(|cause| TriggerError::EvalFailed {
490                    function: ctx.function.into(),
491                    cause,
492                })?;
493                sink.borrow_mut().push(alloc::vec![v]);
494            }
495            // `RETURN QUERY <select>`: append every row it yields, and keep
496            // going. This used to desugar to a side-effect SELECT whose rows
497            // were DISCARDED — the whole answer, thrown away.
498            PlPgSqlStmt::ReturnQuery(query) => {
499                let sink = ctx
500                    .set_sink
501                    .ok_or_else(|| TriggerError::UnsupportedConstruct {
502                        function: ctx.function.into(),
503                        detail: alloc::string::String::from(
504                            "cannot use RETURN QUERY in a non-SETOF function",
505                        ),
506                    })?;
507                let resolver =
508                    ctx.for_query_resolver
509                        .ok_or_else(|| TriggerError::UnsupportedConstruct {
510                            function: ctx.function.into(),
511                            detail: alloc::string::String::from(
512                                "RETURN QUERY needs a query runner (this context has none)",
513                            ),
514                        })?;
515                let mut stmt = spg_sql::ast::Statement::Select((**query).clone());
516                substitute_trigger_context_in_statement(
517                    &mut stmt,
518                    current_new.as_ref(),
519                    old_row,
520                    locals,
521                    ctx.columns,
522                )
523                .map_err(|cause| TriggerError::EvalFailed {
524                    function: ctx.function.into(),
525                    cause,
526                })?;
527                let (_cols, rows) = resolver(&stmt)?;
528                sink.borrow_mut().extend(rows);
529            }
530            PlPgSqlStmt::If {
531                branches,
532                else_branch,
533            } => {
534                let mut matched = false;
535                for (cond_expr, body) in branches {
536                    let cond_val = eval_with_new_old_and_locals(
537                        cond_expr,
538                        current_new.as_ref(),
539                        old_row,
540                        locals,
541                        ctx.columns,
542                        ctx.table_name,
543                        ctx.params,
544                        ctx.default_text_search_config,
545                        ctx.select_into_resolver,
546                    )
547                    .map_err(|cause| TriggerError::EvalFailed {
548                        function: ctx.function.into(),
549                        cause,
550                    })?;
551                    if matches!(cond_val, Value::Bool(true)) {
552                        matched = true;
553                        match execute_stmts(body, current_new, old_row, locals, ctx, deferred)? {
554                            BodyOutcome::Return(t) => return Ok(BodyOutcome::Return(t)),
555                            BodyOutcome::Break => return Ok(BodyOutcome::Break),
556                            BodyOutcome::Continue => return Ok(BodyOutcome::Continue),
557                            BodyOutcome::FellThrough => {}
558                        }
559                        break;
560                    }
561                }
562                if !matched && !else_branch.is_empty() {
563                    match execute_stmts(else_branch, current_new, old_row, locals, ctx, deferred)? {
564                        BodyOutcome::Return(t) => return Ok(BodyOutcome::Return(t)),
565                        BodyOutcome::Break => return Ok(BodyOutcome::Break),
566                        BodyOutcome::Continue => return Ok(BodyOutcome::Continue),
567                        BodyOutcome::FellThrough => {}
568                    }
569                }
570            }
571            PlPgSqlStmt::Raise {
572                level,
573                message,
574                args,
575            } => {
576                // Resolve every %-format placeholder by evaluating
577                // each arg expression and rendering its Value.
578                let mut rendered_args: Vec<String> = Vec::with_capacity(args.len());
579                for a in args {
580                    let v = eval_with_new_old_and_locals(
581                        a,
582                        current_new.as_ref(),
583                        old_row,
584                        locals,
585                        ctx.columns,
586                        ctx.table_name,
587                        ctx.params,
588                        ctx.default_text_search_config,
589                        ctx.select_into_resolver,
590                    )
591                    .map_err(|cause| TriggerError::EvalFailed {
592                        function: ctx.function.into(),
593                        cause,
594                    })?;
595                    rendered_args.push(value_to_display_string(&v));
596                }
597                let resolved = format_raise_message(message, &rendered_args);
598                if matches!(level, RaiseLevel::Exception) {
599                    return Err(TriggerError::RaiseException {
600                        function: ctx.function.into(),
601                        message: resolved,
602                    });
603                }
604                // v7.39 (round 757, F31-B3) — NOTICE / WARNING /
605                // INFO reach the client (the round-753 audit found
606                // them silently discarded here since v7.12.6); LOG
607                // and DEBUG are server-log levels PG does not send
608                // at the default client_min_messages.
609                let severity = match level {
610                    RaiseLevel::Notice => Some(crate::NoticeSeverity::Notice),
611                    RaiseLevel::Warning => Some(crate::NoticeSeverity::Warning),
612                    RaiseLevel::Info => Some(crate::NoticeSeverity::Info),
613                    _ => None,
614                };
615                if let (Some(sev), Some(sink)) = (severity, ctx.notice_sink) {
616                    sink.borrow_mut().push((sev, resolved));
617                }
618            }
619            PlPgSqlStmt::SelectInto { var, body } => {
620                // v7.16.2 — execute via the engine callback the
621                // caller (Engine::exec_do_block) registered on
622                // ctx, assign the result to the local. Trigger
623                // path (no callback) errors loudly: SELECT INTO
624                // doesn't fit in a row-write loop.
625                let mut substituted = spg_sql::ast::Statement::Select((**body).clone());
626                substitute_trigger_context_in_statement(
627                    &mut substituted,
628                    current_new.as_ref(),
629                    old_row,
630                    locals,
631                    ctx.columns,
632                )
633                .map_err(|cause| TriggerError::EvalFailed {
634                    function: ctx.function.into(),
635                    cause,
636                })?;
637                let resolver =
638                    ctx.select_into_resolver.ok_or_else(|| TriggerError::UnsupportedConstruct {
639                        function: ctx.function.into(),
640                        detail: alloc::format!(
641                            "SELECT … INTO {var}: only supported inside DO blocks (not trigger bodies) in v7.16.2"
642                        ),
643                    })?;
644                let value = resolver(&substituted)?;
645                // v7.37.20 (20.15) — the PL/pgSQL FOUND special
646                // variable is auto-set after each SQL-executing
647                // statement. For SELECT INTO: `true` when the query
648                // returned a row (value != Null), `false` otherwise.
649                // The variable name is spelled lower-case per PG
650                // convention; SPG's local map is case-preserving,
651                // so callers reading `found` see this update.
652                let found_after_select_into = !matches!(value, spg_storage::Value::Null);
653                locals.insert(
654                    "found".into(),
655                    spg_storage::Value::Bool(found_after_select_into),
656                );
657                locals.insert(var.clone(), value);
658            }
659            PlPgSqlStmt::ForRange {
660                var,
661                start,
662                end,
663                reverse,
664                body,
665            } => {
666                // v7.37.20 (20.4) — FOR <var> IN [REVERSE] <s>..<e> LOOP.
667                const FOR_RANGE_BUDGET: i64 = 1_000_000;
668                let s_v = eval_with_new_old_and_locals(
669                    start,
670                    current_new.as_ref(),
671                    old_row,
672                    locals,
673                    ctx.columns,
674                    ctx.table_name,
675                    ctx.params,
676                    ctx.default_text_search_config,
677                    ctx.select_into_resolver,
678                )
679                .map_err(|cause| TriggerError::EvalFailed {
680                    function: ctx.function.into(),
681                    cause,
682                })?;
683                let e_v = eval_with_new_old_and_locals(
684                    end,
685                    current_new.as_ref(),
686                    old_row,
687                    locals,
688                    ctx.columns,
689                    ctx.table_name,
690                    ctx.params,
691                    ctx.default_text_search_config,
692                    ctx.select_into_resolver,
693                )
694                .map_err(|cause| TriggerError::EvalFailed {
695                    function: ctx.function.into(),
696                    cause,
697                })?;
698                let to_i64 = |v: &spg_storage::Value<'static>| -> Result<i64, TriggerError> {
699                    match v {
700                        spg_storage::Value::Int(n) => Ok(i64::from(*n)),
701                        spg_storage::Value::BigInt(n) => Ok(*n),
702                        spg_storage::Value::SmallInt(n) => Ok(i64::from(*n)),
703                        other => Err(TriggerError::UnsupportedConstruct {
704                            function: ctx.function.into(),
705                            detail: alloc::format!(
706                                "FOR <var> IN start..end: bounds must be integer, got {}",
707                                crate::conversions::pg_type_name_for_error_opt(other.data_type())
708                            ),
709                        }),
710                    }
711                };
712                let s = to_i64(&s_v)?;
713                let e = to_i64(&e_v)?;
714                // PG's `FOR i IN REVERSE 5..1` iterates 5, 4, 3, 2, 1 —
715                // the first bound is the start, the second is the end,
716                // step is -1.
717                let (lo, hi, step): (i64, i64, i64) = if *reverse { (s, e, -1) } else { (s, e, 1) };
718                let mut i = lo;
719                let mut iter: i64 = 0;
720                loop {
721                    if iter >= FOR_RANGE_BUDGET {
722                        return Err(TriggerError::RaiseException {
723                            function: ctx.function.into(),
724                            message: alloc::format!(
725                                "FOR loop iteration budget {FOR_RANGE_BUDGET} reached"
726                            ),
727                        });
728                    }
729                    let cont = if *reverse { i >= hi } else { i <= hi };
730                    if !cont {
731                        break;
732                    }
733                    locals.insert(var.clone(), spg_storage::Value::BigInt(i));
734                    match execute_stmts(body, current_new, old_row, locals, ctx, deferred)? {
735                        BodyOutcome::FellThrough | BodyOutcome::Continue => {}
736                        BodyOutcome::Break => break,
737                        early @ BodyOutcome::Return(_) => return Ok(early),
738                    }
739                    i = i.saturating_add(step);
740                    iter += 1;
741                }
742            }
743            PlPgSqlStmt::Loop { body } => {
744                // v7.37.20 (20.2) — bare LOOP: iterate body until an
745                // EXIT bubbles up, or the budget is exhausted.
746                const LOOP_BUDGET: u64 = 1_000_000;
747                let mut iter: u64 = 0;
748                loop {
749                    if iter >= LOOP_BUDGET {
750                        return Err(TriggerError::RaiseException {
751                            function: ctx.function.into(),
752                            message: alloc::format!("LOOP iteration budget {LOOP_BUDGET} reached"),
753                        });
754                    }
755                    match execute_stmts(body, current_new, old_row, locals, ctx, deferred)? {
756                        BodyOutcome::FellThrough | BodyOutcome::Continue => {}
757                        BodyOutcome::Break => break,
758                        early @ BodyOutcome::Return(_) => return Ok(early),
759                    }
760                    iter += 1;
761                }
762            }
763            PlPgSqlStmt::Exit { when } => {
764                // v7.37.20 (20.2) — EXIT [WHEN <cond>]. Unconditional
765                // exit or conditional (only breaks when truthy).
766                let should_break = match when {
767                    None => true,
768                    Some(cond) => {
769                        let v = eval_with_new_old_and_locals(
770                            cond,
771                            current_new.as_ref(),
772                            old_row,
773                            locals,
774                            ctx.columns,
775                            ctx.table_name,
776                            ctx.params,
777                            ctx.default_text_search_config,
778                            ctx.select_into_resolver,
779                        )
780                        .map_err(|cause| TriggerError::EvalFailed {
781                            function: ctx.function.into(),
782                            cause,
783                        })?;
784                        matches!(v, spg_storage::Value::Bool(true))
785                    }
786                };
787                if should_break {
788                    return Ok(BodyOutcome::Break);
789                }
790            }
791            PlPgSqlStmt::ForExecute {
792                var,
793                sql_expr,
794                body,
795            } => {
796                // v7.37.20 (20.6) — FOR <var> IN EXECUTE <expr> LOOP.
797                // Evaluate the expression at runtime to obtain a SQL
798                // string, parse it, run through the for_query_resolver,
799                // iterate rows same way ForQuery does.
800                let resolver =
801                    ctx.for_query_resolver
802                        .ok_or_else(|| TriggerError::UnsupportedConstruct {
803                            function: ctx.function.into(),
804                            detail: alloc::format!(
805                                "FOR <var> IN EXECUTE <expr> LOOP: only supported inside DO blocks"
806                            ),
807                        })?;
808                let v = eval_with_new_old_and_locals(
809                    sql_expr,
810                    current_new.as_ref(),
811                    old_row,
812                    locals,
813                    ctx.columns,
814                    ctx.table_name,
815                    ctx.params,
816                    ctx.default_text_search_config,
817                    ctx.select_into_resolver,
818                )
819                .map_err(|cause| TriggerError::EvalFailed {
820                    function: ctx.function.into(),
821                    cause,
822                })?;
823                let sql_text = match v {
824                    spg_storage::Value::Text(s) => s.into_owned(),
825                    other => {
826                        return Err(TriggerError::UnsupportedConstruct {
827                            function: ctx.function.into(),
828                            detail: alloc::format!(
829                                "FOR IN EXECUTE: expression must evaluate to TEXT, got {}",
830                                crate::conversions::pg_type_name_for_error_opt(other.data_type())
831                            ),
832                        });
833                    }
834                };
835                let stmt = spg_sql::parser::parse_statement(&sql_text).map_err(|e| {
836                    TriggerError::UnparseableBody {
837                        function: ctx.function.into(),
838                        detail: alloc::format!(
839                            "FOR IN EXECUTE {sql_text:?}: parse failed: {}",
840                            e.message
841                        ),
842                    }
843                })?;
844                let (col_names, rows) = resolver(&stmt)?;
845                for row_values in rows {
846                    // v7.39 (read01 round 64) — bind the whole ROW: `rec` still
847                    // carries the first cell (what a scalar loop variable
848                    // means), and each column also lands as `rec.<col>` so a
849                    // record variable's fields resolve.
850                    for (i, cname) in col_names.iter().enumerate() {
851                        locals.insert(
852                            alloc::format!(
853                                "{}.{}",
854                                var.to_ascii_lowercase(),
855                                cname.to_ascii_lowercase()
856                            ),
857                            row_values
858                                .get(i)
859                                .cloned()
860                                .unwrap_or(spg_storage::Value::Null),
861                        );
862                    }
863                    let first_cell = row_values
864                        .into_iter()
865                        .next()
866                        .unwrap_or(spg_storage::Value::Null);
867                    locals.insert(var.clone(), first_cell);
868                    match execute_stmts(body, current_new, old_row, locals, ctx, deferred)? {
869                        BodyOutcome::FellThrough | BodyOutcome::Continue => {}
870                        BodyOutcome::Break => break,
871                        early @ BodyOutcome::Return(_) => return Ok(early),
872                    }
873                }
874            }
875            PlPgSqlStmt::ForQuery { var, query, body } => {
876                // v7.37.20 (20.5) — FOR <var> IN <SELECT> LOOP.
877                // Runs the SELECT once via the DO block's registered
878                // resolver, iterates rows, binds the first cell of
879                // each row to `var` as a scalar Value. Full record
880                // binding (var carrying all columns) queues with
881                // v7.40 record type infrastructure.
882                let resolver = ctx.for_query_resolver.ok_or_else(|| {
883                    TriggerError::UnsupportedConstruct {
884                        function: ctx.function.into(),
885                        detail: alloc::format!(
886                            "FOR <var> IN <SELECT> LOOP: only supported inside DO blocks in v7.37.20 (trigger paths queue with v7.40)"
887                        ),
888                    }
889                })?;
890                let mut stmt = spg_sql::ast::Statement::Select((**query).clone());
891                substitute_trigger_context_in_statement(
892                    &mut stmt,
893                    current_new.as_ref(),
894                    old_row,
895                    locals,
896                    ctx.columns,
897                )
898                .map_err(|cause| TriggerError::EvalFailed {
899                    function: ctx.function.into(),
900                    cause,
901                })?;
902                let (col_names, rows) = resolver(&stmt)?;
903                for row_values in rows {
904                    // Same record binding as FOR … IN <SELECT>.
905                    for (i, cname) in col_names.iter().enumerate() {
906                        locals.insert(
907                            alloc::format!(
908                                "{}.{}",
909                                var.to_ascii_lowercase(),
910                                cname.to_ascii_lowercase()
911                            ),
912                            row_values
913                                .get(i)
914                                .cloned()
915                                .unwrap_or(spg_storage::Value::Null),
916                        );
917                    }
918                    let first_cell = row_values
919                        .into_iter()
920                        .next()
921                        .unwrap_or(spg_storage::Value::Null);
922                    locals.insert(var.clone(), first_cell);
923                    match execute_stmts(body, current_new, old_row, locals, ctx, deferred)? {
924                        BodyOutcome::FellThrough | BodyOutcome::Continue => {}
925                        BodyOutcome::Break => break,
926                        early @ BodyOutcome::Return(_) => return Ok(early),
927                    }
928                }
929            }
930            // v7.39 (read01 round 68) — `RETURN QUERY EXECUTE <sql>`: evaluate
931            // the expression to a SQL string, run it through the same query
932            // runner the static form uses, and append the rows to the set. It
933            // used to run and DISCARD them.
934            PlPgSqlStmt::ReturnQueryExecute { sql } => {
935                let sink = ctx
936                    .set_sink
937                    .ok_or_else(|| TriggerError::UnsupportedConstruct {
938                        function: ctx.function.into(),
939                        detail: alloc::string::String::from(
940                            "cannot use RETURN QUERY in a non-SETOF function",
941                        ),
942                    })?;
943                let resolver =
944                    ctx.for_query_resolver
945                        .ok_or_else(|| TriggerError::UnsupportedConstruct {
946                            function: ctx.function.into(),
947                            detail: alloc::string::String::from(
948                                "RETURN QUERY EXECUTE needs a query runner (this context has none)",
949                            ),
950                        })?;
951                let sql_val = eval_with_new_old_and_locals(
952                    sql,
953                    current_new.as_ref(),
954                    old_row,
955                    locals,
956                    ctx.columns,
957                    ctx.table_name,
958                    ctx.params,
959                    ctx.default_text_search_config,
960                    ctx.select_into_resolver,
961                )
962                .map_err(|cause| TriggerError::EvalFailed {
963                    function: ctx.function.into(),
964                    cause,
965                })?;
966                let Value::Text(text) = &sql_val else {
967                    return Err(TriggerError::UnsupportedConstruct {
968                        function: ctx.function.into(),
969                        detail: alloc::format!(
970                            "RETURN QUERY EXECUTE needs a text SQL string, got {}",
971                            crate::conversions::pg_type_name_for_error_opt(sql_val.data_type())
972                        ),
973                    });
974                };
975                let stmt = spg_sql::parser::parse_statement(text.as_ref()).map_err(|e| {
976                    TriggerError::UnparseableBody {
977                        function: ctx.function.into(),
978                        detail: alloc::format!("RETURN QUERY EXECUTE: {e}"),
979                    }
980                })?;
981                let (_cols, rows) = resolver(&stmt)?;
982                sink.borrow_mut().extend(rows);
983            }
984            PlPgSqlStmt::ExecuteDynamic { sql } => {
985                // v7.37.20 (20.13) — EXECUTE <string_expr>. Evaluate
986                // the expression at runtime to obtain a SQL string,
987                // parse it, and queue for post-body execution the
988                // same way EmbeddedSql does. USING <params> for
989                // placeholder binding queues with v7.40 PL/pgSQL
990                // epic.
991                let v = eval_with_new_old_and_locals(
992                    sql,
993                    current_new.as_ref(),
994                    old_row,
995                    locals,
996                    ctx.columns,
997                    ctx.table_name,
998                    ctx.params,
999                    ctx.default_text_search_config,
1000                    ctx.select_into_resolver,
1001                )
1002                .map_err(|cause| TriggerError::EvalFailed {
1003                    function: ctx.function.into(),
1004                    cause,
1005                })?;
1006                let sql_text = match v {
1007                    spg_storage::Value::Text(s) => s.into_owned(),
1008                    other => {
1009                        return Err(TriggerError::UnsupportedConstruct {
1010                            function: ctx.function.into(),
1011                            detail: alloc::format!(
1012                                "EXECUTE <expr>: expression must evaluate to TEXT, got {}",
1013                                crate::conversions::pg_type_name_for_error_opt(other.data_type())
1014                            ),
1015                        });
1016                    }
1017                };
1018                let parsed = spg_sql::parser::parse_statement(&sql_text).map_err(|e| {
1019                    TriggerError::UnparseableBody {
1020                        function: ctx.function.into(),
1021                        detail: alloc::format!("EXECUTE {sql_text:?}: parse failed: {}", e.message),
1022                    }
1023                })?;
1024                deferred.push(DeferredEmbeddedStmt {
1025                    function: ctx.function.into(),
1026                    stmt: parsed,
1027                });
1028            }
1029            PlPgSqlStmt::Continue { when } => {
1030                // v7.37.20 (20.2) — CONTINUE [WHEN <cond>]. Same shape
1031                // as EXIT but signals BodyOutcome::Continue.
1032                let should_continue = match when {
1033                    None => true,
1034                    Some(cond) => {
1035                        let v = eval_with_new_old_and_locals(
1036                            cond,
1037                            current_new.as_ref(),
1038                            old_row,
1039                            locals,
1040                            ctx.columns,
1041                            ctx.table_name,
1042                            ctx.params,
1043                            ctx.default_text_search_config,
1044                            ctx.select_into_resolver,
1045                        )
1046                        .map_err(|cause| TriggerError::EvalFailed {
1047                            function: ctx.function.into(),
1048                            cause,
1049                        })?;
1050                        matches!(v, spg_storage::Value::Bool(true))
1051                    }
1052                };
1053                if should_continue {
1054                    return Ok(BodyOutcome::Continue);
1055                }
1056            }
1057            PlPgSqlStmt::While { condition, body } => {
1058                // v7.37.20 (20.3) — WHILE <cond> LOOP iteration.
1059                // Iteration count bounded by a generous budget so a
1060                // mis-spelled condition can't lock the engine. The
1061                // budget matches the v7.12.6 trigger-recursion cap
1062                // shape (~1M iterations).
1063                const WHILE_LOOP_BUDGET: u64 = 1_000_000;
1064                let mut iter: u64 = 0;
1065                loop {
1066                    if iter >= WHILE_LOOP_BUDGET {
1067                        return Err(TriggerError::RaiseException {
1068                            function: ctx.function.into(),
1069                            message: alloc::format!(
1070                                "WHILE loop iteration budget {WHILE_LOOP_BUDGET} reached — likely runaway condition"
1071                            ),
1072                        });
1073                    }
1074                    let v = eval_with_new_old_and_locals(
1075                        condition,
1076                        current_new.as_ref(),
1077                        old_row,
1078                        locals,
1079                        ctx.columns,
1080                        ctx.table_name,
1081                        ctx.params,
1082                        ctx.default_text_search_config,
1083                        ctx.select_into_resolver,
1084                    )
1085                    .map_err(|cause| TriggerError::EvalFailed {
1086                        function: ctx.function.into(),
1087                        cause,
1088                    })?;
1089                    if !matches!(v, spg_storage::Value::Bool(true)) {
1090                        break;
1091                    }
1092                    // Re-enter the trigger body interpreter on `body`.
1093                    // Recursive call shares the same `ctx`,
1094                    // `current_new`, `old_row`, `locals`, `deferred`
1095                    // so any Assign / RAISE / EmbeddedSql side effect
1096                    // inside the loop propagates back the same way
1097                    // the IF / ELSE arms do.
1098                    match execute_stmts(body, current_new, old_row, locals, ctx, deferred)? {
1099                        BodyOutcome::FellThrough | BodyOutcome::Continue => {}
1100                        BodyOutcome::Break => break,
1101                        early @ BodyOutcome::Return(_) => return Ok(early),
1102                    }
1103                    iter += 1;
1104                }
1105            }
1106            PlPgSqlStmt::Assert { condition, message } => {
1107                // v7.37.20 (20.14) — ASSERT <cond> [, <msg>]. If
1108                // the condition evaluates to a falsy Value (NULL or
1109                // BOOL(false)), raise the same EngineError shape as
1110                // RAISE EXCEPTION. Otherwise no-op.
1111                let v = eval_with_new_old_and_locals(
1112                    condition,
1113                    current_new.as_ref(),
1114                    old_row,
1115                    locals,
1116                    ctx.columns,
1117                    ctx.table_name,
1118                    ctx.params,
1119                    ctx.default_text_search_config,
1120                    ctx.select_into_resolver,
1121                )
1122                .map_err(|cause| TriggerError::EvalFailed {
1123                    function: ctx.function.into(),
1124                    cause,
1125                })?;
1126                let cond_holds = matches!(v, spg_storage::Value::Bool(true));
1127                if !cond_holds {
1128                    let msg_text = if let Some(m) = message {
1129                        let mv = eval_with_new_old_and_locals(
1130                            m,
1131                            current_new.as_ref(),
1132                            old_row,
1133                            locals,
1134                            ctx.columns,
1135                            ctx.table_name,
1136                            ctx.params,
1137                            ctx.default_text_search_config,
1138                            ctx.select_into_resolver,
1139                        )
1140                        .map_err(|cause| TriggerError::EvalFailed {
1141                            function: ctx.function.into(),
1142                            cause,
1143                        })?;
1144                        value_to_display_string(&mv)
1145                    } else {
1146                        alloc::string::String::from("assertion failed")
1147                    };
1148                    return Err(TriggerError::RaiseException {
1149                        function: ctx.function.into(),
1150                        message: msg_text,
1151                    });
1152                }
1153            }
1154            PlPgSqlStmt::EmbeddedSql(boxed_stmt) => {
1155                // v7.12.7 — substitute NEW/OLD/locals into every
1156                // Expr field of the statement, then queue for
1157                // post-DML execution. The trigger interpreter
1158                // doesn't call back into Engine::execute directly
1159                // (that would deadlock the row-write mut borrow);
1160                // the engine drains `deferred` after the firing
1161                // INSERT/UPDATE/DELETE completes its main work.
1162                let mut substituted = (**boxed_stmt).clone();
1163                substitute_trigger_context_in_statement(
1164                    &mut substituted,
1165                    current_new.as_ref(),
1166                    old_row,
1167                    locals,
1168                    ctx.columns,
1169                )
1170                .map_err(|cause| TriggerError::EvalFailed {
1171                    function: ctx.function.into(),
1172                    cause,
1173                })?;
1174                deferred.push(DeferredEmbeddedStmt {
1175                    function: ctx.function.into(),
1176                    stmt: substituted,
1177                });
1178            }
1179        }
1180    }
1181    Ok(BodyOutcome::FellThrough)
1182}
1183
1184/// v7.16.2 — execute a DO block's PlPgSqlBlock at top level.
1185/// Different from `fire_row_trigger` in three ways:
1186///   1. No NEW/OLD row context — DO blocks aren't row-scoped.
1187///   2. EmbeddedSql statements collected into the returned vec
1188///      so the caller (`Engine::exec_do_block`) can dispatch
1189///      them via `Engine::execute_in_with_cancel` IMMEDIATELY,
1190///      not defer. Triggers defer because they fire inside a
1191///      row-write `&mut Catalog` borrow; DO has no such borrow.
1192///   3. Embedded condition Expr (e.g. `IF EXISTS (SELECT ...)`)
1193///      evaluation happens inline against the engine's
1194///      current state — the caller resolves the subquery
1195///      result before walking the body. We do that by
1196///      collecting the IF / Assign / RAISE statements and
1197///      letting the caller-side evaluator decide; v7.16.2's
1198///      simple path lets `eval_with_new_old_and_locals` do
1199///      it inline, falling back to the embedded sub-engine
1200///      for SELECT subqueries via the regular eval path.
1201///
1202/// Returns the deferred SQL list in execution order. Errors
1203/// from the walk propagate verbatim (parse / eval / engine).
1204pub fn execute_do_block_top_level<'a>(
1205    block: &spg_sql::ast::PlPgSqlBlock,
1206    default_text_search_config: Option<&'a str>,
1207    select_into_resolver: Option<&'a SelectIntoResolver<'a>>,
1208    for_query_resolver: Option<&'a ForQueryResolver<'a>>,
1209    notice_sink: Option<&'a NoticeSink>,
1210) -> Result<Vec<spg_sql::ast::Statement>, TriggerError> {
1211    // A DO block returns nothing, so RETURN NEXT / RETURN QUERY have nowhere to
1212    // go — PG rejects them there too.
1213    let set_sink: Option<&core::cell::RefCell<Vec<Vec<Value<'static>>>>> = None;
1214    let mut locals: BTreeMap<String, Value<'static>> = BTreeMap::new();
1215    let empty_cols: &[ColumnSchema] = &[];
1216    init_locals_from_declarations(
1217        &block.declarations,
1218        &mut locals,
1219        None,
1220        None,
1221        empty_cols,
1222        "",
1223        &[],
1224        default_text_search_config,
1225        "DO",
1226        select_into_resolver,
1227    )?;
1228    let ctx = BodyCtx {
1229        function: "DO",
1230        table_name: "",
1231        columns: empty_cols,
1232        params: &[],
1233        default_text_search_config,
1234        is_after: false,
1235        select_into_resolver,
1236        notice_sink,
1237        for_query_resolver,
1238        set_sink,
1239    };
1240    let mut current_new: Option<Row> = None;
1241    let mut deferred: Vec<DeferredEmbeddedStmt> = Vec::new();
1242    // execute_stmts returns BodyOutcome — for DO top-level we
1243    // ignore the return target (RETURN inside DO is a no-op
1244    // by PG semantics: the block's outer scope has no return
1245    // contract).
1246    //
1247    // v7.37.20 (20.10) — EXCEPTION handlers wrap the body walk.
1248    // A TriggerError::RaiseException that matches an
1249    // `EXCEPTION WHEN ...` arm redirects to that arm's body
1250    // and swallows the error. `OTHERS` matches everything;
1251    // named conditions must match the RAISE'd message prefix
1252    // (SPG's simple substring model until a v7.40 error-code
1253    // table lands).
1254    let body_result = execute_stmts(
1255        &block.statements,
1256        &mut current_new,
1257        None,
1258        &mut locals,
1259        &ctx,
1260        &mut deferred,
1261    );
1262    if let Err(err) = body_result {
1263        if !block.exception_handlers.is_empty() {
1264            if let TriggerError::RaiseException { message, .. } = &err {
1265                for handler in &block.exception_handlers {
1266                    let matches = handler.conditions.iter().any(|c| {
1267                        c.eq_ignore_ascii_case("others")
1268                            || message
1269                                .to_ascii_lowercase()
1270                                .contains(&c.to_ascii_lowercase())
1271                    });
1272                    if matches {
1273                        // v7.37.20 (20.16) — GET STACKED DIAGNOSTICS
1274                        // groundwork: expose the caught exception's
1275                        // message as the `sqlerrm` local variable so
1276                        // the handler body (and any `GET STACKED
1277                        // DIAGNOSTICS var := SQLERRM` follow-up)
1278                        // can read it. `sqlstate` gets a placeholder
1279                        // 'P0001' — SPG's unspecified user-defined
1280                        // error code (matches PG's default for
1281                        // RAISE EXCEPTION without ERRCODE) until a
1282                        // v7.40 error-code table lands.
1283                        locals.insert("sqlerrm".into(), Value::text(message.clone()));
1284                        locals.insert(
1285                            "sqlstate".into(),
1286                            Value::text(alloc::string::String::from("P0001")),
1287                        );
1288                        // Run the handler body; ignore its outcome
1289                        // (an exception handler that itself raises
1290                        // propagates as the new error).
1291                        let _ = execute_stmts(
1292                            &handler.body,
1293                            &mut current_new,
1294                            None,
1295                            &mut locals,
1296                            &ctx,
1297                            &mut deferred,
1298                        )?;
1299                        return Ok(deferred.into_iter().map(|d| d.stmt).collect());
1300                    }
1301                }
1302            }
1303        }
1304        return Err(err);
1305    }
1306    Ok(deferred.into_iter().map(|d| d.stmt).collect())
1307}
1308
1309/// v7.39 (read01 round 64) — run a plpgsql body as a SCALAR function: the same
1310/// interpreter the DO block and the triggers use, with no NEW / OLD, the
1311/// arguments pre-bound as locals, and `RETURN <expr>` actually EVALUATED (the
1312/// trigger path discards it — `resolve_return`'s own comment said "the scalar
1313/// UDF surface in a later release handles RETURN <expr> properly").
1314///
1315/// `Ok(None)` means the body fell out of the bottom without returning, which PG
1316/// reports as an error for a non-void function; the caller phrases it.
1317///
1318/// A body that WRITES (an embedded INSERT / UPDATE / DELETE) cannot run here:
1319/// the call arrives through expression evaluation, which holds the engine
1320/// immutably. Those `deferred` statements are refused rather than dropped —
1321/// silently discarding a write would be the worst possible answer.
1322pub fn call_plpgsql_scalar<'a>(
1323    function: &str,
1324    block: &spg_sql::ast::PlPgSqlBlock,
1325    args: BTreeMap<String, Value<'static>>,
1326    default_text_search_config: Option<&'a str>,
1327    select_into_resolver: Option<&'a SelectIntoResolver<'a>>,
1328    for_query_resolver: Option<&'a ForQueryResolver<'a>>,
1329    // v7.39 (read01 round 66) — where `RETURN NEXT` / `RETURN QUERY` append.
1330    // `Some` when the function is SETOF; the caller reads the rows out of it.
1331    set_sink: Option<&'a core::cell::RefCell<Vec<Vec<Value<'static>>>>>,
1332    // v7.39 (round 757, F31-B3) — see [`NoticeSink`]. The SELECT-path
1333    // caller passes `None` (immutable engine borrow; B3 residual).
1334    notice_sink: Option<&'a NoticeSink>,
1335) -> Result<Option<Value<'static>>, TriggerError> {
1336    let mut locals: BTreeMap<String, Value<'static>> = args;
1337    let empty_cols: &[ColumnSchema] = &[];
1338    // The DECLARE block runs AFTER the arguments are bound, so an initialiser
1339    // may reference them (`DECLARE y int := x * 2;`).
1340    init_locals_from_declarations(
1341        &block.declarations,
1342        &mut locals,
1343        None,
1344        None,
1345        empty_cols,
1346        "",
1347        &[],
1348        default_text_search_config,
1349        function,
1350        select_into_resolver,
1351    )?;
1352    let ctx = BodyCtx {
1353        function,
1354        table_name: "",
1355        columns: empty_cols,
1356        params: &[],
1357        default_text_search_config,
1358        is_after: false,
1359        select_into_resolver,
1360        notice_sink,
1361        for_query_resolver,
1362        set_sink,
1363    };
1364    let mut current_new: Option<Row> = None;
1365    let mut deferred: Vec<DeferredEmbeddedStmt> = Vec::new();
1366    let mut outcome = execute_stmts(
1367        &block.statements,
1368        &mut current_new,
1369        None,
1370        &mut locals,
1371        &ctx,
1372        &mut deferred,
1373    );
1374    // An EXCEPTION handler catches a RAISE, exactly as in a DO block.
1375    if let Err(err) = outcome {
1376        let mut handled = None;
1377        if !block.exception_handlers.is_empty()
1378            && let TriggerError::RaiseException { message, .. } = &err
1379        {
1380            for handler in &block.exception_handlers {
1381                let matches = handler.conditions.iter().any(|c| {
1382                    c.eq_ignore_ascii_case("others")
1383                        || message
1384                            .to_ascii_lowercase()
1385                            .contains(&c.to_ascii_lowercase())
1386                });
1387                if matches {
1388                    locals.insert("sqlerrm".into(), Value::text(message.clone()));
1389                    locals.insert(
1390                        "sqlstate".into(),
1391                        Value::text(alloc::string::String::from("P0001")),
1392                    );
1393                    handled = Some(execute_stmts(
1394                        &handler.body,
1395                        &mut current_new,
1396                        None,
1397                        &mut locals,
1398                        &ctx,
1399                        &mut deferred,
1400                    )?);
1401                    break;
1402                }
1403            }
1404        }
1405        match handled {
1406            Some(o) => outcome = Ok(o),
1407            None => return Err(err),
1408        }
1409    }
1410    if !deferred.is_empty() {
1411        return Err(TriggerError::UnsupportedConstruct {
1412            function: function.into(),
1413            detail: alloc::string::String::from(
1414                "a plpgsql function body that writes (INSERT / UPDATE / DELETE) \
1415                 cannot be called from an expression",
1416            ),
1417        });
1418    }
1419    match outcome.expect("error paths returned above") {
1420        BodyOutcome::Return(ReturnTarget::Expr(e)) => {
1421            let v = eval_with_new_old_and_locals(
1422                &e,
1423                None,
1424                None,
1425                &locals,
1426                empty_cols,
1427                "",
1428                &[],
1429                default_text_search_config,
1430                ctx.select_into_resolver,
1431            )
1432            .map_err(|cause| TriggerError::EvalFailed {
1433                function: function.into(),
1434                cause,
1435            })?;
1436            Ok(Some(v))
1437        }
1438        BodyOutcome::Return(ReturnTarget::Null) => Ok(Some(Value::Null)),
1439        BodyOutcome::Return(_) => Err(TriggerError::UnsupportedConstruct {
1440            function: function.into(),
1441            detail: alloc::string::String::from("RETURN NEW / OLD is only meaningful in a trigger"),
1442        }),
1443        _ => Ok(None),
1444    }
1445}
1446
1447fn resolve_return(
1448    target: ReturnTarget,
1449    current_new: Option<Row<'static>>,
1450    old_row: Option<&Row<'static>>,
1451) -> TriggerOutcome {
1452    match target {
1453        ReturnTarget::New => current_new.map_or(TriggerOutcome::Skip, TriggerOutcome::Row),
1454        ReturnTarget::Old => old_row
1455            .cloned()
1456            .map_or(TriggerOutcome::Skip, TriggerOutcome::Row),
1457        ReturnTarget::Null => TriggerOutcome::Skip,
1458        // The scalar UDF surface in a later release handles
1459        // RETURN <expr> properly; for now we fall through to Skip.
1460        ReturnTarget::Expr(_) => TriggerOutcome::Skip,
1461    }
1462}
1463
1464#[allow(clippy::too_many_arguments)]
1465fn init_locals_from_declarations(
1466    decls: &[PlPgSqlDeclare],
1467    locals: &mut BTreeMap<String, Value>,
1468    new_row: Option<&Row<'static>>,
1469    old_row: Option<&Row<'static>>,
1470    columns: &[ColumnSchema],
1471    table_name: &str,
1472    params: &[Value<'static>],
1473    default_text_search_config: Option<&str>,
1474    function_name: &str,
1475    subquery_resolver: Option<&SelectIntoResolver<'_>>,
1476) -> Result<(), TriggerError> {
1477    for d in decls {
1478        let v = if let Some(init) = &d.default {
1479            eval_with_new_old_and_locals(
1480                init,
1481                new_row,
1482                old_row,
1483                locals,
1484                columns,
1485                table_name,
1486                params,
1487                default_text_search_config,
1488                subquery_resolver,
1489            )
1490            .map_err(|cause| TriggerError::EvalFailed {
1491                function: function_name.into(),
1492                cause,
1493            })?
1494        } else {
1495            Value::Null
1496        };
1497        locals.insert(d.name.clone(), v);
1498    }
1499    Ok(())
1500}
1501
1502/// v7.12.6 — PG `%` format expansion for RAISE. Sequential
1503/// positional substitution; `%%` produces a literal `%`.
1504fn format_raise_message(fmt: &str, args: &[String]) -> String {
1505    let mut out = String::with_capacity(fmt.len());
1506    let mut iter = args.iter();
1507    let mut chars = fmt.chars().peekable();
1508    while let Some(c) = chars.next() {
1509        if c == '%' {
1510            match chars.peek() {
1511                Some('%') => {
1512                    out.push('%');
1513                    chars.next();
1514                }
1515                _ => {
1516                    if let Some(a) = iter.next() {
1517                        out.push_str(a);
1518                    } else {
1519                        // Unconsumed placeholder — PG emits an
1520                        // error here; we mirror by leaving the
1521                        // bare `%` so the message stays readable.
1522                        out.push('%');
1523                    }
1524                }
1525            }
1526        } else {
1527            out.push(c);
1528        }
1529    }
1530    out
1531}
1532
1533/// v7.12.6 — Display rendering for a [`Value`] inside a RAISE
1534/// message arg. Booleans / ints / floats render naturally;
1535/// strings render unquoted; other types fall back to Debug.
1536fn value_to_display_string(v: &Value) -> String {
1537    use alloc::string::ToString;
1538    match v {
1539        Value::Null => String::new(),
1540        Value::Bool(b) => b.to_string(),
1541        Value::SmallInt(n) => n.to_string(),
1542        Value::Int(n) => n.to_string(),
1543        Value::BigInt(n) => n.to_string(),
1544        Value::Float(x) => x.to_string(),
1545        Value::Text(s) | Value::Json(s) => s.to_string(),
1546        other => format!("{other:?}"),
1547    }
1548}
1549
1550/// Evaluate a sub-expression against the NEW / OLD row context.
1551/// Pre-walks the AST replacing every `NEW.col` / `OLD.col`
1552/// reference with a literal of the actual value, then dispatches
1553/// to the regular [`eval::eval_expr`]. Pre-walk strategy mirrors
1554/// the existing [`substitute_in_expr`] used by correlated
1555/// subqueries.
1556/// v7.12.6 — same as [`eval_with_new_old`] but also substitutes
1557/// qualifier-less `Column(<name>)` references whose name matches
1558/// a `DECLARE`'d local variable. Locals shadow table-column refs
1559/// (PG semantics — though a careful trigger function avoids the
1560/// collision via naming convention).
1561#[allow(clippy::too_many_arguments)]
1562fn eval_with_new_old_and_locals(
1563    expr: &Expr,
1564    new_row: Option<&Row<'static>>,
1565    old_row: Option<&Row<'static>>,
1566    locals: &BTreeMap<String, Value>,
1567    columns: &[ColumnSchema],
1568    table_alias: &str,
1569    params: &[Value<'static>],
1570    default_text_search_config: Option<&str>,
1571    subquery_resolver: Option<&SelectIntoResolver<'_>>,
1572) -> Result<Value<'static>, EvalError> {
1573    let mut rewritten = expr.clone();
1574    substitute_locals(&mut rewritten, locals);
1575    substitute_new_old(&mut rewritten, new_row, old_row, columns)?;
1576    // v7.39 (round 335, V61) — a scalar subquery inside a plpgsql
1577    // expression is RUN here, before the row evaluator sees it. The
1578    // evaluator cannot execute one — it answered "subquery reached row
1579    // eval — engine resolver bug", an internal message, for
1580    // `RETURN (SELECT …)`, `n := (SELECT …)` and any expression
1581    // containing one. `SELECT … INTO` worked only because it had a
1582    // resolver of its own; this gives expressions the same one.
1583    if let Some(resolver) = subquery_resolver {
1584        let mut failure: Option<EvalError> = None;
1585        substitute_locals_visiting(&mut rewritten, locals, &mut |node| {
1586            if failure.is_some() {
1587                return;
1588            }
1589            let Expr::ScalarSubquery(sel) = node else {
1590                return;
1591            };
1592            let mut stmt = spg_sql::ast::Statement::Select((**sel).clone());
1593            if let Err(e) = substitute_trigger_context_in_statement(
1594                &mut stmt, new_row, old_row, locals, columns,
1595            ) {
1596                failure = Some(e);
1597                return;
1598            }
1599            match resolver(&stmt) {
1600                Ok(v) => *node = value_to_literal_expr(&[], 0, v),
1601                Err(e) => {
1602                    failure = Some(EvalError::TypeMismatch {
1603                        detail: alloc::format!("{e}"),
1604                    });
1605                }
1606            }
1607        });
1608        if let Some(e) = failure {
1609            return Err(e);
1610        }
1611    }
1612    let ctx = EvalContext::new(columns, Some(table_alias))
1613        .with_params(params)
1614        .with_default_text_search_config(default_text_search_config);
1615    let empty = Row::new(Vec::new());
1616    eval::eval_expr(&rewritten, &empty, &ctx)
1617}
1618
1619/// v7.12.6 — in-place substitute every qualifier-less
1620/// `Column(<name>)` whose name is in `locals` with that local's
1621/// current Value as a literal. Runs before [`substitute_new_old`]
1622/// so NEW.col / OLD.col references (which have a qualifier) take
1623/// the NEW/OLD path normally.
1624fn substitute_locals(expr: &mut Expr, locals: &BTreeMap<String, Value>) {
1625    substitute_locals_visiting(expr, locals, &mut |_| {});
1626}
1627
1628/// v7.39 (round 335, V61) — the same full-tree walk, calling `visit` on
1629/// every node. It exists so a scalar subquery can be found and REPLACED
1630/// wherever it sits, reusing the one walker that already knows every
1631/// expression shape rather than growing a second one beside it.
1632fn substitute_locals_visiting(
1633    expr: &mut Expr,
1634    locals: &BTreeMap<String, Value>,
1635    visit: &mut dyn FnMut(&mut Expr),
1636) {
1637    visit(expr);
1638    if let Expr::Column(c) = expr {
1639        if c.qualifier.is_none()
1640            && let Some(v) = locals.get(&c.name)
1641        {
1642            *expr = value_to_literal_expr(&[], 0, v.clone());
1643            return;
1644        }
1645        // v7.39 (read01 round 64) — a RECORD variable's field: `rec.v` inside a
1646        // `FOR rec IN SELECT … LOOP`. The loop binds each row's columns as
1647        // `rec.<col>` locals, so the qualified reference resolves here.
1648        if let Some(q) = &c.qualifier {
1649            let key = alloc::format!("{}.{}", q.to_ascii_lowercase(), c.name.to_ascii_lowercase());
1650            if let Some(v) = locals.get(&key) {
1651                *expr = value_to_literal_expr(&[], 0, v.clone());
1652                return;
1653            }
1654        }
1655    }
1656    match expr {
1657        Expr::NamedArg { expr, .. } => substitute_locals_visiting(expr, locals, visit),
1658        Expr::Variadic(expr) => substitute_locals_visiting(expr, locals, visit),
1659        Expr::AggregateOrdered { call, order_by, .. } => {
1660            substitute_locals_visiting(call, locals, visit);
1661            for o in order_by.iter_mut() {
1662                substitute_locals_visiting(&mut o.expr, locals, visit);
1663            }
1664        }
1665        Expr::Binary { lhs, rhs, .. } => {
1666            substitute_locals_visiting(lhs, locals, visit);
1667            substitute_locals_visiting(rhs, locals, visit);
1668        }
1669        Expr::Unary { expr, .. }
1670        | Expr::Cast { expr, .. }
1671        | Expr::IsNull { expr, .. }
1672        | Expr::BoolTest { expr, .. }
1673        | Expr::FieldAccess { base: expr, .. } => {
1674            substitute_locals_visiting(expr, locals, visit);
1675        }
1676        Expr::Like { expr, pattern, .. } => {
1677            substitute_locals_visiting(expr, locals, visit);
1678            substitute_locals_visiting(pattern, locals, visit);
1679        }
1680        Expr::FunctionCall { args, .. } => {
1681            for a in args {
1682                substitute_locals_visiting(a, locals, visit);
1683            }
1684        }
1685        Expr::Extract { source, .. } => substitute_locals_visiting(source, locals, visit),
1686        Expr::Array(items) => {
1687            for elem in items {
1688                substitute_locals_visiting(elem, locals, visit);
1689            }
1690        }
1691        Expr::ArraySubscript { target, index } => {
1692            substitute_locals_visiting(target, locals, visit);
1693            substitute_locals_visiting(index, locals, visit);
1694        }
1695        Expr::ArraySlice { target, lo, hi } => {
1696            substitute_locals_visiting(target, locals, visit);
1697            if let Some(l) = lo {
1698                substitute_locals_visiting(l, locals, visit);
1699            }
1700            if let Some(h) = hi {
1701                substitute_locals_visiting(h, locals, visit);
1702            }
1703        }
1704        Expr::AnyAll { expr, array, .. } => {
1705            substitute_locals_visiting(expr, locals, visit);
1706            substitute_locals_visiting(array, locals, visit);
1707        }
1708        Expr::InList { expr, list, .. } => {
1709            substitute_locals_visiting(expr, locals, visit);
1710            for item in list {
1711                substitute_locals_visiting(item, locals, visit);
1712            }
1713        }
1714        Expr::Case {
1715            operand,
1716            branches,
1717            else_branch,
1718        } => {
1719            if let Some(o) = operand {
1720                substitute_locals_visiting(o, locals, visit);
1721            }
1722            for (w, t) in branches {
1723                substitute_locals_visiting(w, locals, visit);
1724                substitute_locals_visiting(t, locals, visit);
1725            }
1726            if let Some(e) = else_branch {
1727                substitute_locals_visiting(e, locals, visit);
1728            }
1729        }
1730        Expr::Literal(_)
1731        | Expr::Placeholder(_)
1732        | Expr::Column(_)
1733        | Expr::WindowFunction { .. }
1734        | Expr::ScalarSubquery(_)
1735        | Expr::Exists { .. }
1736        | Expr::InSubquery { .. }
1737        | Expr::RowInSubquery { .. }
1738        | Expr::RowCmpSubquery { .. } => {}
1739    }
1740}
1741
1742fn eval_with_new_old(
1743    expr: &Expr,
1744    new_row: Option<&Row<'static>>,
1745    old_row: Option<&Row<'static>>,
1746    columns: &[ColumnSchema],
1747    table_alias: &str,
1748    params: &[Value<'static>],
1749    default_text_search_config: Option<&str>,
1750) -> Result<Value<'static>, EvalError> {
1751    let mut rewritten = expr.clone();
1752    substitute_new_old(&mut rewritten, new_row, old_row, columns)?;
1753    let ctx = EvalContext::new(columns, Some(table_alias))
1754        .with_params(params)
1755        .with_default_text_search_config(default_text_search_config);
1756    // Empty row — the substitution above eliminated every column
1757    // reference that depended on NEW / OLD; any remaining column
1758    // reference is a bug (would surface as ColumnNotFound).
1759    let empty = Row::new(Vec::new());
1760    eval::eval_expr(&rewritten, &empty, &ctx)
1761}
1762
1763/// In-place walk: replace every `Column{qualifier=NEW|OLD,name=c}`
1764/// reference with the corresponding row value, materialised as
1765/// an `Expr::Literal`. Recurses through every Expr variant so
1766/// `to_tsvector('english', NEW.subject || ' ' || NEW.sender)`
1767/// substitutes cleanly even though the references nest inside
1768/// function calls + binary operators.
1769/// v7.39 (round 138) — does a row trigger's `WHEN ( condition )` hold for the
1770/// NEW / OLD row? Empty text = no condition (always fires). After NEW/OLD are
1771/// substituted to literals the predicate is constant, so a minimal eval context
1772/// suffices — this is a free fn callable from the borrow-constrained INSERT row
1773/// loop. Only a definite TRUE fires (NULL / FALSE skip), matching PG.
1774pub(crate) fn trigger_when_holds(
1775    when_text: &str,
1776    new_row: Option<&Row<'static>>,
1777    old_row: Option<&Row<'static>>,
1778    columns: &[ColumnSchema],
1779) -> Result<bool, EngineError> {
1780    if when_text.is_empty() {
1781        return Ok(true);
1782    }
1783    let mut expr = spg_sql::parser::parse_expression(when_text)
1784        .map_err(|e| EngineError::Unsupported(alloc::format!("trigger WHEN: {e}")))?;
1785    substitute_new_old(&mut expr, new_row, old_row, columns).map_err(EngineError::Eval)?;
1786    let ctx = crate::eval::EvalContext::new(&[], None);
1787    let empty = Row::new(alloc::vec::Vec::new());
1788    let v = crate::eval::eval_expr(&expr, &empty, &ctx).map_err(EngineError::Eval)?;
1789    Ok(matches!(v, Value::Bool(true)))
1790}
1791
1792pub(crate) fn substitute_new_old(
1793    expr: &mut Expr,
1794    new_row: Option<&Row<'static>>,
1795    old_row: Option<&Row<'static>>,
1796    columns: &[ColumnSchema],
1797) -> Result<(), EvalError> {
1798    if let Expr::Column(c) = expr {
1799        if let Some(q) = &c.qualifier {
1800            let lower = q.to_ascii_lowercase();
1801            if lower == "new" || lower == "old" {
1802                let (row, side) = if lower == "new" {
1803                    (new_row, "NEW")
1804                } else {
1805                    (old_row, "OLD")
1806                };
1807                let pos = columns
1808                    .iter()
1809                    .position(|sc| sc.name.eq_ignore_ascii_case(&c.name))
1810                    .ok_or_else(|| EvalError::ColumnNotFound {
1811                        name: format!("{side}.{}", c.name),
1812                    })?;
1813                let v = match row {
1814                    Some(r) => r.values.get(pos).cloned().unwrap_or(Value::Null),
1815                    None => Value::Null,
1816                };
1817                *expr = value_to_literal_expr(columns, pos, v);
1818                return Ok(());
1819            }
1820        }
1821    }
1822    match expr {
1823        Expr::NamedArg { expr, .. } => substitute_new_old(expr, new_row, old_row, columns)?,
1824        Expr::Variadic(expr) => substitute_new_old(expr, new_row, old_row, columns)?,
1825        Expr::AggregateOrdered { call, order_by, .. } => {
1826            substitute_new_old(call, new_row, old_row, columns)?;
1827            for o in order_by.iter_mut() {
1828                substitute_new_old(&mut o.expr, new_row, old_row, columns)?;
1829            }
1830        }
1831        Expr::Binary { lhs, rhs, .. } => {
1832            substitute_new_old(lhs, new_row, old_row, columns)?;
1833            substitute_new_old(rhs, new_row, old_row, columns)?;
1834        }
1835        Expr::Unary { expr, .. }
1836        | Expr::Cast { expr, .. }
1837        | Expr::IsNull { expr, .. }
1838        | Expr::BoolTest { expr, .. }
1839        | Expr::FieldAccess { base: expr, .. } => {
1840            substitute_new_old(expr, new_row, old_row, columns)?;
1841        }
1842        Expr::Like { expr, pattern, .. } => {
1843            substitute_new_old(expr, new_row, old_row, columns)?;
1844            substitute_new_old(pattern, new_row, old_row, columns)?;
1845        }
1846        Expr::FunctionCall { args, .. } => {
1847            for a in args {
1848                substitute_new_old(a, new_row, old_row, columns)?;
1849            }
1850        }
1851        Expr::Extract { source, .. } => substitute_new_old(source, new_row, old_row, columns)?,
1852        Expr::Array(items) => {
1853            for elem in items {
1854                substitute_new_old(elem, new_row, old_row, columns)?;
1855            }
1856        }
1857        Expr::ArraySubscript { target, index } => {
1858            substitute_new_old(target, new_row, old_row, columns)?;
1859            substitute_new_old(index, new_row, old_row, columns)?;
1860        }
1861        Expr::ArraySlice { target, lo, hi } => {
1862            substitute_new_old(target, new_row, old_row, columns)?;
1863            if let Some(l) = lo {
1864                substitute_new_old(l, new_row, old_row, columns)?;
1865            }
1866            if let Some(h) = hi {
1867                substitute_new_old(h, new_row, old_row, columns)?;
1868            }
1869        }
1870        Expr::AnyAll { expr, array, .. } => {
1871            substitute_new_old(expr, new_row, old_row, columns)?;
1872            substitute_new_old(array, new_row, old_row, columns)?;
1873        }
1874        Expr::InList { expr, list, .. } => {
1875            substitute_new_old(expr, new_row, old_row, columns)?;
1876            for item in list {
1877                substitute_new_old(item, new_row, old_row, columns)?;
1878            }
1879        }
1880        Expr::Case {
1881            operand,
1882            branches,
1883            else_branch,
1884        } => {
1885            if let Some(o) = operand {
1886                substitute_new_old(o, new_row, old_row, columns)?;
1887            }
1888            for (w, t) in branches {
1889                substitute_new_old(w, new_row, old_row, columns)?;
1890                substitute_new_old(t, new_row, old_row, columns)?;
1891            }
1892            if let Some(e) = else_branch {
1893                substitute_new_old(e, new_row, old_row, columns)?;
1894            }
1895        }
1896        // Leaves + variants we don't recurse into (sub-queries
1897        // inside a trigger body would require correlated-query
1898        // wiring; carved out of v7.12.4).
1899        Expr::Literal(_)
1900        | Expr::Placeholder(_)
1901        | Expr::Column(_)
1902        | Expr::WindowFunction { .. }
1903        | Expr::ScalarSubquery(_)
1904        | Expr::Exists { .. }
1905        | Expr::InSubquery { .. }
1906        | Expr::RowInSubquery { .. }
1907        | Expr::RowCmpSubquery { .. } => {}
1908    }
1909    Ok(())
1910}
1911
1912/// Turn a [`Value`] back into an [`Expr::Literal`]. Necessary
1913/// because [`substitute_new_old`] inlines NEW/OLD cell values
1914/// into the expression tree.
1915fn value_to_literal_expr(_columns: &[ColumnSchema], _pos: usize, v: Value) -> Expr {
1916    use spg_sql::ast::Literal;
1917    let lit = match v {
1918        Value::Null => Literal::Null,
1919        Value::Bool(b) => Literal::Bool(b),
1920        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
1921        Value::Int(n) => Literal::Integer(i64::from(n)),
1922        Value::BigInt(n) => Literal::Integer(n),
1923        Value::Float(x) => Literal::Float(x),
1924        Value::Text(s) | Value::Json(s) => Literal::String(s.into_owned()),
1925        // Other values (Vector, Date, Timestamp, TsVector, etc.)
1926        // round-trip through the Display form back into a string
1927        // literal. v7.12.5 will add typed-literal variants here
1928        // so the cast layer doesn't need to re-parse from text.
1929        other => Literal::String(format!("{other:?}")),
1930    };
1931    Expr::Literal(lit)
1932}
1933
1934/// v7.12.7 — substitute NEW / OLD / DECLARE-local references in
1935/// every `Expr` field of a [`Statement`]. Used to materialise an
1936/// embedded SQL statement's NEW.col / OLD.col / local-var refs as
1937/// literals so the engine can re-execute it without holding the
1938/// trigger context.
1939pub(crate) fn substitute_trigger_context_in_statement(
1940    stmt: &mut spg_sql::ast::Statement,
1941    new_row: Option<&Row<'static>>,
1942    old_row: Option<&Row<'static>>,
1943    locals: &BTreeMap<String, Value>,
1944    columns: &[ColumnSchema],
1945) -> Result<(), EvalError> {
1946    use spg_sql::ast::Statement;
1947    let mut walk = |e: &mut Expr| -> Result<(), EvalError> {
1948        substitute_locals(e, locals);
1949        substitute_new_old(e, new_row, old_row, columns)?;
1950        Ok(())
1951    };
1952    match stmt {
1953        Statement::Insert(s) => {
1954            for tuple in &mut s.rows {
1955                for e in tuple {
1956                    walk(e)?;
1957                }
1958            }
1959        }
1960        Statement::Update(s) => {
1961            for (_col, e) in &mut s.assignments {
1962                walk(e)?;
1963            }
1964            if let Some(w) = &mut s.where_ {
1965                walk(w)?;
1966            }
1967        }
1968        Statement::Delete(s) => {
1969            if let Some(w) = &mut s.where_ {
1970                walk(w)?;
1971            }
1972        }
1973        Statement::Select(s) => {
1974            substitute_trigger_context_in_select(s, new_row, old_row, locals, columns)?
1975        }
1976        // Other statement kinds (DDL, SHOW, etc.) inside a
1977        // trigger body would only meaningfully reference NEW/OLD
1978        // in error-message position; v7.12.7 doesn't recursively
1979        // substitute their Expr fields. Future surfaces (e.g.
1980        // RAISE ... USING) can add cases here.
1981        _ => {}
1982    }
1983    Ok(())
1984}
1985
1986fn substitute_trigger_context_in_select(
1987    s: &mut spg_sql::ast::SelectStatement,
1988    new_row: Option<&Row<'static>>,
1989    old_row: Option<&Row<'static>>,
1990    locals: &BTreeMap<String, Value>,
1991    columns: &[ColumnSchema],
1992) -> Result<(), EvalError> {
1993    use spg_sql::ast::SelectItem;
1994    let mut walk = |e: &mut Expr| -> Result<(), EvalError> {
1995        substitute_locals(e, locals);
1996        substitute_new_old(e, new_row, old_row, columns)?;
1997        Ok(())
1998    };
1999    for item in &mut s.items {
2000        if let SelectItem::Expr { expr, .. } = item {
2001            walk(expr)?;
2002        }
2003    }
2004    if let Some(w) = &mut s.where_ {
2005        walk(w)?;
2006    }
2007    if let Some(group_by) = &mut s.group_by {
2008        for g in group_by {
2009            walk(g)?;
2010        }
2011    }
2012    if let Some(h) = &mut s.having {
2013        walk(h)?;
2014    }
2015    for ob in &mut s.order_by {
2016        walk(&mut ob.expr)?;
2017    }
2018    // LIMIT / OFFSET use `LimitExpr` (integer literal or
2019    // placeholder); they don't carry an `Expr` to substitute
2020    // into. Leave them alone.
2021    let _ = &s.limit;
2022    let _ = &s.offset;
2023    Ok(())
2024}
2025
2026/// v7.12.4 — find the triggers that should fire for a given
2027/// `(table, event, timing)` tuple. Returns names so the caller
2028/// can iterate without holding a borrow on the catalog while it
2029/// mutates rows.
2030pub fn matching_trigger_names<'a>(
2031    triggers: &'a [TriggerDef],
2032    table: &str,
2033    event: &str,
2034    timing: &str,
2035) -> Vec<&'a TriggerDef> {
2036    triggers
2037        .iter()
2038        .filter(|t| {
2039            t.table == table
2040                && t.timing.eq_ignore_ascii_case(timing)
2041                && t.for_each.eq_ignore_ascii_case("row")
2042                && t.events.iter().any(|e| e.eq_ignore_ascii_case(event))
2043        })
2044        .collect()
2045}
2046
2047impl Engine {
2048    /// v7.12.4 — snapshot every row-level trigger on `table` that
2049    /// fires for `event` (`"INSERT"` / `"UPDATE"` / `"DELETE"`) at
2050    /// the given `timing` (`"BEFORE"` / `"AFTER"`), and clone its
2051    /// referenced function definition. Returned as a vec of owned
2052    /// `FunctionDef` so the row-write loop can fire them without
2053    /// holding a borrow on the catalog (which would conflict with
2054    /// the table.insert / update_row / delete mutable borrows).
2055    pub(crate) fn snapshot_row_triggers(
2056        &self,
2057        table: &str,
2058        event: &str,
2059        timing: &str,
2060    ) -> Vec<(
2061        spg_storage::FunctionDef,
2062        alloc::string::String,
2063        alloc::string::String,
2064    )> {
2065        let cat = self.active_catalog();
2066        let mut matching: Vec<&spg_storage::TriggerDef> = cat
2067            .triggers()
2068            .iter()
2069            .filter(|t| {
2070                // v7.16.1 — skip disabled triggers (mailrs
2071                // round-9 A.2.b — pg_dump --disable-triggers).
2072                t.enabled
2073                    && t.table == table
2074                    && t.timing.eq_ignore_ascii_case(timing)
2075                    && t.for_each.eq_ignore_ascii_case("row")
2076                    && t.events.iter().any(|e| e.eq_ignore_ascii_case(event))
2077            })
2078            .collect();
2079        // v7.39 (round 755, F31-B2) — same-event triggers fire in NAME
2080        // order, PG18-measured (a_trig before z_trig regardless of
2081        // creation order); the catalog Vec keeps insertion order.
2082        matching.sort_by(|a, b| a.name.cmp(&b.name));
2083        matching
2084            .into_iter()
2085            // v7.39 (read01 round 62) — functions are keyed by SIGNATURE now. A
2086            // trigger names its function by NAME, and a trigger function takes
2087            // no arguments, so there is at most one.
2088            // v7.39 (read01 round 82) — carry the TRIGGER's name alongside the
2089            // function, for TG_NAME (which is the trigger name, not the function
2090            // name).
2091            .filter_map(|t| {
2092                cat.functions_named(&t.function)
2093                    .first()
2094                    .map(|f| ((*f).clone(), t.when_condition.clone(), t.name.clone()))
2095            })
2096            .collect()
2097    }
2098
2099    /// v7.13.0 — UPDATE-side snapshot that pairs each trigger's
2100    /// function with its `UPDATE OF cols` filter (mailrs round-5
2101    /// G7). Empty filter Vec means "fire unconditionally", matching
2102    /// the v7.12 behaviour.
2103    pub(crate) fn snapshot_update_row_triggers(
2104        &self,
2105        table: &str,
2106        timing: &str,
2107    ) -> Vec<(
2108        spg_storage::FunctionDef,
2109        Vec<String>,
2110        alloc::string::String,
2111        alloc::string::String,
2112    )> {
2113        let cat = self.active_catalog();
2114        let mut matching: Vec<&spg_storage::TriggerDef> = cat
2115            .triggers()
2116            .iter()
2117            .filter(|t| {
2118                // v7.16.1 — skip disabled triggers.
2119                t.enabled
2120                    && t.table == table
2121                    && t.timing.eq_ignore_ascii_case(timing)
2122                    && t.for_each.eq_ignore_ascii_case("row")
2123                    && t.events.iter().any(|e| e.eq_ignore_ascii_case("UPDATE"))
2124            })
2125            .collect();
2126        // v7.39 (round 755, F31-B2) — NAME order, PG18-measured.
2127        matching.sort_by(|a, b| a.name.cmp(&b.name));
2128        matching
2129            .into_iter()
2130            // (fd, UPDATE-OF cols, WHEN text, trigger name).
2131            .filter_map(|t| {
2132                cat.functions_named(&t.function).first().map(|fd| {
2133                    (
2134                        (*fd).clone(),
2135                        t.update_columns.clone(),
2136                        t.when_condition.clone(),
2137                        t.name.clone(),
2138                    )
2139                })
2140            })
2141            .collect()
2142    }
2143
2144    /// v7.12.7 — drain the trigger-emitted embedded SQL queue.
2145    /// Called by the INSERT / UPDATE / DELETE executors after
2146    /// their main row-write loop returns. Each statement runs
2147    /// inside the same cancel scope as the firing DML and bumps
2148    /// the recursion counter; nested embedded SQL beyond
2149    /// [`MAX_TRIGGER_RECURSION`] errors with a clear message so
2150    /// a trigger-graph cycle surfaces as a query failure instead
2151    /// of stack-blowing the engine.
2152    pub(crate) fn execute_deferred_trigger_stmts(
2153        &mut self,
2154        deferred: Vec<DeferredEmbeddedStmt>,
2155        cancel: CancelToken<'_>,
2156    ) -> Result<(), EngineError> {
2157        for d in deferred {
2158            if self.trigger_recursion_depth >= MAX_TRIGGER_RECURSION {
2159                return Err(EngineError::Storage(StorageError::Corrupt(alloc::format!(
2160                    "trigger embedded SQL recursion depth {} exceeded (trigger function \
2161                     {:?} would push past the {} cap — check for trigger cycles)",
2162                    self.trigger_recursion_depth,
2163                    d.function,
2164                    MAX_TRIGGER_RECURSION,
2165                ))));
2166            }
2167            self.trigger_recursion_depth += 1;
2168            let res = self.execute_stmt_with_cancel(d.stmt, cancel);
2169            self.trigger_recursion_depth -= 1;
2170            res?;
2171        }
2172        Ok(())
2173    }
2174}