Skip to main content

spg_engine/
execute.rs

1//! Statement execution + prepared-statement dispatch, split out of
2//! `lib.rs` (lib.rs split 17). The public `execute` / `execute_in` /
3//! `execute_with_cancel` entry points, the `prepare` / `prepare_cached`
4//! / `describe_prepared` / `execute_prepared` prepared-statement path,
5//! and the internal pipeline (`execute_inner_with_cancel` →
6//! `execute_stmt_with_cancel`) that pre-resolves clock / sequence /
7//! placeholder rewrites and routes each parsed Statement to its domain
8//! handler (DDL / DML / SELECT / transaction / SHOW / …). Whole
9//! `impl Engine` methods reached via the Engine type, so the public
10//! surface is unchanged; `execute_stmt_with_cancel` is pub(crate) for
11//! the plpgsql + trigger re-entry paths.
12
13use alloc::string::String;
14use alloc::vec::Vec;
15
16use spg_sql::ast::Statement;
17use spg_sql::parser::{self, ParseError};
18use spg_storage::{ColumnSchema, Value};
19
20use crate::describe;
21use crate::{
22    CancelToken, Engine, EngineError, IMPLICIT_TX, QueryResult, TxId, expand_group_by_all,
23    plan_cache, reorder, resolve_order_by_position, rewrite_clock_calls, substitute_placeholders,
24};
25
26/// v7.38 Epic P — turn a caught panic payload into an
27/// [`EngineError::Internal`]. Recovers a human-readable detail from the
28/// common payload shapes (`&str` / `String`, and the injection framework's
29/// typed `InjectedError`) so the wire layer sends a clean message; falls
30/// back to a generic string when the payload type is opaque.
31// r1051 — a stray `#[cfg(feature = "std")]` sat here (misattached
32// between two doc comments) and gated this core/alloc-only function
33// out of the no_std build while its two call sites stayed: no_std had
34// not compiled since the GUC round, and nothing in the gate builds
35// no_std, so nothing said so. The suite's first no_std probe did.
36/// v7.38 (read01 P3.17) — reject a clearly-invalid value for a handful of
37/// well-known typed GUCs (boolean / memory-size / duration), so a typo
38/// like `SET work_mem = 'bogus'` errors like PG instead of silently
39/// storing junk. Conservative: only GUCs whose type is unambiguous are
40/// checked; every other name is accepted so pg_dump preambles and
41/// unknown settings still load.
42fn validate_known_guc(name: &str, value: &str) -> Result<(), EngineError> {
43    let key = name.to_ascii_lowercase();
44    let bad = || {
45        EngineError::Unsupported(alloc::format!(
46            "invalid value for parameter \"{name}\": \"{value}\""
47        ))
48    };
49    let is_bool = matches!(
50        value.trim().to_ascii_lowercase().as_str(),
51        "on" | "off" | "true" | "false" | "yes" | "no" | "1" | "0"
52    );
53    // Split a `<number><unit>` GUC value into its numeric head + unit tail.
54    let split_unit = |s: &str| -> (String, String) {
55        let st = s.trim();
56        let cut = st
57            .find(|c: char| c.is_ascii_alphabetic())
58            .unwrap_or(st.len());
59        (
60            String::from(st[..cut].trim()),
61            st[cut..].trim().to_ascii_lowercase(),
62        )
63    };
64    let (num, unit) = split_unit(value);
65    let is_size =
66        num.parse::<f64>().is_ok() && matches!(unit.as_str(), "" | "b" | "kb" | "mb" | "gb" | "tb");
67    let is_duration = num.parse::<i64>().is_ok()
68        && matches!(unit.as_str(), "" | "us" | "ms" | "s" | "min" | "h" | "d");
69    match key.as_str() {
70        "enable_seqscan"
71        | "enable_indexscan"
72        | "enable_bitmapscan"
73        | "enable_indexonlyscan"
74        | "enable_hashjoin"
75        | "enable_mergejoin"
76        | "enable_nestloop"
77        | "autovacuum"
78        | "fsync"
79        | "full_page_writes" => {
80            if !is_bool {
81                return Err(bad());
82            }
83        }
84        "work_mem"
85        | "maintenance_work_mem"
86        | "shared_buffers"
87        | "temp_buffers"
88        | "effective_cache_size"
89        | "wal_buffers" => {
90            if !is_size {
91                return Err(bad());
92            }
93        }
94        "statement_timeout" | "lock_timeout" | "idle_in_transaction_session_timeout" => {
95            if !is_duration {
96                return Err(bad());
97            }
98        }
99        // v7.39 (round 171) — synchronous_commit is a real, session-level
100        // durability control now (the embedded execute path gates its
101        // WAL-fsync wait on it); validate PG's value domain.
102        "synchronous_commit" => {
103            if !matches!(
104                value.to_ascii_lowercase().as_str(),
105                "on" | "off"
106                    | "local"
107                    | "remote_write"
108                    | "remote_apply"
109                    | "true"
110                    | "false"
111                    | "0"
112                    | "1"
113            ) {
114                return Err(bad());
115            }
116        }
117        // v7.39 (round 204) — enum GUCs reject an out-of-domain value
118        // like PG (`SET client_min_messages = bogus` errors). PG's
119        // message quotes the value with a trailing hint listing the
120        // valid set; we match the leading, stable clause.
121        "client_min_messages" => {
122            if !matches!(
123                value.trim().to_ascii_lowercase().as_str(),
124                "debug5"
125                    | "debug4"
126                    | "debug3"
127                    | "debug2"
128                    | "debug1"
129                    | "log"
130                    | "notice"
131                    | "warning"
132                    | "error"
133                    | "fatal"
134                    | "panic"
135            ) {
136                return Err(bad());
137            }
138        }
139        // v7.39 (GUC knife 3) — the render GUCs reject invalid values
140        // with PG's own texts (canonical-caps parameter names).
141        "datestyle" => {
142            if crate::session::parse_datestyle_parts(value, crate::eval::RenderStyle::default())
143                .is_none()
144            {
145                return Err(EngineError::Unsupported(alloc::format!(
146                    "invalid value for parameter \"DateStyle\": \"{value}\""
147                )));
148            }
149        }
150        "intervalstyle" => {
151            if crate::session::parse_intervalstyle(value).is_none() {
152                return Err(EngineError::Unsupported(alloc::format!(
153                    "invalid value for parameter \"IntervalStyle\": \"{value}\""
154                )));
155            }
156        }
157        "extra_float_digits" => match value.trim().parse::<i64>() {
158            Ok(n) if (-15..=3).contains(&n) => {}
159            Ok(n) => {
160                return Err(EngineError::Unsupported(alloc::format!(
161                    "{n} is outside the valid range for parameter \
162                         \"extra_float_digits\" (-15 .. 3)"
163                )));
164            }
165            Err(_) => return Err(bad()),
166        },
167        _ => {}
168    }
169    Ok(())
170}
171
172fn panic_payload_to_engine_error(payload: &(dyn core::any::Any + Send)) -> EngineError {
173    // The injection framework panics with a typed error; surface its
174    // message so tests get a deterministic, informative string.
175    #[cfg(feature = "injection-points")]
176    if let Some(inj) = payload.downcast_ref::<crate::testkit::injection::InjectedError>() {
177        return EngineError::Internal(alloc::format!("query aborted by internal error: {inj}"));
178    }
179    let detail = payload
180        .downcast_ref::<&'static str>()
181        .map(|s| String::from(*s))
182        .or_else(|| payload.downcast_ref::<String>().cloned());
183    match detail {
184        Some(d) => EngineError::Internal(alloc::format!("query aborted by internal error: {d}")),
185        None => EngineError::Internal(String::from("query aborted by internal error")),
186    }
187}
188
189impl Engine {
190    pub fn execute(&mut self, sql: &str) -> Result<QueryResult, EngineError> {
191        self.execute_in_with_cancel(sql, IMPLICIT_TX, CancelToken::none())
192    }
193
194    /// v7.38 (read01 P3.20) — handle a bare `SELECT set_config(name, value,
195    /// is_local)` by writing the GUC to the same session store `SET` uses
196    /// (honouring `is_local` via the transaction undo log), so set_config,
197    /// SHOW, current_setting, and pg_settings all agree. Returns `None`
198    /// (fall through to the ordinary read-only path) unless the statement is
199    /// exactly that shape — set_config buried in a FROM/WHERE/CTE, or over a
200    /// non-text name, keeps the old value-returning behaviour.
201    fn try_exec_set_config(
202        &mut self,
203        s: &spg_sql::ast::SelectStatement,
204    ) -> Result<Option<QueryResult>, EngineError> {
205        use spg_sql::ast::{Expr, SelectItem};
206        if s.from.is_some() || s.where_.is_some() || !s.ctes.is_empty() || s.items.len() != 1 {
207            return Ok(None);
208        }
209        let SelectItem::Expr { expr, .. } = &s.items[0] else {
210            return Ok(None);
211        };
212        let Expr::FunctionCall { name, args } = expr else {
213            return Ok(None);
214        };
215        if !(name.eq_ignore_ascii_case("set_config")
216            || name.eq_ignore_ascii_case("pg_catalog.set_config"))
217            || !(args.len() == 2 || args.len() == 3)
218        {
219            return Ok(None);
220        }
221        // Evaluate the arguments against an empty row.
222        let empty: Vec<ColumnSchema> = Vec::new();
223        let (name_v, value_v, local_v);
224        {
225            let ctx = self.ev_ctx(&empty, None);
226            let dummy = spg_storage::Row::new(Vec::new());
227            name_v = crate::eval::eval_expr(&args[0], &dummy, &ctx).map_err(EngineError::Eval)?;
228            value_v = crate::eval::eval_expr(&args[1], &dummy, &ctx).map_err(EngineError::Eval)?;
229            local_v = if args.len() == 3 {
230                crate::eval::eval_expr(&args[2], &dummy, &ctx).map_err(EngineError::Eval)?
231            } else {
232                Value::Bool(false)
233            };
234        }
235        let single = |v: Value<'static>| QueryResult::Rows {
236            columns: alloc::vec![ColumnSchema::new(
237                "set_config",
238                spg_storage::DataType::Text,
239                true
240            )],
241            rows: alloc::vec![spg_storage::Row::new(alloc::vec![v])],
242        };
243        let pname = match name_v {
244            Value::Text(s) => s.into_owned(),
245            // set_config(NULL, …) is a no-op returning NULL (PG).
246            Value::Null => return Ok(Some(single(Value::Null))),
247            _ => return Ok(None),
248        };
249        let is_local = matches!(local_v, Value::Bool(true));
250        // A NULL value resets the GUC to its default (PG), returning NULL.
251        let pval = match value_v {
252            Value::Text(s) => s.into_owned(),
253            Value::Null => {
254                self.session_params.remove(&pname.to_ascii_lowercase());
255                self.refresh_render_style();
256                return Ok(Some(single(Value::Null)));
257            }
258            _ => return Ok(None),
259        };
260        validate_known_guc(&pname, &pval)?;
261        if is_local {
262            if self.in_transaction() {
263                let prior = self.session_param(&pname).map(String::from);
264                self.local_guc_saves.push((pname.clone(), prior));
265                self.set_session_param(pname, spg_sql::ast::SetValue::String(pval.clone()));
266            }
267        } else {
268            self.set_session_param(pname, spg_sql::ast::SetValue::String(pval.clone()));
269        }
270        Ok(Some(single(Value::text(pval))))
271    }
272
273    /// v4.5 — write path with cooperative cancellation. Same dispatch
274    /// as `execute_in_with_cancel(sql, IMPLICIT_TX, cancel)`. Kept as
275    /// a separate entry point for backward-compat with the v4.5
276    /// public API.
277    pub fn execute_with_cancel(
278        &mut self,
279        sql: &str,
280        cancel: CancelToken<'_>,
281    ) -> Result<QueryResult, EngineError> {
282        self.execute_in_with_cancel(sql, IMPLICIT_TX, cancel)
283    }
284
285    /// v4.41.1 multi-slot write entry. Routes `sql` through the TX
286    /// slot identified by `tx_id` so spg-server dispatch can scope
287    /// each implicit-wrap BEGIN..stmt..COMMIT to its own slot in
288    /// `tx_catalogs`. `IMPLICIT_TX` is the legacy single-slot path
289    /// every other caller (engine self-tests, replay, spg-embedded)
290    /// implicitly takes via `execute()` / `execute_with_cancel()`.
291    pub fn execute_in(&mut self, sql: &str, tx_id: TxId) -> Result<QueryResult, EngineError> {
292        self.execute_in_with_cancel(sql, tx_id, CancelToken::none())
293    }
294
295    /// v4.41.1 write path with cooperative cancellation + explicit TX
296    /// scope. Sets `self.current_tx` for the duration of the call so
297    /// every `exec_*` helper transparently sees its TX's shadow
298    /// catalog and savepoint stack; restores on exit so the field is
299    /// only valid mid-call (no leakage across calls).
300    pub fn execute_in_with_cancel(
301        &mut self,
302        sql: &str,
303        tx_id: TxId,
304        cancel: CancelToken<'_>,
305    ) -> Result<QueryResult, EngineError> {
306        // v7.38 P0 元机制 A — establish the per-engine injection
307        // scope for the duration of this execute. The guard pops
308        // the store on drop so nested or sibling engines don't see
309        // ours. No-op in release builds (feature off).
310        let _inj = self.enter_injection_scope();
311        // v7.39 (read01 round 46) — NOTICEs are per-statement: clear the
312        // buffer here so one statement's "…, skipping" can never leak into
313        // the next one's NoticeResponse batch.
314        self.pending_notices.clear();
315        let saved = self.current_tx;
316        self.current_tx = Some(tx_id);
317        // v7.37.15 (Epic W slice 2) — memoized autocommit writer version
318        // is scoped to one statement. Save + reset like `current_tx` so
319        // a re-entrant execute (e.g. deferred trigger SQL) can't leak its
320        // version into ours, and ours never leaks to the next statement.
321        let saved_stmt_wv = self.stmt_writer_version;
322        self.stmt_writer_version = None;
323        // v7.34 (crash-recovery P0 #2) — row-level redo capture. Arm the
324        // active catalog before dispatch; on success drain the physical
325        // changes into `last_redo` for the embedding layer's WAL, on
326        // failure discard them (a failed statement leaves no redo; the
327        // drain clears the tables' capture buffers either way).
328        // v7.39 (round 736, S14/B3) — a delta-maintainable materialized
329        // view needs the same physical change stream the WAL reads, so
330        // its presence enables capture too (the fan-out below copies;
331        // `last_redo` stays the embedding layer's alone).
332        let matview_capture = !self.matview_maintainable.is_empty();
333        if self.redo_capture || matview_capture {
334            self.active_catalog_mut().enable_redo_all();
335        }
336        // v7.38 Epic P (panic isolation) — run statement execution
337        // behind a catch_unwind firewall so a panic in query
338        // processing surfaces as an ordinary `EngineError` (after
339        // rolling the in-flight tx back) instead of unwinding through
340        // the server's engine `RwLock` write guard (which would poison
341        // it) or aborting the process. NO-OP under the release
342        // `panic = "abort"` profile — the process aborts before any
343        // unwind reaches here; active in dev/test (`panic = "unwind"`)
344        // and once a later slice flips the release profile.
345        let pre_in_tx = self.in_transaction();
346        let result = self.execute_inner_catching(sql, cancel);
347        // v7.39 (round 426) — MySQL's ROW_COUNT() reads what the LAST
348        // statement did. Measured on MariaDB 11: a DML statement leaves the
349        // number of rows it changed (0 when it matched none), a
350        // row-returning statement leaves -1, and DDL leaves 0. One place,
351        // because every statement funnels through here — and it must be
352        // AFTER the dispatch, so ROW_COUNT()'s own SELECT is what sets -1
353        // for the call after it (as MariaDB does).
354        //
355        // A failed statement leaves the previous value alone: MariaDB keeps
356        // the last successful statement's count through an error.
357        if let Ok(res) = &result {
358            self.row_count = match res {
359                QueryResult::CommandOk { affected, .. } => i64::try_from(*affected).unwrap_or(-1),
360                QueryResult::Rows { .. } => -1,
361            };
362        }
363        // v7.39 (pg_stat knife A) — PG counts every statement outside a
364        // transaction block as one implicit xact (commit on success,
365        // rollback on error). Statements INSIDE a block are counted
366        // once, by exec_commit / exec_rollback; BEGIN itself (state
367        // flips outside -> inside) and the block-closers (inside ->
368        // outside, counted in their exec fns) are skipped here.
369        if !pre_in_tx && !self.in_transaction() {
370            let ctr = if result.is_ok() {
371                &self.xact_commit
372            } else {
373                &self.xact_rollback
374            };
375            ctr.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
376        }
377        // r196 — a statement that did NOT run inside its own open tx
378        // slot (autocommit, or a COMMIT/ROLLBACK that just closed its
379        // slot) may have moved the committed base; bump the epoch so
380        // OTHER open txs know their next RC rebase is real. The test
381        // must be per-statement (`tx_catalogs` membership of THIS
382        // call's tx_id), not the global `in_transaction()` — a
383        // concurrent autocommit while some tx is open is exactly the
384        // case the rebase exists for (the first cut used the global
385        // check and 10 isolation pins caught the missed bumps).
386        // Deliberately over-approximate (reads bump too — an extra
387        // rebase is only slower, never wrong).
388        if !self.tx_catalogs.contains_key(&tx_id) {
389            self.commit_epoch = self.commit_epoch.wrapping_add(1);
390            // v7.39 (round 306) — large-object descriptors live only as
391            // long as the transaction that opened them, so this is
392            // exactly where they die: an autocommit statement (the
393            // implicit transaction just ended) or the COMMIT / ROLLBACK
394            // that closed the slot. Numbering restarts from 0, as PG's
395            // does. Same per-slot witness as the epoch bump above —
396            // another connection's open transaction must not keep this
397            // one's descriptors alive.
398            self.lo_descriptors.clear();
399            self.lo_next_fd = 0;
400        }
401        if self.redo_capture || matview_capture {
402            let mut drained = self.active_catalog_mut().drain_redo();
403            if result.is_ok() {
404                if matview_capture {
405                    self.fan_out_matview_deltas(&drained);
406                }
407                // v7.37.15 (Epic W slice 2) — stamp the real committing
408                // writer version onto every change this statement
409                // produced. All changes from one statement share the one
410                // version (the statement's xmin/xmax): in autocommit it's
411                // the memoized value the writes already used; inside an
412                // explicit tx it's the deterministic tx entry. Purely
413                // additive metadata — replay still resolves by physical
414                // position and ignores `writer_version` (later slice).
415                if !drained.is_empty() {
416                    let v = self.writer_version_for_current_stmt();
417                    for change in &mut drained {
418                        change.set_writer_version(v);
419                    }
420                }
421                if self.redo_capture {
422                    self.last_redo = drained;
423                }
424            }
425        }
426        self.current_tx = saved;
427        self.stmt_writer_version = saved_stmt_wv;
428        result
429    }
430
431    /// v6.1.1 — parse and pre-process a SQL string ONCE so the
432    /// resulting [`Statement`] can be cached and re-executed via
433    /// [`Engine::execute_prepared`]. Returns the same `Statement`
434    /// the simple-query path would synthesise internally (clock
435    /// rewrites + ORDER BY position-ref resolution applied at
436    /// prepare time, since both are session-independent). The
437    /// `$N` placeholders in the SQL stay as `Expr::Placeholder(n)`
438    /// nodes; they're resolved to concrete values per-call by
439    /// `execute_prepared`'s substitution walk.
440    ///
441    /// Pgwire's `Parse` (P) message lands here.
442    pub fn prepare(&self, sql: &str) -> Result<Statement, ParseError> {
443        let mut stmt = parser::parse_statement_with(sql, self.backslash_escapes)?;
444        self.preprocess(&mut stmt);
445        Ok(stmt)
446    }
447
448    /// r1043 — every pre-pass a parsed statement gets before execution,
449    /// in one place.
450    ///
451    /// There were two copies. `prepare` had clock rewrites, `GROUP BY
452    /// ALL` expansion, ORDER BY position resolution and the JOIN reorder;
453    /// `execute_readonly_with_cancel` — the path EVERY autocommit SELECT
454    /// takes over the wire — had the same list minus the `GROUP BY ALL`
455    /// expansion, and then r1042 added constant folding to one of them.
456    ///
457    /// The result was a plan that `EXPLAIN` described and the wire did not
458    /// run: `WHERE b = decode(lpad(to_hex(7),16,'0'),'hex')` planned as an
459    /// index scan and took 194 ms, against 0.009 ms for the same statement
460    /// through the embedded API, on the same build and the same 400,000
461    /// rows. EXPLAIN went through `prepare`; the query did not.
462    ///
463    /// One function, both callers. A pass added here reaches every route
464    /// by construction rather than by remembering.
465    pub(crate) fn preprocess(&self, stmt: &mut Statement) {
466        let now_micros = self.clock.map(|f| f());
467        rewrite_clock_calls(
468            stmt,
469            now_micros,
470            self.backslash_escapes,
471            now_micros.map_or(0, |n| self.session_tz_offset_at(n)),
472        );
473        // r1042 — evaluate the constant parts of every predicate once,
474        // here, instead of once per row. A cast on a literal is the
475        // common case and it was costing an index seek: `WHERE id = 7`
476        // sought and `WHERE id = 7::int` scanned, 23x apart at 400k rows.
477        crate::constfold::fold_statement(stmt);
478        if let Statement::Select(s) = stmt {
479            // v6.4.1 — expand `GROUP BY ALL` to every non-aggregate
480            // SELECT-list item BEFORE position / alias resolution so
481            // downstream passes see the explicit list.
482            expand_group_by_all(s);
483            resolve_order_by_position(s);
484            // v6.2.3 — cost-based JOIN reorder. No-op for
485            // single-table FROMs or any non-INNER join shape.
486            // v7.38 元机制 D — `SPG_TEST_PLAN_DETERMINISTIC=1` gates
487            // this so regression tests pin a stable join order.
488            reorder::reorder_joins_with(
489                s,
490                &self.catalog,
491                &self.statistics,
492                self.env_cfg.plan_deterministic,
493            );
494        }
495    }
496
497    /// v6.3.0 — cached prepare. Returns a cloned `Statement` from
498    /// the plan cache on hit, runs the full `prepare()` path on miss
499    /// and inserts the resulting plan before returning. Skipping the
500    /// parse + JOIN-reorder pipeline on hit is the dominant win for
501    /// JDBC / sqlx / pgx clients that reuse the same SQL string.
502    ///
503    /// Returns a cloned `Statement` (not a borrow) because the
504    /// pgwire layer owns its `PreparedStmt` map per-session and the
505    /// engine-level cache must stay available for other sessions.
506    /// Clone cost on a 5-table JOIN AST is well under the parse cost
507    /// it replaces.
508    /// v7.39 (round 192) — bump the engine-side per-table DML
509    /// counters (pg_stat_user_tables n_tup_*). Non-transactional by
510    /// design, like PG's stats collector.
511    pub(crate) fn note_table_write(&mut self, table: &str, ins: u64, upd: u64, del: u64) {
512        let e = self
513            .table_write_stats
514            .entry(alloc::string::String::from(table))
515            .or_insert((0, 0, 0));
516        e.0 = e.0.saturating_add(ins);
517        e.1 = e.1.saturating_add(upd);
518        e.2 = e.2.saturating_add(del);
519    }
520
521    pub fn prepare_cached(&mut self, sql: &str) -> Result<Statement, ParseError> {
522        // v7.39 (round 200) — don't cache LARGE statements. A 24 KB
523        // multi-row VALUES INSERT paid a full AST deep-clone (~640 µs)
524        // just to enter the plan cache, where a unique bulk statement
525        // is never reused — and at that size a cache hit would only
526        // save the ~190 µs re-parse anyway. The threshold keeps every
527        // ORM-shaped statement (small, repeated) on the cached path.
528        const PLAN_CACHE_MAX_SQL_BYTES: usize = 4096;
529        if sql.len() > PLAN_CACHE_MAX_SQL_BYTES {
530            return self.prepare(sql);
531        }
532        // v6.3.1 — version-aware lookup. If the cached plan was
533        // prepared before the most recent ANALYZE, evict and replan.
534        let current_version = self.statistics.version();
535        if let Some(plan) = self.plan_cache.get(sql) {
536            if plan.statistics_version == current_version {
537                return Ok(plan.stmt.clone());
538            }
539            // Stale entry — fall through to evict + re-prepare.
540        }
541        self.plan_cache.evict(sql);
542        let stmt = self.prepare(sql)?;
543        let source_tables = plan_cache::collect_source_tables(&stmt);
544        let plan = plan_cache::PreparedPlan {
545            stmt: stmt.clone(),
546            statistics_version: current_version,
547            source_tables,
548            describe_columns: alloc::vec::Vec::new(),
549        };
550        self.plan_cache.insert(String::from(sql), plan);
551        Ok(stmt)
552    }
553
554    /// v6.3.0 — read-only accessor for tests and v6.3.1 invalidation.
555    pub fn plan_cache(&self) -> &plan_cache::PlanCache {
556        &self.plan_cache
557    }
558
559    /// v7.38 (mailrs prod 7.35 pool-exhaustion incident) — boot-time
560    /// plan-IR cache warm-up. Walks `sqls`, calls `prepare_cached`
561    /// on each one. Each successful prepare leaves the parsed +
562    /// reordered + clock-rewritten `Statement` in the engine-wide
563    /// plan cache; subsequent `Engine::execute` / `execute_prepared`
564    /// for the same SQL skips parse + JOIN reorder. Returns the
565    /// count of successfully cached statements.
566    ///
567    /// The mailrs `Database::new` boot path is the expected caller:
568    /// pre-warm the top-N query shapes (inbox listing, contacts
569    /// search, stats) so the first user-facing request doesn't
570    /// pay the 2-3 s first-fire cost on the readonly-blocking
571    /// sqlx pool — which (under prod concurrency) exhausts the
572    /// pool and stalls the whole UI.
573    pub fn warm_up_plan_cache(&mut self, sqls: &[&str]) -> usize {
574        let mut warmed = 0;
575        for sql in sqls {
576            if self.prepare_cached(sql).is_ok() {
577                warmed += 1;
578            }
579        }
580        warmed
581    }
582
583    /// v7.38 (mailrs prod 7.35 pool-exhaustion incident) — boot-time
584    /// cold-tier OS page-cache warm-up. Walks every table in the
585    /// active catalog, iterates the cold rows via the existing
586    /// BTree-driven `iter_cold_rows_of_table`, drops the rows on
587    /// the floor. The walk's side effect is that every cold
588    /// segment file gets mmap-read once — the OS page cache then
589    /// serves subsequent queries without disk I/O.
590    ///
591    /// Returns the total cold rows touched across all tables.
592    /// On a hot-only catalog (no `cold_segments` populated) the
593    /// call is a near-no-op.
594    pub fn warm_up_cold_tier(&self) -> usize {
595        let catalog = self.active_catalog();
596        let mut total = 0;
597        for name in catalog.table_names() {
598            if let Some(table) = catalog.get(&name) {
599                let rows = self.iter_cold_rows_of_table(table);
600                total += rows.len();
601            }
602        }
603        total
604    }
605
606    /// v6.3.0 — mutable accessor for v6.3.1 invalidation hooks.
607    pub fn plan_cache_mut(&mut self) -> &mut plan_cache::PlanCache {
608        &mut self.plan_cache
609    }
610
611    /// v6.3.3 — Describe a prepared `Statement` without executing.
612    /// Returns `(parameter_oids, output_columns)`. Empty
613    /// `output_columns` means the statement has no row-producing shape
614    /// we could resolve here — the pgwire layer maps that to `NoData`.
615    ///
616    /// v7.39 (round 462) — a SELECT over a system catalog view resolves
617    /// against the same materialised catalog execution builds, so the
618    /// two paths cannot disagree about what a system view looks like.
619    pub fn describe_prepared(&self, stmt: &Statement) -> (Vec<u32>, Vec<ColumnSchema>) {
620        if let Statement::Select(s) = stmt {
621            if crate::system_catalog::select_references_meta_view(s)
622                && let Ok(catalog) = self.meta_view_catalog(s)
623            {
624                return describe::describe_prepared(stmt, &catalog);
625            }
626            if let Some(catalog) = self.admin_view_catalog(s) {
627                return describe::describe_prepared(stmt, &catalog);
628            }
629        }
630        describe::describe_prepared(stmt, self.active_catalog())
631    }
632
633    /// v6.1.1 — execute a [`Statement`] previously returned by
634    /// [`Engine::prepare`], substituting `Expr::Placeholder(n)`
635    /// nodes for the corresponding [`Value`] in `params` (1-based
636    /// per PG: `$1` → `params[0]`). Bind-time string parameters
637    /// are decoded into typed `Value`s by the pgwire layer before
638    /// this call so the resulting AST hits the same execution
639    /// path as a simple query — no SQL re-parse.
640    ///
641    /// Pgwire's `Execute` (E) message after a `Bind` (B) lands here.
642    pub fn execute_prepared(
643        &mut self,
644        stmt: Statement,
645        params: &[Value<'static>],
646    ) -> Result<QueryResult, EngineError> {
647        self.execute_prepared_with_cancel(stmt, params, CancelToken::none())
648    }
649
650    /// v7.37 (SPGS small-query bar) — borrow-based SELECT entry for
651    /// the pgwire `Execute` hot path when the portal has no bound
652    /// parameters. Skips both the AST clone the prepared path used
653    /// to do at the pgwire call site AND the `substitute_
654    /// placeholders` walk (a no-op when params are empty). Caller
655    /// must already hold the engine write lock — read would be
656    /// cleaner, but `current_tx` mutation keeps it `&mut`.
657    pub fn execute_prepared_select_no_params(
658        &mut self,
659        stmt: &spg_sql::ast::SelectStatement,
660        cancel: CancelToken<'_>,
661    ) -> Result<QueryResult, EngineError> {
662        let saved = self.current_tx;
663        self.current_tx = Some(IMPLICIT_TX);
664        // v7.38 Epic P (panic isolation) — Slice 3: route this read-only
665        // prepared-SELECT hot path (pgwire `Execute` with no bound params)
666        // through the SAME `catch_unwind` firewall as the write paths. A
667        // panic in `exec_select_cancel` is caught inside the engine and
668        // returned as `EngineError::Internal`, so it never unwinds through
669        // the caller's engine `RwLock` write guard (poisoning it) or aborts
670        // the process. This path is read-only, so the firewall's
671        // `discard_tx_on_panic` is a no-op (no shadow / writer version to
672        // drop) — exactly right: nothing to roll back, just catch + survive.
673        // `exec_select_cancel` materialises its `QueryResult` synchronously,
674        // so the whole result is produced inside the catch (statement
675        // boundary only — no per-row cost).
676        #[cfg(feature = "std")]
677        let result = self.catch_stmt_panic(|s| s.exec_select_cancel(stmt, cancel));
678        #[cfg(not(feature = "std"))]
679        let result = self.exec_select_cancel(stmt, cancel);
680        self.current_tx = saved;
681        result
682    }
683
684    /// v7.37.17 — `SHOW <name>` / `SHOW ALL`. Extracted (r1058) so the
685    /// READ-ONLY dispatcher can serve it too: the wire routes SHOW as a
686    /// read, and its fallthrough (unknown-to-the-wire names) landed on
687    /// `WriteRequired` instead of this answer.
688    pub(crate) fn exec_show_parameter(
689        &self,
690        name: alloc::string::String,
691    ) -> Result<QueryResult, EngineError> {
692        use spg_storage::{ColumnSchema, DataType, Row, Value};
693        // v7.37.17 (17.6 sibling) — `SHOW ALL` returns a
694        // (name, setting, description) triple for every
695        // parameter SPG knows about. PG's shape is the same.
696        // Emitting a fixed curated inventory here keeps the
697        // client shape stable without wire-tapping every
698        // per-session parameter.
699        // v7.38 (read01 P3.20/P3.23) — SHOW reads the same canonical
700        // GUC inventory as pg_settings, so `SHOW <name>` / `SHOW ALL`
701        // and pg_settings never disagree on which params exist.
702        let canon = crate::system_catalog::canonical_gucs();
703        let effective = |n: &str, boot: &str| -> alloc::string::String {
704            self.session_params
705                .iter()
706                .find(|(k, _)| k.eq_ignore_ascii_case(n))
707                .map(|(_, v)| v.clone())
708                .unwrap_or_else(|| boot.into())
709        };
710        if name.eq_ignore_ascii_case("all") {
711            let cols = alloc::vec![
712                ColumnSchema::new("name", DataType::Text, false),
713                ColumnSchema::new("setting", DataType::Text, false),
714                ColumnSchema::new("description", DataType::Text, false),
715            ];
716            let mut rows: Vec<Row> = Vec::new();
717            // Dynamic params outside the static canonical table.
718            rows.push(Row::new(alloc::vec![
719                Value::text(alloc::string::String::from("transaction_isolation")),
720                Value::text(alloc::string::String::from(
721                    self.current_isolation_level.as_pg_str(),
722                )),
723                Value::text(alloc::string::String::from(
724                    "Shows the current transaction's isolation level.",
725                )),
726            ]));
727            rows.push(Row::new(alloc::vec![
728                Value::text(alloc::string::String::from("is_superuser")),
729                Value::text(alloc::string::String::from("on")),
730                Value::text(alloc::string::String::from("Reports superuser status.")),
731            ]));
732            for (n, boot, cat, _, _) in canon {
733                rows.push(Row::new(alloc::vec![
734                    Value::text(alloc::string::String::from(*n)),
735                    Value::text(effective(n, boot)),
736                    Value::text(alloc::string::String::from(*cat)),
737                ]));
738            }
739            return Ok(QueryResult::Rows {
740                columns: cols,
741                rows,
742            });
743        }
744        let value: alloc::string::String = match name.to_ascii_lowercase().as_str() {
745            "transaction_isolation" => {
746                alloc::string::String::from(self.current_isolation_level.as_pg_str())
747            }
748            "is_superuser" => alloc::string::String::from("on"),
749            _ => {
750                // Canonical GUC? report the session override or its
751                // boot default. Otherwise a user-set custom GUC, or a
752                // recognised-name error pointing at pg_settings.
753                if let Some((_, boot, ..)) =
754                    canon.iter().find(|(n, ..)| n.eq_ignore_ascii_case(&name))
755                {
756                    effective(&name, boot)
757                } else if let Some(v) = self.session_param(&name) {
758                    alloc::string::String::from(v)
759                } else if let Some(boot) = crate::guc_catalog::guc_boot_value(&name) {
760                    // v7.39 (round 534) — a parameter PG18 knows but
761                    // SPG does not model reports its compiled-in
762                    // default. `SHOW random_page_cost` printed
763                    // nothing at all before, and `SHOW fsync` with
764                    // it.
765                    alloc::string::String::from(boot)
766                } else {
767                    return Err(EngineError::Unsupported(alloc::format!(
768                        "SHOW {name:?}: parameter not recognised; \
769                             see `SELECT name, setting FROM pg_settings` for \
770                             the full inventory"
771                    )));
772                }
773            }
774        };
775        Ok(QueryResult::Rows {
776            columns: alloc::vec![ColumnSchema::new(name, DataType::Text, false)],
777            rows: alloc::vec![Row::new(alloc::vec![Value::text(value)])],
778        })
779    }
780
781    /// v7.37 — streaming SELECT for the pgwire `Execute` hot path.
782    /// Emits one `StreamItem::Header(cols)` then one
783    /// `StreamItem::Row(&[&Value])` per surviving row. Returns the
784    /// total row count for the `CommandComplete` tag.
785    ///
786    /// For shapes where the engine can stream directly (non-aggregate
787    /// join projection of bound columns, no ORDER BY / DISTINCT / etc.)
788    /// no `Vec<Row<'static>>` is materialised — cell references come straight
789    /// out of the source tables. For non-streamable shapes the engine
790    /// runs the full `exec_select_cancel`, then walks the materialised
791    /// `Vec<Row<'static>>` driving the same emit callback (no engine-side win,
792    /// but pgwire dispatches every Execute through one path).
793    pub fn execute_prepared_select_streaming<F>(
794        &mut self,
795        stmt: &spg_sql::ast::SelectStatement,
796        cancel: CancelToken<'_>,
797        mut emit: F,
798    ) -> Result<usize, EngineError>
799    where
800        F: FnMut(StreamItem<'_>) -> Result<(), EngineError>,
801    {
802        let saved = self.current_tx;
803        self.current_tx = Some(IMPLICIT_TX);
804        // v7.38 Epic P (panic isolation) — Slice 3: route the streaming
805        // read-only SELECT hot path through the SAME `catch_unwind` firewall.
806        //
807        // Catch SCOPE (verified): `exec_select_streaming` uses a *push*
808        // model — it drives the caller's `emit` callback synchronously via
809        // `?` for the header and every row (both the true-streaming
810        // `try_exec_joined_streaming` fast path and the materialising
811        // fall-back), and only returns once the whole result has been
812        // emitted. It does NOT hand a lazy iterator back to the wire layer to
813        // pull rows from later. Therefore a panic in the per-row streaming
814        // phase unwinds *inside* this call and IS caught by wrapping the one
815        // `exec_select_streaming` call — the entire streaming phase is
816        // covered, not just setup. This is a single statement-boundary catch
817        // (the `catch_unwind` landing pad is armed once, the whole emit loop
818        // runs inside it) — NOT a per-row catch, so there is no hot-path cost.
819        // Read-only, so `discard_tx_on_panic` is a no-op (correct: nothing to
820        // roll back). A panic caught mid-stream (after some rows were encoded
821        // into the wire buffer) leaves the same partial-`wbuf` + `Err` state
822        // the wire layer already handles when `emit` itself returns `Err`
823        // mid-stream, so no new torn-state concern is introduced.
824        #[cfg(feature = "std")]
825        let inner = self.catch_stmt_panic(|s| s.exec_select_streaming(stmt, cancel, &mut emit));
826        #[cfg(not(feature = "std"))]
827        let inner = self.exec_select_streaming(stmt, cancel, &mut emit);
828        self.current_tx = saved;
829        inner
830    }
831
832    /// v7.37 — internal streaming dispatcher. Phase 1: fall-back path
833    /// only — runs the materialising `exec_select_cancel`, then drives
834    /// the emit callback from the resulting `Vec<Row<'static>>`. Phase 2 will
835    /// add a true streaming path for the joined-projection shape.
836    fn exec_select_streaming<F>(
837        &mut self,
838        stmt: &spg_sql::ast::SelectStatement,
839        cancel: CancelToken<'_>,
840        emit: &mut F,
841    ) -> Result<usize, EngineError>
842    where
843        F: FnMut(StreamItem<'_>) -> Result<(), EngineError>,
844    {
845        // v7.37 — true-streaming fast path for joined-non-aggregate
846        // projection of bound columns. Skips `Vec<Row<'static>>` + per-cell
847        // `.cloned()` (about 4 ms saved on the 25 k-row PROJ shape).
848        // Unresolved subqueries / pull-up shapes / non-streamable
849        // structure (ORDER BY, DISTINCT, …) fall through to the
850        // materialising path.
851        if !crate::subquery::expr_tree_has_subquery(stmt) {
852            if let Some(n) = self.try_exec_joined_streaming(stmt, cancel, emit)? {
853                return Ok(n);
854            }
855        }
856        // Fall-back: materialise then iterate.
857        let QueryResult::Rows { columns, rows } = self.exec_select_cancel(stmt, cancel)? else {
858            return Err(EngineError::Unsupported(alloc::string::String::from(
859                "streaming SELECT got a non-Rows result",
860            )));
861        };
862        emit_materialised(&columns, &rows, cancel, emit)
863    }
864}
865
866/// Hand an already-materialised result to a streaming consumer: one
867/// `Header`, then every row, checking for cancellation as it goes.
868///
869/// v7.37 (round 824) — this loop existed three times, in
870/// `exec_select_streaming` and twice in the read-only entry points, and
871/// none of the three checked cancellation. A `statement_timeout` — and
872/// `CancelRequest`, which shares the token — therefore did not bound any
873/// shape the streaming path declines: arithmetic and function
874/// projections, `ORDER BY`, `DISTINCT`. Measured over 200k rows of 200
875/// bytes under a 120ms timeout, every one of them ran to completion,
876/// all 200000 rows, no error.
877///
878/// The loop reads like the cheap half of the work, since the rows
879/// already exist. It is not: handing them to `emit` is what encodes them
880/// and pushes them at the socket, and that is most of the elapsed time
881/// (first row out at 30ms of 400ms). So the interruption a client asked
882/// for never happened, and it never happened for the shapes most likely
883/// to need it.
884///
885/// It is one function now so that the next copy cannot go missing the
886/// check — which is how all three came to be missing it.
887pub(crate) fn emit_materialised<F>(
888    columns: &[ColumnSchema],
889    rows: &[spg_storage::Row<'static>],
890    cancel: CancelToken<'_>,
891    emit: &mut F,
892) -> Result<usize, EngineError>
893where
894    F: FnMut(StreamItem<'_>) -> Result<(), EngineError>,
895{
896    emit(StreamItem::Header(columns))?;
897    let mut cell_refs: Vec<&Value> = Vec::with_capacity(columns.len());
898    for (i, row) in rows.iter().enumerate() {
899        // Same cadence as the streaming path's own check.
900        if i.is_multiple_of(256) {
901            cancel.check()?;
902        }
903        cell_refs.clear();
904        for v in &row.values {
905            cell_refs.push(v);
906        }
907        emit(StreamItem::Row(RowCells::Refs(&cell_refs)))?;
908    }
909    Ok(rows.len())
910}
911
912/// One row's cells, in whichever shape the producer already holds them.
913///
914/// The channel used to be `&[&Value]` only, which cost a `Vec<&Value>`
915/// per row at the two producers that build their cells into a
916/// contiguous buffer: they had a `&[Value]` in hand and collected a
917/// second vector of pointers into it purely to satisfy the type. That
918/// is one heap allocation and one free per row — measured at 400k rows
919/// (round 957) as **9 ns/row**, which on a narrow scan was 54-56% of
920/// the whole walk and on a wide one 8-19%.
921///
922/// The reason it could not simply reuse one buffer is that the values
923/// buffer is refilled each row, so any pointers into it die at the top
924/// of the next iteration; only an owner of the storage (the
925/// materialising path, whose rows outlive the loop) can hoist the
926/// pointer vector out. Handing the contiguous slice over directly
927/// removes the question instead of answering it.
928///
929/// `Refs` stays for producers whose cells really are scattered (a join
930/// projecting out of several rows).
931#[derive(Debug, Clone, Copy)]
932pub enum RowCells<'a> {
933    Refs(&'a [&'a Value<'static>]),
934    Values(&'a [Value<'static>]),
935}
936
937impl<'a> RowCells<'a> {
938    pub fn len(&self) -> usize {
939        match self {
940            RowCells::Refs(v) => v.len(),
941            RowCells::Values(v) => v.len(),
942        }
943    }
944
945    pub fn is_empty(&self) -> bool {
946        self.len() == 0
947    }
948
949    pub fn get(&self, i: usize) -> Option<&'a Value<'static>> {
950        match self {
951            RowCells::Refs(v) => v.get(i).copied(),
952            RowCells::Values(v) => v.get(i),
953        }
954    }
955}
956
957/// v7.37 — one item in the streaming SELECT emit channel. The
958/// engine yields exactly one `Header` (before any row) then zero
959/// or more `Row`s. Pgwire (or any other consumer) decides how to
960/// turn those into wire bytes.
961#[derive(Debug)]
962pub enum StreamItem<'a> {
963    Header(&'a [ColumnSchema]),
964    Row(RowCells<'a>),
965}
966
967impl Engine {
968    /// v7.17.0 Phase 2.3 — prepared-statement entry that honors a
969    /// caller-supplied `CancelToken`. Mirrors `execute_prepared`'s
970    /// `current_tx` save/restore so the extended-query path stays
971    /// transactionally consistent with the simple-query path.
972    /// v7.39 (round 280) — `CREATE STATISTICS`.
973    fn exec_create_statistics(
974        &mut self,
975        name: String,
976        if_not_exists: bool,
977        kinds: alloc::vec::Vec<String>,
978        columns: alloc::vec::Vec<String>,
979        table: String,
980    ) -> Result<QueryResult, EngineError> {
981        if columns.len() < 2 {
982            return Err(EngineError::Unsupported(String::from(
983                "extended statistics require at least 2 columns",
984            )));
985        }
986        if self.active_catalog().get(&table).is_none() {
987            return Err(EngineError::Unsupported(alloc::format!(
988                "relation \"{table}\" does not exist"
989            )));
990        }
991        // PG's default kind set is all three.
992        let kinds = if kinds.is_empty() {
993            alloc::vec![String::from("d"), String::from("f"), String::from("m")]
994        } else {
995            kinds
996        };
997        let def = spg_storage::StatisticsExtDef {
998            name: name.clone(),
999            table,
1000            kinds,
1001            columns,
1002        };
1003        let cat = self.active_catalog_mut();
1004        if let Err(taken) = cat.create_statistics_ext(def) {
1005            if if_not_exists {
1006                return Ok(QueryResult::CommandOk {
1007                    affected: 0,
1008                    modified_catalog: false,
1009                });
1010            }
1011            return Err(EngineError::Unsupported(alloc::format!(
1012                "statistics object \"{taken}\" already exists"
1013            )));
1014        }
1015        Ok(QueryResult::CommandOk {
1016            affected: 0,
1017            modified_catalog: true,
1018        })
1019    }
1020
1021    /// v7.39 (round 280) — `DROP STATISTICS`.
1022    fn exec_drop_statistics(
1023        &mut self,
1024        name: &str,
1025        if_exists: bool,
1026    ) -> Result<QueryResult, EngineError> {
1027        let dropped = self.active_catalog_mut().drop_statistics_ext(name);
1028        if !dropped && !if_exists {
1029            return Err(EngineError::Unsupported(alloc::format!(
1030                "statistics object \"{name}\" does not exist"
1031            )));
1032        }
1033        Ok(QueryResult::CommandOk {
1034            affected: 0,
1035            modified_catalog: dropped,
1036        })
1037    }
1038
1039    /// v7.39 (round 277) — `PREPARE`. Session-scoped, and a duplicate
1040    /// name is an error in PG rather than a silent replace.
1041    fn exec_prepare(
1042        &mut self,
1043        name: String,
1044        param_types: alloc::vec::Vec<String>,
1045        body: Statement,
1046        source: String,
1047    ) -> Result<QueryResult, EngineError> {
1048        if self.prepared_statements.contains_key(&name) {
1049            return Err(EngineError::Unsupported(alloc::format!(
1050                "prepared statement \"{name}\" already exists"
1051            )));
1052        }
1053        // v7.38.4 (sentori 6a, at their request) — PG refuses a PREPARE
1054        // whose parameter cannot be deduced consistently. Their
1055        // assert-stats upsert puts `$4` in a bigint column and again in
1056        // `CASE WHEN $4 > 0`, where the literal is integer: PG answers
1057        // "inconsistent types deduced for parameter $4". SPG let the
1058        // last context win silently.
1059        //
1060        // Only when the type was NOT declared. `PREPARE p (…, bigint, …)`
1061        // is accepted by PG and runs, and sqlx always declares — which is
1062        // why the statement works in their production and why refusing a
1063        // declared parameter would break every driver that does the
1064        // right thing.
1065        if let Some((n, first, second)) =
1066            crate::describe::conflicting_parameter_deductions(&body, self.active_catalog())
1067            && param_types.get((n as usize).saturating_sub(1)).is_none()
1068        {
1069            return Err(EngineError::Unsupported(alloc::format!(
1070                "inconsistent types deduced for parameter ${n} DETAIL: {first} versus {second}"
1071            )));
1072        }
1073        self.prepared_statements.insert(
1074            name,
1075            crate::PreparedSqlStatement {
1076                body,
1077                param_types,
1078                source,
1079            },
1080        );
1081        Ok(QueryResult::CommandOk {
1082            affected: 0,
1083            modified_catalog: false,
1084        })
1085    }
1086
1087    /// v7.39 (round 277) — `EXECUTE`. The arguments evaluate as
1088    /// constants and splice into the body's `$N` placeholders through
1089    /// the same `execute_prepared_with_cancel` the extended-query path
1090    /// uses, so a SQL EXECUTE and a wire Bind take the identical route.
1091    fn exec_execute(
1092        &mut self,
1093        name: &str,
1094        args: &[spg_sql::ast::Expr],
1095        cancel: CancelToken<'_>,
1096    ) -> Result<QueryResult, EngineError> {
1097        let Some(entry) = self.prepared_statements.get(name) else {
1098            return Err(EngineError::Unsupported(alloc::format!(
1099                "prepared statement \"{name}\" does not exist"
1100            )));
1101        };
1102        let body = entry.body.clone();
1103        let empty: alloc::vec::Vec<spg_storage::ColumnSchema> = alloc::vec::Vec::new();
1104        let ctx = self.ev_ctx(&empty, None);
1105        let blank = spg_storage::Row::new(alloc::vec::Vec::new());
1106        let mut params: alloc::vec::Vec<spg_storage::Value<'static>> =
1107            alloc::vec::Vec::with_capacity(args.len());
1108        for a in args {
1109            params.push(crate::eval::eval_expr(a, &blank, &ctx).map_err(EngineError::Eval)?);
1110        }
1111        self.execute_prepared_with_cancel(body, &params, cancel)
1112    }
1113
1114    /// v7.39 (round 277) — `DEALLOCATE <name>` / `DEALLOCATE ALL`.
1115    /// Dropping a name that does not exist is an error in PG; ALL is
1116    /// unconditional.
1117    fn exec_deallocate(&mut self, name: Option<&str>) -> Result<QueryResult, EngineError> {
1118        match name {
1119            None => {
1120                self.prepared_statements.clear();
1121                Ok(QueryResult::CommandOk {
1122                    affected: 0,
1123                    modified_catalog: false,
1124                })
1125            }
1126            Some(n) => {
1127                if self.prepared_statements.remove(n).is_none() {
1128                    return Err(EngineError::Unsupported(alloc::format!(
1129                        "prepared statement \"{n}\" does not exist"
1130                    )));
1131                }
1132                Ok(QueryResult::CommandOk {
1133                    affected: 0,
1134                    modified_catalog: false,
1135                })
1136            }
1137        }
1138    }
1139
1140    pub fn execute_prepared_with_cancel(
1141        &mut self,
1142        stmt: Statement,
1143        params: &[Value<'static>],
1144        cancel: CancelToken<'_>,
1145    ) -> Result<QueryResult, EngineError> {
1146        self.execute_prepared_in_with_cancel(stmt, params, IMPLICIT_TX, cancel)
1147    }
1148
1149    /// v7.39 (round 303, V22) — like [`Self::execute_prepared_with_cancel`]
1150    /// but binds the statement to an explicit transaction slot instead of
1151    /// the implicit one. The mysql-wire binary-protocol path uses this so a
1152    /// prepared INSERT/UPDATE lands in the connection's own `BEGIN`-opened
1153    /// transaction (and never collides with another connection on slot 0),
1154    /// mirroring what pgwire's `Bind`+`Execute` achieves by rendering
1155    /// bind-final SQL through [`Self::execute_in`].
1156    pub fn execute_prepared_in(
1157        &mut self,
1158        stmt: Statement,
1159        params: &[Value<'static>],
1160        tx_id: TxId,
1161    ) -> Result<QueryResult, EngineError> {
1162        self.execute_prepared_in_with_cancel(stmt, params, tx_id, CancelToken::none())
1163    }
1164
1165    pub fn execute_prepared_in_with_cancel(
1166        &mut self,
1167        mut stmt: Statement,
1168        params: &[Value<'static>],
1169        tx_id: TxId,
1170        cancel: CancelToken<'_>,
1171    ) -> Result<QueryResult, EngineError> {
1172        substitute_placeholders(&mut stmt, params)?;
1173        // v7.16.0 — set `current_tx` for the duration of the
1174        // dispatch so the `exec_*` helpers see the right TX
1175        // slot (matches what `execute_in_with_cancel` does for
1176        // simple-query). Pre-v7.16 the simple-query path
1177        // worked because every public entry point routed
1178        // through `execute_in_with_cancel`; the prepared path
1179        // skipped the wrap and so its INSERTs/UPDATEs landed
1180        // in the no-tx default slot, silently invisible to a
1181        // BEGIN/COMMIT-bracketed flow. Caught by spg-sqlx's
1182        // first transaction-visibility test.
1183        let saved = self.current_tx;
1184        self.current_tx = Some(tx_id);
1185        // v7.38 Epic P (panic isolation) — Slice 2: route the
1186        // prepared / extended-query path (the one sqlx / asyncpg / most
1187        // drivers actually use via pgwire `Bind`+`Execute`) through the
1188        // SAME `catch_unwind` firewall as the simple-query path (Slice 1,
1189        // `execute_inner_catching`). A panic in an extended-protocol
1190        // statement is caught inside the engine, the in-flight tx is
1191        // rolled back (shared `discard_tx_on_panic`), and the caller sees
1192        // an ordinary `EngineError::Internal` — never a poisoned write
1193        // guard or an aborted process. `current_tx` is `Some(IMPLICIT_TX)`
1194        // for the duration, so a caught panic rolls back the right tx; the
1195        // `saved` restore below still runs because the catch converts the
1196        // unwind into a normal `Result` return.
1197        let result = self.execute_stmt_catching(stmt, cancel);
1198        self.current_tx = saved;
1199        // r1059 — the r196 per-slot epoch witness, on THIS entry path
1200        // too. The bump lived only in `execute_in_with_cancel`, so an
1201        // autocommit write arriving over the extended protocol's
1202        // direct route moved the committed base with the epoch
1203        // unchanged; a concurrent RC transaction whose last rebase
1204        // matched the stale epoch then skipped its commit-time rebase
1205        // and installed its whole shadow — erasing the write. The
1206        // sqlx gate flaked ~1-in-6 on exactly this: DROP/CREATE
1207        // vanishing under a neighbouring transaction's COMMIT. Same
1208        // over-approximation as r196 (an extra rebase is only slower,
1209        // never wrong); large-object descriptors die at the same
1210        // boundary for the same per-slot reason (round 306).
1211        if !self.tx_catalogs.contains_key(&tx_id) {
1212            self.commit_epoch = self.commit_epoch.wrapping_add(1);
1213            self.lo_descriptors.clear();
1214            self.lo_next_fd = 0;
1215        }
1216        result
1217    }
1218
1219    /// v7.38 Epic P (panic isolation) — shared `catch_unwind` firewall
1220    /// (hosted `std` builds) used by every engine statement entry path: the
1221    /// simple-query ([`Self::execute_inner_catching`]), the prepared /
1222    /// extended-query ([`Self::execute_stmt_catching`]), and the read-only
1223    /// prepared-SELECT hot paths ([`Self::execute_prepared_select_no_params`]
1224    /// / [`Self::execute_prepared_select_streaming`]). Runs `run` under
1225    /// `catch_unwind`; a panic that unwinds out of statement execution is
1226    /// caught here and converted to [`EngineError::Internal`] after
1227    /// discarding the in-flight tx's shadow, so the caller sees a normal SQL
1228    /// error and the engine stays alive. This is the single place the
1229    /// rollback-on-panic policy lives — neither entry path reimplements it.
1230    ///
1231    /// **Why the post-catch engine state is consistent (COW shadow argument):**
1232    /// every uncommitted write of the panicked statement lives in
1233    /// `tx_catalogs[current_tx].catalog` — a per-tx *shadow* catalog that is
1234    /// only merged into the committed `self.catalog` at COMMIT (see
1235    /// `exec_commit`). The committed catalog is therefore never touched
1236    /// mid-statement, so dropping the shadow (mirroring `exec_rollback`)
1237    /// discards all half-applied work and leaves `self.catalog` exactly as it
1238    /// was before the statement. Redo-capture buffers live inside the
1239    /// shadow's tables and die with it, so no partial `RowChange` leaks into
1240    /// `last_redo` either (the caller publishes `last_redo` only on `Ok`).
1241    ///
1242    /// The `catch_unwind` closure holds `&mut self`; wrapping it in
1243    /// `AssertUnwindSafe` is sound precisely because of the above — the only
1244    /// caller-visible state a caught panic can leave behind is the discarded
1245    /// shadow, which is the correct rollback outcome, not a torn invariant.
1246    ///
1247    /// Generic over the closure's success type `T` so the read-only
1248    /// prepared-SELECT paths (which return a row count `usize`, not a
1249    /// `QueryResult`) reuse the *same* firewall — no second `catch_unwind`
1250    /// site. On those read-only paths `discard_tx_on_panic` is a no-op (a
1251    /// SELECT opens no shadow / writer version), which is the correct outcome:
1252    /// nothing to roll back, the point is purely to catch the unwind, return
1253    /// `Internal`, and leave the caller's write guard un-poisoned.
1254    #[cfg(feature = "std")]
1255    fn catch_stmt_panic<T>(
1256        &mut self,
1257        run: impl FnOnce(&mut Self) -> Result<T, EngineError>,
1258    ) -> Result<T, EngineError> {
1259        extern crate std;
1260        let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(self)));
1261        match caught {
1262            Ok(result) => result,
1263            Err(payload) => {
1264                // The panic unwound past every `?`-return in the executor.
1265                // `current_tx` is Some here (set by the caller); roll that tx
1266                // back by discarding its shadow. A statement that panicked in
1267                // autocommit before any shadow was opened simply has nothing
1268                // to drop (`discard_tx_on_panic` is infallible).
1269                let tx_id = self.current_tx.unwrap_or(IMPLICIT_TX);
1270                self.discard_tx_on_panic(tx_id);
1271                Err(panic_payload_to_engine_error(payload.as_ref()))
1272            }
1273        }
1274    }
1275
1276    /// v7.38 Epic P (panic isolation) — simple-query path wrapper: run
1277    /// [`Self::execute_inner_with_cancel`] behind the shared
1278    /// [`Self::catch_stmt_panic`] firewall.
1279    #[cfg(feature = "std")]
1280    fn execute_inner_catching(
1281        &mut self,
1282        sql: &str,
1283        cancel: CancelToken<'_>,
1284    ) -> Result<QueryResult, EngineError> {
1285        self.catch_stmt_panic(|s| s.execute_inner_with_cancel(sql, cancel))
1286    }
1287
1288    /// `no_std` variant — there is no unwinding runtime, so statement
1289    /// execution runs directly with no catch.
1290    #[cfg(not(feature = "std"))]
1291    fn execute_inner_catching(
1292        &mut self,
1293        sql: &str,
1294        cancel: CancelToken<'_>,
1295    ) -> Result<QueryResult, EngineError> {
1296        self.execute_inner_with_cancel(sql, cancel)
1297    }
1298
1299    /// v7.38 Epic P (panic isolation) — Slice 2: prepared / extended-query
1300    /// path wrapper. The extended-protocol path already holds a resolved
1301    /// [`Statement`] (no re-parse), so it cannot reuse the `&str`-taking
1302    /// [`Self::execute_inner_catching`]; instead it runs
1303    /// [`Self::execute_stmt_with_cancel`] behind the SAME shared
1304    /// [`Self::catch_stmt_panic`] firewall — identical rollback + error
1305    /// semantics, zero duplicated policy.
1306    #[cfg(feature = "std")]
1307    fn execute_stmt_catching(
1308        &mut self,
1309        stmt: Statement,
1310        cancel: CancelToken<'_>,
1311    ) -> Result<QueryResult, EngineError> {
1312        self.catch_stmt_panic(|s| s.execute_stmt_with_cancel(stmt, cancel))
1313    }
1314
1315    /// `no_std` variant — there is no unwinding runtime, so statement
1316    /// execution runs directly with no catch.
1317    #[cfg(not(feature = "std"))]
1318    fn execute_stmt_catching(
1319        &mut self,
1320        stmt: Statement,
1321        cancel: CancelToken<'_>,
1322    ) -> Result<QueryResult, EngineError> {
1323        self.execute_stmt_with_cancel(stmt, cancel)
1324    }
1325
1326    /// v7.38 Epic P — discard an in-flight tx's shadow after a caught panic,
1327    /// mirroring the state cleanup of [`Engine::exec_rollback`] but
1328    /// infallibly. Drops the shadow catalog, marks the tx's writer version
1329    /// aborted, and releases its row locks. Leaves the committed
1330    /// `self.catalog` untouched (the COW model kept every uncommitted change
1331    /// inside the shadow), so this is a full rollback of the panicked
1332    /// statement's work.
1333    #[cfg(feature = "std")]
1334    fn discard_tx_on_panic(&mut self, tx_id: TxId) {
1335        self.tx_catalogs.remove(&tx_id);
1336        if let Some(v) = self.tx_writer_versions.remove(&tx_id) {
1337            self.abort_writer_version(v);
1338            self.release_tx_locks(v);
1339        }
1340        // Per-statement scratch: reset so no stale writer version leaks into
1341        // the next statement (the caller also restores the saved value).
1342        self.stmt_writer_version = None;
1343    }
1344
1345    fn execute_inner_with_cancel(
1346        &mut self,
1347        sql: &str,
1348        cancel: CancelToken<'_>,
1349    ) -> Result<QueryResult, EngineError> {
1350        cancel.check()?;
1351        let stmt = self.prepare(sql)?;
1352        // v6.5.1 — wrap the executor with a wall-clock window so we
1353        // can record into spg_stat_query. Skip when the engine has
1354        // no clock attached (no_std embedded callers).
1355        let start_us = self.clock.map(|f| f());
1356        let result = self.execute_stmt_with_cancel(stmt, cancel);
1357        if let (Some(t0), Ok(ok)) = (start_us, &result) {
1358            let now = self.clock.map_or(t0, |f| f());
1359            let elapsed = now.saturating_sub(t0).max(0) as u64;
1360            // v7.37.22 (22.9) — count rows produced (SELECT) or
1361            // affected (INSERT/UPDATE/DELETE) so pg_stat_statements'
1362            // `rows` column populates accurately.
1363            let row_count: u64 = match ok {
1364                QueryResult::Rows { rows, .. } => rows.len() as u64,
1365                QueryResult::CommandOk { affected, .. } => *affected as u64,
1366            };
1367            self.query_stats
1368                .record_with_rows(sql, elapsed, now as u64, row_count);
1369            // v6.5.6 — slow-query log: fire callback when elapsed
1370            // exceeds the configured floor.
1371            if let (Some(threshold), Some(logger)) =
1372                (self.slow_query_threshold_us, self.slow_query_logger)
1373                && elapsed >= threshold
1374            {
1375                logger(sql, elapsed);
1376            }
1377        }
1378        result
1379    }
1380
1381    /// v7.38 (read01 P3.26) — transaction-abort firewall around the raw
1382    /// statement dispatch. After a statement fails inside an explicit
1383    /// transaction PG aborts the whole block: every later statement except
1384    /// COMMIT / ROLLBACK / ROLLBACK TO SAVEPOINT is rejected, and a COMMIT
1385    /// is downgraded to a ROLLBACK so no partial work slips through. We
1386    /// mirror that here so both the embedded engine and the wire server
1387    /// enforce it uniformly.
1388    pub(crate) fn execute_stmt_with_cancel(
1389        &mut self,
1390        stmt: Statement,
1391        cancel: CancelToken<'_>,
1392    ) -> Result<QueryResult, EngineError> {
1393        // v7.39 (round 298) — ask THIS transaction, not "is any
1394        // transaction anywhere aborted".
1395        if self.current_tx_aborted() {
1396            match stmt {
1397                Statement::Rollback | Statement::RollbackToSavepoint(_) => {}
1398                // PG performs a ROLLBACK for a COMMIT in an aborted tx.
1399                Statement::Commit => {
1400                    let r = self.dispatch_stmt_inner(Statement::Rollback, cancel);
1401                    self.set_current_tx_aborted(false);
1402                    return r;
1403                }
1404                _ => return Err(EngineError::InFailedTransaction),
1405            }
1406        }
1407        let is_rollback_to_savepoint = matches!(stmt, Statement::RollbackToSavepoint(_));
1408        // v7.37.17 (Phase E2) — READ COMMITTED per-statement visibility:
1409        // classify (the statement moves into dispatch below), rebase the
1410        // open RC tx's shadow onto the latest committed catalog, then
1411        // record the statement's targets afterwards. Both calls are
1412        // no-ops outside an explicit transaction.
1413        let tx_class = crate::classify_stmt_for_tx(&stmt);
1414        if !matches!(tx_class, crate::TxStmtClass::TxControl) {
1415            // v7.37.17 (E4 r3) — a unique-key collision found while
1416            // rebasing fails THIS statement with 40001 (the tx aborts
1417            // via the standard failed-statement path below, like PG's
1418            // in-statement 23505 after the lock wait).
1419            self.maybe_rc_rebase()?;
1420        }
1421        // v7.39 (round 552) — what a SERIALIZABLE tx READ, taken before
1422        // the statement is consumed, recorded after it succeeds.
1423        let read_tables = crate::transaction::read_tables_of(&stmt);
1424        let result = self.dispatch_stmt_inner(stmt, cancel);
1425        if result.is_ok() {
1426            self.record_tx_stmt(&tx_class);
1427            self.record_tx_reads(read_tables);
1428        }
1429        // v7.39 (round 298) — the witness is THIS connection's slot.
1430        // `in_transaction()` is true whenever ANY connection holds a
1431        // transaction, so an autocommit failure used to abort a block
1432        // that belonged to somebody else.
1433        let mine_open = self.current_tx.is_some_and(|tx| self.is_tx_open(tx));
1434        if !mine_open {
1435            // The tx ended (COMMIT / ROLLBACK) or we were in autocommit;
1436            // either way there is no aborted block to remember.
1437            self.set_current_tx_aborted(false);
1438        } else if result.is_ok() && is_rollback_to_savepoint {
1439            // Rolling back to a savepoint recovers the transaction.
1440            self.set_current_tx_aborted(false);
1441        } else if matches!(result, Err(EngineError::LockWouldBlock)) {
1442            // v7.39 (round 300) — NOT a failure: the server drops the
1443            // engine lock and retries. Marking the block aborted here
1444            // made the FIRST block poison the transaction, so the
1445            // retry hit the abort firewall and the waiter lost a
1446            // deadlock it should have won.
1447        } else if result.is_err() {
1448            // A failure inside an open transaction aborts the whole block.
1449            self.set_current_tx_aborted(true);
1450        }
1451        result
1452    }
1453
1454    pub(crate) fn dispatch_stmt_inner(
1455        &mut self,
1456        stmt: Statement,
1457        cancel: CancelToken<'_>,
1458    ) -> Result<QueryResult, EngineError> {
1459        cancel.check()?;
1460        // v7.17.0 Phase 1.1 — pre-resolve nextval / currval /
1461        // setval calls in the statement tree. Walks SELECT
1462        // projection, INSERT VALUES, UPDATE SET, DELETE WHERE,
1463        // and DEFAULT exprs; replaces sequence FunctionCall
1464        // nodes with concrete Literal values minted against the
1465        // catalog. This is the only place that mutates sequence
1466        // state from a SELECT-shaped path (exec_select_cancel is
1467        // `&self` and can't reach the catalog mutably).
1468        //
1469        // Fast-path: when no sequences exist anywhere in the
1470        // catalog (the typical hot-path INSERT load), skip the
1471        // walker entirely. Single map-emptiness check on the
1472        // catalog beats walking every expression on every call.
1473        let mut stmt = stmt;
1474        // v7.17 dump-compat — the fast-path check
1475        // `sequences().is_empty()` skips pre-resolve when no
1476        // sequence exists in the *currently active* catalog
1477        // snapshot. The committed catalog or the implicit-TX
1478        // catalog may legitimately disagree on this between
1479        // CREATE SEQUENCE and a later setval(): always run the
1480        // resolver — the walk is O(expr-count) and dwarfed by
1481        // the parse cost we just paid.
1482        self.pre_resolve_sequence_calls_in_statement(&mut stmt)?;
1483        // v7.39 (round 305, V23) — evaluate any non-constant LIMIT /
1484        // OFFSET down to a literal row count. It belongs here, at the
1485        // one point both the simple-query and the prepared path pass
1486        // through, because every executor reads the row count as
1487        // `Option<u32>` and takes `None` for "no limit": an expression
1488        // that reached execution would silently widen the result to the
1489        // whole table rather than fail.
1490        self.resolve_limit_exprs_in_statement(&mut stmt, cancel)?;
1491        // v7.39 (read01 round 57) — the table-privilege gate. A superuser
1492        // session (the default login, or `SET ROLE admin`) skips it entirely,
1493        // so nothing changes for a customer who never assumes another role.
1494        self.acl_check_statement(&stmt)?;
1495        // v7.39 (round 435) — MySQL commits an open transaction BEFORE it
1496        // runs DDL (and before a nested START TRANSACTION), where PG keeps
1497        // the DDL inside the transaction. A MySQL client that writes rows,
1498        // runs DDL and then rolls back keeps those rows on MySQL and lost
1499        // them on SPG — silently, since nothing errors. This is the one
1500        // point every path (simple query, prepared, extended) passes
1501        // through, so the commit cannot be skipped by a spelling.
1502        //
1503        // v7.39 (round 444) — the witness is THIS connection's slot, not
1504        // `in_transaction()`. That predicate is true whenever ANY connection
1505        // holds a transaction, so a second client's `BEGIN` tried to commit a
1506        // slot of its own that held nothing and answered an error instead —
1507        // caught by `two_mysql_connections_can_each_hold_a_transaction`, which
1508        // had been failing since round 435 introduced this hook. Same
1509        // global-vs-slot confusion rounds 279 / 283 / 298 / 304 each fixed
1510        // elsewhere; `current_tx` is the connection's own slot here, set by
1511        // `execute_in_with_cancel` before dispatch.
1512        let in_own_tx = self.current_tx.is_some_and(|t| self.is_tx_open(t));
1513        if self.backslash_escapes && in_own_tx && stmt.mysql_implicit_commit() {
1514            self.exec_commit()?;
1515        }
1516        let result = match stmt {
1517            // v7.39 (round 547) — `ALTER ROLE … SET/RESET` and
1518            // `ALTER DATABASE … SET/RESET`. These reported success and
1519            // changed nothing: both fell into the parser's pg_dump
1520            // no-op tail, so a DBA setting a per-role default got no
1521            // effect and no error.
1522            Statement::SetDbRoleSetting(st) => {
1523                // PG refuses a scope that names something absent.
1524                // v7.39 (round 696) — one predicate. This wrote its own
1525                // (`users.any(…) || postgres`), `acl_check_role_exists`
1526                // wrote a third, and round 652 already recorded what
1527                // happens when a role predicate and the catalog it reflects
1528                // disagree. `role_exists` is the one that answers.
1529                if let Some(role) = &st.role
1530                    && !self.role_exists(role)
1531                {
1532                    return Err(EngineError::Unsupported(alloc::format!(
1533                        "role \"{role}\" does not exist"
1534                    )));
1535                }
1536                if let Some(db) = &st.database {
1537                    let current = self
1538                        .session_params
1539                        .get("spg.database")
1540                        .cloned()
1541                        .unwrap_or_else(|| alloc::string::String::from("spg"));
1542                    if !db.eq_ignore_ascii_case(&current) {
1543                        return Err(EngineError::Unsupported(alloc::format!(
1544                            "database \"{db}\" does not exist"
1545                        )));
1546                    }
1547                }
1548                let db = st.database.clone().unwrap_or_default();
1549                let role = st.role.clone().unwrap_or_default();
1550                let cat = self.active_catalog_mut();
1551                match (&st.param, &st.value) {
1552                    (None, _) => cat.reset_db_role_settings(&db, &role),
1553                    (Some(p), v) => cat.set_db_role_setting(&db, &role, p, v.as_deref()),
1554                }
1555                Ok(QueryResult::CommandOk {
1556                    affected: 0,
1557                    modified_catalog: true,
1558                })
1559            }
1560            // v7.39 (round 430) — MySQL USER variables. The value is an
1561            // arbitrary expression, evaluated against an empty row (a
1562            // user-variable assignment is a statement, not a per-row thing),
1563            // and stored in the session's own namespace.
1564            //
1565            // Every right-hand side sees the state as it was BEFORE the
1566            // statement — the assignments do NOT become visible to each
1567            // other. Measured on MariaDB 11: with both fresh,
1568            // `SET @p = 1, @q = @p + 1` leaves @q NULL; with @r already 100,
1569            // `SET @r = 1, @s = @r + 1` leaves @s at 101, i.e. @r's OLD
1570            // value. (Separate statements do chain, as you would expect.)
1571            // So: evaluate them all, THEN apply them all.
1572            Statement::SetUserVars(assigns, settings) => {
1573                let mut resolved: Vec<(String, spg_storage::Value<'static>)> =
1574                    Vec::with_capacity(assigns.len());
1575                for (name, mut expr) in assigns {
1576                    // `SET @total = (SELECT SUM(v) FROM t)` is ordinary MySQL,
1577                    // so the scalar subqueries have to be materialised the way
1578                    // every other statement's do — eval_expr itself refuses to
1579                    // meet one.
1580                    self.resolve_expr_subqueries(&mut expr, cancel)?;
1581                    let cols: Vec<ColumnSchema> = Vec::new();
1582                    let value = {
1583                        let ctx = self.ev_ctx(&cols, None);
1584                        let empty = spg_storage::Row::new(Vec::new());
1585                        crate::eval::eval_expr(&expr, &empty, &ctx).map_err(EngineError::Eval)?
1586                    };
1587                    resolved.push((name, value.into_owned()));
1588                }
1589                for (name, value) in resolved {
1590                    self.user_vars.insert(name, value);
1591                }
1592                // v7.39 (round 554) — the session settings written in
1593                // the same statement, applied after the saves. Routed
1594                // through the ordinary SET path so `SQL_MODE` still
1595                // flips strictness and the rest land where a plain
1596                // `SET x = y` puts them.
1597                for (name, value) in settings {
1598                    let rendered = match crate::conversions::literal_expr_to_value_in(
1599                        value.clone(),
1600                        Some(self.active_catalog()),
1601                    ) {
1602                        Ok(v) => crate::eval::value_to_text(&v),
1603                        Err(_) => alloc::format!("{value}"),
1604                    };
1605                    let _ = self.execute(&alloc::format!("SET {name} = '{rendered}'"));
1606                }
1607                Ok(QueryResult::CommandOk {
1608                    affected: 0,
1609                    modified_catalog: false,
1610                })
1611            }
1612            // v7.39 (round 277) — SQL-level prepared statements.
1613            Statement::Prepare {
1614                name,
1615                param_types,
1616                body,
1617                source,
1618            } => self.exec_prepare(name, param_types, *body, source),
1619            Statement::Execute { name, args } => self.exec_execute(&name, &args, cancel),
1620            Statement::Deallocate(name) => self.exec_deallocate(name.as_deref()),
1621            // v7.39 (round 278) — both were accepted and dropped. They
1622            // are reported as MISSING OBJECTS rather than as syntax
1623            // errors, because the SQL parses fine; what is absent is a
1624            // procedure catalog and a prepared-transaction registry.
1625            // v7.39 (round 280) — extended statistics as a real
1626            // catalog object. The planner does not consult them yet;
1627            // recording them is what makes a pg_dump restore and
1628            // reflection honest, instead of the statement vanishing.
1629            Statement::CreateStatistics {
1630                name,
1631                if_not_exists,
1632                kinds,
1633                columns,
1634                table,
1635            } => self.exec_create_statistics(name, if_not_exists, kinds, columns, table),
1636            Statement::DropStatistics { name, if_exists } => {
1637                self.exec_drop_statistics(&name, if_exists)
1638            }
1639            Statement::Call(name) => Err(EngineError::Unsupported(alloc::format!(
1640                "procedure {name}() does not exist HINT: No procedure matches the given name \
1641                 and argument types. You might need to add explicit type casts."
1642            ))),
1643            Statement::PrepareTransaction(_) => Err(EngineError::Unsupported(String::from(
1644                "prepared transactions are disabled HINT: Set \"max_prepared_transactions\" \
1645                 to a nonzero value.",
1646            ))),
1647            Statement::CreateTable(s) => self.exec_create_table(s),
1648            // v7.39 (round 218) — server-side cursors.
1649            Statement::DeclareCursor {
1650                name,
1651                scroll,
1652                hold,
1653                query,
1654            } => self.exec_declare_cursor(name, scroll, hold, *query),
1655            Statement::FetchCursor { name, direction } => self.exec_fetch_cursor(&name, direction),
1656            Statement::MoveCursor { name, direction } => self.exec_move_cursor(&name, direction),
1657            Statement::CloseCursor { name } => self.exec_close_cursor(name.as_deref()),
1658            // v7.39 (round 222) — LISTEN/NOTIFY with real delivery.
1659            Statement::Listen(ch) => self.exec_listen(ch),
1660            Statement::Notify { channel, payload } => self.exec_notify(channel, payload),
1661            Statement::Unlisten(ch) => self.exec_unlisten(ch),
1662            // v7.9.15 — CREATE EXTENSION is a no-op on SPG. Returns
1663            // CommandOk with affected=0; modified_catalog=false so
1664            // the WAL doesn't grow a useless entry. mailrs F3.
1665            Statement::CreateExtension(_) => Ok(QueryResult::CommandOk {
1666                affected: 0,
1667                modified_catalog: false,
1668            }),
1669            // v7.16.2 — DO $$ ... $$ block. mailrs round-10 A.2
1670            // — the pre-v7.9.27 no-op SILENTLY swallowed every
1671            // mailrs migrate-038/-040/-042 idempotent rename
1672            // (the IF EXISTS … THEN ALTER … END block never
1673            // ran). v7.16.2 dispatches to exec_do_block which
1674            // runs the PlPgSqlBlock at top level via the same
1675            // execute_stmts machinery the trigger executor
1676            // uses (NEW=None, OLD=None — DO blocks have no
1677            // row context).
1678            Statement::DoBlock(body) => self.exec_do_block(body),
1679            // v7.14.0 — empty-statement no-op for pg_dump /
1680            // mysqldump preamble lines that collapse to nothing
1681            // after comment-stripping.
1682            Statement::Empty => Ok(QueryResult::CommandOk {
1683                affected: 0,
1684                modified_catalog: false,
1685            }),
1686            // v7.39 (round 695) — `ALTER SYSTEM SET|RESET <name>`. SPG has
1687            // no postgresql.auto.conf to write, so nothing is APPLIED; what
1688            // changed is that a name PG18 does not know is now refused
1689            // instead of accepted. It reuses the session's own GUC check —
1690            // one place decides what a parameter name means, so `SET` and
1691            // `ALTER SYSTEM` cannot drift apart in what they accept.
1692            //
1693            // The F31 audit found this: the test was called
1694            // `alter_system_set_no_op` and set `work_mem`, a name that
1695            // exists, so it could never have caught a name that does not.
1696            // v7.39 (round 696) — the four statements the F31 sweep found
1697            // accepting a name that does not exist. SPG still performs
1698            // nothing for any of them; what changed is that it no longer
1699            // says "understood" about an object that is not there.
1700            // v7.39 (round 707) — see Statement::DropAggregate. Existence
1701            // first across the whole list (PG's order, measured), canonical
1702            // type names in the signature, and every SPG aggregate is a
1703            // built-in, so a name that exists is undroppable.
1704            Statement::DropAggregate { if_exists, items } => {
1705                let render = |name: &str, args: &Option<Vec<String>>| -> alloc::string::String {
1706                    match args {
1707                        None => alloc::format!("{name}(*)"),
1708                        Some(a) => {
1709                            let canon: Vec<alloc::string::String> = a
1710                                .iter()
1711                                .map(|t| {
1712                                    crate::conversions::type_name_to_data_type(t).map_or_else(
1713                                        || t.clone(),
1714                                        crate::conversions::pg_type_name_for_error,
1715                                    )
1716                                })
1717                                .collect();
1718                            alloc::format!("{name}({})", canon.join(", "))
1719                        }
1720                    }
1721                };
1722                for (name, args) in &items {
1723                    if !crate::aggregate::is_aggregate_name(name.as_str()) {
1724                        if if_exists {
1725                            continue;
1726                        }
1727                        return Err(EngineError::Unsupported(alloc::format!(
1728                            "aggregate {} does not exist",
1729                            render(name, args)
1730                        )));
1731                    }
1732                }
1733                if let Some((name, args)) = items
1734                    .iter()
1735                    .find(|(n, _)| crate::aggregate::is_aggregate_name(n.as_str()))
1736                {
1737                    return Err(EngineError::Unsupported(alloc::format!(
1738                        "cannot drop function {} because it is required by the database system",
1739                        render(name, args)
1740                    )));
1741                }
1742                Ok(QueryResult::CommandOk {
1743                    affected: 0,
1744                    modified_catalog: false,
1745                })
1746            }
1747            // v7.39 (round 750) — `ALTER ROLE … PASSWORD` really rotates
1748            // the credential now (it was a recorded no-op — ledgered as a
1749            // security defect in round 710: `ALTER USER x PASSWORD 'new'`
1750            // answered ALTER ROLE and the OLD password kept working).
1751            Statement::AlterRolePassword { name, password } => {
1752                if !self.role_exists(name.as_str()) {
1753                    return Err(EngineError::Unsupported(alloc::format!(
1754                        "role \"{name}\" does not exist"
1755                    )));
1756                }
1757                self.alter_user_password(&name, password.as_deref())
1758                    .map_err(|e| EngineError::Unsupported(alloc::format!("ALTER ROLE: {e}")))?;
1759                Ok(QueryResult::CommandOk {
1760                    affected: 0,
1761                    modified_catalog: self.catalog_change_is_committed(),
1762                })
1763            }
1764            Statement::ValidateOnly { kind, names } => {
1765                use spg_sql::ast::ValidateOnlyKind as K;
1766                match kind {
1767                    K::LockTable => {
1768                        for n in names {
1769                            if self.catalog.get(n.as_str()).is_none() {
1770                                return Err(EngineError::Storage(
1771                                    spg_storage::StorageError::TableNotFound { name: n.clone() },
1772                                ));
1773                            }
1774                        }
1775                    }
1776                    K::RoleName => {
1777                        for n in names {
1778                            if !self.role_exists(n.as_str()) {
1779                                return Err(EngineError::Unsupported(alloc::format!(
1780                                    "role \"{n}\" does not exist"
1781                                )));
1782                            }
1783                        }
1784                    }
1785                    // PG18 refuses this whatever it names, because no label
1786                    // provider is loaded — and SPG has none either, so the
1787                    // refusal is the honest answer rather than a stand-in.
1788                    // v7.39 (round 697) — one list answers both, which is
1789                    // why these and `pg_extension` cannot disagree.
1790                    //
1791                    // A WARNING, not an error, and that is a deliberate
1792                    // departure from PG. PG can error because an extension
1793                    // can be installed there; SPG cannot be installed into,
1794                    // so refusing would turn a customer dump that restores
1795                    // today into one that needs editing. Saying nothing was
1796                    // the actual defect: `CREATE EXTENSION hstore` reported
1797                    // success and nothing hstore-shaped worked afterwards.
1798                    K::ExtensionAvailable | K::ExtensionInstalled => {
1799                        for n in names {
1800                            if !crate::system_catalog::INSTALLED_EXTENSIONS
1801                                .iter()
1802                                .any(|(e, _)| e.eq_ignore_ascii_case(n.as_str()))
1803                            {
1804                                self.warning(alloc::format!(
1805                                    "extension \"{n}\" is not provided by this build; SPG \
1806                                     accepts the statement so a dump restores, but nothing \
1807                                     that extension supplies will be available"
1808                                ));
1809                            }
1810                        }
1811                    }
1812                    // v7.39 (round 708) — ALTER TYPE's no-op forms validate
1813                    // the name against the three user-type catalogs.
1814                    K::TypeName => {
1815                        for n in &names {
1816                            let cat = self.active_catalog();
1817                            if !cat.enum_types().contains_key(n)
1818                                && !cat.domain_types().contains_key(n)
1819                                && !cat.composite_types().contains_key(n)
1820                            {
1821                                return Err(EngineError::Unsupported(alloc::format!(
1822                                    "type \"{n}\" does not exist"
1823                                )));
1824                            }
1825                        }
1826                    }
1827                    // v7.39 (round 708) — names[0] = aggregate, rest = arg
1828                    // type names; existence by name (round 707's residual on
1829                    // overloads applies here too).
1830                    K::AggregateName => {
1831                        let Some(name) = names.first() else {
1832                            return Ok(QueryResult::CommandOk {
1833                                affected: 0,
1834                                modified_catalog: false,
1835                            });
1836                        };
1837                        if !crate::aggregate::is_aggregate_name(name.as_str()) {
1838                            let canon: Vec<alloc::string::String> = names[1..]
1839                                .iter()
1840                                .map(|t| {
1841                                    if t == "*" {
1842                                        alloc::string::String::from("*")
1843                                    } else {
1844                                        crate::conversions::type_name_to_data_type(t).map_or_else(
1845                                            || t.clone(),
1846                                            crate::conversions::pg_type_name_for_error,
1847                                        )
1848                                    }
1849                                })
1850                                .collect();
1851                            return Err(EngineError::Unsupported(alloc::format!(
1852                                "aggregate {name}({}) does not exist",
1853                                canon.join(", ")
1854                            )));
1855                        }
1856                    }
1857                    // v7.39 (round 708) — SPG ships no conversions at all,
1858                    // so PG's not-found answer is total here.
1859                    K::ConversionName => {
1860                        if let Some(n) = names.first() {
1861                            return Err(EngineError::Unsupported(alloc::format!(
1862                                "conversion \"{n}\" does not exist"
1863                            )));
1864                        }
1865                    }
1866                    // v7.39 (round 708) — the shipped languages are
1867                    // required; anything else does not exist. Both wordings
1868                    // are PG18 measurements.
1869                    K::LanguageName => {
1870                        // One name per statement; PG errors on the first
1871                        // either way, so `first` says what the loop only
1872                        // implied (clippy: never actually loops).
1873                        if let Some(n) = names.first() {
1874                            let lc = n.to_ascii_lowercase();
1875                            return Err(EngineError::Unsupported(match lc.as_str() {
1876                                "plpgsql" => alloc::format!(
1877                                    "cannot drop language {lc} because extension {lc} requires it"
1878                                ),
1879                                "sql" | "internal" | "c" => alloc::format!(
1880                                    "cannot drop language {lc} because it is required by the database system"
1881                                ),
1882                                _ => alloc::format!("language \"{n}\" does not exist"),
1883                            }));
1884                        }
1885                    }
1886                    // v7.39 (round 709) — batch-2 name checks, each wording
1887                    // a PG18 measurement.
1888                    K::CollationName => {
1889                        for n in &names {
1890                            if !crate::collate::is_supported(n) {
1891                                return Err(EngineError::Unsupported(alloc::format!(
1892                                    "collation \"{n}\" for encoding \"UTF8\" does not exist"
1893                                )));
1894                            }
1895                        }
1896                    }
1897                    K::TsConfigName => {
1898                        for n in &names {
1899                            // One list with the pg_ts_config synth: SPG
1900                            // ships `simple` and `english`.
1901                            if !n.eq_ignore_ascii_case("simple")
1902                                && !n.eq_ignore_ascii_case("english")
1903                            {
1904                                return Err(EngineError::Unsupported(alloc::format!(
1905                                    "text search configuration \"{n}\" does not exist"
1906                                )));
1907                            }
1908                        }
1909                    }
1910                    K::EventTriggerName => {
1911                        if let Some(n) = names.first() {
1912                            return Err(EngineError::Unsupported(alloc::format!(
1913                                "event trigger \"{n}\" does not exist"
1914                            )));
1915                        }
1916                    }
1917                    K::TablespaceName => {
1918                        if let Some(n) = names.first() {
1919                            return Err(EngineError::Unsupported(
1920                                if n.eq_ignore_ascii_case("pg_default")
1921                                    || n.eq_ignore_ascii_case("pg_global")
1922                                {
1923                                    alloc::format!("permission denied for tablespace {n}")
1924                                } else {
1925                                    alloc::format!("tablespace \"{n}\" does not exist")
1926                                },
1927                            ));
1928                        }
1929                    }
1930                    K::LargeObjectOid => {
1931                        if let Some(n) = names.first() {
1932                            let oid: u32 = n.parse().unwrap_or(0);
1933                            if !self.active_catalog().large_objects().contains_key(&oid) {
1934                                return Err(EngineError::Unsupported(alloc::format!(
1935                                    "large object {n} does not exist"
1936                                )));
1937                            }
1938                        }
1939                    }
1940                    // v7.39 (round 706) — see ValidateOnlyKind::ForeignInfra
1941                    // for why this warns instead of copying PG's refusal.
1942                    K::ForeignInfra => {
1943                        self.warning(alloc::string::String::from(
1944                            "foreign-data infrastructure is not provided by this build; \
1945                             SPG accepts the statement so a dump restores, but no foreign \
1946                             server, wrapper or table it defines will function",
1947                        ));
1948                    }
1949                    K::SecurityLabel => {
1950                        return Err(EngineError::Unsupported(alloc::string::String::from(
1951                            "no security label providers have been loaded",
1952                        )));
1953                    }
1954                }
1955                Ok(QueryResult::CommandOk {
1956                    affected: 0,
1957                    modified_catalog: false,
1958                })
1959            }
1960            Statement::DropDatabase { name, if_exists } => {
1961                // PG refuses this inside a transaction block; so does
1962                // CREATE DATABASE, and both go through the same guard.
1963                self.require_no_transaction_block("DROP DATABASE")?;
1964                // SPG serves one database, so the name is either the one
1965                // this session is connected to or a name that does not
1966                // exist here. PG has wording for both and never lets
1967                // either succeed, which is the whole behaviour.
1968                let is_current = self
1969                    .session_param("spg.database")
1970                    .unwrap_or("spg")
1971                    .eq_ignore_ascii_case(&name);
1972                if is_current {
1973                    return Err(EngineError::Unsupported(alloc::string::String::from(
1974                        "cannot drop the currently open database",
1975                    )));
1976                }
1977                if if_exists {
1978                    self.notice(alloc::format!(
1979                        "database \"{name}\" does not exist, skipping"
1980                    ));
1981                    return Ok(QueryResult::CommandOk {
1982                        affected: 0,
1983                        modified_catalog: false,
1984                    });
1985                }
1986                Err(EngineError::Unsupported(alloc::format!(
1987                    "database \"{name}\" does not exist"
1988                )))
1989            }
1990            Statement::NoOpPreventedInTransaction { what } => {
1991                self.require_no_transaction_block(&what)?;
1992                Ok(QueryResult::CommandOk {
1993                    affected: 0,
1994                    modified_catalog: false,
1995                })
1996            }
1997            Statement::AlterSystem { parameter } => {
1998                // PG refuses this inside a transaction block (25001): it
1999                // edits postgresql.auto.conf, which no rollback undoes.
2000                self.require_no_transaction_block("ALTER SYSTEM")?;
2001                if let Some(name) = parameter
2002                    && let Some(msg) = self.reject_unsettable_guc(name.as_str())
2003                {
2004                    return Err(EngineError::Unsupported(msg));
2005                }
2006                Ok(QueryResult::CommandOk {
2007                    affected: 0,
2008                    modified_catalog: false,
2009                })
2010            }
2011            Statement::DropTable { names, if_exists } => self.exec_drop_table(names, if_exists),
2012            Statement::DropIndex { name, if_exists } => self.exec_drop_index(name, if_exists),
2013            Statement::CreateIndex(s) => {
2014                // PG bars only the CONCURRENTLY form inside a transaction
2015                // block (25001); a plain CREATE INDEX there is fine.
2016                if s.concurrently {
2017                    self.require_no_transaction_block("CREATE INDEX CONCURRENTLY")?;
2018                }
2019                self.exec_create_index(s)
2020            }
2021            Statement::Insert(s) => {
2022                // v7.39 (pg_stat knife A) — per-table n_tup_ins. Charged
2023                // to the statement's target (a partition-routed insert
2024                // charges the parent; ON CONFLICT updates count here
2025                // too — split is a recorded residual).
2026                let stat_table = s.table.clone();
2027                let r = self.exec_insert(s)?;
2028                if let QueryResult::CommandOk { affected, .. } = &r {
2029                    self.stat_tup_inserted =
2030                        self.stat_tup_inserted.saturating_add(*affected as u64);
2031                    // r192 — engine-side, non-transactional (see
2032                    // table_write_stats): in-tx bumps used to land on
2033                    // the shadow table and vanish in the RC rebase.
2034                    self.note_table_write(&stat_table, *affected as u64, 0, 0);
2035                }
2036                Ok(r)
2037            }
2038            Statement::Update(mut s) => {
2039                // Materialise uncorrelated subqueries in SET / WHERE
2040                // before the row walk — the SELECT path has done this
2041                // since v4.10; UPDATE gained it for mailrs's
2042                // `UPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP
2043                // LOCKED)` claim pattern (embed round-12).
2044                // v7.39 (round 157) — NOT with a WITH clause: the CTE
2045                // temps aren't installed yet here, so a subquery reading
2046                // a CTE either failed ("relation does not exist") or —
2047                // when a same-named real table existed — silently read
2048                // THAT. exec_update_with_ctes resolves after the temps
2049                // install instead.
2050                if s.ctes.is_empty() {
2051                    for (_, e) in &mut s.assignments {
2052                        self.resolve_expr_subqueries(e, cancel)?;
2053                    }
2054                    if let Some(w) = &mut s.where_ {
2055                        self.resolve_expr_subqueries(w, cancel)?;
2056                    }
2057                }
2058                let r = self.exec_update_cancel(&s, cancel)?;
2059                if let QueryResult::CommandOk { affected, .. } = &r {
2060                    self.stat_tup_updated = self.stat_tup_updated.saturating_add(*affected as u64);
2061                    self.note_table_write(&s.table, 0, *affected as u64, 0);
2062                }
2063                Ok(r)
2064            }
2065            Statement::Delete(mut s) => {
2066                // v7.39 (round 157) — see the Update arm: with a WITH
2067                // clause the resolve runs after the CTE temps install.
2068                if s.ctes.is_empty()
2069                    && let Some(w) = &mut s.where_
2070                {
2071                    self.resolve_expr_subqueries(w, cancel)?;
2072                }
2073                let r = self.exec_delete_cancel(&s, cancel)?;
2074                if let QueryResult::CommandOk { affected, .. } = &r {
2075                    self.stat_tup_deleted = self.stat_tup_deleted.saturating_add(*affected as u64);
2076                    self.note_table_write(&s.table, 0, 0, *affected as u64);
2077                }
2078                Ok(r)
2079            }
2080            Statement::Merge(s) => self.exec_merge_cancel(&s, cancel),
2081            // v7.39 (round 295, E3 Phase 1b) — a locking SELECT takes its
2082            // locks in a `&mut self` pre-pass that respects LIMIT, then
2083            // runs the ordinary read path with the rows another
2084            // transaction holds excluded.
2085            Statement::Select(ref sel) if sel.locking.is_some() => {
2086                let sel = sel.clone();
2087                self.lock_skip_rows = None;
2088                let pre = self.run_locking_prepass(&sel);
2089                if let Err(e) = pre {
2090                    self.lock_skip_rows = None;
2091                    return Err(e);
2092                }
2093                let out = self.exec_select_cancel(&sel, cancel);
2094                self.lock_skip_rows = None;
2095                out
2096            }
2097            Statement::Select(s) => {
2098                // v7.38 (read01 P3.20) — `SELECT set_config(name, value,
2099                // is_local)` is the writing sibling of SHOW / current_setting;
2100                // apply it to the session store (respecting is_local) so the
2101                // four GUC surfaces stay unified. pg_dump's
2102                // `SELECT set_config('search_path', '', false)` relies on this.
2103                if let Some(r) = self.try_exec_set_config(&s)? {
2104                    return Ok(r);
2105                }
2106                if s.ctes.iter().any(|c| c.body.is_modifying()) {
2107                    self.exec_select_with_modifying_ctes(s, cancel)
2108                } else {
2109                    self.exec_select_cancel(&s, cancel)
2110                }
2111            }
2112            // v7.39 (round 249) — the engine is no_std: the HOST reads the
2113            // file and calls `copy_from_buffer`. Reaching this arm means a
2114            // host that hasn't wired the file endpoint.
2115            Statement::CopyFromFile { path, .. } => Err(EngineError::Unsupported(alloc::format!(
2116                "COPY FROM file: the host must read {path:?} and call copy_from_buffer"
2117            ))),
2118            Statement::CopyTo {
2119                table,
2120                columns,
2121                query,
2122                options,
2123            } => self.exec_copy_to(
2124                &table,
2125                columns.as_deref(),
2126                query.as_deref(),
2127                &options,
2128                cancel,
2129            ),
2130            // v7.39 (round 252) — the engine is no_std: the HOST renders
2131            // via `copy_to_buffer` and writes the file itself.
2132            Statement::CopyToFile { path, .. } => Err(EngineError::Unsupported(alloc::format!(
2133                "COPY TO file: the host must render via copy_to_buffer and write {path:?}"
2134            ))),
2135            // v7.39 (round 475) — a redundant BEGIN inside a transaction.
2136            //
2137            // SPG raised "a transaction is already open" AND left the
2138            // transaction in the aborted state, so the next statement failed
2139            // with "current transaction is aborted" and the whole block was
2140            // lost. A connection pooler or a framework that wraps its own
2141            // BEGIN around one the caller already opened does this routinely.
2142            //
2143            // The two oracles genuinely differ, and both were measured:
2144            //   PG18       WARNING: there is already a transaction in
2145            //              progress — the BEGIN is a no-op and the existing
2146            //              transaction continues (a later ROLLBACK undoes
2147            //              everything, both rows in the probe).
2148            //   MariaDB 11 START TRANSACTION implicitly COMMITS the open one
2149            //              and begins a new one (the first row survives the
2150            //              rollback, the second does not).
2151            // The predicate is THIS connection's slot, not the engine-global
2152            // `in_transaction()`: the server shares one Engine, so the global
2153            // form makes connection B's BEGIN see connection A's transaction
2154            // (rounds 279 / 283 / 298 / 304 / 443 / 444 are the same trap).
2155            Statement::Begin(_)
2156                if self.current_tx.is_some_and(|t| self.is_tx_open(t))
2157                    && !self.backslash_escapes =>
2158            {
2159                self.warning(alloc::string::String::from(
2160                    "there is already a transaction in progress",
2161                ));
2162                Ok(QueryResult::CommandOk {
2163                    affected: 0,
2164                    modified_catalog: false,
2165                })
2166            }
2167            Statement::Begin(isolation) if self.current_tx.is_some_and(|t| self.is_tx_open(t)) => {
2168                // MySQL dialect: commit what is open, then start fresh.
2169                self.exec_commit()?;
2170                self.exec_begin(isolation)
2171            }
2172            Statement::Begin(isolation) => self.exec_begin(isolation),
2173            // v7.39 (round 435) — a bare COMMIT / ROLLBACK outside a
2174            // transaction is a no-op that SUCCEEDS. Measured on both
2175            // oracles: PG18 answers `WARNING: there is no transaction in
2176            // progress` and still reports COMMIT / ROLLBACK; MariaDB 11
2177            // succeeds silently. SPG answered "no active transaction" as an
2178            // ERROR to both dialects — a divergence from each of them.
2179            // It moved onto the hot path with the implicit-commit rule
2180            // above, which leaves a client's trailing ROLLBACK with nothing
2181            // to roll back.
2182            Statement::Commit | Statement::Rollback if !self.in_transaction() => {
2183                if !self.backslash_escapes {
2184                    self.warning(alloc::string::String::from(
2185                        "there is no transaction in progress",
2186                    ));
2187                }
2188                Ok(QueryResult::CommandOk {
2189                    affected: 0,
2190                    modified_catalog: false,
2191                })
2192            }
2193            Statement::Commit => self.exec_commit(),
2194            Statement::Rollback => self.exec_rollback(),
2195            Statement::Savepoint(name) => self.exec_savepoint(name),
2196            Statement::RollbackToSavepoint(name) => self.exec_rollback_to_savepoint(&name),
2197            Statement::ReleaseSavepoint(name) => self.exec_release_savepoint(&name),
2198            Statement::ShowTables => Ok(self.exec_show_tables()),
2199            Statement::ShowDatabases => Ok(self.exec_show_databases()),
2200            Statement::ShowCreateTable(name) => self.exec_show_create_table(&name),
2201            Statement::ShowIndexes(name) => self.exec_show_indexes(&name),
2202            Statement::ShowStatus => Ok(self.exec_show_status()),
2203            Statement::ShowVariables => Ok(self.exec_show_variables()),
2204            Statement::ShowVariablesLike(p) => Ok(self.exec_show_variables_like(&p)),
2205            Statement::ShowProcesslist => Ok(self.exec_show_processlist()),
2206            Statement::Kill { query_only, id } => self.exec_kill(query_only, &id),
2207            Statement::Discard(target) => self.exec_discard(target),
2208            Statement::ShowColumns(table) => self.exec_show_columns(&table),
2209            Statement::ShowUsers => Ok(self.exec_show_users()),
2210            Statement::ShowPublications => Ok(self.exec_show_publications()),
2211            Statement::ShowSubscriptions => Ok(self.exec_show_subscriptions()),
2212            Statement::CreateUser(s) => self.exec_create_user(&s),
2213            Statement::DropUser { name, if_exists } => self.exec_drop_user(&name, if_exists),
2214            Statement::SetRole(role) => {
2215                match role {
2216                    Some(name) => {
2217                        // v7.39 (read01 round 58) — PG rejects a SET ROLE to a
2218                        // role that does not exist. Before roles were real
2219                        // there was nothing to check against, so any name was
2220                        // accepted — and a typo silently put the session into
2221                        // a role that held nothing.
2222                        self.acl_check_role_exists(&name)?;
2223                        self.session_params.insert(
2224                            alloc::string::String::from(crate::session::CURRENT_ROLE_KEY),
2225                            name,
2226                        );
2227                    }
2228                    None => {
2229                        self.session_params.remove(crate::session::CURRENT_ROLE_KEY);
2230                    }
2231                }
2232                Ok(QueryResult::CommandOk {
2233                    affected: 0,
2234                    modified_catalog: false,
2235                })
2236            }
2237            Statement::Grant(g) => self.exec_grant(&g, true),
2238            Statement::Revoke(g) => self.exec_grant(&g, false),
2239            Statement::CreatePolicy(s) => self.exec_create_policy(s),
2240            Statement::AlterPolicy(s) => self.exec_alter_policy(s),
2241            Statement::DropPolicy(s) => self.exec_drop_policy(s),
2242            // v7.39 (round 286) — ANALYZE over DML really executes, so it
2243            // needs the `&mut self` sibling. Everything else (including
2244            // plain EXPLAIN of a write) stays on the read-only renderer.
2245            // v7.39 (round 288) — SET CONSTRAINTS sets the timing for the
2246            // rest of the transaction. IMMEDIATE also runs everything the
2247            // transaction has postponed, right here — PG raises the
2248            // violation at this statement, not at COMMIT.
2249            Statement::SetConstraints { names, deferred } => {
2250                self.exec_set_constraints(&names, deferred)
2251            }
2252            Statement::Explain(e)
2253                if e.analyze
2254                    && !e.suggest
2255                    && matches!(
2256                        &*e.inner,
2257                        Statement::Insert(_) | Statement::Update(_) | Statement::Delete(_)
2258                    ) =>
2259            {
2260                self.exec_explain_analyze_dml(&e, cancel)
2261            }
2262            Statement::Explain(e) => self.exec_explain(&e, cancel),
2263            Statement::AlterIndex(s) => self.exec_alter_index(s),
2264            Statement::AlterTable(s) => self.exec_alter_table(s),
2265            Statement::CreatePublication(s) => self.exec_create_publication(s),
2266            Statement::DropPublication { name, if_exists } => {
2267                self.exec_drop_publication(&name, if_exists)
2268            }
2269            Statement::CreateSubscription(s) => self.exec_create_subscription(s),
2270            Statement::DropSubscription { name, if_exists } => {
2271                self.exec_drop_subscription(&name, if_exists)
2272            }
2273            // v6.1.7 — WAIT FOR WAL POSITION needs `lag_state`,
2274            // which lives in spg-server's ServerState. The engine
2275            // surfaces a clear error; the server-layer dispatch
2276            // intercepts the SQL before it reaches the engine on
2277            // a server build, so this arm only fires for
2278            // engine-only callers (spg-embedded, lib tests).
2279            Statement::WaitForWalPosition { .. } => Err(EngineError::Unsupported(
2280                "WAIT FOR WAL POSITION must be handled by the server layer".into(),
2281            )),
2282            // v6.2.0 — ANALYZE recomputes per-column histograms.
2283            Statement::Analyze(target) => self.exec_analyze(target.as_deref()),
2284            // v7.39 (round 535) — REINDEX / CLUSTER. SPG has neither index
2285            // bloat to rebuild nor a clustering order to impose, so the
2286            // work is a no-op — but PG VALIDATES the target, and both
2287            // statements were swallowed at parse time AND intercepted at
2288            // the wire, so `REINDEX TABLE typo` answered `REINDEX`. A
2289            // maintenance script that misspells a table was told it
2290            // succeeded.
2291            Statement::Maintain {
2292                kind,
2293                concurrently,
2294                target,
2295            } => {
2296                use spg_sql::ast::MaintainKind;
2297                if concurrently {
2298                    self.require_no_transaction_block(match kind {
2299                        MaintainKind::ClusterRelation => "CLUSTER",
2300                        _ => "REINDEX CONCURRENTLY",
2301                    })?;
2302                }
2303                match (kind, target.as_deref()) {
2304                    (MaintainKind::ReindexRelation | MaintainKind::ClusterRelation, Some(t)) => {
2305                        // An INDEX is a relation too — `REINDEX INDEX ix`
2306                        // names one, and looking only at tables refused a
2307                        // name that is right there.
2308                        let is_index = self
2309                            .active_catalog()
2310                            .table_names()
2311                            .iter()
2312                            .filter_map(|n| self.active_catalog().get(n))
2313                            .any(|tbl| {
2314                                tbl.indices().iter().any(|i| i.name.eq_ignore_ascii_case(t))
2315                            });
2316                        if !is_index && self.active_catalog().get(t).is_none() {
2317                            return Err(EngineError::Storage(
2318                                spg_storage::StorageError::TableNotFound { name: t.into() },
2319                            ));
2320                        }
2321                    }
2322                    (MaintainKind::ReindexSchema, Some(t)) => {
2323                        if !spg_storage::is_builtin_schema(t)
2324                            && !self.active_catalog().schema_exists(t)
2325                        {
2326                            return Err(EngineError::Unsupported(alloc::format!(
2327                                "schema \"{t}\" does not exist"
2328                            )));
2329                        }
2330                    }
2331                    // `REINDEX SYSTEM` / `REINDEX DATABASE` / a bare
2332                    // `CLUSTER` name nothing to check.
2333                    _ => {}
2334                }
2335                Ok(QueryResult::CommandOk {
2336                    affected: 0,
2337                    modified_catalog: false,
2338                })
2339            }
2340            // v7.39 (round 169) — VACUUM does real work under the MVCC
2341            // gate (tombstoned versions are actual bloat); the pre-MVCC
2342            // parse-time no-op silently ignored a customer's manual
2343            // reclaim. Gate-off stays a provable no-op inside vacuum.
2344            Statement::Vacuum { table, analyze } => {
2345                // PG 18.4, measured: every VACUUM form — bare, with a
2346                // table, and VACUUM ANALYZE — is refused inside a
2347                // transaction block with 25001, while a plain ANALYZE is
2348                // allowed. Reclaiming storage cannot be rolled back, so
2349                // it must not be able to join a transaction that can.
2350                self.require_no_transaction_block("VACUUM")?;
2351                match &table {
2352                    Some(t) => {
2353                        // v7.39 (round 535) — PG refuses a VACUUM whose
2354                        // relation does not exist; `vacuum_one_table`
2355                        // simply found nothing to do and said nothing,
2356                        // so a typo'd table reported success.
2357                        if self.active_catalog().get(t).is_none() {
2358                            return Err(EngineError::Storage(
2359                                spg_storage::StorageError::TableNotFound { name: t.clone() },
2360                            ));
2361                        }
2362                        self.vacuum_one_table(t);
2363                    }
2364                    None => {
2365                        let _ = self.vacuum_pass(false);
2366                    }
2367                }
2368                if analyze {
2369                    self.exec_analyze(table.as_deref())?;
2370                }
2371                Ok(QueryResult::CommandOk {
2372                    affected: 0,
2373                    modified_catalog: false,
2374                })
2375            }
2376            // v7.37.17 (17.6 sibling) — TRUNCATE [TABLE] <t>[, ...]
2377            // [RESTART IDENTITY] [CASCADE]. Clears every row from
2378            // each named table. CASCADE currently accepts the syntax
2379            // + records the flag; the FK-referring cascade walk lands
2380            // when FK-cascade delete surface gets extended to
2381            // multi-relation batching (v7.38).
2382            Statement::Truncate {
2383                tables,
2384                restart_identity,
2385                cascade: _,
2386                only,
2387            } => {
2388                for t in &tables {
2389                    self.bump_table_change(t);
2390                }
2391                self.exec_truncate(tables.as_slice(), restart_identity, only)
2392            }
2393            // v6.7.3 — COMPACT COLD SEGMENTS.
2394            Statement::CompactColdSegments => self.exec_compact_cold_segments(),
2395            // v7.12.1 — SET / RESET session parameter. Engine
2396            // tracks the value in `session_params`; FTS dispatcher
2397            // reads `default_text_search_config`. Everything else
2398            // is a recorded no-op (PG dump compat).
2399            Statement::SetParameter { name, value, local } => {
2400                // v7.39 (round 501) — a name PG18 does not know, or one a
2401                // session cannot change, is an error there and was
2402                // silently accepted here (round 500).
2403                if let Some(msg) = self.reject_unsettable_guc(&name) {
2404                    return Err(EngineError::Unsupported(msg));
2405                }
2406                // v7.38 (read01) — SPG serves the wire as UTF8, so a
2407                // non-UTF8 client_encoding can't be honoured (the bytes
2408                // stay UTF8). Reject it rather than silently store a value
2409                // that would mislabel the stream; an unusable name is
2410                // rejected the way PG rejects an invalid one.
2411                if name.eq_ignore_ascii_case("client_encoding") {
2412                    let v: &str = match &value {
2413                        spg_sql::ast::SetValue::String(s)
2414                        | spg_sql::ast::SetValue::Ident(s)
2415                        | spg_sql::ast::SetValue::Number(s) => s.as_str(),
2416                        spg_sql::ast::SetValue::Default => "UTF8",
2417                    };
2418                    let norm: alloc::string::String = v
2419                        .trim()
2420                        .to_ascii_uppercase()
2421                        .chars()
2422                        .filter(|c| *c != '-' && *c != '_')
2423                        .collect();
2424                    if !matches!(norm.as_str(), "UTF8" | "UNICODE") {
2425                        return Err(EngineError::Unsupported(alloc::format!(
2426                            "invalid value for parameter \"client_encoding\": \"{v}\" \
2427                             (SPG serves UTF8 only)"
2428                        )));
2429                    }
2430                }
2431                // v7.38 (read01 P3.17) — reject a clearly-invalid value for
2432                // a handful of well-known typed GUCs (`SET work_mem =
2433                // 'bogus'` errors like PG). Unknown GUCs stay accept-and-
2434                // record for pg_dump compat.
2435                if let spg_sql::ast::SetValue::String(s)
2436                | spg_sql::ast::SetValue::Ident(s)
2437                | spg_sql::ast::SetValue::Number(s) = &value
2438                {
2439                    validate_known_guc(&name, s)?;
2440                    // v7.39 (tz epic) — timezone accepts UTC / fixed
2441                    // offsets / abbreviations (resolve_zone_offset) and
2442                    // IANA names (host tzdb); anything else is PG's
2443                    // invalid-parameter error. Named zones store their
2444                    // canonical spelling (SHOW returns 'Asia/Tokyo'
2445                    // after SET 'asia/tokyo').
2446                    if name.eq_ignore_ascii_case("timezone")
2447                        || name.eq_ignore_ascii_case("time zone")
2448                    {
2449                        let canon = self.canonicalize_timezone(s)?;
2450                        let local = local;
2451                        if local {
2452                            if self.in_transaction() {
2453                                let prior = self.session_param("timezone").map(String::from);
2454                                self.local_guc_saves.push(("timezone".into(), prior));
2455                                self.set_session_param(
2456                                    "timezone".into(),
2457                                    spg_sql::ast::SetValue::String(canon),
2458                                );
2459                            }
2460                        } else {
2461                            self.set_session_param(
2462                                "timezone".into(),
2463                                spg_sql::ast::SetValue::String(canon),
2464                            );
2465                        }
2466                        return Ok(QueryResult::CommandOk {
2467                            affected: 0,
2468                            modified_catalog: false,
2469                        });
2470                    }
2471                }
2472                // v7.38 (read01 P3.19) — `SET LOCAL` scopes the change to
2473                // the current transaction: record the prior value in the
2474                // undo log so COMMIT / ROLLBACK (and ROLLBACK TO) restore
2475                // it. Outside a transaction block it has no lasting effect
2476                // (PG scopes it to the implicit single-statement txn), so
2477                // it is dropped rather than persisted to the session.
2478                if local {
2479                    if self.in_transaction() {
2480                        let prior = self.session_param(&name).map(String::from);
2481                        self.local_guc_saves.push((name.clone(), prior));
2482                        self.set_session_param(name, value);
2483                    }
2484                } else {
2485                    self.set_session_param(name, value);
2486                }
2487                Ok(QueryResult::CommandOk {
2488                    affected: 0,
2489                    modified_catalog: false,
2490                })
2491            }
2492            // v7.38 轴 4 — `SET TRANSACTION ISOLATION LEVEL …`. The
2493            // surface is recorded on `Engine::current_isolation_level`
2494            // and visible via `SHOW transaction_isolation`. Behavioural
2495            // implementation (REPEATABLE READ snapshot / SERIALIZABLE
2496            // SSI) lands separately; today every level reads as
2497            // effective READ COMMITTED (same as PG's silent upgrade
2498            // of READ UNCOMMITTED).
2499            Statement::SetTransaction { isolation } => {
2500                // v7.37.17 (Phase E3) — PG rejects an isolation switch
2501                // after the transaction's first query (SQLSTATE 25001);
2502                // silently applying it to the remaining statements would
2503                // give a tx that is half one level, half another.
2504                if let Some(tx_id) = self.current_tx
2505                    && self
2506                        .tx_catalogs
2507                        .get(&tx_id)
2508                        .is_some_and(|st| st.stmts_run > 0)
2509                {
2510                    return Err(EngineError::Unsupported(
2511                        "SET TRANSACTION ISOLATION LEVEL must be called before any query".into(),
2512                    ));
2513                }
2514                self.current_isolation_level = isolation;
2515                // v7.37.17 (Phase E2) — inside an open tx, switching to
2516                // RR/SER BEFORE the first query freezes the tx's view by
2517                // caching a snapshot now (PG allows the switch until the
2518                // first query; the RC rebase keys off cached_snapshot).
2519                // Switching (back) to RC/RU clears it so the rebase
2520                // resumes.
2521                if let Some(tx_id) = self.current_tx
2522                    && self.tx_catalogs.contains_key(&tx_id)
2523                {
2524                    let cache = match isolation {
2525                        spg_sql::ast::IsolationLevel::RepeatableRead
2526                        | spg_sql::ast::IsolationLevel::Serializable => {
2527                            Some(self.current_snapshot())
2528                        }
2529                        spg_sql::ast::IsolationLevel::ReadUncommitted
2530                        | spg_sql::ast::IsolationLevel::ReadCommitted => None,
2531                    };
2532                    if let Some(st) = self.tx_catalogs.get_mut(&tx_id) {
2533                        st.cached_snapshot = cache;
2534                    }
2535                }
2536                Ok(QueryResult::CommandOk {
2537                    affected: 0,
2538                    modified_catalog: false,
2539                })
2540            }
2541            // v7.38 轴 4 surface expansion — `SHOW <parameter>`
2542            // returns a 1-row 1-column TEXT result (the PG psql
2543            // wire shape). The handler dispatches per-name:
2544            //
2545            // 1. transaction_isolation — direct read of
2546            //    current_isolation_level (the v7.38 axis-4 surface).
2547            // 2. PG preset / engine-tracked params — values mirror
2548            //    pg_catalog.pg_settings to keep ORM /
2549            //    driver-connect probes happy (sqlx asks
2550            //    server_version + standard_conforming_strings +
2551            //    client_encoding; npgsql asks application_name;
2552            //    asyncpg asks search_path). Any
2553            //    SET-tracked override on self.session_params wins.
2554            // 3. Anything else — error with a list-pointer to
2555            //    pg_settings (which lists every recognised name).
2556            Statement::ShowParameter(name) => self.exec_show_parameter(name),
2557            // v7.14.0 — MySQL multi-assignment SET. Each pair runs
2558            // through `set_session_param` so engine-known params
2559            // (FOREIGN_KEY_CHECKS, session_replication_role, …) take
2560            // effect; unknown pairs (including `@VAR` LHS from the
2561            // mysqldump preamble) are recorded then ignored.
2562            Statement::SetParameterList(pairs) => {
2563                // Same validation as the single form (round 501).
2564                for (name, _) in &pairs {
2565                    if let Some(msg) = self.reject_unsettable_guc(name) {
2566                        return Err(EngineError::Unsupported(msg));
2567                    }
2568                }
2569                for (name, value) in pairs {
2570                    self.set_session_param(name, value);
2571                }
2572                Ok(QueryResult::CommandOk {
2573                    affected: 0,
2574                    modified_catalog: false,
2575                })
2576            }
2577            // v7.12.4 — CREATE FUNCTION / CREATE TRIGGER / DROP …
2578            // for the PL/pgSQL trigger surface. exec_* methods are
2579            // defined alongside the existing CREATE handlers below.
2580            Statement::CreateFunction(s) => self.exec_create_function(s),
2581            Statement::CreateTrigger(s) => self.exec_create_trigger(s),
2582            Statement::DropTrigger {
2583                name,
2584                table,
2585                if_exists,
2586            } => self.exec_drop_trigger(&name, &table, if_exists),
2587            Statement::CreateRule(s) => self.exec_create_rule(s),
2588            Statement::DropRule {
2589                name,
2590                table,
2591                if_exists,
2592            } => self.exec_drop_rule(&name, &table, if_exists),
2593            Statement::DropFunction {
2594                name,
2595                args,
2596                if_exists,
2597            } => self.exec_drop_function(&name, args.as_deref(), if_exists),
2598            Statement::CreateSequence(s) => self.exec_create_sequence(s),
2599            Statement::AlterSequence(s) => self.exec_alter_sequence(s),
2600            Statement::DropSequence { names, if_exists } => {
2601                self.exec_drop_sequence(&names, if_exists)
2602            }
2603            Statement::CreateView(s) => self.exec_create_view(s),
2604            Statement::DropView { names, if_exists } => self.exec_drop_view(&names, if_exists),
2605            Statement::CreateMaterializedView(s) => self.exec_create_materialized_view(s),
2606            Statement::RefreshMaterializedView { name, with_data } => {
2607                self.exec_refresh_materialized_view(&name, with_data)
2608            }
2609            Statement::DropMaterializedView { names, if_exists } => {
2610                self.exec_drop_materialized_view(&names, if_exists)
2611            }
2612            Statement::CreateType(s) => self.exec_create_type(s),
2613            Statement::CommentOn {
2614                kind,
2615                name,
2616                comment,
2617            } => self.exec_comment_on(&kind, &name, comment.as_deref()),
2618            Statement::AlterTypeRenameValue {
2619                type_name,
2620                old,
2621                new,
2622            } => {
2623                self.active_catalog_mut()
2624                    .rename_enum_value(&type_name, &old, &new)
2625                    .map_err(EngineError::Storage)?;
2626                Ok(QueryResult::CommandOk {
2627                    affected: 0,
2628                    modified_catalog: self.catalog_change_is_committed(),
2629                })
2630            }
2631            Statement::AlterTypeAddValue {
2632                type_name,
2633                label,
2634                if_not_exists,
2635                position,
2636            } => {
2637                let added = self
2638                    .active_catalog_mut()
2639                    .add_enum_value(&type_name, &label, if_not_exists, position)
2640                    .map_err(EngineError::Storage)?;
2641                Ok(QueryResult::CommandOk {
2642                    affected: 0,
2643                    modified_catalog: added,
2644                })
2645            }
2646            Statement::DropType { names, if_exists } => self.exec_drop_type(&names, if_exists),
2647            Statement::CreateDomain(s) => self.exec_create_domain(s),
2648            Statement::AlterDomain { name, action } => self.exec_alter_domain(&name, action),
2649            Statement::DropDomain { names, if_exists } => self.exec_drop_domain(&names, if_exists),
2650            Statement::CreateSchema {
2651                name,
2652                if_not_exists,
2653            } => self.exec_create_schema(name, if_not_exists),
2654            Statement::DropSchema { names, if_exists } => self.exec_drop_schema(&names, if_exists),
2655            Statement::ResetParameter(target) => {
2656                match target {
2657                    // v7.39 (round 320, V53) — RESET ALL resets GUCs. It
2658                    // must NOT throw away the two internal keys the server
2659                    // parks in the same map: the connection's login
2660                    // identity and its database. PG has no way to reset
2661                    // those with RESET ALL (they are not GUCs), and
2662                    // clearing them here made `current_user` fall back to
2663                    // the admin default mid-session.
2664                    None => self.reset_all_gucs(),
2665                    Some(name) => {
2666                        self.session_params.remove(&name.to_ascii_lowercase());
2667                    }
2668                }
2669                self.refresh_render_style();
2670                Ok(QueryResult::CommandOk {
2671                    affected: 0,
2672                    modified_catalog: false,
2673                })
2674            }
2675        };
2676        self.enforce_row_limit(result)
2677    }
2678}
2679
2680impl Engine {
2681    /// v7.39 (round 247) — resolve the CSV-only extras. QUOTE / ESCAPE /
2682    /// FORCE_QUOTE outside CSV mode are PG's 0A000 refusals (SPG used to
2683    /// ignore a text-mode QUOTE silently); the returned mask marks the
2684    /// force-quoted columns of `column_names`.
2685    fn resolve_copy_csv_extras(
2686        options: &spg_sql::ast::CopyOptions,
2687        is_csv: bool,
2688        quote: char,
2689        column_names: &[alloc::string::String],
2690    ) -> Result<(char, Option<alloc::vec::Vec<bool>>), EngineError> {
2691        if !is_csv {
2692            if options.quote.is_some() {
2693                return Err(EngineError::Unsupported(
2694                    "COPY QUOTE requires CSV mode".into(),
2695                ));
2696            }
2697            if options.escape.is_some() {
2698                return Err(EngineError::Unsupported(
2699                    "COPY ESCAPE requires CSV mode".into(),
2700                ));
2701            }
2702        }
2703        // v7.39 (round 265) — the direction-dependent rules (FORCE_QUOTE is
2704        // TO-only, FORCE_NOT_NULL / FORCE_NULL are FROM-only), sharing one
2705        // validator with the FROM path.
2706        crate::copy::validate_copy_option_direction(options, true)?;
2707        let escape = options.escape.unwrap_or(quote);
2708        let force = match &options.force_quote {
2709            None => None,
2710            Some(cols) if cols.is_empty() => Some(alloc::vec![true; column_names.len()]),
2711            Some(cols) => {
2712                let mut mask = alloc::vec![false; column_names.len()];
2713                for c in cols {
2714                    let pos = column_names
2715                        .iter()
2716                        .position(|n| n.eq_ignore_ascii_case(c))
2717                        .ok_or_else(|| {
2718                            EngineError::Unsupported(alloc::format!(
2719                                "column \"{c}\" does not exist"
2720                            ))
2721                        })?;
2722                    mask[pos] = true;
2723                }
2724                Some(mask)
2725            }
2726        };
2727        Ok((escape, force))
2728    }
2729
2730    /// v7.39 (round 249) — resolve the effective COPY FROM target column
2731    /// list, running PG's pre-file checks in PG's order: the relation
2732    /// must exist, an explicit column must exist on it, and no column
2733    /// may appear twice — all before a single data row is looked at.
2734    ///
2735    /// # Errors
2736    /// `relation "t" does not exist`, `column "x" of relation "t" does
2737    /// not exist` (42703), `column "x" specified more than once` (42701).
2738    /// v7.39 (round 343, V40) — store a file the host just read as a
2739    /// large object. The host does the IO (the engine is `no_std`); the
2740    /// catalog side is the same `create_large_object` the rest of the
2741    /// lo_* family uses, so an imported object is indistinguishable from
2742    /// one built with `lo_from_bytea`.
2743    pub fn lo_import_bytes(
2744        &mut self,
2745        want_oid: u32,
2746        data: alloc::vec::Vec<u8>,
2747    ) -> Result<u32, EngineError> {
2748        self.active_catalog_mut()
2749            .create_large_object(want_oid, data)
2750            .map_err(EngineError::Unsupported)
2751    }
2752
2753    /// v7.39 (round 343, V40) — the bytes the host is about to write out.
2754    /// PG's message for a missing object, verbatim.
2755    pub fn lo_export_bytes(&self, oid: u32) -> Result<alloc::vec::Vec<u8>, EngineError> {
2756        self.active_catalog()
2757            .large_object(oid)
2758            .map(<[u8]>::to_vec)
2759            .ok_or_else(|| {
2760                EngineError::Unsupported(alloc::format!("large object {oid} does not exist"))
2761            })
2762    }
2763
2764    pub fn copy_target_columns(
2765        &self,
2766        table: &str,
2767        columns: Option<&[alloc::string::String]>,
2768    ) -> Result<alloc::vec::Vec<alloc::string::String>, EngineError> {
2769        let table_ref = self.active_catalog().get(table).ok_or_else(|| {
2770            EngineError::Storage(spg_storage::StorageError::TableNotFound {
2771                name: alloc::string::String::from(table),
2772            })
2773        })?;
2774        let schema_cols = &table_ref.schema().columns;
2775        match columns {
2776            None => Ok(schema_cols.iter().map(|c| c.name.clone()).collect()),
2777            Some(cols) => {
2778                for (i, name) in cols.iter().enumerate() {
2779                    if !schema_cols
2780                        .iter()
2781                        .any(|c| c.name.eq_ignore_ascii_case(name))
2782                    {
2783                        return Err(EngineError::Unsupported(alloc::format!(
2784                            "column \"{name}\" of relation \"{table}\" does not exist"
2785                        )));
2786                    }
2787                    if cols[..i].iter().any(|p| p.eq_ignore_ascii_case(name)) {
2788                        return Err(EngineError::Unsupported(alloc::format!(
2789                            "column \"{name}\" specified more than once"
2790                        )));
2791                    }
2792                }
2793                Ok(cols.to_vec())
2794            }
2795        }
2796    }
2797
2798    /// v7.39 (round 249) — execute a parsed `COPY … FROM '<file>'` whose
2799    /// file contents the HOST has already read (the engine is no_std and
2800    /// performs no I/O). Lowers to per-row INSERTs via
2801    /// [`crate::copy::copy_buffer_inserts`]; outside an explicit
2802    /// transaction the rows are wrapped in one, so a bad row aborts the
2803    /// whole COPY exactly as in PG.
2804    ///
2805    /// # Errors
2806    /// The failing row's INSERT error propagates (after rollback).
2807    pub fn copy_from_buffer(
2808        &mut self,
2809        table: &str,
2810        columns: Option<&[alloc::string::String]>,
2811        options: &spg_sql::ast::CopyOptions,
2812        data: &str,
2813    ) -> Result<QueryResult, EngineError> {
2814        let target = self.copy_target_columns(table, columns)?;
2815        let inserts = crate::copy::copy_buffer_inserts(table, columns, &target, options, data)?;
2816        let wrap = !self.in_transaction();
2817        if wrap {
2818            self.execute("BEGIN")?;
2819        }
2820        let mut affected: usize = 0;
2821        for insert in &inserts {
2822            match self.execute(insert) {
2823                Ok(QueryResult::CommandOk { affected: n, .. }) => affected += n,
2824                Ok(_) => affected += 1,
2825                Err(e) => {
2826                    if wrap {
2827                        let _ = self.execute("ROLLBACK");
2828                    }
2829                    return Err(e);
2830                }
2831            }
2832        }
2833        if wrap {
2834            self.execute("COMMIT")?;
2835        }
2836        Ok(QueryResult::CommandOk {
2837            affected,
2838            modified_catalog: false,
2839        })
2840    }
2841
2842    /// v7.39 (round 252) — render a `COPY … TO '<file>'` payload for the
2843    /// HOST to write (the engine is no_std and performs no I/O). Returns
2844    /// the encoded bytes (one line per record, trailing newline) and the
2845    /// DATA row count for the `COPY n` tag — the HEADER line, when
2846    /// present, is part of the payload but not of the count.
2847    ///
2848    /// # Errors
2849    /// Same surface as `COPY … TO STDOUT` (missing relation / column,
2850    /// CSV-mode option refusals).
2851    pub fn copy_to_buffer(
2852        &mut self,
2853        table: &str,
2854        columns: Option<&[alloc::string::String]>,
2855        query: Option<&Statement>,
2856        options: &spg_sql::ast::CopyOptions,
2857    ) -> Result<(alloc::string::String, usize), EngineError> {
2858        let result = self.exec_copy_to(table, columns, query, options, CancelToken::none())?;
2859        let QueryResult::Rows { rows, .. } = result else {
2860            return Err(EngineError::Unsupported(
2861                "COPY TO rendered a non-row result".into(),
2862            ));
2863        };
2864        let mut payload = alloc::string::String::new();
2865        for row in &rows {
2866            if let Some(Value::Text(line)) = row.values.first() {
2867                payload.push_str(line);
2868            }
2869            payload.push('\n');
2870        }
2871        let data_rows = rows.len().saturating_sub(usize::from(options.header));
2872        Ok((payload, data_rows))
2873    }
2874
2875    /// `COPY table [(cols)] TO STDOUT` — render the visible rows
2876    /// in COPY text format (tab-separated, `\N` nulls, backslash
2877    /// escapes) as a single-text-column result set. Embedded
2878    /// consumers read the lines directly; the wire layer streams
2879    /// CopyData frames from them.
2880    fn exec_copy_to(
2881        &mut self,
2882        table_name: &str,
2883        columns: Option<&[String]>,
2884        query: Option<&Statement>,
2885        options: &spg_sql::ast::CopyOptions,
2886        cancel: CancelToken<'_>,
2887    ) -> Result<QueryResult, EngineError> {
2888        use spg_sql::ast::CopyFormat;
2889        // v7.39 (read01 round 94) — `COPY (<query>) TO STDOUT`: run the inner
2890        // statement and render its result set with the same per-format cell
2891        // encoder the table form uses. Kept as an early branch so the
2892        // battle-tested table path below is untouched.
2893        if let Some(q) = query {
2894            return self.exec_copy_to_query(q, options, cancel);
2895        }
2896        let table = self.active_catalog().get(table_name).ok_or_else(|| {
2897            EngineError::Storage(spg_storage::StorageError::TableNotFound {
2898                name: alloc::string::String::from(table_name),
2899            })
2900        })?;
2901        let schema_cols = table.schema().columns.clone();
2902        let positions: alloc::vec::Vec<usize> = match columns {
2903            Some(cols) => cols
2904                .iter()
2905                .map(|c| {
2906                    schema_cols
2907                        .iter()
2908                        .position(|s| s.name.eq_ignore_ascii_case(c))
2909                        .ok_or_else(|| {
2910                            EngineError::Eval(crate::eval::EvalError::ColumnNotFound {
2911                                name: c.clone(),
2912                            })
2913                        })
2914                })
2915                .collect::<Result<_, _>>()?,
2916            None => (0..schema_cols.len()).collect(),
2917        };
2918        // Per-format defaults: text = tab / `\N`; csv = comma / `` / `"`.
2919        let is_csv = options.format == CopyFormat::Csv;
2920        let delimiter = options.delimiter.unwrap_or(if is_csv { ',' } else { '\t' });
2921        let quote = options.quote.unwrap_or('"');
2922        let null_str = options
2923            .null_str
2924            .clone()
2925            .unwrap_or_else(|| alloc::string::String::from(if is_csv { "" } else { "\\N" }));
2926        // v7.39 (round 247) — the FORCE_QUOTE mask follows the emitted
2927        // column order (the projection), not the table order.
2928        let out_names: alloc::vec::Vec<alloc::string::String> = positions
2929            .iter()
2930            .filter_map(|&p| schema_cols.get(p).map(|c| c.name.clone()))
2931            .collect();
2932        let (escape, force_mask) =
2933            Self::resolve_copy_csv_extras(options, is_csv, quote, &out_names)?;
2934        let encode_cells = |cells: &[Option<alloc::string::String>]| -> alloc::string::String {
2935            if is_csv {
2936                crate::copy::encode_copy_csv_cells_opts(
2937                    cells,
2938                    delimiter,
2939                    quote,
2940                    escape,
2941                    force_mask.as_deref(),
2942                    &null_str,
2943                )
2944            } else {
2945                crate::copy::encode_copy_text_cells_opts(cells, delimiter, &null_str)
2946            }
2947        };
2948        let snap = self.current_snapshot();
2949        let mut out_rows: alloc::vec::Vec<spg_storage::Row<'static>> = alloc::vec::Vec::new();
2950        // HEADER: the selected column names as the first line, encoded
2951        // per the same format rules (a name is never NULL).
2952        if options.header {
2953            let names: alloc::vec::Vec<Option<alloc::string::String>> = positions
2954                .iter()
2955                .map(|&p| Some(schema_cols[p].name.clone()))
2956                .collect();
2957            out_rows.push(spg_storage::Row::new(alloc::vec![Value::text(
2958                encode_cells(&names)
2959            )]));
2960        }
2961        // COPY renders each value with its type's output function, the
2962        // same as the wire — notably bool as `t` / `f`, not the engine's
2963        // debug-ish `true` / `false`.
2964        // v7.38 (T-tstz Phase 1) — `ty` is the column's declared type, needed
2965        // only to tell timestamptz from timestamp: PG's COPY renders the former
2966        // with its offset. Everything else renders identically either way.
2967        let cell_text = |v: &Value, ty: spg_storage::DataType| -> Option<alloc::string::String> {
2968            match v {
2969                Value::Null => None,
2970                Value::Bool(b) => Some(alloc::string::String::from(if *b { "t" } else { "f" })),
2971                Value::Timestamp(t) if matches!(ty, spg_storage::DataType::Timestamptz) => {
2972                    Some(crate::eval::format_timestamptz(*t))
2973                }
2974                other => Some(crate::eval::values::value_to_text(other)),
2975            }
2976        };
2977        let encode = |row: &spg_storage::Row<'static>| {
2978            let cells: alloc::vec::Vec<Option<alloc::string::String>> = positions
2979                .iter()
2980                .map(|&p| {
2981                    row.values
2982                        .get(p)
2983                        .and_then(|v| cell_text(v, schema_cols[p].ty))
2984                })
2985                .collect();
2986            encode_cells(&cells)
2987        };
2988        for (_, row) in table.scan_visible(&snap) {
2989            cancel.check()?;
2990            out_rows.push(spg_storage::Row::new(alloc::vec![Value::text(encode(row))]));
2991        }
2992        for row in self.iter_cold_rows_of_table(table) {
2993            cancel.check()?;
2994            out_rows.push(spg_storage::Row::new(alloc::vec![Value::text(encode(
2995                &row
2996            ))]));
2997        }
2998        Ok(QueryResult::Rows {
2999            columns: alloc::vec![spg_storage::ColumnSchema::new(
3000                alloc::string::String::from("copy"),
3001                spg_storage::DataType::Text,
3002                false,
3003            )],
3004            rows: out_rows,
3005        })
3006    }
3007
3008    /// v7.39 (read01 round 94) — the `COPY (<query>) TO STDOUT` renderer.
3009    /// Executes the inner statement and encodes its result set into a single
3010    /// `copy` text column (one row per COPY line, header first when asked),
3011    /// exactly like the table form's tail — the difference is only where the
3012    /// rows and their column types come from.
3013    fn exec_copy_to_query(
3014        &mut self,
3015        query: &Statement,
3016        options: &spg_sql::ast::CopyOptions,
3017        cancel: CancelToken<'_>,
3018    ) -> Result<QueryResult, EngineError> {
3019        use spg_sql::ast::CopyFormat;
3020        let (result_cols, result_rows) = match self.dispatch_stmt_inner(query.clone(), cancel)? {
3021            QueryResult::Rows { columns, rows } => (columns, rows),
3022            _ => {
3023                return Err(EngineError::Unsupported(
3024                    "COPY (query) source did not produce a result set".into(),
3025                ));
3026            }
3027        };
3028        let is_csv = options.format == CopyFormat::Csv;
3029        let delimiter = options.delimiter.unwrap_or(if is_csv { ',' } else { '\t' });
3030        let quote = options.quote.unwrap_or('"');
3031        let null_str = options
3032            .null_str
3033            .clone()
3034            .unwrap_or_else(|| alloc::string::String::from(if is_csv { "" } else { "\\N" }));
3035        let out_names: alloc::vec::Vec<alloc::string::String> =
3036            result_cols.iter().map(|c| c.name.clone()).collect();
3037        let (escape, force_mask) =
3038            Self::resolve_copy_csv_extras(options, is_csv, quote, &out_names)?;
3039        let encode_cells = |cells: &[Option<alloc::string::String>]| -> alloc::string::String {
3040            if is_csv {
3041                crate::copy::encode_copy_csv_cells_opts(
3042                    cells,
3043                    delimiter,
3044                    quote,
3045                    escape,
3046                    force_mask.as_deref(),
3047                    &null_str,
3048                )
3049            } else {
3050                crate::copy::encode_copy_text_cells_opts(cells, delimiter, &null_str)
3051            }
3052        };
3053        let cell_text = |v: &Value, ty: spg_storage::DataType| -> Option<alloc::string::String> {
3054            match v {
3055                Value::Null => None,
3056                Value::Bool(b) => Some(alloc::string::String::from(if *b { "t" } else { "f" })),
3057                Value::Timestamp(t) if matches!(ty, spg_storage::DataType::Timestamptz) => {
3058                    Some(crate::eval::format_timestamptz(*t))
3059                }
3060                other => Some(crate::eval::values::value_to_text(other)),
3061            }
3062        };
3063        let mut out_rows: alloc::vec::Vec<spg_storage::Row<'static>> = alloc::vec::Vec::new();
3064        if options.header {
3065            let names: alloc::vec::Vec<Option<alloc::string::String>> =
3066                result_cols.iter().map(|c| Some(c.name.clone())).collect();
3067            out_rows.push(spg_storage::Row::new(alloc::vec![Value::text(
3068                encode_cells(&names)
3069            )]));
3070        }
3071        for row in &result_rows {
3072            cancel.check()?;
3073            let cells: alloc::vec::Vec<Option<alloc::string::String>> = result_cols
3074                .iter()
3075                .enumerate()
3076                .map(|(p, c)| row.values.get(p).and_then(|v| cell_text(v, c.ty)))
3077                .collect();
3078            out_rows.push(spg_storage::Row::new(alloc::vec![Value::text(
3079                encode_cells(&cells)
3080            )]));
3081        }
3082        Ok(QueryResult::Rows {
3083            columns: alloc::vec![spg_storage::ColumnSchema::new(
3084                alloc::string::String::from("copy"),
3085                spg_storage::DataType::Text,
3086                false,
3087            )],
3088            rows: out_rows,
3089        })
3090    }
3091}
3092
3093impl Engine {
3094    /// PG's `PreventInTransactionBlock`: statements whose effect no
3095    /// rollback can undo are refused inside an explicit transaction with
3096    /// 25001, naming themselves in the message.
3097    ///
3098    /// The witness is THIS connection's slot, not the global
3099    /// `in_transaction()`: the engine is shared, so a global check would
3100    /// refuse an autocommit VACUUM merely because a different connection
3101    /// had a transaction open. Same predicate `DISCARD ALL` already uses.
3102    /// Whether a catalog change this statement made is already committed,
3103    /// i.e. THIS connection is not inside an explicit transaction block.
3104    ///
3105    /// It rides out on `QueryResult::modified_catalog`, and the server
3106    /// takes it as "persist and audit this now": in no-WAL mode it drives
3107    /// the snapshot write, and it gates the audit append in every mode.
3108    ///
3109    /// The witness has to be this connection's slot. Asking the
3110    /// engine-wide `in_transaction()` — true while ANY connection holds a
3111    /// transaction — reported an autocommit DDL as uncommitted, and both
3112    /// consequences were measured in round 795: the statement was missing
3113    /// from the audit log entirely, and after `kill -9` plus a restart the
3114    /// table it created was gone, having been acked to the client. A
3115    /// second connection idling inside a BEGIN was the whole cause.
3116    pub(crate) fn catalog_change_is_committed(&self) -> bool {
3117        !self.current_tx.is_some_and(|tx| self.is_tx_open(tx))
3118    }
3119
3120    pub(crate) fn require_no_transaction_block(&self, what: &str) -> Result<(), EngineError> {
3121        if self.current_tx.is_some_and(|tx| self.is_tx_open(tx)) {
3122            return Err(EngineError::Unsupported(alloc::format!(
3123                "{what} cannot run inside a transaction block"
3124            )));
3125        }
3126        Ok(())
3127    }
3128}