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}