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