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