Skip to main content

spg_engine/
lib.rs

1//! SPG execution engine — v0.3 wires the SQL front-end to the in-memory
2//! storage layer. Implements `CREATE TABLE`, single-row `INSERT VALUES`, and
3//! `SELECT * FROM <table>` (no WHERE yet — that lands in v0.4 alongside
4//! expression evaluation against rows).
5#![no_std]
6
7extern crate alloc;
8
9pub mod aggregate;
10pub mod describe;
11pub mod eval;
12pub mod fts;
13pub mod json;
14pub mod memoize;
15pub mod plan_cache;
16pub mod publications;
17pub mod query_stats;
18pub mod reorder;
19pub mod selectivity;
20pub mod statistics;
21pub mod subscriptions;
22pub mod triggers;
23pub mod users;
24
25pub use crate::users::{Role, ScramSecrets, UserError, UserStore};
26
27use alloc::borrow::Cow;
28use alloc::boxed::Box;
29use alloc::collections::BTreeMap;
30use alloc::string::{String, ToString};
31use alloc::vec::Vec;
32use core::fmt;
33
34use spg_sql::ast::{
35    BinOp, ColumnDef, ColumnName, ColumnTypeName, CreateIndexStatement, CreatePublicationStatement,
36    CreateSubscriptionStatement, CreateTableStatement, CreateUserStatement, Expr, FrameBound,
37    FrameKind, FromClause, IndexMethod, InsertStatement, JoinKind, Literal, OrderBy, SelectItem,
38    SelectStatement, Statement, TableRef, UnOp, UnionKind, VecEncoding as SqlVecEncoding,
39    WindowFrame,
40};
41// v7.16.0 — re-export the parsed-statement AST so downstream
42// crates (spg-embedded → spg-sqlx) don't need a direct dep on
43// spg-sql for the prepare/bind handle.
44pub use spg_sql::ast::Statement as ParsedStatement;
45use spg_sql::parser::{self, ParseError};
46use spg_storage::{
47    Catalog, ColumnSchema, CompactReport, DataType, IndexKey, IndexKind, Row, StorageError, Table,
48    TableSchema, Value, VecEncoding,
49};
50
51use crate::eval::{EvalContext, EvalError};
52
53/// Result of executing one statement.
54#[derive(Debug, Clone, PartialEq)]
55#[non_exhaustive]
56pub enum QueryResult {
57    /// DDL or DML succeeded.
58    ///
59    /// `affected` is the row count for `INSERT` and 0 elsewhere.
60    /// `modified_catalog` tells the server whether this statement
61    /// caused the *committed* catalog to change — it's the signal to
62    /// snapshot/audit. False for `BEGIN`/`ROLLBACK`, false for writeful
63    /// statements executed inside a transaction (those only touch the
64    /// shadow), and true for `COMMIT` and for writes outside a TX.
65    CommandOk {
66        affected: usize,
67        modified_catalog: bool,
68    },
69    /// `SELECT` returned a (possibly empty) row set.
70    Rows {
71        columns: Vec<ColumnSchema>,
72        rows: Vec<Row>,
73    },
74}
75
76/// All errors the engine can return.
77///
78/// Marked `#[non_exhaustive]` from v7.5.0 onward: external `match`
79/// must include a `_` arm so new variants in subsequent v7.x releases
80/// are not breaking changes.
81#[derive(Debug, Clone, PartialEq)]
82#[non_exhaustive]
83pub enum EngineError {
84    Parse(ParseError),
85    Storage(StorageError),
86    Eval(EvalError),
87    /// Front-end accepted a construct that the v0.x executor doesn't support.
88    Unsupported(String),
89    /// `BEGIN` while another transaction is already open.
90    TransactionAlreadyOpen,
91    /// `COMMIT` / `ROLLBACK` with no active transaction.
92    NoActiveTransaction,
93    /// v4.0 sentinel: `execute_readonly` got a statement that
94    /// mutates engine state (INSERT / CREATE / BEGIN / COMMIT / …).
95    /// The caller should retake the write lock and dispatch through
96    /// `execute(&mut self)` instead.
97    WriteRequired,
98    /// v4.2: a SELECT would have returned more rows than the
99    /// configured `max_query_rows` cap. Carries the cap.
100    RowLimitExceeded(usize),
101    /// v4.5: cooperative cancellation — the host (server's
102    /// per-query watchdog) set the cancel flag while a long-running
103    /// SELECT / UPDATE / DELETE was scanning rows. The partial work
104    /// is discarded; the caller should surface this as a timeout
105    /// to the client.
106    Cancelled,
107}
108
109impl fmt::Display for EngineError {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        match self {
112            Self::Parse(e) => write!(f, "parse: {e}"),
113            Self::Storage(e) => write!(f, "storage: {e}"),
114            Self::Eval(e) => write!(f, "eval: {e}"),
115            Self::Unsupported(s) => write!(f, "unsupported: {s}"),
116            Self::TransactionAlreadyOpen => f.write_str("a transaction is already open"),
117            Self::NoActiveTransaction => f.write_str("no active transaction"),
118            Self::WriteRequired => {
119                f.write_str("statement requires a write lock (use execute, not execute_readonly)")
120            }
121            Self::RowLimitExceeded(n) => {
122                write!(f, "query exceeded max_query_rows={n}")
123            }
124            Self::Cancelled => f.write_str("query cancelled (timeout or client request)"),
125        }
126    }
127}
128
129impl From<ParseError> for EngineError {
130    fn from(e: ParseError) -> Self {
131        Self::Parse(e)
132    }
133}
134impl From<StorageError> for EngineError {
135    fn from(e: StorageError) -> Self {
136        Self::Storage(e)
137    }
138}
139impl From<EvalError> for EngineError {
140    fn from(e: EvalError) -> Self {
141        Self::Eval(e)
142    }
143}
144
145/// The execution engine. Holds the catalog and (later) other server-scope
146/// state. `Engine::new()` is intentionally cheap so callers can construct one
147/// per database, per test.
148/// Function pointer that returns "now" as microseconds since Unix
149/// epoch. The engine is `no_std`, so it can't reach for `std::time`
150/// itself — callers (`spg-server`, the sqllogictest runner) inject a
151/// concrete implementation. `None` means `NOW()` / `CURRENT_*` raise
152/// `Unsupported`.
153pub type ClockFn = fn() -> i64;
154
155/// Function pointer that produces 16 cryptographically random bytes.
156/// Like `ClockFn`, the engine is `no_std` and can't reach for /dev/urandom
157/// itself — host (`spg-server`) injects an OS-backed source. `None`
158/// means SQL-driven `CREATE USER` falls back to a deterministic salt
159/// derived from the username (acceptable in tests; the server always
160/// installs a real RNG so production paths never see this).
161pub type SaltFn = fn() -> [u8; 16];
162
163/// v4.5 cooperative cancellation token. A long-running SELECT /
164/// UPDATE / DELETE checks `is_cancelled` at row-loop checkpoints
165/// and bails with `EngineError::Cancelled`. The host
166/// (`spg-server`) creates an `AtomicBool` per query, spawns a
167/// watchdog thread that sets it after `SPG_QUERY_TIMEOUT_MS`,
168/// and passes it via `execute_with_cancel` / `execute_readonly_with_cancel`.
169///
170/// `CancelToken::none()` is a no-op — used by the legacy `execute`
171/// and `execute_readonly` entry points so existing callers don't
172/// change.
173/// v7.17.0 Phase 2.3 — monotonic time source for deadline-aware
174/// cancellation (PG `statement_timeout`). Returns microseconds
175/// since some host-stable monotonic origin (typically the first
176/// call into `Instant::now()` on the server). The engine never
177/// calls `Instant::now()` directly so the crate stays `#![no_std]`.
178pub type MonotonicNowFn = fn() -> u64;
179
180#[derive(Debug, Clone, Copy)]
181struct Deadline {
182    now_fn: MonotonicNowFn,
183    /// Absolute deadline in `now_fn()` units (microseconds).
184    deadline_us: u64,
185}
186
187#[derive(Debug, Clone, Copy)]
188pub struct CancelToken<'a> {
189    flag: Option<&'a core::sync::atomic::AtomicBool>,
190    // v7.17.0 Phase 2.3 — when set, every existing `cancel.check()`
191    // checkpoint also fires `EngineError::Cancelled` once
192    // `(now_fn)() >= deadline_us`. No new check sites, no thread
193    // spawn per query — the monotonic now-fn read is a vDSO
194    // `clock_gettime(CLOCK_MONOTONIC)` (~20ns) and only runs when
195    // the host actually wired a deadline (statement_timeout > 0).
196    deadline: Option<Deadline>,
197}
198
199impl<'a> CancelToken<'a> {
200    #[must_use]
201    pub const fn none() -> Self {
202        Self {
203            flag: None,
204            deadline: None,
205        }
206    }
207
208    #[must_use]
209    pub const fn from_flag(f: &'a core::sync::atomic::AtomicBool) -> Self {
210        Self {
211            flag: Some(f),
212            deadline: None,
213        }
214    }
215
216    /// v7.17.0 Phase 2.3 — attach a monotonic deadline. `now_fn`
217    /// must return microseconds since a stable origin; the token
218    /// trips when `now_fn() >= deadline_us`. Compose with
219    /// `from_flag(...)` when both a watchdog flag and a per-statement
220    /// timeout are in play (e.g. server-wide `SPG_QUERY_TIMEOUT_MS`
221    /// plus session `statement_timeout`); the tighter of the two
222    /// wins by virtue of either signaling first.
223    #[must_use]
224    pub const fn with_deadline(mut self, now_fn: MonotonicNowFn, deadline_us: u64) -> Self {
225        self.deadline = Some(Deadline {
226            now_fn,
227            deadline_us,
228        });
229        self
230    }
231
232    #[must_use]
233    pub fn is_cancelled(self) -> bool {
234        if self
235            .flag
236            .is_some_and(|f| f.load(core::sync::atomic::Ordering::Relaxed))
237        {
238            return true;
239        }
240        // Deadline check is the second branch so the "no timeout"
241        // hot path (`deadline: None`) elides the now-fn call —
242        // predicted-not-taken on the SLO INSERT loop.
243        if let Some(d) = self.deadline
244            && (d.now_fn)() >= d.deadline_us
245        {
246            return true;
247        }
248        false
249    }
250
251    /// Returns `Err(Cancelled)` if the token has been tripped.
252    /// Used at row-loop checkpoints to bail cooperatively without
253    /// scattering raw `is_cancelled` checks across the executor.
254    #[inline]
255    pub fn check(self) -> Result<(), EngineError> {
256        if self.is_cancelled() {
257            Err(EngineError::Cancelled)
258        } else {
259            Ok(())
260        }
261    }
262}
263
264// ---- snapshot envelope (v4.1, extended with CRC32 in v4.37,  ----
265// ----   publications in v6.1.2 v3, subscriptions in v6.1.4 v4) ----
266//
267// Wraps a catalog blob + a user blob behind a small header so the
268// server can persist both atomically without inventing a new file.
269// Bare catalog blobs (v3.x) still load via `restore_envelope` since
270// the magic check fails fast and the function falls back to
271// `Catalog::deserialize`.
272//
273// Layout — v1 (v4.1, no CRC):
274//   [8 bytes magic "SPGENV01"]
275//   [u8 version = 1]
276//   [u32 catalog_len][catalog bytes]
277//   [u32 users_len][users bytes]
278//
279// Layout — v2 (v4.37, CRC32 of body):
280//   [8 bytes magic "SPGENV01"]
281//   [u8 version = 2]
282//   [u32 catalog_len][catalog bytes]
283//   [u32 users_len][users bytes]
284//   [u32 crc32]                      ← CRC32 of every byte before it.
285//
286// Layout — v3 (v6.1.2, publications trailer):
287//   [8 bytes magic "SPGENV01"]
288//   [u8 version = 3]
289//   [u32 catalog_len][catalog bytes]
290//   [u32 users_len][users bytes]
291//   [u32 pubs_len][publications bytes]
292//   [u32 crc32]
293//
294// Layout — v4 (v6.1.4, subscriptions trailer):
295//   [8 bytes magic "SPGENV01"]
296//   [u8 version = 4]
297//   [u32 catalog_len][catalog bytes]
298//   [u32 users_len][users bytes]
299//   [u32 pubs_len][publications bytes]
300//   [u32 subs_len][subscriptions bytes]
301//   [u32 crc32]
302//
303// Layout — v5 (v6.2.0, statistics trailer):
304//   [8 bytes magic "SPGENV01"]
305//   [u8 version = 5]
306//   [u32 catalog_len][catalog bytes]
307//   [u32 users_len][users bytes]
308//   [u32 pubs_len][publications bytes]
309//   [u32 subs_len][subscriptions bytes]
310//   [u32 stats_len][statistics bytes]      ← NEW
311//   [u32 crc32]
312//
313// Writers emit v5 from v6.2.0 on. Readers accept all of {v1, v2,
314// v3, v4, v5}: v1/v2 load with empty publications / subscriptions /
315// statistics; v3 loads with empty subscriptions + statistics; v4
316// loads with empty statistics; v5 deserialises all three. Older
317// SPG versions reading a v5 envelope fall through the version
318// match to `EnvelopeParse::Bare` — pre-v6.2.0 binaries cannot
319// open v6.2.0+ snapshots (matches the v6.1.2 / v6.1.4 breaks).
320
321const ENVELOPE_MAGIC: &[u8; 8] = b"SPGENV01";
322const ENVELOPE_VERSION_V1: u8 = 1;
323const ENVELOPE_VERSION_V2: u8 = 2;
324const ENVELOPE_VERSION_V3: u8 = 3;
325const ENVELOPE_VERSION_V4: u8 = 4;
326const ENVELOPE_VERSION_V5: u8 = 5;
327
328fn build_envelope(catalog: &[u8], users: &[u8], pubs: &[u8], subs: &[u8], stats: &[u8]) -> Vec<u8> {
329    let mut out = Vec::with_capacity(
330        8 + 1
331            + 4
332            + catalog.len()
333            + 4
334            + users.len()
335            + 4
336            + pubs.len()
337            + 4
338            + subs.len()
339            + 4
340            + stats.len()
341            + 4,
342    );
343    out.extend_from_slice(ENVELOPE_MAGIC);
344    out.push(ENVELOPE_VERSION_V5);
345    out.extend_from_slice(
346        &u32::try_from(catalog.len())
347            .expect("≤ 4G catalog")
348            .to_le_bytes(),
349    );
350    out.extend_from_slice(catalog);
351    out.extend_from_slice(
352        &u32::try_from(users.len())
353            .expect("≤ 4G users")
354            .to_le_bytes(),
355    );
356    out.extend_from_slice(users);
357    out.extend_from_slice(
358        &u32::try_from(pubs.len())
359            .expect("≤ 4G publications")
360            .to_le_bytes(),
361    );
362    out.extend_from_slice(pubs);
363    out.extend_from_slice(
364        &u32::try_from(subs.len())
365            .expect("≤ 4G subscriptions")
366            .to_le_bytes(),
367    );
368    out.extend_from_slice(subs);
369    out.extend_from_slice(
370        &u32::try_from(stats.len())
371            .expect("≤ 4G statistics")
372            .to_le_bytes(),
373    );
374    out.extend_from_slice(stats);
375    let crc = spg_crypto::crc32::crc32(&out);
376    out.extend_from_slice(&crc.to_le_bytes());
377    out
378}
379
380/// Outcome of envelope parsing: either bare-catalog fallback, a
381/// successfully split section trio from a v1/v2/v3 envelope, or an
382/// explicit corruption error from a v2/v3 CRC mismatch. `Bare`
383/// (catalog-only fallback) preserves v3.x readability. v1/v2
384/// envelopes set `publications` to `None`; v3 sets it to the
385/// publications byte slice.
386enum EnvelopeParse<'a> {
387    Bare,
388    Pair {
389        catalog: &'a [u8],
390        users: &'a [u8],
391        publications: Option<&'a [u8]>,
392        subscriptions: Option<&'a [u8]>,
393        statistics: Option<&'a [u8]>,
394    },
395    CrcMismatch {
396        expected: u32,
397        computed: u32,
398    },
399}
400
401/// Returns `EnvelopeParse::Pair` for a valid v1 / v2 / v3 envelope,
402/// `Bare` for a buffer that doesn't look like an envelope (v3.x
403/// bare catalog fallback), and `CrcMismatch` for a v2/v3 envelope
404/// whose trailing CRC32 doesn't match the body.
405fn split_envelope(buf: &[u8]) -> EnvelopeParse<'_> {
406    if buf.len() < 8 + 1 + 4 || &buf[..8] != ENVELOPE_MAGIC {
407        return EnvelopeParse::Bare;
408    }
409    let version = buf[8];
410    if !matches!(
411        version,
412        ENVELOPE_VERSION_V1
413            | ENVELOPE_VERSION_V2
414            | ENVELOPE_VERSION_V3
415            | ENVELOPE_VERSION_V4
416            | ENVELOPE_VERSION_V5
417    ) {
418        return EnvelopeParse::Bare;
419    }
420    let mut p = 9usize;
421    let Some(cat_len_bytes) = buf.get(p..p + 4) else {
422        return EnvelopeParse::Bare;
423    };
424    let Ok(cat_len_arr) = cat_len_bytes.try_into() else {
425        return EnvelopeParse::Bare;
426    };
427    let cat_len = u32::from_le_bytes(cat_len_arr) as usize;
428    p += 4;
429    if p + cat_len + 4 > buf.len() {
430        return EnvelopeParse::Bare;
431    }
432    let catalog = &buf[p..p + cat_len];
433    p += cat_len;
434    let Some(user_len_bytes) = buf.get(p..p + 4) else {
435        return EnvelopeParse::Bare;
436    };
437    let Ok(user_len_arr) = user_len_bytes.try_into() else {
438        return EnvelopeParse::Bare;
439    };
440    let user_len = u32::from_le_bytes(user_len_arr) as usize;
441    p += 4;
442    if p + user_len > buf.len() {
443        return EnvelopeParse::Bare;
444    }
445    let users = &buf[p..p + user_len];
446    p += user_len;
447    let publications = if matches!(
448        version,
449        ENVELOPE_VERSION_V3 | ENVELOPE_VERSION_V4 | ENVELOPE_VERSION_V5
450    ) {
451        // [u32 pubs_len][publications bytes]
452        let Some(pubs_len_bytes) = buf.get(p..p + 4) else {
453            return EnvelopeParse::Bare;
454        };
455        let Ok(pubs_len_arr) = pubs_len_bytes.try_into() else {
456            return EnvelopeParse::Bare;
457        };
458        let pubs_len = u32::from_le_bytes(pubs_len_arr) as usize;
459        p += 4;
460        if p + pubs_len > buf.len() {
461            return EnvelopeParse::Bare;
462        }
463        let pubs_slice = &buf[p..p + pubs_len];
464        p += pubs_len;
465        Some(pubs_slice)
466    } else {
467        None
468    };
469    let subscriptions = if matches!(version, ENVELOPE_VERSION_V4 | ENVELOPE_VERSION_V5) {
470        // [u32 subs_len][subscriptions bytes]
471        let Some(subs_len_bytes) = buf.get(p..p + 4) else {
472            return EnvelopeParse::Bare;
473        };
474        let Ok(subs_len_arr) = subs_len_bytes.try_into() else {
475            return EnvelopeParse::Bare;
476        };
477        let subs_len = u32::from_le_bytes(subs_len_arr) as usize;
478        p += 4;
479        if p + subs_len > buf.len() {
480            return EnvelopeParse::Bare;
481        }
482        let subs_slice = &buf[p..p + subs_len];
483        p += subs_len;
484        Some(subs_slice)
485    } else {
486        None
487    };
488    let statistics = if version == ENVELOPE_VERSION_V5 {
489        // [u32 stats_len][statistics bytes]
490        let Some(stats_len_bytes) = buf.get(p..p + 4) else {
491            return EnvelopeParse::Bare;
492        };
493        let Ok(stats_len_arr) = stats_len_bytes.try_into() else {
494            return EnvelopeParse::Bare;
495        };
496        let stats_len = u32::from_le_bytes(stats_len_arr) as usize;
497        p += 4;
498        if p + stats_len > buf.len() {
499            return EnvelopeParse::Bare;
500        }
501        let stats_slice = &buf[p..p + stats_len];
502        p += stats_len;
503        Some(stats_slice)
504    } else {
505        None
506    };
507    if matches!(
508        version,
509        ENVELOPE_VERSION_V2 | ENVELOPE_VERSION_V3 | ENVELOPE_VERSION_V4 | ENVELOPE_VERSION_V5
510    ) {
511        if p + 4 != buf.len() {
512            return EnvelopeParse::Bare;
513        }
514        let Ok(crc_arr) = buf[p..p + 4].try_into() else {
515            return EnvelopeParse::Bare;
516        };
517        let expected = u32::from_le_bytes(crc_arr);
518        let computed = spg_crypto::crc32::crc32(&buf[..p]);
519        if expected != computed {
520            return EnvelopeParse::CrcMismatch { expected, computed };
521        }
522    } else if p != buf.len() {
523        // v1: must end exactly at the users section.
524        return EnvelopeParse::Bare;
525    }
526    EnvelopeParse::Pair {
527        catalog,
528        users,
529        publications,
530        subscriptions,
531        statistics,
532    }
533}
534
535/// v4.41.1 opaque transaction handle. Returned by `Engine::alloc_tx_id`,
536/// threaded through `Engine::execute_in` so dispatch can identify which
537/// in-flight TX a statement belongs to. `IMPLICIT_TX` is the reserved
538/// slot every legacy caller — engine self-tests, spg-cli, spg-embedded,
539/// startup replay — implicitly uses through the unchanged
540/// `Engine::execute(sql)` API. v4.41.1 keeps at most one active slot at
541/// runtime (dispatch holds `engine.write()` across the wrap, same as
542/// v4.34); the map shape is here to let v4.42 turn on N in-flight
543/// implicit TXs without reshuffling the engine internals.
544#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
545pub struct TxId(pub u64);
546
547/// Reserved slot used by `Engine::execute(sql)` — the legacy single-
548/// global-shadow path. New `alloc_tx_id` handles start at 1.
549pub const IMPLICIT_TX: TxId = TxId(0);
550
551/// v6.7.3 — default segment-size threshold used by `COMPACT COLD
552/// SEGMENTS` when no explicit target is supplied. Segments whose
553/// `OwnedSegment::bytes().len()` is **strictly** less than this
554/// value are eligible to merge. spg-server reads
555/// `SPG_COMPACTION_TARGET_SEGMENT_BYTES` to override.
556pub const COMPACTION_TARGET_DEFAULT_BYTES: u64 = 4 * 1024 * 1024;
557
558/// Per-slot transaction state. Held inside `tx_catalogs[tx_id]` for the
559/// lifetime of a BEGIN..COMMIT (or BEGIN..ROLLBACK) window. Drops when
560/// the TX commits (its `catalog` is moved over `Engine.catalog`) or
561/// rolls back (slot removed, catalog discarded).
562#[derive(Debug, Default, Clone)]
563struct TxState {
564    /// The TX's shadow copy of the catalog. Started as a clone of
565    /// `Engine.catalog` at BEGIN time; writes flow into it; COMMIT
566    /// installs it over `Engine.catalog`. `Catalog::clone()` is O(1)
567    /// since v4.40 (`PersistentVec` rows + `PersistentBTreeMap` indices).
568    catalog: Catalog,
569    /// Per-TX savepoint stack. Each entry pairs the savepoint name with
570    /// a clone of `catalog` at the moment `SAVEPOINT <name>` fired.
571    /// `ROLLBACK TO <name>` restores from the entry and pops everything
572    /// after it; `RELEASE <name>` discards the entry and everything
573    /// after; COMMIT/ROLLBACK clears the whole stack.
574    savepoints: Vec<(String, Catalog)>,
575}
576
577/// v7.11.0 — frozen read-only view of the engine's committed state.
578/// Constructed via [`Engine::clone_snapshot`]. Holds clones of the
579/// catalog, statistics, clock function, and row-cap config — the
580/// four fields the `execute_readonly` path actually reads. Cheap to
581/// `Clone` (each clone shares the underlying `PersistentVec` row
582/// storage; only the trie root pointers copy). Send + Sync so a
583/// snapshot can be moved across `tokio::task::spawn_blocking`
584/// boundaries without coordination.
585///
586/// The contract: a snapshot reflects the engine's state at the
587/// moment `clone_snapshot()` returned. Subsequent writes to the
588/// engine are NOT visible. Callers who need fresher data take a
589/// new snapshot.
590#[derive(Debug, Clone)]
591pub struct CatalogSnapshot {
592    catalog: Catalog,
593    statistics: statistics::Statistics,
594    clock: Option<ClockFn>,
595    max_query_rows: Option<usize>,
596}
597
598#[derive(Debug, Default)]
599pub struct Engine {
600    /// Committed catalog — what survives `Engine::snapshot()` and what
601    /// outside-TX `SELECT`s read.
602    catalog: Catalog,
603    /// Active TX slots, keyed by `TxId`. Empty when no TX is in flight.
604    /// v4.41.1 runtime invariant: at most one entry (single-writer
605    /// model unchanged). v4.42 will let dispatch hold multiple entries
606    /// concurrently for group commit + engine MVCC.
607    tx_catalogs: BTreeMap<TxId, TxState>,
608    /// Which slot the next exec_* call should mutate. Set by
609    /// `execute_in(sql, tx_id)` at the entry point; legacy `execute(sql)`
610    /// sets it to `IMPLICIT_TX`. None when no TX is in flight (read /
611    /// write goes straight against `catalog`).
612    current_tx: Option<TxId>,
613    /// Monotonic counter for `alloc_tx_id`. Starts at 1 — slot 0 is
614    /// reserved for `IMPLICIT_TX`.
615    next_tx_id: u64,
616    /// Optional wall clock used to satisfy `NOW()` / `CURRENT_TIMESTAMP`
617    /// / `CURRENT_DATE`. Set by the host environment.
618    clock: Option<ClockFn>,
619    /// v4.1 cryptographic RNG for per-user password salt. Set by the
620    /// host. `None` means SQL-driven `CREATE USER` uses a
621    /// deterministic fallback — see `SaltFn`.
622    salt_fn: Option<SaltFn>,
623    /// v4.2 per-query row cap. `None` = unlimited. When set, a
624    /// SELECT that materialises more than `n` rows returns
625    /// `EngineError::RowLimitExceeded`. Enforced before the result
626    /// is shaped into wire frames so a runaway scan can't blow the
627    /// server's heap.
628    max_query_rows: Option<usize>,
629    /// v4.1 RBAC user table. Empty means "no RBAC configured yet" —
630    /// the server decides what that means at the auth boundary
631    /// (open mode vs legacy single-password mode). User CRUD goes
632    /// through `create_user`/`drop_user`/`verify_user`; persistence
633    /// rides the snapshot envelope alongside the catalog.
634    users: UserStore,
635    /// v6.1.2 logical-replication publication catalog. Empty until
636    /// `CREATE PUBLICATION` runs. Persistence rides the v3 envelope
637    /// trailer (see `build_envelope`).
638    publications: publications::Publications,
639    /// v6.1.4 logical-replication subscription catalog. Empty until
640    /// `CREATE SUBSCRIPTION` runs. Persistence rides the v4 envelope
641    /// trailer.
642    subscriptions: subscriptions::Subscriptions,
643    /// v6.2.0 — per-column statistics for the cost-based optimizer.
644    /// Populated by `ANALYZE`; queried via `spg_statistic` virtual
645    /// table. Persistence rides the v5 envelope trailer.
646    statistics: statistics::Statistics,
647    /// v6.3.0 — engine-level plan cache. Caches the post-`prepare()`
648    /// `Statement` keyed on SQL text. In-memory only — does NOT ride
649    /// the snapshot envelope (rebuilt on demand after restart).
650    plan_cache: plan_cache::PlanCache,
651    /// v6.5.1 — per-distinct-SQL execution stats. In-memory only,
652    /// surfaced via `spg_stat_query` virtual table. Updated by the
653    /// `execute_*` paths after a successful execute.
654    query_stats: query_stats::QueryStats,
655    /// v6.5.2 — connection-state provider callback. spg-server
656    /// registers a function at startup that snapshots its
657    /// per-pgwire-connection registry into `ActivityRow`s; engine
658    /// reads through it on every `SELECT * FROM spg_stat_activity`.
659    /// `None` ⇒ no-data (returns empty rows; matches the no_std
660    /// embedded callers that don't run pgwire).
661    activity_provider: Option<ActivityProvider>,
662    /// v6.5.3 — audit-chain provider + verifier. Same pattern as
663    /// activity_provider: spg-server registers both at startup;
664    /// engine reads through on `SELECT * FROM spg_audit_chain` and
665    /// `SELECT * FROM spg_audit_verify`. `None` ⇒ no-data.
666    audit_chain_provider: Option<AuditChainProvider>,
667    audit_verifier: Option<AuditVerifier>,
668    /// v6.5.6 — slow-query log threshold in microseconds. When set,
669    /// every successful execute whose elapsed exceeds the threshold
670    /// gets fed to the registered slow-query log callback (so
671    /// spg-server can emit a structured log line). Default `None`
672    /// = no slow-query logging.
673    slow_query_threshold_us: Option<u64>,
674    slow_query_logger: Option<SlowQueryLogger>,
675    /// v7.12.1 — session parameters set via `SET <name> = <value>`.
676    /// Only `default_text_search_config` is consumed by the engine
677    /// today (the FTS function dispatcher reads it when
678    /// `to_tsvector(text)` is called without an explicit config).
679    /// All other names are accepted + recorded so PG-dump output
680    /// loads, but have no behavioural effect.
681    session_params: BTreeMap<String, String>,
682    /// v7.12.7 — depth counter for trigger-emitted embedded SQL.
683    /// Each time the engine executes a `DeferredEmbeddedStmt` it
684    /// increments this; the recursive `execute_stmt_with_cancel`
685    /// inside that path checks against [`MAX_TRIGGER_RECURSION`]
686    /// to bound runaway cascades (trigger A's UPDATE on table B
687    /// fires trigger B which UPDATEs table A which fires trigger
688    /// A again…). Reset to 0 once the original DML returns.
689    trigger_recursion_depth: u32,
690    /// v7.14.0 — when `SET FOREIGN_KEY_CHECKS=0` is in effect
691    /// (mysqldump preamble), the FK existence + arity check at
692    /// CREATE TABLE time is deferred. FKs referencing a
693    /// not-yet-existing parent land in `pending_foreign_keys`
694    /// keyed by child table; `SET FOREIGN_KEY_CHECKS=1` drains
695    /// the queue and resolves each FK against the now-complete
696    /// catalog. Empty by default; the queue is drained on every
697    /// `RESET ALL` too.
698    foreign_key_checks: bool,
699    /// v7.16.2 — true on the temp Engine an outer
700    /// `exec_select_with_meta_views` builds, telling that
701    /// temp engine "stop short-circuiting into the meta-view
702    /// path — your catalog already has the materialised
703    /// tables; just run the regular SELECT." Without this we'd
704    /// infinite-loop since the meta-view name (e.g.
705    /// `__spg_info_columns`) still triggers
706    /// `select_references_meta_view`.
707    meta_views_materialised: bool,
708    pending_foreign_keys: Vec<(alloc::string::String, spg_sql::ast::ForeignKeyConstraint)>,
709}
710
711/// v7.12.7 — hard cap on nested trigger-emitted embedded SQL
712/// fires. 16 deep is well past anything a normal trigger graph
713/// uses while still preventing infinite-loop wedging.
714const MAX_TRIGGER_RECURSION: u32 = 16;
715
716/// v6.5.6 — callback signature for slow-query log emission. Called
717/// with `(sql, elapsed_us)` once per successful execute that crosses
718/// the threshold.
719pub type SlowQueryLogger = fn(&str, u64);
720
721/// v6.5.4 — synthesise a `CREATE TABLE` statement from catalog
722/// state. Round-trips through `Engine::execute` to recreate the
723/// same schema (sans data + indexes — indexes are emitted as a
724/// separate `CREATE INDEX` chain in `spg_database_ddl`).
725fn render_create_table(name: &str, columns: &[ColumnSchema]) -> String {
726    let mut out = alloc::format!("CREATE TABLE {name} (");
727    for (i, col) in columns.iter().enumerate() {
728        if i > 0 {
729            out.push_str(", ");
730        }
731        out.push_str(&col.name);
732        out.push(' ');
733        out.push_str(&render_data_type(col.ty));
734        if !col.nullable {
735            out.push_str(" NOT NULL");
736        }
737        if col.auto_increment {
738            out.push_str(" AUTO_INCREMENT");
739        }
740    }
741    out.push(')');
742    out
743}
744
745fn render_data_type(ty: DataType) -> String {
746    match ty {
747        DataType::SmallInt => "SMALLINT".into(),
748        DataType::Int => "INT".into(),
749        DataType::BigInt => "BIGINT".into(),
750        DataType::Float => "FLOAT".into(),
751        DataType::Text => "TEXT".into(),
752        DataType::Varchar(n) => alloc::format!("VARCHAR({n})"),
753        DataType::Char(n) => alloc::format!("CHAR({n})"),
754        DataType::Bool => "BOOL".into(),
755        DataType::Vector { dim, encoding } => match encoding {
756            spg_storage::VecEncoding::F32 => alloc::format!("VECTOR({dim})"),
757            spg_storage::VecEncoding::Sq8 => alloc::format!("VECTOR({dim}) USING SQ8"),
758            spg_storage::VecEncoding::F16 => alloc::format!("VECTOR({dim}) USING HALF"),
759        },
760        DataType::Numeric { precision, scale } => {
761            alloc::format!("NUMERIC({precision},{scale})")
762        }
763        DataType::Date => "DATE".into(),
764        DataType::Timestamp => "TIMESTAMP".into(),
765        DataType::Interval => "INTERVAL".into(),
766        DataType::Json => "JSON".into(),
767        DataType::Jsonb => "JSONB".into(),
768        DataType::Timestamptz => "TIMESTAMPTZ".into(),
769        DataType::Bytes => "BYTEA".into(),
770        DataType::TextArray => "TEXT[]".into(),
771        DataType::IntArray => "INT[]".into(),
772        DataType::BigIntArray => "BIGINT[]".into(),
773        DataType::TsVector => "TSVECTOR".into(),
774        DataType::TsQuery => "TSQUERY".into(),
775        DataType::Uuid => "UUID".into(),
776        DataType::Time => "TIME".into(),
777        DataType::Year => "YEAR".into(),
778        DataType::TimeTz => "TIMETZ".into(),
779        DataType::Money => "MONEY".into(),
780        DataType::Range(k) => k.keyword().into(),
781        DataType::Hstore => "HSTORE".into(),
782        DataType::IntArray2D => "INT[][]".into(),
783        DataType::BigIntArray2D => "BIGINT[][]".into(),
784        DataType::TextArray2D => "TEXT[][]".into(),
785    }
786}
787
788/// v6.5.2 — one row of `spg_stat_activity`. Engine-public so
789/// spg-server can construct rows without re-exporting internal
790/// dispatch types.
791#[derive(Debug, Clone)]
792pub struct ActivityRow {
793    pub pid: u32,
794    pub user: String,
795    pub started_at_us: i64,
796    pub current_sql: String,
797    pub wait_event: String,
798    pub elapsed_us: i64,
799    pub in_transaction: bool,
800    /// v7.17 Phase 2.4 — startup-param `application_name` (or the
801    /// last value the client sent via `SET application_name = '...'`).
802    /// Empty when the client never declared one.
803    pub application_name: String,
804}
805
806/// v6.5.2 — provider callback type. Fresh snapshot returned each
807/// call; engine doesn't cache the slice.
808pub type ActivityProvider = fn() -> Vec<ActivityRow>;
809
810/// v6.5.3 — one row of `spg_audit_chain`. Engine-public so
811/// spg-server can construct rows directly from `AuditEntry`.
812#[derive(Debug, Clone)]
813pub struct AuditRow {
814    pub seq: i64,
815    pub ts_ms: i64,
816    pub prev_hash_hex: String,
817    pub entry_hash_hex: String,
818    pub sql: String,
819}
820
821/// v6.5.3 — chain-table provider + verifier. spg-server registers
822/// fn pointers that snapshot / verify the audit log. `verify`
823/// returns `(verified_count, broken_at_seq)` — `broken_at_seq` is
824/// `-1` on a clean chain.
825pub type AuditChainProvider = fn() -> Vec<AuditRow>;
826pub type AuditVerifier = fn() -> (i64, i64);
827
828impl Engine {
829    pub fn new() -> Self {
830        Self {
831            catalog: Catalog::new(),
832            tx_catalogs: BTreeMap::new(),
833            current_tx: None,
834            next_tx_id: 1,
835            clock: None,
836            salt_fn: None,
837            max_query_rows: None,
838            users: UserStore::new(),
839            publications: publications::Publications::new(),
840            subscriptions: subscriptions::Subscriptions::new(),
841            statistics: statistics::Statistics::new(),
842            plan_cache: plan_cache::PlanCache::new(),
843            query_stats: query_stats::QueryStats::new(),
844            activity_provider: None,
845            audit_chain_provider: None,
846            audit_verifier: None,
847            slow_query_threshold_us: None,
848            slow_query_logger: None,
849            session_params: BTreeMap::new(),
850            trigger_recursion_depth: 0,
851            foreign_key_checks: true,
852            meta_views_materialised: false,
853            pending_foreign_keys: Vec::new(),
854        }
855    }
856
857    /// v7.11.0 — clone the engine's committed catalog + read-time
858    /// state into a frozen `CatalogSnapshot`. Cheap (`Catalog` is
859    /// backed by `PersistentVec`; cloning is O(log n) per table).
860    /// Subsequent writes to this engine are invisible to the
861    /// snapshot; the snapshot is self-contained and can be moved
862    /// to another thread for concurrent `execute_readonly_on_snapshot`
863    /// calls. The basis for [`AsyncReadHandle`] in spg-embedded-tokio
864    /// and any other read-fanout pattern.
865    #[must_use]
866    pub fn clone_snapshot(&self) -> CatalogSnapshot {
867        CatalogSnapshot {
868            catalog: self.active_catalog().clone(),
869            statistics: self.statistics.clone(),
870            clock: self.clock,
871            max_query_rows: self.max_query_rows,
872        }
873    }
874
875    /// v7.11.1 — execute a read-only SQL statement against a
876    /// `CatalogSnapshot` without touching this engine. Same
877    /// semantics as `execute_readonly` but parameterised on the
878    /// snapshot's catalog. Reject DDL/DML the same way
879    /// `execute_readonly` does. Static-on-Self so the caller can
880    /// dispatch without holding an `Engine` borrow alongside the
881    /// snapshot.
882    pub fn execute_readonly_on_snapshot(
883        snapshot: &CatalogSnapshot,
884        sql: &str,
885    ) -> Result<QueryResult, EngineError> {
886        Self::execute_readonly_on_snapshot_with_cancel(snapshot, sql, CancelToken::none())
887    }
888
889    /// v7.11.1 — `execute_readonly_on_snapshot` with cooperative
890    /// cancellation. Builds a transient `Engine` over the snapshot
891    /// state, runs `execute_readonly_with_cancel`, drops. The
892    /// transient engine is cheap to construct (no I/O; everything
893    /// is just struct moves) and lets the existing read path stay
894    /// untouched.
895    pub fn execute_readonly_on_snapshot_with_cancel(
896        snapshot: &CatalogSnapshot,
897        sql: &str,
898        cancel: CancelToken<'_>,
899    ) -> Result<QueryResult, EngineError> {
900        let transient = Engine {
901            catalog: snapshot.catalog.clone(),
902            statistics: snapshot.statistics.clone(),
903            clock: snapshot.clock,
904            max_query_rows: snapshot.max_query_rows,
905            ..Engine::default()
906        };
907        transient.execute_readonly_with_cancel(sql, cancel)
908    }
909
910    /// v7.18 — execute a previously-prepared `Statement` against a
911    /// `CatalogSnapshot` in read-only mode. Mirror of
912    /// [`Engine::execute_prepared`] for the fan-out read path:
913    /// substitutes `Expr::Placeholder(n)` nodes from `params`, then
914    /// dispatches through [`Engine::execute_readonly_stmt_with_cancel`]
915    /// (writes / DDL hit `EngineError::WriteRequired`). Static-on-Self
916    /// so multiple readonly threads can dispatch against the same
917    /// snapshot concurrently without an `Engine` borrow.
918    ///
919    /// **Schema drift contract**. The `Statement` was prepared against
920    /// some prior catalog. If the snapshot's catalog has since
921    /// diverged (DDL renamed / dropped a referenced column / table),
922    /// execution surfaces the normal `EngineError` — same shape as
923    /// PG's "cached plan must not change result type". Caller decides
924    /// whether to re-prepare; engine does NOT auto-retry.
925    pub fn execute_readonly_prepared_on_snapshot(
926        snapshot: &CatalogSnapshot,
927        stmt: Statement,
928        params: &[Value],
929    ) -> Result<QueryResult, EngineError> {
930        Self::execute_readonly_prepared_on_snapshot_with_cancel(
931            snapshot,
932            stmt,
933            params,
934            CancelToken::none(),
935        )
936    }
937
938    /// v7.18 — cancellable variant of
939    /// [`Engine::execute_readonly_prepared_on_snapshot`].
940    pub fn execute_readonly_prepared_on_snapshot_with_cancel(
941        snapshot: &CatalogSnapshot,
942        mut stmt: Statement,
943        params: &[Value],
944        cancel: CancelToken<'_>,
945    ) -> Result<QueryResult, EngineError> {
946        cancel.check()?;
947        substitute_placeholders(&mut stmt, params)?;
948        let transient = Engine {
949            catalog: snapshot.catalog.clone(),
950            statistics: snapshot.statistics.clone(),
951            clock: snapshot.clock,
952            max_query_rows: snapshot.max_query_rows,
953            ..Engine::default()
954        };
955        transient.execute_readonly_stmt_with_cancel(stmt, cancel)
956    }
957
958    /// v7.18 — describe a prepared `Statement` against a
959    /// `CatalogSnapshot`. Same `(parameter_oids, output_columns)`
960    /// shape as [`Engine::describe_prepared`]; resolves names
961    /// against the snapshot's catalog instead of `self`. Pure
962    /// function — no engine state read.
963    pub fn describe_prepared_on_snapshot(
964        snapshot: &CatalogSnapshot,
965        stmt: &Statement,
966    ) -> (Vec<u32>, Vec<ColumnSchema>) {
967        describe::describe_prepared(stmt, &snapshot.catalog)
968    }
969
970    /// v7.18 — does this SQL string classify as read-only? Parses
971    /// `sql` with the engine parser and consults
972    /// `Statement::is_readonly()`. A parse error returns `false`
973    /// (route to the writer path so the user sees the canonical
974    /// parse error from the writer's simple-query dispatch).
975    /// Static-on-Self so the spg-sqlx connection layer can ask
976    /// without an `Engine` borrow.
977    #[must_use]
978    pub fn is_readonly_sql(sql: &str) -> bool {
979        parser::parse_statement(sql)
980            .as_ref()
981            .map(spg_sql::ast::Statement::is_readonly)
982            .unwrap_or(false)
983    }
984
985    /// v7.18 — parse + plan a SQL string against a
986    /// `CatalogSnapshot`. Mirror of [`Engine::prepare`] for the
987    /// readonly fan-out path: applies the same prepare-time
988    /// transforms (clock rewrite, `GROUP BY ALL` expansion, ORDER
989    /// BY position resolve, cost-based JOIN reorder) but resolves
990    /// catalog + statistics against the snapshot, not a live
991    /// engine. Static-on-Self — `AsyncReadHandle::prepare` calls
992    /// this without taking the writer lock so multiple read
993    /// handles can prepare concurrently against frozen views.
994    ///
995    /// # Errors
996    /// Propagates [`ParseError`] from the parser. Schema
997    /// validation deferred to execute time, same as
998    /// [`Engine::prepare`].
999    pub fn prepare_on_snapshot(
1000        snapshot: &CatalogSnapshot,
1001        sql: &str,
1002    ) -> Result<Statement, ParseError> {
1003        let mut stmt = parser::parse_statement(sql)?;
1004        let now_micros = snapshot.clock.map(|f| f());
1005        rewrite_clock_calls(&mut stmt, now_micros);
1006        if let Statement::Select(s) = &mut stmt {
1007            expand_group_by_all(s);
1008            resolve_order_by_position(s);
1009            reorder::reorder_joins(s, &snapshot.catalog, &snapshot.statistics);
1010        }
1011        Ok(stmt)
1012    }
1013
1014    /// Construct an engine restored from a previously-snapshotted catalog
1015    /// (see `snapshot()`).
1016    pub fn restore(catalog: Catalog) -> Self {
1017        Self {
1018            catalog,
1019            tx_catalogs: BTreeMap::new(),
1020            current_tx: None,
1021            next_tx_id: 1,
1022            clock: None,
1023            salt_fn: None,
1024            max_query_rows: None,
1025            users: UserStore::new(),
1026            publications: publications::Publications::new(),
1027            subscriptions: subscriptions::Subscriptions::new(),
1028            statistics: statistics::Statistics::new(),
1029            plan_cache: plan_cache::PlanCache::new(),
1030            query_stats: query_stats::QueryStats::new(),
1031            activity_provider: None,
1032            audit_chain_provider: None,
1033            audit_verifier: None,
1034            slow_query_threshold_us: None,
1035            slow_query_logger: None,
1036            session_params: BTreeMap::new(),
1037            trigger_recursion_depth: 0,
1038            foreign_key_checks: true,
1039            meta_views_materialised: false,
1040            pending_foreign_keys: Vec::new(),
1041        }
1042    }
1043
1044    /// Restore an engine + user table from a v4.1 envelope produced
1045    /// by `snapshot_with_users()`. Falls back to plain catalog-only
1046    /// restore if the envelope magic isn't present (so v3.x snapshot
1047    /// files still load). v6.1.2 adds the optional publications
1048    /// trailer (envelope v3); a v1/v2 envelope deserialises to an
1049    /// empty publication table.
1050    pub fn restore_envelope(buf: &[u8]) -> Result<Self, EngineError> {
1051        match split_envelope(buf) {
1052            EnvelopeParse::Pair {
1053                catalog: catalog_bytes,
1054                users: user_bytes,
1055                publications: pub_bytes,
1056                subscriptions: sub_bytes,
1057                statistics: stats_bytes,
1058            } => {
1059                let catalog = Catalog::deserialize(catalog_bytes).map_err(EngineError::Storage)?;
1060                let users = users::deserialize_users(user_bytes)
1061                    .map_err(|e| EngineError::Unsupported(alloc::format!("users restore: {e}")))?;
1062                let publications = match pub_bytes {
1063                    Some(b) => publications::Publications::deserialize(b).map_err(|e| {
1064                        EngineError::Unsupported(alloc::format!("publications restore: {e:?}"))
1065                    })?,
1066                    None => publications::Publications::new(),
1067                };
1068                let subscriptions = match sub_bytes {
1069                    Some(b) => subscriptions::Subscriptions::deserialize(b).map_err(|e| {
1070                        EngineError::Unsupported(alloc::format!("subscriptions restore: {e:?}"))
1071                    })?,
1072                    None => subscriptions::Subscriptions::new(),
1073                };
1074                let statistics = match stats_bytes {
1075                    Some(b) => statistics::Statistics::deserialize(b).map_err(|e| {
1076                        EngineError::Unsupported(alloc::format!("statistics restore: {e:?}"))
1077                    })?,
1078                    None => statistics::Statistics::new(),
1079                };
1080                Ok(Self {
1081                    catalog,
1082                    tx_catalogs: BTreeMap::new(),
1083                    current_tx: None,
1084                    next_tx_id: 1,
1085                    clock: None,
1086                    salt_fn: None,
1087                    max_query_rows: None,
1088                    users,
1089                    publications,
1090                    subscriptions,
1091                    statistics,
1092                    plan_cache: plan_cache::PlanCache::new(),
1093                    query_stats: query_stats::QueryStats::new(),
1094                    activity_provider: None,
1095                    audit_chain_provider: None,
1096                    audit_verifier: None,
1097                    slow_query_threshold_us: None,
1098                    slow_query_logger: None,
1099                    session_params: BTreeMap::new(),
1100                    trigger_recursion_depth: 0,
1101                    foreign_key_checks: true,
1102                    meta_views_materialised: false,
1103                    pending_foreign_keys: Vec::new(),
1104                })
1105            }
1106            EnvelopeParse::CrcMismatch { expected, computed } => {
1107                Err(EngineError::Storage(StorageError::Corrupt(alloc::format!(
1108                    "snapshot envelope CRC32 mismatch (expected={expected:#010x}, computed={computed:#010x})"
1109                ))))
1110            }
1111            EnvelopeParse::Bare => {
1112                let catalog = Catalog::deserialize(buf).map_err(EngineError::Storage)?;
1113                Ok(Self::restore(catalog))
1114            }
1115        }
1116    }
1117
1118    pub const fn users(&self) -> &UserStore {
1119        &self.users
1120    }
1121
1122    /// `salt` is supplied by the caller (the host has a random
1123    /// source; the engine is `no_std`). Caller should pass a fresh
1124    /// 16-byte random value per user.
1125    pub fn create_user(
1126        &mut self,
1127        name: &str,
1128        password: &str,
1129        role: Role,
1130        salt: [u8; 16],
1131    ) -> Result<(), UserError> {
1132        self.users.create(name, password, role, salt)?;
1133        // v4.8: also derive SCRAM-SHA-256 secrets so PG-wire SASL
1134        // auth can verify without re-running PBKDF2 per attempt.
1135        // Uses a fresh salt from the host RNG (falls back to a
1136        // deterministic per-username salt when no RNG is wired, same
1137        // as the legacy hash path).
1138        let scram_salt = self.salt_fn.map_or_else(
1139            || {
1140                let mut s = [0u8; users::SCRAM_SALT_LEN];
1141                let digest = spg_crypto::hash(name.as_bytes());
1142                // Use bytes 16..32 of BLAKE3 so we don't reuse the
1143                // exact same fallback salt as the BLAKE3 hash path.
1144                s.copy_from_slice(&digest[16..32]);
1145                s
1146            },
1147            |f| f(),
1148        );
1149        self.users
1150            .enable_scram(name, password, scram_salt, users::SCRAM_DEFAULT_ITERS)?;
1151        Ok(())
1152    }
1153
1154    pub fn drop_user(&mut self, name: &str) -> Result<(), UserError> {
1155        self.users.drop(name)
1156    }
1157
1158    pub fn verify_user(&self, name: &str, password: &str) -> Option<Role> {
1159        self.users.verify(name, password)
1160    }
1161
1162    /// Builder: attach a wall clock so `NOW()` / `CURRENT_TIMESTAMP` /
1163    /// `CURRENT_DATE` evaluate to a real value instead of erroring out.
1164    #[must_use]
1165    pub const fn with_clock(mut self, clock: ClockFn) -> Self {
1166        self.clock = Some(clock);
1167        self
1168    }
1169
1170    /// Builder: attach an OS-backed RNG for per-user password salts.
1171    /// The host (`spg-server`) typically wires this to `/dev/urandom`.
1172    #[must_use]
1173    pub const fn with_salt_fn(mut self, f: SaltFn) -> Self {
1174        self.salt_fn = Some(f);
1175        self
1176    }
1177
1178    /// Builder: cap the number of rows a single SELECT may return.
1179    /// Exceeding the cap raises `EngineError::RowLimitExceeded` —
1180    /// the bound is checked inside the executor so a runaway
1181    /// catalog scan can't allocate millions of rows before the
1182    /// server gets a chance to reject the result.
1183    #[must_use]
1184    pub const fn with_max_query_rows(mut self, n: usize) -> Self {
1185        self.max_query_rows = Some(n);
1186        self
1187    }
1188
1189    /// The *committed* catalog. Note: during a transaction this returns the
1190    /// pre-TX state — `SELECT` inside a TX goes through `execute()` and reads
1191    /// the shadow. Tests that inspect outside-TX state should use this.
1192    pub const fn catalog(&self) -> &Catalog {
1193        &self.catalog
1194    }
1195
1196    /// Serialize the *committed* catalog to bytes. v0.6 was full-snapshot; v0.9
1197    /// adds the rule that an open TX's shadow is never snapshotted — only the
1198    /// post-COMMIT state is persisted. v4.1 wraps the catalog in an envelope
1199    /// when there are users to persist; an empty user table snapshots as the
1200    /// bare catalog format (backwards-compat with v3.x readers). v6.1.2
1201    /// adds publications to the envelope condition: either non-empty
1202    /// users OR non-empty publications now triggers the envelope path.
1203    pub fn snapshot(&self) -> Vec<u8> {
1204        if self.users.is_empty()
1205            && self.publications.is_empty()
1206            && self.subscriptions.is_empty()
1207            && self.statistics.is_empty()
1208        {
1209            self.catalog.serialize()
1210        } else {
1211            build_envelope(
1212                &self.catalog.serialize(),
1213                &users::serialize_users(&self.users),
1214                &self.publications.serialize(),
1215                &self.subscriptions.serialize(),
1216                &self.statistics.serialize(),
1217            )
1218        }
1219    }
1220
1221    /// True when at least one TX slot is in flight. v4.41.1 runtime
1222    /// invariant: at most one slot active at a time (dispatch holds
1223    /// `engine.write()` across the entire wrap). v4.42 will let this
1224    /// return true with multiple slots concurrently.
1225    pub fn in_transaction(&self) -> bool {
1226        !self.tx_catalogs.is_empty()
1227    }
1228
1229    /// v4.41.1 allocate a fresh TX handle. Used by spg-server dispatch
1230    /// to scope each implicit-wrap BEGIN..stmt..COMMIT to its own slot
1231    /// in `tx_catalogs`. v4.42 — the commit-barrier leader allocates
1232    /// one of these per task in its group, runs `BEGIN`+sql+`COMMIT`
1233    /// sequentially under a single `engine.write()` so each task's
1234    /// mutations accumulate into shared state, then either keeps the
1235    /// accumulated state (fsync OK) or restores the pre-image via
1236    /// `replace_catalog` (fsync err).
1237    pub fn alloc_tx_id(&mut self) -> TxId {
1238        let id = TxId(self.next_tx_id);
1239        self.next_tx_id = self.next_tx_id.saturating_add(1);
1240        id
1241    }
1242
1243    /// v4.42 — atomically replace the live catalog. Used by the
1244    /// commit-barrier leader to roll back a group whose batched
1245    /// fsync failed: the leader snapshots `engine.catalog().clone()`
1246    /// (O(1) Arc bump after the v4.39/v4.40 persistent migration)
1247    /// at group start, sequentially applies each task's BEGIN+sql+
1248    /// COMMIT under the same write lock to accumulate mutations
1249    /// into shared state, batches the WAL bytes, fsyncs once, and
1250    /// on failure calls this with the pre-image to undo every
1251    /// task in the group at once.
1252    ///
1253    /// **Does NOT touch `tx_catalogs` / `current_tx`.** Any
1254    /// explicit-TX slot from a concurrent client (created via the
1255    /// legacy `IMPLICIT_TX`-less dispatch path or via the future
1256    /// MVCC-readers v5+ work) has its own snapshot baked into the
1257    /// slot — restoring `self.catalog` to the pre-image leaves
1258    /// those slots untouched, exactly as they were when the leader
1259    /// took the lock. The leader's own implicit-TX slots are all
1260    /// already discarded (`exec_commit` removed them as each
1261    /// task's COMMIT ran) by the time this is reached.
1262    pub fn replace_catalog(&mut self, catalog: Catalog) {
1263        self.catalog = catalog;
1264    }
1265
1266    /// v6.7.0 — public shim around `Catalog::freeze_oldest_to_cold`
1267    /// so tests + the spg-server freezer can drive a freeze without
1268    /// reaching into the private `active_catalog_mut`. v6.7.4
1269    /// parallel freezer will build on this surface.
1270    ///
1271    /// Marks the table's cached `cold_row_count` stale because the
1272    /// freeze added cold locators that ANALYZE hasn't yet refreshed.
1273    pub fn freeze_oldest_to_cold(
1274        &mut self,
1275        table_name: &str,
1276        index_name: &str,
1277        max_rows: usize,
1278    ) -> Result<spg_storage::FreezeReport, EngineError> {
1279        let report = self
1280            .active_catalog_mut()
1281            .freeze_oldest_to_cold(table_name, index_name, max_rows)
1282            .map_err(EngineError::Storage)?;
1283        if let Some(t) = self.active_catalog_mut().get_mut(table_name) {
1284            t.mark_cold_row_count_stale();
1285        }
1286        Ok(report)
1287    }
1288
1289    /// v6.7.5 — public shim used by the spg-server follower's
1290    /// segment-forwarding receiver. Registers a cold-tier segment
1291    /// at a specific id (the master's id, as transmitted on the
1292    /// wire) so the follower's BTree-Cold locators stay byte-
1293    /// identical with the master's. Wraps
1294    /// `Catalog::load_segment_bytes_at` under the standard
1295    /// clone-mutate-replace pattern.
1296    ///
1297    /// Returns `Ok(())` on success **and** on the "slot already
1298    /// occupied" case — a follower mid-reconnect may receive a
1299    /// segment chunk for a segment_id it already has on disk
1300    /// (forwarded last session); the caller should treat that
1301    /// path as a no-op rather than a fatal error.
1302    pub fn receive_cold_segment(
1303        &mut self,
1304        segment_id: u32,
1305        bytes: Vec<u8>,
1306    ) -> Result<(), EngineError> {
1307        let mut new_cat = self.catalog.clone();
1308        match new_cat.load_segment_bytes_at(segment_id, bytes) {
1309            Ok(()) => {
1310                self.replace_catalog(new_cat);
1311                Ok(())
1312            }
1313            Err(StorageError::Corrupt(msg)) if msg.contains("already occupied") => Ok(()),
1314            Err(e) => Err(EngineError::Storage(e)),
1315        }
1316    }
1317
1318    /// v6.7.3 — public shim around `Catalog::compact_cold_segments`
1319    /// driving every BTree index on every user table. Returns one
1320    /// `(table, index, report)` triple for each merge that
1321    /// actually happened (no-op (table, index) pairs are filtered
1322    /// out so callers can size persist-side work to the live
1323    /// merges). Caller is responsible for persisting each
1324    /// `report.merged_segment_bytes` and updating the on-disk
1325    /// segment registry; engine layer is no_std and never
1326    /// touches disk.
1327    ///
1328    /// Marks every touched table's cached `cold_row_count` stale
1329    /// — compaction GC'd some shadowed rows, so the count must be
1330    /// re-derived on the next ANALYZE.
1331    pub fn compact_cold_segments_with_target(
1332        &mut self,
1333        target_segment_bytes: u64,
1334    ) -> Result<Vec<(String, String, CompactReport)>, EngineError> {
1335        let table_names = self.active_catalog().table_names();
1336        let mut reports: Vec<(String, String, CompactReport)> = Vec::new();
1337        for tname in table_names {
1338            if is_internal_table_name(&tname) {
1339                continue;
1340            }
1341            let idx_names: Vec<String> = {
1342                let Some(t) = self.active_catalog().get(&tname) else {
1343                    continue;
1344                };
1345                t.indices()
1346                    .iter()
1347                    .filter(|i| matches!(i.kind, IndexKind::BTree(_)))
1348                    .map(|i| i.name.clone())
1349                    .collect()
1350            };
1351            for iname in idx_names {
1352                let report = self
1353                    .active_catalog_mut()
1354                    .compact_cold_segments(&tname, &iname, target_segment_bytes)
1355                    .map_err(EngineError::Storage)?;
1356                if report.merged_segment_id.is_some() {
1357                    if let Some(t) = self.active_catalog_mut().get_mut(&tname) {
1358                        t.mark_cold_row_count_stale();
1359                    }
1360                    reports.push((tname.clone(), iname, report));
1361                }
1362            }
1363        }
1364        Ok(reports)
1365    }
1366
1367    fn active_catalog(&self) -> &Catalog {
1368        match self.current_tx {
1369            Some(t) => self
1370                .tx_catalogs
1371                .get(&t)
1372                .map_or(&self.catalog, |s| &s.catalog),
1373            None => &self.catalog,
1374        }
1375    }
1376
1377    /// v7.12.4 — snapshot every row-level trigger on `table` that
1378    /// fires for `event` (`"INSERT"` / `"UPDATE"` / `"DELETE"`) at
1379    /// the given `timing` (`"BEFORE"` / `"AFTER"`), and clone its
1380    /// referenced function definition. Returned as a vec of owned
1381    /// `FunctionDef` so the row-write loop can fire them without
1382    /// holding a borrow on the catalog (which would conflict with
1383    /// the table.insert / update_row / delete mutable borrows).
1384    /// v7.16.2 — top-level DO block executor. Walks the
1385    /// PlPgSqlBlock via [`triggers::execute_do_block_top_level`],
1386    /// then runs each collected EmbeddedSql statement through
1387    /// the engine's regular execute path (NOT deferred — DO is
1388    /// outside any row-write borrow). Errors from any step
1389    /// abort the block and propagate verbatim.
1390    /// v7.16.2 — resolve every subquery inside a PlPgSqlBlock's
1391    /// expression slots so the downstream trigger-flavoured
1392    /// evaluator (which expects pre-resolved Expr::Literal /
1393    /// Binary chains) doesn't trip on raw Exists/ScalarSubquery
1394    /// nodes. Walks IF conditions, Assign values, RAISE args.
1395    /// EmbeddedSql statements re-enter the engine for execution
1396    /// later so their subqueries get the normal SELECT-side
1397    /// resolution.
1398    fn resolve_plpgsql_block_subqueries(
1399        &self,
1400        block: &mut spg_sql::ast::PlPgSqlBlock,
1401        cancel: CancelToken<'_>,
1402    ) -> Result<(), EngineError> {
1403        for d in &mut block.declarations {
1404            if let Some(e) = &mut d.default {
1405                self.resolve_expr_subqueries(e, cancel)?;
1406            }
1407        }
1408        self.resolve_plpgsql_stmts_subqueries(&mut block.statements, cancel)
1409    }
1410
1411    fn resolve_plpgsql_stmts_subqueries(
1412        &self,
1413        stmts: &mut [spg_sql::ast::PlPgSqlStmt],
1414        cancel: CancelToken<'_>,
1415    ) -> Result<(), EngineError> {
1416        use spg_sql::ast::PlPgSqlStmt;
1417        for stmt in stmts {
1418            match stmt {
1419                PlPgSqlStmt::Assign { value, .. } => {
1420                    self.resolve_expr_subqueries(value, cancel)?;
1421                }
1422                PlPgSqlStmt::Return(spg_sql::ast::ReturnTarget::Expr(e)) => {
1423                    self.resolve_expr_subqueries(e, cancel)?;
1424                }
1425                PlPgSqlStmt::Return(_) => {}
1426                PlPgSqlStmt::If {
1427                    branches,
1428                    else_branch,
1429                } => {
1430                    for (cond, body) in branches.iter_mut() {
1431                        self.resolve_expr_subqueries(cond, cancel)?;
1432                        self.resolve_plpgsql_stmts_subqueries(body, cancel)?;
1433                    }
1434                    self.resolve_plpgsql_stmts_subqueries(else_branch, cancel)?;
1435                }
1436                PlPgSqlStmt::Raise { args, .. } => {
1437                    for a in args {
1438                        self.resolve_expr_subqueries(a, cancel)?;
1439                    }
1440                }
1441                PlPgSqlStmt::EmbeddedSql(_) => {
1442                    // Embedded SQL goes back through execute_stmt
1443                    // _with_cancel which runs the SELECT-side
1444                    // resolver itself; nothing to do here.
1445                }
1446                PlPgSqlStmt::SelectInto { body, .. } => {
1447                    // SELECT INTO runs through Engine::execute
1448                    // when reached, so subquery resolution
1449                    // happens via the normal SELECT-side path.
1450                    // Still walk for nested subqueries inside
1451                    // the SELECT body so eval doesn't trip.
1452                    self.resolve_select_subqueries(body, cancel)?;
1453                }
1454            }
1455        }
1456        Ok(())
1457    }
1458
1459    fn exec_do_block(
1460        &mut self,
1461        body: spg_sql::ast::PlPgSqlBlock,
1462    ) -> Result<QueryResult, EngineError> {
1463        // v7.16.2 — pre-resolve every subquery the body's
1464        // expressions reach. `eval::eval_expr` errors on
1465        // unresolved Exists/ScalarSubquery/InSubquery; the
1466        // top-level SELECT path runs `resolve_select_subqueries`
1467        // for the caller — for plpgsql we have to do the
1468        // equivalent before the body walker runs. Catches the
1469        // mailrs idiom `IF EXISTS (SELECT 1 FROM
1470        // information_schema.columns WHERE …) THEN …`.
1471        let mut body = body;
1472        self.resolve_plpgsql_block_subqueries(&mut body, CancelToken::none())?;
1473        let dts = self
1474            .session_param("default_text_search_config")
1475            .map(String::from);
1476        // v7.16.2 — SELECT … INTO resolver. The walker calls
1477        // this synchronously when it hits a SelectInto stmt
1478        // so the IF / locals scope sees the result before the
1479        // next statement. Body walks for trigger paths (no
1480        // resolver) error loudly on SelectInto.
1481        // SAFETY: the closure shares this engine borrow with
1482        // the walker, but the walker only borrows for the
1483        // duration of `execute_do_block_top_level` and doesn't
1484        // reach back into the engine through any other path —
1485        // so the recursive `&mut` is sound. We use a `RefCell`
1486        // for interior mutability since the closure is
1487        // Fn-shaped.
1488        let engine_cell = core::cell::RefCell::new(&mut *self);
1489        let resolver_fn =
1490            |stmt: &spg_sql::ast::Statement| -> Result<Value, triggers::TriggerError> {
1491                let mut eng = engine_cell.borrow_mut();
1492                let r = eng
1493                    .execute_stmt_with_cancel(stmt.clone(), CancelToken::none())
1494                    .map_err(|e| triggers::TriggerError::EvalFailed {
1495                        function: "DO".into(),
1496                        cause: eval::EvalError::TypeMismatch {
1497                            detail: alloc::format!("SELECT … INTO failed: {e}"),
1498                        },
1499                    })?;
1500                match r {
1501                    QueryResult::Rows { rows, .. } => match rows.into_iter().next() {
1502                        Some(row) => Ok(row.values.into_iter().next().unwrap_or(Value::Null)),
1503                        None => Ok(Value::Null),
1504                    },
1505                    _ => Err(triggers::TriggerError::EvalFailed {
1506                        function: "DO".into(),
1507                        cause: eval::EvalError::TypeMismatch {
1508                            detail: "SELECT … INTO body must be a SELECT".into(),
1509                        },
1510                    }),
1511                }
1512            };
1513        let collected =
1514            triggers::execute_do_block_top_level(&body, dts.as_deref(), Some(&resolver_fn))
1515                .map_err(|e| {
1516                    EngineError::Storage(StorageError::Corrupt(alloc::format!("DO: {e}")))
1517                })?;
1518        // engine_cell goes out of scope here, releasing the &mut self borrow
1519        // Run each embedded statement against the engine. The
1520        // statements were already substitute-walked for NEW/OLD/
1521        // locals (those evaluate to engine literals before they
1522        // land here) so dispatch is plain execute_stmt_with_cancel.
1523        for stmt in collected {
1524            // v7.16.2 — preserve current_tx wrap so an outer
1525            // BEGIN/COMMIT around a DO block keeps the
1526            // EmbeddedSql writes inside that same tx slot.
1527            self.execute_stmt_with_cancel(stmt, CancelToken::none())?;
1528        }
1529        Ok(QueryResult::CommandOk {
1530            affected: 0,
1531            modified_catalog: !self.in_transaction(),
1532        })
1533    }
1534
1535    fn snapshot_row_triggers(
1536        &self,
1537        table: &str,
1538        event: &str,
1539        timing: &str,
1540    ) -> Vec<spg_storage::FunctionDef> {
1541        let cat = self.active_catalog();
1542        cat.triggers()
1543            .iter()
1544            .filter(|t| {
1545                // v7.16.1 — skip disabled triggers (mailrs
1546                // round-9 A.2.b — pg_dump --disable-triggers).
1547                t.enabled
1548                    && t.table == table
1549                    && t.timing.eq_ignore_ascii_case(timing)
1550                    && t.for_each.eq_ignore_ascii_case("row")
1551                    && t.events.iter().any(|e| e.eq_ignore_ascii_case(event))
1552            })
1553            .filter_map(|t| cat.functions().get(&t.function).cloned())
1554            .collect()
1555    }
1556
1557    /// v7.13.0 — UPDATE-side snapshot that pairs each trigger's
1558    /// function with its `UPDATE OF cols` filter (mailrs round-5
1559    /// G7). Empty filter Vec means "fire unconditionally", matching
1560    /// the v7.12 behaviour.
1561    fn snapshot_update_row_triggers(
1562        &self,
1563        table: &str,
1564        timing: &str,
1565    ) -> Vec<(spg_storage::FunctionDef, Vec<String>)> {
1566        let cat = self.active_catalog();
1567        cat.triggers()
1568            .iter()
1569            .filter(|t| {
1570                // v7.16.1 — skip disabled triggers.
1571                t.enabled
1572                    && t.table == table
1573                    && t.timing.eq_ignore_ascii_case(timing)
1574                    && t.for_each.eq_ignore_ascii_case("row")
1575                    && t.events.iter().any(|e| e.eq_ignore_ascii_case("UPDATE"))
1576            })
1577            .filter_map(|t| {
1578                cat.functions()
1579                    .get(&t.function)
1580                    .cloned()
1581                    .map(|fd| (fd, t.update_columns.clone()))
1582            })
1583            .collect()
1584    }
1585
1586    /// v7.12.7 — drain the trigger-emitted embedded SQL queue.
1587    /// Called by the INSERT / UPDATE / DELETE executors after
1588    /// their main row-write loop returns. Each statement runs
1589    /// inside the same cancel scope as the firing DML and bumps
1590    /// the recursion counter; nested embedded SQL beyond
1591    /// [`MAX_TRIGGER_RECURSION`] errors with a clear message so
1592    /// a trigger-graph cycle surfaces as a query failure instead
1593    /// of stack-blowing the engine.
1594    fn execute_deferred_trigger_stmts(
1595        &mut self,
1596        deferred: Vec<triggers::DeferredEmbeddedStmt>,
1597        cancel: CancelToken<'_>,
1598    ) -> Result<(), EngineError> {
1599        for d in deferred {
1600            if self.trigger_recursion_depth >= MAX_TRIGGER_RECURSION {
1601                return Err(EngineError::Storage(StorageError::Corrupt(alloc::format!(
1602                    "trigger embedded SQL recursion depth {} exceeded (trigger function \
1603                     {:?} would push past the {} cap — check for trigger cycles)",
1604                    self.trigger_recursion_depth,
1605                    d.function,
1606                    MAX_TRIGGER_RECURSION,
1607                ))));
1608            }
1609            self.trigger_recursion_depth += 1;
1610            let res = self.execute_stmt_with_cancel(d.stmt, cancel);
1611            self.trigger_recursion_depth -= 1;
1612            res?;
1613        }
1614        Ok(())
1615    }
1616
1617    fn active_catalog_mut(&mut self) -> &mut Catalog {
1618        let tx = self.current_tx;
1619        match tx {
1620            Some(t) => match self.tx_catalogs.get_mut(&t) {
1621                Some(s) => &mut s.catalog,
1622                None => &mut self.catalog,
1623            },
1624            None => &mut self.catalog,
1625        }
1626    }
1627
1628    /// Read-only execute path. Succeeds for `SELECT` / `SHOW TABLES`
1629    /// / `SHOW COLUMNS`; returns `EngineError::WriteRequired` for
1630    /// every other statement, so the caller can fall through to the
1631    /// `&mut self` `execute` path under a write lock. Engine state is
1632    /// not mutated even on the success path (`rewrite_clock_calls`
1633    /// and `resolve_order_by_position` both mutate the locally-owned
1634    /// AST, not `self`).
1635    ///
1636    /// **v4.0 concurrency**: this is the entry point the server takes
1637    /// under an `RwLock::read()` so multiple `SELECT` clients run in
1638    /// parallel without serialising on a single mutex.
1639    pub fn execute_readonly(&self, sql: &str) -> Result<QueryResult, EngineError> {
1640        self.execute_readonly_with_cancel(sql, CancelToken::none())
1641    }
1642
1643    /// v4.5 — read path with cooperative cancellation. Token's
1644    /// `is_cancelled` is checked at the start (so a watchdog that
1645    /// already fired returns Cancelled immediately) and at row-loop
1646    /// checkpoints inside `exec_select`. SHOW paths are O(small) and
1647    /// don't bother checking.
1648    pub fn execute_readonly_with_cancel(
1649        &self,
1650        sql: &str,
1651        cancel: CancelToken<'_>,
1652    ) -> Result<QueryResult, EngineError> {
1653        cancel.check()?;
1654        let mut stmt = parser::parse_statement(sql)?;
1655        let now_micros = self.clock.map(|f| f());
1656        rewrite_clock_calls(&mut stmt, now_micros);
1657        if let Statement::Select(s) = &mut stmt {
1658            resolve_order_by_position(s);
1659            // v6.2.3 — cost-based JOIN reorder (read path).
1660            reorder::reorder_joins(s, &self.catalog, &self.statistics);
1661        }
1662        self.execute_readonly_stmt_with_cancel(stmt, cancel)
1663    }
1664
1665    /// v7.18 — readonly dispatch on a pre-parsed `Statement`.
1666    /// Internal helper shared by the SQL-string path
1667    /// ([`Engine::execute_readonly_with_cancel`]) and the prepared-
1668    /// statement path ([`Engine::execute_readonly_prepared_on_snapshot_with_cancel`]).
1669    /// Statement-level transforms (clock rewrite, ORDER BY position,
1670    /// JOIN reorder, placeholder substitution) are the caller's
1671    /// responsibility — this helper assumes the AST is already
1672    /// execution-ready. Writes / DDL hit
1673    /// [`EngineError::WriteRequired`] the same way the SQL path does.
1674    fn execute_readonly_stmt_with_cancel(
1675        &self,
1676        stmt: Statement,
1677        cancel: CancelToken<'_>,
1678    ) -> Result<QueryResult, EngineError> {
1679        let result = match stmt {
1680            Statement::Select(s) => self.exec_select_cancel(&s, cancel),
1681            Statement::ShowTables => Ok(self.exec_show_tables()),
1682            Statement::ShowDatabases => Ok(self.exec_show_databases()),
1683            Statement::ShowCreateTable(name) => self.exec_show_create_table(&name),
1684            Statement::ShowIndexes(name) => self.exec_show_indexes(&name),
1685            Statement::ShowStatus => Ok(self.exec_show_status()),
1686            Statement::ShowVariables => Ok(self.exec_show_variables()),
1687            Statement::ShowProcesslist => Ok(self.exec_show_processlist()),
1688            Statement::ShowColumns(table) => self.exec_show_columns(&table),
1689            Statement::ShowUsers => Ok(self.exec_show_users()),
1690            Statement::ShowPublications => Ok(self.exec_show_publications()),
1691            Statement::ShowSubscriptions => Ok(self.exec_show_subscriptions()),
1692            Statement::WaitForWalPosition { .. } => Err(EngineError::Unsupported(
1693                "WAIT FOR WAL POSITION must be handled by the server layer".into(),
1694            )),
1695            Statement::Explain(e) => self.exec_explain(&e, cancel),
1696            _ => Err(EngineError::WriteRequired),
1697        };
1698        self.enforce_row_limit(result)
1699    }
1700
1701    /// v4.2: cap result-set size. Applied after the executor
1702    /// materialises rows but before they leave the engine — wrapping
1703    /// every Rows-returning exec_* function would scatter the check.
1704    fn enforce_row_limit(
1705        &self,
1706        result: Result<QueryResult, EngineError>,
1707    ) -> Result<QueryResult, EngineError> {
1708        if let (Ok(QueryResult::Rows { rows, .. }), Some(cap)) = (&result, self.max_query_rows)
1709            && rows.len() > cap
1710        {
1711            return Err(EngineError::RowLimitExceeded(cap));
1712        }
1713        result
1714    }
1715
1716    pub fn execute(&mut self, sql: &str) -> Result<QueryResult, EngineError> {
1717        self.execute_in_with_cancel(sql, IMPLICIT_TX, CancelToken::none())
1718    }
1719
1720    /// v4.5 — write path with cooperative cancellation. Same dispatch
1721    /// as `execute_in_with_cancel(sql, IMPLICIT_TX, cancel)`. Kept as
1722    /// a separate entry point for backward-compat with the v4.5
1723    /// public API.
1724    pub fn execute_with_cancel(
1725        &mut self,
1726        sql: &str,
1727        cancel: CancelToken<'_>,
1728    ) -> Result<QueryResult, EngineError> {
1729        self.execute_in_with_cancel(sql, IMPLICIT_TX, cancel)
1730    }
1731
1732    /// v4.41.1 multi-slot write entry. Routes `sql` through the TX
1733    /// slot identified by `tx_id` so spg-server dispatch can scope
1734    /// each implicit-wrap BEGIN..stmt..COMMIT to its own slot in
1735    /// `tx_catalogs`. `IMPLICIT_TX` is the legacy single-slot path
1736    /// every other caller (engine self-tests, replay, spg-embedded)
1737    /// implicitly takes via `execute()` / `execute_with_cancel()`.
1738    pub fn execute_in(&mut self, sql: &str, tx_id: TxId) -> Result<QueryResult, EngineError> {
1739        self.execute_in_with_cancel(sql, tx_id, CancelToken::none())
1740    }
1741
1742    /// v4.41.1 write path with cooperative cancellation + explicit TX
1743    /// scope. Sets `self.current_tx` for the duration of the call so
1744    /// every `exec_*` helper transparently sees its TX's shadow
1745    /// catalog and savepoint stack; restores on exit so the field is
1746    /// only valid mid-call (no leakage across calls).
1747    pub fn execute_in_with_cancel(
1748        &mut self,
1749        sql: &str,
1750        tx_id: TxId,
1751        cancel: CancelToken<'_>,
1752    ) -> Result<QueryResult, EngineError> {
1753        let saved = self.current_tx;
1754        self.current_tx = Some(tx_id);
1755        let result = self.execute_inner_with_cancel(sql, cancel);
1756        self.current_tx = saved;
1757        result
1758    }
1759
1760    /// v6.1.1 — parse and pre-process a SQL string ONCE so the
1761    /// resulting [`Statement`] can be cached and re-executed via
1762    /// [`Engine::execute_prepared`]. Returns the same `Statement`
1763    /// the simple-query path would synthesise internally (clock
1764    /// rewrites + ORDER BY position-ref resolution applied at
1765    /// prepare time, since both are session-independent). The
1766    /// `$N` placeholders in the SQL stay as `Expr::Placeholder(n)`
1767    /// nodes; they're resolved to concrete values per-call by
1768    /// `execute_prepared`'s substitution walk.
1769    ///
1770    /// Pgwire's `Parse` (P) message lands here.
1771    pub fn prepare(&self, sql: &str) -> Result<Statement, ParseError> {
1772        let mut stmt = parser::parse_statement(sql)?;
1773        let now_micros = self.clock.map(|f| f());
1774        rewrite_clock_calls(&mut stmt, now_micros);
1775        if let Statement::Select(s) = &mut stmt {
1776            // v6.4.1 — expand `GROUP BY ALL` to every non-aggregate
1777            // SELECT-list item BEFORE position / alias resolution so
1778            // downstream passes see the explicit list.
1779            expand_group_by_all(s);
1780            resolve_order_by_position(s);
1781            // v6.2.3 — cost-based JOIN reorder. No-op for
1782            // single-table FROMs or any non-INNER join shape.
1783            reorder::reorder_joins(s, &self.catalog, &self.statistics);
1784        }
1785        Ok(stmt)
1786    }
1787
1788    /// v6.3.0 — cached prepare. Returns a cloned `Statement` from
1789    /// the plan cache on hit, runs the full `prepare()` path on miss
1790    /// and inserts the resulting plan before returning. Skipping the
1791    /// parse + JOIN-reorder pipeline on hit is the dominant win for
1792    /// JDBC / sqlx / pgx clients that reuse the same SQL string.
1793    ///
1794    /// Returns a cloned `Statement` (not a borrow) because the
1795    /// pgwire layer owns its `PreparedStmt` map per-session and the
1796    /// engine-level cache must stay available for other sessions.
1797    /// Clone cost on a 5-table JOIN AST is well under the parse cost
1798    /// it replaces.
1799    pub fn prepare_cached(&mut self, sql: &str) -> Result<Statement, ParseError> {
1800        // v6.3.1 — version-aware lookup. If the cached plan was
1801        // prepared before the most recent ANALYZE, evict and replan.
1802        let current_version = self.statistics.version();
1803        if let Some(plan) = self.plan_cache.get(sql) {
1804            if plan.statistics_version == current_version {
1805                return Ok(plan.stmt.clone());
1806            }
1807            // Stale entry — fall through to evict + re-prepare.
1808        }
1809        self.plan_cache.evict(sql);
1810        let stmt = self.prepare(sql)?;
1811        let source_tables = plan_cache::collect_source_tables(&stmt);
1812        let plan = plan_cache::PreparedPlan {
1813            stmt: stmt.clone(),
1814            statistics_version: current_version,
1815            source_tables,
1816            describe_columns: alloc::vec::Vec::new(),
1817        };
1818        self.plan_cache.insert(String::from(sql), plan);
1819        Ok(stmt)
1820    }
1821
1822    /// v6.3.0 — read-only accessor for tests and v6.3.1 invalidation.
1823    pub fn plan_cache(&self) -> &plan_cache::PlanCache {
1824        &self.plan_cache
1825    }
1826
1827    /// v6.3.0 — mutable accessor for v6.3.1 invalidation hooks.
1828    pub fn plan_cache_mut(&mut self) -> &mut plan_cache::PlanCache {
1829        &mut self.plan_cache
1830    }
1831
1832    /// v6.3.3 — Describe a prepared `Statement` without executing.
1833    /// Returns `(parameter_oids, output_columns)`. Empty
1834    /// `output_columns` means the statement has no row-producing
1835    /// shape we could resolve here (JOIN, subquery, non-SELECT, …)
1836    /// — pgwire layer maps that to a `NoData` reply.
1837    pub fn describe_prepared(&self, stmt: &Statement) -> (Vec<u32>, Vec<ColumnSchema>) {
1838        describe::describe_prepared(stmt, self.active_catalog())
1839    }
1840
1841    /// v6.1.1 — execute a [`Statement`] previously returned by
1842    /// [`Engine::prepare`], substituting `Expr::Placeholder(n)`
1843    /// nodes for the corresponding [`Value`] in `params` (1-based
1844    /// per PG: `$1` → `params[0]`). Bind-time string parameters
1845    /// are decoded into typed `Value`s by the pgwire layer before
1846    /// this call so the resulting AST hits the same execution
1847    /// path as a simple query — no SQL re-parse.
1848    ///
1849    /// Pgwire's `Execute` (E) message after a `Bind` (B) lands here.
1850    pub fn execute_prepared(
1851        &mut self,
1852        stmt: Statement,
1853        params: &[Value],
1854    ) -> Result<QueryResult, EngineError> {
1855        self.execute_prepared_with_cancel(stmt, params, CancelToken::none())
1856    }
1857
1858    /// v7.17.0 Phase 2.3 — prepared-statement entry that honors a
1859    /// caller-supplied `CancelToken`. Mirrors `execute_prepared`'s
1860    /// `current_tx` save/restore so the extended-query path stays
1861    /// transactionally consistent with the simple-query path.
1862    pub fn execute_prepared_with_cancel(
1863        &mut self,
1864        mut stmt: Statement,
1865        params: &[Value],
1866        cancel: CancelToken<'_>,
1867    ) -> Result<QueryResult, EngineError> {
1868        substitute_placeholders(&mut stmt, params)?;
1869        // v7.16.0 — set `current_tx` for the duration of the
1870        // dispatch so the `exec_*` helpers see the right TX
1871        // slot (matches what `execute_in_with_cancel` does for
1872        // simple-query). Pre-v7.16 the simple-query path
1873        // worked because every public entry point routed
1874        // through `execute_in_with_cancel`; the prepared path
1875        // skipped the wrap and so its INSERTs/UPDATEs landed
1876        // in the no-tx default slot, silently invisible to a
1877        // BEGIN/COMMIT-bracketed flow. Caught by spg-sqlx's
1878        // first transaction-visibility test.
1879        let saved = self.current_tx;
1880        self.current_tx = Some(IMPLICIT_TX);
1881        let result = self.execute_stmt_with_cancel(stmt, cancel);
1882        self.current_tx = saved;
1883        result
1884    }
1885
1886    fn execute_inner_with_cancel(
1887        &mut self,
1888        sql: &str,
1889        cancel: CancelToken<'_>,
1890    ) -> Result<QueryResult, EngineError> {
1891        cancel.check()?;
1892        let stmt = self.prepare(sql)?;
1893        // v6.5.1 — wrap the executor with a wall-clock window so we
1894        // can record into spg_stat_query. Skip when the engine has
1895        // no clock attached (no_std embedded callers).
1896        let start_us = self.clock.map(|f| f());
1897        let result = self.execute_stmt_with_cancel(stmt, cancel);
1898        if let (Some(t0), Ok(_)) = (start_us, &result) {
1899            let now = self.clock.map_or(t0, |f| f());
1900            let elapsed = now.saturating_sub(t0).max(0) as u64;
1901            self.query_stats.record(sql, elapsed, now as u64);
1902            // v6.5.6 — slow-query log: fire callback when elapsed
1903            // exceeds the configured floor.
1904            if let (Some(threshold), Some(logger)) =
1905                (self.slow_query_threshold_us, self.slow_query_logger)
1906                && elapsed >= threshold
1907            {
1908                logger(sql, elapsed);
1909            }
1910        }
1911        result
1912    }
1913
1914    fn execute_stmt_with_cancel(
1915        &mut self,
1916        stmt: Statement,
1917        cancel: CancelToken<'_>,
1918    ) -> Result<QueryResult, EngineError> {
1919        cancel.check()?;
1920        // v7.17.0 Phase 1.1 — pre-resolve nextval / currval /
1921        // setval calls in the statement tree. Walks SELECT
1922        // projection, INSERT VALUES, UPDATE SET, DELETE WHERE,
1923        // and DEFAULT exprs; replaces sequence FunctionCall
1924        // nodes with concrete Literal values minted against the
1925        // catalog. This is the only place that mutates sequence
1926        // state from a SELECT-shaped path (exec_select_cancel is
1927        // `&self` and can't reach the catalog mutably).
1928        //
1929        // Fast-path: when no sequences exist anywhere in the
1930        // catalog (the typical hot-path INSERT load), skip the
1931        // walker entirely. Single map-emptiness check on the
1932        // catalog beats walking every expression on every call.
1933        let mut stmt = stmt;
1934        // v7.17 dump-compat — the fast-path check
1935        // `sequences().is_empty()` skips pre-resolve when no
1936        // sequence exists in the *currently active* catalog
1937        // snapshot. The committed catalog or the implicit-TX
1938        // catalog may legitimately disagree on this between
1939        // CREATE SEQUENCE and a later setval(): always run the
1940        // resolver — the walk is O(expr-count) and dwarfed by
1941        // the parse cost we just paid.
1942        self.pre_resolve_sequence_calls_in_statement(&mut stmt)?;
1943        let result = match stmt {
1944            Statement::CreateTable(s) => self.exec_create_table(s),
1945            // v7.9.15 — CREATE EXTENSION is a no-op on SPG. Returns
1946            // CommandOk with affected=0; modified_catalog=false so
1947            // the WAL doesn't grow a useless entry. mailrs F3.
1948            Statement::CreateExtension(_) => Ok(QueryResult::CommandOk {
1949                affected: 0,
1950                modified_catalog: false,
1951            }),
1952            // v7.16.2 — DO $$ ... $$ block. mailrs round-10 A.2
1953            // — the pre-v7.9.27 no-op SILENTLY swallowed every
1954            // mailrs migrate-038/-040/-042 idempotent rename
1955            // (the IF EXISTS … THEN ALTER … END block never
1956            // ran). v7.16.2 dispatches to exec_do_block which
1957            // runs the PlPgSqlBlock at top level via the same
1958            // execute_stmts machinery the trigger executor
1959            // uses (NEW=None, OLD=None — DO blocks have no
1960            // row context).
1961            Statement::DoBlock(body) => self.exec_do_block(body),
1962            // v7.14.0 — empty-statement no-op for pg_dump /
1963            // mysqldump preamble lines that collapse to nothing
1964            // after comment-stripping.
1965            Statement::Empty => Ok(QueryResult::CommandOk {
1966                affected: 0,
1967                modified_catalog: false,
1968            }),
1969            Statement::DropTable { names, if_exists } => self.exec_drop_table(names, if_exists),
1970            Statement::DropIndex { name, if_exists } => self.exec_drop_index(name, if_exists),
1971            Statement::CreateIndex(s) => self.exec_create_index(s),
1972            Statement::Insert(s) => self.exec_insert(s),
1973            Statement::Update(mut s) => {
1974                // Materialise uncorrelated subqueries in SET / WHERE
1975                // before the row walk — the SELECT path has done this
1976                // since v4.10; UPDATE gained it for mailrs's
1977                // `UPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP
1978                // LOCKED)` claim pattern (embed round-12).
1979                for (_, e) in &mut s.assignments {
1980                    self.resolve_expr_subqueries(e, cancel)?;
1981                }
1982                if let Some(w) = &mut s.where_ {
1983                    self.resolve_expr_subqueries(w, cancel)?;
1984                }
1985                self.exec_update_cancel(&s, cancel)
1986            }
1987            Statement::Delete(mut s) => {
1988                if let Some(w) = &mut s.where_ {
1989                    self.resolve_expr_subqueries(w, cancel)?;
1990                }
1991                self.exec_delete_cancel(&s, cancel)
1992            }
1993            Statement::Merge(s) => self.exec_merge_cancel(&s, cancel),
1994            Statement::Select(s) => self.exec_select_cancel(&s, cancel),
1995            Statement::Begin => self.exec_begin(),
1996            Statement::Commit => self.exec_commit(),
1997            Statement::Rollback => self.exec_rollback(),
1998            Statement::Savepoint(name) => self.exec_savepoint(name),
1999            Statement::RollbackToSavepoint(name) => self.exec_rollback_to_savepoint(&name),
2000            Statement::ReleaseSavepoint(name) => self.exec_release_savepoint(&name),
2001            Statement::ShowTables => Ok(self.exec_show_tables()),
2002            Statement::ShowDatabases => Ok(self.exec_show_databases()),
2003            Statement::ShowCreateTable(name) => self.exec_show_create_table(&name),
2004            Statement::ShowIndexes(name) => self.exec_show_indexes(&name),
2005            Statement::ShowStatus => Ok(self.exec_show_status()),
2006            Statement::ShowVariables => Ok(self.exec_show_variables()),
2007            Statement::ShowProcesslist => Ok(self.exec_show_processlist()),
2008            Statement::ShowColumns(table) => self.exec_show_columns(&table),
2009            Statement::ShowUsers => Ok(self.exec_show_users()),
2010            Statement::ShowPublications => Ok(self.exec_show_publications()),
2011            Statement::ShowSubscriptions => Ok(self.exec_show_subscriptions()),
2012            Statement::CreateUser(s) => self.exec_create_user(&s),
2013            Statement::DropUser(name) => self.exec_drop_user(&name),
2014            Statement::Explain(e) => self.exec_explain(&e, cancel),
2015            Statement::AlterIndex(s) => self.exec_alter_index(s),
2016            Statement::AlterTable(s) => self.exec_alter_table(s),
2017            Statement::CreatePublication(s) => self.exec_create_publication(s),
2018            Statement::DropPublication(name) => self.exec_drop_publication(&name),
2019            Statement::CreateSubscription(s) => self.exec_create_subscription(s),
2020            Statement::DropSubscription(name) => self.exec_drop_subscription(&name),
2021            // v6.1.7 — WAIT FOR WAL POSITION needs `lag_state`,
2022            // which lives in spg-server's ServerState. The engine
2023            // surfaces a clear error; the server-layer dispatch
2024            // intercepts the SQL before it reaches the engine on
2025            // a server build, so this arm only fires for
2026            // engine-only callers (spg-embedded, lib tests).
2027            Statement::WaitForWalPosition { .. } => Err(EngineError::Unsupported(
2028                "WAIT FOR WAL POSITION must be handled by the server layer".into(),
2029            )),
2030            // v6.2.0 — ANALYZE recomputes per-column histograms.
2031            Statement::Analyze(target) => self.exec_analyze(target.as_deref()),
2032            // v6.7.3 — COMPACT COLD SEGMENTS.
2033            Statement::CompactColdSegments => self.exec_compact_cold_segments(),
2034            // v7.12.1 — SET / RESET session parameter. Engine
2035            // tracks the value in `session_params`; FTS dispatcher
2036            // reads `default_text_search_config`. Everything else
2037            // is a recorded no-op (PG dump compat).
2038            Statement::SetParameter { name, value } => {
2039                self.set_session_param(name, value);
2040                Ok(QueryResult::CommandOk {
2041                    affected: 0,
2042                    modified_catalog: false,
2043                })
2044            }
2045            // v7.14.0 — MySQL multi-assignment SET. Each pair runs
2046            // through `set_session_param` so engine-known params
2047            // (FOREIGN_KEY_CHECKS, session_replication_role, …) take
2048            // effect; unknown pairs (including `@VAR` LHS from the
2049            // mysqldump preamble) are recorded then ignored.
2050            Statement::SetParameterList(pairs) => {
2051                for (name, value) in pairs {
2052                    self.set_session_param(name, value);
2053                }
2054                Ok(QueryResult::CommandOk {
2055                    affected: 0,
2056                    modified_catalog: false,
2057                })
2058            }
2059            // v7.12.4 — CREATE FUNCTION / CREATE TRIGGER / DROP …
2060            // for the PL/pgSQL trigger surface. exec_* methods are
2061            // defined alongside the existing CREATE handlers below.
2062            Statement::CreateFunction(s) => self.exec_create_function(s),
2063            Statement::CreateTrigger(s) => self.exec_create_trigger(s),
2064            Statement::DropTrigger {
2065                name,
2066                table,
2067                if_exists,
2068            } => self.exec_drop_trigger(&name, &table, if_exists),
2069            Statement::DropFunction { name, if_exists } => {
2070                self.exec_drop_function(&name, if_exists)
2071            }
2072            Statement::CreateSequence(s) => self.exec_create_sequence(s),
2073            Statement::AlterSequence(s) => self.exec_alter_sequence(s),
2074            Statement::DropSequence { names, if_exists } => {
2075                self.exec_drop_sequence(&names, if_exists)
2076            }
2077            Statement::CreateView(s) => self.exec_create_view(s),
2078            Statement::DropView { names, if_exists } => self.exec_drop_view(&names, if_exists),
2079            Statement::CreateMaterializedView(s) => self.exec_create_materialized_view(s),
2080            Statement::RefreshMaterializedView { name, with_data } => {
2081                self.exec_refresh_materialized_view(&name, with_data)
2082            }
2083            Statement::DropMaterializedView { names, if_exists } => {
2084                self.exec_drop_materialized_view(&names, if_exists)
2085            }
2086            Statement::CreateType(s) => self.exec_create_type(s),
2087            Statement::DropType { names, if_exists } => self.exec_drop_type(&names, if_exists),
2088            Statement::CreateDomain(s) => self.exec_create_domain(s),
2089            Statement::DropDomain { names, if_exists } => self.exec_drop_domain(&names, if_exists),
2090            Statement::CreateSchema {
2091                name,
2092                if_not_exists,
2093            } => self.exec_create_schema(name, if_not_exists),
2094            Statement::DropSchema { names, if_exists } => self.exec_drop_schema(&names, if_exists),
2095            Statement::ResetParameter(target) => {
2096                match target {
2097                    None => self.session_params.clear(),
2098                    Some(name) => {
2099                        self.session_params.remove(&name.to_ascii_lowercase());
2100                    }
2101                }
2102                Ok(QueryResult::CommandOk {
2103                    affected: 0,
2104                    modified_catalog: false,
2105                })
2106            }
2107        };
2108        self.enforce_row_limit(result)
2109    }
2110
2111    /// v6.1.2 — `CREATE PUBLICATION` runtime path. Duplicate names
2112    /// surface as `EngineError::Unsupported` so the existing PG-wire
2113    /// error mapping stays uniform; the message carries the name so
2114    /// operators can grep replication-log noise. Inside-transaction
2115    /// invocation is rejected (matches `CREATE USER` / `DROP USER`
2116    /// stance) — replication-catalog mutation is a connection-level
2117    /// administrative op, not a transactional one.
2118    fn exec_create_publication(
2119        &mut self,
2120        s: CreatePublicationStatement,
2121    ) -> Result<QueryResult, EngineError> {
2122        // v6.1.4 — the v6.1.2 "no DDL inside a transaction" guard
2123        // was over-cautious: it also blocked the auto-commit wrap
2124        // path (which begins an internal TX around every WAL-
2125        // logged statement). PG itself allows CREATE PUBLICATION
2126        // inside a transaction (it rolls back with the TX).
2127        self.publications
2128            .create(s.name, s.scope)
2129            .map_err(|e| EngineError::Unsupported(alloc::format!("CREATE PUBLICATION: {e:?}")))?;
2130        Ok(QueryResult::CommandOk {
2131            affected: 1,
2132            modified_catalog: true,
2133        })
2134    }
2135
2136    /// v6.1.2 — `DROP PUBLICATION` runtime path. PG-compatible silent
2137    /// no-op when the publication doesn't exist (returns `affected=0`
2138    /// in that case so the wire-level command tag distinguishes
2139    /// "dropped" from "no-op", though both succeed).
2140    fn exec_drop_publication(&mut self, name: &str) -> Result<QueryResult, EngineError> {
2141        let removed = self.publications.drop(name);
2142        Ok(QueryResult::CommandOk {
2143            affected: usize::from(removed),
2144            modified_catalog: removed,
2145        })
2146    }
2147
2148    /// v6.1.2 — read access to the publication catalog. Used by
2149    /// the v6.1.5 publisher-side WAL filter, by `SHOW PUBLICATIONS`
2150    /// (v6.1.3+), and by e2e tests that need to assert state without
2151    /// going through the wire.
2152    pub const fn publications(&self) -> &publications::Publications {
2153        &self.publications
2154    }
2155
2156    /// v6.1.4 — `CREATE SUBSCRIPTION` runtime path. Defaults
2157    /// `enabled = true` and `last_received_pos = 0` for a freshly-
2158    /// created subscription. The actual worker thread is spawned
2159    /// by spg-server once the engine returns success.
2160    fn exec_create_subscription(
2161        &mut self,
2162        s: CreateSubscriptionStatement,
2163    ) -> Result<QueryResult, EngineError> {
2164        // See exec_create_publication — the in_transaction gate
2165        // was over-cautious; the auto-commit wrap path holds an
2166        // internal TX that this check was incorrectly blocking.
2167        let sub = subscriptions::Subscription {
2168            conn_str: s.conn_str,
2169            publications: s.publications,
2170            enabled: true,
2171            last_received_pos: 0,
2172        };
2173        self.subscriptions
2174            .create(s.name, sub)
2175            .map_err(|e| EngineError::Unsupported(alloc::format!("CREATE SUBSCRIPTION: {e:?}")))?;
2176        Ok(QueryResult::CommandOk {
2177            affected: 1,
2178            modified_catalog: true,
2179        })
2180    }
2181
2182    /// v6.1.4 — `DROP SUBSCRIPTION`. Silent no-op when the name
2183    /// doesn't exist (PG-compatible). The associated worker is
2184    /// torn down by spg-server when it observes the catalog
2185    /// change at the next snapshot or via the engine's
2186    /// subscriptions accessor (the worker polls the catalog on
2187    /// reconnect; v6.1.5's filter-side will tighten this to an
2188    /// explicit signal).
2189    fn exec_drop_subscription(&mut self, name: &str) -> Result<QueryResult, EngineError> {
2190        let removed = self.subscriptions.drop(name);
2191        Ok(QueryResult::CommandOk {
2192            affected: usize::from(removed),
2193            modified_catalog: removed,
2194        })
2195    }
2196
2197    /// v6.1.4 — read access to the subscription catalog. Used by
2198    /// the subscription worker (read its own row to find its
2199    /// publications + last applied position), by SHOW SUBSCRIPTIONS,
2200    /// and by e2e tests asserting state directly.
2201    pub const fn subscriptions(&self) -> &subscriptions::Subscriptions {
2202        &self.subscriptions
2203    }
2204
2205    /// v6.1.4 — write access to `last_received_pos`. Worker
2206    /// calls this after each apply batch (under the engine's
2207    /// write-lock). Returns `false` when the subscription was
2208    /// dropped between when the worker received the record and
2209    /// when this call landed.
2210    pub fn subscription_advance(&mut self, name: &str, pos: u64) -> bool {
2211        self.subscriptions.update_last_received_pos(name, pos)
2212    }
2213
2214    /// v6.1.4 — `SHOW SUBSCRIPTIONS` row materialisation. Returns
2215    /// `(name, conn_str, publications, enabled, last_received_pos)`
2216    /// ordered by subscription name. The `publications` column is
2217    /// the comma-joined list ("p1, p2") for ergonomic SHOW output;
2218    /// callers wanting structured access read `Engine::subscriptions`.
2219    fn exec_show_subscriptions(&self) -> QueryResult {
2220        let columns = alloc::vec![
2221            ColumnSchema::new("name", DataType::Text, false),
2222            ColumnSchema::new("conn_str", DataType::Text, false),
2223            ColumnSchema::new("publications", DataType::Text, false),
2224            ColumnSchema::new("enabled", DataType::Bool, false),
2225            ColumnSchema::new("last_received_pos", DataType::BigInt, false),
2226        ];
2227        let rows: Vec<Row> = self
2228            .subscriptions
2229            .iter()
2230            .map(|(name, sub)| {
2231                Row::new(alloc::vec![
2232                    Value::Text(name.clone()),
2233                    Value::Text(sub.conn_str.clone()),
2234                    Value::Text(sub.publications.join(", ")),
2235                    Value::Bool(sub.enabled),
2236                    Value::BigInt(i64::try_from(sub.last_received_pos).unwrap_or(i64::MAX)),
2237                ])
2238            })
2239            .collect();
2240        QueryResult::Rows { columns, rows }
2241    }
2242
2243    /// v6.2.0 — materialise `spg_statistic` rows. One row per
2244    /// `(table, column)` pair tracked in `Statistics`, with
2245    /// `histogram_bounds` rendered as a `[v0, v1, ...]` string —
2246    /// the same canonical form vector literals use for round-trip.
2247    fn exec_spg_statistic(&self) -> QueryResult {
2248        let columns = alloc::vec![
2249            ColumnSchema::new("table_name", DataType::Text, false),
2250            ColumnSchema::new("column_name", DataType::Text, false),
2251            ColumnSchema::new("null_frac", DataType::Float, false),
2252            ColumnSchema::new("n_distinct", DataType::BigInt, false),
2253            ColumnSchema::new("histogram_bounds", DataType::Text, false),
2254            // v6.7.0 — appended column (v6.2.0 stability contract
2255            // allows APPEND to spg_statistic, not reorder/rename).
2256            // Reports the cached per-table cold-row count; same
2257            // value across every column row of the same table.
2258            ColumnSchema::new("cold_row_count", DataType::BigInt, false),
2259        ];
2260        let rows: Vec<Row> = self
2261            .statistics
2262            .iter()
2263            .map(|((t, c), s)| {
2264                let cold = self
2265                    .catalog
2266                    .get(t)
2267                    .map_or(0, |table| table.cold_row_count());
2268                Row::new(alloc::vec![
2269                    Value::Text(t.clone()),
2270                    Value::Text(c.clone()),
2271                    Value::Float(f64::from(s.null_frac)),
2272                    Value::BigInt(i64::try_from(s.n_distinct).unwrap_or(i64::MAX)),
2273                    Value::Text(render_histogram_bounds(&s.histogram_bounds)),
2274                    Value::BigInt(i64::try_from(cold).unwrap_or(i64::MAX)),
2275                ])
2276            })
2277            .collect();
2278        QueryResult::Rows { columns, rows }
2279    }
2280
2281    /// v6.5.0 — materialise `spg_stat_replication` rows. One row
2282    /// per subscription with `(name, conn_str, publications,
2283    /// last_received_pos, enabled)`. Surface mirrors
2284    /// `SHOW SUBSCRIPTIONS` but follows the virtual-table dispatch
2285    /// shape so it composes with SELECT clauses (WHERE, projection
2286    /// onto specific columns, etc).
2287    fn exec_spg_stat_replication(&self) -> QueryResult {
2288        let columns = alloc::vec![
2289            ColumnSchema::new("name", DataType::Text, false),
2290            ColumnSchema::new("conn_str", DataType::Text, false),
2291            ColumnSchema::new("publications", DataType::Text, false),
2292            ColumnSchema::new("last_received_pos", DataType::BigInt, false),
2293            ColumnSchema::new("enabled", DataType::Bool, false),
2294        ];
2295        let rows: Vec<Row> = self
2296            .subscriptions
2297            .iter()
2298            .map(|(name, sub)| {
2299                Row::new(alloc::vec![
2300                    Value::Text(name.clone()),
2301                    Value::Text(sub.conn_str.clone()),
2302                    Value::Text(sub.publications.join(",")),
2303                    Value::BigInt(i64::try_from(sub.last_received_pos).unwrap_or(i64::MAX)),
2304                    Value::Bool(sub.enabled),
2305                ])
2306            })
2307            .collect();
2308        QueryResult::Rows { columns, rows }
2309    }
2310
2311    /// v6.5.0 — materialise `spg_stat_segment` rows. One row per
2312    /// cold-tier segment with `(segment_id, num_rows, num_pages,
2313    /// total_bytes)`.
2314    ///
2315    /// v6.7.0 — appended `table_name` column resolves the v6.5.0
2316    /// carve-out. Walks every user table's BTree indices to find
2317    /// which table's Cold locators point at each segment. Empty
2318    /// string for orphan segments (loaded via SPG_PRELOAD_COLD_SEGMENT
2319    /// before any index registered a locator). The walk is
2320    /// O(tables × indices × keys); cached per call, not across
2321    /// calls — re-walked on every `SELECT * FROM spg_stat_segment`.
2322    fn exec_spg_stat_segment(&self) -> QueryResult {
2323        let columns = alloc::vec![
2324            ColumnSchema::new("segment_id", DataType::BigInt, false),
2325            ColumnSchema::new("table_name", DataType::Text, false),
2326            ColumnSchema::new("num_rows", DataType::BigInt, false),
2327            ColumnSchema::new("num_pages", DataType::BigInt, false),
2328            ColumnSchema::new("total_bytes", DataType::BigInt, false),
2329        ];
2330        // v6.7.0 — build a segment_id → table_name map by walking
2331        // every user table's BTree indices once. O(tables × indices
2332        // × keys) for the v6.5.0 carve-out resolution; acceptable
2333        // because spg_stat_segment is operator-facing (not on a
2334        // hot-loop path).
2335        let mut segment_owners: alloc::collections::BTreeMap<u32, String> = BTreeMap::new();
2336        for tname in self.catalog.table_names() {
2337            if is_internal_table_name(&tname) {
2338                continue;
2339            }
2340            let Some(t) = self.catalog.get(&tname) else {
2341                continue;
2342            };
2343            for idx in t.indices() {
2344                if let spg_storage::IndexKind::BTree(map) = &idx.kind {
2345                    for (_, locs) in map.iter() {
2346                        for loc in locs {
2347                            if let spg_storage::RowLocator::Cold { segment_id, .. } = loc {
2348                                segment_owners
2349                                    .entry(*segment_id)
2350                                    .or_insert_with(|| tname.clone());
2351                            }
2352                        }
2353                    }
2354                }
2355            }
2356        }
2357        let rows: Vec<Row> = self
2358            .catalog
2359            .cold_segment_ids_global()
2360            .iter()
2361            .filter_map(|&id| {
2362                let seg = self.catalog.cold_segment(id)?;
2363                let meta = seg.meta();
2364                let owner = segment_owners.get(&id).cloned().unwrap_or_default();
2365                Some(Row::new(alloc::vec![
2366                    Value::BigInt(i64::from(id)),
2367                    Value::Text(owner),
2368                    Value::BigInt(i64::try_from(meta.num_rows).unwrap_or(i64::MAX)),
2369                    Value::BigInt(i64::from(meta.num_pages)),
2370                    Value::BigInt(i64::try_from(meta.total_bytes).unwrap_or(i64::MAX)),
2371                ]))
2372            })
2373            .collect();
2374        QueryResult::Rows { columns, rows }
2375    }
2376
2377    /// v6.5.1 — materialise `spg_stat_query` rows. One row per
2378    /// distinct SQL text recorded since the engine booted, capped
2379    /// at `QUERY_STATS_MAX` (1024). Columns:
2380    ///   sql, exec_count, total_us, mean_us, max_us, last_seen_us
2381    /// mean_us = total_us / exec_count (saturating).
2382    fn exec_spg_stat_query(&self) -> QueryResult {
2383        let columns = alloc::vec![
2384            ColumnSchema::new("sql", DataType::Text, false),
2385            ColumnSchema::new("exec_count", DataType::BigInt, false),
2386            ColumnSchema::new("total_us", DataType::BigInt, false),
2387            ColumnSchema::new("mean_us", DataType::BigInt, false),
2388            ColumnSchema::new("max_us", DataType::BigInt, false),
2389            ColumnSchema::new("last_seen_us", DataType::BigInt, false),
2390        ];
2391        let rows: Vec<Row> = self
2392            .query_stats
2393            .snapshot()
2394            .into_iter()
2395            .map(|(sql, s)| {
2396                let mean = if s.exec_count == 0 {
2397                    0
2398                } else {
2399                    s.total_us / s.exec_count
2400                };
2401                Row::new(alloc::vec![
2402                    Value::Text(sql),
2403                    Value::BigInt(i64::try_from(s.exec_count).unwrap_or(i64::MAX)),
2404                    Value::BigInt(i64::try_from(s.total_us).unwrap_or(i64::MAX)),
2405                    Value::BigInt(i64::try_from(mean).unwrap_or(i64::MAX)),
2406                    Value::BigInt(i64::try_from(s.max_us).unwrap_or(i64::MAX)),
2407                    Value::BigInt(i64::try_from(s.last_seen_us).unwrap_or(i64::MAX)),
2408                ])
2409            })
2410            .collect();
2411        QueryResult::Rows { columns, rows }
2412    }
2413
2414    /// v6.5.2 — register a connection-state provider. spg-server
2415    /// calls this at startup with a function that snapshots its
2416    /// per-pgwire-connection registry. Engine reads through the
2417    /// callback on `SELECT * FROM spg_stat_activity`.
2418    #[must_use]
2419    pub const fn with_activity_provider(mut self, f: ActivityProvider) -> Self {
2420        self.activity_provider = Some(f);
2421        self
2422    }
2423
2424    /// v6.5.3 — register audit chain provider + verifier.
2425    #[must_use]
2426    pub const fn with_audit_providers(
2427        mut self,
2428        chain: AuditChainProvider,
2429        verify: AuditVerifier,
2430    ) -> Self {
2431        self.audit_chain_provider = Some(chain);
2432        self.audit_verifier = Some(verify);
2433        self
2434    }
2435
2436    /// v6.5.6 — register a slow-query log callback. `threshold_us`
2437    /// is the floor (in microseconds); only executes above the floor
2438    /// fire the callback. spg-server wires this from
2439    /// `SPG_SLOW_QUERY_THRESHOLD_MS` (default 100 ms).
2440    #[must_use]
2441    pub const fn with_slow_query_log(mut self, threshold_us: u64, logger: SlowQueryLogger) -> Self {
2442        self.slow_query_threshold_us = Some(threshold_us);
2443        self.slow_query_logger = Some(logger);
2444        self
2445    }
2446
2447    /// v6.5.6 — operator knob for plan cache cap. spg-server reads
2448    /// `SPG_PLAN_CACHE_MAX` env at startup; uses this to override
2449    /// the compile-time default of 256.
2450    pub fn set_plan_cache_max(&mut self, n: usize) {
2451        self.plan_cache.set_max_entries(n);
2452    }
2453
2454    /// v6.5.2 — materialise `spg_stat_activity` rows. Pulls a fresh
2455    /// snapshot from the registered `ActivityProvider`. Returns an
2456    /// empty result set when no provider is registered (the no_std
2457    /// embedded path with no pgwire layer).
2458    fn exec_spg_stat_activity(&self) -> QueryResult {
2459        let columns = alloc::vec![
2460            ColumnSchema::new("pid", DataType::Int, false),
2461            ColumnSchema::new("user", DataType::Text, false),
2462            ColumnSchema::new("started_at_us", DataType::BigInt, false),
2463            ColumnSchema::new("current_sql", DataType::Text, false),
2464            ColumnSchema::new("wait_event", DataType::Text, false),
2465            ColumnSchema::new("elapsed_us", DataType::BigInt, false),
2466            ColumnSchema::new("in_transaction", DataType::Bool, false),
2467            ColumnSchema::new("application_name", DataType::Text, false),
2468        ];
2469        let rows: Vec<Row> = self
2470            .activity_provider
2471            .map(|f| f())
2472            .unwrap_or_default()
2473            .into_iter()
2474            .map(|r| {
2475                Row::new(alloc::vec![
2476                    Value::Int(i32::try_from(r.pid).unwrap_or(i32::MAX)),
2477                    Value::Text(r.user),
2478                    Value::BigInt(r.started_at_us),
2479                    Value::Text(r.current_sql),
2480                    Value::Text(r.wait_event),
2481                    Value::BigInt(r.elapsed_us),
2482                    Value::Bool(r.in_transaction),
2483                    Value::Text(r.application_name),
2484                ])
2485            })
2486            .collect();
2487        QueryResult::Rows { columns, rows }
2488    }
2489
2490    /// v6.5.4 — materialise `spg_table_ddl` rows. One row per user
2491    /// table with `(table_name, ddl)`. Reconstructed from catalog
2492    /// state on demand.
2493    fn exec_spg_table_ddl(&self) -> QueryResult {
2494        let columns = alloc::vec![
2495            ColumnSchema::new("table_name", DataType::Text, false),
2496            ColumnSchema::new("ddl", DataType::Text, false),
2497        ];
2498        let rows: Vec<Row> = self
2499            .catalog
2500            .table_names()
2501            .into_iter()
2502            .filter(|n| !is_internal_table_name(n))
2503            .filter_map(|name| {
2504                let table = self.catalog.get(&name)?;
2505                let ddl = render_create_table(&name, &table.schema().columns);
2506                Some(Row::new(alloc::vec![Value::Text(name), Value::Text(ddl),]))
2507            })
2508            .collect();
2509        QueryResult::Rows { columns, rows }
2510    }
2511
2512    /// v6.5.4 — materialise `spg_role_ddl` rows. One row per user
2513    /// with `(role_name, ddl)`. Password is redacted (matches the
2514    /// `Statement::CreateUser` Display which prints `'<redacted>'`).
2515    fn exec_spg_role_ddl(&self) -> QueryResult {
2516        let columns = alloc::vec![
2517            ColumnSchema::new("role_name", DataType::Text, false),
2518            ColumnSchema::new("ddl", DataType::Text, false),
2519        ];
2520        let rows: Vec<Row> = self
2521            .users
2522            .iter()
2523            .map(|(name, rec)| {
2524                let ddl = alloc::format!(
2525                    "CREATE USER {name} WITH PASSWORD '<redacted>' ROLE '{}'",
2526                    rec.role.as_str(),
2527                );
2528                Row::new(alloc::vec![
2529                    Value::Text(String::from(name)),
2530                    Value::Text(ddl)
2531                ])
2532            })
2533            .collect();
2534        QueryResult::Rows { columns, rows }
2535    }
2536
2537    /// v6.5.4 — materialise `spg_database_ddl`: single row whose
2538    /// `ddl` column concatenates every user table's CREATE +
2539    /// every role's CREATE in deterministic catalog order. Suitable
2540    /// for piping back through `Engine::execute` to recreate a
2541    /// schema-equivalent database.
2542    fn exec_spg_database_ddl(&self) -> QueryResult {
2543        let columns = alloc::vec![ColumnSchema::new("ddl", DataType::Text, false)];
2544        let mut out = String::new();
2545        for (name, rec) in self.users.iter() {
2546            out.push_str(&alloc::format!(
2547                "CREATE USER {name} WITH PASSWORD '<redacted>' ROLE '{}';\n",
2548                rec.role.as_str(),
2549            ));
2550        }
2551        for name in self.catalog.table_names() {
2552            if is_internal_table_name(&name) {
2553                continue;
2554            }
2555            if let Some(table) = self.catalog.get(&name) {
2556                out.push_str(&render_create_table(&name, &table.schema().columns));
2557                out.push_str(";\n");
2558            }
2559        }
2560        QueryResult::Rows {
2561            columns,
2562            rows: alloc::vec![Row::new(alloc::vec![Value::Text(out)])],
2563        }
2564    }
2565
2566    /// v6.5.3 — materialise `spg_audit_chain` rows. Pulls a fresh
2567    /// snapshot from the registered provider; empty when no
2568    /// provider is set.
2569    fn exec_spg_audit_chain(&self) -> QueryResult {
2570        let columns = alloc::vec![
2571            ColumnSchema::new("seq", DataType::BigInt, false),
2572            ColumnSchema::new("ts_ms", DataType::BigInt, false),
2573            ColumnSchema::new("prev_hash", DataType::Text, false),
2574            ColumnSchema::new("entry_hash", DataType::Text, false),
2575            ColumnSchema::new("sql", DataType::Text, false),
2576        ];
2577        let rows: Vec<Row> = self
2578            .audit_chain_provider
2579            .map(|f| f())
2580            .unwrap_or_default()
2581            .into_iter()
2582            .map(|r| {
2583                Row::new(alloc::vec![
2584                    Value::BigInt(r.seq),
2585                    Value::BigInt(r.ts_ms),
2586                    Value::Text(r.prev_hash_hex),
2587                    Value::Text(r.entry_hash_hex),
2588                    Value::Text(r.sql),
2589                ])
2590            })
2591            .collect();
2592        QueryResult::Rows { columns, rows }
2593    }
2594
2595    /// v6.5.3 — materialise `spg_audit_verify` single-row result.
2596    /// `(verified_count, broken_at_seq)` — broken_at_seq is `-1`
2597    /// on a clean chain. Returns one row with both values 0 when
2598    /// no verifier is registered (no-data fallback for embedded
2599    /// callers).
2600    fn exec_spg_audit_verify(&self) -> QueryResult {
2601        let columns = alloc::vec![
2602            ColumnSchema::new("verified_count", DataType::BigInt, false),
2603            ColumnSchema::new("broken_at_seq", DataType::BigInt, false),
2604        ];
2605        let (verified, broken) = self.audit_verifier.map(|f| f()).unwrap_or((0, -1));
2606        let row = Row::new(alloc::vec![Value::BigInt(verified), Value::BigInt(broken),]);
2607        QueryResult::Rows {
2608            columns,
2609            rows: alloc::vec![row],
2610        }
2611    }
2612
2613    /// v6.5.1 — read-only accessor for tests + v6.5.6 ops resets.
2614    pub fn query_stats(&self) -> &query_stats::QueryStats {
2615        &self.query_stats
2616    }
2617
2618    /// v6.5.1 — mutable accessor (clear, etc).
2619    pub fn query_stats_mut(&mut self) -> &mut query_stats::QueryStats {
2620        &mut self.query_stats
2621    }
2622
2623    /// v6.2.0 — read access to the per-column statistics table.
2624    /// Used by the planner (v6.2.2 selectivity functions read this),
2625    /// by `SELECT * FROM spg_statistic`, and by e2e tests.
2626    pub const fn statistics(&self) -> &statistics::Statistics {
2627        &self.statistics
2628    }
2629
2630    /// v6.2.1 — return tables whose modified-row count crossed the
2631    /// auto-analyze threshold since the last ANALYZE on that table.
2632    /// The threshold is `0.1 × max(row_count, MIN_ROWS_FOR_AUTO_
2633    /// ANALYZE)` — combines PG-style fractional + absolute lower
2634    /// bound so a fresh / tiny table doesn't get hammered on every
2635    /// INSERT.
2636    ///
2637    /// Designed to be cheap: walks every user table's
2638    /// `Catalog::table_names()` + reads `statistics::modified_
2639    /// since_last_analyze()` (BTreeMap lookup). The background
2640    /// worker calls this under `engine.read()` then drops the lock
2641    /// before re-acquiring `engine.write()` for the actual ANALYZE.
2642    pub fn tables_needing_analyze(&self) -> Vec<String> {
2643        const MIN_ROWS: u64 = 100;
2644        let mut out = Vec::new();
2645        for name in self.catalog.table_names() {
2646            if is_internal_table_name(&name) {
2647                continue;
2648            }
2649            let Some(table) = self.catalog.get(&name) else {
2650                continue;
2651            };
2652            let row_count = table.rows().len() as u64;
2653            let modified = self.statistics.modified_since_last_analyze(&name);
2654            // Threshold: ceil(0.1 × max(row_count, MIN_ROWS)),
2655            // computed in integer arithmetic so spg-engine stays
2656            // no_std without pulling in libm. `(n + 9) / 10` is
2657            // `ceil(n / 10)` for non-negative `n`.
2658            let base = row_count.max(MIN_ROWS);
2659            let threshold = base.saturating_add(9) / 10;
2660            if modified >= threshold {
2661                out.push(name);
2662            }
2663        }
2664        out
2665    }
2666
2667    /// v6.2.0 — `ANALYZE [<table>]` runtime. Bare `ANALYZE` walks
2668    /// every user table; `ANALYZE <name>` re-stats one. For each
2669    /// target table, single-pass scan + per-column histogram +
2670    /// `null_frac` + `n_distinct`. Replaces the table's prior
2671    /// stats; resets the modified-row counter.
2672    ///
2673    /// v6.2.0 doesn't sample — it scans the full table. v6.2.x
2674    /// can add reservoir sampling at the > 100 K-row mark; not a
2675    /// scope blocker for the current commit since rows ≤ 100 K
2676    /// analyse in milliseconds.
2677    fn exec_analyze(&mut self, target: Option<&str>) -> Result<QueryResult, EngineError> {
2678        let names: Vec<String> = if let Some(name) = target {
2679            // Verify the table exists; surface a clear error if not.
2680            if self.catalog.get(name).is_none() {
2681                return Err(EngineError::Storage(StorageError::TableNotFound {
2682                    name: name.to_string(),
2683                }));
2684            }
2685            alloc::vec![name.to_string()]
2686        } else {
2687            self.catalog
2688                .table_names()
2689                .into_iter()
2690                .filter(|n| !is_internal_table_name(n))
2691                .collect()
2692        };
2693        let mut analysed = 0usize;
2694        for table_name in &names {
2695            self.analyze_one_table(table_name)?;
2696            analysed += 1;
2697        }
2698        // v6.3.1 — plan cache invalidation. Bump stats version so
2699        // future lookups see the new generation, and selectively
2700        // evict every plan whose `source_tables` overlap with the
2701        // ANALYZE target set. Bare ANALYZE (all tables) clears the
2702        // whole cache.
2703        if analysed > 0 {
2704            self.statistics.bump_version();
2705            if target.is_some() {
2706                for t in &names {
2707                    self.plan_cache.evict_referencing(t);
2708                }
2709            } else {
2710                self.plan_cache.clear();
2711            }
2712        }
2713        Ok(QueryResult::CommandOk {
2714            affected: analysed,
2715            modified_catalog: true,
2716        })
2717    }
2718
2719    /// v6.7.3 — `COMPACT COLD SEGMENTS` runtime path. Drives the
2720    /// engine-layer compaction shim with the default
2721    /// 4 MiB segment-size threshold. spg-server intercepts the
2722    /// SQL before it reaches the engine on a server build —
2723    /// it reads `SPG_COMPACTION_TARGET_SEGMENT_BYTES`, calls
2724    /// `Engine::compact_cold_segments_with_target` directly with
2725    /// the env value, and persists every merged segment to
2726    /// v7.12.1 — record a `SET <name> = <value>` parameter. Names
2727    /// are case-folded to lowercase to match PG; values keep their
2728    /// caller-supplied form so observability paths see what was
2729    /// requested. Only `default_text_search_config` is consulted by
2730    /// the engine today.
2731    fn set_session_param(&mut self, name: String, value: spg_sql::ast::SetValue) {
2732        let normalised = match value {
2733            spg_sql::ast::SetValue::String(s) => s,
2734            spg_sql::ast::SetValue::Ident(s) => s,
2735            spg_sql::ast::SetValue::Number(s) => s,
2736            spg_sql::ast::SetValue::Default => String::new(),
2737        };
2738        let key = name.to_ascii_lowercase();
2739        // v7.14.0 — mysqldump preamble emits
2740        // `SET FOREIGN_KEY_CHECKS=0` so it can CREATE TABLE in any
2741        // order despite cross-table FK references; the closing
2742        // section emits `SET FOREIGN_KEY_CHECKS=1` (or
2743        // `=@OLD_FOREIGN_KEY_CHECKS` which resolves to "ON" in our
2744        // session-variable-aware path). Match both shapes.
2745        // Also accept PG's `session_replication_role = 'replica'`
2746        // which suppresses trigger + FK enforcement during a
2747        // logical replication apply (pg_dump preserves this for
2748        // schema-only mode but it shows up in some restores).
2749        let value_off = matches!(
2750            normalised.to_ascii_lowercase().as_str(),
2751            "0" | "off" | "false"
2752        );
2753        let value_on = matches!(
2754            normalised.to_ascii_lowercase().as_str(),
2755            "1" | "on" | "true"
2756        );
2757        if key == "foreign_key_checks"
2758            || key == "session_replication_role" && normalised.eq_ignore_ascii_case("replica")
2759        {
2760            if value_off || key == "session_replication_role" {
2761                self.foreign_key_checks = false;
2762            } else if value_on
2763                || (key == "session_replication_role" && normalised.eq_ignore_ascii_case("origin"))
2764            {
2765                self.foreign_key_checks = true;
2766                // Drain pending FK queue against the now-complete
2767                // catalog. Errors here surface as the SET reply —
2768                // caller knows enabling checks revealed orphans.
2769                let _ = self.drain_pending_foreign_keys();
2770            }
2771        }
2772        self.session_params.insert(key, normalised);
2773    }
2774
2775    /// v7.14.0 — resolve every queued FK whose installation was
2776    /// deferred (`SET FOREIGN_KEY_CHECKS=0` window). Called by
2777    /// `set_session_param` when checks flip back on and by the
2778    /// drop-import release gate. Each FK is resolved against the
2779    /// current catalog; remaining missing-parent errors propagate
2780    /// up so the caller knows the import was incomplete.
2781    fn drain_pending_foreign_keys(&mut self) -> Result<(), EngineError> {
2782        let pending = core::mem::take(&mut self.pending_foreign_keys);
2783        for (child, fk) in pending {
2784            // Resolve against the current catalog. Skip silently
2785            // when the child table itself was dropped between
2786            // queue + drain.
2787            let cols_snapshot = match self.active_catalog().get(&child) {
2788                Some(t) => t.schema().columns.clone(),
2789                None => continue,
2790            };
2791            let storage_fk =
2792                resolve_foreign_key(&child, &cols_snapshot, fk, self.active_catalog())?;
2793            let table = self
2794                .active_catalog_mut()
2795                .get_mut(&child)
2796                .expect("checked above");
2797            table.schema_mut().foreign_keys.push(storage_fk);
2798        }
2799        Ok(())
2800    }
2801
2802    /// v7.12.1 — read a session parameter set via `SET`. Used by
2803    /// the FTS function dispatcher to resolve the default config
2804    /// for `to_tsvector(text)` / `plainto_tsquery(text)` etc.
2805    #[must_use]
2806    pub fn session_param(&self, name: &str) -> Option<&str> {
2807        self.session_params
2808            .get(&name.to_ascii_lowercase())
2809            .map(String::as_str)
2810    }
2811
2812    /// v7.12.1 — build an `EvalContext` chained with the session's
2813    /// `default_text_search_config`. Engine-internal callers use
2814    /// this instead of `EvalContext::new` so the FTS function
2815    /// dispatcher sees the SET configuration.
2816    fn ev_ctx<'a>(
2817        &'a self,
2818        columns: &'a [ColumnSchema],
2819        alias: Option<&'a str>,
2820    ) -> EvalContext<'a> {
2821        EvalContext::new(columns, alias)
2822            .with_default_text_search_config(self.session_param("default_text_search_config"))
2823    }
2824
2825    /// `<db>.spg/segments/`. This arm only fires for engine-only
2826    /// callers (spg-embedded, lib tests); in that mode merged
2827    /// segments live in memory and are dropped at process exit.
2828    fn exec_compact_cold_segments(&mut self) -> Result<QueryResult, EngineError> {
2829        let target = COMPACTION_TARGET_DEFAULT_BYTES;
2830        let reports = self.compact_cold_segments_with_target(target)?;
2831        let columns = alloc::vec![
2832            ColumnSchema::new("table_name", DataType::Text, false),
2833            ColumnSchema::new("index_name", DataType::Text, false),
2834            ColumnSchema::new("sources_merged", DataType::BigInt, false),
2835            ColumnSchema::new("merged_segment_id", DataType::BigInt, false),
2836            ColumnSchema::new("merged_rows", DataType::BigInt, false),
2837            ColumnSchema::new("deleted_rows_pruned", DataType::BigInt, false),
2838            ColumnSchema::new("bytes_reclaimed_estimate", DataType::BigInt, false),
2839        ];
2840        let rows: Vec<Row> = reports
2841            .into_iter()
2842            .map(|(tname, iname, report)| {
2843                Row::new(alloc::vec![
2844                    Value::Text(tname),
2845                    Value::Text(iname),
2846                    Value::BigInt(i64::try_from(report.sources.len()).unwrap_or(i64::MAX)),
2847                    Value::BigInt(i64::from(report.merged_segment_id.unwrap_or(0))),
2848                    Value::BigInt(i64::try_from(report.merged_rows).unwrap_or(i64::MAX)),
2849                    Value::BigInt(i64::try_from(report.deleted_rows_pruned).unwrap_or(i64::MAX),),
2850                    Value::BigInt(
2851                        i64::try_from(report.bytes_reclaimed_estimate).unwrap_or(i64::MAX),
2852                    ),
2853                ])
2854            })
2855            .collect();
2856        Ok(QueryResult::Rows { columns, rows })
2857    }
2858
2859    /// Walk a single table's rows once and (re-)populate per-column
2860    /// stats. Drops the existing stats for `table` first so columns
2861    /// that have been DROP-ed between ANALYZEs don't leave stale
2862    /// rows.
2863    fn analyze_one_table(&mut self, table_name: &str) -> Result<(), EngineError> {
2864        let table = self.catalog.get(table_name).ok_or_else(|| {
2865            EngineError::Storage(StorageError::TableNotFound {
2866                name: table_name.to_string(),
2867            })
2868        })?;
2869        let schema = table.schema().clone();
2870        let row_count = table.rows().len();
2871        // For each column, collect (sorted) non-NULL textual values
2872        // + count NULLs; then ask `statistics::build_histogram` to
2873        // produce the 101 bounds and `estimate_n_distinct` the
2874        // distinct count.
2875        self.statistics.clear_table(table_name);
2876        for (col_pos, col_schema) in schema.columns.iter().enumerate() {
2877            // v6.2.0 skip: vector columns have their own stats
2878            // shape (HNSW graph topology). v6.2 deliberation #1.
2879            if matches!(col_schema.ty, DataType::Vector { .. }) {
2880                continue;
2881            }
2882            let mut non_null_values: Vec<Value> = Vec::with_capacity(row_count);
2883            let mut nulls: u64 = 0;
2884            for row in table.rows() {
2885                match row.values.get(col_pos) {
2886                    Some(Value::Null) | None => nulls += 1,
2887                    Some(v) => non_null_values.push(v.clone()),
2888                }
2889            }
2890            // Sort by type-aware ordering (Int as int, Text as
2891            // lex, etc.) so histogram bounds reflect the column's
2892            // natural order — not lexicographic on the string
2893            // representation, which would put "9" after "49".
2894            non_null_values.sort_by(|a, b| sort_values_for_histogram(a, b));
2895            let non_null: Vec<String> = non_null_values.iter().map(canonical_value_repr).collect();
2896            let null_frac = if row_count == 0 {
2897                0.0
2898            } else {
2899                #[allow(clippy::cast_precision_loss)]
2900                let f = nulls as f32 / row_count as f32;
2901                f
2902            };
2903            let n_distinct = statistics::estimate_n_distinct(&non_null);
2904            let histogram_bounds = statistics::build_histogram(&non_null);
2905            self.statistics.set(
2906                table_name.to_string(),
2907                col_schema.name.clone(),
2908                statistics::ColumnStats {
2909                    null_frac,
2910                    n_distinct,
2911                    histogram_bounds,
2912                },
2913            );
2914        }
2915        self.statistics.reset_modified(table_name);
2916        // v6.7.0 — refresh the per-table cold_rows cache. Walk the
2917        // BTree indices and count Cold locators (MAX across
2918        // indices); store the result on the table. Surfaced via
2919        // `spg_statistic.cold_row_count` (new column) and
2920        // `spg_stat_segment.table_name` (new column).
2921        let cold_count = {
2922            let table = self
2923                .active_catalog()
2924                .get(table_name)
2925                .expect("table still present");
2926            table.count_cold_locators()
2927        };
2928        let table_mut = self
2929            .active_catalog_mut()
2930            .get_mut(table_name)
2931            .expect("table still present");
2932        table_mut.set_cold_row_count(cold_count);
2933        Ok(())
2934    }
2935
2936    /// v6.1.3 — `SHOW PUBLICATIONS` row materialisation. Returns
2937    /// `(name, scope, table_count)` ordered by publication name.
2938    ///   - `scope` is the human-readable string:
2939    ///       `"FOR ALL TABLES"` /
2940    ///       `"FOR TABLE t1, t2"` /
2941    ///       `"FOR ALL TABLES EXCEPT t1, t2"`.
2942    ///   - `table_count` is NULL for `AllTables`, the list length
2943    ///     otherwise. NULLability lets clients distinguish "publish
2944    ///     everything" from "publish exactly 0 tables" (the v6.1.3
2945    ///     parser forbids the empty list, but the column shape is
2946    ///     ready for the v6.1.5 publisher-side semantics).
2947    fn exec_show_publications(&self) -> QueryResult {
2948        let columns = alloc::vec![
2949            ColumnSchema::new("name", DataType::Text, false),
2950            ColumnSchema::new("scope", DataType::Text, false),
2951            ColumnSchema::new("table_count", DataType::Int, true),
2952        ];
2953        let rows: Vec<Row> = self
2954            .publications
2955            .iter()
2956            .map(|(name, scope)| {
2957                let (scope_str, count_val) = match scope {
2958                    spg_sql::ast::PublicationScope::AllTables => {
2959                        ("FOR ALL TABLES".to_string(), Value::Null)
2960                    }
2961                    spg_sql::ast::PublicationScope::ForTables(ts) => (
2962                        alloc::format!("FOR TABLE {}", ts.join(", ")),
2963                        Value::Int(i32::try_from(ts.len()).unwrap_or(i32::MAX)),
2964                    ),
2965                    spg_sql::ast::PublicationScope::AllTablesExcept(ts) => (
2966                        alloc::format!("FOR ALL TABLES EXCEPT {}", ts.join(", ")),
2967                        Value::Int(i32::try_from(ts.len()).unwrap_or(i32::MAX)),
2968                    ),
2969                };
2970                Row::new(alloc::vec![
2971                    Value::Text(name.clone()),
2972                    Value::Text(scope_str),
2973                    count_val,
2974                ])
2975            })
2976            .collect();
2977        QueryResult::Rows { columns, rows }
2978    }
2979
2980    /// v4.1 `SHOW USERS` — `(name, role)` per row, ordered by name.
2981    fn exec_show_users(&self) -> QueryResult {
2982        let columns = alloc::vec![
2983            ColumnSchema::new("name", DataType::Text, false),
2984            ColumnSchema::new("role", DataType::Text, false),
2985        ];
2986        let rows: Vec<Row> = self
2987            .users
2988            .iter()
2989            .map(|(name, rec)| {
2990                Row::new(alloc::vec![
2991                    Value::Text(name.to_string()),
2992                    Value::Text(rec.role.as_str().to_string()),
2993                ])
2994            })
2995            .collect();
2996        QueryResult::Rows { columns, rows }
2997    }
2998
2999    fn exec_create_user(&mut self, s: &CreateUserStatement) -> Result<QueryResult, EngineError> {
3000        if self.in_transaction() {
3001            return Err(EngineError::Unsupported(
3002                "CREATE USER is not allowed inside a transaction".into(),
3003            ));
3004        }
3005        let role = users::Role::parse(&s.role).ok_or_else(|| {
3006            EngineError::Unsupported(alloc::format!("invalid role: {:?}", s.role))
3007        })?;
3008        // Prefer the host-injected RNG. Falls back to a deterministic
3009        // salt derived from the username only when no RNG is wired —
3010        // acceptable for tests; the server always installs one.
3011        let salt = self.salt_fn.map_or_else(
3012            || {
3013                let mut s_bytes = [0u8; 16];
3014                let digest = spg_crypto::hash(s.name.as_bytes());
3015                s_bytes.copy_from_slice(&digest[..16]);
3016                s_bytes
3017            },
3018            |f| f(),
3019        );
3020        self.users
3021            .create(&s.name, &s.password, role, salt)
3022            .map_err(|e| EngineError::Unsupported(alloc::format!("CREATE USER: {e}")))?;
3023        Ok(QueryResult::CommandOk {
3024            affected: 1,
3025            modified_catalog: true,
3026        })
3027    }
3028
3029    fn exec_drop_user(&mut self, name: &str) -> Result<QueryResult, EngineError> {
3030        if self.in_transaction() {
3031            return Err(EngineError::Unsupported(
3032                "DROP USER is not allowed inside a transaction".into(),
3033            ));
3034        }
3035        self.users
3036            .drop(name)
3037            .map_err(|e| EngineError::Unsupported(alloc::format!("DROP USER: {e}")))?;
3038        Ok(QueryResult::CommandOk {
3039            affected: 1,
3040            modified_catalog: true,
3041        })
3042    }
3043
3044    /// v7.12.4 — `CREATE [OR REPLACE] FUNCTION`. Stores the
3045    /// function metadata in the catalog. PL/pgSQL bodies are
3046    /// already parsed by the SQL parser; we re-canonicalise the
3047    /// body to source text for storage (the executor re-parses
3048    /// it at trigger fire time — see the trigger fire path).
3049    fn exec_create_function(
3050        &mut self,
3051        s: spg_sql::ast::CreateFunctionStatement,
3052    ) -> Result<QueryResult, EngineError> {
3053        let args_repr = render_function_args(&s.args);
3054        let returns = match &s.returns {
3055            spg_sql::ast::FunctionReturn::Trigger => alloc::string::String::from("TRIGGER"),
3056            spg_sql::ast::FunctionReturn::Void => alloc::string::String::from("VOID"),
3057            spg_sql::ast::FunctionReturn::Type(t) => alloc::format!("{t}"),
3058            spg_sql::ast::FunctionReturn::Other(s) => s.clone(),
3059        };
3060        let body_text = match &s.body {
3061            spg_sql::ast::FunctionBody::PlPgSql(b) => alloc::format!("{b}"),
3062            spg_sql::ast::FunctionBody::Raw(s) => s.clone(),
3063        };
3064        let def = spg_storage::FunctionDef {
3065            name: s.name.clone(),
3066            args_repr,
3067            returns,
3068            language: s.language.clone(),
3069            body: body_text,
3070        };
3071        self.active_catalog_mut()
3072            .create_function(def, s.or_replace)
3073            .map_err(EngineError::Storage)?;
3074        Ok(QueryResult::CommandOk {
3075            affected: 0,
3076            modified_catalog: true,
3077        })
3078    }
3079
3080    /// v7.12.4 — `CREATE [OR REPLACE] TRIGGER`. The referenced
3081    /// function must already exist in the catalog (forward
3082    /// references defer to a later release). Persists the
3083    /// trigger metadata for the row-write hooks below to consult.
3084    fn exec_create_trigger(
3085        &mut self,
3086        s: spg_sql::ast::CreateTriggerStatement,
3087    ) -> Result<QueryResult, EngineError> {
3088        let timing = match s.timing {
3089            spg_sql::ast::TriggerTiming::Before => "BEFORE",
3090            spg_sql::ast::TriggerTiming::After => "AFTER",
3091            spg_sql::ast::TriggerTiming::InsteadOf => "INSTEAD OF",
3092        };
3093        let events: Vec<alloc::string::String> = s
3094            .events
3095            .iter()
3096            .map(|e| match e {
3097                spg_sql::ast::TriggerEvent::Insert => alloc::string::String::from("INSERT"),
3098                spg_sql::ast::TriggerEvent::Update => alloc::string::String::from("UPDATE"),
3099                spg_sql::ast::TriggerEvent::Delete => alloc::string::String::from("DELETE"),
3100                spg_sql::ast::TriggerEvent::Truncate => alloc::string::String::from("TRUNCATE"),
3101            })
3102            .collect();
3103        let for_each = match s.for_each {
3104            spg_sql::ast::TriggerForEach::Row => "ROW",
3105            spg_sql::ast::TriggerForEach::Statement => "STATEMENT",
3106        };
3107        let def = spg_storage::TriggerDef {
3108            name: s.name.clone(),
3109            table: s.table.clone(),
3110            timing: alloc::string::String::from(timing),
3111            events,
3112            for_each: alloc::string::String::from(for_each),
3113            function: s.function.clone(),
3114            update_columns: s.update_columns.clone(),
3115            // v7.16.1 — every trigger is born enabled. Toggled
3116            // by ALTER TABLE … { ENABLE | DISABLE } TRIGGER.
3117            enabled: true,
3118        };
3119        self.active_catalog_mut()
3120            .create_trigger(def, s.or_replace)
3121            .map_err(EngineError::Storage)?;
3122        Ok(QueryResult::CommandOk {
3123            affected: 0,
3124            modified_catalog: true,
3125        })
3126    }
3127
3128    fn exec_drop_trigger(
3129        &mut self,
3130        name: &str,
3131        table: &str,
3132        if_exists: bool,
3133    ) -> Result<QueryResult, EngineError> {
3134        let removed = self.active_catalog_mut().drop_trigger(name, table);
3135        if !removed && !if_exists {
3136            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3137                alloc::format!("trigger {name:?} on {table:?} does not exist"),
3138            )));
3139        }
3140        Ok(QueryResult::CommandOk {
3141            affected: usize::from(removed),
3142            modified_catalog: removed,
3143        })
3144    }
3145
3146    fn exec_drop_function(
3147        &mut self,
3148        name: &str,
3149        if_exists: bool,
3150    ) -> Result<QueryResult, EngineError> {
3151        let removed = self.active_catalog_mut().drop_function(name);
3152        if !removed && !if_exists {
3153            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3154                alloc::format!("function {name:?} does not exist"),
3155            )));
3156        }
3157        Ok(QueryResult::CommandOk {
3158            affected: usize::from(removed),
3159            modified_catalog: removed,
3160        })
3161    }
3162
3163    /// v7.17.0 — `CREATE SEQUENCE` engine path. Resolves
3164    /// `min_value` / `max_value` / `start` against PG defaults
3165    /// when omitted, then installs the SequenceDef in the catalog.
3166    fn exec_create_sequence(
3167        &mut self,
3168        s: spg_sql::ast::CreateSequenceStatement,
3169    ) -> Result<QueryResult, EngineError> {
3170        use spg_sql::ast::{SeqBound, SequenceDataType as AstDt};
3171        use spg_storage::{SequenceDataType, SequenceDef};
3172        let dt = match s.data_type {
3173            None => SequenceDataType::BigInt,
3174            Some(AstDt::SmallInt) => SequenceDataType::SmallInt,
3175            Some(AstDt::Int) => SequenceDataType::Int,
3176            Some(AstDt::BigInt) => SequenceDataType::BigInt,
3177        };
3178        let increment = s.options.increment.unwrap_or(1);
3179        if increment == 0 {
3180            return Err(EngineError::Unsupported(
3181                "INCREMENT must not be zero".into(),
3182            ));
3183        }
3184        let (def_min, def_max) = dt.default_bounds(increment > 0);
3185        let min_value = match s.options.min_value {
3186            None | Some(SeqBound::NoBound) => def_min,
3187            Some(SeqBound::Value(n)) => n,
3188        };
3189        let max_value = match s.options.max_value {
3190            None | Some(SeqBound::NoBound) => def_max,
3191            Some(SeqBound::Value(n)) => n,
3192        };
3193        if min_value > max_value {
3194            return Err(EngineError::Unsupported(alloc::format!(
3195                "MINVALUE ({min_value}) must be <= MAXVALUE ({max_value})"
3196            )));
3197        }
3198        let start = s
3199            .options
3200            .start
3201            .unwrap_or(if increment > 0 { min_value } else { max_value });
3202        if start < min_value || start > max_value {
3203            return Err(EngineError::Unsupported(alloc::format!(
3204                "START WITH ({start}) is outside MINVALUE..MAXVALUE ({min_value}..{max_value})"
3205            )));
3206        }
3207        let cache = s.options.cache.unwrap_or(1);
3208        if cache < 1 {
3209            return Err(EngineError::Unsupported("CACHE must be >= 1".into()));
3210        }
3211        let cycle = s.options.cycle.unwrap_or(false);
3212        let owned_by = match s.options.owned_by {
3213            None | Some(spg_sql::ast::SequenceOwnedBy::None) => None,
3214            Some(spg_sql::ast::SequenceOwnedBy::Column { table, column }) => Some((table, column)),
3215        };
3216        let def = SequenceDef {
3217            name: s.name.clone(),
3218            data_type: dt,
3219            start,
3220            increment,
3221            min_value,
3222            max_value,
3223            cache,
3224            cycle,
3225            owned_by,
3226            last_value: start,
3227            is_called: false,
3228        };
3229        self.active_catalog_mut()
3230            .create_sequence(def, s.if_not_exists)
3231            .map_err(EngineError::Storage)?;
3232        Ok(QueryResult::CommandOk {
3233            affected: 0,
3234            modified_catalog: !self.in_transaction(),
3235        })
3236    }
3237
3238    /// v7.17.0 — `ALTER SEQUENCE` engine path. Re-uses the catalog
3239    /// `alter_sequence` merge helper.
3240    fn exec_alter_sequence(
3241        &mut self,
3242        s: spg_sql::ast::AlterSequenceStatement,
3243    ) -> Result<QueryResult, EngineError> {
3244        use spg_sql::ast::SeqBound;
3245        let cat = self.active_catalog_mut();
3246        if !cat.sequences().contains_key(&s.name) {
3247            if s.if_exists {
3248                return Ok(QueryResult::CommandOk {
3249                    affected: 0,
3250                    modified_catalog: false,
3251                });
3252            }
3253            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3254                alloc::format!("sequence {:?} does not exist", s.name),
3255            )));
3256        }
3257        let min_value = match s.options.min_value {
3258            None => None,
3259            Some(SeqBound::NoBound) => None, // NO MINVALUE → keep current
3260            Some(SeqBound::Value(n)) => Some(n),
3261        };
3262        let max_value = match s.options.max_value {
3263            None => None,
3264            Some(SeqBound::NoBound) => None,
3265            Some(SeqBound::Value(n)) => Some(n),
3266        };
3267        let owned_by = s.options.owned_by.map(|ob| match ob {
3268            spg_sql::ast::SequenceOwnedBy::None => None,
3269            spg_sql::ast::SequenceOwnedBy::Column { table, column } => Some((table, column)),
3270        });
3271        cat.alter_sequence(
3272            &s.name,
3273            s.options.increment,
3274            min_value,
3275            max_value,
3276            s.options.start,
3277            s.options.restart,
3278            s.options.cache,
3279            s.options.cycle,
3280            owned_by,
3281        )
3282        .map_err(EngineError::Storage)?;
3283        Ok(QueryResult::CommandOk {
3284            affected: 0,
3285            modified_catalog: !self.in_transaction(),
3286        })
3287    }
3288
3289    /// v7.17.0 Phase 1.1 — walk a Statement tree and pre-resolve
3290    /// any sequence FunctionCall nodes inside its Expr slots.
3291    /// Delegates per-statement-kind: SELECT projection +
3292    /// WHERE, INSERT VALUES, UPDATE SET, DELETE WHERE.
3293    fn pre_resolve_sequence_calls_in_statement(
3294        &mut self,
3295        stmt: &mut Statement,
3296    ) -> Result<(), EngineError> {
3297        match stmt {
3298            Statement::Select(s) => self.pre_resolve_sequence_calls_in_select(s),
3299            Statement::Insert(s) => {
3300                for tuple in &mut s.rows {
3301                    for cell in tuple.iter_mut() {
3302                        self.resolve_sequence_calls_in_expr(cell)?;
3303                    }
3304                }
3305                Ok(())
3306            }
3307            Statement::Update(s) => {
3308                for (_col, expr) in &mut s.assignments {
3309                    self.resolve_sequence_calls_in_expr(expr)?;
3310                }
3311                if let Some(w) = &mut s.where_ {
3312                    self.resolve_sequence_calls_in_expr(w)?;
3313                }
3314                Ok(())
3315            }
3316            Statement::Delete(s) => {
3317                if let Some(w) = &mut s.where_ {
3318                    self.resolve_sequence_calls_in_expr(w)?;
3319                }
3320                Ok(())
3321            }
3322            _ => Ok(()),
3323        }
3324    }
3325
3326    fn pre_resolve_sequence_calls_in_select(
3327        &mut self,
3328        s: &mut spg_sql::ast::SelectStatement,
3329    ) -> Result<(), EngineError> {
3330        for item in &mut s.items {
3331            match item {
3332                spg_sql::ast::SelectItem::Expr { expr, .. } => {
3333                    self.resolve_sequence_calls_in_expr(expr)?;
3334                }
3335                spg_sql::ast::SelectItem::Wildcard => {}
3336            }
3337        }
3338        if let Some(w) = &mut s.where_ {
3339            self.resolve_sequence_calls_in_expr(w)?;
3340        }
3341        Ok(())
3342    }
3343
3344    /// v7.17.0 Phase 1.1 — walk an Expr tree and pre-resolve any
3345    /// `nextval(name)` / `currval(name)` / `setval(name, value[,
3346    /// is_called])` FunctionCall nodes by calling the catalog and
3347    /// replacing the node with the resulting `Expr::Literal`.
3348    /// Used by INSERT VALUES / UPDATE SET / DEFAULT eval so the
3349    /// row-eval path sees pre-computed sequence values instead of
3350    /// needing mutable catalog access mid-eval.
3351    #[allow(clippy::too_many_lines)]
3352    fn resolve_sequence_calls_in_expr(&mut self, expr: &mut Expr) -> Result<(), EngineError> {
3353        match expr {
3354            Expr::Literal(_) | Expr::Column(_) | Expr::Placeholder(_) => Ok(()),
3355            Expr::FunctionCall { name, args } => {
3356                // Descend first so nested calls — e.g.
3357                // setval('seq', currval('other')) — resolve
3358                // innermost-first.
3359                for a in args.iter_mut() {
3360                    self.resolve_sequence_calls_in_expr(a)?;
3361                }
3362                let lc = name.to_ascii_lowercase();
3363                if lc == "nextval" || lc == "currval" || lc == "setval" {
3364                    let v = self.eval_sequence_call(&lc, args)?;
3365                    *expr = Expr::Literal(value_to_literal(v));
3366                }
3367                Ok(())
3368            }
3369            Expr::Binary { lhs, rhs, .. } => {
3370                self.resolve_sequence_calls_in_expr(lhs)?;
3371                self.resolve_sequence_calls_in_expr(rhs)
3372            }
3373            Expr::Unary { expr, .. } => self.resolve_sequence_calls_in_expr(expr),
3374            Expr::Cast { expr, .. } => self.resolve_sequence_calls_in_expr(expr),
3375            Expr::IsNull { expr, .. } => self.resolve_sequence_calls_in_expr(expr),
3376            Expr::Like { expr, pattern, .. } => {
3377                self.resolve_sequence_calls_in_expr(expr)?;
3378                self.resolve_sequence_calls_in_expr(pattern)
3379            }
3380            Expr::Extract { source, .. } => self.resolve_sequence_calls_in_expr(source),
3381            Expr::Array(items) => {
3382                for it in items.iter_mut() {
3383                    self.resolve_sequence_calls_in_expr(it)?;
3384                }
3385                Ok(())
3386            }
3387            // Window / subquery / etc — sequence calls inside these
3388            // are uncommon and require separate row-eval; leave
3389            // untouched for now and rely on the eval-time error
3390            // (no sequence_resolver attached).
3391            _ => Ok(()),
3392        }
3393    }
3394
3395    /// v7.17.0 Phase 1.1 — evaluate a single nextval/currval/
3396    /// setval call. `args` are already pre-resolved Expr nodes
3397    /// (literals) — we extract their constant values.
3398    fn eval_sequence_call(&mut self, op: &str, args: &[Expr]) -> Result<Value, EngineError> {
3399        if args.is_empty() {
3400            return Err(EngineError::Unsupported(alloc::format!(
3401                "{op}() takes at least one argument"
3402            )));
3403        }
3404        let seq_name = match &args[0] {
3405            Expr::Literal(spg_sql::ast::Literal::String(s)) => {
3406                // v7.17 dump-compat — pg_dump emits sequence
3407                // names schema-qualified (`'public.posts_id_seq'`).
3408                // SPG is single-schema; strip a leading
3409                // `public.` / `pg_catalog.` so the catalog lookup
3410                // matches the bare-name CREATE SEQUENCE used.
3411                let trimmed = s
3412                    .strip_prefix("public.")
3413                    .or_else(|| s.strip_prefix("pg_catalog."))
3414                    .unwrap_or(s);
3415                trimmed.to_string()
3416            }
3417            // v7.17 dump-compat — pg_dump also emits
3418            // `nextval('public.posts_id_seq'::regclass)`
3419            // where the cast wraps the literal. Peel the cast
3420            // and continue.
3421            Expr::Cast { expr, .. } => {
3422                if let Expr::Literal(spg_sql::ast::Literal::String(s)) = expr.as_ref() {
3423                    let trimmed = s
3424                        .strip_prefix("public.")
3425                        .or_else(|| s.strip_prefix("pg_catalog."))
3426                        .unwrap_or(s);
3427                    trimmed.to_string()
3428                } else {
3429                    return Err(EngineError::Unsupported(alloc::format!(
3430                        "{op}() first argument must be a literal sequence name"
3431                    )));
3432                }
3433            }
3434            other => {
3435                return Err(EngineError::Unsupported(alloc::format!(
3436                    "{op}() first argument must be a literal sequence name, got {other:?}"
3437                )));
3438            }
3439        };
3440        match op {
3441            "nextval" => {
3442                let v = self
3443                    .active_catalog_mut()
3444                    .sequence_next_value(&seq_name)
3445                    .map_err(EngineError::Storage)?;
3446                Ok(Value::BigInt(v))
3447            }
3448            "currval" => {
3449                let v = self
3450                    .active_catalog()
3451                    .sequence_current_value(&seq_name)
3452                    .map_err(EngineError::Storage)?;
3453                Ok(Value::BigInt(v))
3454            }
3455            "setval" => {
3456                if args.len() < 2 || args.len() > 3 {
3457                    return Err(EngineError::Unsupported(alloc::format!(
3458                        "setval() takes 2 or 3 arguments, got {}",
3459                        args.len()
3460                    )));
3461                }
3462                let value = match &args[1] {
3463                    Expr::Literal(spg_sql::ast::Literal::Integer(n)) => *n,
3464                    other => {
3465                        return Err(EngineError::Unsupported(alloc::format!(
3466                            "setval() value argument must be a literal integer, got {other:?}"
3467                        )));
3468                    }
3469                };
3470                let is_called = if args.len() == 3 {
3471                    match &args[2] {
3472                        Expr::Literal(spg_sql::ast::Literal::Bool(b)) => *b,
3473                        other => {
3474                            return Err(EngineError::Unsupported(alloc::format!(
3475                                "setval() is_called argument must be a literal BOOL, got {other:?}"
3476                            )));
3477                        }
3478                    }
3479                } else {
3480                    true
3481                };
3482                let v = self
3483                    .active_catalog_mut()
3484                    .sequence_set_value(&seq_name, value, is_called)
3485                    .map_err(EngineError::Storage)?;
3486                Ok(Value::BigInt(v))
3487            }
3488            other => Err(EngineError::Unsupported(alloc::format!(
3489                "unknown sequence op {other:?}"
3490            ))),
3491        }
3492    }
3493
3494    /// v7.17.0 Phase 1.2 — find every catalog VIEW referenced in
3495    /// the SELECT's FROM / JOIN graph, re-parse each view's body
3496    /// source, and prepend it as a synthetic CTE on the
3497    /// returned SelectStatement. Returns `None` when no view
3498    /// references are found (caller proceeds with the original
3499    /// statement); returns `Some(rewritten)` otherwise (caller
3500    /// re-runs exec_select_cancel on the rewritten form so the
3501    /// regular CTE materialiser handles it).
3502    fn expand_views_in_select(
3503        &self,
3504        stmt: &SelectStatement,
3505    ) -> Result<Option<SelectStatement>, EngineError> {
3506        let cat = self.active_catalog();
3507        let mut referenced: Vec<String> = Vec::new();
3508        if let Some(from) = &stmt.from {
3509            collect_view_refs(&from.primary, cat, &mut referenced);
3510            for j in &from.joins {
3511                collect_view_refs(&j.table, cat, &mut referenced);
3512            }
3513        }
3514        // Don't expand a view name that's already shadowed by a
3515        // CTE on the same SELECT — the CTE wins per PG.
3516        referenced.retain(|n| !stmt.ctes.iter().any(|c| c.name == *n));
3517        if referenced.is_empty() {
3518            return Ok(None);
3519        }
3520        let mut new_ctes: Vec<spg_sql::ast::Cte> = Vec::with_capacity(referenced.len());
3521        for name in &referenced {
3522            let view = cat.views().get(name).ok_or_else(|| {
3523                EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
3524                    "view {name:?} disappeared mid-expansion"
3525                )))
3526            })?;
3527            let parsed = spg_sql::parser::parse_statement(&view.body).map_err(|e| {
3528                EngineError::Unsupported(alloc::format!("view {name:?} body re-parse failed: {e}"))
3529            })?;
3530            let Statement::Select(body) = parsed else {
3531                return Err(EngineError::Unsupported(alloc::format!(
3532                    "view {name:?} body is not a SELECT (catalog corruption)"
3533                )));
3534            };
3535            new_ctes.push(spg_sql::ast::Cte {
3536                name: name.clone(),
3537                body,
3538                recursive: false,
3539                column_overrides: view.columns.clone(),
3540            });
3541        }
3542        let mut out = stmt.clone();
3543        // Prepend so view CTEs are visible to caller-supplied CTEs.
3544        new_ctes.extend(out.ctes);
3545        out.ctes = new_ctes;
3546        Ok(Some(out))
3547    }
3548
3549    /// v7.17.0 Phase 1.2 — `CREATE VIEW` engine path. Stores the
3550    /// Display-rendered body verbatim in the catalog; SELECT-from-
3551    /// view at exec time re-parses + prepends as a synthetic CTE.
3552    fn exec_create_view(
3553        &mut self,
3554        s: spg_sql::ast::CreateViewStatement,
3555    ) -> Result<QueryResult, EngineError> {
3556        // Render the SELECT body to canonical form so the catalog
3557        // round-trips a deterministic source (no whitespace /
3558        // comment surprises in the on-disk snapshot).
3559        let body_repr = alloc::format!("{}", spg_sql::ast::Statement::Select(s.body));
3560        let def = spg_storage::ViewDef {
3561            name: s.name.clone(),
3562            columns: s.columns,
3563            body: body_repr,
3564        };
3565        self.active_catalog_mut()
3566            .create_view(def, s.or_replace, s.if_not_exists)
3567            .map_err(EngineError::Storage)?;
3568        Ok(QueryResult::CommandOk {
3569            affected: 0,
3570            modified_catalog: !self.in_transaction(),
3571        })
3572    }
3573
3574    /// v7.17.0 Phase 1.4 — `CREATE TYPE name AS ENUM (…)` engine
3575    /// path. Registers the enum in the catalog with order-
3576    /// preserving labels. PG semantics: CREATE TYPE errors if the
3577    /// name is taken (no IF NOT EXISTS).
3578    fn exec_create_type(
3579        &mut self,
3580        s: spg_sql::ast::CreateTypeStatement,
3581    ) -> Result<QueryResult, EngineError> {
3582        // Name-collision check against tables / sequences / views /
3583        // materialized views.
3584        let cat = self.active_catalog();
3585        if cat.get(&s.name).is_some() {
3586            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3587                alloc::format!("type {:?} would shadow an existing table", s.name),
3588            )));
3589        }
3590        if cat.sequences().contains_key(&s.name) {
3591            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3592                alloc::format!("type {:?} would shadow an existing sequence", s.name),
3593            )));
3594        }
3595        if cat.views().contains_key(&s.name) {
3596            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3597                alloc::format!("type {:?} would shadow an existing view", s.name),
3598            )));
3599        }
3600        let def = match s.kind {
3601            spg_sql::ast::TypeKind::Enum { labels } => {
3602                if labels.is_empty() {
3603                    return Err(EngineError::Unsupported(
3604                        "CREATE TYPE … AS ENUM requires at least one label".into(),
3605                    ));
3606                }
3607                // Reject duplicate labels per PG.
3608                for i in 0..labels.len() {
3609                    for j in (i + 1)..labels.len() {
3610                        if labels[i] == labels[j] {
3611                            return Err(EngineError::Unsupported(alloc::format!(
3612                                "CREATE TYPE {:?}: duplicate ENUM label {:?}",
3613                                s.name,
3614                                labels[i]
3615                            )));
3616                        }
3617                    }
3618                }
3619                spg_storage::EnumDef {
3620                    name: s.name.clone(),
3621                    labels,
3622                }
3623            }
3624        };
3625        self.active_catalog_mut()
3626            .create_enum_type(def)
3627            .map_err(EngineError::Storage)?;
3628        Ok(QueryResult::CommandOk {
3629            affected: 0,
3630            modified_catalog: !self.in_transaction(),
3631        })
3632    }
3633
3634    /// v7.17.0 Phase 1.5 — `CREATE DOMAIN name AS base [DEFAULT
3635    /// expr] [NOT NULL] [CHECK (expr)]*` engine path. Stores the
3636    /// base type + Display-rendered CHECK / DEFAULT sources so
3637    /// INSERT/UPDATE on bound columns can re-eval the checks.
3638    fn exec_create_domain(
3639        &mut self,
3640        s: spg_sql::ast::CreateDomainStatement,
3641    ) -> Result<QueryResult, EngineError> {
3642        let cat = self.active_catalog();
3643        if cat.domain_types().contains_key(&s.name) {
3644            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3645                alloc::format!("domain {:?} already exists", s.name),
3646            )));
3647        }
3648        if cat.get(&s.name).is_some()
3649            || cat.sequences().contains_key(&s.name)
3650            || cat.views().contains_key(&s.name)
3651            || cat.enum_types().contains_key(&s.name)
3652        {
3653            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3654                alloc::format!("domain {:?} would shadow an existing object", s.name),
3655            )));
3656        }
3657        let base_type = column_type_to_data_type(s.base_type);
3658        let default = s.default.as_ref().map(|e| alloc::format!("{e}"));
3659        let checks = s
3660            .checks
3661            .iter()
3662            .map(|e| alloc::format!("{e}"))
3663            .collect::<Vec<_>>();
3664        let def = spg_storage::DomainDef {
3665            name: s.name.clone(),
3666            base_type,
3667            nullable: !s.not_null,
3668            default,
3669            checks,
3670        };
3671        self.active_catalog_mut()
3672            .create_domain_type(def)
3673            .map_err(EngineError::Storage)?;
3674        Ok(QueryResult::CommandOk {
3675            affected: 0,
3676            modified_catalog: !self.in_transaction(),
3677        })
3678    }
3679
3680    /// v7.17.0 Phase 1.5 — `DROP DOMAIN [IF EXISTS] names`.
3681    fn exec_drop_domain(
3682        &mut self,
3683        names: &[String],
3684        if_exists: bool,
3685    ) -> Result<QueryResult, EngineError> {
3686        let mut removed = 0usize;
3687        for name in names {
3688            let was_present = self.active_catalog_mut().drop_domain_type(name);
3689            if was_present {
3690                removed += 1;
3691            } else if !if_exists {
3692                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3693                    alloc::format!("domain {name:?} does not exist"),
3694                )));
3695            }
3696        }
3697        Ok(QueryResult::CommandOk {
3698            affected: removed,
3699            modified_catalog: removed > 0 && !self.in_transaction(),
3700        })
3701    }
3702
3703    /// v7.17.0 Phase 1.6 — `CREATE SCHEMA [IF NOT EXISTS] name`.
3704    /// Registers the schema in the catalog. Schema-qualified
3705    /// table references continue to strip the prefix at lookup
3706    /// time (prefix routing, not isolation — see project-next-
3707    /// docket for the v7.18+ real-isolation tracking).
3708    fn exec_create_schema(
3709        &mut self,
3710        name: String,
3711        if_not_exists: bool,
3712    ) -> Result<QueryResult, EngineError> {
3713        self.active_catalog_mut()
3714            .create_schema(name, if_not_exists)
3715            .map_err(EngineError::Storage)?;
3716        Ok(QueryResult::CommandOk {
3717            affected: 0,
3718            modified_catalog: !self.in_transaction(),
3719        })
3720    }
3721
3722    /// v7.17.0 Phase 1.6 — `DROP SCHEMA [IF EXISTS] names`.
3723    /// Built-in schemas always reject the drop with a clear
3724    /// error.
3725    fn exec_drop_schema(
3726        &mut self,
3727        names: &[String],
3728        if_exists: bool,
3729    ) -> Result<QueryResult, EngineError> {
3730        let mut removed = 0usize;
3731        for name in names {
3732            let was_present = self
3733                .active_catalog_mut()
3734                .drop_schema(name)
3735                .map_err(EngineError::Storage)?;
3736            if was_present {
3737                removed += 1;
3738            } else if !if_exists {
3739                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3740                    alloc::format!("schema {name:?} does not exist"),
3741                )));
3742            }
3743        }
3744        Ok(QueryResult::CommandOk {
3745            affected: removed,
3746            modified_catalog: removed > 0 && !self.in_transaction(),
3747        })
3748    }
3749
3750    /// v7.17.0 Phase 1.4 — `DROP TYPE [IF EXISTS] names`. Only
3751    /// ENUM types are catalogued today; other types silently
3752    /// no-op even outside IF EXISTS to mirror the prior
3753    /// "everything's text" lax stance.
3754    fn exec_drop_type(
3755        &mut self,
3756        names: &[String],
3757        if_exists: bool,
3758    ) -> Result<QueryResult, EngineError> {
3759        let mut removed = 0usize;
3760        for name in names {
3761            let was_present = self.active_catalog_mut().drop_enum_type(name);
3762            if was_present {
3763                removed += 1;
3764            } else if !if_exists {
3765                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3766                    alloc::format!("type {name:?} does not exist"),
3767                )));
3768            }
3769        }
3770        Ok(QueryResult::CommandOk {
3771            affected: removed,
3772            modified_catalog: removed > 0 && !self.in_transaction(),
3773        })
3774    }
3775
3776    /// v7.17.0 Phase 1.3 — `CREATE MATERIALIZED VIEW` engine path.
3777    /// Materialises the body at CREATE time (unless WITH NO DATA),
3778    /// stores the result as a regular `Table`, and registers the
3779    /// body source in the catalog so REFRESH can re-run it.
3780    fn exec_create_materialized_view(
3781        &mut self,
3782        s: spg_sql::ast::CreateMaterializedViewStatement,
3783    ) -> Result<QueryResult, EngineError> {
3784        // Name-collision check (table / view / sequence / mat-view).
3785        let cat = self.active_catalog();
3786        if cat.materialized_views().contains_key(&s.name) || cat.get(&s.name).is_some() {
3787            if s.if_not_exists {
3788                return Ok(QueryResult::CommandOk {
3789                    affected: 0,
3790                    modified_catalog: false,
3791                });
3792            }
3793            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3794                alloc::format!("materialized view {:?} already exists", s.name),
3795            )));
3796        }
3797        if cat.views().contains_key(&s.name) {
3798            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3799                alloc::format!(
3800                    "materialized view {:?} would shadow an existing view",
3801                    s.name
3802                ),
3803            )));
3804        }
3805        if cat.sequences().contains_key(&s.name) {
3806            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3807                alloc::format!(
3808                    "materialized view {:?} would shadow an existing sequence",
3809                    s.name
3810                ),
3811            )));
3812        }
3813        // Render the body to canonical form for the registry.
3814        let body_repr = alloc::format!("{}", spg_sql::ast::Statement::Select(s.body.clone()));
3815        // Execute the body to learn the columns. With WITH DATA we
3816        // also materialise the rows; with WITH NO DATA we only need
3817        // the schema, so re-use a LIMIT 0 wrap to keep the column
3818        // inference path uniform without paying for the rows.
3819        let result = self.exec_select_cancel(&s.body, CancelToken::none())?;
3820        let (mut cols, rows) = match result {
3821            QueryResult::Rows { columns, rows } => (columns, rows),
3822            other => {
3823                return Err(EngineError::Unsupported(alloc::format!(
3824                    "CREATE MATERIALIZED VIEW body did not return rows: {other:?}"
3825                )));
3826            }
3827        };
3828        // Apply the column-rename list per PG semantics.
3829        if !s.columns.is_empty() {
3830            if s.columns.len() != cols.len() {
3831                return Err(EngineError::Unsupported(alloc::format!(
3832                    "CREATE MATERIALIZED VIEW {:?}: column list has {} names but body returns {}",
3833                    s.name,
3834                    s.columns.len(),
3835                    cols.len()
3836                )));
3837            }
3838            for (c, name) in cols.iter_mut().zip(s.columns.iter()) {
3839                c.name.clone_from(name);
3840            }
3841        }
3842        // Promote any synthetic-Text projections to their actual
3843        // observed types so the backing table accepts the rows.
3844        cols = infer_column_types(&cols, &rows);
3845        let schema = spg_storage::TableSchema::new(s.name.clone(), cols);
3846        let cat = self.active_catalog_mut();
3847        cat.create_table(schema).map_err(EngineError::Storage)?;
3848        if s.with_data {
3849            let table = cat
3850                .get_mut(&s.name)
3851                .expect("just-created materialized-view backing table must exist");
3852            for row in rows {
3853                table.insert(row).map_err(EngineError::Storage)?;
3854            }
3855        }
3856        cat.register_materialized_view(s.name.clone(), body_repr);
3857        Ok(QueryResult::CommandOk {
3858            affected: 0,
3859            modified_catalog: !self.in_transaction(),
3860        })
3861    }
3862
3863    /// v7.17.0 Phase 1.3 — `REFRESH MATERIALIZED VIEW name [WITH
3864    /// [NO] DATA]`. Looks up the source, re-runs it, replaces the
3865    /// backing table's rows.
3866    fn exec_refresh_materialized_view(
3867        &mut self,
3868        name: &str,
3869        with_data: bool,
3870    ) -> Result<QueryResult, EngineError> {
3871        let source = self
3872            .active_catalog()
3873            .materialized_views()
3874            .get(name)
3875            .cloned()
3876            .ok_or_else(|| {
3877                EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
3878                    "materialized view {name:?} does not exist"
3879                )))
3880            })?;
3881        // Wipe the existing rows first (PG truncates the matview
3882        // and rebuilds; we approximate with an empty INSERT loop).
3883        {
3884            let cat = self.active_catalog_mut();
3885            let table = cat.get_mut(name).ok_or_else(|| {
3886                EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
3887                    "materialized view {name:?} backing table missing"
3888                )))
3889            })?;
3890            table.truncate();
3891        }
3892        if !with_data {
3893            return Ok(QueryResult::CommandOk {
3894                affected: 0,
3895                modified_catalog: !self.in_transaction(),
3896            });
3897        }
3898        let parsed = spg_sql::parser::parse_statement(&source).map_err(|e| {
3899            EngineError::Unsupported(alloc::format!(
3900                "materialized view {name:?} body re-parse failed: {e}"
3901            ))
3902        })?;
3903        let Statement::Select(body) = parsed else {
3904            return Err(EngineError::Unsupported(alloc::format!(
3905                "materialized view {name:?} body is not a SELECT (catalog corruption)"
3906            )));
3907        };
3908        let rows = match self.exec_select_cancel(&body, CancelToken::none())? {
3909            QueryResult::Rows { rows, .. } => rows,
3910            other => {
3911                return Err(EngineError::Unsupported(alloc::format!(
3912                    "REFRESH MATERIALIZED VIEW {name:?} body did not return rows: {other:?}"
3913                )));
3914            }
3915        };
3916        let cat = self.active_catalog_mut();
3917        let table = cat.get_mut(name).expect("backing table verified above");
3918        let affected = rows.len();
3919        for row in rows {
3920            table.insert(row).map_err(EngineError::Storage)?;
3921        }
3922        Ok(QueryResult::CommandOk {
3923            affected,
3924            modified_catalog: !self.in_transaction(),
3925        })
3926    }
3927
3928    /// v7.17.0 Phase 1.3 — `DROP MATERIALIZED VIEW [IF EXISTS]
3929    /// names`. Drops the backing table + unregisters the source.
3930    fn exec_drop_materialized_view(
3931        &mut self,
3932        names: &[String],
3933        if_exists: bool,
3934    ) -> Result<QueryResult, EngineError> {
3935        let mut removed = 0usize;
3936        for name in names {
3937            let was_present = self
3938                .active_catalog_mut()
3939                .drop_materialized_view_source(name);
3940            if was_present {
3941                // Drop the backing table too.
3942                self.active_catalog_mut().drop_table(name);
3943                removed += 1;
3944            } else if !if_exists {
3945                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3946                    alloc::format!("materialized view {name:?} does not exist"),
3947                )));
3948            }
3949        }
3950        Ok(QueryResult::CommandOk {
3951            affected: removed,
3952            modified_catalog: removed > 0 && !self.in_transaction(),
3953        })
3954    }
3955
3956    /// v7.17.0 Phase 1.2 — `DROP VIEW [IF EXISTS] name [, name…]`.
3957    fn exec_drop_view(
3958        &mut self,
3959        names: &[String],
3960        if_exists: bool,
3961    ) -> Result<QueryResult, EngineError> {
3962        let mut removed = 0usize;
3963        for name in names {
3964            let was_present = self.active_catalog_mut().drop_view(name);
3965            if !was_present && !if_exists {
3966                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3967                    alloc::format!("view {name:?} does not exist"),
3968                )));
3969            }
3970            if was_present {
3971                removed += 1;
3972            }
3973        }
3974        Ok(QueryResult::CommandOk {
3975            affected: removed,
3976            modified_catalog: removed > 0 && !self.in_transaction(),
3977        })
3978    }
3979
3980    /// v7.17.0 — `DROP SEQUENCE [IF EXISTS] name [, name…]`.
3981    fn exec_drop_sequence(
3982        &mut self,
3983        names: &[String],
3984        if_exists: bool,
3985    ) -> Result<QueryResult, EngineError> {
3986        let mut removed = 0usize;
3987        for name in names {
3988            let was_present = self.active_catalog_mut().drop_sequence(name);
3989            if !was_present && !if_exists {
3990                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3991                    alloc::format!("sequence {name:?} does not exist"),
3992                )));
3993            }
3994            if was_present {
3995                removed += 1;
3996            }
3997        }
3998        Ok(QueryResult::CommandOk {
3999            affected: removed,
4000            modified_catalog: removed > 0 && !self.in_transaction(),
4001        })
4002    }
4003
4004    /// v4.4 `UPDATE <table> SET col = expr [, ...] [WHERE cond]`.
4005    /// Filter pass uses the same WHERE eval as `exec_select`. Per
4006    /// matched row, evaluate each RHS expression against the *old*
4007    /// row, then call `Table::update_row` which rebuilds indices.
4008    /// Indexed columns are correctly reflected because rebuild
4009    /// happens after the cell rewrite.
4010    fn exec_update_cancel(
4011        &mut self,
4012        stmt: &spg_sql::ast::UpdateStatement,
4013        cancel: CancelToken<'_>,
4014    ) -> Result<QueryResult, EngineError> {
4015        // v7.12.5 — snapshot BEFORE/AFTER UPDATE row triggers + the
4016        // session FTS config before the table mut-borrow opens (the
4017        // INSERT path uses the same pattern). Empty vecs are the
4018        // common "no triggers on this table" fast path.
4019        // v7.13.0 — UPDATE triggers carry an optional `UPDATE OF
4020        // cols` filter. The filter is paired with each function so
4021        // the per-row fire loop can skip when no listed column
4022        // actually differs between OLD and NEW.
4023        let before_update_triggers = self.snapshot_update_row_triggers(&stmt.table, "BEFORE");
4024        let after_update_triggers = self.snapshot_update_row_triggers(&stmt.table, "AFTER");
4025        let trigger_session_cfg: Option<String> = self
4026            .session_params
4027            .get("default_text_search_config")
4028            .cloned();
4029        // v5.2.3: if the WHERE is a PK equality and matches a cold-
4030        // tier row, promote it back to the hot tier *before* the
4031        // hot-row walk. The promote pushes the row to the end of
4032        // `table.rows`, where the upcoming SET-evaluation loop will
4033        // pick it up and apply the assignments. Lookups for the key
4034        // never observe a gap because `promote_cold_row` inserts the
4035        // hot row before retiring the cold locator.
4036        if let Some(w) = &stmt.where_ {
4037            let schema_cols = self
4038                .active_catalog()
4039                .get(&stmt.table)
4040                .ok_or_else(|| {
4041                    EngineError::Storage(StorageError::TableNotFound {
4042                        name: stmt.table.clone(),
4043                    })
4044                })?
4045                .schema()
4046                .columns
4047                .clone();
4048            if let Some((col_pos, key)) = try_pk_predicate(w, &schema_cols, stmt.table.as_str())
4049                && let Some(idx_name) = self
4050                    .active_catalog()
4051                    .get(&stmt.table)
4052                    .and_then(|t| t.index_on(col_pos).map(|i| i.name.clone()))
4053            {
4054                // Promote may be a no-op (key is hot-only or absent);
4055                // we don't care about the return value here — the
4056                // subsequent hot walk will either match or not.
4057                let _ = self
4058                    .active_catalog_mut()
4059                    .promote_cold_row(&stmt.table, &idx_name, &key);
4060            }
4061        }
4062
4063        // v7.12.1 — cache session FTS config before the table
4064        // mut-borrow (same reason as exec_delete).
4065        let ts_cfg: Option<String> = self
4066            .session_param("default_text_search_config")
4067            .map(String::from);
4068        // v7.17.0 Phase 2.1 — snapshot the clock pointer before
4069        // we hold the catalog mutably so ON UPDATE runtime
4070        // overrides see the engine wall clock.
4071        let clock_for_on_update = self.clock;
4072        let table = self
4073            .active_catalog_mut()
4074            .get_mut(&stmt.table)
4075            .ok_or_else(|| {
4076                EngineError::Storage(StorageError::TableNotFound {
4077                    name: stmt.table.clone(),
4078                })
4079            })?;
4080        let schema_cols: Vec<ColumnSchema> = table.schema().columns.clone();
4081        // Resolve each SET target to a column position once, validate
4082        // up front so a typo'd column doesn't leave a partial mutation
4083        // behind.
4084        let mut targets: Vec<(usize, &Expr)> = Vec::with_capacity(stmt.assignments.len());
4085        for (col, expr) in &stmt.assignments {
4086            let pos = schema_cols
4087                .iter()
4088                .position(|c| c.name == *col)
4089                .ok_or_else(|| {
4090                    EngineError::Eval(EvalError::ColumnNotFound { name: col.clone() })
4091                })?;
4092            targets.push((pos, expr));
4093        }
4094        // v7.17.0 Phase 2.1 — for every column with an
4095        // `ON UPDATE CURRENT_TIMESTAMP` binding that the caller
4096        // did NOT explicitly set, schedule an automatic override.
4097        // Reuses `eval_runtime_default_free` so the same
4098        // canonical runtime-expression whitelist (now /
4099        // current_timestamp / current_date / …) governs both
4100        // DEFAULT and ON UPDATE.
4101        let mut on_update_overrides: Vec<(usize, String)> = Vec::new();
4102        for (i, col) in schema_cols.iter().enumerate() {
4103            if targets.iter().any(|(p, _)| *p == i) {
4104                continue;
4105            }
4106            if let Some(src) = &col.on_update_runtime {
4107                on_update_overrides.push((i, src.clone()));
4108            }
4109        }
4110        let ctx = EvalContext::new(&schema_cols, Some(stmt.table.as_str()))
4111            .with_default_text_search_config(ts_cfg.as_deref());
4112        // Walk candidate rows, evaluate WHERE then SET
4113        // expressions. We gather (position, new_values) tuples
4114        // first and apply them afterwards so the WHERE/RHS
4115        // evaluation reads the original row state — matches PG
4116        // semantics (UPDATE doesn't see its own writes).
4117        //
4118        // v7.20 P4 — index seek: a single-column equality WHERE
4119        // on an indexed column narrows the walk from
4120        // O(table.rows()) to O(matches). The full WHERE still
4121        // re-evaluates per candidate (the seek may be an
4122        // over-approximation under AND-composites), so semantics
4123        // are unchanged. profile: the bench's `UPDATE … WHERE
4124        // id = $1` on a 5 000-row table was a ~1.3 ms full scan
4125        // per statement; with the seek it's ~2 µs.
4126        let seek_positions: Option<Vec<usize>> = stmt
4127            .where_
4128            .as_ref()
4129            .and_then(|w| try_index_seek_positions(w, &schema_cols, table, stmt.table.as_str()));
4130        let mut planned: Vec<(usize, Vec<Value>)> = Vec::new();
4131        let candidate_positions: Vec<usize> = match &seek_positions {
4132            Some(list) => list.clone(),
4133            None => (0..table.row_count()).collect(),
4134        };
4135        for (loop_n, &i) in candidate_positions.iter().enumerate() {
4136            // v4.5: cooperative cancel checkpoint every 256 rows so
4137            // a runaway UPDATE without WHERE doesn't drag past the
4138            // server's query-timeout watchdog.
4139            if loop_n.is_multiple_of(256) {
4140                cancel.check()?;
4141            }
4142            let Some(row) = table.rows().get(i) else {
4143                continue;
4144            };
4145            if let Some(w) = &stmt.where_ {
4146                let cond = eval::eval_expr(w, row, &ctx)?;
4147                if !matches!(cond, Value::Bool(true)) {
4148                    continue;
4149                }
4150            }
4151            let mut new_vals = row.values.clone();
4152            for (pos, expr) in &targets {
4153                let v = eval::eval_expr(expr, row, &ctx)?;
4154                let coerced = coerce_value(v, schema_cols[*pos].ty, &schema_cols[*pos].name, *pos)?;
4155                check_unsigned_range(&coerced, &schema_cols[*pos], *pos)?;
4156                new_vals[*pos] = coerced;
4157            }
4158            // v7.17.0 Phase 2.1 — apply ON UPDATE overrides for
4159            // any column the SET clause didn't touch.
4160            for (pos, src) in &on_update_overrides {
4161                let v = eval_runtime_default_free(src, schema_cols[*pos].ty, clock_for_on_update)?;
4162                new_vals[*pos] = v;
4163            }
4164            planned.push((i, new_vals));
4165        }
4166        // planned must stay position-sorted: downstream passes
4167        // (FK pairing, trigger walks, the apply loop) iterate it
4168        // assuming ascending row order, which the full-scan path
4169        // guaranteed implicitly.
4170        planned.sort_by_key(|(i, _)| *i);
4171        // v7.6.6 — capture pre-update row values for the FK
4172        // enforcement passes below. `planned` carries new values
4173        // only; pair them with the old row.
4174        let plan_with_old: Vec<(usize, Vec<Value>, Vec<Value>)> = planned
4175            .iter()
4176            .map(|(pos, new_vals)| (*pos, table.rows()[*pos].values.clone(), new_vals.clone()))
4177            .collect();
4178        let self_fks = table.schema().foreign_keys.clone();
4179        // v7.12.5 — `affected` is computed post-BEFORE-trigger
4180        // below (triggers may RETURN NULL to skip individual
4181        // rows). The pre-trigger len shape is no longer accurate.
4182        // Release mutable borrow on `table` for the FK passes.
4183        let _ = table;
4184        // v7.6.6 — Stage 2a: outbound FK check. For every row whose
4185        // local FK columns changed, the new value must exist in the
4186        // parent.
4187        if !self_fks.is_empty() {
4188            let new_rows: Vec<Vec<Value>> = planned
4189                .iter()
4190                .map(|(_pos, new_vals)| new_vals.clone())
4191                .collect();
4192            enforce_fk_inserts(self.active_catalog(), &stmt.table, &self_fks, &new_rows)?;
4193        }
4194        // v7.13.0 — CHECK constraint enforcement on UPDATE
4195        // (mailrs round-5 G3). Predicates evaluated against the
4196        // candidate post-UPDATE row; false rejects the UPDATE.
4197        {
4198            let new_rows: Vec<Vec<Value>> = planned
4199                .iter()
4200                .map(|(_pos, new_vals)| new_vals.clone())
4201                .collect();
4202            enforce_check_constraints(self.active_catalog(), &stmt.table, &new_rows)?;
4203        }
4204        // v7.6.6 — Stage 2b: inbound FK check. For every row that
4205        // changed value in a column that *some other table* uses as
4206        // a FK parent column, react per `on_update` action.
4207        let child_plan =
4208            plan_fk_parent_updates(self.active_catalog(), &stmt.table, &plan_with_old)?;
4209        // Stage 3a — apply each child-side action.
4210        for step in &child_plan {
4211            apply_fk_child_step(self.active_catalog_mut(), step)?;
4212        }
4213        // Stage 3b — apply the original UPDATE.
4214        let table = self
4215            .active_catalog_mut()
4216            .get_mut(&stmt.table)
4217            .ok_or_else(|| {
4218                EngineError::Storage(StorageError::TableNotFound {
4219                    name: stmt.table.clone(),
4220                })
4221            })?;
4222        // v7.12.5 — fire BEFORE/AFTER UPDATE row-level triggers
4223        // around the apply loop. BEFORE sees NEW=candidate +
4224        // OLD=current; may rewrite NEW or RETURN NULL to skip.
4225        // AFTER sees NEW=post-write + OLD=pre-write (both read-
4226        // only).
4227        //
4228        // Filter `planned` through the BEFORE pass first so the
4229        // RETURNING snapshot reflects what actually got written
4230        // (triggers may rewrite cells, including a cancellation).
4231        let mut applied_after_before: Vec<(usize, Row, Row)> = Vec::with_capacity(planned.len());
4232        // v7.12.7 — embedded SQL queue.
4233        let mut deferred_embedded: Vec<triggers::DeferredEmbeddedStmt> = Vec::new();
4234        for (pos, new_vals) in &planned {
4235            let old_row = table.rows()[*pos].clone();
4236            let mut new_row = Row::new(new_vals.clone());
4237            let mut skip = false;
4238            for (fd, filter) in &before_update_triggers {
4239                // v7.13.0 — `UPDATE OF cols` filter (mailrs round-5
4240                // G7). Skip this trigger when the filter is set and
4241                // no listed column actually differs between OLD and
4242                // NEW for this row.
4243                if !filter.is_empty()
4244                    && !any_column_changed(filter, &schema_cols, &old_row, &new_row)
4245                {
4246                    continue;
4247                }
4248                let (outcome, deferred) = triggers::fire_row_trigger(
4249                    fd,
4250                    Some(new_row.clone()),
4251                    Some(&old_row),
4252                    &stmt.table,
4253                    &schema_cols,
4254                    &[],
4255                    trigger_session_cfg.as_deref(),
4256                    false,
4257                )
4258                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
4259                deferred_embedded.extend(deferred);
4260                match outcome {
4261                    triggers::TriggerOutcome::Row(r) => new_row = r,
4262                    triggers::TriggerOutcome::Skip => {
4263                        skip = true;
4264                        break;
4265                    }
4266                }
4267            }
4268            if !skip {
4269                applied_after_before.push((*pos, new_row, old_row));
4270            }
4271        }
4272        // v7.9.4 — snapshot post-update values for RETURNING (post-
4273        // BEFORE-trigger because triggers can rewrite cells).
4274        let updated_for_returning: Vec<Vec<Value>> = if stmt.returning.is_some() {
4275            applied_after_before
4276                .iter()
4277                .map(|(_pos, new_row, _old)| new_row.values.clone())
4278                .collect()
4279        } else {
4280            Vec::new()
4281        };
4282        let affected = applied_after_before.len();
4283        // Apply, then fire AFTER triggers per row. AFTER runs read-
4284        // only against the freshly-written row; v7.12.4-shape
4285        // assignment errors with a clear message.
4286        for (pos, new_row, old_row) in applied_after_before {
4287            table.update_row(pos, new_row.values.clone())?;
4288            for (fd, filter) in &after_update_triggers {
4289                if !filter.is_empty()
4290                    && !any_column_changed(filter, &schema_cols, &old_row, &new_row)
4291                {
4292                    continue;
4293                }
4294                let (_outcome, deferred) = triggers::fire_row_trigger(
4295                    fd,
4296                    Some(new_row.clone()),
4297                    Some(&old_row),
4298                    &stmt.table,
4299                    &schema_cols,
4300                    &[],
4301                    trigger_session_cfg.as_deref(),
4302                    true,
4303                )
4304                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
4305                deferred_embedded.extend(deferred);
4306            }
4307        }
4308        let _ = table;
4309        // v7.12.7 — drain trigger-emitted embedded SQL for this UPDATE.
4310        self.execute_deferred_trigger_stmts(deferred_embedded, cancel)?;
4311        // v6.2.1 — auto-analyze modified-row tracking for UPDATE.
4312        if !self.in_transaction() && affected > 0 {
4313            self.statistics
4314                .record_modifications(&stmt.table, affected as u64);
4315        }
4316        // v7.9.4 — RETURNING projection.
4317        if let Some(items) = &stmt.returning {
4318            return self.build_returning_rows(&stmt.table, items, updated_for_returning);
4319        }
4320        Ok(QueryResult::CommandOk {
4321            affected,
4322            modified_catalog: !self.in_transaction(),
4323        })
4324    }
4325
4326    /// v4.4 `DELETE FROM <table> [WHERE cond]`. Collects matching
4327    /// positions then delegates to `Table::delete_rows` (single index
4328    /// rebuild for the batch).
4329    /// v7.17.0 Phase 3.P0-42 — SQL:2003 / PG 15+ `MERGE` execution.
4330    ///
4331    /// Semantics:
4332    ///   * Resolve `target` and `source` tables (catalog reads).
4333    ///   * Build a combined `(target_alias.col, source_alias.col)`
4334    ///     schema so the ON / WHEN AND / SET / VALUES expressions
4335    ///     resolve through the standard qualifier-aware resolver.
4336    ///   * Pass 1: walk every source row × every target hot row,
4337    ///     evaluate ON, then pick the first WHEN clause that fits
4338    ///     (`Matched` if any target row matched, `NotMatched`
4339    ///     otherwise; AND-condition must hold). Collect the action
4340    ///     plan as `(deletes, updates, inserts)` so the apply pass
4341    ///     reads the original target row state.
4342    ///   * Pass 2: apply the plan against the target's mutable row
4343    ///     vector. Deletes execute by index in descending order so
4344    ///     earlier indices remain stable; updates next; inserts
4345    ///     last (matching PG's "INSERT branch sees the post-delete
4346    ///     state" behaviour for the common upsert shape).
4347    ///
4348    /// v7.17 simplifications (documented limitations):
4349    ///   * No triggers / WAL plumbing (MVP); MERGE rows don't fire
4350    ///     INSERT / UPDATE / DELETE row triggers in v7.17.
4351    ///   * No cardinality check (PG-canonical: "MERGE command
4352    ///     cannot affect row a second time" — SPG silently applies
4353    ///     the last action for a target row covered twice).
4354    ///   * Source must be a catalog-resolvable table (no subquery
4355    ///     source); RETURNING / BY SOURCE / BY TARGET unsupported.
4356    fn exec_merge_cancel(
4357        &mut self,
4358        stmt: &spg_sql::ast::MergeStatement,
4359        cancel: CancelToken<'_>,
4360    ) -> Result<QueryResult, EngineError> {
4361        let target_alias = stmt
4362            .target_alias
4363            .clone()
4364            .unwrap_or_else(|| stmt.target.clone());
4365        let source_alias = stmt
4366            .source_alias
4367            .clone()
4368            .unwrap_or_else(|| stmt.source.clone());
4369        let (target_cols, target_rows_snapshot) = {
4370            let t = self.active_catalog().get(&stmt.target).ok_or_else(|| {
4371                EngineError::Storage(StorageError::TableNotFound {
4372                    name: stmt.target.clone(),
4373                })
4374            })?;
4375            (
4376                t.schema().columns.clone(),
4377                t.rows().iter().cloned().collect::<Vec<Row>>(),
4378            )
4379        };
4380        let (source_cols, source_rows) = {
4381            let s = self.active_catalog().get(&stmt.source).ok_or_else(|| {
4382                EngineError::Storage(StorageError::TableNotFound {
4383                    name: stmt.source.clone(),
4384                })
4385            })?;
4386            (
4387                s.schema().columns.clone(),
4388                s.rows().iter().cloned().collect::<Vec<Row>>(),
4389            )
4390        };
4391        // Composite schema: target_alias.col ... source_alias.col ...
4392        let mut combined_schema: Vec<ColumnSchema> = Vec::new();
4393        for col in &target_cols {
4394            combined_schema.push(ColumnSchema::new(
4395                alloc::format!("{target_alias}.{}", col.name),
4396                col.ty,
4397                col.nullable,
4398            ));
4399        }
4400        for col in &source_cols {
4401            combined_schema.push(ColumnSchema::new(
4402                alloc::format!("{source_alias}.{}", col.name),
4403                col.ty,
4404                col.nullable,
4405            ));
4406        }
4407        let combined_ctx = EvalContext::new(&combined_schema, None);
4408        // Source-only context for WHEN NOT MATCHED actions (no
4409        // matched target row exists — the source-side qualified
4410        // columns must still resolve).
4411        let mut source_only_schema: Vec<ColumnSchema> = Vec::new();
4412        for col in &target_cols {
4413            source_only_schema.push(ColumnSchema::new(
4414                alloc::format!("{target_alias}.{}", col.name),
4415                col.ty,
4416                col.nullable,
4417            ));
4418        }
4419        for col in &source_cols {
4420            source_only_schema.push(ColumnSchema::new(
4421                alloc::format!("{source_alias}.{}", col.name),
4422                col.ty,
4423                col.nullable,
4424            ));
4425        }
4426        let source_only_ctx = EvalContext::new(&source_only_schema, None);
4427        let target_arity = target_cols.len();
4428        let source_arity = source_cols.len();
4429
4430        // Resolve INSERT column positions once (validate names).
4431        // For each clause that's an INSERT, map column names → target positions.
4432        let mut delete_indices: Vec<usize> = Vec::new();
4433        let mut updates: Vec<(usize, Vec<Value>)> = Vec::new();
4434        let mut inserts: Vec<Vec<Value>> = Vec::new();
4435        let mut affected: usize = 0;
4436
4437        for (src_idx, src_row) in source_rows.iter().enumerate() {
4438            if src_idx.is_multiple_of(256) {
4439                cancel.check()?;
4440            }
4441            // Find every matched target index (per the ON predicate).
4442            let mut matched_targets: Vec<usize> = Vec::new();
4443            for (t_idx, t_row) in target_rows_snapshot.iter().enumerate() {
4444                let mut combined_vals = t_row.values.clone();
4445                combined_vals.extend(src_row.values.iter().cloned());
4446                let combined_row = Row::new(combined_vals);
4447                let cond = eval::eval_expr(&stmt.on, &combined_row, &combined_ctx)?;
4448                if matches!(cond, Value::Bool(true)) {
4449                    matched_targets.push(t_idx);
4450                }
4451            }
4452            let is_matched = !matched_targets.is_empty();
4453            // Pick the first WHEN clause whose kind agrees with
4454            // `is_matched` and whose AND condition (if any) holds.
4455            // AND condition for MATCHED: evaluated against the
4456            // first matched target row × source. For NOT MATCHED:
4457            // evaluated with target side NULL-padded.
4458            let fired_clause = stmt.clauses.iter().find(|c| {
4459                let kind_ok = match c.matched {
4460                    spg_sql::ast::MergeMatched::Matched => is_matched,
4461                    spg_sql::ast::MergeMatched::NotMatched => !is_matched,
4462                };
4463                if !kind_ok {
4464                    return false;
4465                }
4466                let Some(cond_expr) = &c.condition else {
4467                    return true;
4468                };
4469                let row = if is_matched {
4470                    let t = &target_rows_snapshot[matched_targets[0]];
4471                    let mut vals = t.values.clone();
4472                    vals.extend(src_row.values.iter().cloned());
4473                    Row::new(vals)
4474                } else {
4475                    let mut vals: Vec<Value> = (0..target_arity).map(|_| Value::Null).collect();
4476                    vals.extend(src_row.values.iter().cloned());
4477                    Row::new(vals)
4478                };
4479                let ctx_ref = if is_matched {
4480                    &combined_ctx
4481                } else {
4482                    &source_only_ctx
4483                };
4484                matches!(
4485                    eval::eval_expr(cond_expr, &row, ctx_ref),
4486                    Ok(Value::Bool(true))
4487                )
4488            });
4489            let Some(clause) = fired_clause else { continue };
4490            match &clause.action {
4491                spg_sql::ast::MergeAction::DoNothing => {}
4492                spg_sql::ast::MergeAction::Delete => {
4493                    for &t_idx in &matched_targets {
4494                        if !delete_indices.contains(&t_idx) {
4495                            delete_indices.push(t_idx);
4496                            affected += 1;
4497                        }
4498                    }
4499                }
4500                spg_sql::ast::MergeAction::Update { assignments } => {
4501                    // Pre-resolve SET targets to target column positions.
4502                    let mut planned_sets: Vec<(usize, &Expr)> =
4503                        Vec::with_capacity(assignments.len());
4504                    for (col, expr) in assignments {
4505                        let pos =
4506                            target_cols
4507                                .iter()
4508                                .position(|c| c.name == *col)
4509                                .ok_or_else(|| {
4510                                    EngineError::Eval(EvalError::ColumnNotFound {
4511                                        name: col.clone(),
4512                                    })
4513                                })?;
4514                        planned_sets.push((pos, expr));
4515                    }
4516                    for &t_idx in &matched_targets {
4517                        let t_row = &target_rows_snapshot[t_idx];
4518                        let mut new_values = t_row.values.clone();
4519                        let mut combined_vals = t_row.values.clone();
4520                        combined_vals.extend(src_row.values.iter().cloned());
4521                        let combined_row = Row::new(combined_vals);
4522                        for (pos, expr) in &planned_sets {
4523                            let raw = eval::eval_expr(expr, &combined_row, &combined_ctx)?;
4524                            let coerced = coerce_value(
4525                                raw,
4526                                target_cols[*pos].ty,
4527                                &target_cols[*pos].name,
4528                                *pos,
4529                            )?;
4530                            new_values[*pos] = coerced;
4531                        }
4532                        updates.push((t_idx, new_values));
4533                        affected += 1;
4534                    }
4535                }
4536                spg_sql::ast::MergeAction::Insert { columns, values } => {
4537                    // For INSERT NOT MATCHED, target side is NULL-padded.
4538                    let mut vals: Vec<Value> = (0..target_arity).map(|_| Value::Null).collect();
4539                    vals.extend(src_row.values.iter().cloned());
4540                    let synth_row = Row::new(vals);
4541                    let mut new_row_values: Vec<Value> =
4542                        (0..target_arity).map(|_| Value::Null).collect();
4543                    for (col, expr) in columns.iter().zip(values.iter()) {
4544                        let pos =
4545                            target_cols
4546                                .iter()
4547                                .position(|c| c.name == *col)
4548                                .ok_or_else(|| {
4549                                    EngineError::Eval(EvalError::ColumnNotFound {
4550                                        name: col.clone(),
4551                                    })
4552                                })?;
4553                        let raw = eval::eval_expr(expr, &synth_row, &source_only_ctx)?;
4554                        let coerced =
4555                            coerce_value(raw, target_cols[pos].ty, &target_cols[pos].name, pos)?;
4556                        new_row_values[pos] = coerced;
4557                    }
4558                    inserts.push(new_row_values);
4559                    affected += 1;
4560                }
4561            }
4562        }
4563        let _ = source_arity; // captured for symmetry; cancellation cost negligible.
4564
4565        // Apply the plan to the target table.
4566        let table = self
4567            .active_catalog_mut()
4568            .get_mut(&stmt.target)
4569            .ok_or_else(|| {
4570                EngineError::Storage(StorageError::TableNotFound {
4571                    name: stmt.target.clone(),
4572                })
4573            })?;
4574        // Apply updates first (in-place), then deletes (one batch),
4575        // then inserts. The storage API uses `update_row(pos,
4576        // new_values)`, `delete_rows(&[positions])`, and `insert(row)`.
4577        for (idx, new_vals) in &updates {
4578            table
4579                .update_row(*idx, new_vals.clone())
4580                .map_err(EngineError::Storage)?;
4581        }
4582        if !delete_indices.is_empty() {
4583            table.delete_rows(&delete_indices);
4584        }
4585        for vals in inserts {
4586            table.insert(Row::new(vals)).map_err(EngineError::Storage)?;
4587        }
4588        Ok(QueryResult::CommandOk {
4589            affected,
4590            modified_catalog: affected > 0,
4591        })
4592    }
4593
4594    fn exec_delete_cancel(
4595        &mut self,
4596        stmt: &spg_sql::ast::DeleteStatement,
4597        cancel: CancelToken<'_>,
4598    ) -> Result<QueryResult, EngineError> {
4599        // v7.12.5 — snapshot BEFORE/AFTER DELETE row triggers + the
4600        // session FTS config before the mut borrow (same shape as
4601        // INSERT / UPDATE).
4602        let before_delete_triggers = self.snapshot_row_triggers(&stmt.table, "DELETE", "BEFORE");
4603        let after_delete_triggers = self.snapshot_row_triggers(&stmt.table, "DELETE", "AFTER");
4604        let trigger_session_cfg: Option<String> = self
4605            .session_params
4606            .get("default_text_search_config")
4607            .cloned();
4608        // v5.2.3: PK-targeted DELETE → first retire any cold-tier
4609        // locator for the key. The cold row body stays in the
4610        // segment (becoming shadowed garbage that a future
4611        // compaction pass reclaims) but the index no longer
4612        // resolves it. The shadow count contributes to the
4613        // affected total; the subsequent hot walk handles any hot
4614        // rows for the same key.
4615        let mut cold_shadow_count: usize = 0;
4616        if let Some(w) = &stmt.where_ {
4617            let schema_cols = self
4618                .active_catalog()
4619                .get(&stmt.table)
4620                .ok_or_else(|| {
4621                    EngineError::Storage(StorageError::TableNotFound {
4622                        name: stmt.table.clone(),
4623                    })
4624                })?
4625                .schema()
4626                .columns
4627                .clone();
4628            if let Some((col_pos, key)) = try_pk_predicate(w, &schema_cols, stmt.table.as_str())
4629                && let Some(idx_name) = self
4630                    .active_catalog()
4631                    .get(&stmt.table)
4632                    .and_then(|t| t.index_on(col_pos).map(|i| i.name.clone()))
4633            {
4634                cold_shadow_count = self
4635                    .active_catalog_mut()
4636                    .shadow_cold_row(&stmt.table, &idx_name, &key)
4637                    .unwrap_or(0);
4638            }
4639        }
4640
4641        // v7.12.1 — cache the session FTS config as an owned
4642        // String before the mutable table borrow below; the
4643        // ctx-builder then references it via `as_deref` so the
4644        // immutable read of `session_params` doesn't conflict
4645        // with the mut borrow chain.
4646        let ts_cfg: Option<String> = self
4647            .session_param("default_text_search_config")
4648            .map(String::from);
4649        let table = self
4650            .active_catalog_mut()
4651            .get_mut(&stmt.table)
4652            .ok_or_else(|| {
4653                EngineError::Storage(StorageError::TableNotFound {
4654                    name: stmt.table.clone(),
4655                })
4656            })?;
4657        let schema_cols: Vec<ColumnSchema> = table.schema().columns.clone();
4658        let ctx = EvalContext::new(&schema_cols, Some(stmt.table.as_str()))
4659            .with_default_text_search_config(ts_cfg.as_deref());
4660        let mut positions: Vec<usize> = Vec::new();
4661        // v7.6.3 — collect every to-delete row's full Value tuple
4662        // alongside its position, so the FK enforcement pass can
4663        // run after the mut borrow drops.
4664        let mut to_delete_rows: Vec<Vec<Value>> = Vec::new();
4665        // v7.20 P4 — index seek (same shape as exec_update_cancel):
4666        // an equality WHERE on an indexed column narrows the walk
4667        // to the matching hot positions; the full WHERE still
4668        // re-evaluates per candidate. Downstream passes assume
4669        // ascending position order, so the seek result is sorted.
4670        let seek_positions: Option<Vec<usize>> = stmt
4671            .where_
4672            .as_ref()
4673            .and_then(|w| try_index_seek_positions(w, &schema_cols, table, stmt.table.as_str()));
4674        let candidate_positions: Vec<usize> = match seek_positions {
4675            Some(mut list) => {
4676                list.sort_unstable();
4677                list
4678            }
4679            None => (0..table.row_count()).collect(),
4680        };
4681        for (loop_n, &i) in candidate_positions.iter().enumerate() {
4682            if loop_n.is_multiple_of(256) {
4683                cancel.check()?;
4684            }
4685            let Some(row) = table.rows().get(i) else {
4686                continue;
4687            };
4688            let keep = if let Some(w) = &stmt.where_ {
4689                let cond = eval::eval_expr(w, row, &ctx)?;
4690                !matches!(cond, Value::Bool(true))
4691            } else {
4692                false
4693            };
4694            if !keep {
4695                positions.push(i);
4696                to_delete_rows.push(row.values.clone());
4697            }
4698        }
4699        // v7.6.3 / v7.6.4 — Stage 2: FK enforcement on the immutable
4700        // catalog. Release the mut borrow and run reverse-scan
4701        // against every child table whose FK targets this table.
4702        // RESTRICT / NoAction raise an error; CASCADE returns a
4703        // cascade plan that stage 3 applies after the primary delete.
4704        // SET NULL / SET DEFAULT remain Unsupported until v7.6.5.
4705        let _ = table;
4706        // v7.12.5 — BEFORE DELETE row-level triggers. Each fires
4707        // with NEW=None / OLD=pre-delete row; RETURN OLD (or NEW)
4708        // = proceed, RETURN NULL = skip the row entirely. The
4709        // filter must run BEFORE the FK cascade plan so cascaded
4710        // child rows track the trigger's skip-decision on the
4711        // parent.
4712        // v7.12.7 — embedded SQL queue.
4713        let mut deferred_embedded: Vec<triggers::DeferredEmbeddedStmt> = Vec::new();
4714        if !before_delete_triggers.is_empty() {
4715            let mut filtered_positions: Vec<usize> = Vec::with_capacity(positions.len());
4716            let mut filtered_old_rows: Vec<Vec<Value>> = Vec::with_capacity(to_delete_rows.len());
4717            for (pos, old_vals) in positions.iter().zip(to_delete_rows.iter()) {
4718                let old_row = Row::new(old_vals.clone());
4719                let mut cancel_this = false;
4720                for fd in &before_delete_triggers {
4721                    let (outcome, deferred) = triggers::fire_row_trigger(
4722                        fd,
4723                        None,
4724                        Some(&old_row),
4725                        &stmt.table,
4726                        &schema_cols,
4727                        &[],
4728                        trigger_session_cfg.as_deref(),
4729                        false,
4730                    )
4731                    .map_err(|e| {
4732                        EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}")))
4733                    })?;
4734                    deferred_embedded.extend(deferred);
4735                    if matches!(outcome, triggers::TriggerOutcome::Skip) {
4736                        cancel_this = true;
4737                        break;
4738                    }
4739                }
4740                if !cancel_this {
4741                    filtered_positions.push(*pos);
4742                    filtered_old_rows.push(old_vals.clone());
4743                }
4744            }
4745            positions = filtered_positions;
4746            to_delete_rows = filtered_old_rows;
4747        }
4748        let cascade_plan = plan_fk_parent_deletions(
4749            self.active_catalog(),
4750            &stmt.table,
4751            &positions,
4752            &to_delete_rows,
4753        )?;
4754        // Stage 3a — apply each FK child step (SET NULL / SET
4755        // DEFAULT / CASCADE delete) before deleting the parent.
4756        // The plan is already ordered: nulls/defaults first, then
4757        // cascade deletes (so a row mutated and later deleted
4758        // surfaces as deleted — though v7.6.5 doesn't produce
4759        // that overlap today).
4760        for step in &cascade_plan {
4761            apply_fk_child_step(self.active_catalog_mut(), step)?;
4762        }
4763        // Stage 3b — actually delete the original target rows.
4764        let table = self
4765            .active_catalog_mut()
4766            .get_mut(&stmt.table)
4767            .ok_or_else(|| {
4768                EngineError::Storage(StorageError::TableNotFound {
4769                    name: stmt.table.clone(),
4770                })
4771            })?;
4772        let affected = table.delete_rows(&positions) + cold_shadow_count;
4773        let _ = table;
4774        // v7.12.5 — AFTER DELETE row-level triggers fire post-write
4775        // with NEW=None / OLD=pre-delete row (each from the
4776        // already-snapshotted to_delete_rows). Return value is
4777        // ignored (matches PG AFTER semantics).
4778        if !after_delete_triggers.is_empty() {
4779            for old_vals in &to_delete_rows {
4780                let old_row = Row::new(old_vals.clone());
4781                for fd in &after_delete_triggers {
4782                    let (_outcome, deferred) = triggers::fire_row_trigger(
4783                        fd,
4784                        None,
4785                        Some(&old_row),
4786                        &stmt.table,
4787                        &schema_cols,
4788                        &[],
4789                        trigger_session_cfg.as_deref(),
4790                        true,
4791                    )
4792                    .map_err(|e| {
4793                        EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}")))
4794                    })?;
4795                    deferred_embedded.extend(deferred);
4796                }
4797            }
4798        }
4799        // v7.12.7 — drain trigger-emitted embedded SQL for this DELETE.
4800        self.execute_deferred_trigger_stmts(deferred_embedded, cancel)?;
4801        // v6.2.1 — auto-analyze modified-row tracking for DELETE.
4802        if !self.in_transaction() && affected > 0 {
4803            self.statistics
4804                .record_modifications(&stmt.table, affected as u64);
4805        }
4806        // v7.9.4 — RETURNING projection over the soon-to-be-gone
4807        // rows. `to_delete_rows` was snapshotted in stage 1 before
4808        // mutation, so the projection sees the pre-delete state
4809        // (matches PG semantics: DELETE RETURNING returns the row
4810        // as it was just before removal).
4811        if let Some(items) = &stmt.returning {
4812            return self.build_returning_rows(&stmt.table, items, to_delete_rows);
4813        }
4814        Ok(QueryResult::CommandOk {
4815            affected,
4816            modified_catalog: !self.in_transaction(),
4817        })
4818    }
4819
4820    /// `SHOW TABLES` — one row per table in the active catalog.
4821    /// Column name is `name` so result-set consumers can downstream
4822    /// `SELECT name FROM ...` style logic if needed.
4823    /// v4.26: `EXPLAIN [ANALYZE] <select>`. Returns a single-column
4824    /// `QUERY PLAN` text table — first line names the top operator
4825    /// (Scan / Aggregate / Window / etc.), indented children list
4826    /// FROM joins, WHERE filters, ORDER BY / LIMIT, projection
4827    /// shape, and any active index hits. `ANALYZE` execs the inner
4828    /// SELECT and appends actual-row + elapsed-micros annotations.
4829    #[allow(clippy::format_push_string)]
4830    fn exec_explain(
4831        &self,
4832        e: &spg_sql::ast::ExplainStatement,
4833        cancel: CancelToken<'_>,
4834    ) -> Result<QueryResult, EngineError> {
4835        let mut lines = Vec::<String>::new();
4836        explain_select(&e.inner, self, 0, &mut lines);
4837        if e.suggest {
4838            // v6.8.3 — index advisor. Walks the SELECT's FROM
4839            // tables + WHERE column refs; for each (table, column)
4840            // pair that lacks an index, append a SUGGEST line with
4841            // a copy-pastable `CREATE INDEX` statement. This is a
4842            // pure-syntax heuristic — no cardinality estimation —
4843            // matching the v6.8.3 design intent of "tell the
4844            // operator where indexes are missing", not "give the
4845            // mathematically optimal index set".
4846            let suggestions = build_index_suggestions(&e.inner, self);
4847            for s in suggestions {
4848                lines.push(s);
4849            }
4850        } else if e.analyze {
4851            // v6.2.4 — EXPLAIN ANALYZE annotates each operator line
4852            // with `(rows=N)` where the row count is computable
4853            // without re-executing the full query:
4854            //   - Top-level operator (first non-indented line):
4855            //     rows = final result.len()
4856            //   - "From: <table> [full scan]" lines: rows =
4857            //     table.rows().len() (catalog read; no execution)
4858            //   - "From: <table> [index seek]": indeterminate —
4859            //     the index step would need re-execution; v6.2.5
4860            //     adds per-operator wall-clock + hot/cold rows
4861            //     instrumentation that makes this concrete.
4862            //   - Everything else: marked `(—)` so the surface
4863            //     stays well-defined without silently dropping
4864            //     stats. v6.2.5 fills in via inline executor
4865            //     instrumentation.
4866            // Total elapsed lands on a trailing `Total: …` line.
4867            let started = self.clock.map(|f| f());
4868            let exec = self.exec_select_cancel(&e.inner, cancel)?;
4869            let elapsed_micros = match (self.clock, started) {
4870                (Some(f), Some(s)) => Some(f().saturating_sub(s)),
4871                _ => None,
4872            };
4873            let row_count = if let QueryResult::Rows { rows, .. } = &exec {
4874                rows.len()
4875            } else {
4876                0
4877            };
4878            annotate_explain_lines(&mut lines, row_count, self);
4879            let mut total = alloc::format!("Total: rows={row_count}");
4880            if let Some(us) = elapsed_micros {
4881                total.push_str(&alloc::format!(" elapsed={us}us"));
4882            }
4883            lines.push(total);
4884        }
4885        let columns = alloc::vec![ColumnSchema::new("QUERY PLAN", DataType::Text, false)];
4886        let rows: Vec<Row> = lines
4887            .into_iter()
4888            .map(|l| Row::new(alloc::vec![Value::Text(l)]))
4889            .collect();
4890        Ok(QueryResult::Rows { columns, rows })
4891    }
4892
4893    fn exec_show_tables(&self) -> QueryResult {
4894        let columns = alloc::vec![ColumnSchema::new("name", DataType::Text, false)];
4895        let rows: Vec<Row> = self
4896            .active_catalog()
4897            .table_names()
4898            .into_iter()
4899            .map(|n| Row::new(alloc::vec![Value::Text(n)]))
4900            .collect();
4901        QueryResult::Rows { columns, rows }
4902    }
4903
4904    /// v7.17.0 Phase 3.P0-59 — `SHOW CREATE TABLE <t>`. Synthesise
4905    /// a minimal MySQL-flavoured CREATE TABLE DDL from the
4906    /// catalog's TableSchema so mysqldump round-trips load against
4907    /// SPG without splitting init scripts.
4908    fn exec_show_create_table(&self, name: &str) -> Result<QueryResult, EngineError> {
4909        let t = self.active_catalog().get(name).ok_or_else(|| {
4910            EngineError::Storage(StorageError::TableNotFound { name: name.into() })
4911        })?;
4912        let cols: Vec<String> = t
4913            .schema()
4914            .columns
4915            .iter()
4916            .map(|c| {
4917                let ty = render_data_type(c.ty);
4918                let nullable = if c.nullable { "" } else { " NOT NULL" };
4919                alloc::format!("  `{}` {}{}", c.name, ty, nullable)
4920            })
4921            .collect();
4922        let mut body = cols.join(",\n");
4923        // Append UNIQUE / PRIMARY KEY clauses.
4924        for uc in &t.schema().uniqueness_constraints {
4925            let col_names: Vec<String> = uc
4926                .columns
4927                .iter()
4928                .map(|&p| {
4929                    t.schema().columns.get(p).map_or_else(
4930                        || alloc::format!("col{p}"),
4931                        |c| alloc::format!("`{}`", c.name),
4932                    )
4933                })
4934                .collect();
4935            let kw = if uc.is_primary_key {
4936                "PRIMARY KEY"
4937            } else {
4938                "UNIQUE KEY"
4939            };
4940            body.push_str(",\n  ");
4941            body.push_str(&alloc::format!("{kw} ({})", col_names.join(", ")));
4942        }
4943        // Foreign keys.
4944        for fk in &t.schema().foreign_keys {
4945            let local: Vec<String> = fk
4946                .local_columns
4947                .iter()
4948                .map(|&p| {
4949                    t.schema().columns.get(p).map_or_else(
4950                        || alloc::format!("col{p}"),
4951                        |c| alloc::format!("`{}`", c.name),
4952                    )
4953                })
4954                .collect();
4955            let parent_cols: Vec<String> =
4956                if let Some(parent) = self.active_catalog().get(&fk.parent_table) {
4957                    fk.parent_columns
4958                        .iter()
4959                        .map(|&p| {
4960                            parent.schema().columns.get(p).map_or_else(
4961                                || alloc::format!("col{p}"),
4962                                |c| alloc::format!("`{}`", c.name),
4963                            )
4964                        })
4965                        .collect()
4966                } else {
4967                    fk.parent_columns
4968                        .iter()
4969                        .map(|p| alloc::format!("col{p}"))
4970                        .collect()
4971                };
4972            body.push_str(",\n  ");
4973            body.push_str(&alloc::format!(
4974                "FOREIGN KEY ({}) REFERENCES `{}` ({})",
4975                local.join(", "),
4976                fk.parent_table,
4977                parent_cols.join(", ")
4978            ));
4979        }
4980        let ddl = alloc::format!(
4981            "CREATE TABLE `{}` (\n{}\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
4982            name,
4983            body
4984        );
4985        let columns = alloc::vec![
4986            ColumnSchema::new("Table", DataType::Text, false),
4987            ColumnSchema::new("Create Table", DataType::Text, false),
4988        ];
4989        let rows = alloc::vec![Row::new(alloc::vec![
4990            Value::Text(name.into()),
4991            Value::Text(ddl),
4992        ])];
4993        Ok(QueryResult::Rows { columns, rows })
4994    }
4995
4996    /// v7.17.0 Phase 3.P0-60 — `SHOW INDEXES FROM <t>`. MySQL
4997    /// surface returns one row per (index × column) with 14
4998    /// columns; v7.17 ships the columns admin probes actually
4999    /// filter on: Table, Non_unique, Key_name, Seq_in_index,
5000    /// Column_name, Null, Index_type.
5001    fn exec_show_indexes(&self, name: &str) -> Result<QueryResult, EngineError> {
5002        let t = self.active_catalog().get(name).ok_or_else(|| {
5003            EngineError::Storage(StorageError::TableNotFound { name: name.into() })
5004        })?;
5005        let columns = alloc::vec![
5006            ColumnSchema::new("Table", DataType::Text, false),
5007            ColumnSchema::new("Non_unique", DataType::Int, false),
5008            ColumnSchema::new("Key_name", DataType::Text, false),
5009            ColumnSchema::new("Seq_in_index", DataType::Int, false),
5010            ColumnSchema::new("Column_name", DataType::Text, false),
5011            ColumnSchema::new("Null", DataType::Text, false),
5012            ColumnSchema::new("Index_type", DataType::Text, false),
5013        ];
5014        let mut rows: Vec<Row> = Vec::new();
5015        for idx in t.indices() {
5016            let col = t
5017                .schema()
5018                .columns
5019                .get(idx.column_position)
5020                .map_or("?".into(), |c| c.name.clone());
5021            let nullable = t
5022                .schema()
5023                .columns
5024                .get(idx.column_position)
5025                .map_or(true, |c| c.nullable);
5026            rows.push(Row::new(alloc::vec![
5027                Value::Text(name.into()),
5028                Value::Int(i32::from(!idx.is_unique)),
5029                Value::Text(idx.name.clone()),
5030                Value::Int(1),
5031                Value::Text(col),
5032                Value::Text(if nullable {
5033                    "YES".into()
5034                } else {
5035                    String::new()
5036                }),
5037                Value::Text("BTREE".into()),
5038            ]));
5039        }
5040        Ok(QueryResult::Rows { columns, rows })
5041    }
5042
5043    /// v7.17.0 Phase 3.P0-61 — `SHOW STATUS`. Returns canonical
5044    /// MySQL server-status counters (2-column `(Variable_name,
5045    /// Value)`).
5046    fn exec_show_status(&self) -> QueryResult {
5047        let columns = alloc::vec![
5048            ColumnSchema::new("Variable_name", DataType::Text, false),
5049            ColumnSchema::new("Value", DataType::Text, false),
5050        ];
5051        let pairs: &[(&str, &str)] = &[
5052            ("Uptime", "0"),
5053            ("Threads_connected", "1"),
5054            ("Threads_running", "1"),
5055            ("Questions", "0"),
5056            ("Slow_queries", "0"),
5057            ("Opened_tables", "0"),
5058            ("Innodb_buffer_pool_pages_total", "0"),
5059        ];
5060        let rows: Vec<Row> = pairs
5061            .iter()
5062            .map(|(k, v)| {
5063                Row::new(alloc::vec![
5064                    Value::Text((*k).into()),
5065                    Value::Text((*v).into())
5066                ])
5067            })
5068            .collect();
5069        QueryResult::Rows { columns, rows }
5070    }
5071
5072    /// v7.17.0 Phase 3.P0-61 — `SHOW VARIABLES`. Returns server-side
5073    /// variables MySQL/MariaDB clients probe at connect time.
5074    fn exec_show_variables(&self) -> QueryResult {
5075        let columns = alloc::vec![
5076            ColumnSchema::new("Variable_name", DataType::Text, false),
5077            ColumnSchema::new("Value", DataType::Text, false),
5078        ];
5079        let mut rows: Vec<Row> = Vec::new();
5080        let canonical: &[(&str, &str)] = &[
5081            ("version", "8.0.35-spg"),
5082            ("version_comment", "SPG dual-stack engine"),
5083            ("character_set_server", "utf8mb4"),
5084            ("collation_server", "utf8mb4_0900_ai_ci"),
5085            ("max_allowed_packet", "67108864"),
5086            ("autocommit", "ON"),
5087            ("sql_mode", "STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION"),
5088            ("time_zone", "SYSTEM"),
5089            ("transaction_isolation", "REPEATABLE-READ"),
5090        ];
5091        for &(k, v) in canonical {
5092            rows.push(Row::new(alloc::vec![
5093                Value::Text(k.into()),
5094                Value::Text(v.into()),
5095            ]));
5096        }
5097        // Session-set parameters surface here too.
5098        for (k, v) in &self.session_params {
5099            if !canonical.iter().any(|(n, _)| (*n).eq_ignore_ascii_case(k)) {
5100                rows.push(Row::new(alloc::vec![
5101                    Value::Text(k.clone()),
5102                    Value::Text(v.clone()),
5103                ]));
5104            }
5105        }
5106        QueryResult::Rows { columns, rows }
5107    }
5108
5109    /// v7.17.0 Phase 3.P0-62 — `SHOW PROCESSLIST`. SPG is
5110    /// single-process so the surface returns one synthetic row
5111    /// describing the current connection (Id, User, Host, db,
5112    /// Command, Time, State, Info).
5113    fn exec_show_processlist(&self) -> QueryResult {
5114        let columns = alloc::vec![
5115            ColumnSchema::new("Id", DataType::Int, false),
5116            ColumnSchema::new("User", DataType::Text, false),
5117            ColumnSchema::new("Host", DataType::Text, false),
5118            ColumnSchema::new("db", DataType::Text, true),
5119            ColumnSchema::new("Command", DataType::Text, false),
5120            ColumnSchema::new("Time", DataType::Int, false),
5121            ColumnSchema::new("State", DataType::Text, true),
5122            ColumnSchema::new("Info", DataType::Text, true),
5123        ];
5124        let rows = alloc::vec![Row::new(alloc::vec![
5125            Value::Int(1),
5126            Value::Text("postgres".into()),
5127            Value::Text("localhost".into()),
5128            Value::Text("postgres".into()),
5129            Value::Text("Query".into()),
5130            Value::Int(0),
5131            Value::Text("executing".into()),
5132            Value::Text("SHOW PROCESSLIST".into()),
5133        ])];
5134        QueryResult::Rows { columns, rows }
5135    }
5136
5137    /// v7.17.0 Phase 3.P0-58 — `SHOW DATABASES` / `SHOW SCHEMAS`.
5138    /// SPG is single-database so the result is the canonical MySQL
5139    /// set every mysql/MariaDB client expects at connect time:
5140    /// `information_schema`, `mysql`, `performance_schema`, `sys`,
5141    /// plus a `postgres` slot so dual-stack callers find their
5142    /// PG-compatible database too.
5143    fn exec_show_databases(&self) -> QueryResult {
5144        let columns = alloc::vec![ColumnSchema::new("Database", DataType::Text, false)];
5145        let names = [
5146            "information_schema",
5147            "mysql",
5148            "performance_schema",
5149            "sys",
5150            "postgres",
5151        ];
5152        let rows: Vec<Row> = names
5153            .iter()
5154            .map(|n| Row::new(alloc::vec![Value::Text((*n).into())]))
5155            .collect();
5156        QueryResult::Rows { columns, rows }
5157    }
5158
5159    /// `SHOW COLUMNS FROM <table>` — one row per column with the
5160    /// declared name, SQL type rendering, and nullability flag.
5161    fn exec_show_columns(&self, table_name: &str) -> Result<QueryResult, EngineError> {
5162        let table =
5163            self.active_catalog()
5164                .get(table_name)
5165                .ok_or_else(|| StorageError::TableNotFound {
5166                    name: table_name.into(),
5167                })?;
5168        let columns = alloc::vec![
5169            ColumnSchema::new("name", DataType::Text, false),
5170            ColumnSchema::new("type", DataType::Text, false),
5171            ColumnSchema::new("nullable", DataType::Bool, false),
5172        ];
5173        let rows: Vec<Row> = table
5174            .schema()
5175            .columns
5176            .iter()
5177            .map(|c| {
5178                Row::new(alloc::vec![
5179                    Value::Text(c.name.clone()),
5180                    Value::Text(alloc::format!("{}", c.ty)),
5181                    Value::Bool(c.nullable),
5182                ])
5183            })
5184            .collect();
5185        Ok(QueryResult::Rows { columns, rows })
5186    }
5187
5188    fn exec_begin(&mut self) -> Result<QueryResult, EngineError> {
5189        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5190        if self.tx_catalogs.contains_key(&tx_id) {
5191            return Err(EngineError::TransactionAlreadyOpen);
5192        }
5193        self.tx_catalogs.insert(
5194            tx_id,
5195            TxState {
5196                catalog: self.catalog.clone(),
5197                savepoints: Vec::new(),
5198            },
5199        );
5200        Ok(QueryResult::CommandOk {
5201            affected: 0,
5202            modified_catalog: false,
5203        })
5204    }
5205
5206    fn exec_commit(&mut self) -> Result<QueryResult, EngineError> {
5207        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5208        let state = self
5209            .tx_catalogs
5210            .remove(&tx_id)
5211            .ok_or(EngineError::NoActiveTransaction)?;
5212        self.catalog = state.catalog;
5213        // All savepoints become permanent at COMMIT and the stack
5214        // resets for the next TX (`state.savepoints` is discarded with
5215        // `state`).
5216        Ok(QueryResult::CommandOk {
5217            affected: 0,
5218            modified_catalog: true,
5219        })
5220    }
5221
5222    fn exec_rollback(&mut self) -> Result<QueryResult, EngineError> {
5223        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5224        if self.tx_catalogs.remove(&tx_id).is_none() {
5225            return Err(EngineError::NoActiveTransaction);
5226        }
5227        // savepoints discarded with the TxState
5228        Ok(QueryResult::CommandOk {
5229            affected: 0,
5230            modified_catalog: false,
5231        })
5232    }
5233
5234    fn exec_savepoint(&mut self, name: String) -> Result<QueryResult, EngineError> {
5235        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5236        let state = self
5237            .tx_catalogs
5238            .get_mut(&tx_id)
5239            .ok_or(EngineError::NoActiveTransaction)?;
5240        // PG re-uses an existing savepoint name by dropping the older
5241        // entry and pushing a fresh one — match that behaviour so
5242        // application code can `SAVEPOINT sp; ...; SAVEPOINT sp` freely.
5243        state.savepoints.retain(|(n, _)| n != &name);
5244        let snapshot = state.catalog.clone();
5245        state.savepoints.push((name, snapshot));
5246        Ok(QueryResult::CommandOk {
5247            affected: 0,
5248            modified_catalog: false,
5249        })
5250    }
5251
5252    fn exec_rollback_to_savepoint(&mut self, name: &str) -> Result<QueryResult, EngineError> {
5253        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5254        let state = self
5255            .tx_catalogs
5256            .get_mut(&tx_id)
5257            .ok_or(EngineError::NoActiveTransaction)?;
5258        let pos = state
5259            .savepoints
5260            .iter()
5261            .rposition(|(n, _)| n == name)
5262            .ok_or_else(|| {
5263                EngineError::Unsupported(alloc::format!("savepoint not found: {name}"))
5264            })?;
5265        // The savepoint stays on the stack (PG semantics): a later
5266        // `RELEASE` or further `ROLLBACK TO` is still allowed. Everything
5267        // after it is discarded.
5268        let snapshot = state.savepoints[pos].1.clone();
5269        state.savepoints.truncate(pos + 1);
5270        state.catalog = snapshot;
5271        Ok(QueryResult::CommandOk {
5272            affected: 0,
5273            modified_catalog: false,
5274        })
5275    }
5276
5277    fn exec_release_savepoint(&mut self, name: &str) -> Result<QueryResult, EngineError> {
5278        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5279        let state = self
5280            .tx_catalogs
5281            .get_mut(&tx_id)
5282            .ok_or(EngineError::NoActiveTransaction)?;
5283        let pos = state
5284            .savepoints
5285            .iter()
5286            .rposition(|(n, _)| n == name)
5287            .ok_or_else(|| {
5288                EngineError::Unsupported(alloc::format!("savepoint not found: {name}"))
5289            })?;
5290        // RELEASE keeps the work since the savepoint, just discards the
5291        // bookmark plus everything nested under it.
5292        state.savepoints.truncate(pos);
5293        Ok(QueryResult::CommandOk {
5294            affected: 0,
5295            modified_catalog: false,
5296        })
5297    }
5298
5299    /// v6.0.4 — synchronous `ALTER INDEX <name> REBUILD [WITH
5300    /// (encoding = …)]`. Walks every table in the active catalog
5301    /// looking for an index matching `stmt.name`, then delegates the
5302    /// rebuild (including any encoding switch) to
5303    /// `Table::rebuild_nsw_index`. The "live" non-blocking
5304    /// optimisation is v6.0.4.1 / v6.1.x territory.
5305    /// v6.7.2 — `ALTER TABLE t SET hot_tier_bytes = X`. Dispatch
5306    /// arm. Currently the only setting is `hot_tier_bytes`; later
5307    /// v6.7.x can extend `AlterTableTarget` without touching this
5308    /// arm structure.
5309    fn exec_alter_table(
5310        &mut self,
5311        s: spg_sql::ast::AlterTableStatement,
5312    ) -> Result<QueryResult, EngineError> {
5313        // v7.13.2 — mailrs round-6 S1: apply each subaction in order.
5314        // On first error the statement aborts; subactions already
5315        // applied stay (no transactional rollback in v7.13 — wrap in
5316        // BEGIN/COMMIT if atomicity matters).
5317        let table_name = s.name.clone();
5318        for target in s.targets {
5319            self.exec_alter_table_subaction(&table_name, target)?;
5320        }
5321        Ok(QueryResult::CommandOk {
5322            affected: 0,
5323            modified_catalog: !self.in_transaction(),
5324        })
5325    }
5326
5327    fn exec_alter_table_subaction(
5328        &mut self,
5329        table_name_outer: &str,
5330        target: spg_sql::ast::AlterTableTarget,
5331    ) -> Result<(), EngineError> {
5332        // Inner helper retains the s.name closure shape; alias to `s`
5333        // for minimal diff against the v7.13.0 body.
5334        struct S<'a> {
5335            name: &'a str,
5336        }
5337        let s = S {
5338            name: table_name_outer,
5339        };
5340        match target {
5341            spg_sql::ast::AlterTableTarget::SetHotTierBytes(n) => {
5342                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5343                    EngineError::Storage(StorageError::TableNotFound {
5344                        name: s.name.into(),
5345                    })
5346                })?;
5347                table.schema_mut().hot_tier_bytes = Some(n);
5348            }
5349            spg_sql::ast::AlterTableTarget::AddForeignKey(fk) => {
5350                // v7.6.8 — resolve FK against the live catalog first
5351                // (validates parent table, columns, indices). Then
5352                // verify every existing row in the child table
5353                // satisfies the new constraint. Then install it.
5354                let cols_snapshot = self
5355                    .active_catalog()
5356                    .get(s.name)
5357                    .ok_or_else(|| {
5358                        EngineError::Storage(StorageError::TableNotFound {
5359                            name: s.name.into(),
5360                        })
5361                    })?
5362                    .schema()
5363                    .columns
5364                    .clone();
5365                let storage_fk =
5366                    resolve_foreign_key(s.name, &cols_snapshot, fk, self.active_catalog())?;
5367                // Verify existing rows. Treat them as a virtual
5368                // INSERT batch — reusing the v7.6.2 enforce helper.
5369                let existing_rows: Vec<Vec<Value>> = self
5370                    .active_catalog()
5371                    .get(s.name)
5372                    .expect("checked above")
5373                    .rows()
5374                    .iter()
5375                    .map(|r| r.values.clone())
5376                    .collect();
5377                enforce_fk_inserts(
5378                    self.active_catalog(),
5379                    s.name,
5380                    core::slice::from_ref(&storage_fk),
5381                    &existing_rows,
5382                )?;
5383                // Reject duplicate constraint name.
5384                let table = self
5385                    .active_catalog_mut()
5386                    .get_mut(s.name)
5387                    .expect("checked above");
5388                if let Some(name) = &storage_fk.name
5389                    && table
5390                        .schema()
5391                        .foreign_keys
5392                        .iter()
5393                        .any(|f| f.name.as_ref() == Some(name))
5394                {
5395                    return Err(EngineError::Unsupported(alloc::format!(
5396                        "ALTER TABLE ADD CONSTRAINT: a constraint named {name:?} already exists"
5397                    )));
5398                }
5399                table.schema_mut().foreign_keys.push(storage_fk);
5400            }
5401            spg_sql::ast::AlterTableTarget::DropForeignKey { name, if_exists } => {
5402                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5403                    EngineError::Storage(StorageError::TableNotFound {
5404                        name: s.name.into(),
5405                    })
5406                })?;
5407                let fks = &mut table.schema_mut().foreign_keys;
5408                let before = fks.len();
5409                fks.retain(|f| f.name.as_ref() != Some(&name));
5410                if fks.len() == before && !if_exists {
5411                    return Err(EngineError::Unsupported(alloc::format!(
5412                        "ALTER TABLE DROP CONSTRAINT: no FK named {name:?} on {:?}",
5413                        s.name
5414                    )));
5415                }
5416                // v7.13.2 mailrs round-6 S7: IF EXISTS silences the miss.
5417            }
5418            spg_sql::ast::AlterTableTarget::AddColumn {
5419                column,
5420                if_not_exists,
5421            } => {
5422                // v7.13.0 — mailrs round-5 G1. Append-only column add
5423                // with back-fill of the DEFAULT (or NULL) into every
5424                // existing row. Column positions don't shift, so we
5425                // skip index rebuild.
5426                let clock = self.clock;
5427                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5428                    EngineError::Storage(StorageError::TableNotFound {
5429                        name: s.name.into(),
5430                    })
5431                })?;
5432                if table
5433                    .schema()
5434                    .columns
5435                    .iter()
5436                    .any(|c| c.name.eq_ignore_ascii_case(&column.name))
5437                {
5438                    if if_not_exists {
5439                        return Ok(());
5440                    }
5441                    return Err(EngineError::Unsupported(alloc::format!(
5442                        "ALTER TABLE ADD COLUMN: column {:?} already exists on {:?}",
5443                        column.name,
5444                        s.name
5445                    )));
5446                }
5447                let col_name = column.name.clone();
5448                let nullable = column.nullable;
5449                let has_default = column.default.is_some() || column.auto_increment;
5450                let col_schema = column_def_to_schema(column)?;
5451                let row_count = table.row_count();
5452                // Compute the back-fill value. Literal / runtime DEFAULT
5453                // funnels through the same resolver that INSERT uses
5454                // (v7.9.21 `resolve_column_default_free`). NULL when
5455                // the column is nullable and has no DEFAULT. NOT NULL
5456                // without DEFAULT errors when the table has existing
5457                // rows — same as PG.
5458                let fill_value: Value = if has_default || col_schema.runtime_default.is_some() {
5459                    resolve_column_default_free(&col_schema, clock)?
5460                } else if nullable || row_count == 0 {
5461                    Value::Null
5462                } else {
5463                    return Err(EngineError::Unsupported(alloc::format!(
5464                        "ALTER TABLE ADD COLUMN {col_name:?}: NOT NULL column requires DEFAULT \
5465                         when the table has existing rows"
5466                    )));
5467                };
5468                table.add_column(col_schema, fill_value);
5469            }
5470            spg_sql::ast::AlterTableTarget::AlterColumnType {
5471                column,
5472                new_type,
5473                using,
5474            } => {
5475                // v7.13.0 — mailrs round-5 G8. Re-evaluate each
5476                // row's column value (either through the USING
5477                // expression if supplied, or as a direct CAST of
5478                // the existing value) and re-coerce to the new
5479                // type. Indices on the column get rebuilt.
5480                let new_data_type = column_type_to_data_type(new_type);
5481                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5482                    EngineError::Storage(StorageError::TableNotFound {
5483                        name: s.name.into(),
5484                    })
5485                })?;
5486                let col_pos = table
5487                    .schema()
5488                    .columns
5489                    .iter()
5490                    .position(|c| c.name.eq_ignore_ascii_case(&column))
5491                    .ok_or_else(|| {
5492                        EngineError::Unsupported(alloc::format!(
5493                            "ALTER COLUMN TYPE: column {column:?} not found on {:?}",
5494                            s.name
5495                        ))
5496                    })?;
5497                let schema_cols = table.schema().columns.clone();
5498                let ctx = eval::EvalContext::new(&schema_cols, None);
5499                let mut new_values: alloc::vec::Vec<Value> =
5500                    alloc::vec::Vec::with_capacity(table.row_count());
5501                for row in table.rows().iter() {
5502                    let raw = match &using {
5503                        Some(expr) => eval::eval_expr(expr, row, &ctx).map_err(|e| {
5504                            EngineError::Unsupported(alloc::format!(
5505                                "ALTER COLUMN TYPE: USING expression failed: {e:?}"
5506                            ))
5507                        })?,
5508                        None => row.values.get(col_pos).cloned().unwrap_or(Value::Null),
5509                    };
5510                    let coerced = coerce_value(raw, new_data_type, &column, col_pos)?;
5511                    new_values.push(coerced);
5512                }
5513                table.schema_mut().columns[col_pos].ty = new_data_type;
5514                for (i, v) in new_values.into_iter().enumerate() {
5515                    let mut row_values = table
5516                        .rows()
5517                        .get(i)
5518                        .expect("bounds-checked above")
5519                        .values
5520                        .clone();
5521                    row_values[col_pos] = v;
5522                    table.update_row(i, row_values)?;
5523                }
5524            }
5525            spg_sql::ast::AlterTableTarget::AddTableConstraint(tc) => {
5526                // v7.14.0 — pg_dump emits PKs as a separate
5527                // ALTER TABLE ADD CONSTRAINT post-CREATE-TABLE.
5528                // For PRIMARY KEY / UNIQUE, install a UC entry
5529                // and the implicit BTree index on the leading
5530                // column. CHECK: append predicate to schema.
5531                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5532                    EngineError::Storage(StorageError::TableNotFound {
5533                        name: s.name.into(),
5534                    })
5535                })?;
5536                let is_pk = matches!(tc, spg_sql::ast::TableConstraint::PrimaryKey { .. });
5537                match tc {
5538                    spg_sql::ast::TableConstraint::PrimaryKey { columns, .. }
5539                    | spg_sql::ast::TableConstraint::Unique { columns, .. } => {
5540                        let positions: Vec<usize> = columns
5541                            .iter()
5542                            .map(|c| {
5543                                table
5544                                    .schema()
5545                                    .columns
5546                                    .iter()
5547                                    .position(|sc| sc.name.eq_ignore_ascii_case(c))
5548                                    .ok_or_else(|| {
5549                                        EngineError::Unsupported(alloc::format!(
5550                                            "ALTER TABLE ADD CONSTRAINT: column {c:?} not found on {:?}",
5551                                            s.name
5552                                        ))
5553                                    })
5554                            })
5555                            .collect::<Result<Vec<_>, _>>()?;
5556                        // Skip if an equivalent UC is already there
5557                        // (idempotent — pg_dump's PK + a prior inline
5558                        // PK shouldn't double-install).
5559                        let already = table
5560                            .schema()
5561                            .uniqueness_constraints
5562                            .iter()
5563                            .any(|u| u.columns == positions);
5564                        if !already {
5565                            table.schema_mut().uniqueness_constraints.push(
5566                                spg_storage::UniquenessConstraint {
5567                                    is_primary_key: is_pk,
5568                                    columns: positions.clone(),
5569                                    nulls_not_distinct: false,
5570                                },
5571                            );
5572                            // PK implies NOT NULL on referenced cols.
5573                            if is_pk {
5574                                for p in &positions {
5575                                    if let Some(c) = table.schema_mut().columns.get_mut(*p) {
5576                                        c.nullable = false;
5577                                    }
5578                                }
5579                            }
5580                            // Add a BTree index on the leading
5581                            // column for INSERT-side enforcement.
5582                            let leading = &columns[0];
5583                            let already_idx = table.indices().iter().any(|idx| {
5584                                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
5585                                    && table.schema().columns[idx.column_position].name == *leading
5586                            });
5587                            if !already_idx {
5588                                let suffix = if is_pk { "pkey" } else { "key" };
5589                                let idx_name = alloc::format!("{}_{leading}_{suffix}", s.name);
5590                                let _ = table.add_index(idx_name, leading);
5591                            }
5592                        }
5593                    }
5594                    spg_sql::ast::TableConstraint::Check { expr, .. } => {
5595                        table.schema_mut().checks.push(alloc::format!("{expr}"));
5596                    }
5597                    spg_sql::ast::TableConstraint::Index { name, columns } => {
5598                        // v7.15.0 — ALTER TABLE ADD KEY (cols).
5599                        // mysqldump occasionally emits this
5600                        // post-CREATE-TABLE shape; build a BTree
5601                        // on the leading column using the
5602                        // user-supplied or synthesised name.
5603                        let leading = &columns[0];
5604                        let already_idx = table.indices().iter().any(|idx| {
5605                            matches!(idx.kind, spg_storage::IndexKind::BTree(_))
5606                                && table.schema().columns[idx.column_position].name == *leading
5607                        });
5608                        if !already_idx {
5609                            let idx_name = name
5610                                .clone()
5611                                .unwrap_or_else(|| alloc::format!("{}_{leading}_idx", s.name));
5612                            let _ = table.add_index(idx_name, leading);
5613                        }
5614                    }
5615                    spg_sql::ast::TableConstraint::FulltextIndex { name, columns } => {
5616                        // v7.17.0 Phase 2.2 — ALTER TABLE ADD
5617                        // FULLTEXT KEY (cols). Builds one
5618                        // fulltext-GIN per named column so MATCH
5619                        // AGAINST gets a real inverted index.
5620                        // Multi-column declarations expand to
5621                        // per-column GINs (the leading column
5622                        // drives MATCH AGAINST planning).
5623                        for (k, col) in columns.iter().enumerate() {
5624                            let already_idx = table.indices().iter().any(|idx| {
5625                                matches!(idx.kind, spg_storage::IndexKind::GinFulltext(_))
5626                                    && table.schema().columns[idx.column_position].name == *col
5627                            });
5628                            if already_idx {
5629                                continue;
5630                            }
5631                            let idx_name = match (&name, columns.len(), k) {
5632                                (Some(n), 1, _) => n.clone(),
5633                                (Some(n), _, k) => alloc::format!("{n}_{k}"),
5634                                (None, _, _) => {
5635                                    alloc::format!("{}_{col}_ftidx", s.name)
5636                                }
5637                            };
5638                            let _ = table.add_gin_fulltext_index(idx_name, col);
5639                        }
5640                    }
5641                }
5642            }
5643            spg_sql::ast::AlterTableTarget::DropColumn {
5644                column,
5645                if_exists,
5646                cascade,
5647            } => {
5648                // v7.13.3 — mailrs round-7 S8. Remove the column +
5649                // every row's value at that position; drop any index
5650                // on the column. RESTRICT (default) rejects when an
5651                // FK on this table or partial-index predicate
5652                // references the column; CASCADE removes those
5653                // dependents first.
5654                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5655                    EngineError::Storage(StorageError::TableNotFound {
5656                        name: s.name.into(),
5657                    })
5658                })?;
5659                let col_pos = match table
5660                    .schema()
5661                    .columns
5662                    .iter()
5663                    .position(|c| c.name.eq_ignore_ascii_case(&column))
5664                {
5665                    Some(p) => p,
5666                    None => {
5667                        if if_exists {
5668                            return Ok(());
5669                        }
5670                        return Err(EngineError::Unsupported(alloc::format!(
5671                            "ALTER TABLE DROP COLUMN: column {column:?} not found on {:?}",
5672                            s.name
5673                        )));
5674                    }
5675                };
5676                // Dependent check: FKs whose local columns include
5677                // col_pos. CASCADE drops them; otherwise reject.
5678                let dependent_fks: Vec<usize> = table
5679                    .schema()
5680                    .foreign_keys
5681                    .iter()
5682                    .enumerate()
5683                    .filter_map(|(i, fk)| {
5684                        if fk.local_columns.contains(&col_pos) {
5685                            Some(i)
5686                        } else {
5687                            None
5688                        }
5689                    })
5690                    .collect();
5691                if !dependent_fks.is_empty() && !cascade {
5692                    return Err(EngineError::Unsupported(alloc::format!(
5693                        "ALTER TABLE DROP COLUMN {column:?}: column has FK dependents; \
5694                         use DROP COLUMN ... CASCADE to remove them"
5695                    )));
5696                }
5697                // CASCADE the FK removals first.
5698                if cascade {
5699                    // Drop in reverse so indices stay valid.
5700                    let mut sorted = dependent_fks.clone();
5701                    sorted.sort();
5702                    sorted.reverse();
5703                    let fks = &mut table.schema_mut().foreign_keys;
5704                    for i in sorted {
5705                        fks.remove(i);
5706                    }
5707                }
5708                // Drop the column. New helper on Table does the
5709                // row + schema + index shift atomically.
5710                table.drop_column(col_pos);
5711            }
5712            spg_sql::ast::AlterTableTarget::SetTriggerEnabled { which, enabled } => {
5713                // v7.16.1 — mailrs round-9 A.2.b. pg_dump
5714                // --disable-triggers wraps each table's data
5715                // block with `ALTER TABLE … DISABLE TRIGGER ALL`
5716                // / `… ENABLE TRIGGER ALL`. Toggle the enabled
5717                // flag on every matching trigger so the row-
5718                // write paths skip them; the catalog snapshot
5719                // persists the new state across restarts.
5720                let table_name = s.name.to_string();
5721                let trigs = self.active_catalog_mut().triggers_mut();
5722                let mut touched = false;
5723                for t in trigs.iter_mut() {
5724                    if !t.table.eq_ignore_ascii_case(&table_name) {
5725                        continue;
5726                    }
5727                    match &which {
5728                        spg_sql::ast::TriggerSelector::All => {
5729                            t.enabled = enabled;
5730                            touched = true;
5731                        }
5732                        spg_sql::ast::TriggerSelector::Named(name) => {
5733                            if t.name.eq_ignore_ascii_case(name) {
5734                                t.enabled = enabled;
5735                                touched = true;
5736                            }
5737                        }
5738                    }
5739                }
5740                // PG semantics: `ALL` on a table with no
5741                // triggers is a no-op (no error). A `Named`
5742                // form pointing at a non-existent trigger
5743                // raises in PG; v7.16.1 also raises so we
5744                // don't silently lose state.
5745                if !touched {
5746                    if let spg_sql::ast::TriggerSelector::Named(name) = &which {
5747                        return Err(EngineError::Unsupported(alloc::format!(
5748                            "ALTER TABLE {table_name:?} {} TRIGGER {name:?}: no such trigger on table",
5749                            if enabled { "ENABLE" } else { "DISABLE" },
5750                        )));
5751                    }
5752                }
5753            }
5754            spg_sql::ast::AlterTableTarget::RenameTable { new } => {
5755                // v7.16.2 — table-level rename (mailrs round-10
5756                // A.5 — used by migrate-042's `ALTER TABLE
5757                // contacts RENAME TO email_contacts`). Storage
5758                // helper updates the schema + by_name index +
5759                // dangling FK / trigger references in one
5760                // atomic step.
5761                let old = s.name.to_string();
5762                self.active_catalog_mut()
5763                    .rename_table(&old, &new)
5764                    .map_err(EngineError::Storage)?;
5765            }
5766            spg_sql::ast::AlterTableTarget::RenameColumn { old, new } => {
5767                // v7.15.0 — `ALTER TABLE t RENAME [COLUMN] old TO
5768                // new`. Rename the column in the schema; rewrite
5769                // every stored source string on this table that
5770                // references it as a (potentially-qualified)
5771                // column identifier: CHECK predicates, partial-
5772                // index predicates, runtime DEFAULT expressions.
5773                // Then walk catalog triggers on this table and
5774                // patch any `UPDATE OF` column list. Function and
5775                // trigger bodies are NOT auto-rewritten — that
5776                // surface is dynamic SQL territory; users update
5777                // those separately (matches PG plpgsql behavior:
5778                // a column rename invalidates name-referencing
5779                // plpgsql at call time, not rename time).
5780                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5781                    EngineError::Storage(StorageError::TableNotFound {
5782                        name: s.name.into(),
5783                    })
5784                })?;
5785                let col_pos = table
5786                    .schema()
5787                    .columns
5788                    .iter()
5789                    .position(|c| c.name.eq_ignore_ascii_case(&old))
5790                    .ok_or_else(|| {
5791                        EngineError::Unsupported(alloc::format!(
5792                            "ALTER TABLE RENAME COLUMN: column {old:?} not found on {:?}",
5793                            s.name
5794                        ))
5795                    })?;
5796                // Reject same-name (case-insensitive) collision.
5797                if table
5798                    .schema()
5799                    .columns
5800                    .iter()
5801                    .enumerate()
5802                    .any(|(i, c)| i != col_pos && c.name.eq_ignore_ascii_case(&new))
5803                {
5804                    return Err(EngineError::Unsupported(alloc::format!(
5805                        "ALTER TABLE RENAME COLUMN: column {new:?} already exists on {:?}",
5806                        s.name
5807                    )));
5808                }
5809                // Schema rename first — even idempotent same-name
5810                // rename (`ALTER TABLE t RENAME a TO a`) needs to
5811                // be a no-op, not an error.
5812                if old.eq_ignore_ascii_case(&new) {
5813                    return Ok(());
5814                }
5815                table.rename_column(col_pos, &new);
5816                // Rewrite per-column runtime_default sources on
5817                // every column of this table — a DEFAULT expression
5818                // on column X may reference column Y by name (rare,
5819                // but legal in PG when the value is supplied via a
5820                // function that takes the row).
5821                let n_cols = table.schema().columns.len();
5822                for i in 0..n_cols {
5823                    let rt = table.schema().columns[i].runtime_default.clone();
5824                    if let Some(src) = rt {
5825                        let rewritten = rewrite_column_in_source(&src, &old, &new)?;
5826                        table.schema_mut().columns[i].runtime_default = Some(rewritten);
5827                    }
5828                }
5829                // Rewrite table-level CHECK predicates.
5830                let checks = table.schema().checks.clone();
5831                let mut new_checks = Vec::with_capacity(checks.len());
5832                for chk in checks {
5833                    new_checks.push(rewrite_column_in_source(&chk, &old, &new)?);
5834                }
5835                table.schema_mut().checks = new_checks;
5836                // Rewrite per-index partial_predicate sources.
5837                let n_idx = table.indices().len();
5838                for i in 0..n_idx {
5839                    let pred = table.indices()[i].partial_predicate.clone();
5840                    if let Some(src) = pred {
5841                        let rewritten = rewrite_column_in_source(&src, &old, &new)?;
5842                        // SAFETY: indices_mut would be cleanest, but
5843                        // partial_predicate is the only mutable field
5844                        // here; reach in via the public mut accessor.
5845                        table.set_partial_predicate(i, Some(rewritten));
5846                    }
5847                }
5848                // Walk catalog triggers; patch `update_columns` on
5849                // triggers attached to this table.
5850                let table_name = s.name.to_string();
5851                for trig in self.active_catalog_mut().triggers_mut() {
5852                    if !trig.table.eq_ignore_ascii_case(&table_name) {
5853                        continue;
5854                    }
5855                    for c in &mut trig.update_columns {
5856                        if c.eq_ignore_ascii_case(&old) {
5857                            *c = new.clone();
5858                        }
5859                    }
5860                }
5861            }
5862        }
5863        Ok(())
5864    }
5865
5866    fn exec_alter_index(
5867        &mut self,
5868        stmt: spg_sql::ast::AlterIndexStatement,
5869    ) -> Result<QueryResult, EngineError> {
5870        // Translate the optional SQL-side encoding choice into the
5871        // storage-side enum; the same SqlVecEncoding -> VecEncoding
5872        // bridge `column_type_to_data_type` uses.
5873        let spg_sql::ast::AlterIndexStatement {
5874            name: idx_name,
5875            target,
5876        } = stmt;
5877        // v7.16.2 — RENAME TO branch (mailrs round-10 migrate-042).
5878        // IF EXISTS makes a missing index a no-op rather than an
5879        // error, mirroring PG semantics.
5880        if let spg_sql::ast::AlterIndexTarget::Rename { new, if_exists } = target {
5881            let renamed = self.active_catalog_mut().rename_index(&idx_name, &new);
5882            return match renamed {
5883                Ok(()) => Ok(QueryResult::CommandOk {
5884                    affected: 0,
5885                    modified_catalog: !self.in_transaction(),
5886                }),
5887                Err(StorageError::IndexNotFound { .. }) if if_exists => {
5888                    Ok(QueryResult::CommandOk {
5889                        affected: 0,
5890                        modified_catalog: false,
5891                    })
5892                }
5893                Err(e) => Err(EngineError::Storage(e)),
5894            };
5895        }
5896        let spg_sql::ast::AlterIndexTarget::Rebuild { encoding } = target else {
5897            unreachable!("Rename branch returned above");
5898        };
5899        let target = encoding.map(|e| match e {
5900            SqlVecEncoding::F32 => VecEncoding::F32,
5901            SqlVecEncoding::Sq8 => VecEncoding::Sq8,
5902            SqlVecEncoding::F16 => VecEncoding::F16,
5903        });
5904        // Linear scan: index names are globally unique within a
5905        // catalog (enforced by add_nsw_index_inner) so the first
5906        // match is the only one. Save the table name to avoid
5907        // borrowing while we then take a mut borrow.
5908        let table_name = {
5909            let cat = self.active_catalog();
5910            let mut found: Option<String> = None;
5911            for tname in cat.table_names() {
5912                if let Some(t) = cat.get(&tname)
5913                    && t.indices().iter().any(|i| i.name == idx_name)
5914                {
5915                    found = Some(tname);
5916                    break;
5917                }
5918            }
5919            found.ok_or_else(|| {
5920                EngineError::Storage(StorageError::IndexNotFound {
5921                    name: idx_name.clone(),
5922                })
5923            })?
5924        };
5925        let table = self
5926            .active_catalog_mut()
5927            .get_mut(&table_name)
5928            .expect("table found above");
5929        table.rebuild_nsw_index(&idx_name, target)?;
5930        // v6.3.1 — ALTER INDEX REBUILD potentially with new encoding
5931        // changes cost characteristics; evict any cached plans.
5932        self.plan_cache.evict_referencing(&table_name);
5933        Ok(QueryResult::CommandOk {
5934            affected: 0,
5935            modified_catalog: !self.in_transaction(),
5936        })
5937    }
5938
5939    fn exec_create_index(
5940        &mut self,
5941        stmt: CreateIndexStatement,
5942    ) -> Result<QueryResult, EngineError> {
5943        let table = self
5944            .active_catalog_mut()
5945            .get_mut(&stmt.table)
5946            .ok_or_else(|| {
5947                EngineError::Storage(StorageError::TableNotFound {
5948                    name: stmt.table.clone(),
5949                })
5950            })?;
5951        // `IF NOT EXISTS` reduces DuplicateIndex to a no-op CommandOk.
5952        if stmt.if_not_exists && table.indices().iter().any(|i| i.name == stmt.name) {
5953            return Ok(QueryResult::CommandOk {
5954                affected: 0,
5955                modified_catalog: false,
5956            });
5957        }
5958        // v7.9.14 — multi-column index parses through; engine
5959        // builds a single-column BTree on the leading column only.
5960        // The extras live on the AST so spg-server's dispatcher
5961        // can emit a PG-wire NoticeResponse / log line. Composite
5962        // BTree keys land in v7.10.
5963        let _ = &stmt.extra_columns; // intentional drop on engine side
5964        let table_name = stmt.table.clone();
5965        // v6.8.0 — resolve INCLUDE column names to positions. Done
5966        // before `add_index` so a typo error surfaces before any
5967        // catalog mutation lands.
5968        let included_positions: Vec<usize> = if stmt.included_columns.is_empty() {
5969            Vec::new()
5970        } else {
5971            let schema = table.schema();
5972            stmt.included_columns
5973                .iter()
5974                .map(|c| {
5975                    schema.column_position(c).ok_or_else(|| {
5976                        EngineError::Storage(StorageError::ColumnNotFound { column: c.clone() })
5977                    })
5978                })
5979                .collect::<Result<Vec<_>, _>>()?
5980        };
5981        match stmt.method {
5982            IndexMethod::BTree => table.add_index(stmt.name.clone(), &stmt.column)?,
5983            IndexMethod::Hnsw => {
5984                if !included_positions.is_empty() {
5985                    return Err(EngineError::Unsupported(
5986                        "INCLUDE columns are not supported on HNSW indexes".into(),
5987                    ));
5988                }
5989                table.add_nsw_index(stmt.name.clone(), &stmt.column, spg_storage::NSW_DEFAULT_M)?;
5990            }
5991            // v6.7.1 — BRIN. Pure metadata; no in-memory data.
5992            IndexMethod::Brin => {
5993                if !included_positions.is_empty() {
5994                    return Err(EngineError::Unsupported(
5995                        "INCLUDE columns are not supported on BRIN indexes".into(),
5996                    ));
5997                }
5998                table.add_brin_index(stmt.name.clone(), &stmt.column)?;
5999            }
6000            // v7.12.3 — GIN inverted index. Real posting-list-backed
6001            // GIN when the indexed column is `tsvector`; falls back
6002            // to a BTree on the leading column for any other column
6003            // type so v7.9.26b's `pg_dump` compatibility (GIN on
6004            // JSONB etc. silently loading as BTree) is preserved.
6005            // Operators see the real GIN only where it matters; old
6006            // schemas keep loading.
6007            IndexMethod::Gin => {
6008                if !included_positions.is_empty() {
6009                    return Err(EngineError::Unsupported(
6010                        "INCLUDE columns are not supported on GIN indexes".into(),
6011                    ));
6012                }
6013                let col_pos = table
6014                    .schema()
6015                    .column_position(&stmt.column)
6016                    .ok_or_else(|| {
6017                        EngineError::Storage(StorageError::ColumnNotFound {
6018                            column: stmt.column.clone(),
6019                        })
6020                    })?;
6021                let col_ty = table.schema().columns[col_pos].ty;
6022                // v7.15.0 — `gin_trgm_ops` on a TEXT/VARCHAR
6023                // column dispatches to the real trigram-shingle
6024                // GIN build (LIKE / similarity acceleration).
6025                // Other GIN opclasses fall through to the regular
6026                // tsvector-vs-BTree split below.
6027                let is_trgm = stmt
6028                    .opclass
6029                    .as_deref()
6030                    .is_some_and(|op| op.eq_ignore_ascii_case("gin_trgm_ops"));
6031                if is_trgm
6032                    && matches!(
6033                        col_ty,
6034                        spg_storage::DataType::Text | spg_storage::DataType::Varchar(_)
6035                    )
6036                {
6037                    table
6038                        .add_gin_trgm_index(stmt.name.clone(), &stmt.column)
6039                        .map_err(EngineError::Storage)?;
6040                } else if col_ty == spg_storage::DataType::TsVector {
6041                    table
6042                        .add_gin_index(stmt.name.clone(), &stmt.column)
6043                        .map_err(EngineError::Storage)?;
6044                } else {
6045                    // v7.9.26b BTree fallback — the catalog still
6046                    // gets an index entry on the leading column so
6047                    // pg_dump scripts that name GIN on JSONB / etc.
6048                    // load clean; query-time gain stays opt-in for
6049                    // tsvector callers.
6050                    table.add_index(stmt.name.clone(), &stmt.column)?;
6051                }
6052            }
6053        }
6054        if !included_positions.is_empty()
6055            && let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name)
6056        {
6057            idx.included_columns = included_positions;
6058        }
6059        // v6.8.1 — persist partial-index predicate. Stored as the
6060        // expression's Display form so the catalog snapshot stays
6061        // pure (storage has no spg-sql dependency). The runtime
6062        // maintenance path treats partial indexes identically to
6063        // full indexes for v6.8.1 (over-maintenance is safe; the
6064        // planner-side "use partial when query WHERE implies the
6065        // predicate" pass is STABILITY carve-out).
6066        if let Some(pred_expr) = &stmt.partial_predicate {
6067            let canonical = pred_expr.to_string();
6068            // v7.13.2 — mailrs round-6 S2. PG's `pg_trgm` uses
6069            // `CREATE INDEX … USING gin(col gin_trgm_ops) WHERE …`
6070            // routinely to slim trigram indexes. SPG now persists
6071            // the predicate for GIN / BRIN / HNSW the same way it
6072            // already does for BTree — same v6.8.1 "over-maintain
6073            // is safe; planner-side partial routing is STABILITY
6074            // carve-out" semantics. HNSW carries an additional
6075            // caveat: the predicate isn't applied at index build
6076            // time (would require per-row eval inside the NSW
6077            // construction loop), so the index oversamples; query
6078            // time the WHERE clause still filters correctly.
6079            if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
6080                idx.partial_predicate = Some(canonical);
6081            }
6082        }
6083        // v6.8.2 — persist expression index key. Same Display-form
6084        // storage; the runtime maintenance pass evaluates each
6085        // row's expression to derive the index key, but for v6.8.2
6086        // the engine falls through to the bare-column-reference
6087        // path and the expression is preserved for format-layer
6088        // round-trip + future planner work. Carved-out in
6089        // STABILITY § "Out of v6.8".
6090        if let Some(key_expr) = &stmt.expression {
6091            if matches!(
6092                stmt.method,
6093                IndexMethod::Hnsw | IndexMethod::Brin | IndexMethod::Gin
6094            ) {
6095                return Err(EngineError::Unsupported(
6096                    "Expression keys are not supported on HNSW or BRIN indexes".into(),
6097                ));
6098            }
6099            let canonical = key_expr.to_string();
6100            if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
6101                idx.expression = Some(canonical);
6102            }
6103        }
6104        // v7.9.29 — persist `is_unique` flag on the storage Index.
6105        // Combined with `partial_predicate`, INSERT enforcement
6106        // checks that no other row whose predicate evaluates true
6107        // shares the same indexed key. Parser already rejected
6108        // `UNIQUE` on HNSW / BRIN, so plain BTree here.
6109        // For multi-column UNIQUE INDEX the extras matter (the
6110        // full tuple is the uniqueness key), so resolve them to
6111        // column positions and persist on the index too.
6112        if stmt.is_unique {
6113            let mut extra_positions: alloc::vec::Vec<usize> = alloc::vec::Vec::new();
6114            for col_name in &stmt.extra_columns {
6115                let pos = table
6116                    .schema()
6117                    .columns
6118                    .iter()
6119                    .position(|c| c.name.eq_ignore_ascii_case(col_name))
6120                    .ok_or_else(|| {
6121                        EngineError::Unsupported(alloc::format!(
6122                            "UNIQUE INDEX {:?}: extra column {col_name:?} not in table {:?}",
6123                            stmt.name,
6124                            stmt.table
6125                        ))
6126                    })?;
6127                extra_positions.push(pos);
6128            }
6129            if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
6130                idx.is_unique = true;
6131                idx.extra_column_positions = extra_positions;
6132            }
6133            // At index-creation time, check the existing rows for
6134            // pre-existing duplicates that would have violated the
6135            // new constraint — otherwise CREATE UNIQUE INDEX would
6136            // silently leave duplicates in place.
6137            let snapshot_indices = table.indices().to_vec();
6138            let snapshot_rows: alloc::vec::Vec<spg_storage::Row> =
6139                table.rows().iter().cloned().collect();
6140            let snapshot_schema = table.schema().clone();
6141            let idx_ref = snapshot_indices
6142                .iter()
6143                .find(|i| i.name == stmt.name)
6144                .expect("just-added index");
6145            check_existing_unique_violation(idx_ref, &snapshot_schema, &snapshot_rows)?;
6146        }
6147        // v6.3.1 — adding an index can change the optimal plan for
6148        // any cached query that references this table.
6149        self.plan_cache.evict_referencing(&table_name);
6150        Ok(QueryResult::CommandOk {
6151            affected: 0,
6152            modified_catalog: !self.in_transaction(),
6153        })
6154    }
6155
6156    /// v7.13.3 — mailrs round-7 S9. SPG-specific reconciliation
6157    /// for `CREATE TABLE IF NOT EXISTS` when the table already
6158    /// exists. Adds missing columns + inline FKs from the new
6159    /// definition; existing columns / constraints stay untouched.
6160    /// New columns with a `NOT NULL` declaration without a
6161    /// `DEFAULT` are reported as a clear error rather than
6162    /// silently dropped — this is the "fail loud on real
6163    /// incompatibility, fail silent on schema-superset" tradeoff.
6164    fn reconcile_table_if_not_exists(
6165        &mut self,
6166        stmt: CreateTableStatement,
6167    ) -> Result<QueryResult, EngineError> {
6168        let table_name = stmt.name.clone();
6169        let clock = self.clock;
6170        let existing_col_names: alloc::collections::BTreeSet<String> = self
6171            .active_catalog()
6172            .get(&table_name)
6173            .expect("checked above")
6174            .schema()
6175            .columns
6176            .iter()
6177            .map(|c| c.name.to_ascii_lowercase())
6178            .collect();
6179        let row_count = self
6180            .active_catalog()
6181            .get(&table_name)
6182            .expect("checked above")
6183            .row_count();
6184        // Collect missing column defs in source order.
6185        let new_columns: alloc::vec::Vec<spg_sql::ast::ColumnDef> = stmt
6186            .columns
6187            .iter()
6188            .filter(|c| !existing_col_names.contains(&c.name.to_ascii_lowercase()))
6189            .cloned()
6190            .collect();
6191        for col_def in new_columns {
6192            let col_name = col_def.name.clone();
6193            let nullable = col_def.nullable;
6194            let has_default = col_def.default.is_some() || col_def.auto_increment;
6195            let col_schema = column_def_to_schema(col_def)?;
6196            let fill_value: Value = if has_default || col_schema.runtime_default.is_some() {
6197                resolve_column_default_free(&col_schema, clock)?
6198            } else if nullable || row_count == 0 {
6199                Value::Null
6200            } else {
6201                return Err(EngineError::Unsupported(alloc::format!(
6202                    "CREATE TABLE IF NOT EXISTS {table_name:?}: reconciling \
6203                     column {col_name:?} requires DEFAULT (existing rows would violate NOT NULL)"
6204                )));
6205            };
6206            let table = self
6207                .active_catalog_mut()
6208                .get_mut(&table_name)
6209                .expect("checked above");
6210            table.add_column(col_schema, fill_value);
6211        }
6212        // Resolve any newly-added inline FKs (column-level
6213        // REFERENCES forms) and install. Skip FKs whose local
6214        // columns we didn't have in the existing table.
6215        let table_cols_now = self
6216            .active_catalog()
6217            .get(&table_name)
6218            .expect("checked above")
6219            .schema()
6220            .columns
6221            .clone();
6222        for fk in stmt.foreign_keys {
6223            // Only install FKs whose every local column resolves
6224            // — older catalogs may have a column the new FK
6225            // references but not the column the new FK declares.
6226            let all_resolved = fk.columns.iter().all(|c| {
6227                table_cols_now
6228                    .iter()
6229                    .any(|sc| sc.name.eq_ignore_ascii_case(c))
6230            });
6231            if !all_resolved {
6232                continue;
6233            }
6234            let already_present = {
6235                let table = self
6236                    .active_catalog()
6237                    .get(&table_name)
6238                    .expect("checked above");
6239                table.schema().foreign_keys.iter().any(|f| {
6240                    f.parent_table.eq_ignore_ascii_case(&fk.parent_table)
6241                        && f.local_columns.len() == fk.columns.len()
6242                })
6243            };
6244            if already_present {
6245                continue;
6246            }
6247            let storage_fk =
6248                resolve_foreign_key(&table_name, &table_cols_now, fk, self.active_catalog())?;
6249            let table = self
6250                .active_catalog_mut()
6251                .get_mut(&table_name)
6252                .expect("checked above");
6253            table.schema_mut().foreign_keys.push(storage_fk);
6254        }
6255        Ok(QueryResult::CommandOk {
6256            affected: 0,
6257            modified_catalog: !self.in_transaction(),
6258        })
6259    }
6260
6261    /// v7.14.0 — DROP TABLE handler (pg_dump / mysqldump preamble).
6262    fn exec_drop_table(
6263        &mut self,
6264        names: Vec<String>,
6265        if_exists: bool,
6266    ) -> Result<QueryResult, EngineError> {
6267        for name in names {
6268            let dropped = self.active_catalog_mut().drop_table(&name);
6269            if !dropped && !if_exists {
6270                return Err(EngineError::Storage(StorageError::TableNotFound { name }));
6271            }
6272        }
6273        Ok(QueryResult::CommandOk {
6274            affected: 0,
6275            modified_catalog: !self.in_transaction(),
6276        })
6277    }
6278
6279    /// v7.14.0 — DROP INDEX handler.
6280    fn exec_drop_index(
6281        &mut self,
6282        name: String,
6283        if_exists: bool,
6284    ) -> Result<QueryResult, EngineError> {
6285        let dropped = self.active_catalog_mut().drop_named_index(&name);
6286        if !dropped && !if_exists {
6287            return Err(EngineError::Storage(StorageError::IndexNotFound { name }));
6288        }
6289        Ok(QueryResult::CommandOk {
6290            affected: 0,
6291            modified_catalog: !self.in_transaction(),
6292        })
6293    }
6294
6295    fn exec_create_table(
6296        &mut self,
6297        stmt: CreateTableStatement,
6298    ) -> Result<QueryResult, EngineError> {
6299        if stmt.if_not_exists && self.active_catalog().get(&stmt.name).is_some() {
6300            // v7.16.2 — PG-strict silent no-op (mailrs round-10
6301            // surfaced this). v7.13.3's "reconcile by adding
6302            // missing columns" was friendly for mailrs round-7
6303            // where init-schema's `contacts` and migrate-023's
6304            // CardDAV `contacts` collided; but it ALSO silently
6305            // added columns to existing tables when later
6306            // migrations had a duplicate `CREATE TABLE IF NOT
6307            // EXISTS <t> (different-shape-cols)` shape. mailrs's
6308            // migrate-030 has exactly that — re-declares
6309            // system_config with `key` even though init-schema
6310            // already created it with `config_key`. PG's silent
6311            // no-op leaves system_config at `config_key`;
6312            // v7.13.3 added a phantom `key` column that then
6313            // tripped migrate-040's idempotent rename guard.
6314            // mailrs v1.7.106 ships the proper PG-style
6315            // contacts rename via DO + IF EXISTS, so SPG can
6316            // revert to PG-strict here without re-breaking the
6317            // round-7 case.
6318            return Ok(QueryResult::CommandOk {
6319                affected: 0,
6320                modified_catalog: false,
6321            });
6322        }
6323        let table_name = stmt.name.clone();
6324        // v7.9.13 — pluck the names of any columns marked
6325        // `PRIMARY KEY` inline so the post-create-table pass can
6326        // build an implicit BTree index. mailrs F1.
6327        let inline_pk_columns: Vec<String> = stmt
6328            .columns
6329            .iter()
6330            .filter(|c| c.is_primary_key)
6331            .map(|c| c.name.clone())
6332            .collect();
6333        // v7.9.19 — table-level constraints: PRIMARY KEY (a, b, ...)
6334        // and UNIQUE (a, b, ...). Each builds a BTree index on the
6335        // leading column (the existing single-column storage tier)
6336        // and registers a UniquenessConstraint on the schema for
6337        // INSERT-time enforcement of the full tuple. mailrs G1/G6.
6338        let cols = stmt
6339            .columns
6340            .into_iter()
6341            .map(column_def_to_schema)
6342            .collect::<Result<Vec<_>, _>>()?;
6343        // v7.17.0 Phase 1.4 + 1.5 — classify every raw
6344        // user_type_ref (parked as user_enum_type by
6345        // column_def_to_schema) into either an enum binding or a
6346        // domain binding. For domains, also rewrite the column's
6347        // base DataType from the placeholder Text to the domain's
6348        // declared base. Unknown idents are still a hard error
6349        // here (same as Phase 1.4) so silent acceptance never
6350        // happens.
6351        let mut cols = cols;
6352        for col in cols.iter_mut() {
6353            let Some(name) = col.user_enum_type.take() else {
6354                continue;
6355            };
6356            let cat = self.active_catalog();
6357            if cat.enum_types().contains_key(&name) {
6358                col.user_enum_type = Some(name);
6359                continue;
6360            }
6361            if let Some(dom) = cat.domain_types().get(&name) {
6362                col.ty = dom.base_type;
6363                col.user_domain_type = Some(name);
6364                if !dom.nullable {
6365                    col.nullable = false;
6366                }
6367                continue;
6368            }
6369            return Err(EngineError::Unsupported(alloc::format!(
6370                "column {:?}: unknown column type {:?} (not a built-in, ENUM, or DOMAIN)",
6371                col.name,
6372                name
6373            )));
6374        }
6375        for tc in &stmt.table_constraints {
6376            if let spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } = tc {
6377                for col_name in columns {
6378                    if let Some(col) = cols.iter_mut().find(|c| c.name == *col_name) {
6379                        col.nullable = false;
6380                    }
6381                }
6382            }
6383        }
6384        // v7.6.1 — resolve every FK in the statement against the
6385        // already-known catalog. Validates: parent table exists,
6386        // parent column names exist, arity matches, parent columns
6387        // have a PK / UNIQUE index. Self-referencing FKs (parent
6388        // table == this table) resolve against the column list we
6389        // just built — they don't need the catalog yet.
6390        let mut fks: Vec<spg_storage::ForeignKeyConstraint> =
6391            Vec::with_capacity(stmt.foreign_keys.len());
6392        for fk in stmt.foreign_keys {
6393            // v7.14.0 — when SET FOREIGN_KEY_CHECKS=0 is in effect
6394            // (mysqldump preamble + bulk imports), defer FK
6395            // resolution if the parent table isn't in the catalog
6396            // yet. The FK is queued and resolved when checks flip
6397            // back on. Self-references stay in-band (the parent is
6398            // the same as the child we're building).
6399            let needs_parent = !fk.parent_table.eq_ignore_ascii_case(&table_name);
6400            if !self.foreign_key_checks
6401                && needs_parent
6402                && self.active_catalog().get(&fk.parent_table).is_none()
6403            {
6404                self.pending_foreign_keys.push((table_name.clone(), fk));
6405                continue;
6406            }
6407            fks.push(resolve_foreign_key(
6408                &table_name,
6409                &cols,
6410                fk,
6411                self.active_catalog(),
6412            )?);
6413        }
6414        let mut schema = TableSchema::new(table_name.clone(), cols);
6415        schema.foreign_keys = fks;
6416        // v7.9.19 — translate AST table_constraints to storage
6417        // UniquenessConstraints (column name → position) so the
6418        // INSERT enforcement helper sees positions directly.
6419        let mut uc_storage: Vec<spg_storage::UniquenessConstraint> = Vec::new();
6420        let mut check_exprs: Vec<String> = Vec::new();
6421        for tc in &stmt.table_constraints {
6422            let (is_pk, names, nnd) = match tc {
6423                spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } => {
6424                    (true, columns.clone(), false)
6425                }
6426                spg_sql::ast::TableConstraint::Unique {
6427                    columns,
6428                    nulls_not_distinct,
6429                    ..
6430                } => (false, columns.clone(), *nulls_not_distinct),
6431                spg_sql::ast::TableConstraint::Check { expr, .. } => {
6432                    // v7.13.0 — collect CHECK predicate sources;
6433                    // they get attached to the schema below.
6434                    check_exprs.push(alloc::format!("{expr}"));
6435                    continue;
6436                }
6437                // v7.15.0 — plain `KEY (cols)` from MySQL inline
6438                // is NOT a uniqueness constraint; skip the UC
6439                // build path entirely. The BTree index lands in
6440                // the post-create loop below alongside the PK/UQ
6441                // implicit indexes.
6442                spg_sql::ast::TableConstraint::Index { .. } => continue,
6443                // v7.17.0 Phase 2.2 — MySQL FULLTEXT KEY is not
6444                // a uniqueness constraint either; its GIN gets
6445                // built in the post-create loop below.
6446                spg_sql::ast::TableConstraint::FulltextIndex { .. } => continue,
6447            };
6448            let mut positions = Vec::with_capacity(names.len());
6449            for n in &names {
6450                let pos = schema
6451                    .columns
6452                    .iter()
6453                    .position(|c| c.name == *n)
6454                    .ok_or_else(|| {
6455                        EngineError::Unsupported(alloc::format!(
6456                            "table constraint references unknown column {n:?}"
6457                        ))
6458                    })?;
6459                positions.push(pos);
6460            }
6461            uc_storage.push(spg_storage::UniquenessConstraint {
6462                is_primary_key: is_pk,
6463                columns: positions,
6464                nulls_not_distinct: nnd,
6465            });
6466        }
6467        schema.uniqueness_constraints = uc_storage.clone();
6468        schema.checks = check_exprs;
6469        self.active_catalog_mut().create_table(schema)?;
6470        // v7.9.13 — implicit BTree per inline PK column +
6471        // v7.9.19 — implicit BTree on the leading column of every
6472        // table-level PRIMARY KEY / UNIQUE constraint.
6473        let table = self
6474            .active_catalog_mut()
6475            .get_mut(&table_name)
6476            .expect("just created");
6477        for (i, col_name) in inline_pk_columns.iter().enumerate() {
6478            let idx_name = if inline_pk_columns.len() == 1 {
6479                alloc::format!("{table_name}_pkey")
6480            } else {
6481                alloc::format!("{table_name}_pkey_{i}")
6482            };
6483            if let Err(e) = table.add_index(idx_name, col_name) {
6484                return Err(EngineError::Storage(e));
6485            }
6486        }
6487        for (i, tc) in stmt.table_constraints.iter().enumerate() {
6488            // v7.17.0 Phase 2.2 — FULLTEXT KEY lands a real
6489            // tsvector-GIN per declared column instead of the
6490            // BTree the PK / UQ / KEY paths build. Branch early
6491            // so the BTree loop never sees the FULLTEXT shape.
6492            if let spg_sql::ast::TableConstraint::FulltextIndex { name, columns } = tc {
6493                for (k, col) in columns.iter().enumerate() {
6494                    let already = table.indices().iter().any(|idx| {
6495                        matches!(idx.kind, spg_storage::IndexKind::GinFulltext(_))
6496                            && table.schema().columns[idx.column_position].name == *col
6497                    });
6498                    if already {
6499                        continue;
6500                    }
6501                    let idx_name = match (name.as_ref(), columns.len(), k) {
6502                        (Some(n), 1, _) => n.clone(),
6503                        (Some(n), _, k) => alloc::format!("{n}_{k}"),
6504                        (None, _, _) => {
6505                            alloc::format!("{table_name}_{col}_ftidx")
6506                        }
6507                    };
6508                    if let Err(e) = table.add_gin_fulltext_index(idx_name, col) {
6509                        return Err(EngineError::Storage(e));
6510                    }
6511                }
6512                continue;
6513            }
6514            // v7.15.0 — plain KEY/INDEX rides this same loop so
6515            // the implicit BTree gets built. It carries its own
6516            // user-supplied name; PK/UQ still synthesise.
6517            let (suffix, names, explicit_name): (&str, &Vec<String>, Option<&String>) = match tc {
6518                spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } => {
6519                    ("pkey", columns, None)
6520                }
6521                spg_sql::ast::TableConstraint::Unique { columns, .. } => ("key", columns, None),
6522                spg_sql::ast::TableConstraint::Index { name, columns } => {
6523                    ("idx", columns, name.as_ref())
6524                }
6525                spg_sql::ast::TableConstraint::Check { .. } => continue,
6526                // Handled by the early-branch above.
6527                spg_sql::ast::TableConstraint::FulltextIndex { .. } => continue,
6528            };
6529            let leading = &names[0];
6530            // Skip if a same-column BTree already exists (e.g.
6531            // inline PK on the leading column).
6532            let already = table.indices().iter().any(|idx| {
6533                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
6534                    && table.schema().columns[idx.column_position].name == *leading
6535            });
6536            if already {
6537                continue;
6538            }
6539            let idx_name = if let Some(n) = explicit_name {
6540                n.clone()
6541            } else if names.len() == 1 {
6542                alloc::format!("{table_name}_{leading}_{suffix}")
6543            } else {
6544                alloc::format!("{table_name}_{leading}_{suffix}_{i}")
6545            };
6546            if let Err(e) = table.add_index(idx_name, leading) {
6547                return Err(EngineError::Storage(e));
6548            }
6549        }
6550        Ok(QueryResult::CommandOk {
6551            affected: 0,
6552            modified_catalog: !self.in_transaction(),
6553        })
6554    }
6555
6556    fn exec_insert(&mut self, mut stmt: InsertStatement) -> Result<QueryResult, EngineError> {
6557        // v7.17.0 Phase 1.1 — pre-resolve any nextval / currval /
6558        // setval calls against the catalog before the row loop. We
6559        // walk each tuple expression and replace matching
6560        // FunctionCall nodes with their concrete Literal. This
6561        // keeps `literal_expr_to_value` free of `&mut self` and
6562        // lets multi-row INSERT VALUES (… nextval('seq') …)
6563        // mint a separate sequence value per row.
6564        for tuple in &mut stmt.rows {
6565            for cell in tuple.iter_mut() {
6566                self.resolve_sequence_calls_in_expr(cell)?;
6567            }
6568        }
6569        // v7.13.0 — `INSERT INTO t [(cols)] SELECT …` (mailrs
6570        // round-5 G4). Execute the inner SELECT first, then route
6571        // back through the regular VALUES code path with the
6572        // materialised rows.
6573        if let Some(select) = stmt.select_source.clone() {
6574            let select_result = self.exec_select_cancel(&select, CancelToken::none())?;
6575            let rows = match select_result {
6576                QueryResult::Rows { rows, .. } => rows,
6577                other => {
6578                    return Err(EngineError::Unsupported(alloc::format!(
6579                        "INSERT … SELECT: inner statement produced {other:?} instead of a row set"
6580                    )));
6581                }
6582            };
6583            let mut materialised: Vec<Vec<Expr>> = Vec::with_capacity(rows.len());
6584            for row in rows {
6585                let mut tuple: Vec<Expr> = Vec::with_capacity(row.values.len());
6586                for v in row.values {
6587                    tuple.push(value_to_literal_expr_permissive(v)?);
6588                }
6589                materialised.push(tuple);
6590            }
6591            let recurse = InsertStatement {
6592                table: stmt.table,
6593                columns: stmt.columns,
6594                rows: materialised,
6595                select_source: None,
6596                on_conflict: stmt.on_conflict,
6597                returning: stmt.returning,
6598            };
6599            return self.exec_insert(recurse);
6600        }
6601        // v7.9.21 — snapshot the clock fn pointer before the mut
6602        // borrow on the catalog opens; runtime DEFAULT eval needs
6603        // it inside the row hot loop.
6604        let clock = self.clock;
6605        // v7.12.4 — snapshot row-level triggers + their referenced
6606        // functions before the mut borrow on the catalog opens.
6607        // Cloned out so the row hot loop can fire them without
6608        // re-borrowing the catalog (which would conflict with
6609        // table.insert's mutable borrow).
6610        let before_insert_triggers = self.snapshot_row_triggers(&stmt.table, "INSERT", "BEFORE");
6611        let after_insert_triggers = self.snapshot_row_triggers(&stmt.table, "INSERT", "AFTER");
6612        let trigger_session_cfg: Option<alloc::string::String> = self
6613            .session_params
6614            .get("default_text_search_config")
6615            .cloned();
6616        // v7.17.0 Phase 1.4 — snapshot the enum label lookup BEFORE
6617        // opening the mutable borrow on the table below. We need
6618        // catalog-level read access (enum_types lives at the
6619        // catalog level, not the table) and the upcoming mutable
6620        // borrow shadows it.
6621        let pre_borrow_column_meta: Vec<ColumnSchema> = {
6622            let preview_table = self.active_catalog().get(&stmt.table).ok_or_else(|| {
6623                EngineError::Storage(StorageError::TableNotFound {
6624                    name: stmt.table.clone(),
6625                })
6626            })?;
6627            preview_table.schema().columns.clone()
6628        };
6629        let enum_label_lookup: alloc::collections::BTreeMap<usize, Vec<String>> =
6630            pre_borrow_column_meta
6631                .iter()
6632                .enumerate()
6633                .filter_map(|(i, col)| {
6634                    // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM
6635                    // variant lists take priority over the PG
6636                    // catalog enum_types lookup (they're
6637                    // column-local and authoritative when set).
6638                    if let Some(inline) = &col.inline_enum_variants {
6639                        return Some((i, inline.clone()));
6640                    }
6641                    col.user_enum_type.as_ref().and_then(|ename| {
6642                        self.active_catalog()
6643                            .enum_types()
6644                            .get(ename)
6645                            .map(|e| (i, e.labels.clone()))
6646                    })
6647                })
6648                .collect();
6649        // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant lists.
6650        // Distinct from enum_label_lookup: SET validates that
6651        // every comma-separated token is in the variant list, and
6652        // canonicalises the cell to definition-order de-duped text.
6653        let set_variant_lookup: alloc::collections::BTreeMap<usize, Vec<String>> =
6654            pre_borrow_column_meta
6655                .iter()
6656                .enumerate()
6657                .filter_map(|(i, col)| col.inline_set_variants.as_ref().map(|vs| (i, vs.clone())))
6658                .collect();
6659        let table = self
6660            .active_catalog_mut()
6661            .get_mut(&stmt.table)
6662            .ok_or_else(|| {
6663                EngineError::Storage(StorageError::TableNotFound {
6664                    name: stmt.table.clone(),
6665                })
6666            })?;
6667        // v3.1.5: clone the columns vector only (not the whole
6668        // TableSchema — saves one String alloc for the table name).
6669        // We need an owned snapshot because we'll call `table.insert`
6670        // (mutable borrow on `table`) inside the row loop while
6671        // reading schema fields.
6672        let column_meta: Vec<ColumnSchema> = table.schema().columns.clone();
6673        let schema_cols_len = column_meta.len();
6674        // Build a permutation `tuple_pos[c] = Some(j)` meaning schema
6675        // column `c` is filled from the `j`-th tuple slot; `None` means
6676        // "fill with NULL". Validated once and reused for every row.
6677        let tuple_pos: Option<Vec<Option<usize>>> = match &stmt.columns {
6678            None => None, // 1-1 mapping, fast path
6679            Some(cols) => {
6680                let mut map = alloc::vec![None; schema_cols_len];
6681                for (j, name) in cols.iter().enumerate() {
6682                    let idx = column_meta
6683                        .iter()
6684                        .position(|c| c.name == *name)
6685                        .ok_or_else(|| {
6686                            EngineError::Eval(EvalError::ColumnNotFound { name: name.clone() })
6687                        })?;
6688                    if map[idx].is_some() {
6689                        return Err(EngineError::Storage(StorageError::ArityMismatch {
6690                            expected: schema_cols_len,
6691                            actual: cols.len(),
6692                        }));
6693                    }
6694                    map[idx] = Some(j);
6695                }
6696                // Omitted columns must either be nullable, carry a
6697                // DEFAULT, or be AUTO_INCREMENT. Catch NOT NULL
6698                // omissions up front so the WAL stays clean.
6699                for (i, col) in column_meta.iter().enumerate() {
6700                    if map[i].is_none()
6701                        && !col.nullable
6702                        && col.default.is_none()
6703                        && col.runtime_default.is_none()
6704                        && !col.auto_increment
6705                    {
6706                        return Err(EngineError::Storage(StorageError::NullInNotNull {
6707                            column: col.name.clone(),
6708                        }));
6709                    }
6710                }
6711                Some(map)
6712            }
6713        };
6714        let expected_tuple_len = stmt.columns.as_ref().map_or(schema_cols_len, Vec::len);
6715        // v7.6.2 — snapshot this table's FK list before the
6716        // mutable-borrow window so we can run parent lookups
6717        // against the immutable catalog after parsing. Empty vec is
6718        // the no-FK fast path; clone cost is O(fks * arity) which
6719        // is < 100 ns for typical schemas.
6720        let fks = table.schema().foreign_keys.clone();
6721        let mut affected = 0usize;
6722        // Stage 1 — parse + AUTO_INC + coerce all rows under the
6723        // single mutable borrow.
6724        let mut all_values: Vec<Vec<Value>> = Vec::with_capacity(stmt.rows.len());
6725        for tuple in stmt.rows {
6726            if tuple.len() != expected_tuple_len {
6727                return Err(EngineError::Storage(StorageError::ArityMismatch {
6728                    expected: expected_tuple_len,
6729                    actual: tuple.len(),
6730                }));
6731            }
6732            // Fast path: no column-list permutation → tuple slot j
6733            // maps to schema column j. We can zip schema with tuple
6734            // and skip the `raw_tuple` staging allocation entirely.
6735            let values: Vec<Value> = if let Some(map) = &tuple_pos {
6736                // Permuted path: still need raw_tuple to index by `map[i]`.
6737                let raw_tuple: Vec<Value> = tuple
6738                    .into_iter()
6739                    .map(literal_expr_to_value)
6740                    .collect::<Result<_, _>>()?;
6741                let mut out = Vec::with_capacity(schema_cols_len);
6742                for (i, col) in column_meta.iter().enumerate() {
6743                    let mut raw = match map[i] {
6744                        Some(j) => raw_tuple[j].clone(),
6745                        None => resolve_column_default_free(col, clock)?,
6746                    };
6747                    if col.auto_increment && raw.is_null() {
6748                        let next = table.next_auto_value(i).ok_or_else(|| {
6749                            EngineError::Unsupported(alloc::format!(
6750                                "AUTO_INCREMENT applies to integer columns only (column `{}`)",
6751                                col.name
6752                            ))
6753                        })?;
6754                        raw = Value::BigInt(next);
6755                    }
6756                    let coerced = coerce_value(raw, col.ty, &col.name, i)?;
6757                    enforce_enum_label(&enum_label_lookup, i, &col.name, &coerced)?;
6758                    let coerced =
6759                        canonicalize_set_value(&set_variant_lookup, i, &col.name, coerced)?;
6760                    check_unsigned_range(&coerced, col, i)?;
6761                    out.push(coerced);
6762                }
6763                out
6764            } else {
6765                // 1-1 mapping fast path: single Vec alloc, no raw_tuple.
6766                let mut out = Vec::with_capacity(schema_cols_len);
6767                for (i, (col, expr)) in column_meta.iter().zip(tuple).enumerate() {
6768                    let mut raw = literal_expr_to_value(expr)?;
6769                    if col.auto_increment && raw.is_null() {
6770                        let next = table.next_auto_value(i).ok_or_else(|| {
6771                            EngineError::Unsupported(alloc::format!(
6772                                "AUTO_INCREMENT applies to integer columns only (column `{}`)",
6773                                col.name
6774                            ))
6775                        })?;
6776                        raw = Value::BigInt(next);
6777                    }
6778                    let coerced = coerce_value(raw, col.ty, &col.name, i)?;
6779                    enforce_enum_label(&enum_label_lookup, i, &col.name, &coerced)?;
6780                    let coerced =
6781                        canonicalize_set_value(&set_variant_lookup, i, &col.name, coerced)?;
6782                    check_unsigned_range(&coerced, col, i)?;
6783                    out.push(coerced);
6784                }
6785                out
6786            };
6787            all_values.push(values);
6788        }
6789        // Stage 2 — FK enforcement on the immutable catalog.
6790        // Non-lexical lifetimes release the mutable borrow on
6791        // `table` here since stage 1 was the last use. The
6792        // parent-table lookup runs before any row is committed.
6793        let uniqueness = table.schema().uniqueness_constraints.clone();
6794        let _ = table;
6795        if !fks.is_empty() {
6796            enforce_fk_inserts(self.active_catalog(), &stmt.table, &fks, &all_values)?;
6797        }
6798        // v7.13.0 — CHECK constraint enforcement (mailrs round-5 G3).
6799        enforce_check_constraints(self.active_catalog(), &stmt.table, &all_values)?;
6800        // NOTE (mailrs embed round-12): UNIQUE / PRIMARY KEY and
6801        // UNIQUE INDEX enforcement moved BELOW the ON CONFLICT
6802        // resolution pass. Running them first made every
6803        // `ON CONFLICT … DO UPDATE` upsert fail with a uniqueness
6804        // violation before the conflict handler could route the row
6805        // to an UPDATE — PG resolves the conflict action first and
6806        // only errors on rows no arbiter matched.
6807        // v7.9.8 / v7.9.9 — ON CONFLICT handling.
6808        //   - `DO NOTHING` filters `all_values` to non-conflicting
6809        //     rows + drops within-batch duplicates.
6810        //   - `DO UPDATE SET …` ALSO filters, but for each
6811        //     conflicting row it queues an UPDATE on the existing
6812        //     row using the incoming row's values as `EXCLUDED.*`.
6813        let mut pending_updates: Vec<(usize, Vec<Value>)> = Vec::new();
6814        let mut skipped_count = 0usize;
6815        if let Some(clause) = &stmt.on_conflict {
6816            let conflict_cols = resolve_on_conflict_columns(
6817                self.active_catalog(),
6818                &stmt.table,
6819                clause.target_columns.as_slice(),
6820            )?;
6821            let mut kept: Vec<Vec<Value>> = Vec::with_capacity(all_values.len());
6822            let mut seen_keys: Vec<Vec<Value>> = Vec::new();
6823            for values in all_values {
6824                let key_tuple: Vec<&Value> = conflict_cols.iter().map(|&c| &values[c]).collect();
6825                // SQL spec: NULL in any conflict column means "no
6826                // conflict possible" (NULL ≠ NULL for uniqueness).
6827                let has_null_key = key_tuple.iter().any(|v| matches!(v, Value::Null));
6828                let collides_with_table = !has_null_key
6829                    && on_conflict_keys_exist(
6830                        self.active_catalog(),
6831                        &stmt.table,
6832                        &conflict_cols,
6833                        &key_tuple,
6834                    );
6835                let key_tuple_owned: Vec<Value> = key_tuple.iter().map(|v| (*v).clone()).collect();
6836                let collides_with_batch =
6837                    !has_null_key && seen_keys.iter().any(|k| k == &key_tuple_owned);
6838                let collides = collides_with_table || collides_with_batch;
6839                match (&clause.action, collides) {
6840                    (_, false) => {
6841                        seen_keys.push(key_tuple_owned);
6842                        kept.push(values);
6843                    }
6844                    (spg_sql::ast::OnConflictAction::Nothing, true) => {
6845                        skipped_count += 1;
6846                    }
6847                    (
6848                        spg_sql::ast::OnConflictAction::Update {
6849                            assignments,
6850                            where_,
6851                        },
6852                        true,
6853                    ) => {
6854                        if !collides_with_table {
6855                            skipped_count += 1;
6856                            continue;
6857                        }
6858                        let target_pos = lookup_row_position_by_keys(
6859                            self.active_catalog(),
6860                            &stmt.table,
6861                            &conflict_cols,
6862                            &key_tuple,
6863                        )
6864                        .ok_or_else(|| {
6865                            EngineError::Unsupported(
6866                                "ON CONFLICT DO UPDATE: conflict detected but row \
6867                                 position could not be resolved (cold-tier row?)"
6868                                    .into(),
6869                            )
6870                        })?;
6871                        let updated = apply_on_conflict_assignments(
6872                            self.active_catalog(),
6873                            &stmt.table,
6874                            target_pos,
6875                            &values,
6876                            assignments,
6877                            where_.as_ref(),
6878                        )?;
6879                        if let Some(new_row) = updated {
6880                            pending_updates.push((target_pos, new_row));
6881                        } else {
6882                            skipped_count += 1;
6883                        }
6884                    }
6885                }
6886            }
6887            all_values = kept;
6888        }
6889        // v7.9.19 — composite UNIQUE / PRIMARY KEY enforcement.
6890        // v7.9.29 — CREATE UNIQUE INDEX [WHERE pred] enforcement.
6891        // Both run on the post-ON-CONFLICT row set: conflicting rows
6892        // already left `all_values` (DO NOTHING drop / DO UPDATE
6893        // reroute), so what remains must be genuinely unique.
6894        enforce_uniqueness_inserts(self.active_catalog(), &stmt.table, &uniqueness, &all_values)?;
6895        enforce_unique_index_inserts(self.active_catalog(), &stmt.table, &all_values)?;
6896        // Stage 3 — insert all rows under a fresh mutable borrow.
6897        let table = self
6898            .active_catalog_mut()
6899            .get_mut(&stmt.table)
6900            .ok_or_else(|| {
6901                EngineError::Storage(StorageError::TableNotFound {
6902                    name: stmt.table.clone(),
6903                })
6904            })?;
6905        // v7.9.4 — keep RETURNING projection rows separate per
6906        // INSERT and per UPDATE branch so DO UPDATE pushes the new
6907        // post-update state, not the incoming-only values.
6908        let mut returning_rows: Vec<Vec<Value>> = Vec::new();
6909        // v7.12.7 — collect embedded SQL emitted by any trigger
6910        // fire across the row loop; engine drains the queue after
6911        // the table mut borrow drops.
6912        let mut deferred_embedded: Vec<triggers::DeferredEmbeddedStmt> = Vec::new();
6913        'rowloop: for values in all_values {
6914            let mut row = Row::new(values);
6915            // v7.12.4 — BEFORE INSERT row-level triggers. Each
6916            // trigger may rewrite NEW cells (e.g. populate
6917            // `search_vector := to_tsvector(...)`) and may return
6918            // NULL to skip the row entirely.
6919            for fd in &before_insert_triggers {
6920                let (outcome, deferred) = triggers::fire_row_trigger(
6921                    fd,
6922                    Some(row.clone()),
6923                    None,
6924                    &stmt.table,
6925                    &column_meta,
6926                    &[],
6927                    trigger_session_cfg.as_deref(),
6928                    false,
6929                )
6930                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
6931                deferred_embedded.extend(deferred);
6932                match outcome {
6933                    triggers::TriggerOutcome::Row(r) => row = r,
6934                    triggers::TriggerOutcome::Skip => continue 'rowloop,
6935                }
6936            }
6937            if stmt.returning.is_some() {
6938                returning_rows.push(row.values.clone());
6939            }
6940            // v7.12.4 — clone for the AFTER trigger view; insert
6941            // moves the row into the table.
6942            let inserted = row.clone();
6943            table.insert(row)?;
6944            affected += 1;
6945            // v7.12.4 — AFTER INSERT row-level triggers fire post-
6946            // write. Return value is ignored (PG semantics); we
6947            // surface any error from the body up to the caller.
6948            for fd in &after_insert_triggers {
6949                let (_outcome, deferred) = triggers::fire_row_trigger(
6950                    fd,
6951                    Some(inserted.clone()),
6952                    None,
6953                    &stmt.table,
6954                    &column_meta,
6955                    &[],
6956                    trigger_session_cfg.as_deref(),
6957                    true,
6958                )
6959                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
6960                deferred_embedded.extend(deferred);
6961            }
6962        }
6963        // v7.9.9 — apply ON CONFLICT DO UPDATE rewrites collected
6964        // in the conflict-resolution pass. update_row handles
6965        // index maintenance + body re-encoding.
6966        for (pos, new_row) in pending_updates {
6967            if stmt.returning.is_some() {
6968                returning_rows.push(new_row.clone());
6969            }
6970            table.update_row(pos, new_row)?;
6971            affected += 1;
6972        }
6973        let _ = skipped_count;
6974        // v7.12.7 — drop the table mut borrow and drain any
6975        // trigger-emitted embedded SQL queued during this INSERT.
6976        // The borrow has to release first because each deferred
6977        // stmt may UPDATE / INSERT / DELETE the same (or another)
6978        // table — including, in principle, this one.
6979        let _ = table;
6980        self.execute_deferred_trigger_stmts(deferred_embedded, CancelToken::none())?;
6981        // v7.9.4/v7.9.9 — RETURNING streams the rows that ended
6982        // up in the table after this statement (insert or
6983        // post-update on conflict).
6984        if let Some(items) = &stmt.returning {
6985            return self.build_returning_rows(&stmt.table, items, returning_rows);
6986        }
6987        // v6.2.1 — auto-analyze: track per-table modified-row
6988        // counter so the background sweep can decide when to
6989        // re-ANALYZE. Cheap path on the autocommit-wrap hot loop
6990        // — one BTreeMap entry update per INSERT batch.
6991        if !self.in_transaction() && affected > 0 {
6992            self.statistics
6993                .record_modifications(&stmt.table, affected as u64);
6994        }
6995        Ok(QueryResult::CommandOk {
6996            affected,
6997            modified_catalog: !self.in_transaction(),
6998        })
6999    }
7000
7001    /// v4.5: SELECT with cooperative cancellation. The token is
7002    /// honoured between UNION peers and inside the bare-SELECT row
7003    /// loop; HNSW kNN graph walks and the aggregate executor don't
7004    /// honour it yet (deferred — those paths bound their work
7005    /// internally by `LIMIT k` and `GROUP BY` cardinality).
7006    /// v6.10.2 — cold-tier time-travel scan. Resolves the segment
7007    /// by id, decodes each row body against the table's current
7008    /// schema, applies the SELECT's projection + optional WHERE +
7009    /// optional LIMIT, returns a `Rows` result. JOINs / aggregates
7010    /// / ORDER BY are unsupported on this path (STABILITY carve-
7011    /// out); operators wanting them should restore the segment
7012    /// into a regular table first.
7013    fn exec_select_as_of_segment(
7014        &self,
7015        stmt: &SelectStatement,
7016        from: &spg_sql::ast::FromClause,
7017        segment_id: u32,
7018    ) -> Result<QueryResult, EngineError> {
7019        // v6.10.2 scope: no joins, no aggregates, no ORDER BY,
7020        // no GROUP BY / HAVING / UNION / OFFSET / DISTINCT.
7021        if !from.joins.is_empty()
7022            || stmt.group_by.is_some()
7023            || stmt.having.is_some()
7024            || !stmt.unions.is_empty()
7025            || !stmt.order_by.is_empty()
7026            || stmt.offset.is_some()
7027            || stmt.distinct
7028            || aggregate::uses_aggregate(stmt)
7029        {
7030            return Err(EngineError::Unsupported(
7031                "AS OF SEGMENT supports SELECT projection + WHERE + LIMIT only \
7032                 (joins / aggregates / ORDER BY are STABILITY § \"Out of v6.10\")"
7033                    .into(),
7034            ));
7035        }
7036        let table = self
7037            .active_catalog()
7038            .get(&from.primary.name)
7039            .ok_or_else(|| StorageError::TableNotFound {
7040                name: from.primary.name.clone(),
7041            })?;
7042        let schema = table.schema().clone();
7043        let schema_cols = &schema.columns;
7044        let alias = from
7045            .primary
7046            .alias
7047            .as_deref()
7048            .unwrap_or(from.primary.name.as_str());
7049        let ctx = EvalContext::new(schema_cols, Some(alias));
7050        let seg = self
7051            .active_catalog()
7052            .cold_segment(segment_id)
7053            .ok_or_else(|| {
7054                EngineError::Unsupported(alloc::format!(
7055                    "AS OF SEGMENT: cold segment {segment_id} not registered"
7056                ))
7057            })?;
7058        let mut out_rows: Vec<Row> = Vec::new();
7059        let mut limit_remaining: Option<usize> =
7060            stmt.limit_literal().and_then(|n| usize::try_from(n).ok());
7061        for (_key, body) in seg.scan() {
7062            let (row, _consumed) =
7063                spg_storage::decode_row_body_dense(&body, &schema).map_err(EngineError::Storage)?;
7064            if let Some(where_expr) = &stmt.where_ {
7065                let cond = self.eval_expr_simple(where_expr, &row, &ctx)?;
7066                if !matches!(cond, Value::Bool(true)) {
7067                    continue;
7068                }
7069            }
7070            // Projection.
7071            let projected = self.project_row_simple(&row, &stmt.items, schema_cols, alias)?;
7072            out_rows.push(projected);
7073            if let Some(rem) = limit_remaining.as_mut() {
7074                if *rem == 0 {
7075                    out_rows.pop();
7076                    break;
7077                }
7078                *rem -= 1;
7079            }
7080        }
7081        // Output column schema: derive from SELECT items.
7082        let columns = self.derive_output_columns(&stmt.items, schema_cols, alias);
7083        Ok(QueryResult::Rows {
7084            columns,
7085            rows: out_rows,
7086        })
7087    }
7088
7089    /// v6.10.2 — simple-path WHERE eval that doesn't go through
7090    /// the correlated-subquery / Memoize machinery. AS OF SEGMENT
7091    /// scan paths predicate against a snapshot frozen segment, no
7092    /// cross-row state.
7093    fn eval_expr_simple(
7094        &self,
7095        expr: &Expr,
7096        row: &Row,
7097        ctx: &EvalContext,
7098    ) -> Result<Value, EngineError> {
7099        let cancel = CancelToken::none();
7100        self.eval_expr_with_correlated(expr, row, ctx, cancel, None)
7101    }
7102
7103    /// v7.9.4 — INSERT / UPDATE / DELETE RETURNING projector.
7104    /// Given the table name, the user-supplied projection items,
7105    /// and the mutated rows (post-insert / post-update values, or
7106    /// pre-delete snapshot), build a `QueryResult::Rows` whose
7107    /// schema describes the projected columns. Mailrs migration
7108    /// blocker #1.
7109    fn build_returning_rows(
7110        &self,
7111        table_name: &str,
7112        items: &[SelectItem],
7113        mutated_rows: Vec<Vec<Value>>,
7114    ) -> Result<QueryResult, EngineError> {
7115        let table = self.active_catalog().get(table_name).ok_or_else(|| {
7116            EngineError::Storage(StorageError::TableNotFound {
7117                name: table_name.into(),
7118            })
7119        })?;
7120        let schema_cols = table.schema().columns.clone();
7121        let columns = self.derive_output_columns(items, &schema_cols, table_name);
7122        let mut out_rows: Vec<Row> = Vec::with_capacity(mutated_rows.len());
7123        for values in mutated_rows {
7124            let row = Row::new(values);
7125            let projected = self.project_row_simple(&row, items, &schema_cols, table_name)?;
7126            out_rows.push(projected);
7127        }
7128        Ok(QueryResult::Rows {
7129            columns,
7130            rows: out_rows,
7131        })
7132    }
7133
7134    /// v6.10.2 — projection for AS OF SEGMENT. Resolves
7135    /// `SelectItem::Wildcard` to all schema columns and
7136    /// `SelectItem::Expr` via the regular eval path.
7137    fn project_row_simple(
7138        &self,
7139        row: &Row,
7140        items: &[SelectItem],
7141        schema_cols: &[ColumnSchema],
7142        alias: &str,
7143    ) -> Result<Row, EngineError> {
7144        let ctx = EvalContext::new(schema_cols, Some(alias));
7145        let cancel = CancelToken::none();
7146        let mut out_vals = Vec::new();
7147        for item in items {
7148            match item {
7149                SelectItem::Wildcard => {
7150                    out_vals.extend(row.values.iter().cloned());
7151                }
7152                SelectItem::Expr { expr, .. } => {
7153                    let v = self.eval_expr_with_correlated(expr, row, &ctx, cancel, None)?;
7154                    out_vals.push(v);
7155                }
7156            }
7157        }
7158        Ok(Row::new(out_vals))
7159    }
7160
7161    /// v6.10.2 — derive the output `ColumnSchema` list for an
7162    /// AS OF SEGMENT projection. Wildcards take the full schema;
7163    /// expressions take the alias if present or a synthetic
7164    /// `?column?` (PG convention) otherwise.
7165    fn derive_output_columns(
7166        &self,
7167        items: &[SelectItem],
7168        schema_cols: &[ColumnSchema],
7169        _alias: &str,
7170    ) -> Vec<ColumnSchema> {
7171        let mut out = Vec::new();
7172        for item in items {
7173            match item {
7174                SelectItem::Wildcard => {
7175                    out.extend(schema_cols.iter().cloned());
7176                }
7177                SelectItem::Expr { expr, alias } => {
7178                    // Bare column references inherit the schema
7179                    // column's name + type — PG names `RETURNING id`
7180                    // "id" and types it BIGINT, and the sqlx embed
7181                    // path type-checks RowDescription against the
7182                    // Rust target (mailrs embed round-12).
7183                    if let Expr::Column(col) = expr
7184                        && let Some(sc) = schema_cols.iter().find(|c| c.name == col.name)
7185                    {
7186                        let name = alias.clone().unwrap_or_else(|| sc.name.clone());
7187                        out.push(ColumnSchema::new(name, sc.ty, sc.nullable));
7188                        continue;
7189                    }
7190                    let name = alias.clone().unwrap_or_else(|| "?column?".to_string());
7191                    // Default to Text; the caller's row values
7192                    // carry the actual type. v6.10.2 scope.
7193                    out.push(ColumnSchema::new(name, DataType::Text, true));
7194                }
7195            }
7196        }
7197        out
7198    }
7199
7200    fn exec_select_cancel(
7201        &self,
7202        stmt: &SelectStatement,
7203        cancel: CancelToken<'_>,
7204    ) -> Result<QueryResult, EngineError> {
7205        cancel.check()?;
7206        // v7.17.0 Phase 1.2 — user-defined VIEW expansion. If the
7207        // FROM / JOIN graph references any catalogued view name,
7208        // re-parse the view body and prepend it as a synthetic
7209        // CTE. Recurses on views-in-views via the regular CTE
7210        // dispatch below. Fast-path: skip the walker entirely when
7211        // the catalog has no views (the typical OLTP load).
7212        if !self.active_catalog().views().is_empty() {
7213            if let Some(rewritten) = self.expand_views_in_select(stmt)? {
7214                return self.exec_select_cancel(&rewritten, cancel);
7215            }
7216        }
7217        // v7.16.2 — information_schema / pg_catalog virtual
7218        // views (mailrs round-10 A.3). If the SELECT touches a
7219        // synthetic meta-table name (`__spg_info_*` /
7220        // `__spg_pg_*` — produced by the parser for
7221        // `information_schema.X` / `pg_catalog.X`), clone the
7222        // catalog, materialise the requested view as a real
7223        // temporary table, and re-execute against an enriched
7224        // engine. Same pattern as `exec_with_ctes` for CTEs.
7225        if !self.meta_views_materialised && select_references_meta_view(stmt) {
7226            return self.exec_select_with_meta_views(stmt, cancel);
7227        }
7228        // v6.10.2 — cold-tier time-travel short-circuit. When the
7229        // primary TableRef carries `AS OF SEGMENT '<id>'`, run a
7230        // dedicated cold-segment scan instead of the regular
7231        // hot+index path. The scope is intentionally narrow for
7232        // v6.10.2 — bare `SELECT * FROM <t> AS OF SEGMENT 'id'`,
7233        // optionally with a single-column-equality WHERE. JOINs /
7234        // aggregates / ORDER BY / subqueries on top of a time-
7235        // travelled scan are STABILITY § "Out of v6.10".
7236        if let Some(from) = &stmt.from
7237            && let Some(seg_id) = from.primary.as_of_segment
7238        {
7239            return self.exec_select_as_of_segment(stmt, from, seg_id);
7240        }
7241        // v6.2.0 / v6.5.0 — virtual-table short-circuits. Detected
7242        // pre-CTE because they don't read from the catalog and
7243        // shouldn't participate in regular FROM resolution.
7244        if let Some(from) = &stmt.from
7245            && from.joins.is_empty()
7246            && stmt.where_.is_none()
7247            && stmt.group_by.is_none()
7248            && stmt.having.is_none()
7249            && stmt.unions.is_empty()
7250            && stmt.order_by.is_empty()
7251            && stmt.limit.is_none()
7252            && stmt.offset.is_none()
7253            && !stmt.distinct
7254            && stmt.items.iter().all(|i| matches!(i, SelectItem::Wildcard))
7255        {
7256            let lower = from.primary.name.to_ascii_lowercase();
7257            match lower.as_str() {
7258                "spg_statistic" => return Ok(self.exec_spg_statistic()),
7259                // v6.5.0 — observability v2 virtual tables.
7260                "spg_stat_replication" => return Ok(self.exec_spg_stat_replication()),
7261                "spg_stat_segment" => return Ok(self.exec_spg_stat_segment()),
7262                "spg_stat_query" => return Ok(self.exec_spg_stat_query()),
7263                "spg_stat_activity" => return Ok(self.exec_spg_stat_activity()),
7264                "spg_audit_chain" => return Ok(self.exec_spg_audit_chain()),
7265                "spg_audit_verify" => return Ok(self.exec_spg_audit_verify()),
7266                "spg_table_ddl" => return Ok(self.exec_spg_table_ddl()),
7267                "spg_role_ddl" => return Ok(self.exec_spg_role_ddl()),
7268                "spg_database_ddl" => return Ok(self.exec_spg_database_ddl()),
7269                _ => {}
7270            }
7271        }
7272        // v4.11: CTEs materialise into a temporary enriched catalog
7273        // *before* anything else — the body SELECT can then refer
7274        // to CTE names via the regular FROM-clause resolution.
7275        // Uncorrelated only: each CTE body runs once against the
7276        // current catalog, not against later CTEs' results (left-
7277        // to-right materialisation would relax this, but we keep
7278        // it simple for v4.11 MVP).
7279        if !stmt.ctes.is_empty() {
7280            return self.exec_with_ctes(stmt, cancel);
7281        }
7282        // v4.10: subqueries (uncorrelated) are resolved here, before
7283        // the executor sees the row loop. We clone the statement so
7284        // we can mutate without disturbing the caller's AST — most
7285        // queries pass through with no subquery nodes and the clone
7286        // is cheap; with subqueries the materialisation cost
7287        // dominates anyway.
7288        let mut stmt_owned;
7289        let stmt_ref: &SelectStatement = if expr_tree_has_subquery(stmt) {
7290            stmt_owned = stmt.clone();
7291            self.resolve_select_subqueries(&mut stmt_owned, cancel)?;
7292            &stmt_owned
7293        } else {
7294            stmt
7295        };
7296        if stmt_ref.unions.is_empty() {
7297            return self.exec_bare_select_cancel(stmt_ref, cancel);
7298        }
7299        // UNION path: clone-strip the head into a bare block (its own
7300        // DISTINCT and any inner ORDER BY are dropped by parser rule —
7301        // the wrapper SelectStatement carries them), execute, then chain
7302        // peers with left-associative dedup semantics.
7303        let mut head = stmt_ref.clone();
7304        head.unions = Vec::new();
7305        head.order_by = Vec::new();
7306        head.limit = None;
7307        let QueryResult::Rows { columns, mut rows } =
7308            self.exec_bare_select_cancel(&head, cancel)?
7309        else {
7310            unreachable!("bare SELECT cannot return CommandOk")
7311        };
7312        for (kind, peer) in &stmt_ref.unions {
7313            let QueryResult::Rows {
7314                columns: peer_cols,
7315                rows: peer_rows,
7316            } = self.exec_bare_select_cancel(peer, cancel)?
7317            else {
7318                unreachable!("bare SELECT cannot return CommandOk")
7319            };
7320            if peer_cols.len() != columns.len() {
7321                return Err(EngineError::Unsupported(alloc::format!(
7322                    "UNION arity mismatch: head has {} columns, peer has {}",
7323                    columns.len(),
7324                    peer_cols.len()
7325                )));
7326            }
7327            rows.extend(peer_rows);
7328            if matches!(kind, UnionKind::Distinct) {
7329                rows = dedup_rows(rows);
7330            }
7331        }
7332        // ORDER BY at the top of a UNION applies to the combined result.
7333        // Eval against the projected schema (NOT the source table).
7334        if !stmt.order_by.is_empty() {
7335            let synth_ctx = EvalContext::new(&columns, None);
7336            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
7337            let mut tagged: Vec<(Vec<f64>, Row)> = Vec::with_capacity(rows.len());
7338            for r in rows {
7339                let keys = build_order_keys(&stmt.order_by, &r, &synth_ctx)?;
7340                tagged.push((keys, r));
7341            }
7342            sort_by_keys(&mut tagged, &descs);
7343            rows = tagged.into_iter().map(|(_, r)| r).collect();
7344        }
7345        apply_offset_and_limit(&mut rows, stmt.offset_literal(), stmt.limit_literal());
7346        Ok(QueryResult::Rows { columns, rows })
7347    }
7348
7349    #[allow(clippy::too_many_lines)]
7350    #[allow(clippy::too_many_lines)] // huge match — splitting fragments the planner
7351    /// v7.11.7 — execute `SELECT … FROM unnest(expr) [AS] alias …`.
7352    /// Synthesises a single-column virtual table whose column type
7353    /// is TEXT and whose rows are the array elements. Routes
7354    /// through the regular projection / WHERE / ORDER BY / LIMIT
7355    /// machinery so set-returning UNNEST composes naturally with
7356    /// the rest of the SELECT surface.
7357    fn exec_select_unnest(
7358        &self,
7359        stmt: &SelectStatement,
7360        primary: &TableRef,
7361        cancel: CancelToken<'_>,
7362    ) -> Result<QueryResult, EngineError> {
7363        let expr = primary
7364            .unnest_expr
7365            .as_deref()
7366            .expect("caller guards unnest_expr.is_some()");
7367        // Evaluate the array expression once. Empty schema / empty
7368        // row — uncorrelated UNNEST cannot reference outer columns.
7369        let empty_schema: alloc::vec::Vec<ColumnSchema> = alloc::vec::Vec::new();
7370        let ctx = EvalContext::new(&empty_schema, None);
7371        let dummy_row = Row::new(alloc::vec::Vec::new());
7372        // v7.11.13 — unnest dispatches per array element type so
7373        // INT[] / BIGINT[] surface their PG types in projection.
7374        let (elem_dtype, rows): (DataType, alloc::vec::Vec<Row>) =
7375            match eval::eval_expr(expr, &dummy_row, &ctx).map_err(EngineError::Eval)? {
7376                Value::Null => (DataType::Text, alloc::vec::Vec::new()),
7377                Value::TextArray(items) => {
7378                    let rows = items
7379                        .into_iter()
7380                        .map(|item| {
7381                            Row::new(alloc::vec![match item {
7382                                Some(s) => Value::Text(s),
7383                                None => Value::Null,
7384                            }])
7385                        })
7386                        .collect();
7387                    (DataType::Text, rows)
7388                }
7389                Value::IntArray(items) => {
7390                    let rows = items
7391                        .into_iter()
7392                        .map(|item| {
7393                            Row::new(alloc::vec![match item {
7394                                Some(n) => Value::Int(n),
7395                                None => Value::Null,
7396                            }])
7397                        })
7398                        .collect();
7399                    (DataType::Int, rows)
7400                }
7401                Value::BigIntArray(items) => {
7402                    let rows = items
7403                        .into_iter()
7404                        .map(|item| {
7405                            Row::new(alloc::vec![match item {
7406                                Some(n) => Value::BigInt(n),
7407                                None => Value::Null,
7408                            }])
7409                        })
7410                        .collect();
7411                    (DataType::BigInt, rows)
7412                }
7413                other => {
7414                    return Err(EngineError::Unsupported(alloc::format!(
7415                        "unnest() expects an array argument, got {:?}",
7416                        other.data_type()
7417                    )));
7418                }
7419            };
7420        let alias = primary
7421            .alias
7422            .clone()
7423            .unwrap_or_else(|| "unnest".to_string());
7424        // v7.13.2 — mailrs round-6 S5. Honour PG-standard
7425        // `UNNEST(arr) AS p(col_name)` column-list aliasing: the
7426        // first entry overrides the projected column's name.
7427        // Without the column list, fall back to the table alias
7428        // (pre-v7.13.2 behaviour).
7429        let col_name = primary
7430            .unnest_column_aliases
7431            .first()
7432            .cloned()
7433            .unwrap_or_else(|| alias.clone());
7434        let col_schema = ColumnSchema::new(col_name, elem_dtype, true);
7435        let schema_cols = alloc::vec![col_schema.clone()];
7436        let scan_ctx = EvalContext::new(&schema_cols, Some(&alias));
7437        // Apply WHERE.
7438        let filtered: alloc::vec::Vec<Row> = if let Some(w) = &stmt.where_ {
7439            let mut out = alloc::vec::Vec::with_capacity(rows.len());
7440            for row in rows {
7441                cancel.check()?;
7442                let v = eval::eval_expr(w, &row, &scan_ctx).map_err(EngineError::Eval)?;
7443                if matches!(v, Value::Bool(true)) {
7444                    out.push(row);
7445                }
7446            }
7447            out
7448        } else {
7449            rows
7450        };
7451        // v7.17.0 Phase 3.P0-48 — aggregate dispatch over the
7452        // unnest source. Same routing the relational scan path
7453        // already takes — without it `SELECT COUNT(*) FROM
7454        // unnest(ARRAY[…])` either errored at projection time or
7455        // returned the wrong shape.
7456        if aggregate::uses_aggregate(stmt) {
7457            let filtered_refs: alloc::vec::Vec<&Row> = filtered.iter().collect();
7458            let mut agg = aggregate::run(stmt, &filtered_refs, &schema_cols, Some(&alias))?;
7459            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
7460            return Ok(QueryResult::Rows {
7461                columns: agg.columns,
7462                rows: agg.rows,
7463            });
7464        }
7465        // Projection.
7466        let projection = build_projection(&stmt.items, &schema_cols, &alias)?;
7467        let mut projected_rows: alloc::vec::Vec<Row> =
7468            alloc::vec::Vec::with_capacity(filtered.len());
7469        // v7.19 P5 — Set-Returning-Function in projection
7470        // position (PG `SELECT unnest(arr) FROM t` shape). When a
7471        // SELECT item evaluates to a top-level unnest(arr) call,
7472        // expand it: for each input row, evaluate the array, emit
7473        // one output row per element, broadcasting non-SRF
7474        // projections from the same input row. Multi-SRF + LCM
7475        // padding stays a documented carve-out; mailrs uses
7476        // single-SRF for redirect_uris.
7477        let srf_position = projection.iter().position(|p| is_top_level_unnest(&p.expr));
7478        if let Some(srf_idx) = srf_position {
7479            let srf_arg = top_level_unnest_arg(&projection[srf_idx].expr)
7480                .expect("checked by is_top_level_unnest above");
7481            for row in &filtered {
7482                let arr_val =
7483                    eval::eval_expr(srf_arg, row, &scan_ctx).map_err(EngineError::Eval)?;
7484                let elements = array_value_to_elements(&arr_val)?;
7485                // Empty array → zero rows for this input row (PG
7486                // semantics: `SELECT unnest('{}'::int[])` returns
7487                // 0 rows, not a single NULL row).
7488                for elem in elements {
7489                    let mut vals = alloc::vec::Vec::with_capacity(projection.len());
7490                    for (i, p) in projection.iter().enumerate() {
7491                        if i == srf_idx {
7492                            vals.push(elem.clone());
7493                        } else {
7494                            vals.push(
7495                                eval::eval_expr(&p.expr, row, &scan_ctx)
7496                                    .map_err(EngineError::Eval)?,
7497                            );
7498                        }
7499                    }
7500                    projected_rows.push(Row::new(vals));
7501                }
7502            }
7503        } else {
7504            for row in &filtered {
7505                let mut vals = alloc::vec::Vec::with_capacity(projection.len());
7506                for p in &projection {
7507                    vals.push(eval::eval_expr(&p.expr, row, &scan_ctx).map_err(EngineError::Eval)?);
7508                }
7509                projected_rows.push(Row::new(vals));
7510            }
7511        }
7512        // ORDER BY / LIMIT — apply on the projected rows (cheap;
7513        // unnest result sets are small by design).
7514        let columns: alloc::vec::Vec<ColumnSchema> = projection
7515            .iter()
7516            .map(|p| ColumnSchema::new(p.output_name.clone(), p.ty, p.nullable))
7517            .collect();
7518        // Re-evaluate ORDER BY against the source schema (pre-projection
7519        // so col refs by name still resolve through `scan_ctx`).
7520        if !stmt.order_by.is_empty() {
7521            let mut indexed: alloc::vec::Vec<(usize, Vec<Value>)> = filtered
7522                .iter()
7523                .enumerate()
7524                .map(|(i, r)| -> Result<_, EngineError> {
7525                    let keys: Result<Vec<Value>, EngineError> = stmt
7526                        .order_by
7527                        .iter()
7528                        .map(|ob| {
7529                            eval::eval_expr(&ob.expr, r, &scan_ctx).map_err(EngineError::Eval)
7530                        })
7531                        .collect();
7532                    Ok((i, keys?))
7533                })
7534                .collect::<Result<_, _>>()?;
7535            indexed.sort_by(|a, b| {
7536                for (idx, (ka, kb)) in a.1.iter().zip(b.1.iter()).enumerate() {
7537                    let mut cmp = value_cmp(ka, kb);
7538                    if stmt.order_by[idx].desc {
7539                        cmp = cmp.reverse();
7540                    }
7541                    if cmp != core::cmp::Ordering::Equal {
7542                        return cmp;
7543                    }
7544                }
7545                core::cmp::Ordering::Equal
7546            });
7547            projected_rows = indexed
7548                .into_iter()
7549                .map(|(i, _)| projected_rows[i].clone())
7550                .collect();
7551        }
7552        // LIMIT / OFFSET — apply at the tail.
7553        if let Some(offset) = stmt.offset_literal() {
7554            let off = (offset as usize).min(projected_rows.len());
7555            projected_rows.drain(..off);
7556        }
7557        if let Some(limit) = stmt.limit_literal() {
7558            projected_rows.truncate(limit as usize);
7559        }
7560        Ok(QueryResult::Rows {
7561            columns,
7562            rows: projected_rows,
7563        })
7564    }
7565
7566    /// v7.17.0 Phase 3.10 — `FROM generate_series(start, stop [,
7567    /// step])` set-returning source. Mirrors `exec_select_unnest`'s
7568    /// shape: evaluate the arg list once against an empty row,
7569    /// materialise the row stream by stepping start → stop, then
7570    /// route through the standard WHERE / projection / ORDER BY /
7571    /// LIMIT pipeline. Two arg-type combos in v7.17:
7572    ///   * integer / integer [/ integer] — SmallInt, Int, BigInt
7573    ///     (widened to BigInt internally; step defaults to 1)
7574    ///   * timestamp / timestamp / interval — date-range
7575    ///     iteration (mailrs's daily-report pattern)
7576    fn exec_select_generate_series(
7577        &self,
7578        stmt: &SelectStatement,
7579        primary: &TableRef,
7580        cancel: CancelToken<'_>,
7581    ) -> Result<QueryResult, EngineError> {
7582        let args = primary
7583            .generate_series_args
7584            .as_ref()
7585            .expect("caller guards generate_series_args.is_some()");
7586        let empty_schema: alloc::vec::Vec<ColumnSchema> = alloc::vec::Vec::new();
7587        let ctx = EvalContext::new(&empty_schema, None);
7588        let dummy_row = Row::new(alloc::vec::Vec::new());
7589        let mut arg_values: alloc::vec::Vec<Value> = alloc::vec::Vec::with_capacity(args.len());
7590        for a in args {
7591            arg_values.push(eval::eval_expr(a, &dummy_row, &ctx).map_err(EngineError::Eval)?);
7592        }
7593        // Dispatch on the start value's shape. Reject mixed-shape
7594        // calls early (e.g. start = timestamp, stop = integer) so
7595        // the caller gets a clean error rather than a panic.
7596        let (elem_dtype, rows) = match arg_values.as_slice() {
7597            [Value::Timestamp(start), Value::Timestamp(stop), step] => {
7598                let interval_step = match step {
7599                    Value::Interval { .. } => step.clone(),
7600                    other => {
7601                        return Err(EngineError::Unsupported(alloc::format!(
7602                            "generate_series(timestamp, timestamp, …): \
7603                             step must be INTERVAL, got {:?}",
7604                            other.data_type()
7605                        )));
7606                    }
7607                };
7608                let rows = generate_series_timestamps(*start, *stop, interval_step, &cancel)?;
7609                (DataType::Timestamp, rows)
7610            }
7611            [start, stop, step]
7612                if value_is_integer(start) && value_is_integer(stop) && value_is_integer(step) =>
7613            {
7614                let s = value_to_i64(start);
7615                let e = value_to_i64(stop);
7616                let st = value_to_i64(step);
7617                let rows = generate_series_integers(s, e, st, &cancel)?;
7618                (DataType::BigInt, rows)
7619            }
7620            [start, stop] if value_is_integer(start) && value_is_integer(stop) => {
7621                let s = value_to_i64(start);
7622                let e = value_to_i64(stop);
7623                let rows = generate_series_integers(s, e, 1, &cancel)?;
7624                (DataType::BigInt, rows)
7625            }
7626            _ => {
7627                return Err(EngineError::Unsupported(alloc::format!(
7628                    "generate_series(): v7.17 supports integer or (timestamp, timestamp, interval) \
7629                     argument shapes; got {:?}",
7630                    arg_values
7631                        .iter()
7632                        .map(|v| v.data_type())
7633                        .collect::<alloc::vec::Vec<_>>()
7634                )));
7635            }
7636        };
7637        let alias = primary
7638            .alias
7639            .clone()
7640            .unwrap_or_else(|| "generate_series".to_string());
7641        let col_name = alias.clone();
7642        let col_schema = ColumnSchema::new(col_name, elem_dtype, true);
7643        let schema_cols = alloc::vec![col_schema.clone()];
7644        let scan_ctx = EvalContext::new(&schema_cols, Some(&alias));
7645        // WHERE.
7646        let filtered: alloc::vec::Vec<Row> = if let Some(w) = &stmt.where_ {
7647            let mut out = alloc::vec::Vec::with_capacity(rows.len());
7648            for row in rows {
7649                cancel.check()?;
7650                let v = eval::eval_expr(w, &row, &scan_ctx).map_err(EngineError::Eval)?;
7651                if matches!(v, Value::Bool(true)) {
7652                    out.push(row);
7653                }
7654            }
7655            out
7656        } else {
7657            rows
7658        };
7659        // v7.17.0 Phase 3.P0-48 — aggregate dispatch for set-
7660        // returning sources. When the SELECT projection contains
7661        // aggregate functions (COUNT/SUM/MIN/MAX/AVG/string_agg/
7662        // …) we route the filtered row stream through the same
7663        // aggregate executor the relational scan path uses, so
7664        // `SELECT COUNT(*) FROM generate_series(1, 100)` returns
7665        // a single 100 row instead of erroring at projection
7666        // time. GROUP BY / HAVING / ORDER BY over the aggregate
7667        // output all ride through `aggregate::run`.
7668        if aggregate::uses_aggregate(stmt) {
7669            let filtered_refs: alloc::vec::Vec<&Row> = filtered.iter().collect();
7670            let mut agg = aggregate::run(stmt, &filtered_refs, &schema_cols, Some(&alias))?;
7671            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
7672            return Ok(QueryResult::Rows {
7673                columns: agg.columns,
7674                rows: agg.rows,
7675            });
7676        }
7677        // Projection.
7678        let projection = build_projection(&stmt.items, &schema_cols, &alias)?;
7679        let mut projected_rows: alloc::vec::Vec<Row> =
7680            alloc::vec::Vec::with_capacity(filtered.len());
7681        for row in &filtered {
7682            let mut vals = alloc::vec::Vec::with_capacity(projection.len());
7683            for p in &projection {
7684                vals.push(eval::eval_expr(&p.expr, row, &scan_ctx).map_err(EngineError::Eval)?);
7685            }
7686            projected_rows.push(Row::new(vals));
7687        }
7688        let columns: alloc::vec::Vec<ColumnSchema> = projection
7689            .iter()
7690            .map(|p| ColumnSchema::new(p.output_name.clone(), p.ty, p.nullable))
7691            .collect();
7692        // ORDER BY against the source schema.
7693        if !stmt.order_by.is_empty() {
7694            let mut indexed: alloc::vec::Vec<(usize, Vec<Value>)> = filtered
7695                .iter()
7696                .enumerate()
7697                .map(|(i, r)| -> Result<_, EngineError> {
7698                    let keys: Result<Vec<Value>, EngineError> = stmt
7699                        .order_by
7700                        .iter()
7701                        .map(|ob| {
7702                            eval::eval_expr(&ob.expr, r, &scan_ctx).map_err(EngineError::Eval)
7703                        })
7704                        .collect();
7705                    Ok((i, keys?))
7706                })
7707                .collect::<Result<_, _>>()?;
7708            indexed.sort_by(|a, b| {
7709                for (idx, (ka, kb)) in a.1.iter().zip(b.1.iter()).enumerate() {
7710                    let mut cmp = value_cmp(ka, kb);
7711                    if stmt.order_by[idx].desc {
7712                        cmp = cmp.reverse();
7713                    }
7714                    if cmp != core::cmp::Ordering::Equal {
7715                        return cmp;
7716                    }
7717                }
7718                core::cmp::Ordering::Equal
7719            });
7720            projected_rows = indexed
7721                .into_iter()
7722                .map(|(i, _)| projected_rows[i].clone())
7723                .collect();
7724        }
7725        if let Some(offset) = stmt.offset_literal() {
7726            let off = (offset as usize).min(projected_rows.len());
7727            projected_rows.drain(..off);
7728        }
7729        if let Some(limit) = stmt.limit_literal() {
7730            projected_rows.truncate(limit as usize);
7731        }
7732        Ok(QueryResult::Rows {
7733            columns,
7734            rows: projected_rows,
7735        })
7736    }
7737
7738    fn exec_bare_select_cancel(
7739        &self,
7740        stmt: &SelectStatement,
7741        cancel: CancelToken<'_>,
7742    ) -> Result<QueryResult, EngineError> {
7743        // v7.17.0 Phase 3.P0-49 — `FETCH FIRST N ROWS WITH TIES`
7744        // is meaningless without an ORDER BY; PG raises a hard
7745        // error and SPG mirrors the surface so the same DDL/app
7746        // path behaves identically on cutover.
7747        check_with_ties_requires_order_by(stmt)?;
7748        // v7.16.2 — same meta-view dispatch as
7749        // `exec_select_cancel`, applied here too because
7750        // `subquery_replacement` enters this function directly
7751        // for Exists / ScalarSubquery / InSubquery resolution
7752        // (bypassing the top-level entry to avoid double
7753        // subquery walking). Without this dispatch the subquery
7754        // hits `__spg_info_columns` and reports TableNotFound.
7755        if !self.meta_views_materialised && select_references_meta_view(stmt) {
7756            return self.exec_select_with_meta_views(stmt, cancel);
7757        }
7758        // v4.12: window-function path. When the projection contains
7759        // any `name(args) OVER (...)` we route to the dedicated
7760        // executor — partition + sort + per-row window value before
7761        // the regular projection.
7762        if select_has_window(stmt) {
7763            return self.exec_select_with_window(stmt, cancel);
7764        }
7765        // Constant SELECT (no FROM) — evaluate each item once against an
7766        // empty dummy row. Useful for `SELECT 1`, `SELECT coalesce(...)`,
7767        // `SELECT '7'::INT`. Column references will surface as
7768        // ColumnNotFound on eval since the schema is empty.
7769        let Some(from) = &stmt.from else {
7770            let empty_schema: Vec<ColumnSchema> = Vec::new();
7771            let ctx = self.ev_ctx(&empty_schema, None);
7772            let projection = build_projection(&stmt.items, &empty_schema, "")?;
7773            let dummy_row = Row::new(Vec::new());
7774            let mut values = Vec::with_capacity(projection.len());
7775            for p in &projection {
7776                values.push(eval::eval_expr(&p.expr, &dummy_row, &ctx)?);
7777            }
7778            let columns: Vec<ColumnSchema> = projection
7779                .into_iter()
7780                .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
7781                .collect();
7782            return Ok(QueryResult::Rows {
7783                columns,
7784                rows: alloc::vec![Row::new(values)],
7785            });
7786        };
7787        // Multi-table FROM (one or more joined peers) goes through the
7788        // nested-loop join executor. Single-table FROM stays on the
7789        // existing scan + index-seek path.
7790        if !from.joins.is_empty() {
7791            return self.exec_joined_select(stmt, from);
7792        }
7793        // v7.11.7 — `FROM unnest(<expr>) [AS] <alias>`. Synthesise a
7794        // single-column table at SELECT entry by evaluating the
7795        // expression once against the empty row (UNNEST is
7796        // uncorrelated in v7.11; correlated / LATERAL unnest is a
7797        // v7.12 carve-out). Build a virtual `Table` in a heap-only
7798        // catalog, then route to the regular scan path.
7799        if from.primary.unnest_expr.is_some() {
7800            return self.exec_select_unnest(stmt, &from.primary, cancel);
7801        }
7802        // v7.17.0 Phase 3.10 — `FROM generate_series(start, stop
7803        // [, step])` set-returning source. Dispatch mirrors UNNEST:
7804        // materialise the row stream from a single eval pass, then
7805        // run the regular projection / WHERE / ORDER BY / LIMIT
7806        // pipeline over the synthetic single-column table.
7807        if from.primary.generate_series_args.is_some() {
7808            return self.exec_select_generate_series(stmt, &from.primary, cancel);
7809        }
7810        let primary = &from.primary;
7811        let table = self.active_catalog().get(&primary.name).ok_or_else(|| {
7812            StorageError::TableNotFound {
7813                name: primary.name.clone(),
7814            }
7815        })?;
7816        let schema_cols = &table.schema().columns;
7817        // The qualifier accepted on column refs is the alias (if any) else the
7818        // bare table name.
7819        let alias = primary.alias.as_deref().unwrap_or(primary.name.as_str());
7820        let ctx = self.ev_ctx(schema_cols, Some(alias));
7821
7822        // NSW kNN planner: `ORDER BY col <-> literal LIMIT k` with no
7823        // WHERE and an NSW index on `col` skips the full scan. The
7824        // walk returns rows already in ascending-distance order, so
7825        // ORDER BY / LIMIT are honoured implicitly.
7826        if let Some(nsw_rows) = try_nsw_knn(stmt, table, schema_cols, alias) {
7827            return materialise_in_order(stmt, table, schema_cols, alias, &nsw_rows);
7828        }
7829
7830        // Index seek: if WHERE is `col = literal` (or commuted) and the
7831        // referenced column has an index, dispatch each locator through
7832        // the catalog (hot tier → borrow, cold tier → page-read +
7833        // decode) and iterate just those rows. Otherwise fall back to a
7834        // full scan over the hot tier (cold-tier rows are only reached
7835        // via index seek in v5.1 — full table scans against cold-tier
7836        // data ship in v5.2 with the freezer's per-segment scan API).
7837        let indexed_rows: Option<Vec<Cow<'_, Row>>> = stmt.where_.as_ref().and_then(|w| {
7838            // BTree / col=literal seek first — covers the v7.11.3 multi-
7839            // column AND case and the leading-column equality lookup.
7840            try_index_seek(w, schema_cols, self.active_catalog(), table, alias)
7841                .or_else(|| {
7842                    // v7.12.3 — GIN-accelerated `WHERE col @@
7843                    // tsquery` when the column has a `USING gin`
7844                    // index. Returns an over-approximate candidate
7845                    // set; the WHERE re-eval loop below verifies
7846                    // the full `@@` predicate per row.
7847                    try_gin_seek(w, schema_cols, self.active_catalog(), table, alias, &ctx)
7848                })
7849                .or_else(|| {
7850                    // v7.15.0 — trigram-GIN-accelerated
7851                    // `WHERE col LIKE / ILIKE '<pat>'` when the
7852                    // column has a `gin_trgm_ops` GIN index.
7853                    // Over-approximate candidate set; the WHERE
7854                    // re-eval verifies the LIKE per row.
7855                    try_trgm_seek(w, schema_cols, table, alias)
7856                })
7857        });
7858
7859        // Aggregate path: filter rows first, then hand off to the
7860        // aggregate executor which does its own projection + ORDER BY.
7861        if aggregate::uses_aggregate(stmt) {
7862            let mut filtered: Vec<&Row> = Vec::new();
7863            // v6.2.6 — Memoize: per-query LRU cache for correlated
7864            // scalar subqueries. Fresh per row-loop entry so each
7865            // SELECT execution gets an isolated cache.
7866            let mut memo = memoize::MemoizeCache::new();
7867            if let Some(rows) = &indexed_rows {
7868                for cow in rows {
7869                    let row = cow.as_ref();
7870                    if let Some(where_expr) = &stmt.where_ {
7871                        let cond = self.eval_expr_with_correlated(
7872                            where_expr,
7873                            row,
7874                            &ctx,
7875                            cancel,
7876                            Some(&mut memo),
7877                        )?;
7878                        if !matches!(cond, Value::Bool(true)) {
7879                            continue;
7880                        }
7881                    }
7882                    filtered.push(row);
7883                }
7884            } else {
7885                for i in 0..table.row_count() {
7886                    let row = &table.rows()[i];
7887                    if let Some(where_expr) = &stmt.where_ {
7888                        let cond = self.eval_expr_with_correlated(
7889                            where_expr,
7890                            row,
7891                            &ctx,
7892                            cancel,
7893                            Some(&mut memo),
7894                        )?;
7895                        if !matches!(cond, Value::Bool(true)) {
7896                            continue;
7897                        }
7898                    }
7899                    filtered.push(row);
7900                }
7901            }
7902            let mut agg = aggregate::run(stmt, &filtered, schema_cols, Some(alias))?;
7903            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
7904            return Ok(QueryResult::Rows {
7905                columns: agg.columns,
7906                rows: agg.rows,
7907            });
7908        }
7909
7910        let projection = build_projection(&stmt.items, schema_cols, alias)?;
7911        // v7.19 P5 — single-table SELECT path for SRF
7912        // `SELECT unnest(arr) FROM t` shape. Detect a top-level
7913        // unnest in the projection list. When present, the
7914        // per-row processor emits one output row per array
7915        // element (broadcasting non-SRF projections from the
7916        // same input row). Empty / NULL arrays emit zero rows
7917        // for that input — PG semantics.
7918        let srf_position = projection.iter().position(|p| is_top_level_unnest(&p.expr));
7919
7920        // Materialise the filter pass into `(order_key, projected_row)`
7921        // tuples. The order key is `None` when there's no ORDER BY clause.
7922        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::new();
7923        // v6.2.6 — Memoize per-row WHERE eval shares one cache.
7924        let mut memo = memoize::MemoizeCache::new();
7925        // Inline the per-row work in a closure so the indexed and full-
7926        // scan branches share the body.
7927        let mut process_row = |row: &Row, loop_idx: usize| -> Result<(), EngineError> {
7928            if loop_idx.is_multiple_of(256) {
7929                cancel.check()?;
7930            }
7931            if let Some(where_expr) = &stmt.where_ {
7932                let cond =
7933                    self.eval_expr_with_correlated(where_expr, row, &ctx, cancel, Some(&mut memo))?;
7934                if !matches!(cond, Value::Bool(true)) {
7935                    return Ok(());
7936                }
7937            }
7938            let order_keys = if stmt.order_by.is_empty() {
7939                Vec::new()
7940            } else {
7941                build_order_keys(&stmt.order_by, row, &ctx)?
7942            };
7943            if let Some(srf_idx) = srf_position {
7944                let srf_arg = top_level_unnest_arg(&projection[srf_idx].expr)
7945                    .expect("checked by is_top_level_unnest above");
7946                let arr_val = eval::eval_expr(srf_arg, row, &ctx)?;
7947                let elements = array_value_to_elements(&arr_val)?;
7948                for elem in elements {
7949                    let mut values = Vec::with_capacity(projection.len());
7950                    for (i, p) in projection.iter().enumerate() {
7951                        if i == srf_idx {
7952                            values.push(elem.clone());
7953                        } else {
7954                            values.push(eval::eval_expr(&p.expr, row, &ctx)?);
7955                        }
7956                    }
7957                    tagged.push((order_keys.clone(), Row::new(values)));
7958                }
7959            } else {
7960                let mut values = Vec::with_capacity(projection.len());
7961                for p in &projection {
7962                    values.push(eval::eval_expr(&p.expr, row, &ctx)?);
7963                }
7964                tagged.push((order_keys, Row::new(values)));
7965            }
7966            Ok(())
7967        };
7968        if let Some(rows) = &indexed_rows {
7969            for (loop_idx, cow) in rows.iter().enumerate() {
7970                process_row(cow.as_ref(), loop_idx)?;
7971            }
7972        } else {
7973            for i in 0..table.row_count() {
7974                process_row(&table.rows()[i], i)?;
7975            }
7976        }
7977
7978        if !stmt.order_by.is_empty() {
7979            // Partial-sort fast path: when LIMIT is small relative to
7980            // the row count, select_nth_unstable + sort just the
7981            // prefix is O(n + k log k) instead of O(n log n). DISTINCT
7982            // requires the full sort because de-dup happens after.
7983            // WITH TIES likewise needs the full sort so the tie
7984            // extension can scan past `limit` to find rows that
7985            // share the last-kept row's key.
7986            let keep = if stmt.distinct || stmt.limit_with_ties {
7987                None
7988            } else {
7989                stmt.limit_literal()
7990                    .map(|l| l as usize + stmt.offset_literal().map_or(0, |o| o as usize))
7991            };
7992            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
7993            partial_sort_tagged(&mut tagged, keep, &descs);
7994        }
7995
7996        // v7.17.0 Phase 3.P0-49 — `FETCH FIRST … WITH TIES` extends
7997        // past the truncated tail through every row that shares the
7998        // last-kept row's ORDER BY key. The tie check uses the
7999        // already-computed `(order_keys, row)` pairs so it matches
8000        // the sort comparator exactly. DISTINCT + WITH TIES falls
8001        // through to the no-ties path (PG also disallows their
8002        // combination; SPG silently drops the tie extension here so
8003        // the customer doesn't see a hard error mid-query — the
8004        // user-visible result is still correct, just narrower).
8005        let output_rows: Vec<Row> = if stmt.limit_with_ties && !stmt.distinct {
8006            apply_offset_and_limit_tagged(
8007                &mut tagged,
8008                stmt.offset_literal(),
8009                stmt.limit_literal(),
8010                true,
8011            );
8012            tagged.into_iter().map(|(_, r)| r).collect()
8013        } else {
8014            let mut output_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
8015            if stmt.distinct {
8016                output_rows = dedup_rows(output_rows);
8017            }
8018            apply_offset_and_limit(
8019                &mut output_rows,
8020                stmt.offset_literal(),
8021                stmt.limit_literal(),
8022            );
8023            output_rows
8024        };
8025
8026        let columns: Vec<ColumnSchema> = projection
8027            .into_iter()
8028            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
8029            .collect();
8030
8031        Ok(QueryResult::Rows {
8032            columns,
8033            rows: output_rows,
8034        })
8035    }
8036
8037    /// Multi-table SELECT executor (one or more JOIN peers).
8038    ///
8039    /// v1.10 builds the joined row set up-front via nested-loop joins,
8040    /// then runs WHERE + projection + ORDER BY against the combined
8041    /// rows. No index seek. Aggregates and DISTINCT still work because
8042    /// the executor delegates projection through the same shared paths.
8043    #[allow(clippy::too_many_lines)]
8044    /// v7.13.2 — mailrs round-6 S5. Resolve a TableRef into an
8045    /// owned (rows, schema) pair. Catalog tables clone their hot
8046    /// rows + schema; UNNEST table refs evaluate their array
8047    /// expression once and synthesise a single-column row set
8048    /// using the same dispatch as `exec_select_unnest`. Used by
8049    /// the joined-select path so UNNEST can appear in any FROM
8050    /// position, not just as the primary.
8051    fn materialise_table_ref(
8052        &self,
8053        tref: &TableRef,
8054    ) -> Result<(Vec<Row>, Vec<ColumnSchema>), EngineError> {
8055        if let Some(expr) = tref.unnest_expr.as_deref() {
8056            let empty_schema: Vec<ColumnSchema> = Vec::new();
8057            let ctx = EvalContext::new(&empty_schema, None);
8058            let dummy_row = Row::new(Vec::new());
8059            let (elem_dtype, rows) =
8060                match eval::eval_expr(expr, &dummy_row, &ctx).map_err(EngineError::Eval)? {
8061                    Value::Null => (DataType::Text, Vec::new()),
8062                    Value::TextArray(items) => (
8063                        DataType::Text,
8064                        items
8065                            .into_iter()
8066                            .map(|item| {
8067                                Row::new(alloc::vec![match item {
8068                                    Some(s) => Value::Text(s),
8069                                    None => Value::Null,
8070                                }])
8071                            })
8072                            .collect(),
8073                    ),
8074                    Value::IntArray(items) => (
8075                        DataType::Int,
8076                        items
8077                            .into_iter()
8078                            .map(|item| {
8079                                Row::new(alloc::vec![match item {
8080                                    Some(n) => Value::Int(n),
8081                                    None => Value::Null,
8082                                }])
8083                            })
8084                            .collect(),
8085                    ),
8086                    Value::BigIntArray(items) => (
8087                        DataType::BigInt,
8088                        items
8089                            .into_iter()
8090                            .map(|item| {
8091                                Row::new(alloc::vec![match item {
8092                                    Some(n) => Value::BigInt(n),
8093                                    None => Value::Null,
8094                                }])
8095                            })
8096                            .collect(),
8097                    ),
8098                    other => {
8099                        return Err(EngineError::Unsupported(alloc::format!(
8100                            "unnest() expects an array argument, got {:?}",
8101                            other.data_type()
8102                        )));
8103                    }
8104                };
8105            let alias = tref.alias.clone().unwrap_or_else(|| "unnest".to_string());
8106            let col_name = tref.unnest_column_aliases.first().cloned().unwrap_or(alias);
8107            return Ok((
8108                rows,
8109                alloc::vec![ColumnSchema::new(col_name, elem_dtype, true)],
8110            ));
8111        }
8112        let table =
8113            self.active_catalog()
8114                .get(&tref.name)
8115                .ok_or_else(|| StorageError::TableNotFound {
8116                    name: tref.name.clone(),
8117                })?;
8118        let rows: Vec<Row> = table.rows().iter().cloned().collect();
8119        let cols = table.schema().columns.clone();
8120        Ok((rows, cols))
8121    }
8122
8123    /// v7.17.0 Phase 3.P0-43 — materialise a `FROM` with one or more
8124    /// JOINs into `(combined_schema, filtered_rows)`. The combined
8125    /// schema uses composite `alias.col` column names so the
8126    /// qualifier-aware column resolver finds every join peer by
8127    /// exact match; the filtered rows are the join cross-product
8128    /// after the optional WHERE clause is applied.
8129    ///
8130    /// Shared by `exec_joined_select` and the JOIN branch of
8131    /// `exec_select_with_window`; both paths used to inline the
8132    /// same nested-loop logic and the window path rejected JOIN
8133    /// outright.
8134    fn build_joined_filtered_rows(
8135        &self,
8136        from: &FromClause,
8137        where_: Option<&Expr>,
8138    ) -> Result<(Vec<ColumnSchema>, Vec<Row>), EngineError> {
8139        let (primary_rows, primary_cols) = self.materialise_table_ref(&from.primary)?;
8140        let primary_alias = from
8141            .primary
8142            .alias
8143            .as_deref()
8144            .unwrap_or(from.primary.name.as_str())
8145            .to_string();
8146        // v7.17.0 Phase 3.P0-41 — LATERAL peers can't be
8147        // pre-materialised because their rows depend on outer
8148        // columns. For each peer, build either an eager
8149        // (rows, schema) pair or a "lateral" sentinel carrying
8150        // just the schema and the inner SELECT to re-run per
8151        // outer row.
8152        #[allow(clippy::type_complexity)]
8153        let mut joined: Vec<JoinedPeer<'_>> = Vec::new();
8154        for j in &from.joins {
8155            let a = j
8156                .table
8157                .alias
8158                .as_deref()
8159                .unwrap_or(j.table.name.as_str())
8160                .to_string();
8161            if let Some(inner_box) = &j.table.lateral_subquery {
8162                // Probe schema by running the inner SELECT against a
8163                // NULL-padded outer context. The probe gives us the
8164                // projection's column shape; rows materialise per
8165                // left-row below.
8166                let schema = self.lateral_probe_schema(inner_box)?;
8167                joined.push(JoinedPeer {
8168                    eager_rows: None,
8169                    cols: schema,
8170                    alias: a,
8171                    kind: j.kind,
8172                    on: j.on.as_ref(),
8173                    lateral: Some(inner_box.as_ref()),
8174                });
8175            } else {
8176                let (rows, cols) = self.materialise_table_ref(&j.table)?;
8177                joined.push(JoinedPeer {
8178                    eager_rows: Some(rows),
8179                    cols,
8180                    alias: a,
8181                    kind: j.kind,
8182                    on: j.on.as_ref(),
8183                    lateral: None,
8184                });
8185            }
8186        }
8187        let mut combined_schema: Vec<ColumnSchema> = Vec::new();
8188        for col in &primary_cols {
8189            combined_schema.push(ColumnSchema::new(
8190                alloc::format!("{primary_alias}.{}", col.name),
8191                col.ty,
8192                col.nullable,
8193            ));
8194        }
8195        for peer in &joined {
8196            for col in &peer.cols {
8197                combined_schema.push(ColumnSchema::new(
8198                    alloc::format!("{}.{}", peer.alias, col.name),
8199                    col.ty,
8200                    col.nullable,
8201                ));
8202            }
8203        }
8204        let ctx = EvalContext::new(&combined_schema, None);
8205        let mut working: Vec<Row> = primary_rows;
8206        // Track the per-row width consumed by the outer left side so
8207        // each lateral evaluation sees the correct schema slice.
8208        let mut consumed_cols = primary_cols.len();
8209        for peer in &joined {
8210            let right_arity = peer.cols.len();
8211            let mut next: Vec<Row> = Vec::new();
8212            for left in &working {
8213                let mut left_matched = false;
8214                let per_left_rrows: alloc::borrow::Cow<'_, [Row]> = match peer.lateral {
8215                    Some(inner) => {
8216                        // Substitute outer columns and run the inner
8217                        // SELECT against the current left row's slice
8218                        // of the combined schema.
8219                        let outer_schema = &combined_schema[..consumed_cols];
8220                        let rows = self.materialise_lateral_for_outer(inner, outer_schema, left)?;
8221                        alloc::borrow::Cow::Owned(rows)
8222                    }
8223                    None => {
8224                        let r = peer.eager_rows.as_ref().expect("non-lateral peer eager");
8225                        alloc::borrow::Cow::Borrowed(r.as_slice())
8226                    }
8227                };
8228                for right in per_left_rrows.as_ref() {
8229                    let mut combined_vals = left.values.clone();
8230                    combined_vals.extend(right.values.iter().cloned());
8231                    let combined = Row::new(combined_vals);
8232                    let keep = if let Some(on_expr) = peer.on {
8233                        let cond = eval::eval_expr(on_expr, &combined, &ctx)?;
8234                        matches!(cond, Value::Bool(true))
8235                    } else {
8236                        true
8237                    };
8238                    if keep {
8239                        next.push(combined);
8240                        left_matched = true;
8241                    }
8242                }
8243                if !left_matched && matches!(peer.kind, JoinKind::Left) {
8244                    let mut combined_vals = left.values.clone();
8245                    for _ in 0..right_arity {
8246                        combined_vals.push(Value::Null);
8247                    }
8248                    next.push(Row::new(combined_vals));
8249                }
8250            }
8251            working = next;
8252            consumed_cols += right_arity;
8253            debug_assert!(consumed_cols <= combined_schema.len());
8254        }
8255        let mut filtered: Vec<Row> = Vec::new();
8256        for row in working {
8257            if let Some(where_expr) = where_ {
8258                let cond = eval::eval_expr(where_expr, &row, &ctx)?;
8259                if !matches!(cond, Value::Bool(true)) {
8260                    continue;
8261                }
8262            }
8263            filtered.push(row);
8264        }
8265        Ok((combined_schema, filtered))
8266    }
8267
8268    /// v7.17.0 Phase 3.P0-41 — probe a LATERAL subquery's projection
8269    /// schema by running it once with a NULL-padded outer context.
8270    /// The probe never materialises real outer rows; it just executes
8271    /// the inner SELECT with `outer_alias.col` references substituted
8272    /// to NULL so the projection's type inference is exercised.
8273    fn lateral_probe_schema(
8274        &self,
8275        inner: &SelectStatement,
8276    ) -> Result<Vec<ColumnSchema>, EngineError> {
8277        // Substitute every qualified column reference whose qualifier
8278        // does NOT match an in-subquery FROM alias with NULL. The
8279        // safest probe is to walk the inner SELECT and replace any
8280        // `<qual>.<col>` whose qual isn't bound inside the subquery
8281        // with a Null literal. For the v7.17 probe we just run the
8282        // unmodified subquery and surface the columns; if it fails
8283        // (e.g. references an outer column the probe can't resolve),
8284        // we synthesise a best-effort schema from the SELECT items
8285        // by inferring a single Text-typed column per projection.
8286        match self.execute_readonly_select_for_lateral_probe(inner) {
8287            Ok(QueryResult::Rows { columns, .. }) => Ok(columns),
8288            // Best-effort fallback: each SELECT item becomes a TEXT
8289            // column. Real schemas only differ when the inner SELECT
8290            // references outer columns at projection-time; those
8291            // queries surface via the substitution path during
8292            // per-row execution and still return the right values.
8293            _ => {
8294                let mut out: Vec<ColumnSchema> = Vec::new();
8295                for (i, item) in inner.items.iter().enumerate() {
8296                    let name = match item {
8297                        SelectItem::Expr { alias: Some(a), .. } => a.clone(),
8298                        SelectItem::Expr { expr, .. } => synth_lateral_col_name(expr, i),
8299                        SelectItem::Wildcard => alloc::format!("col{i}"),
8300                    };
8301                    out.push(ColumnSchema::new(name, DataType::Text, true));
8302                }
8303                Ok(out)
8304            }
8305        }
8306    }
8307
8308    /// v7.17.0 Phase 3.P0-41 — try the inner LATERAL subquery against
8309    /// the engine in read-only mode for schema-probe purposes. Failure
8310    /// is expected when the subquery references an outer column the
8311    /// probe can't resolve; the caller falls back to a best-effort
8312    /// schema based on the SELECT items.
8313    fn execute_readonly_select_for_lateral_probe(
8314        &self,
8315        inner: &SelectStatement,
8316    ) -> Result<QueryResult, EngineError> {
8317        self.exec_bare_select_cancel(inner, CancelToken::none())
8318    }
8319
8320    /// v7.17.0 Phase 3.P0-41 — materialise a LATERAL subquery's rows
8321    /// for one outer-row context. Walks the inner SELECT, replaces
8322    /// every `<outer_alias>.<col>` reference whose alias appears in
8323    /// the outer schema with the literal value from the outer row,
8324    /// then runs the rewritten SELECT against the engine.
8325    fn materialise_lateral_for_outer(
8326        &self,
8327        inner: &SelectStatement,
8328        outer_schema: &[ColumnSchema],
8329        outer_row: &Row,
8330    ) -> Result<Vec<Row>, EngineError> {
8331        let mut substituted = inner.clone();
8332        substitute_outer_columns_multi(&mut substituted, outer_row, outer_schema);
8333        let result = self.exec_bare_select_cancel(&substituted, CancelToken::none())?;
8334        match result {
8335            QueryResult::Rows { rows, .. } => Ok(rows),
8336            _ => Err(EngineError::Unsupported(
8337                "LATERAL subquery must be a SELECT (cannot be a write statement)".into(),
8338            )),
8339        }
8340    }
8341
8342    fn exec_joined_select(
8343        &self,
8344        stmt: &SelectStatement,
8345        from: &FromClause,
8346    ) -> Result<QueryResult, EngineError> {
8347        // v7.17.0 Phase 3.P0-43 + P0-41 — delegate the join +
8348        // WHERE materialisation to the shared helper so the LATERAL
8349        // / UNNEST / regular-catalog paths route through one place.
8350        // (`build_joined_filtered_rows` carries LATERAL support as
8351        // of Phase 3.P0-41.) Downstream we still handle aggregate /
8352        // projection / ORDER BY / DISTINCT / LIMIT inline because
8353        // those depend on the SelectStatement's items list.
8354        let (combined_schema, filtered) =
8355            self.build_joined_filtered_rows(from, stmt.where_.as_ref())?;
8356        let ctx = EvalContext::new(&combined_schema, None);
8357        // Aggregate path: handle GROUP BY / aggregate calls over the
8358        // joined+filtered rows.
8359        if aggregate::uses_aggregate(stmt) {
8360            let refs: Vec<&Row> = filtered.iter().collect();
8361            let mut agg = aggregate::run(stmt, &refs, &combined_schema, None)?;
8362            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
8363            return Ok(QueryResult::Rows {
8364                columns: agg.columns,
8365                rows: agg.rows,
8366            });
8367        }
8368
8369        let projection = build_projection(&stmt.items, &combined_schema, "")?;
8370        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::new();
8371        for row in &filtered {
8372            let mut values = Vec::with_capacity(projection.len());
8373            for p in &projection {
8374                values.push(eval::eval_expr(&p.expr, row, &ctx)?);
8375            }
8376            let order_keys = if stmt.order_by.is_empty() {
8377                Vec::new()
8378            } else {
8379                build_order_keys(&stmt.order_by, row, &ctx)?
8380            };
8381            tagged.push((order_keys, Row::new(values)));
8382        }
8383        if !stmt.order_by.is_empty() {
8384            let keep = if stmt.distinct {
8385                None
8386            } else {
8387                stmt.limit_literal()
8388                    .map(|l| l as usize + stmt.offset_literal().map_or(0, |o| o as usize))
8389            };
8390            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
8391            partial_sort_tagged(&mut tagged, keep, &descs);
8392        }
8393        let mut output_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
8394        if stmt.distinct {
8395            output_rows = dedup_rows(output_rows);
8396        }
8397        apply_offset_and_limit(
8398            &mut output_rows,
8399            stmt.offset_literal(),
8400            stmt.limit_literal(),
8401        );
8402        let columns: Vec<ColumnSchema> = projection
8403            .into_iter()
8404            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
8405            .collect();
8406        Ok(QueryResult::Rows {
8407            columns,
8408            rows: output_rows,
8409        })
8410    }
8411}
8412
8413/// One row-producing projection: an expression to evaluate, the resulting
8414/// column's user-visible name, its inferred type, and nullability.
8415#[derive(Debug, Clone)]
8416struct ProjectedItem {
8417    expr: Expr,
8418    output_name: String,
8419    ty: DataType,
8420    nullable: bool,
8421}
8422
8423/// Dedupe a row set, preserving first-seen order. `Row`'s `PartialEq` is
8424/// structural (`Vec<Value>` ⇒ pairwise `Value` equality), which gives SQL
8425/// `NULL = NULL → TRUE` and `NaN = NaN → FALSE`. The first agrees with
8426/// the spec's "two NULLs are not distinct"; the second is a tolerated
8427/// quirk for v1 (no NaN literals are reachable from the SQL surface).
8428fn dedup_rows(rows: Vec<Row>) -> Vec<Row> {
8429    let mut out: Vec<Row> = Vec::with_capacity(rows.len());
8430    for r in rows {
8431        if !out.iter().any(|seen| seen == &r) {
8432            out.push(r);
8433        }
8434    }
8435    out
8436}
8437
8438/// Coerce a `Value` to an `f64` sort key for ORDER BY. Numbers map directly;
8439/// NULL sorts last (treated as `+∞`); booleans are 0.0 / 1.0; text uses lex
8440/// order via the byte values; vectors are not sortable.
8441fn value_to_order_key(v: &Value) -> Result<f64, EngineError> {
8442    match v {
8443        Value::Null => Ok(f64::INFINITY),
8444        Value::SmallInt(n) => Ok(f64::from(*n)),
8445        Value::Int(n) => Ok(f64::from(*n)),
8446        Value::Date(d) => Ok(f64::from(*d)),
8447        #[allow(clippy::cast_precision_loss)]
8448        Value::Timestamp(t) => Ok(*t as f64),
8449        // v7.17.0 Phase 3.P0-32 — PG TIME ordered by underlying
8450        // i64 microseconds (matches wall-clock ordering).
8451        #[allow(clippy::cast_precision_loss)]
8452        Value::Time(us) => Ok(*us as f64),
8453        // v7.17.0 Phase 3.P0-33 — MySQL YEAR ordered by underlying
8454        // u16 (matches calendar ordering; zero-year sentinel
8455        // sorts before 1901).
8456        Value::Year(y) => Ok(f64::from(*y)),
8457        // v7.17.0 Phase 3.P0-34 — PG TIMETZ ordered by the
8458        // UTC-equivalent microseconds (local wall - offset). Two
8459        // values for the same physical instant in different zones
8460        // sort equal — matches PG TIMETZ index behaviour.
8461        #[allow(clippy::cast_precision_loss)]
8462        Value::TimeTz { us, offset_secs } => Ok((us - i64::from(*offset_secs) * 1_000_000) as f64),
8463        // v7.17.0 Phase 3.P0-35 — PG MONEY ordered by i64 cents.
8464        #[allow(clippy::cast_precision_loss)]
8465        Value::Money(c) => Ok(*c as f64),
8466        // v7.17.0 Phase 3.P0-38 — range ordering is not supported
8467        // in v7.17.0 (needs lex-then-inclusivity tiebreak).
8468        Value::Range { .. } => Err(EngineError::Unsupported(
8469            "ORDER BY of a range value is not supported in v7.17.0".into(),
8470        )),
8471        // v7.17.0 Phase 3.P0-39 — hstore is not orderable.
8472        Value::Hstore(_) => Err(EngineError::Unsupported(
8473            "ORDER BY of a hstore value is not supported".into(),
8474        )),
8475        // v7.17.0 Phase 3.P0-40 — 2D arrays not orderable.
8476        Value::IntArray2D(_) | Value::BigIntArray2D(_) | Value::TextArray2D(_) => Err(
8477            EngineError::Unsupported("ORDER BY of a 2D array is not supported in v7.17.0".into()),
8478        ),
8479        #[allow(clippy::cast_precision_loss)]
8480        Value::Numeric { scaled, scale } => {
8481            // Scaled integer / 10^scale, computed via f64 for sort
8482            // ordering only. Precision losses here only matter for
8483            // ORDER BY tie-breaks well past 15 significant digits.
8484            // `f64::powi` lives in std; we hand-roll the loop so the
8485            // no_std engine crate doesn't need it.
8486            let mut divisor = 1.0_f64;
8487            for _ in 0..*scale {
8488                divisor *= 10.0;
8489            }
8490            Ok((*scaled as f64) / divisor)
8491        }
8492        #[allow(clippy::cast_precision_loss)]
8493        Value::BigInt(n) => Ok(*n as f64),
8494        Value::Float(x) => Ok(*x),
8495        Value::Bool(b) => Ok(if *b { 1.0 } else { 0.0 }),
8496        Value::Text(s) => {
8497            // Lex order by codepoints — good enough for ORDER BY name.
8498            // Map first 8 bytes packed into u64 as a coarse key; ties fall to
8499            // partial_cmp Equal. v1.x can swap in a real string comparator.
8500            let mut key: u64 = 0;
8501            for &b in s.as_bytes().iter().take(8) {
8502                key = (key << 8) | u64::from(b);
8503            }
8504            #[allow(clippy::cast_precision_loss)]
8505            Ok(key as f64)
8506        }
8507        Value::Vector(_) | Value::Sq8Vector(_) | Value::HalfVector(_) => {
8508            Err(EngineError::Unsupported(
8509                "ORDER BY of a raw vector column is not meaningful — use `<->`".into(),
8510            ))
8511        }
8512        Value::Interval { .. } => Err(EngineError::Unsupported(
8513            "ORDER BY of an INTERVAL is not supported in v2.11 \
8514             (months vs micros has no single canonical ordering)"
8515                .into(),
8516        )),
8517        Value::Json(_) => Err(EngineError::Unsupported(
8518            "ORDER BY of a JSON value is not supported — cast the document to text first".into(),
8519        )),
8520        // v7.5.0 — Value is #[non_exhaustive]; future variants need
8521        // an explicit ORDER BY mapping. Surface as Unsupported until
8522        // engine support is added.
8523        _ => Err(EngineError::Unsupported(
8524            "ORDER BY of this value type is not supported".into(),
8525        )),
8526    }
8527}
8528
8529/// Try to plan a WHERE clause as an equality lookup against an existing
8530/// index. Returns the candidate row indices on success; `None` means the
8531/// caller should fall back to a full scan.
8532///
8533/// v0.8 recognises a single top-level `col = literal` (in either operand
8534/// order). AND chains and range scans land in later milestones.
8535/// Look for `ORDER BY col <dist-op> literal LIMIT k` against an
8536/// NSW-indexed vector column. Recognised distance ops: `<->` (L2),
8537/// `<#>` (inner product), `<=>` (cosine). When a WHERE clause is
8538/// present, the planner does an "over-fetch and filter" pass — it
8539/// asks the graph for `k * over_fetch` candidates, evaluates WHERE
8540/// against each, and trims back to `k`. Returns the row indices in
8541/// ascending-distance order when the plan applies.
8542fn try_nsw_knn(
8543    stmt: &SelectStatement,
8544    table: &Table,
8545    schema_cols: &[ColumnSchema],
8546    table_alias: &str,
8547) -> Option<Vec<usize>> {
8548    if stmt.distinct {
8549        return None;
8550    }
8551    let limit = usize::try_from(stmt.limit_literal()?).ok()?;
8552    if limit == 0 {
8553        return None;
8554    }
8555    // v6.4.0 — NSW kNN dispatch needs a single ORDER BY key on the
8556    // distance metric. Multi-key ORDER BY falls through to the
8557    // generic sort path.
8558    if stmt.order_by.len() != 1 {
8559        return None;
8560    }
8561    let order = &stmt.order_by[0];
8562    // NSW kNN returns rows ascending by distance — DESC inverts the
8563    // natural order, so the planner can't handle it without a sort
8564    // pass. Fall back to the generic ORDER BY path.
8565    if order.desc {
8566        return None;
8567    }
8568    let Expr::Binary { lhs, op, rhs } = &order.expr else {
8569        return None;
8570    };
8571    let metric = match op {
8572        BinOp::L2Distance => spg_storage::NswMetric::L2,
8573        BinOp::InnerProduct => spg_storage::NswMetric::InnerProduct,
8574        BinOp::CosineDistance => spg_storage::NswMetric::Cosine,
8575        _ => return None,
8576    };
8577    // Accept both `col <op> literal` and `literal <op> col`.
8578    let ((Expr::Column(col), literal) | (literal, Expr::Column(col))) =
8579        (lhs.as_ref(), rhs.as_ref())
8580    else {
8581        return None;
8582    };
8583    if let Some(q) = &col.qualifier
8584        && q != table_alias
8585    {
8586        return None;
8587    }
8588    let col_pos = schema_cols.iter().position(|s| s.name == col.name)?;
8589    let query = literal_to_vector(literal)?;
8590    let idx = spg_storage::nsw_index_on(table, col_pos)?;
8591    if let Some(where_expr) = &stmt.where_ {
8592        // Over-fetch and filter. The factor (10×) is a heuristic that
8593        // covers typical selectivity for the corpus tests; v2.x will
8594        // make it configurable.
8595        let over_fetch = limit.saturating_mul(10).max(NSW_OVER_FETCH_FLOOR);
8596        let candidates = spg_storage::nsw_query(table, &idx.name, &query, over_fetch, metric);
8597        let ctx = EvalContext::new(schema_cols, Some(table_alias));
8598        let mut kept: Vec<usize> = Vec::with_capacity(limit);
8599        for i in candidates {
8600            let row = &table.rows()[i];
8601            let cond = eval::eval_expr(where_expr, row, &ctx).ok()?;
8602            if matches!(cond, Value::Bool(true)) {
8603                kept.push(i);
8604                if kept.len() >= limit {
8605                    break;
8606                }
8607            }
8608        }
8609        Some(kept)
8610    } else {
8611        Some(spg_storage::nsw_query(
8612            table, &idx.name, &query, limit, metric,
8613        ))
8614    }
8615}
8616
8617/// Lower bound on the over-fetch pool when WHERE is present — even
8618/// for tiny `LIMIT 1` queries we keep enough candidates to absorb a
8619/// few WHERE rejections.
8620const NSW_OVER_FETCH_FLOOR: usize = 32;
8621
8622/// Pull a `Vec<f32>` out of a literal-or-cast expression. Returns
8623/// `None` for anything we can't fold at plan time.
8624fn literal_to_vector(e: &Expr) -> Option<Vec<f32>> {
8625    match e {
8626        Expr::Literal(Literal::Vector(v)) => Some(v.clone()),
8627        Expr::Cast { expr, .. } => literal_to_vector(expr),
8628        _ => None,
8629    }
8630}
8631
8632/// Materialise rows in a planner-supplied order (used by the NSW path)
8633/// without re-running ORDER BY. The projection + LIMIT slot mirror the
8634/// equivalent block in `exec_bare_select`.
8635fn materialise_in_order(
8636    stmt: &SelectStatement,
8637    table: &Table,
8638    schema_cols: &[ColumnSchema],
8639    table_alias: &str,
8640    ordered_rows: &[usize],
8641) -> Result<QueryResult, EngineError> {
8642    let ctx = EvalContext::new(schema_cols, Some(table_alias));
8643    let projection = build_projection(&stmt.items, schema_cols, table_alias)?;
8644    let mut output_rows: Vec<Row> = Vec::with_capacity(ordered_rows.len());
8645    for &i in ordered_rows {
8646        let row = &table.rows()[i];
8647        let mut values = Vec::with_capacity(projection.len());
8648        for p in &projection {
8649            values.push(eval::eval_expr(&p.expr, row, &ctx)?);
8650        }
8651        output_rows.push(Row::new(values));
8652    }
8653    apply_offset_and_limit(
8654        &mut output_rows,
8655        stmt.offset_literal(),
8656        stmt.limit_literal(),
8657    );
8658    let columns: Vec<ColumnSchema> = projection
8659        .into_iter()
8660        .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
8661        .collect();
8662    Ok(QueryResult::Rows {
8663        columns,
8664        rows: output_rows,
8665    })
8666}
8667
8668/// v7.20 P4 — hot-row POSITION seek for the mutation paths
8669/// (UPDATE / DELETE index their planned writes by position in
8670/// `table.rows()`, so the Cow-row shape `try_index_seek`
8671/// returns doesn't fit). Same top-level-AND recursion and
8672/// col=literal resolution; the caller re-applies the full WHERE
8673/// to every returned row so the index only narrows candidates.
8674///
8675/// Returns `None` (→ caller full-scans) when no equality leaf
8676/// hits an index OR any matching locator lives in the cold tier
8677/// — the mutation paths operate on hot rows, and the PK
8678/// promote-then-walk upstream already handles the
8679/// cold-single-row case.
8680fn try_index_seek_positions(
8681    where_expr: &Expr,
8682    schema_cols: &[ColumnSchema],
8683    table: &Table,
8684    table_alias: &str,
8685) -> Option<Vec<usize>> {
8686    if let Expr::Binary {
8687        lhs,
8688        op: BinOp::And,
8689        rhs,
8690    } = where_expr
8691    {
8692        if let Some(p) = try_index_seek_positions(lhs, schema_cols, table, table_alias) {
8693            return Some(p);
8694        }
8695        return try_index_seek_positions(rhs, schema_cols, table, table_alias);
8696    }
8697    let Expr::Binary {
8698        lhs,
8699        op: BinOp::Eq,
8700        rhs,
8701    } = where_expr
8702    else {
8703        return None;
8704    };
8705    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
8706        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
8707    let idx = table.index_on(col_pos)?;
8708    let key = IndexKey::from_value(&value)?;
8709    let locators = idx.lookup_eq(&key);
8710    let mut out = Vec::with_capacity(locators.len());
8711    for loc in locators {
8712        match *loc {
8713            spg_storage::RowLocator::Hot(i) => out.push(i),
8714            spg_storage::RowLocator::Cold { .. } => return None,
8715        }
8716    }
8717    Some(out)
8718}
8719
8720fn try_index_seek<'a>(
8721    where_expr: &Expr,
8722    schema_cols: &[ColumnSchema],
8723    catalog: &'a Catalog,
8724    table: &'a Table,
8725    table_alias: &str,
8726) -> Option<Vec<Cow<'a, Row>>> {
8727    // v7.11.3 — recurse through top-level `AND` so a PG-style
8728    // composite predicate like `WHERE id = 1 AND created_at > $1`
8729    // still hits the index on `id`. The caller re-applies the
8730    // full WHERE expression to each returned row, so dropping the
8731    // residual conjuncts here is correct — the index just narrows
8732    // the candidate set.
8733    if let Expr::Binary {
8734        lhs,
8735        op: BinOp::And,
8736        rhs,
8737    } = where_expr
8738    {
8739        // Try LHS first (typical convention: leading equality on
8740        // the indexed column comes first in user-written SQL).
8741        if let Some(rows) = try_index_seek(lhs, schema_cols, catalog, table, table_alias) {
8742            return Some(rows);
8743        }
8744        return try_index_seek(rhs, schema_cols, catalog, table, table_alias);
8745    }
8746    let Expr::Binary {
8747        lhs,
8748        op: BinOp::Eq,
8749        rhs,
8750    } = where_expr
8751    else {
8752        return None;
8753    };
8754    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
8755        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
8756    let idx = table.index_on(col_pos)?;
8757    let key = IndexKey::from_value(&value)?;
8758    let locators = idx.lookup_eq(&key);
8759    let table_name = table.schema().name.as_str();
8760    // v5.1: each locator dispatches to either the hot tier (zero-
8761    // copy borrow of `table.rows()[i]`) or a cold-tier segment
8762    // (one page read + dense row decode, ~µs scale). Cold rows are
8763    // returned as `Cow::Owned` so the caller's `&Row` iteration
8764    // doesn't see a tier distinction; pre-freezer (no cold
8765    // segments loaded) every locator is `Hot` and every entry is
8766    // `Cow::Borrowed` — identical cost to the pre-v5.1 path.
8767    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(locators.len());
8768    for loc in locators {
8769        match *loc {
8770            spg_storage::RowLocator::Hot(i) => {
8771                if let Some(row) = table.rows().get(i) {
8772                    out.push(Cow::Borrowed(row));
8773                }
8774            }
8775            spg_storage::RowLocator::Cold { segment_id, .. } => {
8776                if let Some(row) = catalog.resolve_cold_locator(table_name, segment_id, &key) {
8777                    out.push(Cow::Owned(row));
8778                }
8779            }
8780        }
8781    }
8782    Some(out)
8783}
8784
8785/// v7.12.3 — GIN-accelerated candidate seek for `WHERE col @@ <ts_query>`.
8786///
8787/// Recurses through top-level `AND` like [`try_index_seek`] so a
8788/// composite predicate `WHERE search_vector @@ q AND id > $1` still
8789/// hits the GIN index on `search_vector` — the caller re-applies the
8790/// full WHERE expression to each returned candidate, so dropping the
8791/// `id > $1` residual here stays semantically correct.
8792///
8793/// Returns `None` when:
8794///   - no leaf is a `col @@ <rhs>` shape on a GIN-indexed column;
8795///   - the RHS can't be const-evaluated to a `Value::TsQuery`
8796///     (typically because it references row columns);
8797///   - the resolved `TsQuery` uses query shapes the MVP doesn't
8798///     accelerate (`Not`, `Phrase` — those fall through to full scan).
8799///
8800/// On `Some(rows)` the caller iterates only `rows` and re-evaluates
8801/// the full `@@` predicate per row, so an over-approximate candidate
8802/// set is safe.
8803fn try_gin_seek<'a>(
8804    where_expr: &Expr,
8805    schema_cols: &[ColumnSchema],
8806    catalog: &'a Catalog,
8807    table: &'a Table,
8808    table_alias: &str,
8809    ctx: &eval::EvalContext<'_>,
8810) -> Option<Vec<Cow<'a, Row>>> {
8811    if let Expr::Binary {
8812        lhs,
8813        op: BinOp::And,
8814        rhs,
8815    } = where_expr
8816    {
8817        if let Some(rows) = try_gin_seek(lhs, schema_cols, catalog, table, table_alias, ctx) {
8818            return Some(rows);
8819        }
8820        return try_gin_seek(rhs, schema_cols, catalog, table, table_alias, ctx);
8821    }
8822    // v7.17.0 Phase 3.P0-44 — MySQL `MATCH(col1, col2) AGAINST (...)`
8823    // desugars into `(to_tsvector(col1) @@ q) OR (to_tsvector(col2) @@ q)`
8824    // in the parser. To accelerate the multi-column case, walk OR the same
8825    // way we walk AND: only emit a candidate set if BOTH sides can seek
8826    // (otherwise the OR result is unbounded and we must fall through to
8827    // the full scan). Candidates are union'd; the caller's WHERE re-eval
8828    // verifies the full predicate per row, so duplicates / supersets stay
8829    // semantically safe.
8830    if let Expr::Binary {
8831        lhs,
8832        op: BinOp::Or,
8833        rhs,
8834    } = where_expr
8835    {
8836        let left = try_gin_seek(lhs, schema_cols, catalog, table, table_alias, ctx)?;
8837        let right = try_gin_seek(rhs, schema_cols, catalog, table, table_alias, ctx)?;
8838        let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(left.len() + right.len());
8839        out.extend(left);
8840        out.extend(right);
8841        return Some(out);
8842    }
8843    let Expr::Binary {
8844        lhs,
8845        op: BinOp::TsMatch,
8846        rhs,
8847    } = where_expr
8848    else {
8849        return None;
8850    };
8851    // Either side can be the column; pgvector idiom (`vec @@ q`)
8852    // hits the first arm, FROM-clause-derived (`plainto_tsquery($1)
8853    // q ... WHERE search_vector @@ q`) the same. CROSS JOIN derived
8854    // tables resolve `q` to a Column too.
8855    let (col_pos, query) = resolve_gin_col_query(lhs, rhs, schema_cols, table_alias, ctx)
8856        .or_else(|| resolve_gin_col_query(rhs, lhs, schema_cols, table_alias, ctx))?;
8857    // v7.17.0 Phase 3.P0-44 — MySQL `FULLTEXT KEY` builds a
8858    // `IndexKind::GinFulltext` posting list (Phase 2.2). It shares
8859    // the same `gin_lookup_word` shape as the tsvector-typed GIN,
8860    // so the MATCH-AGAINST `@@` predicate (desugared by the parser
8861    // into `to_tsvector(col) @@ plainto_tsquery('term')`) routes
8862    // through the same candidate-set seek.
8863    let idx = table
8864        .indices()
8865        .iter()
8866        .find(|i| i.column_position == col_pos && (i.is_gin() || i.is_gin_fulltext()))?;
8867    let candidates = gin_query_candidates(idx, &query)?;
8868    let _ = catalog; // cold-tier row resolution unused in MVP; see below.
8869    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(candidates.len());
8870    for loc in candidates {
8871        match loc {
8872            spg_storage::RowLocator::Hot(i) => {
8873                if let Some(row) = table.rows().get(i) {
8874                    out.push(Cow::Borrowed(row));
8875                }
8876            }
8877            // GIN cold-tier rows in the MVP: skipped, matching the
8878            // full-scan `@@` path which itself only iterates
8879            // `table.rows()` (hot tier). When v7.13+ adds cold-tier
8880            // scan-time materialisation for `@@`, the parallel
8881            // resolution lands here; until then both paths see the
8882            // same hot-only candidate set so correctness is preserved.
8883            spg_storage::RowLocator::Cold { .. } => {}
8884        }
8885    }
8886    Some(out)
8887}
8888
8889/// v7.15.0 — trigram-GIN-accelerated candidate seek for
8890/// `WHERE col LIKE '<pat>'` and `WHERE col ILIKE '<pat>'` when
8891/// the column has a `gin_trgm_ops` GIN index.
8892///
8893/// Walks top-level `AND` so multi-predicate WHEREs (`col LIKE
8894/// 'foo%' AND id > 1`) still hit the trigram index; the caller
8895/// re-evaluates the full WHERE per candidate row, so dropping
8896/// non-LIKE conjuncts here stays semantically correct.
8897///
8898/// Returns `None` when:
8899///   - no leaf is `col LIKE/ILIKE <literal>` on a trigram-GIN-
8900///     indexed column;
8901///   - the pattern's literal runs are too short to constrain
8902///     (pattern decomposes into `< 3`-char runs, e.g. `%ab%`);
8903///   - the pattern doesn't const-evaluate to a TEXT.
8904fn try_trgm_seek<'a>(
8905    where_expr: &Expr,
8906    schema_cols: &[ColumnSchema],
8907    table: &'a Table,
8908    table_alias: &str,
8909) -> Option<Vec<Cow<'a, Row>>> {
8910    if let Expr::Binary {
8911        lhs,
8912        op: BinOp::And,
8913        rhs,
8914    } = where_expr
8915    {
8916        if let Some(rows) = try_trgm_seek(lhs, schema_cols, table, table_alias) {
8917            return Some(rows);
8918        }
8919        return try_trgm_seek(rhs, schema_cols, table, table_alias);
8920    }
8921    // LIKE node is what carries the column reference + pattern.
8922    // ILIKE is the same AST node — PG's LIKE/ILIKE both lower
8923    // through `Expr::Like { expr, pattern, negated }`. The trigram
8924    // index posting-list keys are already lower-cased and
8925    // case-folded, so we only need the pattern's literal runs.
8926    let Expr::Like { expr, pattern, .. } = where_expr else {
8927        return None;
8928    };
8929    // Column side.
8930    let Expr::Column(c) = expr.as_ref() else {
8931        return None;
8932    };
8933    if let Some(q) = &c.qualifier
8934        && q != table_alias
8935    {
8936        return None;
8937    }
8938    let col_pos = schema_cols
8939        .iter()
8940        .position(|s| s.name.eq_ignore_ascii_case(&c.name))?;
8941    // Index must exist on that column AND be a trigram-GIN.
8942    let idx = table
8943        .indices()
8944        .iter()
8945        .find(|i| i.column_position == col_pos && i.is_gin_trgm())?;
8946    // Pattern side must be a literal TEXT — anything else (column
8947    // ref, function call, parameter that hasn't been bound yet)
8948    // falls through to full scan.
8949    let Expr::Literal(spg_sql::ast::Literal::String(pat)) = pattern.as_ref() else {
8950        return None;
8951    };
8952    let trigrams = spg_storage::trgm::trigrams_from_like_pattern(pat)?;
8953    // Intersect every trigram's posting list. Empty intersection
8954    // → empty candidate set (caller short-circuits its row loop).
8955    let mut iter = trigrams.iter();
8956    let first = iter.next()?;
8957    let mut acc: Vec<spg_storage::RowLocator> = {
8958        let mut v = idx.gin_trgm_lookup(first).to_vec();
8959        v.sort_by_key(locator_sort_key);
8960        v.dedup_by_key(|l| locator_sort_key(l));
8961        v
8962    };
8963    for tri in iter {
8964        let mut next: Vec<spg_storage::RowLocator> = idx.gin_trgm_lookup(tri).to_vec();
8965        next.sort_by_key(locator_sort_key);
8966        next.dedup_by_key(|l| locator_sort_key(l));
8967        // Sorted-merge intersection.
8968        let mut merged: Vec<spg_storage::RowLocator> =
8969            Vec::with_capacity(acc.len().min(next.len()));
8970        let (mut i, mut j) = (0usize, 0usize);
8971        while i < acc.len() && j < next.len() {
8972            let lk = locator_sort_key(&acc[i]);
8973            let rk = locator_sort_key(&next[j]);
8974            match lk.cmp(&rk) {
8975                core::cmp::Ordering::Less => i += 1,
8976                core::cmp::Ordering::Greater => j += 1,
8977                core::cmp::Ordering::Equal => {
8978                    merged.push(acc[i]);
8979                    i += 1;
8980                    j += 1;
8981                }
8982            }
8983        }
8984        acc = merged;
8985        if acc.is_empty() {
8986            break;
8987        }
8988    }
8989    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(acc.len());
8990    for loc in acc {
8991        if let spg_storage::RowLocator::Hot(i) = loc
8992            && let Some(row) = table.rows().get(i)
8993        {
8994            out.push(Cow::Borrowed(row));
8995        }
8996        // Cold-tier rows: skipped in MVP (same as try_gin_seek).
8997    }
8998    Some(out)
8999}
9000
9001/// v7.12.3 — extract `(column_position, TsQueryAst)` when one side of
9002/// the binary is a column reference to a GIN-indexed tsvector column
9003/// and the other side const-evaluates to a `Value::TsQuery`. Returns
9004/// `None` if the column reference is for the wrong table alias, or if
9005/// the RHS expression depends on row data.
9006fn resolve_gin_col_query(
9007    col_side: &Expr,
9008    query_side: &Expr,
9009    schema_cols: &[ColumnSchema],
9010    table_alias: &str,
9011    ctx: &eval::EvalContext<'_>,
9012) -> Option<(usize, spg_storage::TsQueryAst)> {
9013    // v7.17.0 Phase 3.P0-44 — the MATCH AGAINST desugar wraps the
9014    // column in `to_tsvector('simple', col)`, so we peel that wrapper
9015    // before the column lookup. Direct `col @@ tsquery` paths (the
9016    // tsvector-typed v7.12 surface) skip the wrapper entirely.
9017    let column = match col_side {
9018        Expr::Column(c) => c,
9019        Expr::FunctionCall { name, args }
9020            if name.eq_ignore_ascii_case("to_tsvector") && !args.is_empty() =>
9021        {
9022            // PG `to_tsvector` accepts either `to_tsvector(col)` or
9023            // `to_tsvector(config, col)`. In both shapes the column
9024            // we care about is the final argument.
9025            if let Expr::Column(c) = args.last().unwrap() {
9026                c
9027            } else {
9028                return None;
9029            }
9030        }
9031        _ => return None,
9032    };
9033    let c = column;
9034    if let Some(q) = &c.qualifier
9035        && q != table_alias
9036    {
9037        return None;
9038    }
9039    let pos = schema_cols.iter().position(|s| s.name == c.name)?;
9040    // Const-evaluate the query side with an empty row — fails fast
9041    // (with a `ColumnNotFound` / similar) if the expression actually
9042    // depends on row data, which is exactly the bail signal we want.
9043    let empty_row = Row::new(Vec::new());
9044    let v = eval::eval_expr(query_side, &empty_row, ctx).ok()?;
9045    let Value::TsQuery(q) = v else { return None };
9046    Some((pos, q))
9047}
9048
9049/// v7.12.3 — walk a `TsQueryAst` against an [`IndexKind::Gin`] index
9050/// to produce a candidate row-locator set. Returns `None` for query
9051/// shapes the MVP doesn't accelerate (`Not` / `Phrase` — both bail to
9052/// full scan since their semantics need either complementation across
9053/// the whole row set or positional verification beyond what the
9054/// posting list carries).
9055///
9056/// Candidate sets are over-approximate — the caller re-applies the
9057/// full `@@` predicate per row, so reporting "row was in some
9058/// posting list" without verifying positions / weights stays correct.
9059fn gin_query_candidates(
9060    idx: &spg_storage::Index,
9061    query: &spg_storage::TsQueryAst,
9062) -> Option<Vec<spg_storage::RowLocator>> {
9063    use spg_storage::TsQueryAst;
9064    match query {
9065        TsQueryAst::Term { word, .. } => {
9066            let mut v: Vec<spg_storage::RowLocator> = idx.gin_lookup_word(word).to_vec();
9067            v.sort_by_key(locator_sort_key);
9068            v.dedup_by_key(|l| locator_sort_key(l));
9069            Some(v)
9070        }
9071        TsQueryAst::And(l, r) => {
9072            let mut left = gin_query_candidates(idx, l)?;
9073            let mut right = gin_query_candidates(idx, r)?;
9074            left.sort_by_key(locator_sort_key);
9075            right.sort_by_key(locator_sort_key);
9076            // Sorted-merge intersection.
9077            let mut out: Vec<spg_storage::RowLocator> = Vec::new();
9078            let (mut i, mut j) = (0usize, 0usize);
9079            while i < left.len() && j < right.len() {
9080                let lk = locator_sort_key(&left[i]);
9081                let rk = locator_sort_key(&right[j]);
9082                match lk.cmp(&rk) {
9083                    core::cmp::Ordering::Less => i += 1,
9084                    core::cmp::Ordering::Greater => j += 1,
9085                    core::cmp::Ordering::Equal => {
9086                        out.push(left[i]);
9087                        i += 1;
9088                        j += 1;
9089                    }
9090                }
9091            }
9092            Some(out)
9093        }
9094        TsQueryAst::Or(l, r) => {
9095            let mut out = gin_query_candidates(idx, l)?;
9096            out.extend(gin_query_candidates(idx, r)?);
9097            out.sort_by_key(locator_sort_key);
9098            out.dedup_by_key(|l| locator_sort_key(l));
9099            Some(out)
9100        }
9101        // Not / Phrase bail to full scan in the MVP. Not needs
9102        // complementation against the whole row set (not represented
9103        // in the posting-list view); Phrase needs positional
9104        // verification beyond what `word → rows` carries.
9105        TsQueryAst::Not(_) | TsQueryAst::Phrase { .. } => None,
9106    }
9107}
9108
9109/// v7.12.3 — total ordering on `RowLocator` for sort/dedup purposes
9110/// inside the GIN intersection / union loops. Hot rows order by their
9111/// row index; Cold rows order after all Hot rows, then by
9112/// `(segment_id, the cold sub-key)`.
9113fn locator_sort_key(l: &spg_storage::RowLocator) -> (u8, u64, u64) {
9114    match *l {
9115        spg_storage::RowLocator::Hot(i) => (0, i as u64, 0),
9116        spg_storage::RowLocator::Cold {
9117            segment_id,
9118            page_offset,
9119        } => (1, u64::from(segment_id), u64::from(page_offset)),
9120    }
9121}
9122
9123/// v5.2.3: extract `(column_position, IndexKey)` when `where_expr`
9124/// is a simple `col = literal` predicate suitable for a `BTree` index
9125/// seek. Used by `exec_update_cancel` / `exec_delete_cancel` to
9126/// decide whether a write touches a cold-tier row (which requires
9127/// promote-on-write / shadow-on-delete) before falling through to
9128/// the hot-tier row walk.
9129///
9130/// Returns `None` for any predicate shape the planner can't push
9131/// down to an index seek — complex WHERE clauses always take the
9132/// hot-only path (cold rows are immutable to non-indexed writes
9133/// until a future scan-fanout sub-version).
9134fn try_pk_predicate(
9135    where_expr: &Expr,
9136    schema_cols: &[ColumnSchema],
9137    table_alias: &str,
9138) -> Option<(usize, IndexKey)> {
9139    let Expr::Binary {
9140        lhs,
9141        op: BinOp::Eq,
9142        rhs,
9143    } = where_expr
9144    else {
9145        return None;
9146    };
9147    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
9148        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
9149    let key = IndexKey::from_value(&value)?;
9150    Some((col_pos, key))
9151}
9152
9153fn resolve_col_literal_pair(
9154    col_side: &Expr,
9155    lit_side: &Expr,
9156    schema_cols: &[ColumnSchema],
9157    table_alias: &str,
9158) -> Option<(usize, Value)> {
9159    let Expr::Column(c) = col_side else {
9160        return None;
9161    };
9162    if let Some(q) = &c.qualifier
9163        && q != table_alias
9164    {
9165        return None;
9166    }
9167    let pos = schema_cols.iter().position(|s| s.name == c.name)?;
9168    let Expr::Literal(l) = lit_side else {
9169        return None;
9170    };
9171    let v = match l {
9172        Literal::Integer(n) => {
9173            if let Ok(small) = i32::try_from(*n) {
9174                Value::Int(small)
9175            } else {
9176                Value::BigInt(*n)
9177            }
9178        }
9179        Literal::Float(x) => Value::Float(*x),
9180        Literal::String(s) => Value::Text(s.clone()),
9181        Literal::Bool(b) => Value::Bool(*b),
9182        Literal::Null => Value::Null,
9183        // Vector, array and Interval literals can't be used as B-tree
9184        // index keys. Tell the planner to fall back to full-scan.
9185        Literal::Vector(_)
9186        | Literal::Interval { .. }
9187        | Literal::TextArray(_)
9188        | Literal::IntArray(_)
9189        | Literal::BigIntArray(_) => return None,
9190    };
9191    Some((pos, v))
9192}
9193
9194/// Find the schema entry that a SELECT-list `Expr::Column` refers to.
9195/// Mirrors `resolve_column` in `eval.rs`, but returns a proper
9196/// `EngineError` so the projection-build path keeps `UnknownQualifier`
9197/// vs `ColumnNotFound` distinct.
9198fn resolve_projection_column<'a>(
9199    c: &ColumnName,
9200    schema_cols: &'a [ColumnSchema],
9201    table_alias: &str,
9202) -> Result<&'a ColumnSchema, EngineError> {
9203    if let Some(q) = &c.qualifier {
9204        let composite = alloc::format!("{q}.{name}", name = c.name);
9205        if let Some(s) = schema_cols.iter().find(|s| s.name == composite) {
9206            return Ok(s);
9207        }
9208        // Single-table case: the qualifier may equal the active alias —
9209        // then look for the bare column name.
9210        if q == table_alias
9211            && let Some(s) = schema_cols.iter().find(|s| s.name == c.name)
9212        {
9213            return Ok(s);
9214        }
9215        // For multi-table schemas the qualifier is unknown only if no
9216        // column bears the "<q>." prefix. For single-table, the alias
9217        // mismatch alone is enough.
9218        let prefix = alloc::format!("{q}.");
9219        let qualifier_known =
9220            q == table_alias || schema_cols.iter().any(|s| s.name.starts_with(&prefix));
9221        if !qualifier_known {
9222            return Err(EngineError::Eval(EvalError::UnknownQualifier {
9223                qualifier: q.clone(),
9224            }));
9225        }
9226        return Err(EngineError::Eval(EvalError::ColumnNotFound {
9227            name: c.name.clone(),
9228        }));
9229    }
9230    if let Some(s) = schema_cols.iter().find(|s| s.name == c.name) {
9231        return Ok(s);
9232    }
9233    let suffix = alloc::format!(".{name}", name = c.name);
9234    let mut matches = schema_cols.iter().filter(|s| s.name.ends_with(&suffix));
9235    let first = matches.next();
9236    let extra = matches.next();
9237    match (first, extra) {
9238        (Some(s), None) => Ok(s),
9239        (Some(_), Some(_)) => Err(EngineError::Eval(EvalError::TypeMismatch {
9240            detail: alloc::format!("ambiguous column reference: {}", c.name),
9241        })),
9242        _ => Err(EngineError::Eval(EvalError::ColumnNotFound {
9243            name: c.name.clone(),
9244        })),
9245    }
9246}
9247
9248fn build_projection(
9249    items: &[SelectItem],
9250    schema_cols: &[ColumnSchema],
9251    table_alias: &str,
9252) -> Result<Vec<ProjectedItem>, EngineError> {
9253    let mut out = Vec::new();
9254    for item in items {
9255        match item {
9256            SelectItem::Wildcard => {
9257                for col in schema_cols {
9258                    out.push(ProjectedItem {
9259                        expr: Expr::Column(ColumnName {
9260                            qualifier: None,
9261                            name: col.name.clone(),
9262                        }),
9263                        output_name: col.name.clone(),
9264                        ty: col.ty,
9265                        nullable: col.nullable,
9266                    });
9267                }
9268            }
9269            SelectItem::Expr { expr, alias } => {
9270                // Plain column ref keeps full schema info (real type +
9271                // nullability). For compound expressions try the
9272                // describe-side function-return-type table first
9273                // (e.g. `SELECT now()` → Timestamptz, `SELECT
9274                // concat(…)` → Text). Falls back to nullable Text
9275                // for shapes the describe path can't resolve.
9276                if let Expr::Column(c) = expr {
9277                    let sch = resolve_projection_column(c, schema_cols, table_alias)?;
9278                    let output_name = alias.clone().unwrap_or_else(|| c.name.clone());
9279                    out.push(ProjectedItem {
9280                        expr: expr.clone(),
9281                        output_name,
9282                        ty: sch.ty,
9283                        nullable: sch.nullable,
9284                    });
9285                } else if let Some(shape) = describe::describe_expr(expr, schema_cols) {
9286                    let output_name = alias.clone().unwrap_or_else(|| expr.to_string());
9287                    out.push(ProjectedItem {
9288                        expr: expr.clone(),
9289                        output_name,
9290                        ty: shape.ty,
9291                        nullable: shape.nullable,
9292                    });
9293                } else {
9294                    let output_name = alias.clone().unwrap_or_else(|| expr.to_string());
9295                    out.push(ProjectedItem {
9296                        expr: expr.clone(),
9297                        output_name,
9298                        ty: DataType::Text,
9299                        nullable: true,
9300                    });
9301                }
9302            }
9303        }
9304    }
9305    Ok(out)
9306}
9307
9308/// Promote an integer to a NUMERIC value at the requested scale.
9309/// Rejects values that, after scaling, would overflow the column's
9310/// precision budget.
9311fn numeric_from_integer(
9312    n: i128,
9313    precision: u8,
9314    scale: u8,
9315    col_name: &str,
9316) -> Result<Value, EngineError> {
9317    let factor = pow10_i128(scale);
9318    let scaled = n.checked_mul(factor).ok_or_else(|| {
9319        EngineError::Unsupported(alloc::format!(
9320            "integer overflow scaling value for column `{col_name}` to scale {scale}"
9321        ))
9322    })?;
9323    check_precision(scaled, precision, col_name)?;
9324    Ok(Value::Numeric { scaled, scale })
9325}
9326
9327/// Float → NUMERIC. Uses round-half-away-from-zero on `x * 10^scale`,
9328/// then verifies the result fits the column's precision.
9329#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
9330fn numeric_from_float(
9331    x: f64,
9332    precision: u8,
9333    scale: u8,
9334    col_name: &str,
9335) -> Result<Value, EngineError> {
9336    if !x.is_finite() {
9337        return Err(EngineError::Unsupported(alloc::format!(
9338            "cannot store non-finite float in NUMERIC column `{col_name}`"
9339        )));
9340    }
9341    let mut factor = 1.0_f64;
9342    for _ in 0..scale {
9343        factor *= 10.0;
9344    }
9345    // Round half-away-from-zero by biasing then casting (`as i128`
9346    // truncates toward zero, so the bias + truncation gives the
9347    // desired rounding). `f64::floor` / `ceil` live in std; we don't
9348    // need them — the cast handles the truncation step.
9349    let shifted = x * factor;
9350    let biased = if shifted >= 0.0 {
9351        shifted + 0.5
9352    } else {
9353        shifted - 0.5
9354    };
9355    // Range-check before casting back to i128 — the cast itself is
9356    // saturating in Rust, which would silently truncate huge inputs.
9357    if !(-1e38..=1e38).contains(&biased) {
9358        return Err(EngineError::Unsupported(alloc::format!(
9359            "value {x} overflows NUMERIC range for column `{col_name}`"
9360        )));
9361    }
9362    let scaled = biased as i128;
9363    check_precision(scaled, precision, col_name)?;
9364    Ok(Value::Numeric { scaled, scale })
9365}
9366
9367/// v7.17.0 Phase 3.P0-67 — parse PG-canonical decimal text into
9368/// `(mantissa: i128, source_scale: u8)`. Accepts optional sign,
9369/// optional integer part, optional fractional part. Rejects
9370/// scientific notation, embedded spaces, locale-specific
9371/// thousand separators. Returns None on bad input — coerce_value
9372/// turns that into a TypeMismatch error.
9373fn parse_numeric_text(s: &str) -> Option<(i128, u8)> {
9374    let s = s.trim();
9375    if s.is_empty() {
9376        return None;
9377    }
9378    let (negative, rest) = match s.as_bytes()[0] {
9379        b'-' => (true, &s[1..]),
9380        b'+' => (false, &s[1..]),
9381        _ => (false, s),
9382    };
9383    if rest.is_empty() {
9384        return None;
9385    }
9386    // Reject scientific notation — bigdecimal collapses it before
9387    // hitting the wire, and we want a clear error if a stray `e`
9388    // sneaks in.
9389    if rest.bytes().any(|b| b == b'e' || b == b'E') {
9390        return None;
9391    }
9392    let (int_part, frac_part) = match rest.find('.') {
9393        Some(idx) => (&rest[..idx], &rest[idx + 1..]),
9394        None => (rest, ""),
9395    };
9396    if int_part.is_empty() && frac_part.is_empty() {
9397        return None;
9398    }
9399    if int_part.bytes().any(|b| !b.is_ascii_digit()) {
9400        return None;
9401    }
9402    if frac_part.bytes().any(|b| !b.is_ascii_digit()) {
9403        return None;
9404    }
9405    let scale_u32 = u32::try_from(frac_part.len()).ok()?;
9406    if scale_u32 > u32::from(u8::MAX) {
9407        return None;
9408    }
9409    let scale = scale_u32 as u8;
9410    let mut digits = alloc::string::String::with_capacity(int_part.len() + frac_part.len() + 1);
9411    if negative {
9412        digits.push('-');
9413    }
9414    digits.push_str(int_part);
9415    digits.push_str(frac_part);
9416    // Strip a leading "+0..0" so parse doesn't choke on "00" etc.
9417    let digits = if digits == "-" {
9418        return None;
9419    } else if digits.is_empty() {
9420        "0"
9421    } else {
9422        digits.as_str()
9423    };
9424    let mantissa: i128 = digits.parse().ok()?;
9425    Some((mantissa, scale))
9426}
9427
9428/// Move a Numeric value from `src_scale` to `dst_scale`. Going up
9429/// multiplies by 10; going down rounds half-away-from-zero.
9430fn numeric_rescale(
9431    scaled: i128,
9432    src_scale: u8,
9433    precision: u8,
9434    dst_scale: u8,
9435    col_name: &str,
9436) -> Result<Value, EngineError> {
9437    let new_scaled = if dst_scale >= src_scale {
9438        let bump = pow10_i128(dst_scale - src_scale);
9439        scaled.checked_mul(bump).ok_or_else(|| {
9440            EngineError::Unsupported(alloc::format!(
9441                "overflow rescaling NUMERIC for column `{col_name}`"
9442            ))
9443        })?
9444    } else {
9445        let drop = pow10_i128(src_scale - dst_scale);
9446        let half = drop / 2;
9447        if scaled >= 0 {
9448            (scaled + half) / drop
9449        } else {
9450            (scaled - half) / drop
9451        }
9452    };
9453    check_precision(new_scaled, precision, col_name)?;
9454    Ok(Value::Numeric {
9455        scaled: new_scaled,
9456        scale: dst_scale,
9457    })
9458}
9459
9460/// Drop the fractional part of a scaled integer, returning the integer
9461/// portion (toward zero). Used for NUMERIC → INT casts.
9462const fn numeric_truncate_to_integer(scaled: i128, scale: u8) -> i128 {
9463    if scale == 0 {
9464        return scaled;
9465    }
9466    let factor = pow10_i128_const(scale);
9467    scaled / factor
9468}
9469
9470/// Verify a scaled NUMERIC value fits the column's declared precision.
9471/// `precision == 0` is the "unconstrained" form (bare `NUMERIC`); we
9472/// skip the check there.
9473fn check_precision(scaled: i128, precision: u8, col_name: &str) -> Result<(), EngineError> {
9474    if precision == 0 {
9475        return Ok(());
9476    }
9477    let limit = pow10_i128(precision);
9478    if scaled.unsigned_abs() >= limit.unsigned_abs() {
9479        return Err(EngineError::Unsupported(alloc::format!(
9480            "NUMERIC value exceeds precision {precision} for column `{col_name}`"
9481        )));
9482    }
9483    Ok(())
9484}
9485
9486const fn pow10_i128_const(p: u8) -> i128 {
9487    let mut acc: i128 = 1;
9488    let mut i = 0;
9489    while i < p {
9490        acc *= 10;
9491        i += 1;
9492    }
9493    acc
9494}
9495
9496fn pow10_i128(p: u8) -> i128 {
9497    pow10_i128_const(p)
9498}
9499
9500/// Walk a parsed `Statement`, swapping any `NOW()` /
9501/// `CURRENT_TIMESTAMP()` / `CURRENT_DATE()` function calls for a
9502/// literal cast that wraps the engine's per-statement clock reading.
9503/// When `now_micros` is `None`, calls stay as-is and surface as
9504/// `unknown function` at eval time — keeps the error path explicit.
9505/// v4.10: pre-walk the WHERE / projection / etc. of a SELECT and
9506/// replace every subquery node with a materialised literal. SPG
9507/// only supports uncorrelated subqueries — the inner SELECT does
9508/// not see outer-row columns, so the result is the same for every
9509/// outer row and can be evaluated once.
9510///
9511/// Returns the rewritten statement; the caller passes this to the
9512/// regular row-loop executor which no longer sees Subquery nodes
9513/// in its tree.
9514impl Engine {
9515    /// v4.12 window executor. Implements `ROW_NUMBER` / `RANK` /
9516    /// `DENSE_RANK` and the partition-aware aggregates `SUM` /
9517    /// `AVG` / `COUNT` / `MIN` / `MAX`. The plan is:
9518    /// 1. Apply the WHERE filter.
9519    /// 2. For each unique `WindowFunction` node in the projection,
9520    ///    partition + sort, compute the per-row value.
9521    /// 3. Append the window values as synthetic columns (`__win_N`)
9522    ///    to the row schema.
9523    /// 4. Rewrite the projection to read those columns.
9524    /// 5. Hand off to the regular project / ORDER BY / LIMIT pipe.
9525    #[allow(
9526        clippy::too_many_lines,
9527        clippy::type_complexity,
9528        clippy::needless_range_loop
9529    )] // window-eval is one cohesive pipe; splitting fragments
9530    fn exec_select_with_window(
9531        &self,
9532        stmt: &SelectStatement,
9533        cancel: CancelToken<'_>,
9534    ) -> Result<QueryResult, EngineError> {
9535        let from = stmt.from.as_ref().ok_or_else(|| {
9536            EngineError::Unsupported("window functions require a FROM clause".into())
9537        })?;
9538        // v7.17.0 Phase 3.P0-43 — JOIN + window functions. Phase
9539        // 3.6 rejected this combination outright ("queued for
9540        // v5.x"); P0-43 materialises the join + WHERE through the
9541        // existing nested-loop helper and runs the window pipeline
9542        // on the joined row set with the combined `alias.col`
9543        // schema. The window expressions resolve through the
9544        // qualifier-aware column resolver same as the aggregate /
9545        // projection paths on JOIN.
9546        let (schema_cols_owned, alias_opt): (Vec<ColumnSchema>, Option<&str>);
9547        let filtered: Vec<Row>;
9548        if from.joins.is_empty() {
9549            let primary = &from.primary;
9550            let table = self.active_catalog().get(&primary.name).ok_or_else(|| {
9551                StorageError::TableNotFound {
9552                    name: primary.name.clone(),
9553                }
9554            })?;
9555            let alias = primary.alias.as_deref().unwrap_or(primary.name.as_str());
9556            schema_cols_owned = table.schema().columns.clone();
9557            alias_opt = Some(alias);
9558            // Materialise WHERE-filtered rows owned so the JOIN
9559            // and single-table paths share a single downstream
9560            // shape. The clone is cheap relative to the window
9561            // computation that follows.
9562            let ctx = self.ev_ctx(&schema_cols_owned, alias_opt);
9563            let mut owned: Vec<Row> = Vec::new();
9564            for (i, row) in table.rows().iter().enumerate() {
9565                if i.is_multiple_of(256) {
9566                    cancel.check()?;
9567                }
9568                if let Some(w) = &stmt.where_ {
9569                    let cond = eval::eval_expr(w, row, &ctx)?;
9570                    if !matches!(cond, Value::Bool(true)) {
9571                        continue;
9572                    }
9573                }
9574                owned.push(row.clone());
9575            }
9576            filtered = owned;
9577        } else {
9578            let (combined_schema, rows) =
9579                self.build_joined_filtered_rows(from, stmt.where_.as_ref())?;
9580            schema_cols_owned = combined_schema;
9581            alias_opt = None;
9582            filtered = rows;
9583        }
9584        let schema_cols = &schema_cols_owned;
9585        let ctx = self.ev_ctx(schema_cols, alias_opt);
9586        let alias = alias_opt.unwrap_or("");
9587        let n_rows = filtered.len();
9588        // Borrow refs into the owned row vec once so the downstream
9589        // `compute_window_partition` call (which takes `&[&Row]`) and
9590        // the per-row eval loops share a single backing buffer.
9591        let filtered_refs: Vec<&Row> = filtered.iter().collect();
9592
9593        // 2) Collect unique window function nodes from projection.
9594        let mut window_nodes: Vec<Expr> = Vec::new();
9595        for item in &stmt.items {
9596            if let SelectItem::Expr { expr, .. } = item {
9597                collect_window_nodes(expr, &mut window_nodes);
9598            }
9599        }
9600
9601        // 3) For each window, compute per-row value.
9602        // Index: same order as window_nodes; for row i, win_vals[w][i].
9603        let mut win_vals: Vec<Vec<Value>> = Vec::with_capacity(window_nodes.len());
9604        for wnode in &window_nodes {
9605            let Expr::WindowFunction {
9606                name,
9607                args,
9608                partition_by,
9609                order_by,
9610                frame,
9611                null_treatment,
9612            } = wnode
9613            else {
9614                unreachable!("collect_window_nodes pushes only WindowFunction");
9615            };
9616            // Compute (partition_key, order_key, original_index) for each row.
9617            let mut indexed: Vec<(Vec<Value>, Vec<(Value, bool)>, usize)> =
9618                Vec::with_capacity(n_rows);
9619            for (i, row) in filtered.iter().enumerate() {
9620                let pkey: Vec<Value> = partition_by
9621                    .iter()
9622                    .map(|p| eval::eval_expr(p, row, &ctx))
9623                    .collect::<Result<_, _>>()?;
9624                let okey: Vec<(Value, bool)> = order_by
9625                    .iter()
9626                    .map(|(e, desc)| eval::eval_expr(e, row, &ctx).map(|v| (v, *desc)))
9627                    .collect::<Result<_, _>>()?;
9628                indexed.push((pkey, okey, i));
9629            }
9630            // Sort by (partition_key, order_key). Partition key uses
9631            // a stable encoded form; order key respects ASC/DESC.
9632            indexed.sort_by(|a, b| {
9633                let p_cmp = partition_key_cmp(&a.0, &b.0);
9634                if p_cmp != core::cmp::Ordering::Equal {
9635                    return p_cmp;
9636                }
9637                order_key_cmp(&a.1, &b.1)
9638            });
9639            // Per-partition compute.
9640            let mut out_vals: Vec<Value> = alloc::vec![Value::Null; n_rows];
9641            let mut p_start = 0;
9642            while p_start < indexed.len() {
9643                let mut p_end = p_start + 1;
9644                while p_end < indexed.len()
9645                    && partition_key_cmp(&indexed[p_start].0, &indexed[p_end].0)
9646                        == core::cmp::Ordering::Equal
9647                {
9648                    p_end += 1;
9649                }
9650                // Compute the function within this partition slice.
9651                compute_window_partition(
9652                    name,
9653                    args,
9654                    !order_by.is_empty(),
9655                    frame.as_ref(),
9656                    *null_treatment,
9657                    &indexed[p_start..p_end],
9658                    &filtered_refs,
9659                    &ctx,
9660                    &mut out_vals,
9661                )?;
9662                p_start = p_end;
9663            }
9664            win_vals.push(out_vals);
9665        }
9666
9667        // 4) Build extended schema: original columns + synthetic.
9668        let mut ext_cols = schema_cols.clone();
9669        for i in 0..window_nodes.len() {
9670            ext_cols.push(ColumnSchema::new(
9671                alloc::format!("__win_{i}"),
9672                DataType::Text, // type doesn't matter for projection eval
9673                true,
9674            ));
9675        }
9676        // 5) Build extended rows: each row gets its window values appended.
9677        let mut ext_rows: Vec<Row> = Vec::with_capacity(n_rows);
9678        for i in 0..n_rows {
9679            let mut values = filtered[i].values.clone();
9680            for w in 0..window_nodes.len() {
9681                values.push(win_vals[w][i].clone());
9682            }
9683            ext_rows.push(Row::new(values));
9684        }
9685        // 6) Rewrite the projection: WindowFunction nodes → Column(__win_N).
9686        let mut rewritten_items: Vec<SelectItem> = Vec::with_capacity(stmt.items.len());
9687        for item in &stmt.items {
9688            let new_item = match item {
9689                SelectItem::Wildcard => SelectItem::Wildcard,
9690                SelectItem::Expr { expr, alias } => {
9691                    let mut e = expr.clone();
9692                    rewrite_window_to_columns(&mut e, &window_nodes);
9693                    SelectItem::Expr {
9694                        expr: e,
9695                        alias: alias.clone(),
9696                    }
9697                }
9698            };
9699            rewritten_items.push(new_item);
9700        }
9701
9702        // 7) Project into final rows. JOIN case uses None so the
9703        // qualifier check in `resolve_column` falls through to the
9704        // composite `alias.col` schema lookup; single-table case
9705        // keeps the bare alias so `bare_col` resolution still
9706        // works for the projection's per-row column references.
9707        let ext_ctx = EvalContext::new(&ext_cols, alias_opt);
9708        let projection = build_projection(&rewritten_items, &ext_cols, alias)?;
9709        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::with_capacity(n_rows);
9710        for (i, row) in ext_rows.iter().enumerate() {
9711            if i.is_multiple_of(256) {
9712                cancel.check()?;
9713            }
9714            let mut values = Vec::with_capacity(projection.len());
9715            for p in &projection {
9716                values.push(eval::eval_expr(&p.expr, row, &ext_ctx)?);
9717            }
9718            let order_keys = if stmt.order_by.is_empty() {
9719                Vec::new()
9720            } else {
9721                let mut keys = Vec::with_capacity(stmt.order_by.len());
9722                for o in &stmt.order_by {
9723                    let mut e = o.expr.clone();
9724                    rewrite_window_to_columns(&mut e, &window_nodes);
9725                    let key = eval::eval_expr(&e, row, &ext_ctx)?;
9726                    keys.push(value_to_order_key(&key)?);
9727                }
9728                keys
9729            };
9730            tagged.push((order_keys, Row::new(values)));
9731        }
9732        // ORDER BY + LIMIT/OFFSET on the projected rows.
9733        if !stmt.order_by.is_empty() {
9734            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
9735            sort_by_keys(&mut tagged, &descs);
9736        }
9737        let mut out_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
9738        apply_offset_and_limit(&mut out_rows, stmt.offset_literal(), stmt.limit_literal());
9739        let final_cols: Vec<ColumnSchema> = projection
9740            .into_iter()
9741            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
9742            .collect();
9743        Ok(QueryResult::Rows {
9744            columns: final_cols,
9745            rows: out_rows,
9746        })
9747    }
9748
9749    /// v4.11: materialise each CTE into a temp table inside a
9750    /// cloned catalog, then run the body SELECT against a fresh
9751    /// engine instance that owns the enriched catalog. The clone
9752    /// is moderately expensive — only paid by CTE-bearing queries.
9753    /// Subqueries inside CTE bodies / the main body resolve as
9754    /// usual; `clock_fn` is propagated so `NOW()` lines up.
9755    /// v7.16.2 — mailrs round-10 A.3. Materialise the
9756    /// `information_schema.*` / `pg_catalog.*` virtual views
9757    /// the SELECT references, then re-execute the SELECT
9758    /// against an enriched catalog where those views are real
9759    /// tables. Same pattern as `exec_with_ctes`. The temp
9760    /// engine carries `meta_views_materialised = true` so its
9761    /// own meta-dispatch short-circuits — without that we'd
9762    /// infinite-recurse since the temp catalog's view name
9763    /// still starts with `__spg_info_` and re-triggers the
9764    /// check.
9765    fn exec_select_with_meta_views(
9766        &self,
9767        stmt: &SelectStatement,
9768        cancel: CancelToken<'_>,
9769    ) -> Result<QueryResult, EngineError> {
9770        let mut needed: alloc::collections::BTreeSet<String> = alloc::collections::BTreeSet::new();
9771        collect_meta_view_names(stmt, &mut needed);
9772        let mut catalog = self.active_catalog().clone();
9773        for view in &needed {
9774            if catalog.get(view).is_some() {
9775                continue;
9776            }
9777            match view.as_str() {
9778                "__spg_info_columns" => {
9779                    let (schema, rows) = synth_information_schema_columns(self.active_catalog());
9780                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9781                }
9782                "__spg_info_tables" => {
9783                    let (schema, rows) = synth_information_schema_tables(self.active_catalog());
9784                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9785                }
9786                "__spg_pg_class" => {
9787                    let (schema, rows) = synth_pg_class(self.active_catalog());
9788                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9789                }
9790                "__spg_pg_attribute" => {
9791                    let (schema, rows) = synth_pg_attribute(self.active_catalog());
9792                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9793                }
9794                // v7.17.0 Phase 3.P0-50 — pg_catalog.pg_type for
9795                // sqlx / SQLAlchemy / Diesel / pgAdmin lookups.
9796                "__spg_pg_type" => {
9797                    let (schema, rows) = synth_pg_type(self.active_catalog());
9798                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9799                }
9800                // v7.17.0 Phase 3.P0-51 — pg_catalog.pg_proc for
9801                // function-name introspection (ORM / pgAdmin).
9802                "__spg_pg_proc" => {
9803                    let (schema, rows) = synth_pg_proc(self.active_catalog());
9804                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9805                }
9806                // v7.17.0 Phase 3.P0-52 — pg_catalog.pg_namespace
9807                // (schema list for admin tools' tree views).
9808                "__spg_pg_namespace" => {
9809                    let (schema, rows) = synth_pg_namespace(self.active_catalog());
9810                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9811                }
9812                // v7.17.0 Phase 3.P0-53 — pg_catalog.pg_indexes view
9813                // for pgAdmin / DataGrip "indexes per table" listings.
9814                "__spg_pg_indexes" => {
9815                    let (schema, rows) = synth_pg_indexes(self.active_catalog());
9816                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9817                }
9818                // v7.17.0 Phase 3.P0-53 — pg_catalog.pg_index (raw)
9819                // for index introspection by ORM compilers.
9820                "__spg_pg_index" => {
9821                    let (schema, rows) = synth_pg_index_raw(self.active_catalog());
9822                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9823                }
9824                // v7.17.0 Phase 3.P0-54 — pg_catalog.pg_constraint
9825                // for FK / UNIQUE / PK / CHECK introspection.
9826                "__spg_pg_constraint" => {
9827                    let (schema, rows) = synth_pg_constraint(self.active_catalog());
9828                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9829                }
9830                // v7.17.0 Phase 3.P0-55 — pg_catalog.pg_database /
9831                // pg_roles / pg_user. SPG is single-database so
9832                // pg_database surfaces just `postgres`; pg_roles
9833                // / pg_user walk the engine's UserStore.
9834                "__spg_pg_database" => {
9835                    let (schema, rows) = synth_pg_database(self.active_catalog());
9836                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9837                }
9838                "__spg_pg_roles" | "__spg_pg_user" => {
9839                    let (schema, rows) = synth_pg_roles(self);
9840                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9841                }
9842                // v7.17.0 Phase 3.P0-56 — pg_catalog.pg_views. PG's
9843                // pg_views surfaces every CREATE VIEW result; SPG
9844                // ships one row per declared view from the catalog.
9845                "__spg_pg_views" => {
9846                    let (schema, rows) = synth_pg_views(self.active_catalog());
9847                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9848                }
9849                // v7.17.0 Phase 3.P0-56 — pg_catalog.pg_matviews.
9850                // SPG has no materialised view surface yet so the
9851                // table shares pg_views's schema but stays empty.
9852                "__spg_pg_matviews" => {
9853                    let (schema, _) = synth_pg_views(self.active_catalog());
9854                    materialise_meta_view(&mut catalog, view, schema, Vec::new())?;
9855                }
9856                // pg_catalog.pg_extension — native capability list
9857                // (mailrs embed round-12).
9858                "__spg_pg_extension" => {
9859                    let (schema, rows) = synth_pg_extension();
9860                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9861                }
9862                // v7.17.0 Phase 3.P0-57 — pg_catalog.pg_settings.
9863                "__spg_pg_settings" => {
9864                    let (schema, rows) = synth_pg_settings(self);
9865                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9866                }
9867                // v7.17.0 Phase 3.P0-63 — information_schema.KEY_COLUMN_USAGE.
9868                "__spg_info_key_column_usage" => {
9869                    let (schema, rows) = synth_info_key_column_usage(self.active_catalog());
9870                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9871                }
9872                // v7.17.0 Phase 3.P0-64 — information_schema.REFERENTIAL_CONSTRAINTS.
9873                "__spg_info_referential_constraints" => {
9874                    let (schema, rows) = synth_info_referential_constraints(self.active_catalog());
9875                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9876                }
9877                // v7.17.0 Phase 3.P0-64 — information_schema.STATISTICS.
9878                "__spg_info_statistics" => {
9879                    let (schema, rows) = synth_info_statistics(self.active_catalog());
9880                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9881                }
9882                // v7.17.0 Phase 3.P0-64 — information_schema.ROUTINES.
9883                "__spg_info_routines" => {
9884                    let (schema, rows) = synth_info_routines();
9885                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9886                }
9887                // v7.17.0 Phase 3.P0-65 — mysql.user / mysql.db.
9888                "__spg_mysql_user" => {
9889                    let (schema, rows) = synth_mysql_user(self);
9890                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9891                }
9892                "__spg_mysql_db" => {
9893                    let (schema, rows) = synth_mysql_db();
9894                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9895                }
9896                _ => {
9897                    return Err(EngineError::Unsupported(alloc::format!(
9898                        "meta view {view:?} is not yet materialisable; \
9899                         v7.16.2 covers information_schema.columns / .tables \
9900                         and pg_catalog.pg_class / pg_attribute; \
9901                         v7.17.0 P0-50..P0-57 add pg_type / pg_proc / pg_namespace / \
9902                         pg_indexes / pg_index / pg_constraint / pg_database / pg_roles / \
9903                         pg_user / pg_views / pg_matviews / pg_settings"
9904                    )));
9905                }
9906            }
9907        }
9908        let mut temp = Engine::restore(catalog);
9909        if let Some(c) = self.clock {
9910            temp = temp.with_clock(c);
9911        }
9912        if let Some(f) = self.salt_fn {
9913            temp = temp.with_salt_fn(f);
9914        }
9915        temp.meta_views_materialised = true;
9916        temp.exec_select_cancel(stmt, cancel)
9917    }
9918
9919    fn exec_with_ctes(
9920        &self,
9921        stmt: &SelectStatement,
9922        cancel: CancelToken<'_>,
9923    ) -> Result<QueryResult, EngineError> {
9924        cancel.check()?;
9925        let mut catalog = self.active_catalog().clone();
9926        for cte in &stmt.ctes {
9927            if catalog.get(&cte.name).is_some() {
9928                return Err(EngineError::Unsupported(alloc::format!(
9929                    "CTE name {:?} shadows an existing table; rename the CTE",
9930                    cte.name
9931                )));
9932            }
9933            let (columns, rows) = if cte.recursive {
9934                self.materialise_recursive_cte(cte, &catalog, cancel)?
9935            } else {
9936                let body_result = self.exec_select_cancel(&cte.body, cancel)?;
9937                let QueryResult::Rows { columns, rows } = body_result else {
9938                    return Err(EngineError::Unsupported(alloc::format!(
9939                        "CTE {:?} body did not return rows",
9940                        cte.name
9941                    )));
9942                };
9943                (columns, rows)
9944            };
9945            // v4.22: the projection builder labels any non-column
9946            // expression as Text — including literal SELECT 1.
9947            // Promote each column's type to whatever the rows
9948            // actually carry so the CTE storage table accepts them.
9949            let inferred = infer_column_types(&columns, &rows);
9950            let mut columns = inferred;
9951            // v4.22: apply optional `WITH name(a, b, c)` overrides.
9952            if !cte.column_overrides.is_empty() {
9953                if cte.column_overrides.len() != columns.len() {
9954                    return Err(EngineError::Unsupported(alloc::format!(
9955                        "CTE {:?} column list has {} names but body returns {} columns",
9956                        cte.name,
9957                        cte.column_overrides.len(),
9958                        columns.len()
9959                    )));
9960                }
9961                for (col, name) in columns.iter_mut().zip(cte.column_overrides.iter()) {
9962                    col.name.clone_from(name);
9963                }
9964            }
9965            let schema = TableSchema::new(cte.name.clone(), columns);
9966            catalog.create_table(schema).map_err(EngineError::Storage)?;
9967            let table = catalog
9968                .get_mut(&cte.name)
9969                .expect("just-created CTE table must exist");
9970            for row in rows {
9971                table.insert(row).map_err(EngineError::Storage)?;
9972            }
9973        }
9974        // Strip CTEs from the body before running on the temp engine
9975        // so we don't recurse forever.
9976        let mut body = stmt.clone();
9977        body.ctes = Vec::new();
9978        let mut temp = Engine::restore(catalog);
9979        if let Some(c) = self.clock {
9980            temp = temp.with_clock(c);
9981        }
9982        if let Some(f) = self.salt_fn {
9983            temp = temp.with_salt_fn(f);
9984        }
9985        temp.exec_select_cancel(&body, cancel)
9986    }
9987
9988    /// v4.22: materialise a WITH RECURSIVE CTE. The body must be a
9989    /// UNION (or UNION ALL) of an anchor that does not reference
9990    /// the CTE name, and one or more recursive terms that do. The
9991    /// anchor runs first; each subsequent iteration runs the
9992    /// recursive term against a temp catalog where the CTE name is
9993    /// bound to the *previous* iteration's output. Iteration stops
9994    /// when the recursive term yields no rows; UNION (DISTINCT)
9995    /// deduplicates against the accumulated result, UNION ALL does
9996    /// not. A hard cap on total rows prevents runaway queries.
9997    #[allow(clippy::too_many_lines)]
9998    fn materialise_recursive_cte(
9999        &self,
10000        cte: &spg_sql::ast::Cte,
10001        base_catalog: &Catalog,
10002        cancel: CancelToken<'_>,
10003    ) -> Result<(Vec<ColumnSchema>, Vec<Row>), EngineError> {
10004        const MAX_TOTAL_ROWS: usize = 1_000_000;
10005        const MAX_ITERATIONS: usize = 100_000;
10006        cancel.check()?;
10007        if cte.body.unions.is_empty() {
10008            return Err(EngineError::Unsupported(alloc::format!(
10009                "WITH RECURSIVE {:?} body must be a UNION of an anchor and a recursive term",
10010                cte.name
10011            )));
10012        }
10013        // Anchor: the body's leading SELECT, with unions stripped.
10014        let mut anchor = cte.body.clone();
10015        let union_terms = core::mem::take(&mut anchor.unions);
10016        anchor.ctes = Vec::new();
10017        // Anchor must not reference the CTE name.
10018        if select_refers_to(&anchor, &cte.name) {
10019            return Err(EngineError::Unsupported(alloc::format!(
10020                "WITH RECURSIVE {:?}: the anchor must not reference the CTE itself",
10021                cte.name
10022            )));
10023        }
10024        let anchor_result = self.exec_select_cancel(&anchor, cancel)?;
10025        let QueryResult::Rows {
10026            columns: anchor_cols,
10027            rows: anchor_rows,
10028        } = anchor_result
10029        else {
10030            return Err(EngineError::Unsupported(alloc::format!(
10031                "WITH RECURSIVE {:?}: anchor did not return rows",
10032                cte.name
10033            )));
10034        };
10035        // The projection builder labels non-column expressions Text;
10036        // refine column types from the anchor's actual values so the
10037        // intermediate iter-catalog tables accept them.
10038        let mut columns = infer_column_types(&anchor_cols, &anchor_rows);
10039        if !cte.column_overrides.is_empty() {
10040            if cte.column_overrides.len() != columns.len() {
10041                return Err(EngineError::Unsupported(alloc::format!(
10042                    "CTE {:?} column list has {} names but anchor returns {} columns",
10043                    cte.name,
10044                    cte.column_overrides.len(),
10045                    columns.len()
10046                )));
10047            }
10048            for (col, name) in columns.iter_mut().zip(cte.column_overrides.iter()) {
10049                col.name.clone_from(name);
10050            }
10051        }
10052        let mut all_rows: Vec<Row> = anchor_rows.clone();
10053        let mut working_set: Vec<Row> = anchor_rows;
10054        let mut seen: alloc::collections::BTreeSet<Vec<u8>> = alloc::collections::BTreeSet::new();
10055        // Track at least one "all UNION ALL" flag — if every union
10056        // kind is ALL we skip the dedup step (faster + matches PG).
10057        let all_union_all = union_terms.iter().all(|(k, _)| matches!(k, UnionKind::All));
10058        if !all_union_all {
10059            for r in &all_rows {
10060                seen.insert(encode_row_key(r));
10061            }
10062        }
10063        for iter in 0..MAX_ITERATIONS {
10064            cancel.check()?;
10065            if working_set.is_empty() {
10066                break;
10067            }
10068            // Build a fresh catalog: base + CTE bound to working_set.
10069            let mut iter_catalog = base_catalog.clone();
10070            let schema = TableSchema::new(cte.name.clone(), columns.clone());
10071            iter_catalog
10072                .create_table(schema)
10073                .map_err(EngineError::Storage)?;
10074            {
10075                let table = iter_catalog.get_mut(&cte.name).expect("just-created");
10076                for row in &working_set {
10077                    table.insert(row.clone()).map_err(EngineError::Storage)?;
10078                }
10079            }
10080            let mut iter_engine = Engine::restore(iter_catalog);
10081            if let Some(c) = self.clock {
10082                iter_engine = iter_engine.with_clock(c);
10083            }
10084            if let Some(f) = self.salt_fn {
10085                iter_engine = iter_engine.with_salt_fn(f);
10086            }
10087            // Run each recursive term in sequence and collect new rows.
10088            let mut next_set: Vec<Row> = Vec::new();
10089            for (_, term) in &union_terms {
10090                let mut term = term.clone();
10091                term.ctes = Vec::new();
10092                let r = iter_engine.exec_select_cancel(&term, cancel)?;
10093                let QueryResult::Rows {
10094                    columns: rc,
10095                    rows: rs,
10096                } = r
10097                else {
10098                    return Err(EngineError::Unsupported(alloc::format!(
10099                        "WITH RECURSIVE {:?}: recursive term did not return rows",
10100                        cte.name
10101                    )));
10102                };
10103                if rc.len() != columns.len() {
10104                    return Err(EngineError::Unsupported(alloc::format!(
10105                        "WITH RECURSIVE {:?}: column count of recursive term ({}) does not match anchor ({})",
10106                        cte.name,
10107                        rc.len(),
10108                        columns.len()
10109                    )));
10110                }
10111                for row in rs {
10112                    if !all_union_all {
10113                        let key = encode_row_key(&row);
10114                        if !seen.insert(key) {
10115                            continue;
10116                        }
10117                    }
10118                    next_set.push(row);
10119                }
10120            }
10121            if next_set.is_empty() {
10122                break;
10123            }
10124            all_rows.extend(next_set.iter().cloned());
10125            working_set = next_set;
10126            if all_rows.len() > MAX_TOTAL_ROWS {
10127                return Err(EngineError::Unsupported(alloc::format!(
10128                    "WITH RECURSIVE {:?}: produced more than {MAX_TOTAL_ROWS} rows — likely runaway recursion",
10129                    cte.name
10130                )));
10131            }
10132            if iter + 1 == MAX_ITERATIONS {
10133                return Err(EngineError::Unsupported(alloc::format!(
10134                    "WITH RECURSIVE {:?}: exceeded {MAX_ITERATIONS} iterations",
10135                    cte.name
10136                )));
10137            }
10138        }
10139        Ok((columns, all_rows))
10140    }
10141
10142    fn resolve_select_subqueries(
10143        &self,
10144        stmt: &mut SelectStatement,
10145        cancel: CancelToken<'_>,
10146    ) -> Result<(), EngineError> {
10147        for item in &mut stmt.items {
10148            if let SelectItem::Expr { expr, .. } = item {
10149                self.resolve_expr_subqueries(expr, cancel)?;
10150            }
10151        }
10152        if let Some(w) = &mut stmt.where_ {
10153            self.resolve_expr_subqueries(w, cancel)?;
10154        }
10155        if let Some(gs) = &mut stmt.group_by {
10156            for g in gs {
10157                self.resolve_expr_subqueries(g, cancel)?;
10158            }
10159        }
10160        if let Some(h) = &mut stmt.having {
10161            self.resolve_expr_subqueries(h, cancel)?;
10162        }
10163        for o in &mut stmt.order_by {
10164            self.resolve_expr_subqueries(&mut o.expr, cancel)?;
10165        }
10166        for (_, peer) in &mut stmt.unions {
10167            self.resolve_select_subqueries(peer, cancel)?;
10168        }
10169        Ok(())
10170    }
10171
10172    #[allow(clippy::only_used_in_recursion)] // engine handle reads aren't really pure
10173    fn resolve_expr_subqueries(
10174        &self,
10175        e: &mut Expr,
10176        cancel: CancelToken<'_>,
10177    ) -> Result<(), EngineError> {
10178        // Replace-on-this-node cases first.
10179        if let Some(replacement) = self.subquery_replacement(e, cancel)? {
10180            *e = replacement;
10181            return Ok(());
10182        }
10183        match e {
10184            Expr::Binary { lhs, rhs, .. } => {
10185                self.resolve_expr_subqueries(lhs, cancel)?;
10186                self.resolve_expr_subqueries(rhs, cancel)?;
10187            }
10188            Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
10189                self.resolve_expr_subqueries(expr, cancel)?;
10190            }
10191            Expr::FunctionCall { args, .. } => {
10192                for a in args {
10193                    self.resolve_expr_subqueries(a, cancel)?;
10194                }
10195            }
10196            Expr::Like { expr, pattern, .. } => {
10197                self.resolve_expr_subqueries(expr, cancel)?;
10198                self.resolve_expr_subqueries(pattern, cancel)?;
10199            }
10200            Expr::Extract { source, .. } => self.resolve_expr_subqueries(source, cancel)?,
10201            // v4.12 window functions — recurse into args + ORDER BY
10202            // + PARTITION BY in case they carry inner subqueries.
10203            Expr::WindowFunction {
10204                args,
10205                partition_by,
10206                order_by,
10207                ..
10208            } => {
10209                for a in args {
10210                    self.resolve_expr_subqueries(a, cancel)?;
10211                }
10212                for p in partition_by {
10213                    self.resolve_expr_subqueries(p, cancel)?;
10214                }
10215                for (e, _) in order_by {
10216                    self.resolve_expr_subqueries(e, cancel)?;
10217                }
10218            }
10219            // Subquery nodes are handled in subquery_replacement
10220            // (which returned None — defensive no-op); Literal /
10221            // Column are leaves.
10222            Expr::ScalarSubquery(_)
10223            | Expr::Exists { .. }
10224            | Expr::InSubquery { .. }
10225            | Expr::Literal(_)
10226            | Expr::Placeholder(_)
10227            | Expr::Column(_) => {}
10228            // v7.10.10 — recurse children.
10229            Expr::Array(items) => {
10230                for elem in items {
10231                    self.resolve_expr_subqueries(elem, cancel)?;
10232                }
10233            }
10234            Expr::ArraySubscript { target, index } => {
10235                self.resolve_expr_subqueries(target, cancel)?;
10236                self.resolve_expr_subqueries(index, cancel)?;
10237            }
10238            Expr::AnyAll { expr, array, .. } => {
10239                self.resolve_expr_subqueries(expr, cancel)?;
10240                self.resolve_expr_subqueries(array, cancel)?;
10241            }
10242            Expr::Case {
10243                operand,
10244                branches,
10245                else_branch,
10246            } => {
10247                if let Some(o) = operand {
10248                    self.resolve_expr_subqueries(o, cancel)?;
10249                }
10250                for (w, t) in branches {
10251                    self.resolve_expr_subqueries(w, cancel)?;
10252                    self.resolve_expr_subqueries(t, cancel)?;
10253                }
10254                if let Some(e) = else_branch {
10255                    self.resolve_expr_subqueries(e, cancel)?;
10256                }
10257            }
10258        }
10259        Ok(())
10260    }
10261
10262    /// v4.23: per-row eval that handles correlated subqueries.
10263    /// Equivalent to `eval::eval_expr` when the expression has no
10264    /// subqueries; otherwise clones the expression, substitutes
10265    /// outer-row columns into each surviving subquery node, runs
10266    /// the inner SELECT, and replaces the node with the literal
10267    /// result. Only the WHERE-filter call sites use this path so
10268    /// the uncorrelated fast path is preserved everywhere else.
10269    fn eval_expr_with_correlated(
10270        &self,
10271        expr: &Expr,
10272        row: &Row,
10273        ctx: &EvalContext<'_>,
10274        cancel: CancelToken<'_>,
10275        memo: Option<&mut memoize::MemoizeCache>,
10276    ) -> Result<Value, EngineError> {
10277        if !expr_has_subquery(expr) {
10278            return eval::eval_expr(expr, row, ctx).map_err(EngineError::Eval);
10279        }
10280        let mut e = expr.clone();
10281        self.resolve_correlated_in_expr(&mut e, row, ctx, cancel, memo)?;
10282        eval::eval_expr(&e, row, ctx).map_err(EngineError::Eval)
10283    }
10284
10285    fn resolve_correlated_in_expr(
10286        &self,
10287        e: &mut Expr,
10288        row: &Row,
10289        ctx: &EvalContext<'_>,
10290        cancel: CancelToken<'_>,
10291        mut memo: Option<&mut memoize::MemoizeCache>,
10292    ) -> Result<(), EngineError> {
10293        match e {
10294            Expr::ScalarSubquery(inner) => {
10295                // v6.2.6 — Memoize: build the cache key from the
10296                // pre-substitution subquery repr + the outer row's
10297                // values. Two outer rows with identical correlated
10298                // values hit the same entry.
10299                let cache_key = memo.as_ref().map(|_| memoize::CacheKey {
10300                    subquery_repr: alloc::format!("{}", **inner),
10301                    outer_values: row.values.clone(),
10302                });
10303                if let (Some(cache), Some(k)) = (memo.as_deref_mut(), cache_key.as_ref())
10304                    && let Some(cached) = cache.get(k)
10305                {
10306                    *e = value_to_literal_expr(cached)?;
10307                    return Ok(());
10308                }
10309                let mut s = (**inner).clone();
10310                substitute_outer_columns(&mut s, row, ctx);
10311                let r = self.exec_select_cancel(&s, cancel)?;
10312                let QueryResult::Rows { rows, .. } = r else {
10313                    return Err(EngineError::Unsupported(
10314                        "scalar subquery: inner did not return rows".into(),
10315                    ));
10316                };
10317                let value = match rows.as_slice() {
10318                    [] => Value::Null,
10319                    [r0] => r0.values.first().cloned().unwrap_or(Value::Null),
10320                    _ => {
10321                        return Err(EngineError::Unsupported(alloc::format!(
10322                            "scalar subquery returned {} rows; expected 0 or 1",
10323                            rows.len()
10324                        )));
10325                    }
10326                };
10327                if let (Some(cache), Some(k)) = (memo.as_deref_mut(), cache_key) {
10328                    cache.insert(k, value.clone());
10329                }
10330                *e = value_to_literal_expr(value)?;
10331            }
10332            Expr::Exists { subquery, negated } => {
10333                let mut s = (**subquery).clone();
10334                substitute_outer_columns(&mut s, row, ctx);
10335                let r = self.exec_select_cancel(&s, cancel)?;
10336                let exists = matches!(r, QueryResult::Rows { rows, .. } if !rows.is_empty());
10337                let bit = if *negated { !exists } else { exists };
10338                *e = Expr::Literal(Literal::Bool(bit));
10339            }
10340            Expr::InSubquery {
10341                expr: lhs,
10342                subquery,
10343                negated,
10344            } => {
10345                self.resolve_correlated_in_expr(lhs, row, ctx, cancel, memo.as_deref_mut())?;
10346                let lhs_val = eval::eval_expr(lhs, row, ctx).map_err(EngineError::Eval)?;
10347                let mut s = (**subquery).clone();
10348                substitute_outer_columns(&mut s, row, ctx);
10349                let r = self.exec_select_cancel(&s, cancel)?;
10350                let QueryResult::Rows { columns, rows, .. } = r else {
10351                    return Err(EngineError::Unsupported(
10352                        "IN-subquery: inner did not return rows".into(),
10353                    ));
10354                };
10355                if columns.len() != 1 {
10356                    return Err(EngineError::Unsupported(alloc::format!(
10357                        "IN-subquery must project exactly one column; got {}",
10358                        columns.len()
10359                    )));
10360                }
10361                let mut found = false;
10362                let mut any_null = false;
10363                for r0 in rows {
10364                    let v = r0.values.into_iter().next().unwrap_or(Value::Null);
10365                    if v.is_null() {
10366                        any_null = true;
10367                        continue;
10368                    }
10369                    if value_cmp(&v, &lhs_val) == core::cmp::Ordering::Equal {
10370                        found = true;
10371                        break;
10372                    }
10373                }
10374                let bit = if found {
10375                    !*negated
10376                } else if any_null {
10377                    return Err(EngineError::Unsupported(
10378                        "IN-subquery with NULL in result and no match: NULL semantics not yet implemented".into(),
10379                    ));
10380                } else {
10381                    *negated
10382                };
10383                *e = Expr::Literal(Literal::Bool(bit));
10384            }
10385            Expr::Binary { lhs, rhs, .. } => {
10386                self.resolve_correlated_in_expr(lhs, row, ctx, cancel, memo.as_deref_mut())?;
10387                self.resolve_correlated_in_expr(rhs, row, ctx, cancel, memo.as_deref_mut())?;
10388            }
10389            Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
10390                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
10391            }
10392            Expr::Like { expr, pattern, .. } => {
10393                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
10394                self.resolve_correlated_in_expr(pattern, row, ctx, cancel, memo.as_deref_mut())?;
10395            }
10396            Expr::FunctionCall { args, .. } => {
10397                for a in args {
10398                    self.resolve_correlated_in_expr(a, row, ctx, cancel, memo.as_deref_mut())?;
10399                }
10400            }
10401            Expr::Extract { source, .. } => {
10402                self.resolve_correlated_in_expr(source, row, ctx, cancel, memo.as_deref_mut())?;
10403            }
10404            Expr::WindowFunction { .. }
10405            | Expr::Literal(_)
10406            | Expr::Placeholder(_)
10407            | Expr::Column(_) => {}
10408            // v7.10.10 — recurse children.
10409            Expr::Array(items) => {
10410                for elem in items {
10411                    self.resolve_correlated_in_expr(elem, row, ctx, cancel, memo.as_deref_mut())?;
10412                }
10413            }
10414            Expr::ArraySubscript { target, index } => {
10415                self.resolve_correlated_in_expr(target, row, ctx, cancel, memo.as_deref_mut())?;
10416                self.resolve_correlated_in_expr(index, row, ctx, cancel, memo.as_deref_mut())?;
10417            }
10418            Expr::AnyAll { expr, array, .. } => {
10419                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
10420                self.resolve_correlated_in_expr(array, row, ctx, cancel, memo.as_deref_mut())?;
10421            }
10422            Expr::Case {
10423                operand,
10424                branches,
10425                else_branch,
10426            } => {
10427                if let Some(o) = operand {
10428                    self.resolve_correlated_in_expr(o, row, ctx, cancel, memo.as_deref_mut())?;
10429                }
10430                for (w, t) in branches {
10431                    self.resolve_correlated_in_expr(w, row, ctx, cancel, memo.as_deref_mut())?;
10432                    self.resolve_correlated_in_expr(t, row, ctx, cancel, memo.as_deref_mut())?;
10433                }
10434                if let Some(e) = else_branch {
10435                    self.resolve_correlated_in_expr(e, row, ctx, cancel, memo.as_deref_mut())?;
10436                }
10437            }
10438        }
10439        Ok(())
10440    }
10441
10442    fn subquery_replacement(
10443        &self,
10444        e: &Expr,
10445        cancel: CancelToken<'_>,
10446    ) -> Result<Option<Expr>, EngineError> {
10447        match e {
10448            Expr::ScalarSubquery(inner) => {
10449                let mut s = (**inner).clone();
10450                // Recurse into the inner SELECT first so nested
10451                // subqueries materialise bottom-up.
10452                self.resolve_select_subqueries(&mut s, cancel)?;
10453                let r = match self.exec_bare_select_cancel(&s, cancel) {
10454                    Ok(r) => r,
10455                    Err(e) if is_correlation_error(&e) => return Ok(None),
10456                    Err(e) => return Err(e),
10457                };
10458                let QueryResult::Rows { rows, .. } = r else {
10459                    return Err(EngineError::Unsupported(
10460                        "scalar subquery: inner statement did not return rows".into(),
10461                    ));
10462                };
10463                let value = match rows.as_slice() {
10464                    [] => Value::Null,
10465                    [row] => row.values.first().cloned().unwrap_or(Value::Null),
10466                    _ => {
10467                        return Err(EngineError::Unsupported(alloc::format!(
10468                            "scalar subquery returned {} rows; expected 0 or 1",
10469                            rows.len()
10470                        )));
10471                    }
10472                };
10473                Ok(Some(value_to_literal_expr(value)?))
10474            }
10475            Expr::Exists { subquery, negated } => {
10476                let mut s = (**subquery).clone();
10477                self.resolve_select_subqueries(&mut s, cancel)?;
10478                let r = match self.exec_bare_select_cancel(&s, cancel) {
10479                    Ok(r) => r,
10480                    Err(e) if is_correlation_error(&e) => return Ok(None),
10481                    Err(e) => return Err(e),
10482                };
10483                let exists = match r {
10484                    QueryResult::Rows { rows, .. } => !rows.is_empty(),
10485                    QueryResult::CommandOk { .. } => false,
10486                };
10487                let bit = if *negated { !exists } else { exists };
10488                Ok(Some(Expr::Literal(Literal::Bool(bit))))
10489            }
10490            Expr::InSubquery {
10491                expr,
10492                subquery,
10493                negated,
10494            } => {
10495                let mut s = (**subquery).clone();
10496                self.resolve_select_subqueries(&mut s, cancel)?;
10497                let r = match self.exec_bare_select_cancel(&s, cancel) {
10498                    Ok(r) => r,
10499                    Err(e) if is_correlation_error(&e) => return Ok(None),
10500                    Err(e) => return Err(e),
10501                };
10502                let QueryResult::Rows { columns, rows, .. } = r else {
10503                    return Err(EngineError::Unsupported(
10504                        "IN-subquery: inner statement did not return rows".into(),
10505                    ));
10506                };
10507                if columns.len() != 1 {
10508                    return Err(EngineError::Unsupported(alloc::format!(
10509                        "IN-subquery must project exactly one column; got {}",
10510                        columns.len()
10511                    )));
10512                }
10513                // Build the same OR-Eq chain the parse-time literal-list
10514                // path constructs, with each value lifted into a Literal.
10515                let mut acc: Option<Expr> = None;
10516                for row in rows {
10517                    let v = row.values.into_iter().next().unwrap_or(Value::Null);
10518                    let lit = value_to_literal_expr(v)?;
10519                    let cmp = Expr::Binary {
10520                        lhs: expr.clone(),
10521                        op: BinOp::Eq,
10522                        rhs: Box::new(lit),
10523                    };
10524                    acc = Some(match acc {
10525                        None => cmp,
10526                        Some(prev) => Expr::Binary {
10527                            lhs: Box::new(prev),
10528                            op: BinOp::Or,
10529                            rhs: Box::new(cmp),
10530                        },
10531                    });
10532                }
10533                let combined = acc.unwrap_or(Expr::Literal(Literal::Bool(false)));
10534                let final_expr = if *negated {
10535                    Expr::Unary {
10536                        op: UnOp::Not,
10537                        expr: Box::new(combined),
10538                    }
10539                } else {
10540                    combined
10541                };
10542                Ok(Some(final_expr))
10543            }
10544            _ => Ok(None),
10545        }
10546    }
10547}
10548
10549// ---- v4.12 window-function helpers ----
10550// The (partition-key, order-key, original-index) tuple shape used
10551// across these helpers is intrinsic to the planner. Factoring it
10552// into a typedef adds indirection without making the code clearer,
10553// so several lints are allowed inline on the affected functions
10554// rather than module-wide.
10555
10556/// v4.22: cheap structural scan for `FROM <name>` (qualified or
10557/// not) inside a SELECT — used to verify the anchor of a WITH
10558/// RECURSIVE CTE doesn't recurse into itself. Conservative: walks
10559/// FROM joins, subqueries, and unions.
10560fn select_refers_to(stmt: &SelectStatement, target: &str) -> bool {
10561    if let Some(from) = &stmt.from
10562        && from_refers_to(from, target)
10563    {
10564        return true;
10565    }
10566    for (_, peer) in &stmt.unions {
10567        if select_refers_to(peer, target) {
10568            return true;
10569        }
10570    }
10571    for item in &stmt.items {
10572        if let SelectItem::Expr { expr, .. } = item
10573            && expr_refers_to(expr, target)
10574        {
10575            return true;
10576        }
10577    }
10578    if let Some(w) = &stmt.where_
10579        && expr_refers_to(w, target)
10580    {
10581        return true;
10582    }
10583    false
10584}
10585
10586fn from_refers_to(from: &FromClause, target: &str) -> bool {
10587    if from.primary.name.eq_ignore_ascii_case(target) {
10588        return true;
10589    }
10590    from.joins
10591        .iter()
10592        .any(|j| j.table.name.eq_ignore_ascii_case(target))
10593}
10594
10595fn expr_refers_to(e: &Expr, target: &str) -> bool {
10596    match e {
10597        Expr::ScalarSubquery(s) => select_refers_to(s, target),
10598        Expr::Exists { subquery, .. } | Expr::InSubquery { subquery, .. } => {
10599            select_refers_to(subquery, target)
10600        }
10601        Expr::Binary { lhs, rhs, .. } => expr_refers_to(lhs, target) || expr_refers_to(rhs, target),
10602        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
10603            expr_refers_to(expr, target)
10604        }
10605        Expr::Like { expr, pattern, .. } => {
10606            expr_refers_to(expr, target) || expr_refers_to(pattern, target)
10607        }
10608        Expr::FunctionCall { args, .. } => args.iter().any(|a| expr_refers_to(a, target)),
10609        Expr::Extract { source, .. } => expr_refers_to(source, target),
10610        Expr::WindowFunction {
10611            args,
10612            partition_by,
10613            order_by,
10614            ..
10615        } => {
10616            args.iter().any(|a| expr_refers_to(a, target))
10617                || partition_by.iter().any(|p| expr_refers_to(p, target))
10618                || order_by.iter().any(|(o, _)| expr_refers_to(o, target))
10619        }
10620        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => false,
10621        Expr::Array(items) => items.iter().any(|e| expr_refers_to(e, target)),
10622        Expr::ArraySubscript { target: t, index } => {
10623            expr_refers_to(t, target) || expr_refers_to(index, target)
10624        }
10625        Expr::AnyAll { expr, array, .. } => {
10626            expr_refers_to(expr, target) || expr_refers_to(array, target)
10627        }
10628        Expr::Case {
10629            operand,
10630            branches,
10631            else_branch,
10632        } => {
10633            operand
10634                .as_deref()
10635                .is_some_and(|o| expr_refers_to(o, target))
10636                || branches
10637                    .iter()
10638                    .any(|(w, t)| expr_refers_to(w, target) || expr_refers_to(t, target))
10639                || else_branch
10640                    .as_deref()
10641                    .is_some_and(|e| expr_refers_to(e, target))
10642        }
10643    }
10644}
10645
10646/// v4.22: pick more specific column types from observed rows when
10647/// the projection builder defaulted to Text (the v1.x behavior for
10648/// non-column expressions). Lets `WITH t(n) AS (SELECT 1 ...)`
10649/// land an Int column in the CTE storage table rather than failing
10650/// the insert with "expected TEXT, got INT".
10651/// v7.16.2 — map an SPG [`DataType`] to the PG-canonical
10652/// `information_schema.columns.data_type` text. Covers the
10653/// values mailrs's migrations probe (`'ARRAY'`, `'integer'`,
10654/// `'text'`, …). Unknown variants fall back to the SPG name
10655/// downcased — better than panicking on a future DataType.
10656fn pg_data_type_text(ty: DataType) -> alloc::string::String {
10657    let s = match ty {
10658        DataType::Int => "integer",
10659        DataType::BigInt => "bigint",
10660        DataType::SmallInt => "smallint",
10661        DataType::Float => "double precision",
10662        DataType::Bool => "boolean",
10663        DataType::Text => "text",
10664        DataType::Varchar(_) => "character varying",
10665        DataType::Date => "date",
10666        DataType::Timestamp => "timestamp without time zone",
10667        DataType::Timestamptz => "timestamp with time zone",
10668        DataType::Json => "jsonb",
10669        DataType::Bytes => "bytea",
10670        DataType::TextArray | DataType::IntArray | DataType::BigIntArray => "ARRAY",
10671        DataType::TsVector => "tsvector",
10672        DataType::TsQuery => "tsquery",
10673        DataType::Vector { .. } => "USER-DEFINED",
10674        // Non-exhaustive — fall back to "USER-DEFINED" the way
10675        // PG labels any pg_type it doesn't recognise.
10676        _ => "USER-DEFINED",
10677    };
10678    alloc::string::String::from(s)
10679}
10680
10681/// v7.16.2 — synthesise `information_schema.columns`. mailrs
10682/// queries are of shape `SELECT 1 FROM information_schema.columns
10683/// WHERE table_name = … AND column_name = … AND data_type = …` —
10684/// the v7.16.2 view returns the columns mailrs probes; broader
10685/// PG-spec parity (ordinal_position, is_nullable, character_
10686/// maximum_length, udt_name, …) lands as needed.
10687fn synth_information_schema_columns(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10688    let schema = alloc::vec![
10689        ColumnSchema::new("table_catalog", DataType::Text, false),
10690        ColumnSchema::new("table_schema", DataType::Text, false),
10691        ColumnSchema::new("table_name", DataType::Text, false),
10692        ColumnSchema::new("column_name", DataType::Text, false),
10693        ColumnSchema::new("ordinal_position", DataType::Int, false),
10694        ColumnSchema::new("is_nullable", DataType::Text, false),
10695        ColumnSchema::new("data_type", DataType::Text, false),
10696    ];
10697    let mut rows: Vec<Row> = Vec::new();
10698    for tname in cat.table_names() {
10699        let Some(t) = cat.get(&tname) else { continue };
10700        for (i, col) in t.schema().columns.iter().enumerate() {
10701            #[allow(clippy::cast_possible_wrap)]
10702            let ordinal = (i + 1) as i32;
10703            rows.push(Row::new(alloc::vec![
10704                Value::Text("spg".into()),
10705                Value::Text("public".into()),
10706                Value::Text(tname.clone()),
10707                Value::Text(col.name.clone()),
10708                Value::Int(ordinal),
10709                Value::Text(if col.nullable {
10710                    "YES".into()
10711                } else {
10712                    "NO".into()
10713                }),
10714                Value::Text(pg_data_type_text(col.ty)),
10715            ]));
10716        }
10717    }
10718    (schema, rows)
10719}
10720
10721/// v7.16.2 — synthesise `information_schema.tables`.
10722fn synth_information_schema_tables(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10723    let schema = alloc::vec![
10724        ColumnSchema::new("table_catalog", DataType::Text, false),
10725        ColumnSchema::new("table_schema", DataType::Text, false),
10726        ColumnSchema::new("table_name", DataType::Text, false),
10727        ColumnSchema::new("table_type", DataType::Text, false),
10728    ];
10729    let mut rows: Vec<Row> = Vec::new();
10730    for tname in cat.table_names() {
10731        rows.push(Row::new(alloc::vec![
10732            Value::Text("spg".into()),
10733            Value::Text("public".into()),
10734            Value::Text(tname.clone()),
10735            Value::Text("BASE TABLE".into()),
10736        ]));
10737    }
10738    (schema, rows)
10739}
10740
10741/// v7.16.2 — synthesise `pg_catalog.pg_class`. Minimum shape
10742/// for psql `\d` / ORM probes: `relname` + `relkind`. Each
10743/// user table emits one row.
10744fn synth_pg_class(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10745    let schema = alloc::vec![
10746        ColumnSchema::new("relname", DataType::Text, false),
10747        ColumnSchema::new("relkind", DataType::Text, false),
10748        ColumnSchema::new("relnamespace", DataType::BigInt, false),
10749    ];
10750    let mut rows: Vec<Row> = Vec::new();
10751    for tname in cat.table_names() {
10752        rows.push(Row::new(alloc::vec![
10753            Value::Text(tname.clone()),
10754            Value::Text("r".into()),
10755            Value::BigInt(2200), // PG's `public` namespace OID
10756        ]));
10757    }
10758    (schema, rows)
10759}
10760
10761/// v7.16.2 — synthesise `pg_catalog.pg_attribute`. Minimum
10762/// shape: `attrelid` (text — SPG has no OID), `attname`,
10763/// `attnum`, `atttypid` (text), `attnotnull`.
10764fn synth_pg_attribute(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10765    let schema = alloc::vec![
10766        ColumnSchema::new("attrelid", DataType::Text, false),
10767        ColumnSchema::new("attname", DataType::Text, false),
10768        ColumnSchema::new("attnum", DataType::Int, false),
10769        ColumnSchema::new("atttypid", DataType::Text, false),
10770        ColumnSchema::new("attnotnull", DataType::Bool, false),
10771    ];
10772    let mut rows: Vec<Row> = Vec::new();
10773    for tname in cat.table_names() {
10774        let Some(t) = cat.get(&tname) else { continue };
10775        for (i, col) in t.schema().columns.iter().enumerate() {
10776            #[allow(clippy::cast_possible_wrap)]
10777            let ordinal = (i + 1) as i32;
10778            rows.push(Row::new(alloc::vec![
10779                Value::Text(tname.clone()),
10780                Value::Text(col.name.clone()),
10781                Value::Int(ordinal),
10782                Value::Text(pg_data_type_text(col.ty)),
10783                Value::Bool(!col.nullable),
10784            ]));
10785        }
10786    }
10787    (schema, rows)
10788}
10789
10790/// v7.17.0 Phase 3.P0-50 — synthesise `pg_catalog.pg_type`. The
10791/// returned rows cover every built-in scalar / array type sqlx,
10792/// SQLAlchemy, Diesel and pgAdmin look up at compile / connect
10793/// time. PG-canonical schema columns we expose:
10794///   * oid           — type OID (the lookup key sqlx uses)
10795///   * typname       — canonical type name (`int4`, `text`, …)
10796///   * typlen        — width in bytes (-1 for var-length)
10797///   * typtype       — `b`ase / `c`omposite / `e`num / etc.
10798///   * typcategory   — PG type category single-char
10799///   * typelem       — element OID for arrays (0 otherwise)
10800///   * typarray      — array-type OID (0 if no array type)
10801///   * typnamespace  — schema OID (always `public` = 2200)
10802///
10803/// Other pg_type columns (typowner, typinput/typoutput, etc.)
10804/// land in follow-up work — sqlx encoders don't query them at
10805/// connect time.
10806fn synth_pg_type(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10807    let schema = alloc::vec![
10808        ColumnSchema::new("oid", DataType::BigInt, false),
10809        ColumnSchema::new("typname", DataType::Text, false),
10810        ColumnSchema::new("typlen", DataType::SmallInt, false),
10811        ColumnSchema::new("typtype", DataType::Text, false),
10812        ColumnSchema::new("typcategory", DataType::Text, false),
10813        ColumnSchema::new("typelem", DataType::BigInt, false),
10814        ColumnSchema::new("typarray", DataType::BigInt, false),
10815        ColumnSchema::new("typnamespace", DataType::BigInt, false),
10816    ];
10817    // (oid, name, len, type, cat, elem, array_oid). PG OID
10818    // numbers come straight from `pg_type.dat`.
10819    let scalars: &[(i64, &str, i16, &str, &str, i64, i64)] = &[
10820        // bool
10821        (16, "bool", 1, "b", "B", 0, 1000),
10822        (17, "bytea", -1, "b", "U", 0, 1001),
10823        (18, "char", 1, "b", "S", 0, 1002),
10824        (19, "name", 64, "b", "S", 0, 1003),
10825        (20, "int8", 8, "b", "N", 0, 1016),
10826        (21, "int2", 2, "b", "N", 0, 1005),
10827        (23, "int4", 4, "b", "N", 0, 1007),
10828        (24, "regproc", 4, "b", "N", 0, 1008),
10829        (25, "text", -1, "b", "S", 0, 1009),
10830        (26, "oid", 4, "b", "N", 0, 1028),
10831        (114, "json", -1, "b", "U", 0, 199),
10832        (142, "xml", -1, "b", "U", 0, 143),
10833        (700, "float4", 4, "b", "N", 0, 1021),
10834        (701, "float8", 8, "b", "N", 0, 1022),
10835        (650, "cidr", -1, "b", "I", 0, 651),
10836        (869, "inet", -1, "b", "I", 0, 1041),
10837        (829, "macaddr", 6, "b", "U", 0, 1040),
10838        (1042, "bpchar", -1, "b", "S", 0, 1014),
10839        (1043, "varchar", -1, "b", "S", 0, 1015),
10840        (1082, "date", 4, "b", "D", 0, 1182),
10841        (1083, "time", 8, "b", "D", 0, 1183),
10842        (1114, "timestamp", 8, "b", "D", 0, 1115),
10843        (1184, "timestamptz", 8, "b", "D", 0, 1185),
10844        (1186, "interval", 16, "b", "T", 0, 1187),
10845        (1266, "timetz", 12, "b", "D", 0, 1270),
10846        (1700, "numeric", -1, "b", "N", 0, 1231),
10847        (790, "money", 8, "b", "N", 0, 791),
10848        (2950, "uuid", 16, "b", "U", 0, 2951),
10849        (3802, "jsonb", -1, "b", "U", 0, 3807),
10850        (3614, "tsvector", -1, "b", "U", 0, 3643),
10851        (3615, "tsquery", -1, "b", "U", 0, 3645),
10852        // hstore + range types — typcategory 'U' (user) / 'R' (range).
10853        (3908, "tstzrange", -1, "r", "R", 0, 3909),
10854        (3910, "tsrange", -1, "r", "R", 0, 3911),
10855        (3904, "int4range", -1, "r", "R", 0, 3905),
10856        (3926, "int8range", -1, "r", "R", 0, 3927),
10857        (3906, "numrange", -1, "r", "R", 0, 3907),
10858        (3912, "daterange", -1, "r", "R", 0, 3913),
10859    ];
10860    // Array companion types share the typelem / typcategory='A'.
10861    // We emit just the array OIDs the scalars reference.
10862    let arrays: &[(i64, &str, i64)] = &[
10863        (1000, "_bool", 16),
10864        (1001, "_bytea", 17),
10865        (1002, "_char", 18),
10866        (1003, "_name", 19),
10867        (1016, "_int8", 20),
10868        (1005, "_int2", 21),
10869        (1007, "_int4", 23),
10870        (1008, "_regproc", 24),
10871        (1009, "_text", 25),
10872        (1028, "_oid", 26),
10873        (199, "_json", 114),
10874        (143, "_xml", 142),
10875        (1021, "_float4", 700),
10876        (1022, "_float8", 701),
10877        (651, "_cidr", 650),
10878        (1041, "_inet", 869),
10879        (1040, "_macaddr", 829),
10880        (1014, "_bpchar", 1042),
10881        (1015, "_varchar", 1043),
10882        (1182, "_date", 1082),
10883        (1183, "_time", 1083),
10884        (1115, "_timestamp", 1114),
10885        (1185, "_timestamptz", 1184),
10886        (1187, "_interval", 1186),
10887        (1270, "_timetz", 1266),
10888        (1231, "_numeric", 1700),
10889        (791, "_money", 790),
10890        (2951, "_uuid", 2950),
10891        (3807, "_jsonb", 3802),
10892        (3643, "_tsvector", 3614),
10893        (3645, "_tsquery", 3615),
10894    ];
10895    let mut rows: Vec<Row> = Vec::with_capacity(scalars.len() + arrays.len());
10896    for &(oid, name, len, ty, cat, elem, arr) in scalars {
10897        rows.push(Row::new(alloc::vec![
10898            Value::BigInt(oid),
10899            Value::Text(name.into()),
10900            Value::SmallInt(len),
10901            Value::Text(ty.into()),
10902            Value::Text(cat.into()),
10903            Value::BigInt(elem),
10904            Value::BigInt(arr),
10905            Value::BigInt(2200),
10906        ]));
10907    }
10908    for &(oid, name, elem) in arrays {
10909        rows.push(Row::new(alloc::vec![
10910            Value::BigInt(oid),
10911            Value::Text(name.into()),
10912            Value::SmallInt(-1),
10913            Value::Text("b".into()),
10914            Value::Text("A".into()),
10915            Value::BigInt(elem),
10916            Value::BigInt(0),
10917            Value::BigInt(2200),
10918        ]));
10919    }
10920    (schema, rows)
10921}
10922
10923/// v7.17.0 Phase 3.P0-51 — synthesise `pg_catalog.pg_proc`. ORM /
10924/// pgAdmin probes look up functions by name; SPG synthesises rows
10925/// for the built-in scalar functions / aggregates / window funcs
10926/// the engine actually dispatches. SPG has no user-defined
10927/// functions yet so the table is a stable static list.
10928///
10929/// Schema columns exposed:
10930///   * oid (BigInt) — function OID from PG's pg_proc.dat
10931///   * proname (Text) — function name (lowercase)
10932///   * pronamespace (BigInt) — 11 (`pg_catalog`)
10933///   * prokind (Text) — 'f' function, 'a' aggregate, 'w' window
10934///   * pronargs (SmallInt) — declared arg count (-1 for variadic)
10935///   * prorettype (BigInt) — return type OID (matches synth_pg_type)
10936fn synth_pg_proc(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10937    let schema = alloc::vec![
10938        ColumnSchema::new("oid", DataType::BigInt, false),
10939        ColumnSchema::new("proname", DataType::Text, false),
10940        ColumnSchema::new("pronamespace", DataType::BigInt, false),
10941        ColumnSchema::new("prokind", DataType::Text, false),
10942        ColumnSchema::new("pronargs", DataType::Int, false),
10943        ColumnSchema::new("prorettype", DataType::BigInt, false),
10944    ];
10945    // (oid, name, kind, nargs, rettype). OIDs taken from PG's
10946    // pg_proc.dat for the common subset.
10947    let funcs: &[(i64, &str, &str, i32, i64)] = &[
10948        // Scalar functions.
10949        (1318, "length", "f", 1, 23),
10950        (871, "upper", "f", 1, 25),
10951        (870, "lower", "f", 1, 25),
10952        (936, "substring", "f", 3, 25),
10953        (937, "substring", "f", 2, 25),
10954        (3055, "btrim", "f", 1, 25),
10955        (885, "btrim", "f", 2, 25),
10956        (3056, "ltrim", "f", 1, 25),
10957        (875, "ltrim", "f", 2, 25),
10958        (3057, "rtrim", "f", 1, 25),
10959        (876, "rtrim", "f", 2, 25),
10960        (1397, "abs", "f", 1, 23),
10961        (1396, "abs", "f", 1, 20),
10962        (1606, "round", "f", 1, 1700),
10963        (1707, "round", "f", 2, 1700),
10964        (2308, "ceil", "f", 1, 701),
10965        (2309, "ceiling", "f", 1, 701),
10966        (2310, "floor", "f", 1, 701),
10967        (1376, "sqrt", "f", 1, 701),
10968        (1369, "ln", "f", 1, 701),
10969        (1373, "exp", "f", 1, 701),
10970        (1368, "power", "f", 2, 701),
10971        (2228, "random", "f", 0, 701),
10972        // Date / time.
10973        (1299, "now", "f", 0, 1184),
10974        (1274, "current_timestamp", "f", 0, 1184),
10975        (1140, "current_date", "f", 0, 1082),
10976        (2050, "current_time", "f", 0, 1083),
10977        (1158, "date_trunc", "f", 2, 1184),
10978        (1171, "date_part", "f", 2, 701),
10979        (1172, "age", "f", 1, 1186),
10980        (936, "to_char", "f", 2, 25),
10981        // Session / introspection.
10982        (861, "current_database", "f", 0, 19),
10983        (745, "current_user", "f", 0, 19),
10984        (745, "session_user", "f", 0, 19),
10985        (1402, "current_schema", "f", 0, 19),
10986        // String concat / format.
10987        (3058, "concat", "f", -1, 25),
10988        (3059, "concat_ws", "f", -1, 25),
10989        (3539, "format", "f", -1, 25),
10990        // Type introspection.
10991        (2877, "pg_typeof", "f", 1, 2206),
10992        // JSON.
10993        (3198, "json_build_object", "f", -1, 114),
10994        (3199, "jsonb_build_object", "f", -1, 3802),
10995        (3271, "json_build_array", "f", -1, 114),
10996        (3272, "jsonb_build_array", "f", -1, 3802),
10997        // UUID.
10998        (3253, "gen_random_uuid", "f", 0, 2950),
10999        (3252, "uuid_generate_v4", "f", 0, 2950),
11000        // Aggregates.
11001        (2147, "count", "a", 0, 20),
11002        (2803, "count", "a", -1, 20),
11003        (2116, "max", "a", 1, 23),
11004        (2132, "min", "a", 1, 23),
11005        (2108, "sum", "a", 1, 20),
11006        (2100, "avg", "a", 1, 1700),
11007        (2517, "string_agg", "a", 2, 25),
11008        (2747, "array_agg", "a", 1, 1009),
11009        (2517, "bool_and", "a", 1, 16),
11010        (2518, "bool_or", "a", 1, 16),
11011        (2519, "every", "a", 1, 16),
11012        // Window functions.
11013        (3100, "row_number", "w", 0, 20),
11014        (3101, "rank", "w", 0, 20),
11015        (3102, "dense_rank", "w", 0, 20),
11016        (3103, "percent_rank", "w", 0, 701),
11017        (3104, "cume_dist", "w", 0, 701),
11018        (3105, "lag", "w", -1, 2283),
11019        (3106, "lead", "w", -1, 2283),
11020        (3107, "first_value", "w", 1, 2283),
11021        (3108, "last_value", "w", 1, 2283),
11022        (3109, "nth_value", "w", 2, 2283),
11023    ];
11024    let mut rows: Vec<Row> = Vec::with_capacity(funcs.len());
11025    for &(oid, name, kind, nargs, rettype) in funcs {
11026        rows.push(Row::new(alloc::vec![
11027            Value::BigInt(oid),
11028            Value::Text(name.into()),
11029            Value::BigInt(11),
11030            Value::Text(kind.into()),
11031            Value::Int(nargs),
11032            Value::BigInt(rettype),
11033        ]));
11034    }
11035    (schema, rows)
11036}
11037
11038/// v7.17.0 Phase 3.P0-65 — synthesise `mysql.user`. MySQL admin
11039/// queries (`SELECT user, host FROM mysql.user`) probe this at
11040/// connect time to list accounts. SPG ships one row per
11041/// UserStore entry plus a synthetic `root` superuser row for
11042/// MySQL bootstrap compat.
11043fn synth_mysql_user(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
11044    let schema = alloc::vec![
11045        ColumnSchema::new("user", DataType::Text, false),
11046        ColumnSchema::new("host", DataType::Text, false),
11047        ColumnSchema::new("select_priv", DataType::Text, false),
11048    ];
11049    let mut rows: Vec<Row> = Vec::new();
11050    rows.push(Row::new(alloc::vec![
11051        Value::Text("root".into()),
11052        Value::Text("localhost".into()),
11053        Value::Text("Y".into()),
11054    ]));
11055    for (name, _) in engine.users.iter() {
11056        if name != "root" {
11057            rows.push(Row::new(alloc::vec![
11058                Value::Text(name.to_string()),
11059                Value::Text("%".into()),
11060                Value::Text("Y".into()),
11061            ]));
11062        }
11063    }
11064    (schema, rows)
11065}
11066
11067/// v7.17.0 Phase 3.P0-65 — synthesise `mysql.db`. The
11068/// per-database privileges table. SPG is single-database so the
11069/// table surfaces one row per declared user with full privileges
11070/// on the canonical `postgres` database.
11071fn synth_mysql_db() -> (Vec<ColumnSchema>, Vec<Row>) {
11072    let schema = alloc::vec![
11073        ColumnSchema::new("host", DataType::Text, false),
11074        ColumnSchema::new("db", DataType::Text, false),
11075        ColumnSchema::new("user", DataType::Text, false),
11076        ColumnSchema::new("select_priv", DataType::Text, false),
11077    ];
11078    let rows = alloc::vec![Row::new(alloc::vec![
11079        Value::Text("localhost".into()),
11080        Value::Text("postgres".into()),
11081        Value::Text("root".into()),
11082        Value::Text("Y".into()),
11083    ])];
11084    (schema, rows)
11085}
11086
11087/// v7.17.0 Phase 3.P0-63 — synthesise
11088/// `information_schema.KEY_COLUMN_USAGE`. ORM migration tools
11089/// (Alembic, Sequelize, TypeORM) walk this view to discover FK
11090/// relationships in MySQL-flavoured introspection queries.
11091///
11092/// Schema columns exposed:
11093///   * CONSTRAINT_NAME (Text)
11094///   * TABLE_NAME (Text)
11095///   * COLUMN_NAME (Text)
11096///   * ORDINAL_POSITION (Int)
11097///   * REFERENCED_TABLE_NAME (Text) — empty for non-FK rows
11098///   * REFERENCED_COLUMN_NAME (Text) — empty for non-FK rows
11099fn synth_info_key_column_usage(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11100    let schema = alloc::vec![
11101        ColumnSchema::new("constraint_name", DataType::Text, false),
11102        ColumnSchema::new("table_name", DataType::Text, false),
11103        ColumnSchema::new("column_name", DataType::Text, false),
11104        ColumnSchema::new("ordinal_position", DataType::Int, false),
11105        ColumnSchema::new("referenced_table_name", DataType::Text, false),
11106        ColumnSchema::new("referenced_column_name", DataType::Text, false),
11107    ];
11108    let mut rows: Vec<Row> = Vec::new();
11109    for tname in cat.table_names() {
11110        let Some(t) = cat.get(&tname) else { continue };
11111        let cols = &t.schema().columns;
11112        let col_name_at = |pos: usize| -> String {
11113            cols.get(pos)
11114                .map_or_else(|| alloc::format!("col{pos}"), |c| c.name.clone())
11115        };
11116        // FKs.
11117        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
11118            let conname = fk
11119                .name
11120                .clone()
11121                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
11122            for (i, (&local, &parent)) in fk
11123                .local_columns
11124                .iter()
11125                .zip(fk.parent_columns.iter())
11126                .enumerate()
11127            {
11128                let parent_name = cat
11129                    .get(&fk.parent_table)
11130                    .and_then(|pt| pt.schema().columns.get(parent).map(|c| c.name.clone()))
11131                    .unwrap_or_else(|| alloc::format!("col{parent}"));
11132                #[allow(clippy::cast_possible_wrap)]
11133                let ordinal = (i + 1) as i32;
11134                rows.push(Row::new(alloc::vec![
11135                    Value::Text(conname.clone()),
11136                    Value::Text(tname.clone()),
11137                    Value::Text(col_name_at(local)),
11138                    Value::Int(ordinal),
11139                    Value::Text(fk.parent_table.clone()),
11140                    Value::Text(parent_name),
11141                ]));
11142            }
11143        }
11144        // PK / composite UC entries.
11145        for (ci, uc) in t.schema().uniqueness_constraints.iter().enumerate() {
11146            let conname = if uc.is_primary_key {
11147                alloc::format!("{}_pkey", tname)
11148            } else {
11149                alloc::format!("{}_uniq{ci}", tname)
11150            };
11151            for (i, &local) in uc.columns.iter().enumerate() {
11152                #[allow(clippy::cast_possible_wrap)]
11153                let ordinal = (i + 1) as i32;
11154                rows.push(Row::new(alloc::vec![
11155                    Value::Text(conname.clone()),
11156                    Value::Text(tname.clone()),
11157                    Value::Text(col_name_at(local)),
11158                    Value::Int(ordinal),
11159                    Value::Text(String::new()),
11160                    Value::Text(String::new()),
11161                ]));
11162            }
11163        }
11164    }
11165    (schema, rows)
11166}
11167
11168/// v7.17.0 Phase 3.P0-64 — synthesise
11169/// `information_schema.REFERENTIAL_CONSTRAINTS`. One row per FK.
11170fn synth_info_referential_constraints(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11171    let schema = alloc::vec![
11172        ColumnSchema::new("constraint_name", DataType::Text, false),
11173        ColumnSchema::new("table_name", DataType::Text, false),
11174        ColumnSchema::new("referenced_table_name", DataType::Text, false),
11175        ColumnSchema::new("update_rule", DataType::Text, false),
11176        ColumnSchema::new("delete_rule", DataType::Text, false),
11177    ];
11178    fn rule_name(a: spg_storage::FkAction) -> &'static str {
11179        match a {
11180            spg_storage::FkAction::Cascade => "CASCADE",
11181            spg_storage::FkAction::SetNull => "SET NULL",
11182            spg_storage::FkAction::SetDefault => "SET DEFAULT",
11183            spg_storage::FkAction::Restrict => "RESTRICT",
11184            spg_storage::FkAction::NoAction => "NO ACTION",
11185        }
11186    }
11187    let mut rows: Vec<Row> = Vec::new();
11188    for tname in cat.table_names() {
11189        let Some(t) = cat.get(&tname) else { continue };
11190        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
11191            let conname = fk
11192                .name
11193                .clone()
11194                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
11195            rows.push(Row::new(alloc::vec![
11196                Value::Text(conname),
11197                Value::Text(tname.clone()),
11198                Value::Text(fk.parent_table.clone()),
11199                Value::Text(rule_name(fk.on_update).into()),
11200                Value::Text(rule_name(fk.on_delete).into()),
11201            ]));
11202        }
11203    }
11204    (schema, rows)
11205}
11206
11207/// v7.17.0 Phase 3.P0-64 — synthesise `information_schema.STATISTICS`.
11208/// One row per (index × column) — admin tools walk this to
11209/// surface index-cardinality estimates.
11210fn synth_info_statistics(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11211    let schema = alloc::vec![
11212        ColumnSchema::new("table_name", DataType::Text, false),
11213        ColumnSchema::new("index_name", DataType::Text, false),
11214        ColumnSchema::new("column_name", DataType::Text, false),
11215        ColumnSchema::new("seq_in_index", DataType::Int, false),
11216        ColumnSchema::new("non_unique", DataType::Int, false),
11217        ColumnSchema::new("index_type", DataType::Text, false),
11218    ];
11219    let mut rows: Vec<Row> = Vec::new();
11220    for tname in cat.table_names() {
11221        let Some(t) = cat.get(&tname) else { continue };
11222        for idx in t.indices() {
11223            let col = t
11224                .schema()
11225                .columns
11226                .get(idx.column_position)
11227                .map_or("?".into(), |c| c.name.clone());
11228            rows.push(Row::new(alloc::vec![
11229                Value::Text(tname.clone()),
11230                Value::Text(idx.name.clone()),
11231                Value::Text(col),
11232                Value::Int(1),
11233                Value::Int(i32::from(!idx.is_unique)),
11234                Value::Text("BTREE".into()),
11235            ]));
11236        }
11237    }
11238    (schema, rows)
11239}
11240
11241/// v7.17.0 Phase 3.P0-64 — synthesise `information_schema.ROUTINES`.
11242/// SPG has no user-defined functions in v7.17 so the surface is
11243/// always empty; admin tools just need the table to exist.
11244fn synth_info_routines() -> (Vec<ColumnSchema>, Vec<Row>) {
11245    let schema = alloc::vec![
11246        ColumnSchema::new("routine_name", DataType::Text, false),
11247        ColumnSchema::new("routine_type", DataType::Text, false),
11248        ColumnSchema::new("data_type", DataType::Text, false),
11249    ];
11250    (schema, Vec::new())
11251}
11252
11253/// v7.17.0 Phase 3.P0-54 — synthesise `pg_catalog.pg_constraint`.
11254/// ORM compilers (Diesel, sea-orm) and admin tools probe this for
11255/// FK / UNIQUE / PK / CHECK definitions to surface relationship
11256/// graphs and validation rules. SPG ships one row per
11257/// uniqueness constraint + foreign key declared in the catalog.
11258///
11259/// Schema columns exposed:
11260///   * conname (Text) — constraint name (synthetic when anonymous)
11261///   * contype (Text) — `p` PK, `u` UNIQUE, `f` FK, `c` CHECK
11262///   * conrelid (Text) — owner table name
11263///   * confrelid (Text) — referenced parent table (FK only;
11264///     empty string otherwise)
11265///   * conkey (Text) — comma-separated column names
11266///   * confkey (Text) — comma-separated parent column names (FK only)
11267fn synth_pg_constraint(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11268    let schema = alloc::vec![
11269        ColumnSchema::new("conname", DataType::Text, false),
11270        ColumnSchema::new("contype", DataType::Text, false),
11271        ColumnSchema::new("conrelid", DataType::Text, false),
11272        ColumnSchema::new("confrelid", DataType::Text, false),
11273        ColumnSchema::new("conkey", DataType::Text, false),
11274        ColumnSchema::new("confkey", DataType::Text, false),
11275    ];
11276    let mut rows: Vec<Row> = Vec::new();
11277    for tname in cat.table_names() {
11278        let Some(t) = cat.get(&tname) else { continue };
11279        let cols = &t.schema().columns;
11280        let col_name_at = |pos: usize| -> String {
11281            cols.get(pos)
11282                .map_or_else(|| alloc::format!("col{pos}"), |c| c.name.clone())
11283        };
11284        // Uniqueness constraints (composite UNIQUE / PRIMARY KEY).
11285        for (ci, uc) in t.schema().uniqueness_constraints.iter().enumerate() {
11286            let kind = if uc.is_primary_key { "p" } else { "u" };
11287            let conname = if uc.is_primary_key {
11288                alloc::format!("{}_pkey", tname)
11289            } else {
11290                alloc::format!("{}_uniq{ci}", tname)
11291            };
11292            let conkey: Vec<String> = uc.columns.iter().map(|&p| col_name_at(p)).collect();
11293            rows.push(Row::new(alloc::vec![
11294                Value::Text(conname),
11295                Value::Text(kind.into()),
11296                Value::Text(tname.clone()),
11297                Value::Text(String::new()),
11298                Value::Text(conkey.join(",")),
11299                Value::Text(String::new()),
11300            ]));
11301        }
11302        // Single-column PK / UNIQUE indexes that have no
11303        // matching entry in `uniqueness_constraints` (the engine
11304        // creates only the BTree index for the bare-column case;
11305        // composite forms ride the UC path above).
11306        for idx in t.indices() {
11307            if !idx.is_unique {
11308                continue;
11309            }
11310            let is_primary = idx.name.ends_with("_pkey");
11311            let conname = idx.name.clone();
11312            let kind = if is_primary { "p" } else { "u" };
11313            let col_name = col_name_at(idx.column_position);
11314            // Skip if already emitted via the UC loop above (same
11315            // tuple shape — single-column).
11316            let already = t
11317                .schema()
11318                .uniqueness_constraints
11319                .iter()
11320                .any(|uc| uc.columns.len() == 1 && uc.columns[0] == idx.column_position);
11321            if already {
11322                continue;
11323            }
11324            rows.push(Row::new(alloc::vec![
11325                Value::Text(conname),
11326                Value::Text(kind.into()),
11327                Value::Text(tname.clone()),
11328                Value::Text(String::new()),
11329                Value::Text(col_name),
11330                Value::Text(String::new()),
11331            ]));
11332        }
11333        // Foreign keys.
11334        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
11335            let conname = fk
11336                .name
11337                .clone()
11338                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
11339            let conkey: Vec<String> = fk.local_columns.iter().map(|&p| col_name_at(p)).collect();
11340            // Parent column names: look up the parent table's
11341            // schema if it exists; otherwise emit positions.
11342            let confkey: Vec<String> = if let Some(parent) = cat.get(&fk.parent_table) {
11343                fk.parent_columns
11344                    .iter()
11345                    .map(|&p| {
11346                        parent
11347                            .schema()
11348                            .columns
11349                            .get(p)
11350                            .map_or_else(|| alloc::format!("col{p}"), |c| c.name.clone())
11351                    })
11352                    .collect()
11353            } else {
11354                fk.parent_columns
11355                    .iter()
11356                    .map(|p| alloc::format!("col{p}"))
11357                    .collect()
11358            };
11359            rows.push(Row::new(alloc::vec![
11360                Value::Text(conname),
11361                Value::Text("f".into()),
11362                Value::Text(tname.clone()),
11363                Value::Text(fk.parent_table.clone()),
11364                Value::Text(conkey.join(",")),
11365                Value::Text(confkey.join(",")),
11366            ]));
11367        }
11368    }
11369    (schema, rows)
11370}
11371
11372/// v7.17.0 Phase 3.P0-55 — synthesise `pg_catalog.pg_database`.
11373/// SPG is single-database so we surface a single row keyed on the
11374/// canonical `postgres` database name (matching what every PG
11375/// admin tool's startup screen expects to find).
11376fn synth_pg_database(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11377    let schema = alloc::vec![
11378        ColumnSchema::new("oid", DataType::BigInt, false),
11379        ColumnSchema::new("datname", DataType::Text, false),
11380        ColumnSchema::new("datdba", DataType::BigInt, false),
11381        ColumnSchema::new("encoding", DataType::Int, false),
11382        ColumnSchema::new("datcollate", DataType::Text, false),
11383    ];
11384    let rows = alloc::vec![Row::new(alloc::vec![
11385        Value::BigInt(16384),
11386        Value::Text("postgres".into()),
11387        Value::BigInt(10),
11388        Value::Int(6), // UTF8
11389        Value::Text("en_US.UTF-8".into()),
11390    ])];
11391    (schema, rows)
11392}
11393
11394/// v7.17.0 Phase 3.P0-55 — synthesise `pg_catalog.pg_roles`. PG's
11395/// pg_roles is a view over pg_authid showing all roles. SPG ships
11396/// one row per declared user from the engine's UserStore so admin
11397/// tool startup screens can populate.
11398fn synth_pg_roles(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
11399    let schema = alloc::vec![
11400        ColumnSchema::new("oid", DataType::BigInt, false),
11401        ColumnSchema::new("rolname", DataType::Text, false),
11402        ColumnSchema::new("rolsuper", DataType::Bool, false),
11403        ColumnSchema::new("rolinherit", DataType::Bool, false),
11404        ColumnSchema::new("rolcanlogin", DataType::Bool, false),
11405    ];
11406    let mut rows: Vec<Row> = Vec::new();
11407    let oid: i64 = 10;
11408    for (i, (name, _)) in engine.users.iter().enumerate() {
11409        rows.push(Row::new(alloc::vec![
11410            Value::BigInt(oid + (i as i64) + 1),
11411            Value::Text(name.to_string()),
11412            Value::Bool(false),
11413            Value::Bool(true),
11414            Value::Bool(true),
11415        ]));
11416    }
11417    // Always include `postgres` as the bootstrap superuser if not
11418    // already present — admin tools probe for it.
11419    if !rows
11420        .iter()
11421        .any(|r| matches!(&r.values[1], Value::Text(s) if s == "postgres"))
11422    {
11423        rows.insert(
11424            0,
11425            Row::new(alloc::vec![
11426                Value::BigInt(10),
11427                Value::Text("postgres".into()),
11428                Value::Bool(true),
11429                Value::Bool(true),
11430                Value::Bool(true),
11431            ]),
11432        );
11433    }
11434    (schema, rows)
11435}
11436
11437/// v7.17.0 Phase 3.P0-56 — synthesise `pg_catalog.pg_views`. PG's
11438/// pg_views is a view listing every catalog view; SPG ships one
11439/// row per declared view + its definition text.
11440/// Synthesise `pg_catalog.pg_extension`. SPG ships its "extension"
11441/// surfaces natively (vector, pg_trgm, plpgsql-shaped DO blocks), so
11442/// the table lists those as installed — `SELECT … FROM pg_extension
11443/// WHERE extname = 'vector'` probes from PG clients (mailrs embed
11444/// round-12) answer truthfully about capability presence.
11445fn synth_pg_extension() -> (Vec<ColumnSchema>, Vec<Row>) {
11446    let schema = alloc::vec![
11447        ColumnSchema::new("oid", DataType::BigInt, false),
11448        ColumnSchema::new("extname", DataType::Text, false),
11449        ColumnSchema::new("extversion", DataType::Text, false),
11450        ColumnSchema::new("extnamespace", DataType::Text, false),
11451    ];
11452    let exts: &[(&str, &str)] = &[("plpgsql", "1.0"), ("vector", "0.8.0"), ("pg_trgm", "1.6")];
11453    let rows = exts
11454        .iter()
11455        .enumerate()
11456        .map(|(i, (name, ver))| {
11457            Row::new(alloc::vec![
11458                Value::BigInt(16384 + i as i64),
11459                Value::Text((*name).into()),
11460                Value::Text((*ver).into()),
11461                Value::Text("pg_catalog".into()),
11462            ])
11463        })
11464        .collect();
11465    (schema, rows)
11466}
11467
11468fn synth_pg_views(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11469    let schema = alloc::vec![
11470        ColumnSchema::new("schemaname", DataType::Text, false),
11471        ColumnSchema::new("viewname", DataType::Text, false),
11472        ColumnSchema::new("definition", DataType::Text, false),
11473    ];
11474    let mut rows: Vec<Row> = Vec::new();
11475    for (name, def) in cat.views() {
11476        rows.push(Row::new(alloc::vec![
11477            Value::Text("public".into()),
11478            Value::Text(name.clone()),
11479            Value::Text(def.body.clone()),
11480        ]));
11481    }
11482    (schema, rows)
11483}
11484
11485/// v7.17.0 Phase 3.P0-57 — synthesise `pg_catalog.pg_settings`. ORM
11486/// connection-checkers (sqlx pre-flight, Diesel migrator) and admin
11487/// tools read `pg_settings` to discover server-side configuration.
11488/// SPG surfaces every session_param + a small set of canonical PG
11489/// defaults so the pre-flight queries match.
11490fn synth_pg_settings(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
11491    let schema = alloc::vec![
11492        ColumnSchema::new("name", DataType::Text, false),
11493        ColumnSchema::new("setting", DataType::Text, false),
11494        ColumnSchema::new("category", DataType::Text, false),
11495    ];
11496    let mut rows: Vec<Row> = Vec::new();
11497    // Canonical defaults every admin tool expects to find.
11498    let defaults: &[(&str, &str, &str)] = &[
11499        ("server_version", "16.0 (spg)", "Preset Options"),
11500        ("server_encoding", "UTF8", "Client Connection Defaults"),
11501        ("client_encoding", "UTF8", "Client Connection Defaults"),
11502        ("DateStyle", "ISO, MDY", "Client Connection Defaults"),
11503        ("TimeZone", "UTC", "Client Connection Defaults"),
11504        ("standard_conforming_strings", "on", "Compatibility"),
11505        ("integer_datetimes", "on", "Compatibility"),
11506        ("max_connections", "100", "Connections and Authentication"),
11507    ];
11508    for &(name, val, cat) in defaults {
11509        rows.push(Row::new(alloc::vec![
11510            Value::Text(name.into()),
11511            Value::Text(val.into()),
11512            Value::Text(cat.into()),
11513        ]));
11514    }
11515    // Session-set params override the static defaults.
11516    for (k, v) in &engine.session_params {
11517        if !defaults
11518            .iter()
11519            .any(|(n, _, _)| (*n).eq_ignore_ascii_case(k))
11520        {
11521            rows.push(Row::new(alloc::vec![
11522                Value::Text(k.clone()),
11523                Value::Text(v.clone()),
11524                Value::Text("Session".into()),
11525            ]));
11526        }
11527    }
11528    (schema, rows)
11529}
11530
11531/// v7.17.0 Phase 3.P0-53 — synthesise `pg_catalog.pg_indexes`.
11532/// PG's pg_indexes is a real view on pg_index + pg_class + pg_attribute.
11533/// SPG ships it as a synthesised flat table so admin tools (pgAdmin,
11534/// DataGrip) can list indexes by tablename without joining four catalogs.
11535///
11536/// Schema columns exposed:
11537///   * schemaname (Text) — always `public`
11538///   * tablename (Text)
11539///   * indexname (Text)
11540///   * indexdef (Text) — best-effort CREATE INDEX DDL
11541fn synth_pg_indexes(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11542    let schema = alloc::vec![
11543        ColumnSchema::new("schemaname", DataType::Text, false),
11544        ColumnSchema::new("tablename", DataType::Text, false),
11545        ColumnSchema::new("indexname", DataType::Text, false),
11546        ColumnSchema::new("indexdef", DataType::Text, false),
11547    ];
11548    let mut rows: Vec<Row> = Vec::new();
11549    for tname in cat.table_names() {
11550        let Some(t) = cat.get(&tname) else { continue };
11551        for idx in t.indices() {
11552            let col_name = t
11553                .schema()
11554                .columns
11555                .get(idx.column_position)
11556                .map_or("?".into(), |c| c.name.clone());
11557            let unique_kw = if idx.is_unique { "UNIQUE " } else { "" };
11558            let indexdef = alloc::format!(
11559                "CREATE {unique_kw}INDEX {} ON public.{} ({})",
11560                idx.name,
11561                tname,
11562                col_name
11563            );
11564            rows.push(Row::new(alloc::vec![
11565                Value::Text("public".into()),
11566                Value::Text(tname.clone()),
11567                Value::Text(idx.name.clone()),
11568                Value::Text(indexdef),
11569            ]));
11570        }
11571    }
11572    (schema, rows)
11573}
11574
11575/// v7.17.0 Phase 3.P0-53 — synthesise `pg_catalog.pg_index`. The
11576/// "raw" pg_index catalog used by PG-internal tooling for index
11577/// flags and ordinal information. SPG ships the columns ORM probes
11578/// actually filter on.
11579///
11580/// Schema columns exposed:
11581///   * indexrelid (BigInt) — index OID (synthetic = position+1)
11582///   * indrelid (BigInt) — table OID (synthetic = position+1)
11583///   * indnatts (Int) — number of indexed columns
11584///   * indisunique (Bool)
11585///   * indisprimary (Bool)
11586fn synth_pg_index_raw(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11587    let schema = alloc::vec![
11588        ColumnSchema::new("indexrelid", DataType::BigInt, false),
11589        ColumnSchema::new("indrelid", DataType::BigInt, false),
11590        ColumnSchema::new("indnatts", DataType::Int, false),
11591        ColumnSchema::new("indisunique", DataType::Bool, false),
11592        ColumnSchema::new("indisprimary", DataType::Bool, false),
11593    ];
11594    let mut rows: Vec<Row> = Vec::new();
11595    let mut idx_oid: i64 = 100_000;
11596    for (table_idx, tname) in cat.table_names().iter().enumerate() {
11597        let Some(t) = cat.get(tname) else { continue };
11598        for idx in t.indices() {
11599            idx_oid += 1;
11600            #[allow(clippy::cast_possible_wrap)]
11601            let nattrs = (1 + idx.extra_column_positions.len()) as i32;
11602            // is_primary: SPG / PG flag the primary via the
11603            // index name convention `<table>_pkey`.
11604            let is_primary = idx.name.ends_with("_pkey");
11605            rows.push(Row::new(alloc::vec![
11606                Value::BigInt(idx_oid),
11607                Value::BigInt((table_idx + 1) as i64),
11608                Value::Int(nattrs),
11609                Value::Bool(idx.is_unique),
11610                Value::Bool(is_primary),
11611            ]));
11612        }
11613    }
11614    (schema, rows)
11615}
11616
11617/// v7.17.0 Phase 3.P0-52 — synthesise `pg_catalog.pg_namespace`.
11618/// SPG is single-schema so we expose the canonical PG schemas:
11619/// `public` (user-facing), `pg_catalog` (built-in), and
11620/// `information_schema` (PG meta).
11621fn synth_pg_namespace(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11622    let schema = alloc::vec![
11623        ColumnSchema::new("oid", DataType::BigInt, false),
11624        ColumnSchema::new("nspname", DataType::Text, false),
11625        ColumnSchema::new("nspowner", DataType::BigInt, false),
11626    ];
11627    let rows = alloc::vec![
11628        Row::new(alloc::vec![
11629            Value::BigInt(11),
11630            Value::Text("pg_catalog".into()),
11631            Value::BigInt(10),
11632        ]),
11633        Row::new(alloc::vec![
11634            Value::BigInt(2200),
11635            Value::Text("public".into()),
11636            Value::BigInt(10),
11637        ]),
11638        Row::new(alloc::vec![
11639            Value::BigInt(13000),
11640            Value::Text("information_schema".into()),
11641            Value::BigInt(10),
11642        ]),
11643    ];
11644    (schema, rows)
11645}
11646
11647/// v7.16.2 — drop the synthesised meta view into the enriched
11648/// catalog so the regular FROM-resolution path can see it.
11649fn materialise_meta_view(
11650    catalog: &mut Catalog,
11651    name: &str,
11652    columns: Vec<ColumnSchema>,
11653    rows: Vec<Row>,
11654) -> Result<(), EngineError> {
11655    let schema = TableSchema::new(name.to_string(), columns);
11656    catalog.create_table(schema).map_err(EngineError::Storage)?;
11657    let table = catalog
11658        .get_mut(name)
11659        .expect("just-created meta view must exist");
11660    for row in rows {
11661        table.insert(row).map_err(EngineError::Storage)?;
11662    }
11663    Ok(())
11664}
11665
11666/// v7.16.2 — true when the SELECT statement references any
11667/// `__spg_info_*` or `__spg_pg_*` synthetic table name (the
11668/// parser produces these for `information_schema.X` /
11669/// `pg_catalog.X`). Used by `exec_select_cancel` to short-
11670/// circuit into the meta-view materialisation path.
11671/// v7.17.0 Phase 1.2 — append the names of any catalog-known
11672/// views referenced by `tref` to `into`. Helper for
11673/// `Engine::expand_views_in_select`. A view that's been already
11674/// materialised as a table (e.g. via the synthetic CTE pass for
11675/// SELECT FROM v) is skipped — the table form wins so the
11676/// recursive exec_select_cancel call inside exec_with_ctes
11677/// doesn't re-expand and trigger the CTE-shadow guard.
11678fn collect_view_refs(
11679    tref: &spg_sql::ast::TableRef,
11680    cat: &spg_storage::Catalog,
11681    into: &mut Vec<String>,
11682) {
11683    if cat.views().contains_key(&tref.name)
11684        && cat.get(&tref.name).is_none()
11685        && !into.iter().any(|n| n == &tref.name)
11686    {
11687        into.push(tref.name.clone());
11688    }
11689}
11690
11691fn select_references_meta_view(stmt: &SelectStatement) -> bool {
11692    fn is_meta(name: &str) -> bool {
11693        name.starts_with("__spg_info_")
11694            || name.starts_with("__spg_pg_")
11695            || name.starts_with("__spg_mysql_")
11696    }
11697    if let Some(from) = &stmt.from {
11698        if is_meta(&from.primary.name) {
11699            return true;
11700        }
11701        for j in &from.joins {
11702            if is_meta(&j.table.name) {
11703                return true;
11704            }
11705        }
11706    }
11707    for cte in &stmt.ctes {
11708        if select_references_meta_view(&cte.body) {
11709            return true;
11710        }
11711    }
11712    false
11713}
11714
11715/// v7.16.2 — collect every meta-view name a SELECT touches.
11716/// Returns a deduplicated, sorted list. Caller materialises
11717/// each one into the enriched catalog before re-running the
11718/// SELECT. Walks JOINs, CTEs, and the primary FROM.
11719fn collect_meta_view_names(
11720    stmt: &SelectStatement,
11721    into: &mut alloc::collections::BTreeSet<String>,
11722) {
11723    fn is_meta(name: &str) -> bool {
11724        name.starts_with("__spg_info_")
11725            || name.starts_with("__spg_pg_")
11726            || name.starts_with("__spg_mysql_")
11727    }
11728    if let Some(from) = &stmt.from {
11729        if is_meta(&from.primary.name) {
11730            into.insert(from.primary.name.clone());
11731        }
11732        for j in &from.joins {
11733            if is_meta(&j.table.name) {
11734                into.insert(j.table.name.clone());
11735            }
11736        }
11737    }
11738    for cte in &stmt.ctes {
11739        collect_meta_view_names(&cte.body, into);
11740    }
11741}
11742
11743fn infer_column_types(columns: &[ColumnSchema], rows: &[Row]) -> Vec<ColumnSchema> {
11744    let mut out = columns.to_vec();
11745    for (col_idx, col) in out.iter_mut().enumerate() {
11746        if col.ty != DataType::Text {
11747            continue;
11748        }
11749        let mut inferred: Option<DataType> = None;
11750        let mut all_null = true;
11751        for row in rows {
11752            let Some(v) = row.values.get(col_idx) else {
11753                continue;
11754            };
11755            let ty = match v {
11756                Value::Null => continue,
11757                Value::SmallInt(_) => DataType::SmallInt,
11758                Value::Int(_) => DataType::Int,
11759                Value::BigInt(_) => DataType::BigInt,
11760                Value::Float(_) => DataType::Float,
11761                Value::Bool(_) => DataType::Bool,
11762                Value::Vector(_) => DataType::Vector {
11763                    dim: 0,
11764                    encoding: VecEncoding::F32,
11765                },
11766                _ => DataType::Text,
11767            };
11768            all_null = false;
11769            inferred = Some(match inferred {
11770                None => ty,
11771                Some(prev) if prev == ty => prev,
11772                Some(_) => DataType::Text,
11773            });
11774        }
11775        if let Some(t) = inferred {
11776            col.ty = t;
11777            col.nullable = true;
11778        } else if all_null {
11779            col.nullable = true;
11780        }
11781    }
11782    out
11783}
11784
11785/// v4.26: render a human-readable plan tree for `EXPLAIN <select>`.
11786/// Lines are pushed into `out`; `depth` controls indentation. We
11787/// describe the rewritten SELECT — what the executor *would* do —
11788/// using the engine handle to spot indexed lookups and table shapes.
11789#[allow(clippy::too_many_lines, clippy::format_push_string)]
11790/// v6.2.4 — Walk every line of the rendered plan tree and append
11791/// per-operator stats. Lines that name a known operator get
11792/// `(rows=N)` (`actual_rows` of the top-level operator equals the
11793/// final result row count; scans report their catalog row count
11794/// as the rows-considered metric). Other lines — Filter / Join /
11795/// GroupBy / OrderBy etc. — are marked `(—)` so the surface is
11796/// complete-by-construction; v6.2.5 fills these in via inline
11797/// executor counters.
11798/// v6.8.3 — surface "CREATE INDEX …" suggestions for every
11799/// `(table, column)` pair the query touches via WHERE / JOIN
11800/// that doesn't already have an index on the owning table.
11801/// Walks the SELECT's FROM clauses + WHERE expression tree;
11802/// returns one line per missing index. Deterministic order:
11803/// FROM-clause iteration order, then column-reference walk
11804/// order inside each WHERE. Each suggestion is a copy-pastable
11805/// DDL string.
11806fn build_index_suggestions(stmt: &SelectStatement, engine: &Engine) -> Vec<String> {
11807    use alloc::collections::BTreeSet;
11808    let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
11809    let mut out: Vec<String> = Vec::new();
11810    let cat = engine.active_catalog();
11811    // Build a (table, qualifier-or-alias) list from the FROM clause
11812    // so unqualified column refs in WHERE resolve to the correct
11813    // table.
11814    let Some(from) = &stmt.from else {
11815        return out;
11816    };
11817    let mut tables: Vec<String> = Vec::new();
11818    tables.push(from.primary.name.clone());
11819    for j in &from.joins {
11820        tables.push(j.table.name.clone());
11821    }
11822    // Collect column refs from the WHERE expression. JOIN ON
11823    // predicates also feed in.
11824    let mut col_refs: Vec<spg_sql::ast::ColumnName> = Vec::new();
11825    if let Some(w) = &stmt.where_ {
11826        collect_column_refs(w, &mut col_refs);
11827    }
11828    for j in &from.joins {
11829        if let Some(on) = &j.on {
11830            collect_column_refs(on, &mut col_refs);
11831        }
11832    }
11833    for cn in &col_refs {
11834        // Resolve owner table: explicit qualifier first, else
11835        // first table in FROM that has a column of this name.
11836        let owner: Option<String> = if let Some(q) = &cn.qualifier {
11837            tables.iter().find(|t| t == &q).cloned()
11838        } else {
11839            tables.iter().find_map(|t| {
11840                cat.get(t).and_then(|tbl| {
11841                    if tbl.schema().column_position(&cn.name).is_some() {
11842                        Some(t.clone())
11843                    } else {
11844                        None
11845                    }
11846                })
11847            })
11848        };
11849        let Some(owner) = owner else {
11850            continue;
11851        };
11852        let Some(tbl) = cat.get(&owner) else {
11853            continue;
11854        };
11855        let Some(col_pos) = tbl.schema().column_position(&cn.name) else {
11856            continue;
11857        };
11858        // Skip if any BTree index already covers this column as
11859        // its key.
11860        let already_indexed = tbl.indices().iter().any(|i| {
11861            matches!(i.kind, spg_storage::IndexKind::BTree(_))
11862                && i.column_position == col_pos
11863                && i.expression.is_none()
11864                && i.partial_predicate.is_none()
11865        });
11866        if already_indexed {
11867            continue;
11868        }
11869        if seen.insert((owner.clone(), cn.name.clone())) {
11870            out.push(alloc::format!(
11871                "SUGGEST: CREATE INDEX ix_{}_{} ON {} ({})",
11872                owner,
11873                cn.name,
11874                owner,
11875                cn.name
11876            ));
11877        }
11878    }
11879    out
11880}
11881
11882/// Walks an `Expr` and pushes every `ColumnName` it references.
11883/// Order is depth-first, left-to-right.
11884fn collect_column_refs(expr: &Expr, out: &mut Vec<spg_sql::ast::ColumnName>) {
11885    match expr {
11886        Expr::Column(cn) => out.push(cn.clone()),
11887        Expr::FunctionCall { args, .. } => {
11888            for a in args {
11889                collect_column_refs(a, out);
11890            }
11891        }
11892        Expr::Binary { lhs, rhs, .. } => {
11893            collect_column_refs(lhs, out);
11894            collect_column_refs(rhs, out);
11895        }
11896        Expr::Unary { expr: e, .. } => collect_column_refs(e, out),
11897        _ => {}
11898    }
11899}
11900
11901fn annotate_explain_lines(lines: &mut [String], total_rows: usize, engine: &Engine) {
11902    let catalog = engine.active_catalog();
11903    let cold_ids = catalog.cold_segment_ids_global();
11904    let any_cold = !cold_ids.is_empty();
11905    let cold_ids_repr = if any_cold {
11906        let mut s = alloc::string::String::from("[");
11907        for (i, id) in cold_ids.iter().enumerate() {
11908            if i > 0 {
11909                s.push(',');
11910            }
11911            s.push_str(&alloc::format!("{id}"));
11912        }
11913        s.push(']');
11914        s
11915    } else {
11916        alloc::string::String::new()
11917    };
11918    for (idx, line) in lines.iter_mut().enumerate() {
11919        let trimmed = line.trim_start();
11920        let is_top_level = idx == 0;
11921        if is_top_level {
11922            line.push_str(&alloc::format!(" (rows={total_rows})"));
11923            continue;
11924        }
11925        if let Some(rest) = trimmed.strip_prefix("From: ") {
11926            let (name, scan_kind) = match rest.split_once(" [") {
11927                Some((n, k)) => (n.trim(), k.trim_end_matches(']')),
11928                None => (rest.trim(), ""),
11929            };
11930            let bare = name.split_whitespace().next().unwrap_or(name);
11931            let hot = catalog.get(bare).map(|t| t.rows().len());
11932            // v6.2.7 — `cold_segments=[id0,id1,…]` enumerates every
11933            // cold-tier segment the scan COULD have walked. v6.2.x
11934            // can tighten to per-table by walking the table's
11935            // BTree-index cold locators.
11936            let annot = match (hot, scan_kind) {
11937                (Some(h), "full scan") => {
11938                    let mut s = alloc::format!(" (hot_rows={h}");
11939                    if any_cold {
11940                        s.push_str(&alloc::format!(
11941                            ", cold_tier=present, cold_segments={cold_ids_repr}"
11942                        ));
11943                    }
11944                    s.push(')');
11945                    s
11946                }
11947                (Some(h), "index seek") => {
11948                    let mut s = alloc::format!(" (hot_rows≤{h}");
11949                    if any_cold {
11950                        s.push_str(&alloc::format!(
11951                            ", cold_tier=present, cold_segments={cold_ids_repr}"
11952                        ));
11953                    }
11954                    s.push(')');
11955                    s
11956                }
11957                _ => " (rows=—)".to_string(),
11958            };
11959            line.push_str(&annot);
11960            continue;
11961        }
11962        // Filter / GroupBy / Having / OrderBy / Limit / Join etc.
11963        line.push_str(" (rows=—)");
11964    }
11965}
11966
11967fn explain_select(stmt: &SelectStatement, engine: &Engine, depth: usize, out: &mut Vec<String>) {
11968    let pad = "  ".repeat(depth);
11969    // 1) Top-level operator label.
11970    let top = if !stmt.ctes.is_empty() {
11971        if stmt.ctes.iter().any(|c| c.recursive) {
11972            "CTEScan (WITH RECURSIVE)"
11973        } else {
11974            "CTEScan (WITH)"
11975        }
11976    } else if !stmt.unions.is_empty() {
11977        "UnionScan"
11978    } else if select_has_window(stmt) {
11979        "WindowAgg"
11980    } else if aggregate::uses_aggregate(stmt) {
11981        "Aggregate"
11982    } else if stmt.distinct {
11983        "Distinct"
11984    } else if stmt.from.is_some() {
11985        "TableScan"
11986    } else {
11987        "Result"
11988    };
11989    out.push(alloc::format!("{pad}{top}"));
11990    let child = "  ".repeat(depth + 1);
11991    // 2) CTE bodies.
11992    for cte in &stmt.ctes {
11993        let head = if cte.recursive {
11994            alloc::format!("{child}CTE (recursive): {}", cte.name)
11995        } else {
11996            alloc::format!("{child}CTE: {}", cte.name)
11997        };
11998        out.push(head);
11999        explain_select(&cte.body, engine, depth + 2, out);
12000    }
12001    // 3) FROM details — primary table + joins, index hits.
12002    if let Some(from) = &stmt.from {
12003        let mut tag = alloc::format!("{child}From: {}", from.primary.name);
12004        if let Some(alias) = &from.primary.alias {
12005            tag.push_str(&alloc::format!(" AS {alias}"));
12006        }
12007        // Try to detect an index-seek opportunity on WHERE against
12008        // the primary table — same heuristic the executor uses.
12009        if let Some(w) = &stmt.where_
12010            && let Some(table) = engine.active_catalog().get(&from.primary.name)
12011        {
12012            let alias = from.primary.alias.as_deref().unwrap_or(&from.primary.name);
12013            let cols = &table.schema().columns;
12014            if try_index_seek(w, cols, engine.active_catalog(), table, alias).is_some() {
12015                tag.push_str(" [index seek]");
12016            } else {
12017                tag.push_str(" [full scan]");
12018            }
12019        } else {
12020            tag.push_str(" [full scan]");
12021        }
12022        out.push(tag);
12023        for j in &from.joins {
12024            let kind = match j.kind {
12025                spg_sql::ast::JoinKind::Inner => "INNER JOIN",
12026                spg_sql::ast::JoinKind::Left => "LEFT JOIN",
12027                spg_sql::ast::JoinKind::Cross => "CROSS JOIN",
12028            };
12029            let mut s = alloc::format!("{child}{kind}: {}", j.table.name);
12030            if let Some(alias) = &j.table.alias {
12031                s.push_str(&alloc::format!(" AS {alias}"));
12032            }
12033            if j.on.is_some() {
12034                s.push_str(" (ON …)");
12035            }
12036            out.push(s);
12037        }
12038    }
12039    // 4) WHERE / GROUP BY / HAVING / ORDER BY / LIMIT / OFFSET.
12040    if let Some(w) = &stmt.where_ {
12041        let mut s = alloc::format!("{child}Filter: {w}");
12042        if expr_has_subquery(w) {
12043            s.push_str(" [subquery]");
12044        }
12045        out.push(s);
12046    }
12047    if let Some(gs) = &stmt.group_by {
12048        let mut parts = Vec::new();
12049        for g in gs {
12050            parts.push(alloc::format!("{g}"));
12051        }
12052        out.push(alloc::format!("{child}GroupBy: {}", parts.join(", ")));
12053    }
12054    if let Some(h) = &stmt.having {
12055        out.push(alloc::format!("{child}Having: {h}"));
12056    }
12057    for o in &stmt.order_by {
12058        let dir = if o.desc { "DESC" } else { "ASC" };
12059        out.push(alloc::format!("{child}OrderBy: {} {dir}", o.expr));
12060    }
12061    if let Some(lim) = stmt.limit {
12062        out.push(alloc::format!("{child}Limit: {lim}"));
12063    }
12064    if let Some(off) = stmt.offset {
12065        out.push(alloc::format!("{child}Offset: {off}"));
12066    }
12067    // 5) Projection — collapse Wildcard or render N items.
12068    if stmt
12069        .items
12070        .iter()
12071        .any(|it| matches!(it, SelectItem::Wildcard))
12072    {
12073        out.push(alloc::format!("{child}Project: *"));
12074    } else {
12075        out.push(alloc::format!(
12076            "{child}Project: {} item(s)",
12077            stmt.items.len()
12078        ));
12079    }
12080    // 6) Recurse into UNION peers.
12081    for (kind, peer) in &stmt.unions {
12082        let label = match kind {
12083            UnionKind::All => "UNION ALL",
12084            UnionKind::Distinct => "UNION",
12085        };
12086        out.push(alloc::format!("{child}{label}"));
12087        explain_select(peer, engine, depth + 2, out);
12088    }
12089}
12090
12091/// v4.23: recognise the engine errors that indicate the inner
12092/// SELECT couldn't be evaluated in isolation because it references
12093/// an outer column — used by `subquery_replacement` to skip
12094/// materialisation and let row-eval handle it instead.
12095fn is_correlation_error(e: &EngineError) -> bool {
12096    matches!(
12097        e,
12098        EngineError::Eval(
12099            eval::EvalError::ColumnNotFound { .. } | eval::EvalError::UnknownQualifier { .. }
12100        )
12101    )
12102}
12103
12104/// v4.23: walk every Expr in `stmt` and replace each Column ref
12105/// that targets the outer scope (qualifier matches the outer
12106/// table alias) with a Literal carrying the outer row's value.
12107/// Conservative: only qualified refs are substituted, so the user
12108/// must write `outer_alias.col` to reference an outer column. This
12109/// matches PG's lexical scoping for correlated subqueries and
12110/// avoids accidentally rebinding inner columns of the same name.
12111/// v7.17.0 Phase 3.P0-41 — LATERAL peer descriptor. Either eagerly
12112/// materialised (every regular table / unnest / generate_series) or
12113/// lateral (subquery re-evaluated per outer row).
12114struct JoinedPeer<'a> {
12115    eager_rows: Option<Vec<Row>>,
12116    cols: Vec<ColumnSchema>,
12117    alias: String,
12118    kind: JoinKind,
12119    on: Option<&'a Expr>,
12120    lateral: Option<&'a SelectStatement>,
12121}
12122
12123/// v7.17.0 Phase 3.P0-41 — synthesise a column name for a LATERAL
12124/// projection item that has no explicit alias. PG names anonymous
12125/// projection items by the function call's name or by `column<i>`.
12126/// SPG mirrors the latter (lower-overhead than walking arbitrary
12127/// Expr shapes) so the probe-schema fallback path produces stable
12128/// names for the lateral peer's columns.
12129fn synth_lateral_col_name(expr: &Expr, idx: usize) -> String {
12130    match expr {
12131        // Bare column reference — use the column's own name.
12132        Expr::Column(c) => c.name.clone(),
12133        // Function call — use the function name (PG canonical:
12134        // `count` / `max` / `lower` …).
12135        Expr::FunctionCall { name, .. } => name.clone(),
12136        // Cast — drill into the inner expression.
12137        Expr::Cast { expr: inner, .. } => synth_lateral_col_name(inner, idx),
12138        // Everything else falls back to PG's `column<N>` placeholder.
12139        _ => alloc::format!("column{}", idx + 1),
12140    }
12141}
12142
12143/// v7.17.0 Phase 3.P0-41 — substitute every `<alias>.<col>` Expr
12144/// reference whose `<alias>.<col>` exists in the outer composite
12145/// schema with the matching value from the outer row. Walks the
12146/// entire SELECT body (items, WHERE, GROUP BY, HAVING, ORDER BY,
12147/// UNION peers) so any depth of outer reference inside the
12148/// LATERAL subquery resolves before execution.
12149fn substitute_outer_columns_multi(
12150    stmt: &mut SelectStatement,
12151    outer_row: &Row,
12152    outer_schema: &[ColumnSchema],
12153) {
12154    substitute_outer_in_select(stmt, outer_row, outer_schema);
12155}
12156
12157fn substitute_outer_in_select(
12158    stmt: &mut SelectStatement,
12159    outer_row: &Row,
12160    outer_schema: &[ColumnSchema],
12161) {
12162    for item in &mut stmt.items {
12163        if let SelectItem::Expr { expr, .. } = item {
12164            substitute_outer_in_expr(expr, outer_row, outer_schema);
12165        }
12166    }
12167    if let Some(w) = &mut stmt.where_ {
12168        substitute_outer_in_expr(w, outer_row, outer_schema);
12169    }
12170    if let Some(gs) = &mut stmt.group_by {
12171        for g in gs {
12172            substitute_outer_in_expr(g, outer_row, outer_schema);
12173        }
12174    }
12175    if let Some(h) = &mut stmt.having {
12176        substitute_outer_in_expr(h, outer_row, outer_schema);
12177    }
12178    for o in &mut stmt.order_by {
12179        substitute_outer_in_expr(&mut o.expr, outer_row, outer_schema);
12180    }
12181    for (_, peer) in &mut stmt.unions {
12182        substitute_outer_in_select(peer, outer_row, outer_schema);
12183    }
12184}
12185
12186fn substitute_outer_in_expr(e: &mut Expr, outer_row: &Row, outer_schema: &[ColumnSchema]) {
12187    if let Expr::Column(c) = e
12188        && let Some(qual) = &c.qualifier
12189    {
12190        let composite = alloc::format!("{qual}.{}", c.name);
12191        if let Some(idx) = outer_schema
12192            .iter()
12193            .position(|sc| sc.name.eq_ignore_ascii_case(&composite))
12194        {
12195            let v = outer_row.values.get(idx).cloned().unwrap_or(Value::Null);
12196            if let Ok(lit) = value_to_literal_expr(v) {
12197                *e = lit;
12198                return;
12199            }
12200        }
12201    }
12202    match e {
12203        Expr::Binary { lhs, rhs, .. } => {
12204            substitute_outer_in_expr(lhs, outer_row, outer_schema);
12205            substitute_outer_in_expr(rhs, outer_row, outer_schema);
12206        }
12207        Expr::Unary { expr: inner, .. } => {
12208            substitute_outer_in_expr(inner, outer_row, outer_schema);
12209        }
12210        Expr::FunctionCall { args, .. } => {
12211            for a in args {
12212                substitute_outer_in_expr(a, outer_row, outer_schema);
12213            }
12214        }
12215        Expr::Cast { expr: inner, .. } => {
12216            substitute_outer_in_expr(inner, outer_row, outer_schema);
12217        }
12218        Expr::Case {
12219            operand,
12220            branches,
12221            else_branch,
12222        } => {
12223            if let Some(op) = operand {
12224                substitute_outer_in_expr(op, outer_row, outer_schema);
12225            }
12226            for (cond, val) in branches {
12227                substitute_outer_in_expr(cond, outer_row, outer_schema);
12228                substitute_outer_in_expr(val, outer_row, outer_schema);
12229            }
12230            if let Some(e) = else_branch {
12231                substitute_outer_in_expr(e, outer_row, outer_schema);
12232            }
12233        }
12234        _ => {}
12235    }
12236}
12237
12238fn substitute_outer_columns(stmt: &mut SelectStatement, row: &Row, ctx: &EvalContext<'_>) {
12239    let Some(outer_alias) = ctx.table_alias else {
12240        return;
12241    };
12242    substitute_in_select(stmt, row, ctx, outer_alias);
12243}
12244
12245fn substitute_in_select(
12246    stmt: &mut SelectStatement,
12247    row: &Row,
12248    ctx: &EvalContext<'_>,
12249    outer_alias: &str,
12250) {
12251    for item in &mut stmt.items {
12252        if let SelectItem::Expr { expr, .. } = item {
12253            substitute_in_expr(expr, row, ctx, outer_alias);
12254        }
12255    }
12256    if let Some(w) = &mut stmt.where_ {
12257        substitute_in_expr(w, row, ctx, outer_alias);
12258    }
12259    if let Some(gs) = &mut stmt.group_by {
12260        for g in gs {
12261            substitute_in_expr(g, row, ctx, outer_alias);
12262        }
12263    }
12264    if let Some(h) = &mut stmt.having {
12265        substitute_in_expr(h, row, ctx, outer_alias);
12266    }
12267    for o in &mut stmt.order_by {
12268        substitute_in_expr(&mut o.expr, row, ctx, outer_alias);
12269    }
12270    for (_, peer) in &mut stmt.unions {
12271        substitute_in_select(peer, row, ctx, outer_alias);
12272    }
12273}
12274
12275fn substitute_in_expr(e: &mut Expr, row: &Row, ctx: &EvalContext<'_>, outer_alias: &str) {
12276    if let Expr::Column(c) = e
12277        && let Some(qual) = &c.qualifier
12278        && qual.eq_ignore_ascii_case(outer_alias)
12279    {
12280        // Look up the column's index in the outer schema.
12281        if let Some(idx) = ctx
12282            .columns
12283            .iter()
12284            .position(|sc| sc.name.eq_ignore_ascii_case(&c.name))
12285        {
12286            let v = row.values.get(idx).cloned().unwrap_or(Value::Null);
12287            if let Ok(lit) = value_to_literal_expr(v) {
12288                *e = lit;
12289                return;
12290            }
12291        }
12292    }
12293    match e {
12294        Expr::Binary { lhs, rhs, .. } => {
12295            substitute_in_expr(lhs, row, ctx, outer_alias);
12296            substitute_in_expr(rhs, row, ctx, outer_alias);
12297        }
12298        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12299            substitute_in_expr(expr, row, ctx, outer_alias);
12300        }
12301        Expr::Like { expr, pattern, .. } => {
12302            substitute_in_expr(expr, row, ctx, outer_alias);
12303            substitute_in_expr(pattern, row, ctx, outer_alias);
12304        }
12305        Expr::FunctionCall { args, .. } => {
12306            for a in args {
12307                substitute_in_expr(a, row, ctx, outer_alias);
12308            }
12309        }
12310        Expr::Extract { source, .. } => substitute_in_expr(source, row, ctx, outer_alias),
12311        Expr::WindowFunction {
12312            args,
12313            partition_by,
12314            order_by,
12315            ..
12316        } => {
12317            for a in args {
12318                substitute_in_expr(a, row, ctx, outer_alias);
12319            }
12320            for p in partition_by {
12321                substitute_in_expr(p, row, ctx, outer_alias);
12322            }
12323            for (o, _) in order_by {
12324                substitute_in_expr(o, row, ctx, outer_alias);
12325            }
12326        }
12327        Expr::ScalarSubquery(s) => substitute_in_select(s, row, ctx, outer_alias),
12328        Expr::Exists { subquery, .. } | Expr::InSubquery { subquery, .. } => {
12329            substitute_in_select(subquery, row, ctx, outer_alias);
12330        }
12331        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => {}
12332        Expr::Array(items) => {
12333            for elem in items {
12334                substitute_in_expr(elem, row, ctx, outer_alias);
12335            }
12336        }
12337        Expr::ArraySubscript { target, index } => {
12338            substitute_in_expr(target, row, ctx, outer_alias);
12339            substitute_in_expr(index, row, ctx, outer_alias);
12340        }
12341        Expr::AnyAll { expr, array, .. } => {
12342            substitute_in_expr(expr, row, ctx, outer_alias);
12343            substitute_in_expr(array, row, ctx, outer_alias);
12344        }
12345        Expr::Case {
12346            operand,
12347            branches,
12348            else_branch,
12349        } => {
12350            if let Some(o) = operand {
12351                substitute_in_expr(o, row, ctx, outer_alias);
12352            }
12353            for (w, t) in branches {
12354                substitute_in_expr(w, row, ctx, outer_alias);
12355                substitute_in_expr(t, row, ctx, outer_alias);
12356            }
12357            if let Some(e) = else_branch {
12358                substitute_in_expr(e, row, ctx, outer_alias);
12359            }
12360        }
12361    }
12362}
12363
12364/// v4.22: encode a Row to a comparable byte key for UNION-DISTINCT
12365/// dedup inside the recursive iteration. Crude but deterministic
12366/// — Debug prints embed type discriminants so NULL ≠ "" ≠ 0.
12367fn encode_row_key(row: &Row) -> Vec<u8> {
12368    let mut out = Vec::new();
12369    for v in &row.values {
12370        let s = alloc::format!("{v:?}|");
12371        out.extend_from_slice(s.as_bytes());
12372    }
12373    out
12374}
12375
12376fn select_has_window(stmt: &SelectStatement) -> bool {
12377    for item in &stmt.items {
12378        if let SelectItem::Expr { expr, .. } = item
12379            && expr_has_window(expr)
12380        {
12381            return true;
12382        }
12383    }
12384    false
12385}
12386
12387fn expr_has_window(e: &Expr) -> bool {
12388    match e {
12389        Expr::WindowFunction { .. } => true,
12390        Expr::Binary { lhs, rhs, .. } => expr_has_window(lhs) || expr_has_window(rhs),
12391        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12392            expr_has_window(expr)
12393        }
12394        Expr::FunctionCall { args, .. } => args.iter().any(expr_has_window),
12395        Expr::Like { expr, pattern, .. } => expr_has_window(expr) || expr_has_window(pattern),
12396        Expr::Extract { source, .. } => expr_has_window(source),
12397        Expr::ScalarSubquery(_)
12398        | Expr::Exists { .. }
12399        | Expr::InSubquery { .. }
12400        | Expr::Literal(_)
12401        | Expr::Placeholder(_)
12402        | Expr::Column(_) => false,
12403        Expr::Array(items) => items.iter().any(expr_has_window),
12404        Expr::ArraySubscript { target, index } => expr_has_window(target) || expr_has_window(index),
12405        Expr::AnyAll { expr, array, .. } => expr_has_window(expr) || expr_has_window(array),
12406        Expr::Case {
12407            operand,
12408            branches,
12409            else_branch,
12410        } => {
12411            operand.as_deref().is_some_and(expr_has_window)
12412                || branches
12413                    .iter()
12414                    .any(|(w, t)| expr_has_window(w) || expr_has_window(t))
12415                || else_branch.as_deref().is_some_and(expr_has_window)
12416        }
12417    }
12418}
12419
12420fn collect_window_nodes(e: &Expr, out: &mut Vec<Expr>) {
12421    if let Expr::WindowFunction { .. } = e {
12422        // Deduplicate by structural equality on the expression
12423        // (cheap because window args + partition + order are
12424        // small). Without dedup we'd recompute identical windows
12425        // once per occurrence in the projection.
12426        if !out.iter().any(|x| x == e) {
12427            out.push(e.clone());
12428        }
12429        return;
12430    }
12431    match e {
12432        // Already handled by the early-return at the top.
12433        Expr::WindowFunction { .. } => unreachable!(),
12434        Expr::Binary { lhs, rhs, .. } => {
12435            collect_window_nodes(lhs, out);
12436            collect_window_nodes(rhs, out);
12437        }
12438        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12439            collect_window_nodes(expr, out);
12440        }
12441        Expr::FunctionCall { args, .. } => {
12442            for a in args {
12443                collect_window_nodes(a, out);
12444            }
12445        }
12446        Expr::Like { expr, pattern, .. } => {
12447            collect_window_nodes(expr, out);
12448            collect_window_nodes(pattern, out);
12449        }
12450        Expr::Extract { source, .. } => collect_window_nodes(source, out),
12451        _ => {}
12452    }
12453}
12454
12455fn rewrite_window_to_columns(e: &mut Expr, window_nodes: &[Expr]) {
12456    if let Expr::WindowFunction { .. } = e
12457        && let Some(idx) = window_nodes.iter().position(|w| w == e)
12458    {
12459        *e = Expr::Column(spg_sql::ast::ColumnName {
12460            qualifier: None,
12461            name: alloc::format!("__win_{idx}"),
12462        });
12463        return;
12464    }
12465    match e {
12466        Expr::Binary { lhs, rhs, .. } => {
12467            rewrite_window_to_columns(lhs, window_nodes);
12468            rewrite_window_to_columns(rhs, window_nodes);
12469        }
12470        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12471            rewrite_window_to_columns(expr, window_nodes);
12472        }
12473        Expr::FunctionCall { args, .. } => {
12474            for a in args {
12475                rewrite_window_to_columns(a, window_nodes);
12476            }
12477        }
12478        Expr::Like { expr, pattern, .. } => {
12479            rewrite_window_to_columns(expr, window_nodes);
12480            rewrite_window_to_columns(pattern, window_nodes);
12481        }
12482        Expr::Extract { source, .. } => rewrite_window_to_columns(source, window_nodes),
12483        _ => {}
12484    }
12485}
12486
12487/// Total order over partition-key tuples. NULL sorts as the
12488/// lowest value (matches the `<` partial order's NULL-last
12489/// behaviour with `INFINITY` flipped).
12490fn partition_key_cmp(a: &[Value], b: &[Value]) -> core::cmp::Ordering {
12491    for (x, y) in a.iter().zip(b.iter()) {
12492        let c = value_cmp(x, y);
12493        if c != core::cmp::Ordering::Equal {
12494            return c;
12495        }
12496    }
12497    a.len().cmp(&b.len())
12498}
12499
12500fn order_key_cmp(a: &[(Value, bool)], b: &[(Value, bool)]) -> core::cmp::Ordering {
12501    for ((va, desc), (vb, _)) in a.iter().zip(b.iter()) {
12502        let c = value_cmp(va, vb);
12503        let c = if *desc { c.reverse() } else { c };
12504        if c != core::cmp::Ordering::Equal {
12505            return c;
12506        }
12507    }
12508    a.len().cmp(&b.len())
12509}
12510
12511/// v7.17.0 Phase 3.10 — true when the Value is one of the
12512/// integer-shaped variants `generate_series` accepts as a start
12513/// / stop / step component. Float / NUMERIC are rejected — PG's
12514/// `generate_series(numeric, numeric)` overload is out of v7.17
12515/// scope.
12516const fn value_is_integer(v: &Value) -> bool {
12517    matches!(v, Value::SmallInt(_) | Value::Int(_) | Value::BigInt(_))
12518}
12519
12520/// v7.17.0 Phase 3.10 — widen any integer-shaped Value to i64 for
12521/// the generate_series iteration loop. Non-integer inputs panic;
12522/// caller guards via `value_is_integer`.
12523const fn value_to_i64(v: &Value) -> i64 {
12524    match v {
12525        Value::SmallInt(n) => *n as i64,
12526        Value::Int(n) => *n as i64,
12527        Value::BigInt(n) => *n,
12528        _ => panic!("value_to_i64 called on non-integer Value"),
12529    }
12530}
12531
12532/// v7.17.0 Phase 3.10 — integer-mode generate_series materialiser.
12533/// Step direction follows the sign: positive step iterates upward
12534/// (stops when current > stop); negative iterates downward; zero
12535/// errors. Caller-facing row stream is `BigInt`-typed so a single
12536/// projection schema covers SmallInt / Int / BigInt callers.
12537fn generate_series_integers(
12538    start: i64,
12539    stop: i64,
12540    step: i64,
12541    cancel: &CancelToken<'_>,
12542) -> Result<alloc::vec::Vec<Row>, EngineError> {
12543    if step == 0 {
12544        return Err(EngineError::Unsupported(
12545            "generate_series(): step argument cannot be zero".into(),
12546        ));
12547    }
12548    let mut out = alloc::vec::Vec::new();
12549    let mut cur = start;
12550    // Hard cap to keep a runaway call from eating all memory. PG
12551    // has no such cap but does honour query timeout; SPG's cancel
12552    // token will fire too — this is a defense-in-depth backstop.
12553    const MAX_ROWS: usize = 10_000_000;
12554    loop {
12555        cancel.check()?;
12556        if step > 0 && cur > stop {
12557            break;
12558        }
12559        if step < 0 && cur < stop {
12560            break;
12561        }
12562        out.push(Row::new(alloc::vec![Value::BigInt(cur)]));
12563        if out.len() > MAX_ROWS {
12564            return Err(EngineError::Unsupported(alloc::format!(
12565                "generate_series(): exceeded {MAX_ROWS} rows; \
12566                 narrow start/stop or use a larger step"
12567            )));
12568        }
12569        cur = match cur.checked_add(step) {
12570            Some(n) => n,
12571            None => break,
12572        };
12573    }
12574    Ok(out)
12575}
12576
12577/// v7.17.0 Phase 3.10 — timestamp-mode generate_series. step is a
12578/// `Value::Interval { months, micros }` per the caller's guard;
12579/// each iteration adds the interval via `apply_binary_interval`
12580/// so month-shifting handles short-month rollover (PG semantics).
12581fn generate_series_timestamps(
12582    start: i64,
12583    stop: i64,
12584    step: Value,
12585    cancel: &CancelToken<'_>,
12586) -> Result<alloc::vec::Vec<Row>, EngineError> {
12587    let (months, micros) = match &step {
12588        Value::Interval { months, micros } => (*months, *micros),
12589        _ => unreachable!("caller guards step.is_interval"),
12590    };
12591    if months == 0 && micros == 0 {
12592        return Err(EngineError::Unsupported(
12593            "generate_series(): INTERVAL step cannot be zero".into(),
12594        ));
12595    }
12596    let ascending = months > 0 || micros > 0;
12597    let mut out = alloc::vec::Vec::new();
12598    let mut cur = Value::Timestamp(start);
12599    const MAX_ROWS: usize = 10_000_000;
12600    loop {
12601        cancel.check()?;
12602        let cur_t = match cur {
12603            Value::Timestamp(t) => t,
12604            _ => unreachable!("loop invariant: cur is Timestamp"),
12605        };
12606        if ascending && cur_t > stop {
12607            break;
12608        }
12609        if !ascending && cur_t < stop {
12610            break;
12611        }
12612        out.push(Row::new(alloc::vec![Value::Timestamp(cur_t)]));
12613        if out.len() > MAX_ROWS {
12614            return Err(EngineError::Unsupported(alloc::format!(
12615                "generate_series(): exceeded {MAX_ROWS} rows; \
12616                 narrow start/stop or use a larger step"
12617            )));
12618        }
12619        let next = eval::apply_binary_interval(
12620            spg_sql::ast::BinOp::Add,
12621            &cur,
12622            &Value::Interval { months, micros },
12623        )
12624        .map_err(EngineError::Eval)?;
12625        cur = match next {
12626            Some(v) => v,
12627            None => break,
12628        };
12629    }
12630    Ok(out)
12631}
12632
12633#[allow(clippy::match_same_arms)] // explicit arms per type document the supported pairs
12634fn value_cmp(a: &Value, b: &Value) -> core::cmp::Ordering {
12635    use core::cmp::Ordering;
12636    match (a, b) {
12637        (Value::Null, Value::Null) => Ordering::Equal,
12638        (Value::Null, _) => Ordering::Less,
12639        (_, Value::Null) => Ordering::Greater,
12640        (Value::Int(x), Value::Int(y)) => x.cmp(y),
12641        (Value::BigInt(x), Value::BigInt(y)) => x.cmp(y),
12642        (Value::SmallInt(x), Value::SmallInt(y)) => x.cmp(y),
12643        (Value::Text(x), Value::Text(y)) => x.cmp(y),
12644        (Value::Bool(x), Value::Bool(y)) => x.cmp(y),
12645        (Value::Float(x), Value::Float(y)) => x.partial_cmp(y).unwrap_or(Ordering::Equal),
12646        (Value::Date(x), Value::Date(y)) => x.cmp(y),
12647        (Value::Timestamp(x), Value::Timestamp(y)) => x.cmp(y),
12648        // Cross-type compare: fall back to the debug rendering —
12649        // same-partition is the goal, exact order is irrelevant.
12650        _ => alloc::format!("{a:?}").cmp(&alloc::format!("{b:?}")),
12651    }
12652}
12653
12654/// Compute the window function's per-row output for one partition.
12655/// `slice` has (partition key, order key, original-row-index)
12656/// tuples already sorted by order key. `filtered_rows` is the
12657/// full row list indexed by original-row-index. `out_vals` is
12658/// the destination, also indexed by original-row-index.
12659#[allow(
12660    clippy::too_many_arguments,
12661    clippy::cast_possible_truncation,
12662    clippy::cast_possible_wrap,
12663    clippy::cast_precision_loss,
12664    clippy::cast_sign_loss,
12665    clippy::doc_markdown,
12666    clippy::too_many_lines,
12667    clippy::type_complexity,
12668    clippy::match_same_arms
12669)]
12670fn compute_window_partition(
12671    name: &str,
12672    args: &[Expr],
12673    ordered: bool,
12674    frame: Option<&WindowFrame>,
12675    null_treatment: spg_sql::ast::NullTreatment,
12676    slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)],
12677    filtered_rows: &[&Row],
12678    ctx: &EvalContext<'_>,
12679    out_vals: &mut [Value],
12680) -> Result<(), EngineError> {
12681    let ignore_nulls = matches!(null_treatment, spg_sql::ast::NullTreatment::Ignore);
12682    let lower = name.to_ascii_lowercase();
12683    match lower.as_str() {
12684        "row_number" => {
12685            for (rank, (_, _, idx)) in slice.iter().enumerate() {
12686                out_vals[*idx] = Value::BigInt((rank + 1) as i64);
12687            }
12688            Ok(())
12689        }
12690        "rank" => {
12691            let mut prev_key: Option<&[(Value, bool)]> = None;
12692            let mut current_rank: i64 = 1;
12693            for (i, (_, okey, idx)) in slice.iter().enumerate() {
12694                if let Some(p) = prev_key
12695                    && order_key_cmp(p, okey) != core::cmp::Ordering::Equal
12696                {
12697                    current_rank = (i + 1) as i64;
12698                }
12699                if prev_key.is_none() {
12700                    current_rank = 1;
12701                }
12702                out_vals[*idx] = Value::BigInt(current_rank);
12703                prev_key = Some(okey.as_slice());
12704            }
12705            Ok(())
12706        }
12707        "dense_rank" => {
12708            let mut prev_key: Option<&[(Value, bool)]> = None;
12709            let mut current_rank: i64 = 0;
12710            for (_, okey, idx) in slice {
12711                if prev_key.is_none_or(|p| order_key_cmp(p, okey) != core::cmp::Ordering::Equal) {
12712                    current_rank += 1;
12713                }
12714                out_vals[*idx] = Value::BigInt(current_rank);
12715                prev_key = Some(okey.as_slice());
12716            }
12717            Ok(())
12718        }
12719        "sum" | "avg" | "min" | "max" | "count" | "count_star" => {
12720            // Pre-evaluate the function arg per row in the slice
12721            // (count_star has no arg).
12722            let arg_values: Vec<Value> = if lower == "count_star" || args.is_empty() {
12723                slice.iter().map(|_| Value::Null).collect()
12724            } else {
12725                slice
12726                    .iter()
12727                    .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
12728                    .collect::<Result<_, _>>()
12729                    .map_err(EngineError::Eval)?
12730            };
12731            // v4.20: pick the effective frame. Explicit frame
12732            // overrides the implicit default (running for ordered,
12733            // whole-partition for unordered).
12734            let eff = effective_frame(frame, ordered)?;
12735            #[allow(clippy::needless_range_loop)]
12736            for i in 0..slice.len() {
12737                let (lo, hi) = frame_bounds_for_row(&eff, i, slice);
12738                let mut sum: f64 = 0.0;
12739                let mut count: i64 = 0;
12740                let mut min_v: Option<f64> = None;
12741                let mut max_v: Option<f64> = None;
12742                let mut row_count: i64 = 0;
12743                if lo <= hi {
12744                    for j in lo..=hi {
12745                        let v = &arg_values[j];
12746                        match lower.as_str() {
12747                            "count_star" => row_count += 1,
12748                            "count" => {
12749                                if !v.is_null() {
12750                                    count += 1;
12751                                }
12752                            }
12753                            _ => {
12754                                if let Some(x) = value_to_f64(v) {
12755                                    sum += x;
12756                                    count += 1;
12757                                    min_v = Some(min_v.map_or(x, |m| m.min(x)));
12758                                    max_v = Some(max_v.map_or(x, |m| m.max(x)));
12759                                }
12760                            }
12761                        }
12762                    }
12763                }
12764                let value = match lower.as_str() {
12765                    "count_star" => Value::BigInt(row_count),
12766                    "count" => Value::BigInt(count),
12767                    "sum" => Value::Float(sum),
12768                    "avg" => {
12769                        if count == 0 {
12770                            Value::Null
12771                        } else {
12772                            Value::Float(sum / count as f64)
12773                        }
12774                    }
12775                    "min" => min_v.map_or(Value::Null, Value::Float),
12776                    "max" => max_v.map_or(Value::Null, Value::Float),
12777                    _ => unreachable!(),
12778                };
12779                let (_, _, idx) = &slice[i];
12780                out_vals[*idx] = value;
12781            }
12782            Ok(())
12783        }
12784        "lag" | "lead" => {
12785            // lag(expr [, offset [, default]])
12786            // lead(expr [, offset [, default]])
12787            if args.is_empty() {
12788                return Err(EngineError::Unsupported(alloc::format!(
12789                    "{lower}() requires at least one argument"
12790                )));
12791            }
12792            let offset: i64 = if args.len() >= 2 {
12793                let v = eval::eval_expr(&args[1], filtered_rows[slice[0].2], ctx)
12794                    .map_err(EngineError::Eval)?;
12795                match v {
12796                    Value::SmallInt(n) => i64::from(n),
12797                    Value::Int(n) => i64::from(n),
12798                    Value::BigInt(n) => n,
12799                    _ => {
12800                        return Err(EngineError::Unsupported(alloc::format!(
12801                            "{lower}() offset must be integer"
12802                        )));
12803                    }
12804                }
12805            } else {
12806                1
12807            };
12808            let default: Value = if args.len() >= 3 {
12809                eval::eval_expr(&args[2], filtered_rows[slice[0].2], ctx)
12810                    .map_err(EngineError::Eval)?
12811            } else {
12812                Value::Null
12813            };
12814            let values: Vec<Value> = slice
12815                .iter()
12816                .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
12817                .collect::<Result<_, _>>()
12818                .map_err(EngineError::Eval)?;
12819            let n = slice.len();
12820            for (i, (_, _, idx)) in slice.iter().enumerate() {
12821                let signed_offset = if lower == "lag" { -offset } else { offset };
12822                let v = if ignore_nulls {
12823                    // v6.4.2 — IGNORE NULLS: walk in the offset direction
12824                    // skipping NULL values; the `offset`-th non-NULL
12825                    // encountered is the result.
12826                    let step: i64 = if signed_offset >= 0 { 1 } else { -1 };
12827                    let needed: i64 = signed_offset.abs();
12828                    if needed == 0 {
12829                        values[i].clone()
12830                    } else {
12831                        let mut j: i64 = i as i64;
12832                        let mut hits: i64 = 0;
12833                        let mut found: Option<Value> = None;
12834                        loop {
12835                            j += step;
12836                            if j < 0 || j >= n as i64 {
12837                                break;
12838                            }
12839                            #[allow(clippy::cast_sign_loss)]
12840                            let v = &values[j as usize];
12841                            if !v.is_null() {
12842                                hits += 1;
12843                                if hits == needed {
12844                                    found = Some(v.clone());
12845                                    break;
12846                                }
12847                            }
12848                        }
12849                        found.unwrap_or_else(|| default.clone())
12850                    }
12851                } else {
12852                    let target_signed = i64::try_from(i).unwrap_or(i64::MAX) + signed_offset;
12853                    if target_signed < 0 || target_signed >= i64::try_from(n).unwrap_or(i64::MAX) {
12854                        default.clone()
12855                    } else {
12856                        #[allow(clippy::cast_sign_loss)]
12857                        {
12858                            values[target_signed as usize].clone()
12859                        }
12860                    }
12861                };
12862                out_vals[*idx] = v;
12863            }
12864            Ok(())
12865        }
12866        "first_value" | "last_value" | "nth_value" => {
12867            if args.is_empty() {
12868                return Err(EngineError::Unsupported(alloc::format!(
12869                    "{lower}() requires at least one argument"
12870                )));
12871            }
12872            let values: Vec<Value> = slice
12873                .iter()
12874                .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
12875                .collect::<Result<_, _>>()
12876                .map_err(EngineError::Eval)?;
12877            let nth: usize = if lower == "nth_value" {
12878                if args.len() < 2 {
12879                    return Err(EngineError::Unsupported(
12880                        "nth_value() requires (expr, n)".into(),
12881                    ));
12882                }
12883                let v = eval::eval_expr(&args[1], filtered_rows[slice[0].2], ctx)
12884                    .map_err(EngineError::Eval)?;
12885                let raw = match v {
12886                    Value::SmallInt(n) => i64::from(n),
12887                    Value::Int(n) => i64::from(n),
12888                    Value::BigInt(n) => n,
12889                    _ => {
12890                        return Err(EngineError::Unsupported(
12891                            "nth_value() n must be integer".into(),
12892                        ));
12893                    }
12894                };
12895                if raw < 1 {
12896                    return Err(EngineError::Unsupported(
12897                        "nth_value() n must be >= 1".into(),
12898                    ));
12899                }
12900                #[allow(clippy::cast_sign_loss)]
12901                {
12902                    raw as usize
12903                }
12904            } else {
12905                0
12906            };
12907            let eff = effective_frame(frame, ordered)?;
12908            for i in 0..slice.len() {
12909                let (lo, hi) = frame_bounds_for_row(&eff, i, slice);
12910                let (_, _, idx) = &slice[i];
12911                let v = if lo > hi {
12912                    Value::Null
12913                } else if ignore_nulls && matches!(lower.as_str(), "first_value" | "last_value") {
12914                    // v6.4.2 — IGNORE NULLS: skip NULL cells when
12915                    // selecting the boundary value within the frame.
12916                    if lower == "first_value" {
12917                        (lo..=hi)
12918                            .find_map(|j| {
12919                                let v = &values[j];
12920                                (!v.is_null()).then(|| v.clone())
12921                            })
12922                            .unwrap_or(Value::Null)
12923                    } else {
12924                        (lo..=hi)
12925                            .rev()
12926                            .find_map(|j| {
12927                                let v = &values[j];
12928                                (!v.is_null()).then(|| v.clone())
12929                            })
12930                            .unwrap_or(Value::Null)
12931                    }
12932                } else {
12933                    match lower.as_str() {
12934                        "first_value" => values[lo].clone(),
12935                        "last_value" => values[hi].clone(),
12936                        "nth_value" => {
12937                            let pos = lo + nth - 1;
12938                            if pos > hi {
12939                                Value::Null
12940                            } else {
12941                                values[pos].clone()
12942                            }
12943                        }
12944                        _ => unreachable!(),
12945                    }
12946                };
12947                out_vals[*idx] = v;
12948            }
12949            Ok(())
12950        }
12951        "ntile" => {
12952            if args.is_empty() {
12953                return Err(EngineError::Unsupported(
12954                    "ntile(n) requires an integer argument".into(),
12955                ));
12956            }
12957            let v = eval::eval_expr(&args[0], filtered_rows[slice[0].2], ctx)
12958                .map_err(EngineError::Eval)?;
12959            let bucket_count: i64 = match v {
12960                Value::SmallInt(n) => i64::from(n),
12961                Value::Int(n) => i64::from(n),
12962                Value::BigInt(n) => n,
12963                _ => {
12964                    return Err(EngineError::Unsupported(
12965                        "ntile() argument must be integer".into(),
12966                    ));
12967                }
12968            };
12969            if bucket_count < 1 {
12970                return Err(EngineError::Unsupported(
12971                    "ntile() argument must be >= 1".into(),
12972                ));
12973            }
12974            #[allow(clippy::cast_sign_loss)]
12975            let buckets = bucket_count as usize;
12976            let n = slice.len();
12977            // Each bucket gets `base` rows; the first `extras` buckets
12978            // get one extra. PG semantics.
12979            let base = n / buckets;
12980            let extras = n % buckets;
12981            let mut bucket: usize = 1;
12982            let mut remaining_in_bucket = if extras > 0 { base + 1 } else { base };
12983            let mut buckets_with_extra_remaining = extras;
12984            for (_, _, idx) in slice {
12985                if remaining_in_bucket == 0 {
12986                    bucket += 1;
12987                    buckets_with_extra_remaining = buckets_with_extra_remaining.saturating_sub(1);
12988                    remaining_in_bucket = if buckets_with_extra_remaining > 0 {
12989                        base + 1
12990                    } else {
12991                        base
12992                    };
12993                    // Edge: if base==0 and extras==0, all rows fit;
12994                    // shouldn't reach here, but guard anyway.
12995                    if remaining_in_bucket == 0 {
12996                        remaining_in_bucket = 1;
12997                    }
12998                }
12999                out_vals[*idx] = Value::BigInt(i64::try_from(bucket).unwrap_or(i64::MAX));
13000                remaining_in_bucket -= 1;
13001            }
13002            Ok(())
13003        }
13004        "percent_rank" => {
13005            // (rank - 1) / (n - 1) where rank is the standard RANK().
13006            // Single-row partitions get 0.
13007            let n = slice.len();
13008            let mut prev_key: Option<&[(Value, bool)]> = None;
13009            let mut current_rank: i64 = 1;
13010            for (i, (_, okey, idx)) in slice.iter().enumerate() {
13011                if let Some(p) = prev_key
13012                    && order_key_cmp(p, okey) != core::cmp::Ordering::Equal
13013                {
13014                    current_rank = i64::try_from(i + 1).unwrap_or(i64::MAX);
13015                }
13016                if prev_key.is_none() {
13017                    current_rank = 1;
13018                }
13019                #[allow(clippy::cast_precision_loss)]
13020                let pr = if n <= 1 {
13021                    0.0
13022                } else {
13023                    (current_rank - 1) as f64 / (n - 1) as f64
13024                };
13025                out_vals[*idx] = Value::Float(pr);
13026                prev_key = Some(okey.as_slice());
13027            }
13028            Ok(())
13029        }
13030        "cume_dist" => {
13031            // # rows up to and including this row's peer group / n.
13032            let n = slice.len();
13033            // First pass: find peer-group-end rank for each row.
13034            for i in 0..slice.len() {
13035                let peer_end = peer_group_end(slice, i);
13036                #[allow(clippy::cast_precision_loss)]
13037                let cd = (peer_end + 1) as f64 / n as f64;
13038                let (_, _, idx) = &slice[i];
13039                out_vals[*idx] = Value::Float(cd);
13040            }
13041            Ok(())
13042        }
13043        other => Err(EngineError::Unsupported(alloc::format!(
13044            "window function {other:?} not supported (v4.21: row_number/rank/dense_rank/sum/avg/count/min/max/lag/lead/first_value/last_value/nth_value/ntile/percent_rank/cume_dist)"
13045        ))),
13046    }
13047}
13048
13049/// v4.20: resolve the user-provided frame down to a normalised
13050/// `(kind, start, end)`. `None` means default — derive from
13051/// `ordered`: ordered ⇒ RANGE UNBOUNDED PRECEDING AND CURRENT ROW,
13052/// unordered ⇒ ROWS UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
13053/// Single-bound shorthand (e.g. `ROWS 5 PRECEDING`) normalises
13054/// end → CURRENT ROW per the PG spec.
13055fn effective_frame(
13056    frame: Option<&WindowFrame>,
13057    ordered: bool,
13058) -> Result<(FrameKind, FrameBound, FrameBound), EngineError> {
13059    match frame {
13060        None => {
13061            if ordered {
13062                Ok((
13063                    FrameKind::Range,
13064                    FrameBound::UnboundedPreceding,
13065                    FrameBound::CurrentRow,
13066                ))
13067            } else {
13068                Ok((
13069                    FrameKind::Rows,
13070                    FrameBound::UnboundedPreceding,
13071                    FrameBound::UnboundedFollowing,
13072                ))
13073            }
13074        }
13075        Some(fr) => {
13076            let end = fr.end.clone().unwrap_or(FrameBound::CurrentRow);
13077            // Reject start > end (a few impossible combinations).
13078            if matches!(fr.start, FrameBound::UnboundedFollowing)
13079                || matches!(end, FrameBound::UnboundedPreceding)
13080            {
13081                return Err(EngineError::Unsupported(alloc::format!(
13082                    "invalid frame: start={:?} end={:?}",
13083                    fr.start,
13084                    end
13085                )));
13086            }
13087            // RANGE OFFSET PRECEDING / FOLLOWING needs value-typed
13088            // arithmetic on the ORDER BY key (e.g. `RANGE BETWEEN
13089            // INTERVAL '1 day' PRECEDING AND CURRENT ROW`). Not
13090            // implemented in v4.20.
13091            if fr.kind == FrameKind::Range
13092                && (matches!(
13093                    fr.start,
13094                    FrameBound::OffsetPreceding(_) | FrameBound::OffsetFollowing(_)
13095                ) || matches!(
13096                    end,
13097                    FrameBound::OffsetPreceding(_) | FrameBound::OffsetFollowing(_)
13098                ))
13099            {
13100                return Err(EngineError::Unsupported(
13101                    "RANGE with explicit offset bounds is not supported (v4.20: only UNBOUNDED / CURRENT ROW for RANGE)".into(),
13102                ));
13103            }
13104            Ok((fr.kind, fr.start.clone(), end))
13105        }
13106    }
13107}
13108
13109/// Compute `(lo, hi)` row-index bounds inside the partition slice
13110/// for the row at position `i`. Inclusive, clamped to
13111/// `[0, slice.len()-1]`. Empty result if `lo > hi`.
13112#[allow(clippy::type_complexity)]
13113fn frame_bounds_for_row(
13114    eff: &(FrameKind, FrameBound, FrameBound),
13115    i: usize,
13116    slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)],
13117) -> (usize, usize) {
13118    let (kind, start, end) = eff;
13119    let n = slice.len();
13120    let last = n.saturating_sub(1);
13121    let (mut lo, mut hi) = match kind {
13122        FrameKind::Rows => {
13123            let lo = match start {
13124                FrameBound::UnboundedPreceding => 0,
13125                FrameBound::OffsetPreceding(k) => {
13126                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
13127                    i.saturating_sub(k)
13128                }
13129                FrameBound::CurrentRow => i,
13130                FrameBound::OffsetFollowing(k) => {
13131                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
13132                    i.saturating_add(k).min(last)
13133                }
13134                FrameBound::UnboundedFollowing => last,
13135            };
13136            let hi = match end {
13137                FrameBound::UnboundedPreceding => 0,
13138                FrameBound::OffsetPreceding(k) => {
13139                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
13140                    i.saturating_sub(k)
13141                }
13142                FrameBound::CurrentRow => i,
13143                FrameBound::OffsetFollowing(k) => {
13144                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
13145                    i.saturating_add(k).min(last)
13146                }
13147                FrameBound::UnboundedFollowing => last,
13148            };
13149            (lo, hi)
13150        }
13151        FrameKind::Range => {
13152            // RANGE bounds are peer-aware. With only UNBOUNDED and
13153            // CURRENT ROW supported (rejected at effective_frame for
13154            // explicit offsets), the start/end map to the
13155            // partition's full extent at the same-order-key peer
13156            // group boundary.
13157            let lo = match start {
13158                FrameBound::UnboundedPreceding => 0,
13159                FrameBound::CurrentRow => peer_group_start(slice, i),
13160                FrameBound::UnboundedFollowing => last,
13161                _ => unreachable!("offset bounds rejected for RANGE"),
13162            };
13163            let hi = match end {
13164                FrameBound::UnboundedPreceding => 0,
13165                FrameBound::CurrentRow => peer_group_end(slice, i),
13166                FrameBound::UnboundedFollowing => last,
13167                _ => unreachable!("offset bounds rejected for RANGE"),
13168            };
13169            (lo, hi)
13170        }
13171    };
13172    if hi >= n {
13173        hi = last;
13174    }
13175    if lo >= n {
13176        lo = last;
13177    }
13178    (lo, hi)
13179}
13180
13181/// Find the inclusive index of the first row with the same ORDER
13182/// BY key as `slice[i]`. Slice is already sorted by partition then
13183/// order, so peers are contiguous.
13184#[allow(clippy::type_complexity)]
13185fn peer_group_start(slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)], i: usize) -> usize {
13186    let key = &slice[i].1;
13187    let mut j = i;
13188    while j > 0 && order_key_cmp(&slice[j - 1].1, key) == core::cmp::Ordering::Equal {
13189        j -= 1;
13190    }
13191    j
13192}
13193
13194/// Find the inclusive index of the last row with the same ORDER
13195/// BY key as `slice[i]`.
13196#[allow(clippy::type_complexity)]
13197fn peer_group_end(slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)], i: usize) -> usize {
13198    let key = &slice[i].1;
13199    let mut j = i;
13200    while j + 1 < slice.len() && order_key_cmp(&slice[j + 1].1, key) == core::cmp::Ordering::Equal {
13201        j += 1;
13202    }
13203    j
13204}
13205
13206fn value_to_f64(v: &Value) -> Option<f64> {
13207    match v {
13208        Value::SmallInt(n) => Some(f64::from(*n)),
13209        Value::Int(n) => Some(f64::from(*n)),
13210        #[allow(clippy::cast_precision_loss)]
13211        Value::BigInt(n) => Some(*n as f64),
13212        Value::Float(x) => Some(*x),
13213        _ => None,
13214    }
13215}
13216
13217/// Quick scan for any subquery-bearing node in a SELECT's WHERE /
13218/// projection / `order_by` — saves cloning the AST when there are
13219/// none (the common case).
13220fn expr_tree_has_subquery(stmt: &SelectStatement) -> bool {
13221    let mut any = false;
13222    for item in &stmt.items {
13223        if let SelectItem::Expr { expr, .. } = item {
13224            any = any || expr_has_subquery(expr);
13225        }
13226    }
13227    if let Some(w) = &stmt.where_ {
13228        any = any || expr_has_subquery(w);
13229    }
13230    if let Some(h) = &stmt.having {
13231        any = any || expr_has_subquery(h);
13232    }
13233    for o in &stmt.order_by {
13234        any = any || expr_has_subquery(&o.expr);
13235    }
13236    for (_, peer) in &stmt.unions {
13237        any = any || expr_tree_has_subquery(peer);
13238    }
13239    any
13240}
13241
13242fn expr_has_subquery(e: &Expr) -> bool {
13243    match e {
13244        Expr::ScalarSubquery(_) | Expr::Exists { .. } | Expr::InSubquery { .. } => true,
13245        Expr::Binary { lhs, rhs, .. } => expr_has_subquery(lhs) || expr_has_subquery(rhs),
13246        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13247            expr_has_subquery(expr)
13248        }
13249        Expr::FunctionCall { args, .. } => args.iter().any(expr_has_subquery),
13250        Expr::Like { expr, pattern, .. } => expr_has_subquery(expr) || expr_has_subquery(pattern),
13251        Expr::Extract { source, .. } => expr_has_subquery(source),
13252        Expr::WindowFunction {
13253            args,
13254            partition_by,
13255            order_by,
13256            ..
13257        } => {
13258            args.iter().any(expr_has_subquery)
13259                || partition_by.iter().any(expr_has_subquery)
13260                || order_by.iter().any(|(e, _)| expr_has_subquery(e))
13261        }
13262        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => false,
13263        Expr::Array(items) => items.iter().any(expr_has_subquery),
13264        Expr::ArraySubscript { target, index } => {
13265            expr_has_subquery(target) || expr_has_subquery(index)
13266        }
13267        Expr::AnyAll { expr, array, .. } => expr_has_subquery(expr) || expr_has_subquery(array),
13268        Expr::Case {
13269            operand,
13270            branches,
13271            else_branch,
13272        } => {
13273            operand.as_deref().is_some_and(expr_has_subquery)
13274                || branches
13275                    .iter()
13276                    .any(|(w, t)| expr_has_subquery(w) || expr_has_subquery(t))
13277                || else_branch.as_deref().is_some_and(expr_has_subquery)
13278        }
13279    }
13280}
13281
13282/// v4.10 helper: materialise a runtime `Value` back into an AST
13283/// `Expr::Literal` for the subquery-rewrite path. Supports the
13284/// types `Literal` can represent (Integer / Float / Text / Bool /
13285/// Null). Date / Timestamp / Numeric / Vector / Interval / JSON
13286/// would lose precision through Literal and aren't supported in
13287/// uncorrelated-subquery results; they error with a clear hint.
13288fn value_to_literal_expr(v: Value) -> Result<Expr, EngineError> {
13289    let lit = match v {
13290        Value::Null => Literal::Null,
13291        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
13292        Value::Int(n) => Literal::Integer(i64::from(n)),
13293        Value::BigInt(n) => Literal::Integer(n),
13294        Value::Float(x) => Literal::Float(x),
13295        Value::Text(s) | Value::Json(s) => Literal::String(s),
13296        Value::Bool(b) => Literal::Bool(b),
13297        other => {
13298            return Err(EngineError::Unsupported(alloc::format!(
13299                "subquery result type {:?} not yet materialisable; cast to text or integer in the inner SELECT",
13300                other.data_type()
13301            )));
13302        }
13303    };
13304    Ok(Expr::Literal(lit))
13305}
13306
13307/// v7.13.0 — wider helper used by `INSERT … SELECT` (mailrs
13308/// round-5 G4). Covers the most common `Value` variants. Types
13309/// that need lossy textual round-trip (BYTEA, arrays, ts*)
13310/// surface as an Unsupported error so the caller can add a cast
13311/// in the inner SELECT.
13312fn value_to_literal_expr_permissive(v: Value) -> Result<Expr, EngineError> {
13313    let lit = match v {
13314        Value::Null => Literal::Null,
13315        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
13316        Value::Int(n) => Literal::Integer(i64::from(n)),
13317        Value::BigInt(n) => Literal::Integer(n),
13318        Value::Float(x) => Literal::Float(x),
13319        Value::Text(s) | Value::Json(s) => Literal::String(s),
13320        Value::Bool(b) => Literal::Bool(b),
13321        Value::Vector(xs) => Literal::Vector(xs),
13322        // Date / Timestamp / Timestamptz / Numeric round-trip
13323        // through a TEXT literal that `coerce_value` re-parses
13324        // against the target column type.
13325        Value::Date(days) => {
13326            let micros = (i64::from(days)) * 86_400_000_000;
13327            Literal::String(format_timestamp_micros_as_date(micros))
13328        }
13329        Value::Timestamp(us) => Literal::String(format_timestamp_micros(us)),
13330        Value::Numeric { scaled, scale } => Literal::String(format_numeric(scaled, scale)),
13331        other => {
13332            return Err(EngineError::Unsupported(alloc::format!(
13333                "INSERT … SELECT cannot materialise value of type {:?}; \
13334                 add an explicit CAST in the inner SELECT",
13335                other.data_type()
13336            )));
13337        }
13338    };
13339    Ok(Expr::Literal(lit))
13340}
13341
13342fn format_timestamp_micros(us: i64) -> String {
13343    // Same Y/M/D split used by the wire layer; epoch-relative.
13344    let days = us.div_euclid(86_400_000_000);
13345    let intra_day = us.rem_euclid(86_400_000_000);
13346    let date = format_timestamp_micros_as_date(days * 86_400_000_000);
13347    let secs = intra_day / 1_000_000;
13348    let us_rem = intra_day % 1_000_000;
13349    let h = (secs / 3600) % 24;
13350    let m = (secs / 60) % 60;
13351    let s = secs % 60;
13352    if us_rem == 0 {
13353        alloc::format!("{date} {h:02}:{m:02}:{s:02}")
13354    } else {
13355        alloc::format!("{date} {h:02}:{m:02}:{s:02}.{us_rem:06}")
13356    }
13357}
13358
13359fn format_timestamp_micros_as_date(us: i64) -> String {
13360    // Days since 1970-01-01 → calendar Y-M-D via the proleptic
13361    // Gregorian conversion used by spg-engine's date helpers.
13362    let days = us.div_euclid(86_400_000_000);
13363    // 1970-01-01 = JDN 2440588.
13364    let jdn = days + 2_440_588;
13365    let (y, mo, d) = jdn_to_ymd(jdn);
13366    alloc::format!("{y:04}-{mo:02}-{d:02}")
13367}
13368
13369fn jdn_to_ymd(jdn: i64) -> (i64, u32, u32) {
13370    // Fliegel & Van Flandern (1968) — works for all positive JDNs.
13371    let l = jdn + 68569;
13372    let n = (4 * l) / 146_097;
13373    let l = l - (146_097 * n + 3) / 4;
13374    let i = (4000 * (l + 1)) / 1_461_001;
13375    let l = l - (1461 * i) / 4 + 31;
13376    let j = (80 * l) / 2447;
13377    let day = (l - (2447 * j) / 80) as u32;
13378    let l = j / 11;
13379    let month = (j + 2 - 12 * l) as u32;
13380    let year = 100 * (n - 49) + i + l;
13381    (year, month, day)
13382}
13383
13384fn format_numeric(scaled: i128, scale: u8) -> String {
13385    if scale == 0 {
13386        return alloc::format!("{scaled}");
13387    }
13388    let abs = scaled.unsigned_abs();
13389    let divisor = 10u128.pow(u32::from(scale));
13390    let whole = abs / divisor;
13391    let frac = abs % divisor;
13392    let sign = if scaled < 0 { "-" } else { "" };
13393    alloc::format!("{sign}{whole}.{frac:0width$}", width = usize::from(scale))
13394}
13395
13396/// v6.1.1 — walk the prepared `Statement` AST and replace every
13397/// `Expr::Placeholder(n)` with `Expr::Literal(value_to_literal(
13398/// params[n-1]))`. The dispatch downstream sees a `Statement`
13399/// indistinguishable from a simple-query parse, so the exec path
13400/// stays unchanged.
13401///
13402/// Errors fall into one shape: a `$N` references past the bound
13403/// `params.len()`. Out-of-range happens when the Bind didn't
13404/// supply enough values; pgwire surfaces this as a protocol error
13405/// to the client.
13406/// v7.15.0 — rewrite every (potentially-qualified) column
13407/// identifier matching `old` to `new` in a stored SQL source
13408/// string. Used by `ALTER TABLE … RENAME COLUMN` to patch
13409/// CHECK predicate sources, partial-index predicate sources,
13410/// and runtime DEFAULT expression sources before they get
13411/// re-parsed on the next INSERT/UPDATE.
13412///
13413/// Round-trips through the parser, so the rewritten output is
13414/// the canonical Display form (matches what the engine stores
13415/// for fresh predicates). If the source doesn't parse, surfaces
13416/// the parse error — the invariant that stored predicates are
13417/// in canonical Display form means a parse failure here is a
13418/// real bug, not a user mistake to swallow.
13419fn rewrite_column_in_source(
13420    src: &str,
13421    old: &str,
13422    new: &str,
13423) -> Result<alloc::string::String, EngineError> {
13424    let mut expr = spg_sql::parser::parse_expression(src).map_err(|e| {
13425        EngineError::Unsupported(alloc::format!(
13426            "ALTER TABLE RENAME COLUMN: stored predicate source {src:?} \
13427             failed to parse for rewrite ({e})"
13428        ))
13429    })?;
13430    rewrite_column_in_expr(&mut expr, old, new);
13431    Ok(alloc::format!("{expr}"))
13432}
13433
13434/// v7.15.0 — Expr walker that swaps `Expr::Column { name: old, .. }`
13435/// for `Expr::Column { name: new, .. }`. Qualifier is preserved
13436/// (e.g. `t.old` → `t.new`); a foreign-table qualifier still
13437/// gets rewritten because the AST has no way to tell us this
13438/// predicate is on table T versus table T2 — predicate sources
13439/// in SPG are always scoped to the owning table, so any
13440/// qualifier present is either redundant or wrong.
13441fn rewrite_column_in_expr(e: &mut Expr, old: &str, new: &str) {
13442    match e {
13443        Expr::Column(c) => {
13444            if c.name.eq_ignore_ascii_case(old) {
13445                c.name = new.to_string();
13446            }
13447        }
13448        Expr::Binary { lhs, rhs, .. } => {
13449            rewrite_column_in_expr(lhs, old, new);
13450            rewrite_column_in_expr(rhs, old, new);
13451        }
13452        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13453            rewrite_column_in_expr(expr, old, new);
13454        }
13455        Expr::FunctionCall { args, .. } => {
13456            for a in args {
13457                rewrite_column_in_expr(a, old, new);
13458            }
13459        }
13460        Expr::Like { expr, pattern, .. } => {
13461            rewrite_column_in_expr(expr, old, new);
13462            rewrite_column_in_expr(pattern, old, new);
13463        }
13464        Expr::Extract { source, .. } => rewrite_column_in_expr(source, old, new),
13465        Expr::WindowFunction {
13466            args,
13467            partition_by,
13468            order_by,
13469            ..
13470        } => {
13471            for a in args {
13472                rewrite_column_in_expr(a, old, new);
13473            }
13474            for p in partition_by {
13475                rewrite_column_in_expr(p, old, new);
13476            }
13477            for (o, _) in order_by {
13478                rewrite_column_in_expr(o, old, new);
13479            }
13480        }
13481        Expr::Array(items) => {
13482            for elem in items {
13483                rewrite_column_in_expr(elem, old, new);
13484            }
13485        }
13486        Expr::ArraySubscript { target, index } => {
13487            rewrite_column_in_expr(target, old, new);
13488            rewrite_column_in_expr(index, old, new);
13489        }
13490        Expr::AnyAll { expr, array, .. } => {
13491            rewrite_column_in_expr(expr, old, new);
13492            rewrite_column_in_expr(array, old, new);
13493        }
13494        Expr::Case {
13495            operand,
13496            branches,
13497            else_branch,
13498        } => {
13499            if let Some(o) = operand {
13500                rewrite_column_in_expr(o, old, new);
13501            }
13502            for (w, t) in branches {
13503                rewrite_column_in_expr(w, old, new);
13504                rewrite_column_in_expr(t, old, new);
13505            }
13506            if let Some(e) = else_branch {
13507                rewrite_column_in_expr(e, old, new);
13508            }
13509        }
13510        // Stored predicate sources never contain subqueries —
13511        // CHECK / partial-index / runtime_default are all scalar.
13512        // If a future feature changes that, recurse here.
13513        Expr::ScalarSubquery(_) | Expr::Exists { .. } | Expr::InSubquery { .. } => {}
13514        Expr::Literal(_) | Expr::Placeholder(_) => {}
13515    }
13516}
13517
13518/// v7.16.0 — walks a parsed statement and replaces every
13519/// `Expr::Placeholder(N)` with the corresponding `params[N-1]`
13520/// re-encoded as an `Expr::Literal`. Used internally by
13521/// `Engine::execute_prepared` AND surfaced for the spg-embedded
13522/// WAL path (which needs the bind-final AST so replay sees a
13523/// simple-query-shaped statement, not a `$1`-shaped one). Errors
13524/// when a placeholder references an index past the params slice.
13525pub fn substitute_placeholders(stmt: &mut Statement, params: &[Value]) -> Result<(), EngineError> {
13526    match stmt {
13527        Statement::Select(s) => substitute_select(s, params)?,
13528        Statement::Insert(ins) => {
13529            for row in &mut ins.rows {
13530                for e in row {
13531                    substitute_expr(e, params)?;
13532                }
13533            }
13534            // ON CONFLICT DO UPDATE assignments / WHERE can carry
13535            // placeholders too (`… DO UPDATE SET reason = $2` —
13536            // mailrs embed round-12).
13537            if let Some(clause) = &mut ins.on_conflict
13538                && let spg_sql::ast::OnConflictAction::Update {
13539                    assignments,
13540                    where_,
13541                } = &mut clause.action
13542            {
13543                for (_, e) in assignments.iter_mut() {
13544                    substitute_expr(e, params)?;
13545                }
13546                if let Some(w) = where_ {
13547                    substitute_expr(w, params)?;
13548                }
13549            }
13550        }
13551        Statement::Update(u) => {
13552            for (_, e) in &mut u.assignments {
13553                substitute_expr(e, params)?;
13554            }
13555            if let Some(w) = &mut u.where_ {
13556                substitute_expr(w, params)?;
13557            }
13558        }
13559        Statement::Delete(d) => {
13560            if let Some(w) = &mut d.where_ {
13561                substitute_expr(w, params)?;
13562            }
13563        }
13564        Statement::Explain(e) => substitute_select(&mut e.inner, params)?,
13565        // Other statements (CREATE / BEGIN / SHOW / …) have no
13566        // expression slots; no walk needed.
13567        _ => {}
13568    }
13569    Ok(())
13570}
13571
13572fn substitute_select(s: &mut SelectStatement, params: &[Value]) -> Result<(), EngineError> {
13573    for item in &mut s.items {
13574        if let SelectItem::Expr { expr, .. } = item {
13575            substitute_expr(expr, params)?;
13576        }
13577    }
13578    if let Some(w) = &mut s.where_ {
13579        substitute_expr(w, params)?;
13580    }
13581    if let Some(gs) = &mut s.group_by {
13582        for g in gs {
13583            substitute_expr(g, params)?;
13584        }
13585    }
13586    if let Some(h) = &mut s.having {
13587        substitute_expr(h, params)?;
13588    }
13589    for o in &mut s.order_by {
13590        substitute_expr(&mut o.expr, params)?;
13591    }
13592    for (_, peer) in &mut s.unions {
13593        substitute_select(peer, params)?;
13594    }
13595    // v7.9.24 — LIMIT $N / OFFSET $N placeholder resolution.
13596    // mailrs H2. After this pass each LIMIT/OFFSET that was a
13597    // Placeholder is rewritten to Literal so the existing
13598    // `LimitExpr::as_literal` path consumes a concrete u32.
13599    if let Some(le) = s.limit {
13600        s.limit = Some(resolve_limit_placeholder(le, params)?);
13601    }
13602    if let Some(le) = s.offset {
13603        s.offset = Some(resolve_limit_placeholder(le, params)?);
13604    }
13605    Ok(())
13606}
13607
13608fn resolve_limit_placeholder(
13609    le: spg_sql::ast::LimitExpr,
13610    params: &[Value],
13611) -> Result<spg_sql::ast::LimitExpr, EngineError> {
13612    use spg_sql::ast::LimitExpr;
13613    match le {
13614        LimitExpr::Literal(_) => Ok(le),
13615        LimitExpr::Placeholder(n) => {
13616            let idx = usize::from(n).saturating_sub(1);
13617            let v = params.get(idx).ok_or_else(|| {
13618                EngineError::Eval(EvalError::PlaceholderOutOfRange {
13619                    n,
13620                    bound: u16::try_from(params.len()).unwrap_or(u16::MAX),
13621                })
13622            })?;
13623            let int = match v {
13624                Value::SmallInt(x) => Some(i64::from(*x)),
13625                Value::Int(x) => Some(i64::from(*x)),
13626                Value::BigInt(x) => Some(*x),
13627                _ => None,
13628            }
13629            .ok_or_else(|| {
13630                EngineError::Unsupported(alloc::format!(
13631                    "LIMIT/OFFSET ${n} bound to non-integer {v:?}"
13632                ))
13633            })?;
13634            if int < 0 {
13635                return Err(EngineError::Unsupported(alloc::format!(
13636                    "LIMIT/OFFSET ${n} bound to negative value {int}"
13637                )));
13638            }
13639            let bounded = u32::try_from(int).map_err(|_| {
13640                EngineError::Unsupported(alloc::format!(
13641                    "LIMIT/OFFSET ${n} value {int} exceeds u32 range"
13642                ))
13643            })?;
13644            Ok(LimitExpr::Literal(bounded))
13645        }
13646    }
13647}
13648
13649fn substitute_expr(e: &mut Expr, params: &[Value]) -> Result<(), EngineError> {
13650    if let Expr::Placeholder(n) = e {
13651        let idx = usize::from(*n).saturating_sub(1);
13652        let v = params.get(idx).ok_or_else(|| {
13653            EngineError::Eval(EvalError::PlaceholderOutOfRange {
13654                n: *n,
13655                bound: u16::try_from(params.len()).unwrap_or(u16::MAX),
13656            })
13657        })?;
13658        *e = Expr::Literal(value_to_literal(v.clone()));
13659        return Ok(());
13660    }
13661    match e {
13662        Expr::Binary { lhs, rhs, .. } => {
13663            substitute_expr(lhs, params)?;
13664            substitute_expr(rhs, params)?;
13665        }
13666        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13667            substitute_expr(expr, params)?;
13668        }
13669        Expr::FunctionCall { args, .. } => {
13670            for a in args {
13671                substitute_expr(a, params)?;
13672            }
13673        }
13674        Expr::Like { expr, pattern, .. } => {
13675            substitute_expr(expr, params)?;
13676            substitute_expr(pattern, params)?;
13677        }
13678        Expr::Extract { source, .. } => substitute_expr(source, params)?,
13679        Expr::ScalarSubquery(s) => substitute_select(s, params)?,
13680        Expr::Exists { subquery, .. } => substitute_select(subquery, params)?,
13681        Expr::InSubquery { expr, subquery, .. } => {
13682            substitute_expr(expr, params)?;
13683            substitute_select(subquery, params)?;
13684        }
13685        Expr::WindowFunction {
13686            args,
13687            partition_by,
13688            order_by,
13689            ..
13690        } => {
13691            for a in args {
13692                substitute_expr(a, params)?;
13693            }
13694            for p in partition_by {
13695                substitute_expr(p, params)?;
13696            }
13697            for (e, _) in order_by {
13698                substitute_expr(e, params)?;
13699            }
13700        }
13701        Expr::Literal(_) | Expr::Column(_) => {}
13702        // Already handled above.
13703        Expr::Placeholder(_) => unreachable!("Placeholder handled at top of fn"),
13704        Expr::Array(items) => {
13705            for elem in items {
13706                substitute_expr(elem, params)?;
13707            }
13708        }
13709        Expr::ArraySubscript { target, index } => {
13710            substitute_expr(target, params)?;
13711            substitute_expr(index, params)?;
13712        }
13713        Expr::AnyAll { expr, array, .. } => {
13714            substitute_expr(expr, params)?;
13715            substitute_expr(array, params)?;
13716        }
13717        Expr::Case {
13718            operand,
13719            branches,
13720            else_branch,
13721        } => {
13722            if let Some(o) = operand {
13723                substitute_expr(o, params)?;
13724            }
13725            for (w, t) in branches {
13726                substitute_expr(w, params)?;
13727                substitute_expr(t, params)?;
13728            }
13729            if let Some(e) = else_branch {
13730                substitute_expr(e, params)?;
13731            }
13732        }
13733    }
13734    Ok(())
13735}
13736
13737/// v6.1.1 — convert a runtime `Value` into the closest matching
13738/// `Literal` for the substitute walker. Lossless for the simple
13739/// scalars (Int / Float / Text / Bool); Numeric / Date / Timestamp
13740/// / Json / Interval render as their canonical text form so the
13741/// downstream coerce_value can re-parse against the target column
13742/// type. SQ8 / HalfVector cells are NOT expected as bind params;
13743/// pgwire's Bind decodes vector params to the f32 representation
13744/// before they reach this helper.
13745/// v6.2.0 — total ordering on `Value`s used by ANALYZE to sort a
13746/// column's non-NULL sample before histogram building. Cross-type
13747/// pairs (Int vs Float, Date vs Timestamp, …) compare via the
13748/// same widening the eval-side `compare` operator uses; everything
13749/// else (the genuinely-incompatible pairs) falls back to ordering
13750/// by canonical string form so the sort is still total + stable.
13751/// Vector / SQ8 / Half / Json / Numeric / Interval values reach
13752/// here only via the string-fallback path because vector columns
13753/// are filtered out upstream.
13754fn sort_values_for_histogram(a: &Value, b: &Value) -> core::cmp::Ordering {
13755    use core::cmp::Ordering;
13756    match (a, b) {
13757        (Value::SmallInt(a), Value::SmallInt(b)) => a.cmp(b),
13758        (Value::Int(a), Value::Int(b)) => a.cmp(b),
13759        (Value::BigInt(a), Value::BigInt(b)) => a.cmp(b),
13760        (Value::SmallInt(a), Value::Int(b)) => i32::from(*a).cmp(b),
13761        (Value::Int(a), Value::SmallInt(b)) => a.cmp(&i32::from(*b)),
13762        (Value::Int(a), Value::BigInt(b)) => i64::from(*a).cmp(b),
13763        (Value::BigInt(a), Value::Int(b)) => a.cmp(&i64::from(*b)),
13764        (Value::SmallInt(a), Value::BigInt(b)) => i64::from(*a).cmp(b),
13765        (Value::BigInt(a), Value::SmallInt(b)) => a.cmp(&i64::from(*b)),
13766        (Value::Float(a), Value::Float(b)) => a.partial_cmp(b).unwrap_or(Ordering::Equal),
13767        (Value::Text(a), Value::Text(b)) | (Value::Json(a), Value::Json(b)) => a.cmp(b),
13768        (Value::Bool(a), Value::Bool(b)) => a.cmp(b),
13769        (Value::Date(a), Value::Date(b)) => a.cmp(b),
13770        (Value::Timestamp(a), Value::Timestamp(b)) => a.cmp(b),
13771        // Mixed numeric/float — widen to f64 and compare.
13772        (Value::SmallInt(n), Value::Float(x)) => {
13773            (f64::from(*n)).partial_cmp(x).unwrap_or(Ordering::Equal)
13774        }
13775        (Value::Float(x), Value::SmallInt(n)) => {
13776            x.partial_cmp(&f64::from(*n)).unwrap_or(Ordering::Equal)
13777        }
13778        (Value::Int(n), Value::Float(x)) => {
13779            (f64::from(*n)).partial_cmp(x).unwrap_or(Ordering::Equal)
13780        }
13781        (Value::Float(x), Value::Int(n)) => {
13782            x.partial_cmp(&f64::from(*n)).unwrap_or(Ordering::Equal)
13783        }
13784        (Value::BigInt(n), Value::Float(x)) => {
13785            #[allow(clippy::cast_precision_loss)]
13786            let nf = *n as f64;
13787            nf.partial_cmp(x).unwrap_or(Ordering::Equal)
13788        }
13789        (Value::Float(x), Value::BigInt(n)) => {
13790            #[allow(clippy::cast_precision_loss)]
13791            let nf = *n as f64;
13792            x.partial_cmp(&nf).unwrap_or(Ordering::Equal)
13793        }
13794        // Cross-type fallback: lexicographic on canonical form.
13795        // Total + stable so the sort is well-defined.
13796        _ => canonical_value_repr(a).cmp(&canonical_value_repr(b)),
13797    }
13798}
13799
13800/// v6.2.0 — render the histogram bounds list as a `[v0, v1, ...]`
13801/// string for the `spg_statistic.histogram_bounds` column. Values
13802/// containing `,` or `[` / `]` are JSON-style escaped so the
13803/// rendering round-trips through a future parser; v6.2.0 only
13804/// uses the rendered form for human consumption, so the escaping
13805/// is conservative.
13806fn render_histogram_bounds(bounds: &[alloc::string::String]) -> alloc::string::String {
13807    let mut out = alloc::string::String::with_capacity(bounds.len() * 8 + 2);
13808    out.push('[');
13809    for (i, b) in bounds.iter().enumerate() {
13810        if i > 0 {
13811            out.push_str(", ");
13812        }
13813        let needs_quote = b.contains([',', '[', ']', '"']) || b.is_empty();
13814        if needs_quote {
13815            out.push('"');
13816            for ch in b.chars() {
13817                if ch == '"' || ch == '\\' {
13818                    out.push('\\');
13819                }
13820                out.push(ch);
13821            }
13822            out.push('"');
13823        } else {
13824            out.push_str(b);
13825        }
13826    }
13827    out.push(']');
13828    out
13829}
13830
13831/// v6.2.0 — canonical textual form of a `Value` for histogram
13832/// bound storage. Strings used by ANALYZE for sort + bound output.
13833/// INT / BIGINT → decimal; FLOAT → shortest-round-trip via
13834/// `{:?}`; TEXT pass-through; BOOL → `t` / `f`; DATE / TIMESTAMP →
13835/// the same form `format_date` / `format_timestamp` produce for
13836/// SQL Display. Vector / SQ8 / Half / Json / Numeric / Interval
13837/// reach this only via a non-Vector column (vector columns are
13838/// skipped upstream); they fall back to a Debug-derived form so
13839/// stats still serialise without crashing.
13840pub(crate) fn canonical_value_repr(v: &Value) -> alloc::string::String {
13841    match v {
13842        Value::Null => "NULL".to_string(),
13843        Value::SmallInt(n) => alloc::format!("{n}"),
13844        Value::Int(n) => alloc::format!("{n}"),
13845        Value::BigInt(n) => alloc::format!("{n}"),
13846        Value::Float(x) => alloc::format!("{x:?}"),
13847        Value::Text(s) | Value::Json(s) => s.clone(),
13848        Value::Bool(b) => if *b { "t" } else { "f" }.to_string(),
13849        Value::Date(d) => eval::format_date(*d),
13850        Value::Timestamp(t) => eval::format_timestamp(*t),
13851        // v7.17.0 Phase 3.P0-32 — PG TIME canonical text form.
13852        Value::Time(us) => eval::format_time(*us),
13853        // v7.17.0 Phase 3.P0-33 — MySQL YEAR 4-digit zero-padded.
13854        Value::Year(y) => alloc::format!("{y:04}"),
13855        // v7.17.0 Phase 3.P0-34 — PG TIMETZ canonical text form.
13856        Value::TimeTz { us, offset_secs } => eval::format_timetz(*us, *offset_secs),
13857        // v7.17.0 Phase 3.P0-35 — PG MONEY canonical en_US text form.
13858        Value::Money(c) => eval::format_money(*c),
13859        // v7.17.0 Phase 3.P0-38 — PG range canonical text form.
13860        v @ Value::Range { .. } => format_range_str(v),
13861        // v7.17.0 Phase 3.P0-39 — PG hstore canonical text form.
13862        Value::Hstore(pairs) => format_hstore_str(pairs),
13863        // v7.17.0 Phase 3.P0-40 — 2D array canonical text form.
13864        Value::IntArray2D(rows) => format_int_2d_text(rows),
13865        Value::BigIntArray2D(rows) => format_bigint_2d_text(rows),
13866        Value::TextArray2D(rows) => format_text_2d_text(rows),
13867        Value::Interval { months, micros } => eval::format_interval(*months, *micros),
13868        Value::Numeric { scaled, scale } => eval::format_numeric(*scaled, *scale),
13869        Value::Vector(_) | Value::Sq8Vector(_) | Value::HalfVector(_) => {
13870            // Unreachable in practice (vector columns are filtered
13871            // out before this). Defensive fallback so a future
13872            // vector-stats path doesn't crash.
13873            alloc::format!("{v:?}")
13874        }
13875        // v7.5.0 — Value is #[non_exhaustive] for downstream
13876        // forward-compat. Future variants fall through to Debug
13877        // form here (same shape as the vector fallback above).
13878        _ => alloc::format!("{v:?}"),
13879    }
13880}
13881
13882/// v6.2.0 — true for engine-managed catalog tables that the bare
13883/// `ANALYZE` (no target) should skip. v6.2.0 has no internal
13884/// tables yet (publications / subscriptions / users / statistics
13885/// all live as engine fields, not catalog tables), so this is a
13886/// reserved future-proofing hook — every existing user table is
13887/// analysed.
13888const fn is_internal_table_name(_name: &str) -> bool {
13889    false
13890}
13891
13892fn value_to_literal(v: Value) -> Literal {
13893    match v {
13894        Value::Null => Literal::Null,
13895        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
13896        Value::Int(n) => Literal::Integer(i64::from(n)),
13897        Value::BigInt(n) => Literal::Integer(n),
13898        Value::Float(x) => Literal::Float(x),
13899        Value::Text(s) | Value::Json(s) => Literal::String(s),
13900        Value::Bool(b) => Literal::Bool(b),
13901        Value::Vector(v) => Literal::Vector(v),
13902        Value::Numeric { scaled, scale } => Literal::String(eval::format_numeric(scaled, scale)),
13903        Value::Date(d) => Literal::String(eval::format_date(d)),
13904        Value::Timestamp(t) => Literal::String(eval::format_timestamp(t)),
13905        // v7.17.0 Phase 3.P0-69 — UUID round-trips via canonical
13906        // hyphenated text. Without this arm the fallback below
13907        // renders `Debug` form ("Uuid([85, …])") which the
13908        // engine's Text → Uuid coerce can't parse, breaking
13909        // prepared-bind round-trip from the spg-sqlx adapter.
13910        Value::Uuid(b) => Literal::String(spg_storage::format_uuid(&b)),
13911        // v7.16.0 — BYTEA round-trip for the spg-sqlx Bind path.
13912        // PG-canonical text rep is `\x` + lowercase hex; the
13913        // engine's coerce_value already accepts that on the
13914        // text → bytea direction.
13915        Value::Bytes(b) => Literal::String(eval::format_bytea_hex(&b)),
13916        // Arrays ride the AST natively (mailrs embed round-12) —
13917        // the prior `{a,b,c}` text form only worked where a column
13918        // type drove the re-parse; `= ANY($1)` has no column
13919        // context and saw a bare Text value.
13920        Value::TextArray(items) => Literal::TextArray(items),
13921        Value::IntArray(items) => Literal::IntArray(items),
13922        Value::BigIntArray(items) => Literal::BigIntArray(items),
13923        Value::Interval { months, micros } => Literal::Interval {
13924            months,
13925            micros,
13926            text: eval::format_interval(months, micros),
13927        },
13928        // SQ8 / halfvec cells dequantise to f32 before reaching the
13929        // substitute walker; pgwire's Bind path handles that.
13930        Value::Sq8Vector(q) => Literal::Vector(spg_storage::quantize::dequantize(&q)),
13931        Value::HalfVector(h) => Literal::Vector(h.to_f32_vec()),
13932        // v7.5.0 — Value is #[non_exhaustive]; future variants
13933        // render as Debug-form String literal until explicit
13934        // mapping is added.
13935        v => Literal::String(alloc::format!("{v:?}")),
13936    }
13937}
13938
13939fn rewrite_clock_calls(stmt: &mut Statement, now_micros: Option<i64>) {
13940    let Some(now) = now_micros else {
13941        return;
13942    };
13943    match stmt {
13944        Statement::Select(s) => rewrite_select_clock(s, now),
13945        Statement::Insert(ins) => {
13946            for row in &mut ins.rows {
13947                for e in row {
13948                    rewrite_expr_clock(e, now);
13949                }
13950            }
13951            // `ON CONFLICT … DO UPDATE SET created_at = NOW()` —
13952            // the upsert assignments carry clock calls too (mailrs
13953            // embed round-12).
13954            if let Some(clause) = &mut ins.on_conflict
13955                && let spg_sql::ast::OnConflictAction::Update {
13956                    assignments,
13957                    where_,
13958                } = &mut clause.action
13959            {
13960                for (_, e) in assignments.iter_mut() {
13961                    rewrite_expr_clock(e, now);
13962                }
13963                if let Some(w) = where_ {
13964                    rewrite_expr_clock(w, now);
13965                }
13966            }
13967        }
13968        // `UPDATE … SET seen_at = NOW() WHERE …` / `DELETE … WHERE
13969        // ts < NOW()` (mailrs embed round-12 — previously only
13970        // SELECT / INSERT-rows were walked).
13971        Statement::Update(u) => {
13972            for (_, e) in &mut u.assignments {
13973                rewrite_expr_clock(e, now);
13974            }
13975            if let Some(w) = &mut u.where_ {
13976                rewrite_expr_clock(w, now);
13977            }
13978        }
13979        Statement::Delete(d) => {
13980            if let Some(w) = &mut d.where_ {
13981                rewrite_expr_clock(w, now);
13982            }
13983        }
13984        _ => {}
13985    }
13986}
13987
13988fn rewrite_select_clock(s: &mut SelectStatement, now: i64) {
13989    for item in &mut s.items {
13990        if let SelectItem::Expr { expr, .. } = item {
13991            rewrite_expr_clock(expr, now);
13992        }
13993    }
13994    if let Some(w) = &mut s.where_ {
13995        rewrite_expr_clock(w, now);
13996    }
13997    if let Some(gs) = &mut s.group_by {
13998        for g in gs {
13999            rewrite_expr_clock(g, now);
14000        }
14001    }
14002    if let Some(h) = &mut s.having {
14003        rewrite_expr_clock(h, now);
14004    }
14005    for o in &mut s.order_by {
14006        rewrite_expr_clock(&mut o.expr, now);
14007    }
14008    for (_, peer) in &mut s.unions {
14009        rewrite_select_clock(peer, now);
14010    }
14011}
14012
14013/// v3.0.3 hot path: every recursion lands in exactly one `match` arm.
14014/// Literal / Column-with-qualifier (the dominant cases on a typical
14015/// AST) take a single pattern dispatch and exit. The clock-rewrite
14016/// targets (zero-arg `NOW` / `CURRENT_TIMESTAMP` / `CURRENT_DATE`
14017/// functions, and bare `CURRENT_TIMESTAMP` / `CURRENT_DATE` column
14018/// refs) sit on their own arms with match guards so the fall-through
14019/// to the recursive arms is unambiguous.
14020fn rewrite_expr_clock(e: &mut Expr, now: i64) {
14021    // Fast-path test on the no-recursion shapes first. We can't fold
14022    // them into the big match below because they need to *replace* `e`
14023    // outright; the recursive arms below match on its sub-fields.
14024    if let Some(replacement) = clock_replacement_for(e, now) {
14025        *e = replacement;
14026        return;
14027    }
14028    match e {
14029        Expr::Binary { lhs, rhs, .. } => {
14030            rewrite_expr_clock(lhs, now);
14031            rewrite_expr_clock(rhs, now);
14032        }
14033        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
14034            rewrite_expr_clock(expr, now);
14035        }
14036        Expr::FunctionCall { args, .. } => {
14037            for a in args {
14038                rewrite_expr_clock(a, now);
14039            }
14040        }
14041        Expr::Like { expr, pattern, .. } => {
14042            rewrite_expr_clock(expr, now);
14043            rewrite_expr_clock(pattern, now);
14044        }
14045        Expr::Extract { source, .. } => rewrite_expr_clock(source, now),
14046        // v4.10 subquery nodes — recurse into the inner SELECT's
14047        // expression slots so e.g. SELECT NOW() in a scalar
14048        // subquery picks up the same instant as the outer query.
14049        Expr::ScalarSubquery(s) => rewrite_select_clock(s, now),
14050        Expr::Exists { subquery, .. } => rewrite_select_clock(subquery, now),
14051        Expr::InSubquery { expr, subquery, .. } => {
14052            rewrite_expr_clock(expr, now);
14053            rewrite_select_clock(subquery, now);
14054        }
14055        // v4.12 window functions — args + PARTITION BY + ORDER BY
14056        // may all reference clock literals.
14057        Expr::WindowFunction {
14058            args,
14059            partition_by,
14060            order_by,
14061            ..
14062        } => {
14063            for a in args {
14064                rewrite_expr_clock(a, now);
14065            }
14066            for p in partition_by {
14067                rewrite_expr_clock(p, now);
14068            }
14069            for (e, _) in order_by {
14070                rewrite_expr_clock(e, now);
14071            }
14072        }
14073        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => {}
14074        Expr::Array(items) => {
14075            for elem in items {
14076                rewrite_expr_clock(elem, now);
14077            }
14078        }
14079        Expr::ArraySubscript { target, index } => {
14080            rewrite_expr_clock(target, now);
14081            rewrite_expr_clock(index, now);
14082        }
14083        Expr::AnyAll { expr, array, .. } => {
14084            rewrite_expr_clock(expr, now);
14085            rewrite_expr_clock(array, now);
14086        }
14087        Expr::Case {
14088            operand,
14089            branches,
14090            else_branch,
14091        } => {
14092            if let Some(o) = operand {
14093                rewrite_expr_clock(o, now);
14094            }
14095            for (w, t) in branches {
14096                rewrite_expr_clock(w, now);
14097                rewrite_expr_clock(t, now);
14098            }
14099            if let Some(e) = else_branch {
14100                rewrite_expr_clock(e, now);
14101            }
14102        }
14103    }
14104}
14105
14106/// Returns `Some(Expr)` when `e` is one of the clock-call shapes that
14107/// must be rewritten; otherwise `None` so the caller falls through to
14108/// the recursive walk. Identifies both function-call forms (`NOW()` /
14109/// `CURRENT_TIMESTAMP()` / `CURRENT_DATE()`) and bare-identifier forms
14110/// (`CURRENT_TIMESTAMP` / `CURRENT_DATE` as unqualified column refs,
14111/// which is how PG accepts them without parens).
14112fn clock_replacement_for(e: &Expr, now: i64) -> Option<Expr> {
14113    let (kind, name) = match e {
14114        Expr::FunctionCall { name, args } if args.is_empty() => (ClockSite::Fn, name.as_str()),
14115        Expr::Column(c) if c.qualifier.is_none() => (ClockSite::BareIdent, c.name.as_str()),
14116        _ => return None,
14117    };
14118    // ASCII case-insensitive name match. Each entry decides what
14119    // synthetic literal the call expands to.
14120    //
14121    // v7.17.0 Phase 3.P0-29 — `unix_timestamp` (no args) joins this
14122    // table as MySQL's epoch-seconds equivalent of `now()`. Folded
14123    // to a BigInt literal here so apply_function never needs a
14124    // clock dependency.
14125    enum ClockShape {
14126        Timestamp,
14127        Date,
14128        UnixSeconds,
14129    }
14130    let shape = match name.len() {
14131        3 if kind == ClockSite::Fn && name.eq_ignore_ascii_case("now") => {
14132            Some(ClockShape::Timestamp)
14133        }
14134        12 if name.eq_ignore_ascii_case("current_date") => Some(ClockShape::Date),
14135        14 if kind == ClockSite::Fn && name.eq_ignore_ascii_case("unix_timestamp") => {
14136            Some(ClockShape::UnixSeconds)
14137        }
14138        17 if name.eq_ignore_ascii_case("current_timestamp") => Some(ClockShape::Timestamp),
14139        _ => None,
14140    };
14141    let shape = shape?;
14142    let payload = match shape {
14143        ClockShape::Timestamp => now,
14144        ClockShape::Date => now.div_euclid(86_400_000_000),
14145        ClockShape::UnixSeconds => now.div_euclid(1_000_000),
14146    };
14147    let target = match shape {
14148        ClockShape::Timestamp => spg_sql::ast::CastTarget::Timestamp,
14149        ClockShape::Date => spg_sql::ast::CastTarget::Date,
14150        ClockShape::UnixSeconds => spg_sql::ast::CastTarget::BigInt,
14151    };
14152    Some(Expr::Cast {
14153        expr: alloc::boxed::Box::new(Expr::Literal(spg_sql::ast::Literal::Integer(payload))),
14154        target,
14155    })
14156}
14157
14158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14159enum ClockSite {
14160    Fn,
14161    BareIdent,
14162}
14163
14164/// `ORDER BY <integer>` references the N-th SELECT item (1-based).
14165/// Swap the integer literal for the matching item's expression so the
14166/// executor doesn't need a special-case branch. Recurses into UNION
14167/// peers because each peer keeps its own SELECT list.
14168/// v6.4.1 — expand `GROUP BY ALL` to every non-aggregate SELECT-list
14169/// item. Mirrors DuckDB / PG 19 semantics. Wildcards (`SELECT * …`)
14170/// are NOT expanded by GROUP BY ALL (PG 19 leaves the wildcard intact
14171/// and groups by whatever explicit non-aggregates remain — none in
14172/// the wildcard-only case, which still works for non-aggregate
14173/// queries).
14174fn expand_group_by_all(s: &mut SelectStatement) {
14175    if !s.group_by_all {
14176        for (_, peer) in &mut s.unions {
14177            expand_group_by_all(peer);
14178        }
14179        return;
14180    }
14181    let mut groups: Vec<Expr> = Vec::new();
14182    for item in &s.items {
14183        if let SelectItem::Expr { expr, .. } = item
14184            && !aggregate::contains_aggregate(expr)
14185        {
14186            groups.push(expr.clone());
14187        }
14188    }
14189    s.group_by = Some(groups);
14190    s.group_by_all = false;
14191    for (_, peer) in &mut s.unions {
14192        expand_group_by_all(peer);
14193    }
14194}
14195
14196fn resolve_order_by_position(s: &mut SelectStatement) {
14197    // v6.4.0 — iterate every ORDER BY key. Position references
14198    // (`ORDER BY 2`) bind to the 1-based projection index;
14199    // identifier references that match a SELECT-list alias bind to
14200    // the projected expression (Step 4 of L3a).
14201    for order in &mut s.order_by {
14202        match &order.expr {
14203            Expr::Literal(Literal::Integer(n)) if *n >= 1 => {
14204                if let Ok(idx_one_based) = usize::try_from(*n) {
14205                    let idx = idx_one_based - 1;
14206                    if idx < s.items.len()
14207                        && let SelectItem::Expr { expr, .. } = &s.items[idx]
14208                    {
14209                        order.expr = expr.clone();
14210                    }
14211                }
14212            }
14213            Expr::Column(c) if c.qualifier.is_none() => {
14214                // Alias-in-ORDER-BY lookup.
14215                for item in &s.items {
14216                    if let SelectItem::Expr {
14217                        expr,
14218                        alias: Some(a),
14219                    } = item
14220                        && a == &c.name
14221                    {
14222                        order.expr = expr.clone();
14223                        break;
14224                    }
14225                }
14226            }
14227            _ => {}
14228        }
14229    }
14230    for (_, peer) in &mut s.unions {
14231        resolve_order_by_position(peer);
14232    }
14233}
14234
14235/// Sort `tagged` by `f64` key, reversing the comparator under DESC.
14236/// Used by the UNION ORDER BY path; per-block paths inline the same
14237/// comparator because they already hold `&OrderBy` directly.
14238/// v3.1.1: partial-sort helper. When `keep` (= offset + limit) is
14239/// strictly less than `tagged.len()`, run `select_nth_unstable_by` to
14240/// partition the prefix in O(n), then sort just that prefix in O(k
14241/// log k). Total O(n + k log k), vs O(n log n) for a full sort. The
14242/// caller decides what `keep` is; passing `None` (no LIMIT) keeps the
14243/// full-sort behaviour.
14244///
14245/// `tagged` holds `(Option<f64>, Row)` (the SELECT path) — `None` keys
14246/// sort last in ascending order, mirroring NULL-sorts-last in SQL.
14247fn partial_sort_tagged(tagged: &mut Vec<(Vec<f64>, Row)>, keep: Option<usize>, descs: &[bool]) {
14248    let cmp = |a: &(Vec<f64>, Row), b: &(Vec<f64>, Row)| cmp_multi_key(&a.0, &b.0, descs);
14249    match keep {
14250        Some(k) if k < tagged.len() && k > 0 => {
14251            let pivot = k - 1;
14252            tagged.select_nth_unstable_by(pivot, cmp);
14253            tagged[..k].sort_by(cmp);
14254            tagged.truncate(k);
14255        }
14256        _ => {
14257            tagged.sort_by(cmp);
14258        }
14259    }
14260}
14261
14262fn sort_by_keys(tagged: &mut [(Vec<f64>, Row)], descs: &[bool]) {
14263    tagged.sort_by(|a, b| cmp_multi_key(&a.0, &b.0, descs));
14264}
14265
14266/// v6.4.0 — multi-key ORDER BY comparator. Each key's per-key DESC
14267/// flag is honored independently. NULL is encoded as `f64::INFINITY`
14268/// so it sorts last in ASC and first in DESC (matches PG default).
14269fn cmp_multi_key(a: &[f64], b: &[f64], descs: &[bool]) -> core::cmp::Ordering {
14270    use core::cmp::Ordering;
14271    for (i, (ka, kb)) in a.iter().zip(b.iter()).enumerate() {
14272        let ord = ka.partial_cmp(kb).unwrap_or(Ordering::Equal);
14273        let ord = if descs.get(i).copied().unwrap_or(false) {
14274            ord.reverse()
14275        } else {
14276            ord
14277        };
14278        if ord != Ordering::Equal {
14279            return ord;
14280        }
14281    }
14282    Ordering::Equal
14283}
14284
14285/// v6.4.0 — eval every ORDER BY expression for a row and pack the
14286/// resulting keys into a `Vec<f64>`. NULL → `f64::INFINITY`.
14287fn build_order_keys(
14288    order_by: &[OrderBy],
14289    row: &Row,
14290    ctx: &EvalContext,
14291) -> Result<Vec<f64>, EngineError> {
14292    let mut keys = Vec::with_capacity(order_by.len());
14293    for o in order_by {
14294        let v = eval::eval_expr(&o.expr, row, ctx)?;
14295        keys.push(value_to_order_key(&v)?);
14296    }
14297    Ok(keys)
14298}
14299
14300/// Drop the first `offset` rows then truncate to `limit`. PG / `MySQL`
14301/// agree: OFFSET applies *after* ORDER BY but *before* LIMIT (so
14302/// `LIMIT 10 OFFSET 5` keeps rows 6..=15).
14303fn apply_offset_and_limit(rows: &mut Vec<Row>, offset: Option<u32>, limit: Option<u32>) {
14304    if let Some(off) = offset {
14305        let off = off as usize;
14306        if off >= rows.len() {
14307            rows.clear();
14308        } else {
14309            rows.drain(..off);
14310        }
14311    }
14312    if let Some(n) = limit {
14313        rows.truncate(n as usize);
14314    }
14315}
14316
14317/// v7.17.0 Phase 3.P0-49 — offset + limit applied to a tagged
14318/// `(order_keys, row)` sequence, with optional SQL:2008 `WITH
14319/// TIES` extension. When `with_ties` is set, the truncated tail
14320/// is extended through every subsequent row whose order keys
14321/// equal the last-kept row's keys (so a "top 3 by score" with
14322/// WITH TIES emits row 4 too when row 4 ties row 3 on `score`).
14323///
14324/// The order-key vector is the per-row sort key the caller already
14325/// computed via `build_order_keys`; equal-key detection therefore
14326/// matches the sort comparator exactly.
14327fn apply_offset_and_limit_tagged(
14328    tagged: &mut Vec<(Vec<f64>, Row)>,
14329    offset: Option<u32>,
14330    limit: Option<u32>,
14331    with_ties: bool,
14332) {
14333    if let Some(off) = offset {
14334        let off = off as usize;
14335        if off >= tagged.len() {
14336            tagged.clear();
14337        } else {
14338            tagged.drain(..off);
14339        }
14340    }
14341    if let Some(n) = limit {
14342        let n = n as usize;
14343        if with_ties && n > 0 && n < tagged.len() {
14344            let cutoff_key = tagged[n - 1].0.clone();
14345            let mut end = n;
14346            while end < tagged.len() && tagged[end].0 == cutoff_key {
14347                end += 1;
14348            }
14349            tagged.truncate(end);
14350        } else {
14351            tagged.truncate(n);
14352        }
14353    }
14354}
14355
14356/// v7.17.0 Phase 3.P0-49 — PG-canonical: `FETCH FIRST <n> ROWS
14357/// WITH TIES` requires an `ORDER BY`. Without one, there's no
14358/// way to identify "ties" deterministically, so PG errors at
14359/// plan time. SPG mirrors that surface so the same DDL / app
14360/// behaviour holds on cutover.
14361fn check_with_ties_requires_order_by(stmt: &SelectStatement) -> Result<(), EngineError> {
14362    if stmt.limit_with_ties && stmt.order_by.is_empty() {
14363        return Err(EngineError::Unsupported(alloc::string::String::from(
14364            "FETCH FIRST … ROWS WITH TIES requires an ORDER BY clause",
14365        )));
14366    }
14367    Ok(())
14368}
14369
14370/// v7.6.1 — resolve a parser-level `ForeignKeyConstraint` (column
14371/// names + parent table name) into the storage-layer shape (column
14372/// indices + same parent table). Validates everything the engine
14373/// needs to know about the FK at CREATE TABLE time:
14374///
14375///   - parent table exists (catalog lookup, unless self-referencing)
14376///   - parent columns exist on the parent table
14377///   - parent column list matches the local arity (defaults to the
14378///     parent's primary index column when omitted)
14379///   - parent columns are covered by a `BTree` UNIQUE-class index
14380///     (SPG's stand-in for `PRIMARY KEY`/`UNIQUE`) — required so
14381///     the v7.6.2 INSERT path can do an O(log n) parent lookup
14382///   - local columns exist on the table being created
14383fn resolve_foreign_key(
14384    local_table_name: &str,
14385    local_cols: &[ColumnSchema],
14386    fk: spg_sql::ast::ForeignKeyConstraint,
14387    catalog: &Catalog,
14388) -> Result<spg_storage::ForeignKeyConstraint, EngineError> {
14389    // Resolve local columns.
14390    let mut local_columns = Vec::with_capacity(fk.columns.len());
14391    for name in &fk.columns {
14392        let pos = local_cols
14393            .iter()
14394            .position(|c| c.name == *name)
14395            .ok_or_else(|| {
14396                EngineError::Unsupported(alloc::format!(
14397                    "FOREIGN KEY references unknown local column {name:?}"
14398                ))
14399            })?;
14400        local_columns.push(pos);
14401    }
14402    // Self-referencing FK: parent table is the one we're creating.
14403    // The parent column resolution uses the local column list since
14404    // the catalog doesn't have this table yet.
14405    let is_self_ref = fk.parent_table == local_table_name;
14406    let (parent_cols_for_lookup, parent_table_str): (&[ColumnSchema], &str) = if is_self_ref {
14407        (local_cols, local_table_name)
14408    } else {
14409        let parent_table = catalog.get(&fk.parent_table).ok_or_else(|| {
14410            EngineError::Storage(StorageError::TableNotFound {
14411                name: fk.parent_table.clone(),
14412            })
14413        })?;
14414        (
14415            parent_table.schema().columns.as_slice(),
14416            fk.parent_table.as_str(),
14417        )
14418    };
14419    // Resolve parent column names → positions. If the FK omitted the
14420    // parent column list, fall back to the parent's primary index
14421    // column (single-column only — composite default is rejected
14422    // because there's no unambiguous "PK" in SPG's index list).
14423    let parent_columns: Vec<usize> = if fk.parent_columns.is_empty() {
14424        if fk.columns.len() != 1 {
14425            return Err(EngineError::Unsupported(
14426                "composite FOREIGN KEY without explicit parent column list is not supported \
14427                 — list the parent columns explicitly"
14428                    .into(),
14429            ));
14430        }
14431        // Find a single BTree index on the parent and use its column.
14432        let pos = pick_pk_index_column(catalog, parent_table_str, is_self_ref, local_cols)
14433            .ok_or_else(|| {
14434                EngineError::Unsupported(alloc::format!(
14435                    "parent table {parent_table_str:?} has no PRIMARY-key / UNIQUE BTree index \
14436                     to default the FOREIGN KEY against"
14437                ))
14438            })?;
14439        alloc::vec![pos]
14440    } else {
14441        let mut out = Vec::with_capacity(fk.parent_columns.len());
14442        for name in &fk.parent_columns {
14443            let pos = parent_cols_for_lookup
14444                .iter()
14445                .position(|c| c.name == *name)
14446                .ok_or_else(|| {
14447                    EngineError::Unsupported(alloc::format!(
14448                        "FOREIGN KEY references unknown parent column \
14449                         {name:?} on table {parent_table_str:?}"
14450                    ))
14451                })?;
14452            out.push(pos);
14453        }
14454        out
14455    };
14456    if parent_columns.len() != local_columns.len() {
14457        return Err(EngineError::Unsupported(alloc::format!(
14458            "FOREIGN KEY arity mismatch: {} local columns vs {} parent columns",
14459            local_columns.len(),
14460            parent_columns.len()
14461        )));
14462    }
14463    // For non-self-referencing FKs, verify the parent column set is
14464    // covered by a BTree index. SPG doesn't have a `PRIMARY KEY`
14465    // declaration; the convention is "the parent column for FK
14466    // purposes must have a BTree index" — which the user creates via
14467    // `CREATE INDEX ... USING btree (col)` (the default). We accept
14468    // any single-column BTree index that covers a parent column;
14469    // composite parent column lists require an index whose `column_position`
14470    // matches the first parent column (multi-column BTree indices
14471    // are not in the v7.x roadmap).
14472    if !is_self_ref {
14473        let parent_table = catalog.get(&fk.parent_table).expect("checked above");
14474        let primary_parent_col = parent_columns[0];
14475        let has_btree = parent_table
14476            .schema()
14477            .columns
14478            .get(primary_parent_col)
14479            .is_some()
14480            && parent_table.indices().iter().any(|idx| {
14481                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14482                    && idx.column_position == primary_parent_col
14483                    && idx.partial_predicate.is_none()
14484            });
14485        if !has_btree {
14486            return Err(EngineError::Unsupported(alloc::format!(
14487                "FOREIGN KEY parent column on {:?} is not covered by an unconditional BTree \
14488                 index — create one with `CREATE INDEX ... ON {} ({})` first",
14489                parent_table_str,
14490                parent_table_str,
14491                parent_table.schema().columns[primary_parent_col].name,
14492            )));
14493        }
14494    }
14495    let on_delete = fk_action_sql_to_storage(fk.on_delete);
14496    let on_update = fk_action_sql_to_storage(fk.on_update);
14497    Ok(spg_storage::ForeignKeyConstraint {
14498        name: fk.name,
14499        local_columns,
14500        parent_table: fk.parent_table,
14501        parent_columns,
14502        on_delete,
14503        on_update,
14504    })
14505}
14506
14507/// v7.6.1 — pick a sentinel "primary key" column from the parent
14508/// table when the FK didn't name parent columns. Picks the first
14509/// single-column unconditional BTree index — that's the closest
14510/// thing SPG has to a PRIMARY KEY today. Self-referencing FKs use
14511/// `local_cols` as the column source.
14512fn pick_pk_index_column(
14513    catalog: &Catalog,
14514    parent_name: &str,
14515    is_self_ref: bool,
14516    local_cols: &[ColumnSchema],
14517) -> Option<usize> {
14518    if is_self_ref {
14519        // Self-ref FK omitted parent columns: pick column 0 by
14520        // convention (no catalog entry yet). Engine will widen this
14521        // when v7.6.7 lands; v7.6.1 only handles the explicit form.
14522        let _ = local_cols;
14523        return Some(0);
14524    }
14525    let parent = catalog.get(parent_name)?;
14526    parent.indices().iter().find_map(|idx| {
14527        if matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14528            && idx.partial_predicate.is_none()
14529            && idx.included_columns.is_empty()
14530            && idx.expression.is_none()
14531        {
14532            Some(idx.column_position)
14533        } else {
14534            None
14535        }
14536    })
14537}
14538
14539/// v7.9.8 / v7.9.10 — resolve the column positions that
14540/// identify a conflict for ON CONFLICT. Returns a Vec of
14541/// column positions (1 element for single-column form, N for
14542/// composite). When the user wrote bare `ON CONFLICT DO …`,
14543/// falls back to the table's first unconditional BTree index
14544/// (always single-column today).
14545fn resolve_on_conflict_columns(
14546    catalog: &Catalog,
14547    table_name: &str,
14548    target: &[String],
14549) -> Result<Vec<usize>, EngineError> {
14550    let table = catalog.get(table_name).ok_or_else(|| {
14551        EngineError::Storage(StorageError::TableNotFound {
14552            name: table_name.into(),
14553        })
14554    })?;
14555    if target.is_empty() {
14556        // v7.13.2 — mailrs round-6 S5 follow-up. Composite UNIQUE
14557        // constraints carry a multi-column tuple; the prior code
14558        // path picked only the leading column of the first BTree
14559        // index, which caused `ON CONFLICT DO NOTHING` to dedup
14560        // by leading column alone (3 rows with same group_id but
14561        // different permission collapsed to 1). PG semantics use
14562        // the full tuple. Prefer a UniquenessConstraint's full
14563        // column list when one exists; fall back to the leading
14564        // BTree column for legacy single-column UNIQUE.
14565        if let Some(uc) = table.schema().uniqueness_constraints.first() {
14566            return Ok(uc.columns.clone());
14567        }
14568        let pos = table
14569            .indices()
14570            .iter()
14571            .find_map(|idx| {
14572                if matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14573                    && idx.partial_predicate.is_none()
14574                    && idx.included_columns.is_empty()
14575                    && idx.expression.is_none()
14576                {
14577                    Some(idx.column_position)
14578                } else {
14579                    None
14580                }
14581            })
14582            .ok_or_else(|| {
14583                EngineError::Unsupported(alloc::format!(
14584                    "ON CONFLICT without target requires a UNIQUE BTree index on {table_name:?}"
14585                ))
14586            })?;
14587        return Ok(alloc::vec![pos]);
14588    }
14589    let mut out = Vec::with_capacity(target.len());
14590    for name in target {
14591        let pos = table
14592            .schema()
14593            .columns
14594            .iter()
14595            .position(|c| c.name == *name)
14596            .ok_or_else(|| {
14597                EngineError::Unsupported(alloc::format!(
14598                    "ON CONFLICT target column {name:?} not found on {table_name:?}"
14599                ))
14600            })?;
14601        out.push(pos);
14602    }
14603    Ok(out)
14604}
14605
14606/// v7.9.8 — check whether the BTree index on `column_pos` of
14607/// `table_name` already has a row with this key.
14608fn on_conflict_key_exists(
14609    catalog: &Catalog,
14610    table_name: &str,
14611    column_pos: usize,
14612    key: &Value,
14613) -> bool {
14614    let Some(table) = catalog.get(table_name) else {
14615        return false;
14616    };
14617    let Some(idx_key) = spg_storage::IndexKey::from_value(key) else {
14618        return false;
14619    };
14620    table.indices().iter().any(|idx| {
14621        matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14622            && idx.column_position == column_pos
14623            && idx.partial_predicate.is_none()
14624            && !idx.lookup_eq(&idx_key).is_empty()
14625    })
14626}
14627
14628/// v7.9.9 / v7.9.10 — look up an existing row's position by
14629/// matching all `column_positions` against the incoming `key`
14630/// tuple. Single-column shape (one column) reduces to the
14631/// canonical PK lookup; composite shapes scan linearly until
14632/// every position matches.
14633fn lookup_row_position_by_keys(
14634    catalog: &Catalog,
14635    table_name: &str,
14636    column_positions: &[usize],
14637    key: &[&Value],
14638) -> Option<usize> {
14639    let table = catalog.get(table_name)?;
14640    table.rows().iter().position(|r| {
14641        column_positions
14642            .iter()
14643            .enumerate()
14644            .all(|(i, &pos)| r.values.get(pos) == Some(key[i]))
14645    })
14646}
14647
14648/// v7.9.10 — does the table already contain a row whose
14649/// `column_positions` tuple equals `key`? Single-column shape
14650/// uses the existing BTree fast path; composite shapes fall
14651/// back to a row scan.
14652fn on_conflict_keys_exist(
14653    catalog: &Catalog,
14654    table_name: &str,
14655    column_positions: &[usize],
14656    key: &[&Value],
14657) -> bool {
14658    if column_positions.len() == 1 {
14659        return on_conflict_key_exists(catalog, table_name, column_positions[0], key[0]);
14660    }
14661    let Some(table) = catalog.get(table_name) else {
14662        return false;
14663    };
14664    table.rows().iter().any(|r| {
14665        column_positions
14666            .iter()
14667            .enumerate()
14668            .all(|(i, &pos)| r.values.get(pos) == Some(key[i]))
14669    })
14670}
14671
14672/// v7.9.9 — apply ON CONFLICT DO UPDATE SET assignments to an
14673/// existing row.
14674///
14675/// `incoming` is the rejected INSERT row (used to resolve
14676/// `EXCLUDED.col` references in the assignment exprs);
14677/// `target_pos` is the position of the existing row in the table.
14678/// Each assignment substitutes `EXCLUDED.col` with the matching
14679/// incoming value, evaluates the resulting expression against
14680/// the existing row, and writes the new value into the
14681/// corresponding column of the returned `Vec<Value>`. If
14682/// `where_` evaluates falsy, returns Ok(None) — PG behaviour:
14683/// the conflicting row is silently kept unchanged.
14684fn apply_on_conflict_assignments(
14685    catalog: &Catalog,
14686    table_name: &str,
14687    target_pos: usize,
14688    incoming: &[Value],
14689    assignments: &[(String, Expr)],
14690    where_: Option<&Expr>,
14691) -> Result<Option<Vec<Value>>, EngineError> {
14692    let table = catalog.get(table_name).ok_or_else(|| {
14693        EngineError::Storage(StorageError::TableNotFound {
14694            name: table_name.into(),
14695        })
14696    })?;
14697    let schema_cols = table.schema().columns.clone();
14698    let existing = table
14699        .rows()
14700        .get(target_pos)
14701        .ok_or_else(|| {
14702            EngineError::Unsupported(alloc::format!(
14703                "ON CONFLICT DO UPDATE: row position {target_pos} out of bounds on {table_name:?}"
14704            ))
14705        })?
14706        .clone();
14707    let ctx = eval::EvalContext::new(&schema_cols, Some(table_name));
14708    // Optional WHERE filter on the conflict row.
14709    if let Some(w) = where_ {
14710        let pred = w.clone();
14711        let pred = substitute_excluded_refs(pred, &schema_cols, incoming);
14712        let v = eval::eval_expr(&pred, &existing, &ctx)?;
14713        if !matches!(v, Value::Bool(true)) {
14714            return Ok(None);
14715        }
14716    }
14717    let mut new_values = existing.values.clone();
14718    for (col_name, expr) in assignments {
14719        let target_idx = schema_cols
14720            .iter()
14721            .position(|c| c.name == *col_name)
14722            .ok_or_else(|| {
14723                EngineError::Eval(EvalError::ColumnNotFound {
14724                    name: col_name.clone(),
14725                })
14726            })?;
14727        let sub = substitute_excluded_refs(expr.clone(), &schema_cols, incoming);
14728        let v = eval::eval_expr(&sub, &existing, &ctx)?;
14729        let coerced = coerce_value(v, schema_cols[target_idx].ty, col_name, target_idx)?;
14730        check_unsigned_range(&coerced, &schema_cols[target_idx], target_idx)?;
14731        new_values[target_idx] = coerced;
14732    }
14733    Ok(Some(new_values))
14734}
14735
14736/// v7.9.9 — walk an `Expr` tree replacing any `Column { qualifier:
14737/// "EXCLUDED", name }` reference with a `Literal` of the matching
14738/// value from the incoming-row vec. Resolution against the
14739/// child-table column list (by name).
14740fn substitute_excluded_refs(expr: Expr, schema_cols: &[ColumnSchema], incoming: &[Value]) -> Expr {
14741    use spg_sql::ast::ColumnName;
14742    match expr {
14743        Expr::Column(ColumnName { qualifier, name })
14744            if qualifier
14745                .as_deref()
14746                .is_some_and(|q| q.eq_ignore_ascii_case("excluded")) =>
14747        {
14748            let pos = schema_cols.iter().position(|c| c.name == name);
14749            match pos {
14750                Some(p) => {
14751                    let v = incoming.get(p).cloned().unwrap_or(Value::Null);
14752                    value_to_literal_expr(v)
14753                        .unwrap_or_else(|_| Expr::Literal(spg_sql::ast::Literal::Null))
14754                }
14755                None => Expr::Column(ColumnName { qualifier, name }),
14756            }
14757        }
14758        Expr::Binary { op, lhs, rhs } => Expr::Binary {
14759            op,
14760            lhs: Box::new(substitute_excluded_refs(*lhs, schema_cols, incoming)),
14761            rhs: Box::new(substitute_excluded_refs(*rhs, schema_cols, incoming)),
14762        },
14763        Expr::Unary { op, expr } => Expr::Unary {
14764            op,
14765            expr: Box::new(substitute_excluded_refs(*expr, schema_cols, incoming)),
14766        },
14767        Expr::FunctionCall { name, args } => Expr::FunctionCall {
14768            name,
14769            args: args
14770                .into_iter()
14771                .map(|a| substitute_excluded_refs(a, schema_cols, incoming))
14772                .collect(),
14773        },
14774        other => other,
14775    }
14776}
14777
14778/// v7.6.2 / v7.6.7 — INSERT-side FK enforcement. For every row
14779/// about to be inserted into `child_table`, every FK declared on
14780/// that table is checked: the row's FK columns must either be
14781/// NULL (SQL spec skip) or match an existing parent row via the
14782/// parent's BTree PK / UNIQUE index.
14783///
14784/// Returns `EngineError::Unsupported` with a `FOREIGN KEY violation`
14785/// payload on first failure.
14786///
14787/// **Self-referencing FKs (v7.6.7 widening):** when `fk.parent_table
14788/// == child_table`, the parent rows visible to this check are
14789///  (a) rows already committed to the table, plus
14790///  (b) earlier rows from the *same* `rows` batch.
14791/// This makes `INSERT INTO tree VALUES (1, NULL), (2, 1), (3, 2)`
14792/// work in a single statement — common pattern for bulk-loading
14793/// hierarchies.
14794/// v7.9.19 — enforce table-level UNIQUE / PRIMARY KEY tuple
14795/// constraints at INSERT time. For each constraint declared on
14796/// the target table, check that no existing row + no earlier row
14797/// in the same batch has the same full-column tuple. NULL in
14798/// any column lifts the row out of the check (SQL spec: NULL
14799/// ≠ NULL for uniqueness). mailrs G1 + G6.
14800fn enforce_uniqueness_inserts(
14801    catalog: &Catalog,
14802    child_table: &str,
14803    constraints: &[spg_storage::UniquenessConstraint],
14804    rows: &[Vec<Value>],
14805) -> Result<(), EngineError> {
14806    if constraints.is_empty() {
14807        return Ok(());
14808    }
14809    let table = catalog.get(child_table).ok_or_else(|| {
14810        EngineError::Storage(StorageError::TableNotFound {
14811            name: child_table.into(),
14812        })
14813    })?;
14814    let schema = table.schema();
14815    for uc in constraints {
14816        for (batch_idx, row_values) in rows.iter().enumerate() {
14817            // v7.17.0 Phase 3.P0-45 — fold each key cell by its
14818            // column's declared Collation before comparing. Phase
14819            // 2.5b wired Collation into GROUP BY / ORDER BY / `=`
14820            // but the UNIQUE-constraint enforcement still compared
14821            // Text byte-wise; a `*_ci` column would let
14822            // `('Foo')` and `('FOO')` coexist when MySQL would
14823            // reject the second. Owned Values so the fold and
14824            // the borrow live in the same scope.
14825            let key: Vec<Value> = uc
14826                .columns
14827                .iter()
14828                .map(|&i| collated_key_cell(&row_values[i], i, schema))
14829                .collect();
14830            let has_null = key.iter().any(|v| matches!(v, Value::Null));
14831            // v7.13.0 — `NULLS NOT DISTINCT` (mailrs round-5 G10,
14832            // PG 15+): two rows whose constrained columns are all
14833            // NULL collide. SQL-standard `NULLS DISTINCT` lets any
14834            // NULL skip the check.
14835            if has_null && !uc.nulls_not_distinct {
14836                continue;
14837            }
14838            // Table-side collision: scan existing rows.
14839            let collides_in_table = table.rows().iter().any(|prow| {
14840                uc.columns.iter().enumerate().all(|(i, &p)| {
14841                    prow.values
14842                        .get(p)
14843                        .is_some_and(|v| collated_key_cell(v, p, schema) == key[i])
14844                })
14845            });
14846            // Batch-side collision: earlier rows in the same INSERT.
14847            let collides_in_batch = rows[..batch_idx].iter().any(|earlier| {
14848                uc.columns.iter().enumerate().all(|(i, &p)| {
14849                    earlier
14850                        .get(p)
14851                        .is_some_and(|v| collated_key_cell(v, p, schema) == key[i])
14852                })
14853            });
14854            if collides_in_table || collides_in_batch {
14855                let kind = if uc.is_primary_key {
14856                    "PRIMARY KEY"
14857                } else {
14858                    "UNIQUE"
14859                };
14860                let col_names: Vec<String> = uc
14861                    .columns
14862                    .iter()
14863                    .map(|&i| table.schema().columns[i].name.clone())
14864                    .collect();
14865                return Err(EngineError::Unsupported(alloc::format!(
14866                    "{kind} violation on {child_table:?} columns {col_names:?}: \
14867                     row #{batch_idx} duplicates an existing key"
14868                )));
14869            }
14870        }
14871    }
14872    Ok(())
14873}
14874
14875/// v7.17.0 Phase 3.P0-45 — return a key cell folded by its column's
14876/// declared `Collation`. For `CaseInsensitive`, fold Text payloads to
14877/// ASCII lowercase (matches Phase 2.5's `*_ci` semantics: ASCII case-
14878/// fold only, non-ASCII bytes stay byte-wise). For `Binary` or non-Text
14879/// values, the cell passes through unchanged. The caller compares the
14880/// folded values with `==`.
14881fn collated_key_cell(
14882    v: &spg_storage::Value,
14883    column_position: usize,
14884    schema: &spg_storage::TableSchema,
14885) -> spg_storage::Value {
14886    match (v, schema.columns.get(column_position).map(|c| c.collation)) {
14887        (spg_storage::Value::Text(s), Some(spg_storage::Collation::CaseInsensitive)) => {
14888            spg_storage::Value::Text(s.to_ascii_lowercase())
14889        }
14890        _ => v.clone(),
14891    }
14892}
14893
14894/// v7.9.29 — `true` iff `v` counts as a truthy SQL value for a
14895/// WHERE-style predicate. NULL → false (three-valued logic
14896/// collapses to "skip this row" for index inclusion). Numeric
14897/// non-zero, BIGINT non-zero, TINYINT non-zero, BOOLEAN true → true.
14898/// Everything else (strings, vectors, JSON, …) is not a valid
14899/// predicate result and surfaces as `false` so a malformed
14900/// predicate degrades to "row not in index" rather than panicking.
14901fn predicate_truthy(v: &spg_storage::Value) -> bool {
14902    use spg_storage::Value as V;
14903    match v {
14904        V::Bool(b) => *b,
14905        V::Int(n) => *n != 0,
14906        V::BigInt(n) => *n != 0,
14907        V::SmallInt(n) => *n != 0,
14908        _ => false,
14909    }
14910}
14911
14912/// v7.9.29 — at CREATE UNIQUE INDEX time, scan the table's
14913/// committed rows for pre-existing duplicates. If any pair of rows
14914/// matches the predicate AND has the same index key, refuse to
14915/// create the index so the user fixes the data before retrying.
14916fn check_existing_unique_violation(
14917    idx: &spg_storage::Index,
14918    schema: &spg_storage::TableSchema,
14919    rows: &[spg_storage::Row],
14920) -> Result<(), EngineError> {
14921    let predicate_expr = match idx.partial_predicate.as_deref() {
14922        Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
14923            EngineError::Unsupported(alloc::format!(
14924                "stored partial predicate {s:?} failed to re-parse: {e:?}"
14925            ))
14926        })?),
14927        None => None,
14928    };
14929    let ctx = eval::EvalContext::new(&schema.columns, None);
14930    let key_positions = unique_key_positions(idx);
14931    let mut seen: alloc::vec::Vec<alloc::vec::Vec<spg_storage::Value>> = alloc::vec::Vec::new();
14932    for row in rows {
14933        if let Some(expr) = &predicate_expr {
14934            let v = eval::eval_expr(expr, row, &ctx).map_err(|e| {
14935                EngineError::Unsupported(alloc::format!(
14936                    "evaluating UNIQUE INDEX predicate against existing row: {e:?}"
14937                ))
14938            })?;
14939            if !predicate_truthy(&v) {
14940                continue;
14941            }
14942        }
14943        let key: alloc::vec::Vec<spg_storage::Value> = key_positions
14944            .iter()
14945            .map(|&p| {
14946                let v = row
14947                    .values
14948                    .get(p)
14949                    .cloned()
14950                    .unwrap_or(spg_storage::Value::Null);
14951                collated_key_cell(&v, p, schema)
14952            })
14953            .collect();
14954        if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
14955            continue;
14956        }
14957        if seen.iter().any(|other| *other == key) {
14958            return Err(EngineError::Unsupported(alloc::format!(
14959                "CREATE UNIQUE INDEX {:?}: existing rows already violate the constraint",
14960                idx.name
14961            )));
14962        }
14963        seen.push(key);
14964    }
14965    Ok(())
14966}
14967
14968/// v7.9.29 — full key tuple for a UNIQUE INDEX (leading +
14969/// extra positions). For single-column indexes this is just
14970/// `[column_position]`.
14971fn unique_key_positions(idx: &spg_storage::Index) -> alloc::vec::Vec<usize> {
14972    let mut out = alloc::vec::Vec::with_capacity(1 + idx.extra_column_positions.len());
14973    out.push(idx.column_position);
14974    out.extend_from_slice(&idx.extra_column_positions);
14975    out
14976}
14977
14978/// v7.9.29 — at INSERT time, walk every `is_unique` index on the
14979/// target table. For each, eval the index's optional predicate
14980/// against (a) the candidate row and (b) every committed row plus
14981/// earlier batch rows; only rows where the predicate is truthy
14982/// participate. A duplicate key among predicate-matching rows is a
14983/// uniqueness violation. NULL keys lift the row out of the check
14984/// (matching PG's "UNIQUE allows multiple NULLs" semantics).
14985fn enforce_unique_index_inserts(
14986    catalog: &Catalog,
14987    table_name: &str,
14988    rows: &[alloc::vec::Vec<spg_storage::Value>],
14989) -> Result<(), EngineError> {
14990    let table = catalog.get(table_name).ok_or_else(|| {
14991        EngineError::Storage(StorageError::TableNotFound {
14992            name: table_name.into(),
14993        })
14994    })?;
14995    let schema = table.schema();
14996    let ctx = eval::EvalContext::new(&schema.columns, None);
14997    for idx in table.indices() {
14998        if !idx.is_unique {
14999            continue;
15000        }
15001        // Re-parse the predicate once per index per batch.
15002        let predicate_expr = match idx.partial_predicate.as_deref() {
15003            Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
15004                EngineError::Unsupported(alloc::format!(
15005                    "UNIQUE INDEX {:?} predicate {s:?} failed to re-parse: {e:?}",
15006                    idx.name
15007                ))
15008            })?),
15009            None => None,
15010        };
15011        let key_positions = unique_key_positions(idx);
15012        let key_of = |values: &[spg_storage::Value]| -> alloc::vec::Vec<spg_storage::Value> {
15013            // v7.17.0 Phase 3.P0-45 — fold per-column collation
15014            // before building the comparison key so a `*_ci`
15015            // column treats `'Foo'` and `'FOO'` as equal.
15016            key_positions
15017                .iter()
15018                .map(|&p| {
15019                    let v = values.get(p).cloned().unwrap_or(spg_storage::Value::Null);
15020                    collated_key_cell(&v, p, schema)
15021                })
15022                .collect()
15023        };
15024        // Helper: does `values` participate in this index? (predicate
15025        // truthy when present.) Wraps `values` into a transient Row
15026        // because eval_expr requires &Row.
15027        let participates = |values: &[spg_storage::Value]| -> Result<bool, EngineError> {
15028            let Some(expr) = &predicate_expr else {
15029                return Ok(true);
15030            };
15031            let tmp_row = spg_storage::Row {
15032                values: values.to_vec(),
15033            };
15034            let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
15035                EngineError::Unsupported(alloc::format!(
15036                    "UNIQUE INDEX {:?} predicate eval: {e:?}",
15037                    idx.name
15038                ))
15039            })?;
15040            Ok(predicate_truthy(&v))
15041        };
15042        for (batch_idx, row_values) in rows.iter().enumerate() {
15043            if !participates(row_values)? {
15044                continue;
15045            }
15046            let key = key_of(row_values);
15047            if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
15048                continue;
15049            }
15050            // Committed-table collision.
15051            for prow in table.rows() {
15052                if !participates(&prow.values)? {
15053                    continue;
15054                }
15055                if key_of(&prow.values) == key {
15056                    return Err(EngineError::Unsupported(alloc::format!(
15057                        "UNIQUE INDEX {:?} violation on {table_name:?}: \
15058                         row #{batch_idx} duplicates an existing key",
15059                        idx.name
15060                    )));
15061                }
15062            }
15063            // Within-batch collision: earlier rows in the same INSERT.
15064            for earlier in &rows[..batch_idx] {
15065                if !participates(earlier)? {
15066                    continue;
15067                }
15068                if key_of(earlier) == key {
15069                    return Err(EngineError::Unsupported(alloc::format!(
15070                        "UNIQUE INDEX {:?} violation on {table_name:?}: \
15071                         row #{batch_idx} duplicates an earlier row in the same batch",
15072                        idx.name
15073                    )));
15074                }
15075            }
15076        }
15077    }
15078    Ok(())
15079}
15080
15081/// v7.13.0 — `UPDATE OF cols` filter helper (mailrs round-5 G7).
15082/// Returns `true` when at least one of `filter_cols` has a
15083/// different value in `new_row` vs `old_row`. Column lookup is
15084/// case-insensitive against `schema_cols`; unknown filter columns
15085/// are treated as "not changed" (the trigger therefore won't
15086/// fire on them — surfacing a parse-time error would be too
15087/// strict for catalog reloads where the schema may have drifted).
15088fn any_column_changed(
15089    filter_cols: &[String],
15090    schema_cols: &[ColumnSchema],
15091    old_row: &Row,
15092    new_row: &Row,
15093) -> bool {
15094    for col_name in filter_cols {
15095        let Some(pos) = schema_cols
15096            .iter()
15097            .position(|c| c.name.eq_ignore_ascii_case(col_name))
15098        else {
15099            continue;
15100        };
15101        let old_v = old_row.values.get(pos);
15102        let new_v = new_row.values.get(pos);
15103        if old_v != new_v {
15104            return true;
15105        }
15106    }
15107    false
15108}
15109
15110/// v7.13.0 — evaluate every CHECK predicate on the schema against
15111/// each candidate row. Mirrors PG semantics: a `false` result
15112/// rejects the mutation; a NULL result *passes* (CHECK rejects
15113/// only on definite-false, not on unknown). mailrs round-5 G3.
15114fn enforce_check_constraints(
15115    catalog: &Catalog,
15116    table_name: &str,
15117    rows: &[alloc::vec::Vec<spg_storage::Value>],
15118) -> Result<(), EngineError> {
15119    let table = catalog.get(table_name).ok_or_else(|| {
15120        EngineError::Storage(StorageError::TableNotFound {
15121            name: table_name.into(),
15122        })
15123    })?;
15124    let schema = table.schema();
15125    // v7.17.0 Phase 1.5 — domain-level CHECKs are enforced in
15126    // parallel with table-level CHECKs. Collect both lists up
15127    // front; if neither exists we early-out.
15128    let mut domain_checks_per_col: alloc::vec::Vec<(usize, alloc::vec::Vec<Expr>)> =
15129        alloc::vec::Vec::new();
15130    for (idx, col) in schema.columns.iter().enumerate() {
15131        let Some(dname) = &col.user_domain_type else {
15132            continue;
15133        };
15134        let Some(dom) = catalog.domain_types().get(dname) else {
15135            continue;
15136        };
15137        let mut parsed_for_col: alloc::vec::Vec<Expr> =
15138            alloc::vec::Vec::with_capacity(dom.checks.len());
15139        for src in &dom.checks {
15140            let expr = spg_sql::parser::parse_expression(src).map_err(|e| {
15141                EngineError::Unsupported(alloc::format!(
15142                    "DOMAIN {dname:?} CHECK ({src:?}) on column {:?}: re-parse failed: {e:?}",
15143                    col.name
15144                ))
15145            })?;
15146            parsed_for_col.push(expr);
15147        }
15148        if !parsed_for_col.is_empty() {
15149            domain_checks_per_col.push((idx, parsed_for_col));
15150        }
15151    }
15152    if schema.checks.is_empty() && domain_checks_per_col.is_empty() {
15153        return Ok(());
15154    }
15155    let ctx = eval::EvalContext::new(&schema.columns, None);
15156    let mut parsed: alloc::vec::Vec<(usize, Expr)> = alloc::vec::Vec::new();
15157    for (i, src) in schema.checks.iter().enumerate() {
15158        let expr = spg_sql::parser::parse_expression(src).map_err(|e| {
15159            EngineError::Unsupported(alloc::format!(
15160                "CHECK constraint #{i} on {table_name:?} ({src:?}) failed to re-parse: {e:?}"
15161            ))
15162        })?;
15163        parsed.push((i, expr));
15164    }
15165    for (batch_idx, row_values) in rows.iter().enumerate() {
15166        let tmp_row = spg_storage::Row {
15167            values: row_values.clone(),
15168        };
15169        for (i, expr) in &parsed {
15170            let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
15171                EngineError::Unsupported(alloc::format!(
15172                    "CHECK constraint #{i} on {table_name:?} eval at row #{batch_idx}: {e:?}"
15173                ))
15174            })?;
15175            // PG: NULL passes (CHECK rejects on definite-false only).
15176            if matches!(v, spg_storage::Value::Bool(false)) {
15177                return Err(EngineError::Unsupported(alloc::format!(
15178                    "CHECK constraint violation on {table_name:?} (row #{batch_idx}): {:?}",
15179                    schema.checks[*i]
15180                )));
15181            }
15182        }
15183        // v7.17.0 Phase 1.5 — domain-level CHECKs. Each CHECK
15184        // expression references VALUE as a column-name; we
15185        // substitute the per-row cell into the eval context by
15186        // synthesising a single-column row of just that value
15187        // under a temporary `value` column schema.
15188        for (col_idx, checks) in &domain_checks_per_col {
15189            let cell = row_values
15190                .get(*col_idx)
15191                .cloned()
15192                .unwrap_or(spg_storage::Value::Null);
15193            let synth_cols = alloc::vec![spg_storage::ColumnSchema::new(
15194                "value",
15195                schema.columns[*col_idx].ty,
15196                schema.columns[*col_idx].nullable,
15197            )];
15198            let synth_ctx = eval::EvalContext::new(&synth_cols, None);
15199            let synth_row = spg_storage::Row {
15200                values: alloc::vec![cell],
15201            };
15202            for (ci, expr) in checks.iter().enumerate() {
15203                let v = eval::eval_expr(expr, &synth_row, &synth_ctx).map_err(|e| {
15204                    EngineError::Unsupported(alloc::format!(
15205                        "DOMAIN CHECK #{ci} on column {:?} eval at row #{batch_idx}: {e:?}",
15206                        schema.columns[*col_idx].name
15207                    ))
15208                })?;
15209                if matches!(v, spg_storage::Value::Bool(false)) {
15210                    return Err(EngineError::Unsupported(alloc::format!(
15211                        "DOMAIN CHECK violation on column {:?} (row #{batch_idx})",
15212                        schema.columns[*col_idx].name
15213                    )));
15214                }
15215            }
15216        }
15217    }
15218    Ok(())
15219}
15220
15221fn enforce_fk_inserts(
15222    catalog: &Catalog,
15223    child_table: &str,
15224    fks: &[spg_storage::ForeignKeyConstraint],
15225    rows: &[Vec<Value>],
15226) -> Result<(), EngineError> {
15227    for fk in fks {
15228        let parent_is_self = fk.parent_table == child_table;
15229        let parent = if parent_is_self {
15230            // Self-ref: read the current state of the same table.
15231            // The mut borrow on child has been dropped by the caller.
15232            catalog.get(child_table).ok_or_else(|| {
15233                EngineError::Storage(StorageError::TableNotFound {
15234                    name: child_table.into(),
15235                })
15236            })?
15237        } else {
15238            catalog.get(&fk.parent_table).ok_or_else(|| {
15239                EngineError::Storage(StorageError::TableNotFound {
15240                    name: fk.parent_table.clone(),
15241                })
15242            })?
15243        };
15244        for (batch_idx, row_values) in rows.iter().enumerate() {
15245            // Single-column FK fast path: try the parent's BTree
15246            // index for an O(log n) lookup. Composite FKs fall back
15247            // to a parent-row scan.
15248            if fk.local_columns.len() == 1 {
15249                let v = &row_values[fk.local_columns[0]];
15250                if matches!(v, Value::Null) {
15251                    continue;
15252                }
15253                let parent_col = fk.parent_columns[0];
15254                let key = spg_storage::IndexKey::from_value(v).ok_or_else(|| {
15255                    EngineError::Unsupported(alloc::format!(
15256                        "FOREIGN KEY column value of type {:?} is not index-eligible",
15257                        v.data_type()
15258                    ))
15259                })?;
15260                let present_committed = parent.indices().iter().any(|idx| {
15261                    matches!(idx.kind, spg_storage::IndexKind::BTree(_))
15262                        && idx.column_position == parent_col
15263                        && idx.partial_predicate.is_none()
15264                        && !idx.lookup_eq(&key).is_empty()
15265                });
15266                // v7.6.7 self-ref widening: also accept a match
15267                // against earlier rows in this same batch when the
15268                // FK points at the table being inserted into.
15269                let present_in_batch = parent_is_self
15270                    && rows[..batch_idx]
15271                        .iter()
15272                        .any(|earlier| earlier.get(parent_col) == Some(v));
15273                if !(present_committed || present_in_batch) {
15274                    return Err(EngineError::Unsupported(alloc::format!(
15275                        "FOREIGN KEY violation: no parent row in {:?} where {} = {:?}",
15276                        fk.parent_table,
15277                        parent
15278                            .schema()
15279                            .columns
15280                            .get(parent_col)
15281                            .map_or("?", |c| c.name.as_str()),
15282                        v,
15283                    )));
15284                }
15285            } else {
15286                // Composite FK: scan parent rows. v7.6.7 also
15287                // accepts a match against earlier rows in the same
15288                // batch (self-ref bulk-loading of hierarchies).
15289                if fk
15290                    .local_columns
15291                    .iter()
15292                    .all(|&i| matches!(row_values.get(i), Some(Value::Null)))
15293                {
15294                    continue;
15295                }
15296                let local: Vec<&Value> = fk.local_columns.iter().map(|&i| &row_values[i]).collect();
15297                let parent_match_committed = parent.rows().iter().any(|prow| {
15298                    fk.parent_columns
15299                        .iter()
15300                        .enumerate()
15301                        .all(|(i, &pi)| prow.values.get(pi) == Some(local[i]))
15302                });
15303                let parent_match_in_batch = parent_is_self
15304                    && rows[..batch_idx].iter().any(|earlier| {
15305                        fk.parent_columns
15306                            .iter()
15307                            .enumerate()
15308                            .all(|(i, &pi)| earlier.get(pi) == Some(local[i]))
15309                    });
15310                if !(parent_match_committed || parent_match_in_batch) {
15311                    return Err(EngineError::Unsupported(alloc::format!(
15312                        "FOREIGN KEY violation: no parent row in {:?} matching composite key",
15313                        fk.parent_table,
15314                    )));
15315                }
15316            }
15317        }
15318    }
15319    Ok(())
15320}
15321
15322/// v7.6.4 / v7.6.5 — one step of the FK action plan computed for a
15323/// DELETE on a parent. The plan is a list of these steps, stacked
15324/// across the FK graph by `plan_fk_parent_deletions`.
15325#[derive(Debug, Clone)]
15326struct FkChildStep {
15327    child_table: String,
15328    action: FkChildAction,
15329}
15330
15331#[derive(Debug, Clone)]
15332enum FkChildAction {
15333    /// CASCADE — remove these rows. Sorted, deduplicated positions.
15334    Delete { positions: Vec<usize> },
15335    /// SET NULL — for each (row, column) in the flat list, write
15336    /// NULL into that child cell. Multiple FKs on the same row may
15337    /// produce overlapping entries (deduped at plan time).
15338    SetNull {
15339        positions: Vec<usize>,
15340        columns: Vec<usize>,
15341    },
15342    /// SET DEFAULT — same shape as SetNull but writes the column's
15343    /// declared DEFAULT value (resolved at plan time). Columns
15344    /// without a DEFAULT raise an error during planning.
15345    SetDefault {
15346        positions: Vec<usize>,
15347        columns: Vec<usize>,
15348        defaults: Vec<Value>,
15349    },
15350}
15351
15352/// v7.6.3 → v7.6.5 — plan FK fallout for a DELETE on a parent table.
15353///
15354/// Walks every table in the catalog looking for FKs whose
15355/// `parent_table` is `parent_table_name`. For each such FK + each
15356/// to-be-deleted parent row:
15357///
15358///   - RESTRICT / NoAction → error, no plan returned
15359///   - CASCADE → child rows get scheduled for deletion; recursive
15360///   - SetNull → child FK column(s) scheduled to be NULL-ed.
15361///     Verified NULL-able at plan time.
15362///   - SetDefault → child FK column(s) scheduled to be reset to
15363///     their declared DEFAULT. Columns without a DEFAULT raise.
15364///
15365/// SET NULL / SET DEFAULT do NOT cascade further — the child row
15366/// stays; only one of its columns mutates.
15367fn plan_fk_parent_deletions(
15368    catalog: &Catalog,
15369    parent_table_name: &str,
15370    to_delete_positions: &[usize],
15371    to_delete_rows: &[Vec<Value>],
15372) -> Result<Vec<FkChildStep>, EngineError> {
15373    use alloc::collections::{BTreeMap, BTreeSet};
15374    if to_delete_rows.is_empty() {
15375        return Ok(Vec::new());
15376    }
15377    let mut delete_plan: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
15378    // setnull / setdefault keyed by child_table → (row_idx, col_idx) → optional default
15379    let mut setnull_plan: BTreeMap<String, BTreeSet<(usize, usize)>> = BTreeMap::new();
15380    let mut setdefault_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
15381    let mut visited: BTreeSet<(String, usize)> = BTreeSet::new();
15382    for &p in to_delete_positions {
15383        visited.insert((parent_table_name.to_string(), p));
15384    }
15385    let mut work: Vec<(String, Vec<Value>)> = to_delete_rows
15386        .iter()
15387        .map(|r| (parent_table_name.to_string(), r.clone()))
15388        .collect();
15389    while let Some((cur_parent, parent_row)) = work.pop() {
15390        for child_name in catalog.table_names() {
15391            let child = catalog
15392                .get(&child_name)
15393                .expect("table_names → catalog.get round-trip is total");
15394            for fk in &child.schema().foreign_keys {
15395                if fk.parent_table != cur_parent {
15396                    continue;
15397                }
15398                let parent_key: Vec<&Value> = fk
15399                    .parent_columns
15400                    .iter()
15401                    .map(|&pi| &parent_row[pi])
15402                    .collect();
15403                if parent_key.iter().any(|v| matches!(v, Value::Null)) {
15404                    continue;
15405                }
15406                for (child_row_idx, child_row) in child.rows().iter().enumerate() {
15407                    if child_name == cur_parent
15408                        && visited.contains(&(child_name.clone(), child_row_idx))
15409                    {
15410                        continue;
15411                    }
15412                    let matches_key = fk
15413                        .local_columns
15414                        .iter()
15415                        .enumerate()
15416                        .all(|(i, &li)| child_row.values.get(li) == Some(parent_key[i]));
15417                    if !matches_key {
15418                        continue;
15419                    }
15420                    match fk.on_delete {
15421                        spg_storage::FkAction::Restrict | spg_storage::FkAction::NoAction => {
15422                            return Err(EngineError::Unsupported(alloc::format!(
15423                                "FOREIGN KEY violation: DELETE on {cur_parent:?} is \
15424                                 restricted by FK from {child_name:?}.{:?}",
15425                                fk.local_columns,
15426                            )));
15427                        }
15428                        spg_storage::FkAction::Cascade => {
15429                            if visited.insert((child_name.clone(), child_row_idx)) {
15430                                delete_plan
15431                                    .entry(child_name.clone())
15432                                    .or_default()
15433                                    .insert(child_row_idx);
15434                                work.push((child_name.clone(), child_row.values.clone()));
15435                            }
15436                        }
15437                        spg_storage::FkAction::SetNull => {
15438                            // Verify every local FK column is NULL-able.
15439                            for &li in &fk.local_columns {
15440                                let col = child.schema().columns.get(li).ok_or_else(|| {
15441                                    EngineError::Unsupported(alloc::format!(
15442                                        "FK local column {li} missing in {child_name:?}"
15443                                    ))
15444                                })?;
15445                                if !col.nullable {
15446                                    return Err(EngineError::Unsupported(alloc::format!(
15447                                        "FOREIGN KEY ON DELETE SET NULL: column \
15448                                         {child_name:?}.{:?} is NOT NULL — cannot SET NULL",
15449                                        col.name,
15450                                    )));
15451                                }
15452                            }
15453                            let entry = setnull_plan.entry(child_name.clone()).or_default();
15454                            for &li in &fk.local_columns {
15455                                entry.insert((child_row_idx, li));
15456                            }
15457                        }
15458                        spg_storage::FkAction::SetDefault => {
15459                            // Resolve the DEFAULT for every local FK col.
15460                            let entry = setdefault_plan.entry(child_name.clone()).or_default();
15461                            for &li in &fk.local_columns {
15462                                let col = child.schema().columns.get(li).ok_or_else(|| {
15463                                    EngineError::Unsupported(alloc::format!(
15464                                        "FK local column {li} missing in {child_name:?}"
15465                                    ))
15466                                })?;
15467                                let default = col.default.clone().ok_or_else(|| {
15468                                    EngineError::Unsupported(alloc::format!(
15469                                        "FOREIGN KEY ON DELETE SET DEFAULT: column \
15470                                         {child_name:?}.{:?} has no DEFAULT declared",
15471                                        col.name,
15472                                    ))
15473                                })?;
15474                                entry.insert((child_row_idx, li), default);
15475                            }
15476                        }
15477                    }
15478                }
15479            }
15480        }
15481    }
15482    // Flatten the three plans into the ordered `FkChildStep` list.
15483    // Deletes are applied last per child (after any null/default
15484    // re-writes on the same child) so a child row that's both
15485    // re-written and then cascade-deleted only ends up deleted —
15486    // but in v7.6.5 SetNull/Cascade never overlap on the same row
15487    // (a single FK chooses exactly one action), so the order is
15488    // mostly a precaution.
15489    let mut steps: Vec<FkChildStep> = Vec::new();
15490    for (child_table, entries) in setnull_plan {
15491        let (positions, columns): (Vec<usize>, Vec<usize>) = entries.into_iter().unzip();
15492        steps.push(FkChildStep {
15493            child_table,
15494            action: FkChildAction::SetNull { positions, columns },
15495        });
15496    }
15497    for (child_table, entries) in setdefault_plan {
15498        let mut positions = Vec::with_capacity(entries.len());
15499        let mut columns = Vec::with_capacity(entries.len());
15500        let mut defaults = Vec::with_capacity(entries.len());
15501        for ((p, c), v) in entries {
15502            positions.push(p);
15503            columns.push(c);
15504            defaults.push(v);
15505        }
15506        steps.push(FkChildStep {
15507            child_table,
15508            action: FkChildAction::SetDefault {
15509                positions,
15510                columns,
15511                defaults,
15512            },
15513        });
15514    }
15515    for (child_table, positions) in delete_plan {
15516        steps.push(FkChildStep {
15517            child_table,
15518            action: FkChildAction::Delete {
15519                positions: positions.into_iter().collect(),
15520            },
15521        });
15522    }
15523    Ok(steps)
15524}
15525
15526/// v7.6.6 — plan FK fallout for an UPDATE that mutates parent-side
15527/// PK/UNIQUE columns. Walks every other table whose FK references
15528/// `parent_table_name`; for each FK whose parent_columns overlap a
15529/// mutated column, decides the action by `fk.on_update`.
15530///
15531///   - RESTRICT / NoAction → error if any child references the OLD
15532///     value
15533///   - CASCADE → child FK columns get rewritten to the NEW parent
15534///     value (a SetNull-style update step with the new value)
15535///   - SetNull → child FK columns set to NULL
15536///   - SetDefault → child FK columns set to declared default
15537///
15538/// `plan_with_old` is `(row_position, old_values, new_values)` so
15539/// the planner can detect "did this row's parent key actually
15540/// change?" — only rows where at least one referenced parent
15541/// column moved trigger inbound work.
15542fn plan_fk_parent_updates(
15543    catalog: &Catalog,
15544    parent_table_name: &str,
15545    plan_with_old: &[(usize, Vec<Value>, Vec<Value>)],
15546) -> Result<Vec<FkChildStep>, EngineError> {
15547    use alloc::collections::BTreeMap;
15548    if plan_with_old.is_empty() {
15549        return Ok(Vec::new());
15550    }
15551    // For each child table we may touch, build per-child step
15552    // lists. UPDATE never deletes children — `delete_plan` stays
15553    // empty here but is kept structurally aligned with
15554    // `plan_fk_parent_deletions` for future use.
15555    let delete_plan: BTreeMap<String, alloc::collections::BTreeSet<usize>> = BTreeMap::new();
15556    let mut setnull_plan: BTreeMap<String, alloc::collections::BTreeSet<(usize, usize)>> =
15557        BTreeMap::new();
15558    let mut setdefault_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
15559    // Cascade-update plan: child_table → row_idx → col_idx → new_value
15560    let mut cascade_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
15561
15562    for child_name in catalog.table_names() {
15563        let child = catalog
15564            .get(&child_name)
15565            .expect("table_names → catalog.get total");
15566        for fk in &child.schema().foreign_keys {
15567            if fk.parent_table != parent_table_name {
15568                continue;
15569            }
15570            for (_pos, old_row, new_row) in plan_with_old {
15571                // Did any parent FK column change?
15572                let key_changed = fk
15573                    .parent_columns
15574                    .iter()
15575                    .any(|&pi| old_row.get(pi) != new_row.get(pi));
15576                if !key_changed {
15577                    continue;
15578                }
15579                // The OLD parent key — used to find referring children.
15580                let old_key: Vec<&Value> =
15581                    fk.parent_columns.iter().map(|&pi| &old_row[pi]).collect();
15582                if old_key.iter().any(|v| matches!(v, Value::Null)) {
15583                    // NULL parent has no children — skip.
15584                    continue;
15585                }
15586                let new_key: Vec<&Value> =
15587                    fk.parent_columns.iter().map(|&pi| &new_row[pi]).collect();
15588                for (child_row_idx, child_row) in child.rows().iter().enumerate() {
15589                    // Self-ref same-row updates: a row updating its
15590                    // own PK doesn't restrict itself.
15591                    if child_name == parent_table_name
15592                        && plan_with_old.iter().any(|(p, _, _)| *p == child_row_idx)
15593                    {
15594                        continue;
15595                    }
15596                    let matches_key = fk
15597                        .local_columns
15598                        .iter()
15599                        .enumerate()
15600                        .all(|(i, &li)| child_row.values.get(li) == Some(old_key[i]));
15601                    if !matches_key {
15602                        continue;
15603                    }
15604                    match fk.on_update {
15605                        spg_storage::FkAction::Restrict | spg_storage::FkAction::NoAction => {
15606                            return Err(EngineError::Unsupported(alloc::format!(
15607                                "FOREIGN KEY violation: UPDATE on {parent_table_name:?} PK is \
15608                                 restricted by FK from {child_name:?}.{:?}",
15609                                fk.local_columns,
15610                            )));
15611                        }
15612                        spg_storage::FkAction::Cascade => {
15613                            // Rewrite child FK columns to new key.
15614                            let entry = cascade_plan.entry(child_name.clone()).or_default();
15615                            for (i, &li) in fk.local_columns.iter().enumerate() {
15616                                entry.insert((child_row_idx, li), new_key[i].clone());
15617                            }
15618                        }
15619                        spg_storage::FkAction::SetNull => {
15620                            for &li in &fk.local_columns {
15621                                let col = child.schema().columns.get(li).ok_or_else(|| {
15622                                    EngineError::Unsupported(alloc::format!(
15623                                        "FK local column {li} missing in {child_name:?}"
15624                                    ))
15625                                })?;
15626                                if !col.nullable {
15627                                    return Err(EngineError::Unsupported(alloc::format!(
15628                                        "FOREIGN KEY ON UPDATE SET NULL: column \
15629                                         {child_name:?}.{:?} is NOT NULL",
15630                                        col.name,
15631                                    )));
15632                                }
15633                            }
15634                            let entry = setnull_plan.entry(child_name.clone()).or_default();
15635                            for &li in &fk.local_columns {
15636                                entry.insert((child_row_idx, li));
15637                            }
15638                        }
15639                        spg_storage::FkAction::SetDefault => {
15640                            let entry = setdefault_plan.entry(child_name.clone()).or_default();
15641                            for &li in &fk.local_columns {
15642                                let col = child.schema().columns.get(li).ok_or_else(|| {
15643                                    EngineError::Unsupported(alloc::format!(
15644                                        "FK local column {li} missing in {child_name:?}"
15645                                    ))
15646                                })?;
15647                                let default = col.default.clone().ok_or_else(|| {
15648                                    EngineError::Unsupported(alloc::format!(
15649                                        "FOREIGN KEY ON UPDATE SET DEFAULT: column \
15650                                         {child_name:?}.{:?} has no DEFAULT",
15651                                        col.name,
15652                                    ))
15653                                })?;
15654                                entry.insert((child_row_idx, li), default);
15655                            }
15656                        }
15657                    }
15658                }
15659            }
15660        }
15661    }
15662    // Flatten into FkChildStep list. UPDATE doesn't produce
15663    // DeleteSteps (CASCADE on UPDATE just rewrites FK values).
15664    let mut steps: Vec<FkChildStep> = Vec::new();
15665    for (child_table, entries) in cascade_plan {
15666        let mut positions = Vec::with_capacity(entries.len());
15667        let mut columns = Vec::with_capacity(entries.len());
15668        let mut defaults = Vec::with_capacity(entries.len());
15669        for ((p, c), v) in entries {
15670            positions.push(p);
15671            columns.push(c);
15672            defaults.push(v);
15673        }
15674        // We reuse `FkChildAction::SetDefault` for cascade-update:
15675        // both shapes are "write a known value into specific cells"
15676        // — `apply_per_cell_writes` doesn't care whether the value
15677        // came from a DEFAULT declaration or a new parent key.
15678        steps.push(FkChildStep {
15679            child_table,
15680            action: FkChildAction::SetDefault {
15681                positions,
15682                columns,
15683                defaults,
15684            },
15685        });
15686    }
15687    for (child_table, entries) in setnull_plan {
15688        let (positions, columns): (Vec<usize>, Vec<usize>) = entries.into_iter().unzip();
15689        steps.push(FkChildStep {
15690            child_table,
15691            action: FkChildAction::SetNull { positions, columns },
15692        });
15693    }
15694    for (child_table, entries) in setdefault_plan {
15695        let mut positions = Vec::with_capacity(entries.len());
15696        let mut columns = Vec::with_capacity(entries.len());
15697        let mut defaults = Vec::with_capacity(entries.len());
15698        for ((p, c), v) in entries {
15699            positions.push(p);
15700            columns.push(c);
15701            defaults.push(v);
15702        }
15703        steps.push(FkChildStep {
15704            child_table,
15705            action: FkChildAction::SetDefault {
15706                positions,
15707                columns,
15708                defaults,
15709            },
15710        });
15711    }
15712    let _ = delete_plan; // UPDATE never deletes children.
15713    Ok(steps)
15714}
15715
15716/// v7.6.5 — apply one FK child step to the catalog. Encapsulates
15717/// the three action variants so the DELETE executor stays a
15718/// simple loop over the planned steps.
15719fn apply_fk_child_step(catalog: &mut Catalog, step: &FkChildStep) -> Result<(), EngineError> {
15720    let child = catalog.get_mut(&step.child_table).ok_or_else(|| {
15721        EngineError::Storage(StorageError::TableNotFound {
15722            name: step.child_table.clone(),
15723        })
15724    })?;
15725    match &step.action {
15726        FkChildAction::Delete { positions } => {
15727            let _ = child.delete_rows(positions);
15728        }
15729        FkChildAction::SetNull { positions, columns } => {
15730            apply_per_cell_writes(child, positions, columns, |_| Value::Null)?;
15731        }
15732        FkChildAction::SetDefault {
15733            positions,
15734            columns,
15735            defaults,
15736        } => {
15737            apply_per_cell_writes(child, positions, columns, |i| defaults[i].clone())?;
15738        }
15739    }
15740    Ok(())
15741}
15742
15743/// v7.6.5 — write new values into selected child cells via
15744/// `Table::update_row` (the catalog's existing UPDATE entry).
15745/// Groups writes by row position so multi-column updates on the
15746/// same row only call `update_row` once. `value_for(i)` produces
15747/// the new value for the i-th (position, column) entry.
15748fn apply_per_cell_writes(
15749    child: &mut spg_storage::Table,
15750    positions: &[usize],
15751    columns: &[usize],
15752    mut value_for: impl FnMut(usize) -> Value,
15753) -> Result<(), EngineError> {
15754    use alloc::collections::BTreeMap;
15755    let mut by_row: BTreeMap<usize, Vec<(usize, Value)>> = BTreeMap::new();
15756    for i in 0..positions.len() {
15757        by_row
15758            .entry(positions[i])
15759            .or_default()
15760            .push((columns[i], value_for(i)));
15761    }
15762    for (pos, mutations) in by_row {
15763        let mut new_values = child.rows()[pos].values.clone();
15764        for (col, v) in mutations {
15765            if let Some(slot) = new_values.get_mut(col) {
15766                *slot = v;
15767            }
15768        }
15769        child
15770            .update_row(pos, new_values)
15771            .map_err(EngineError::Storage)?;
15772    }
15773    Ok(())
15774}
15775
15776fn fk_action_sql_to_storage(a: spg_sql::ast::FkAction) -> spg_storage::FkAction {
15777    match a {
15778        spg_sql::ast::FkAction::Restrict => spg_storage::FkAction::Restrict,
15779        spg_sql::ast::FkAction::Cascade => spg_storage::FkAction::Cascade,
15780        spg_sql::ast::FkAction::SetNull => spg_storage::FkAction::SetNull,
15781        spg_sql::ast::FkAction::SetDefault => spg_storage::FkAction::SetDefault,
15782        spg_sql::ast::FkAction::NoAction => spg_storage::FkAction::NoAction,
15783    }
15784}
15785
15786/// v7.9.21 — resolve a column's DEFAULT for INSERT-time
15787/// default-fill. Free fn (rather than `&self`) so callers
15788/// with an active `&mut Table` borrow can still use it.
15789/// Literal defaults take the cached path (`col.default`);
15790/// runtime defaults hit `clock_fn` at each call. mailrs G4.
15791fn resolve_column_default_free(
15792    col: &ColumnSchema,
15793    clock_fn: Option<ClockFn>,
15794) -> Result<Value, EngineError> {
15795    if let Some(rt) = &col.runtime_default {
15796        return eval_runtime_default_free(rt, col.ty, clock_fn);
15797    }
15798    Ok(col.default.clone().unwrap_or(Value::Null))
15799}
15800
15801fn eval_runtime_default_free(
15802    rt: &str,
15803    ty: DataType,
15804    clock_fn: Option<ClockFn>,
15805) -> Result<Value, EngineError> {
15806    let s = rt.trim().to_ascii_lowercase();
15807    // v7.17.0 Phase 2.1 — also strip `(N)` precision suffix
15808    // so MySQL `CURRENT_TIMESTAMP(6)` resolves the same as
15809    // bare `CURRENT_TIMESTAMP`. SPG stores TIMESTAMP at fixed
15810    // microsecond resolution; the precision modifier is
15811    // parser-only.
15812    let with_no_parens = s.trim_end_matches("()");
15813    let canonical: &str = if let Some(open_idx) = with_no_parens.find('(') {
15814        if with_no_parens.ends_with(')') {
15815            &with_no_parens[..open_idx]
15816        } else {
15817            with_no_parens
15818        }
15819    } else {
15820        with_no_parens
15821    };
15822    let now_us = match clock_fn {
15823        Some(f) => f(),
15824        None => 0,
15825    };
15826    let v = match canonical {
15827        "now" | "current_timestamp" | "localtimestamp" => Value::Timestamp(now_us),
15828        "current_date" => Value::Date((now_us / 86_400_000_000) as i32),
15829        "current_time" | "localtime" => Value::Timestamp(now_us),
15830        // v7.17.0 — UUID generators in DEFAULT clauses. Required
15831        // for the canonical Django / Rails / Hibernate `id UUID
15832        // PRIMARY KEY DEFAULT gen_random_uuid()` pattern. Each
15833        // INSERT evaluates the function fresh; the per-row UUID
15834        // is the storage value, not a cached literal.
15835        "gen_random_uuid" | "uuid_generate_v4" => Value::Uuid(eval::gen_random_uuid_bytes()),
15836        other => {
15837            return Err(EngineError::Unsupported(alloc::format!(
15838                "runtime DEFAULT expression {other:?} not supported \
15839                 (v7.17.0 whitelist: now() / current_timestamp / \
15840                 current_date / current_time / localtimestamp / \
15841                 localtime / gen_random_uuid() / \
15842                 uuid_generate_v4())"
15843            )));
15844        }
15845    };
15846    coerce_value(v, ty, "DEFAULT", 0)
15847}
15848
15849/// v7.9.21 — true when a DEFAULT expression needs INSERT-time
15850/// evaluation rather than being cacheable as a literal Value.
15851/// FunctionCall is the immediate case (`now()`,
15852/// `current_timestamp`). Literal expressions and simple sign-
15853/// flipped numerics still take the static-cache path.
15854fn is_runtime_default_expr(expr: &Expr) -> bool {
15855    match expr {
15856        Expr::FunctionCall { .. } => true,
15857        Expr::Unary { expr, .. } => is_runtime_default_expr(expr),
15858        _ => false,
15859    }
15860}
15861
15862/// v7.17.0 Phase 1.4 — INSERT/UPDATE-time enum label check. When
15863/// `col_idx` has a registered label list, the cell value must be
15864/// NULL or one of the labels (case-sensitive per PG).
15865/// v7.17.0 Phase 3.P0-37 — validate + canonicalise a MySQL inline
15866/// SET cell. For non-SET columns this is a no-op pass-through.
15867///
15868/// Semantics:
15869///   * NULL preserved.
15870///   * Empty string → `''` (zero flags).
15871///   * Otherwise split on ',', trim each token, validate every
15872///     token against the column's variant list (error on miss),
15873///     de-dup, then re-emit in DEFINITION order joined by ','.
15874fn canonicalize_set_value(
15875    lookup: &alloc::collections::BTreeMap<usize, Vec<String>>,
15876    col_idx: usize,
15877    col_name: &str,
15878    value: Value,
15879) -> Result<Value, EngineError> {
15880    let Some(variants) = lookup.get(&col_idx) else {
15881        return Ok(value);
15882    };
15883    match value {
15884        Value::Null => Ok(Value::Null),
15885        Value::Text(s) => {
15886            if s.is_empty() {
15887                return Ok(Value::Text(alloc::string::String::new()));
15888            }
15889            // Collect a presence-set of variant indices to keep
15890            // definition order + handle de-dup in one pass.
15891            let mut present = alloc::vec![false; variants.len()];
15892            for raw in s.split(',') {
15893                let tok = raw.trim();
15894                if tok.is_empty() {
15895                    continue;
15896                }
15897                let idx = variants.iter().position(|v| v == tok).ok_or_else(|| {
15898                    EngineError::Unsupported(alloc::format!(
15899                        "column {col_name:?}: invalid SET token {tok:?}; \
15900                         allowed: {variants:?}"
15901                    ))
15902                })?;
15903                present[idx] = true;
15904            }
15905            // Re-emit in definition order.
15906            let mut out = alloc::string::String::new();
15907            let mut first = true;
15908            for (i, keep) in present.iter().enumerate() {
15909                if !keep {
15910                    continue;
15911                }
15912                if !first {
15913                    out.push(',');
15914                }
15915                first = false;
15916                out.push_str(&variants[i]);
15917            }
15918            Ok(Value::Text(out))
15919        }
15920        other => Err(EngineError::Unsupported(alloc::format!(
15921            "column {col_name:?}: SET-typed column expects TEXT, got {:?}",
15922            other.data_type()
15923        ))),
15924    }
15925}
15926
15927fn enforce_enum_label(
15928    lookup: &alloc::collections::BTreeMap<usize, Vec<String>>,
15929    col_idx: usize,
15930    col_name: &str,
15931    value: &Value,
15932) -> Result<(), EngineError> {
15933    if let Some(labels) = lookup.get(&col_idx) {
15934        match value {
15935            Value::Null => Ok(()),
15936            Value::Text(s) => {
15937                if labels.iter().any(|l| l == s) {
15938                    Ok(())
15939                } else {
15940                    Err(EngineError::Unsupported(alloc::format!(
15941                        "column {col_name:?}: invalid enum label {s:?}; allowed: {labels:?}"
15942                    )))
15943                }
15944            }
15945            other => Err(EngineError::Unsupported(alloc::format!(
15946                "column {col_name:?}: enum-typed column expects TEXT, got {:?}",
15947                other.data_type()
15948            ))),
15949        }
15950    } else {
15951        Ok(())
15952    }
15953}
15954
15955fn column_def_to_schema(c: ColumnDef) -> Result<ColumnSchema, EngineError> {
15956    let ty = column_type_to_data_type(c.ty);
15957    let mut schema = ColumnSchema::new(c.name.clone(), ty, c.nullable);
15958    // user_type_ref is the raw ident the parser couldn't resolve
15959    // to a built-in; classification into enum vs domain happens
15960    // at exec_create_table where we have catalog access. We
15961    // park it temporarily as user_enum_type and the engine
15962    // promotes domain bindings to user_domain_type before the
15963    // table is stored.
15964    if let Some(name) = c.user_type_ref {
15965        schema.user_enum_type = Some(name);
15966    }
15967    // v7.17.0 Phase 2.1 — render the ON UPDATE expression to
15968    // canonical text (the engine re-parses at UPDATE time).
15969    if let Some(expr) = c.on_update_runtime {
15970        schema.on_update_runtime = Some(alloc::format!("{expr}"));
15971    }
15972    // v7.17.0 Phase 2.5 — bridge the AST `Collation` enum to the
15973    // storage one. Same variants, different crates (spg-storage
15974    // owns no dep on spg-sql).
15975    schema.collation = match c.collation {
15976        spg_sql::ast::Collation::Binary => spg_storage::Collation::Binary,
15977        spg_sql::ast::Collation::CaseInsensitive => spg_storage::Collation::CaseInsensitive,
15978    };
15979    // v7.17.0 Phase 4.4 — MySQL `UNSIGNED` flag propagates to
15980    // storage so engine INSERT / UPDATE can range-check.
15981    schema.is_unsigned = c.is_unsigned;
15982    // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM variant list.
15983    // INSERT validation lives in coerce_value (Text → Text path
15984    // with the column's variant list as the accept-set).
15985    schema.inline_enum_variants = c.inline_enum_variants;
15986    // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant list.
15987    // INSERT canonicalisation (de-dup + sort by definition order)
15988    // lives in the exec_insert path next to the ENUM check.
15989    schema.inline_set_variants = c.inline_set_variants;
15990    if let Some(default_expr) = c.default {
15991        // v7.9.21 — distinguish literal defaults (evaluated once
15992        // at CREATE TABLE) from expression defaults (deferred to
15993        // INSERT). Function calls (`now()`, `current_timestamp`
15994        // — see v7.9.20 keyword promotion) take the runtime path.
15995        // Literals continue to cache. mailrs G4.
15996        if is_runtime_default_expr(&default_expr) {
15997            let display = alloc::format!("{default_expr}");
15998            schema = schema.with_runtime_default(display);
15999        } else {
16000            let raw = literal_expr_to_value(default_expr)?;
16001            let coerced = coerce_value(raw, ty, &c.name, 0)?;
16002            schema = schema.with_default(coerced);
16003        }
16004    }
16005    if c.auto_increment {
16006        // AUTO_INCREMENT only makes sense on integer-shaped columns.
16007        if !matches!(ty, DataType::SmallInt | DataType::Int | DataType::BigInt) {
16008            return Err(EngineError::Unsupported(alloc::format!(
16009                "AUTO_INCREMENT requires an integer column type, got {ty:?}"
16010            )));
16011        }
16012        schema = schema.with_auto_increment();
16013    }
16014    Ok(schema)
16015}
16016
16017/// v7.10.4 — decode a BYTEA literal. Accepts:
16018///   * `\xDEADBEEF` (case-insensitive hex; whitespace stripped)
16019///   * `Hello\000world` (backslash escape form; `\\` for literal backslash)
16020///   * Anything else → raw UTF-8 bytes of the input (PG accepts this too).
16021fn decode_bytea_literal(s: &str) -> Result<alloc::vec::Vec<u8>, &'static str> {
16022    let s = s.trim();
16023    if let Some(hex) = s.strip_prefix("\\x").or_else(|| s.strip_prefix("\\X")) {
16024        // Hex form. Each pair of hex digits → one byte.
16025        let cleaned: alloc::string::String = hex.chars().filter(|c| !c.is_whitespace()).collect();
16026        if cleaned.len() % 2 != 0 {
16027            return Err("odd-length hex literal");
16028        }
16029        let mut out = alloc::vec::Vec::with_capacity(cleaned.len() / 2);
16030        let cleaned_bytes = cleaned.as_bytes();
16031        for i in (0..cleaned_bytes.len()).step_by(2) {
16032            let hi = hex_nibble(cleaned_bytes[i])?;
16033            let lo = hex_nibble(cleaned_bytes[i + 1])?;
16034            out.push((hi << 4) | lo);
16035        }
16036        return Ok(out);
16037    }
16038    // Escape form or raw. Walk char-by-char; `\\` and `\NNN` octal
16039    // sequences decode; anything else is a literal byte.
16040    let bytes = s.as_bytes();
16041    let mut out = alloc::vec::Vec::with_capacity(bytes.len());
16042    let mut i = 0;
16043    while i < bytes.len() {
16044        let b = bytes[i];
16045        if b == b'\\' && i + 1 < bytes.len() {
16046            let n = bytes[i + 1];
16047            if n == b'\\' {
16048                out.push(b'\\');
16049                i += 2;
16050                continue;
16051            }
16052            if n.is_ascii_digit()
16053                && i + 3 < bytes.len()
16054                && bytes[i + 2].is_ascii_digit()
16055                && bytes[i + 3].is_ascii_digit()
16056            {
16057                let oct = |x: u8| (x - b'0') as u32;
16058                let v = oct(n) * 64 + oct(bytes[i + 2]) * 8 + oct(bytes[i + 3]);
16059                if v <= 0xFF {
16060                    out.push(v as u8);
16061                    i += 4;
16062                    continue;
16063                }
16064            }
16065        }
16066        out.push(b);
16067        i += 1;
16068    }
16069    Ok(out)
16070}
16071
16072fn hex_nibble(b: u8) -> Result<u8, &'static str> {
16073    match b {
16074        b'0'..=b'9' => Ok(b - b'0'),
16075        b'a'..=b'f' => Ok(b - b'a' + 10),
16076        b'A'..=b'F' => Ok(b - b'A' + 10),
16077        _ => Err("invalid hex digit"),
16078    }
16079}
16080
16081/// v7.10.11 — decode a PG TEXT[] external array form
16082/// (`{a,b,NULL}` with optional double-quoted elements). The
16083/// engine takes a leading/trailing `{`/`}` and splits at commas.
16084/// Quoted elements (`"hello, world"`) preserve embedded commas;
16085/// `\\` and `\"` decode to literal backslash / quote. Plain
16086/// unquoted `NULL` (case-insensitive) maps to `None`.
16087/// v7.11.13 — pick the array type for `ARRAY[lit, …]` from the
16088/// element values. Single-element-type rules:
16089///   - all NULL / all Text → TextArray
16090///   - all Int (or Int+NULL) → IntArray
16091///   - any BigInt without Text → BigIntArray (widening)
16092///   - any Text → TextArray (fallback; non-string elements
16093///     render as text)
16094fn array_literal_widen(items: alloc::vec::Vec<Value>) -> Value {
16095    let mut has_text = false;
16096    let mut has_bigint = false;
16097    let mut has_int = false;
16098    for v in &items {
16099        match v {
16100            Value::Null => {}
16101            Value::Text(_) | Value::Json(_) => has_text = true,
16102            Value::BigInt(_) => has_bigint = true,
16103            Value::Int(_) | Value::SmallInt(_) => has_int = true,
16104            _ => has_text = true,
16105        }
16106    }
16107    if has_text || (!has_bigint && !has_int) {
16108        let out: alloc::vec::Vec<Option<alloc::string::String>> = items
16109            .into_iter()
16110            .map(|v| match v {
16111                Value::Null => None,
16112                Value::Text(s) | Value::Json(s) => Some(s),
16113                other => Some(alloc::format!("{other:?}")),
16114            })
16115            .collect();
16116        return Value::TextArray(out);
16117    }
16118    if has_bigint {
16119        let out: alloc::vec::Vec<Option<i64>> = items
16120            .into_iter()
16121            .map(|v| match v {
16122                Value::Null => None,
16123                Value::Int(n) => Some(i64::from(n)),
16124                Value::SmallInt(n) => Some(i64::from(n)),
16125                Value::BigInt(n) => Some(n),
16126                _ => unreachable!("widen: unexpected non-integer in BigInt path"),
16127            })
16128            .collect();
16129        return Value::BigIntArray(out);
16130    }
16131    let out: alloc::vec::Vec<Option<i32>> = items
16132        .into_iter()
16133        .map(|v| match v {
16134            Value::Null => None,
16135            Value::Int(n) => Some(n),
16136            Value::SmallInt(n) => Some(i32::from(n)),
16137            _ => unreachable!("widen: unexpected non-i32-compatible in Int path"),
16138        })
16139        .collect();
16140    Value::IntArray(out)
16141}
16142
16143fn decode_text_array_literal(
16144    s: &str,
16145) -> Result<alloc::vec::Vec<Option<alloc::string::String>>, &'static str> {
16146    let trimmed = s.trim();
16147    let inner = trimmed
16148        .strip_prefix('{')
16149        .and_then(|x| x.strip_suffix('}'))
16150        .ok_or("TEXT[] literal must be enclosed in '{...}'")?;
16151    let mut out: alloc::vec::Vec<Option<alloc::string::String>> = alloc::vec::Vec::new();
16152    if inner.trim().is_empty() {
16153        return Ok(out);
16154    }
16155    let bytes = inner.as_bytes();
16156    let mut i = 0;
16157    while i <= bytes.len() {
16158        // Skip leading whitespace.
16159        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
16160            i += 1;
16161        }
16162        // Quoted element.
16163        if i < bytes.len() && bytes[i] == b'"' {
16164            i += 1; // open quote
16165            let mut buf = alloc::string::String::new();
16166            while i < bytes.len() && bytes[i] != b'"' {
16167                if bytes[i] == b'\\' && i + 1 < bytes.len() {
16168                    buf.push(bytes[i + 1] as char);
16169                    i += 2;
16170                } else {
16171                    buf.push(bytes[i] as char);
16172                    i += 1;
16173                }
16174            }
16175            if i >= bytes.len() {
16176                return Err("unterminated quoted element");
16177            }
16178            i += 1; // close quote
16179            out.push(Some(buf));
16180        } else {
16181            // Unquoted element — read until next comma or end.
16182            let start = i;
16183            while i < bytes.len() && bytes[i] != b',' {
16184                i += 1;
16185            }
16186            let raw = inner[start..i].trim();
16187            if raw.eq_ignore_ascii_case("NULL") {
16188                out.push(None);
16189            } else {
16190                out.push(Some(alloc::string::ToString::to_string(raw)));
16191            }
16192        }
16193        // Skip whitespace, expect comma or end.
16194        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
16195            i += 1;
16196        }
16197        if i >= bytes.len() {
16198            break;
16199        }
16200        if bytes[i] != b',' {
16201            return Err("expected ',' between TEXT[] elements");
16202        }
16203        i += 1;
16204    }
16205    Ok(out)
16206}
16207
16208/// v7.10.11 — encode a TEXT[] back into the PG external array
16209/// form. NULL elements become the literal `NULL`; elements
16210/// containing commas, quotes, backslashes, or braces are
16211/// double-quoted with `\\` / `\"` escapes.
16212fn encode_text_array(items: &[Option<alloc::string::String>]) -> alloc::string::String {
16213    let mut out = alloc::string::String::with_capacity(2 + items.len() * 8);
16214    out.push('{');
16215    for (i, item) in items.iter().enumerate() {
16216        if i > 0 {
16217            out.push(',');
16218        }
16219        match item {
16220            None => out.push_str("NULL"),
16221            Some(s) => {
16222                let needs_quote = s.is_empty()
16223                    || s.eq_ignore_ascii_case("NULL")
16224                    || s.chars()
16225                        .any(|c| matches!(c, ',' | '{' | '}' | '"' | '\\' | ' ' | '\t'));
16226                if needs_quote {
16227                    out.push('"');
16228                    for c in s.chars() {
16229                        if c == '"' || c == '\\' {
16230                            out.push('\\');
16231                        }
16232                        out.push(c);
16233                    }
16234                    out.push('"');
16235                } else {
16236                    out.push_str(s);
16237                }
16238            }
16239        }
16240    }
16241    out.push('}');
16242    out
16243}
16244
16245/// v7.10.4 — encode BYTEA bytes in PG hex output format
16246/// (`\x` prefix, lowercase hex pairs). Used by Text-side
16247/// round-trip + the wire layer's text-mode encoder.
16248fn encode_bytea_hex(b: &[u8]) -> alloc::string::String {
16249    let mut out = alloc::string::String::with_capacity(2 + 2 * b.len());
16250    out.push_str("\\x");
16251    for byte in b {
16252        let hi = byte >> 4;
16253        let lo = byte & 0x0F;
16254        out.push(hex_digit(hi));
16255        out.push(hex_digit(lo));
16256    }
16257    out
16258}
16259
16260const fn hex_digit(n: u8) -> char {
16261    match n {
16262        0..=9 => (b'0' + n) as char,
16263        10..=15 => (b'a' + n - 10) as char,
16264        _ => '?',
16265    }
16266}
16267
16268/// v7.17.0 Phase 3.P0-39 — parse a PG `hstore` text literal into
16269/// a flat key→value map. Empty string → empty map. Duplicate
16270/// keys take last-write-wins (matches PG `hstore_in`).
16271///
16272/// Accepted shapes (minimal subset):
16273///   * `'a=>1, b=>2'`            — bareword keys/values
16274///   * `'"a"=>"1", "b"=>"2"'`    — quoted keys/values
16275///   * `'a=>NULL'`               — case-insensitive NULL token
16276///     surfaces as `None` (no quotes around NULL)
16277///
16278/// Returns None on parse failure → caller surfaces as hard error.
16279fn parse_hstore_str(
16280    s: &str,
16281) -> Option<Vec<(alloc::string::String, Option<alloc::string::String>)>> {
16282    let bytes = s.as_bytes();
16283    let mut i = 0;
16284    let mut out: Vec<(alloc::string::String, Option<alloc::string::String>)> = Vec::new();
16285    let skip_ws = |bytes: &[u8], i: &mut usize| {
16286        while *i < bytes.len() && matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r') {
16287            *i += 1;
16288        }
16289    };
16290    let parse_token = |bytes: &[u8], i: &mut usize| -> Option<alloc::string::String> {
16291        if *i >= bytes.len() {
16292            return None;
16293        }
16294        if bytes[*i] == b'"' {
16295            *i += 1;
16296            let mut out = alloc::string::String::new();
16297            while *i < bytes.len() {
16298                match bytes[*i] {
16299                    b'"' => {
16300                        *i += 1;
16301                        return Some(out);
16302                    }
16303                    b'\\' if *i + 1 < bytes.len() => {
16304                        out.push(bytes[*i + 1] as char);
16305                        *i += 2;
16306                    }
16307                    c => {
16308                        out.push(c as char);
16309                        *i += 1;
16310                    }
16311                }
16312            }
16313            None
16314        } else {
16315            let start = *i;
16316            while *i < bytes.len()
16317                && !matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r' | b',' | b'=')
16318            {
16319                *i += 1;
16320            }
16321            if *i == start {
16322                return None;
16323            }
16324            Some(alloc::str::from_utf8(&bytes[start..*i]).ok()?.to_string())
16325        }
16326    };
16327    skip_ws(bytes, &mut i);
16328    while i < bytes.len() {
16329        let key = parse_token(bytes, &mut i)?;
16330        skip_ws(bytes, &mut i);
16331        if i + 1 >= bytes.len() || bytes[i] != b'=' || bytes[i + 1] != b'>' {
16332            return None;
16333        }
16334        i += 2;
16335        skip_ws(bytes, &mut i);
16336        // Check for unquoted NULL token (case-insensitive).
16337        let val_token = if i + 4 <= bytes.len()
16338            && bytes[i..i + 4].eq_ignore_ascii_case(b"NULL")
16339            && (i + 4 == bytes.len() || matches!(bytes[i + 4], b' ' | b'\t' | b',' | b'\n' | b'\r'))
16340        {
16341            i += 4;
16342            None
16343        } else {
16344            Some(parse_token(bytes, &mut i)?)
16345        };
16346        // Replace any existing entry with the same key (last-wins).
16347        if let Some(pos) = out.iter().position(|(k, _)| k == &key) {
16348            out[pos] = (key, val_token);
16349        } else {
16350            out.push((key, val_token));
16351        }
16352        skip_ws(bytes, &mut i);
16353        if i >= bytes.len() {
16354            break;
16355        }
16356        if bytes[i] == b',' {
16357            i += 1;
16358            skip_ws(bytes, &mut i);
16359            continue;
16360        }
16361        return None;
16362    }
16363    Some(out)
16364}
16365
16366/// v7.17.0 Phase 3.P0-39 — render a hstore as canonical PG text
16367/// form `"k"=>"v"` (keys and non-NULL values always quoted;
16368/// NULL token is bare).
16369fn format_hstore_str(
16370    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
16371) -> alloc::string::String {
16372    let mut out = alloc::string::String::new();
16373    for (i, (k, v)) in pairs.iter().enumerate() {
16374        if i > 0 {
16375            out.push_str(", ");
16376        }
16377        out.push('"');
16378        out.push_str(k);
16379        out.push_str("\"=>");
16380        match v {
16381            None => out.push_str("NULL"),
16382            Some(val) => {
16383                out.push('"');
16384                out.push_str(val);
16385                out.push('"');
16386            }
16387        }
16388    }
16389    out
16390}
16391
16392/// v7.17.0 Phase 3.P0-39 — pub re-export so pgwire + sqllogictest
16393/// share the single hstore renderer.
16394pub fn format_hstore_text(
16395    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
16396) -> alloc::string::String {
16397    format_hstore_str(pairs)
16398}
16399
16400// ─── v7.17.0 Phase 3.P0-40 — 2D array parse + display ─────────
16401
16402/// Split a PG external 2D-array literal `'{{a,b},{c,d}}'` into
16403/// per-row token lists. Returns Err on shape mismatch.
16404fn split_2d_literal(s: &str) -> Result<Vec<Vec<alloc::string::String>>, &'static str> {
16405    let s = s.trim();
16406    let outer = s
16407        .strip_prefix('{')
16408        .and_then(|x| x.strip_suffix('}'))
16409        .ok_or("missing outer '{...}' braces")?;
16410    let trimmed = outer.trim();
16411    if trimmed.is_empty() {
16412        return Ok(Vec::new());
16413    }
16414    let mut rows: Vec<Vec<alloc::string::String>> = Vec::new();
16415    let mut i = 0;
16416    let bytes = trimmed.as_bytes();
16417    while i < bytes.len() {
16418        while i < bytes.len() && matches!(bytes[i], b' ' | b'\t' | b'\n' | b'\r' | b',') {
16419            i += 1;
16420        }
16421        if i >= bytes.len() {
16422            break;
16423        }
16424        if bytes[i] != b'{' {
16425            return Err("expected '{' opening a row");
16426        }
16427        i += 1;
16428        let row_start = i;
16429        let mut depth = 1;
16430        while i < bytes.len() && depth > 0 {
16431            match bytes[i] {
16432                b'{' => depth += 1,
16433                b'}' => depth -= 1,
16434                _ => {}
16435            }
16436            if depth > 0 {
16437                i += 1;
16438            }
16439        }
16440        if depth != 0 {
16441            return Err("unbalanced '{...}' in row");
16442        }
16443        let row_text = &trimmed[row_start..i];
16444        i += 1;
16445        let cells: Vec<alloc::string::String> = if row_text.trim().is_empty() {
16446            Vec::new()
16447        } else {
16448            row_text.split(',').map(|t| t.trim().to_string()).collect()
16449        };
16450        rows.push(cells);
16451    }
16452    if let Some(first) = rows.first() {
16453        let cols = first.len();
16454        for r in &rows {
16455            if r.len() != cols {
16456                return Err("ragged 2D array (rows have different column counts)");
16457            }
16458        }
16459    }
16460    Ok(rows)
16461}
16462
16463fn parse_int_2d_literal(s: &str) -> Result<Vec<Vec<Option<i32>>>, &'static str> {
16464    let raw = split_2d_literal(s)?;
16465    raw.into_iter()
16466        .map(|row| {
16467            row.into_iter()
16468                .map(|cell| {
16469                    if cell.eq_ignore_ascii_case("NULL") {
16470                        Ok(None)
16471                    } else {
16472                        cell.parse::<i32>()
16473                            .map(Some)
16474                            .map_err(|_| "invalid int element")
16475                    }
16476                })
16477                .collect()
16478        })
16479        .collect()
16480}
16481
16482fn parse_bigint_2d_literal(s: &str) -> Result<Vec<Vec<Option<i64>>>, &'static str> {
16483    let raw = split_2d_literal(s)?;
16484    raw.into_iter()
16485        .map(|row| {
16486            row.into_iter()
16487                .map(|cell| {
16488                    if cell.eq_ignore_ascii_case("NULL") {
16489                        Ok(None)
16490                    } else {
16491                        cell.parse::<i64>()
16492                            .map(Some)
16493                            .map_err(|_| "invalid bigint element")
16494                    }
16495                })
16496                .collect()
16497        })
16498        .collect()
16499}
16500
16501fn parse_text_2d_literal(s: &str) -> Result<Vec<Vec<Option<alloc::string::String>>>, &'static str> {
16502    let raw = split_2d_literal(s)?;
16503    Ok(raw
16504        .into_iter()
16505        .map(|row| {
16506            row.into_iter()
16507                .map(|cell| {
16508                    if cell.eq_ignore_ascii_case("NULL") {
16509                        None
16510                    } else {
16511                        Some(cell.trim_matches('"').to_string())
16512                    }
16513                })
16514                .collect()
16515        })
16516        .collect())
16517}
16518
16519fn format_int_2d_text(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
16520    let mut out = alloc::string::String::from("{");
16521    for (i, row) in rows.iter().enumerate() {
16522        if i > 0 {
16523            out.push(',');
16524        }
16525        out.push('{');
16526        for (j, cell) in row.iter().enumerate() {
16527            if j > 0 {
16528                out.push(',');
16529            }
16530            match cell {
16531                None => out.push_str("NULL"),
16532                Some(n) => out.push_str(&alloc::format!("{n}")),
16533            }
16534        }
16535        out.push('}');
16536    }
16537    out.push('}');
16538    out
16539}
16540
16541fn format_bigint_2d_text(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
16542    let mut out = alloc::string::String::from("{");
16543    for (i, row) in rows.iter().enumerate() {
16544        if i > 0 {
16545            out.push(',');
16546        }
16547        out.push('{');
16548        for (j, cell) in row.iter().enumerate() {
16549            if j > 0 {
16550                out.push(',');
16551            }
16552            match cell {
16553                None => out.push_str("NULL"),
16554                Some(n) => out.push_str(&alloc::format!("{n}")),
16555            }
16556        }
16557        out.push('}');
16558    }
16559    out.push('}');
16560    out
16561}
16562
16563fn format_text_2d_text(rows: &[Vec<Option<alloc::string::String>>]) -> alloc::string::String {
16564    let mut out = alloc::string::String::from("{");
16565    for (i, row) in rows.iter().enumerate() {
16566        if i > 0 {
16567            out.push(',');
16568        }
16569        out.push('{');
16570        for (j, cell) in row.iter().enumerate() {
16571            if j > 0 {
16572                out.push(',');
16573            }
16574            match cell {
16575                None => out.push_str("NULL"),
16576                Some(s) => out.push_str(s),
16577            }
16578        }
16579        out.push('}');
16580    }
16581    out.push('}');
16582    out
16583}
16584
16585/// v7.17.0 Phase 3.P0-40 — pub re-exports so pgwire + sqllogictest
16586/// share the single 2D-array renderer.
16587pub fn format_int_2d_text_pub(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
16588    format_int_2d_text(rows)
16589}
16590pub fn format_bigint_2d_text_pub(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
16591    format_bigint_2d_text(rows)
16592}
16593pub fn format_text_2d_text_pub(
16594    rows: &[Vec<Option<alloc::string::String>>],
16595) -> alloc::string::String {
16596    format_text_2d_text(rows)
16597}
16598
16599/// v7.17.0 Phase 3.P0-38 — parse a PG range literal of the form
16600/// `'[lo,up)'` / `'(lo,up]'` / `'[lo,up]'` / `'(lo,up)'` /
16601/// `'empty'`. Lower / upper may be empty (unbounded). Returns
16602/// `None` on any parse failure; caller surfaces as hard error.
16603fn parse_range_str(s: &str, kind: spg_storage::RangeKind) -> Option<Value> {
16604    let s = s.trim();
16605    if s.eq_ignore_ascii_case("empty") {
16606        return Some(Value::Range {
16607            kind,
16608            lower: None,
16609            upper: None,
16610            lower_inc: false,
16611            upper_inc: false,
16612            empty: true,
16613        });
16614    }
16615    let bytes = s.as_bytes();
16616    if bytes.len() < 3 {
16617        return None;
16618    }
16619    let lower_inc = match bytes[0] {
16620        b'[' => true,
16621        b'(' => false,
16622        _ => return None,
16623    };
16624    let upper_inc = match bytes[bytes.len() - 1] {
16625        b']' => true,
16626        b')' => false,
16627        _ => return None,
16628    };
16629    let inner = &s[1..s.len() - 1];
16630    let (lo_text, up_text) = inner.split_once(',')?;
16631    let lower = if lo_text.is_empty() {
16632        None
16633    } else {
16634        Some(alloc::boxed::Box::new(parse_range_element(lo_text, kind)?))
16635    };
16636    let upper = if up_text.is_empty() {
16637        None
16638    } else {
16639        Some(alloc::boxed::Box::new(parse_range_element(up_text, kind)?))
16640    };
16641    Some(Value::Range {
16642        kind,
16643        lower,
16644        upper,
16645        lower_inc,
16646        upper_inc,
16647        empty: false,
16648    })
16649}
16650
16651/// v7.17.0 Phase 3.P0-38 — parse a single range bound text into
16652/// the matching element Value for the RangeKind.
16653fn parse_range_element(text: &str, kind: spg_storage::RangeKind) -> Option<Value> {
16654    let text = text.trim().trim_matches('"');
16655    use spg_storage::RangeKind as K;
16656    match kind {
16657        K::Int4 => text.parse::<i32>().ok().map(Value::Int),
16658        K::Int8 => text.parse::<i64>().ok().map(Value::BigInt),
16659        K::Num => {
16660            // Reuse the Numeric parse via the engine's text-coercion
16661            // path; bail to None on failure.
16662            let dot = text.find('.');
16663            let scale: u8 = dot.map_or(0, |p| (text.len() - p - 1) as u8);
16664            let digits: alloc::string::String = text
16665                .chars()
16666                .filter(|c| *c == '-' || c.is_ascii_digit())
16667                .collect();
16668            let scaled: i128 = digits.parse().ok()?;
16669            Some(Value::Numeric { scaled, scale })
16670        }
16671        K::Ts | K::TsTz => {
16672            // Reuse the existing timestamp parse path. v7.17.0
16673            // expects `'YYYY-MM-DD HH:MM:SS[.ffffff]'` in range
16674            // bounds (TZ offset on TsTz is OOS for the initial
16675            // P0-38; ship plain Timestamp shape).
16676            crate::eval::parse_timestamp_literal(text).map(Value::Timestamp)
16677        }
16678        K::Date => crate::eval::parse_date_literal(text).map(Value::Date),
16679    }
16680}
16681
16682/// v7.17.0 Phase 3.P0-38 — render a Range value as its canonical
16683/// PG text form. Re-exported via [`format_range_text`] for use
16684/// from spg-server's pgwire layer.
16685pub fn format_range_text(v: &Value) -> alloc::string::String {
16686    format_range_str(v)
16687}
16688
16689fn format_range_str(v: &Value) -> alloc::string::String {
16690    let Value::Range {
16691        lower,
16692        upper,
16693        lower_inc,
16694        upper_inc,
16695        empty,
16696        ..
16697    } = v
16698    else {
16699        return alloc::string::String::new();
16700    };
16701    if *empty {
16702        return "empty".into();
16703    }
16704    let mut out = alloc::string::String::new();
16705    out.push(if *lower_inc { '[' } else { '(' });
16706    if let Some(l) = lower {
16707        out.push_str(&format_range_element(l));
16708    }
16709    out.push(',');
16710    if let Some(u) = upper {
16711        out.push_str(&format_range_element(u));
16712    }
16713    out.push(if *upper_inc { ']' } else { ')' });
16714    out
16715}
16716
16717fn format_range_element(v: &Value) -> alloc::string::String {
16718    match v {
16719        Value::Int(n) => alloc::format!("{n}"),
16720        Value::BigInt(n) => alloc::format!("{n}"),
16721        Value::Date(d) => crate::eval::format_date(*d),
16722        Value::Timestamp(t) => crate::eval::format_timestamp(*t),
16723        Value::Numeric { scaled, scale } => crate::eval::format_numeric(*scaled, *scale),
16724        other => alloc::format!("{other:?}"),
16725    }
16726}
16727
16728/// v7.17.0 Phase 3.P0-35 — parse a PG `money` literal into i64
16729/// cents. Accepts:
16730///   * Optional leading `-` (negative)
16731///   * Optional `$` prefix
16732///   * Integer portion with optional `,` thousands separators
16733///   * Optional `.` followed by 1-2 digits (cents); 1 digit
16734///     auto-pads to 2 (`.5` → 50 cents).
16735///
16736/// Returns None on any parse failure — caller surfaces as hard
16737/// SQL error.
16738fn parse_money_str(s: &str) -> Option<i64> {
16739    let s = s.trim();
16740    let (neg, rest) = match s.strip_prefix('-') {
16741        Some(r) => (true, r.trim_start()),
16742        None => (false, s),
16743    };
16744    let rest = rest.strip_prefix('$').unwrap_or(rest).trim_start();
16745    let (int_part, frac_part) = match rest.split_once('.') {
16746        Some((i, f)) => (i, Some(f)),
16747        None => (rest, None),
16748    };
16749    if int_part.is_empty() {
16750        return None;
16751    }
16752    // Validate + strip commas from the integer portion.
16753    let mut int_digits = alloc::string::String::with_capacity(int_part.len());
16754    for b in int_part.bytes() {
16755        match b {
16756            b',' => {}
16757            b'0'..=b'9' => int_digits.push(b as char),
16758            _ => return None,
16759        }
16760    }
16761    if int_digits.is_empty() {
16762        return None;
16763    }
16764    let dollars: i64 = int_digits.parse().ok()?;
16765    let cents: i64 = match frac_part {
16766        None => 0,
16767        Some(f) => {
16768            if f.is_empty() || f.len() > 2 || !f.bytes().all(|b| b.is_ascii_digit()) {
16769                return None;
16770            }
16771            let padded = if f.len() == 1 {
16772                alloc::format!("{f}0")
16773            } else {
16774                f.to_string()
16775            };
16776            padded.parse().ok()?
16777        }
16778    };
16779    let total = dollars.checked_mul(100)?.checked_add(cents)?;
16780    Some(if neg { -total } else { total })
16781}
16782
16783/// v7.17.0 Phase 3.P0-34 — parse a PG `timetz` literal
16784/// `HH:MM:SS[.fraction]±HH[:MM]` into (us, offset_secs).
16785///
16786/// The offset suffix is MANDATORY: SPG doesn't have a session TZ
16787/// wired into eval, so a bare `HH:MM:SS` literal would be
16788/// ambiguous. Returns None for any parse failure or out-of-range
16789/// component — caller surfaces as a hard SQL error.
16790///
16791/// Offset range: ±14 hours (±50400 seconds), matching PG's
16792/// internal limit.
16793fn parse_timetz_str(s: &str) -> Option<(i64, i32)> {
16794    let s = s.trim();
16795    // Find the offset sign — scan from right since the time part
16796    // never contains '+' / '-' (after the optional fractional dot
16797    // it's all digits and ':').
16798    let bytes = s.as_bytes();
16799    let sign_pos = bytes
16800        .iter()
16801        .enumerate()
16802        .rev()
16803        .find(|&(_, &b)| b == b'+' || b == b'-')
16804        .map(|(i, _)| i)?;
16805    if sign_pos == 0 {
16806        return None; // bare sign — no time component
16807    }
16808    let time_part = &s[..sign_pos];
16809    let offset_part = &s[sign_pos..];
16810    let us = parse_time_str(time_part)?;
16811    let sign: i32 = if offset_part.starts_with('+') { 1 } else { -1 };
16812    let offset_body = &offset_part[1..];
16813    let (hh_str, mm_str) = match offset_body.split_once(':') {
16814        Some((h, m)) => (h, m),
16815        None => (offset_body, "0"),
16816    };
16817    let hh: i32 = hh_str.parse().ok()?;
16818    let mm: i32 = mm_str.parse().ok()?;
16819    if !(0..=14).contains(&hh) || !(0..=59).contains(&mm) {
16820        return None;
16821    }
16822    let total = sign * (hh * 3600 + mm * 60);
16823    if total.abs() > 50_400 {
16824        return None;
16825    }
16826    Some((us, total))
16827}
16828
16829/// v7.17.0 Phase 3.P0-33 — funnel an integer literal through MySQL
16830/// YEAR range validation: 0 sentinel or 1901..=2155. Out-of-range
16831/// surfaces as a hard SQL error (no silent truncation, mirrors PG
16832/// `time_in` / `uuid_in` discipline).
16833fn coerce_int_to_year(n: i64, col_name: &str) -> Result<Value, EngineError> {
16834    if n == 0 || (1901..=2155).contains(&n) {
16835        // u16::try_from cannot fail in this range; the cast also
16836        // covers the 0 sentinel.
16837        return Ok(Value::Year(n as u16));
16838    }
16839    Err(EngineError::Eval(EvalError::TypeMismatch {
16840        detail: alloc::format!(
16841            "year value out of range: {n} (column `{col_name}`; \
16842             MySQL accepts 0 or 1901..=2155)"
16843        ),
16844    }))
16845}
16846
16847/// v7.17.0 Phase 3.P0-32 — parse a PG `time` literal
16848/// `HH:MM:SS[.fraction]` into microseconds since 00:00:00.
16849///
16850/// Accepts:
16851///   * `HH:MM:SS`            — exact-second precision
16852///   * `HH:MM:SS.f` .. `.ffffff` — 1-6 fractional digits, right-padded
16853///     with zeros to microseconds
16854///
16855/// Range: hour 0..=23, minute 0..=59, second 0..=59. Anything else
16856/// returns None — caller surfaces as a hard SQL error (no silent
16857/// truncation, matches PG's `time_in` behaviour).
16858fn parse_time_str(s: &str) -> Option<i64> {
16859    let s = s.trim();
16860    let (hms, frac) = match s.split_once('.') {
16861        Some((h, f)) => (h, Some(f)),
16862        None => (s, None),
16863    };
16864    let mut parts = hms.split(':');
16865    let hh: u32 = parts.next()?.parse().ok()?;
16866    let mm: u32 = parts.next()?.parse().ok()?;
16867    let ss: u32 = parts.next()?.parse().ok()?;
16868    if parts.next().is_some() {
16869        return None;
16870    }
16871    if hh > 23 || mm > 59 || ss > 59 {
16872        return None;
16873    }
16874    let frac_us: i64 = match frac {
16875        None => 0,
16876        Some(f) => {
16877            if f.is_empty() || f.len() > 6 || !f.bytes().all(|b| b.is_ascii_digit()) {
16878                return None;
16879            }
16880            // Right-pad with zeros so '.5' = 500000 µsec.
16881            let mut padded = alloc::string::String::with_capacity(6);
16882            padded.push_str(f);
16883            while padded.len() < 6 {
16884                padded.push('0');
16885            }
16886            padded.parse().ok()?
16887        }
16888    };
16889    Some(
16890        i64::from(hh) * 3_600_000_000
16891            + i64::from(mm) * 60_000_000
16892            + i64::from(ss) * 1_000_000
16893            + frac_us,
16894    )
16895}
16896
16897const fn column_type_to_data_type(t: ColumnTypeName) -> DataType {
16898    match t {
16899        ColumnTypeName::SmallInt => DataType::SmallInt,
16900        ColumnTypeName::Int => DataType::Int,
16901        ColumnTypeName::BigInt => DataType::BigInt,
16902        ColumnTypeName::Float => DataType::Float,
16903        ColumnTypeName::Text => DataType::Text,
16904        ColumnTypeName::Varchar(n) => DataType::Varchar(n),
16905        ColumnTypeName::Char(n) => DataType::Char(n),
16906        ColumnTypeName::Bool => DataType::Bool,
16907        ColumnTypeName::Vector { dim, encoding } => DataType::Vector {
16908            dim,
16909            encoding: match encoding {
16910                SqlVecEncoding::F32 => VecEncoding::F32,
16911                SqlVecEncoding::Sq8 => VecEncoding::Sq8,
16912                SqlVecEncoding::F16 => VecEncoding::F16,
16913            },
16914        },
16915        ColumnTypeName::Numeric(precision, scale) => DataType::Numeric { precision, scale },
16916        ColumnTypeName::Date => DataType::Date,
16917        ColumnTypeName::Timestamp => DataType::Timestamp,
16918        ColumnTypeName::Timestamptz => DataType::Timestamptz,
16919        ColumnTypeName::Json => DataType::Json,
16920        ColumnTypeName::Jsonb => DataType::Jsonb,
16921        ColumnTypeName::Bytes => DataType::Bytes,
16922        ColumnTypeName::TextArray => DataType::TextArray,
16923        ColumnTypeName::IntArray => DataType::IntArray,
16924        ColumnTypeName::BigIntArray => DataType::BigIntArray,
16925        ColumnTypeName::TsVector => DataType::TsVector,
16926        ColumnTypeName::TsQuery => DataType::TsQuery,
16927        ColumnTypeName::Uuid => DataType::Uuid,
16928        ColumnTypeName::Time => DataType::Time,
16929        ColumnTypeName::Year => DataType::Year,
16930        ColumnTypeName::TimeTz => DataType::TimeTz,
16931        ColumnTypeName::Money => DataType::Money,
16932        ColumnTypeName::Range(k) => DataType::Range(match k {
16933            spg_sql::ast::RangeKindAst::Int4 => spg_storage::RangeKind::Int4,
16934            spg_sql::ast::RangeKindAst::Int8 => spg_storage::RangeKind::Int8,
16935            spg_sql::ast::RangeKindAst::Num => spg_storage::RangeKind::Num,
16936            spg_sql::ast::RangeKindAst::Ts => spg_storage::RangeKind::Ts,
16937            spg_sql::ast::RangeKindAst::TsTz => spg_storage::RangeKind::TsTz,
16938            spg_sql::ast::RangeKindAst::Date => spg_storage::RangeKind::Date,
16939        }),
16940        ColumnTypeName::Hstore => DataType::Hstore,
16941        ColumnTypeName::IntArray2D => DataType::IntArray2D,
16942        ColumnTypeName::BigIntArray2D => DataType::BigIntArray2D,
16943        ColumnTypeName::TextArray2D => DataType::TextArray2D,
16944    }
16945}
16946
16947/// Convert an INSERT VALUES expression to a storage Value. Supports literal
16948/// expressions, unary-minus over numeric literals, and pgvector-style
16949/// `'[..]'::vector` cast (v1.2). Anything more complex returns `Unsupported`.
16950fn literal_expr_to_value(expr: Expr) -> Result<Value, EngineError> {
16951    match expr {
16952        Expr::Literal(l) => Ok(literal_to_value(l)),
16953        Expr::Cast { expr, target } => {
16954            let inner_value = literal_expr_to_value(*expr)?;
16955            crate::eval::cast_value(inner_value, target).map_err(EngineError::Eval)
16956        }
16957        Expr::Unary {
16958            op: UnOp::Neg,
16959            expr,
16960        } => match *expr {
16961            Expr::Literal(Literal::Integer(n)) => {
16962                // Fold to i32 if it fits, else BigInt. Parser emits Integer(i64)
16963                // — overflow on negate of i64::MIN is the one edge case.
16964                let neg = n.checked_neg().ok_or_else(|| {
16965                    EngineError::Unsupported("integer literal overflow on negation".into())
16966                })?;
16967                Ok(int_value_for(neg))
16968            }
16969            Expr::Literal(Literal::Float(x)) => Ok(Value::Float(-x)),
16970            other => Err(EngineError::Unsupported(alloc::format!(
16971                "unary minus over non-literal expression: {other:?}"
16972            ))),
16973        },
16974        // v7.10.10 — `ARRAY[lit, lit, …]` constructor accepted at
16975        // INSERT-time. Each element must reduce to a Value through
16976        // `literal_expr_to_value`; NULL elements become `None`.
16977        // v7.11.13 — deduce shape from element values: all Int →
16978        // IntArray; any BigInt → BigIntArray (widening); any Text
16979        // → TextArray. Cast targets (`ARRAY[]::INT[]`) flow through
16980        // the outer Cast arm before reaching here and re-coerce.
16981        Expr::Array(items) => {
16982            let mut materialised: alloc::vec::Vec<Value> =
16983                alloc::vec::Vec::with_capacity(items.len());
16984            for elem in items {
16985                materialised.push(literal_expr_to_value(elem)?);
16986            }
16987            Ok(array_literal_widen(materialised))
16988        }
16989        // Any other Expr shape — fall back to a general evaluation
16990        // against an empty row + empty schema. This unblocks the
16991        // app-common patterns where INSERT VALUES carries a
16992        // non-correlated function call:
16993        //   INSERT INTO t VALUES (concat('U-', 42))
16994        //   INSERT INTO t VALUES (now())
16995        //   INSERT INTO t VALUES (format('%s-%s', 'a', 'b'))
16996        // Any expression that references a column or `$N`
16997        // placeholder fails cleanly inside `eval_expr` with a
16998        // descriptive error; literals + casts + ARRAY[…] continue
16999        // to take the fast paths above so the hot INSERT path is
17000        // unchanged on the common case.
17001        other => {
17002            let empty_schema: alloc::vec::Vec<spg_storage::ColumnSchema> = alloc::vec::Vec::new();
17003            let ctx = EvalContext::new(&empty_schema, None);
17004            let empty_row = spg_storage::Row::new(alloc::vec::Vec::new());
17005            crate::eval::eval_expr(&other, &empty_row, &ctx).map_err(EngineError::Eval)
17006        }
17007    }
17008}
17009
17010fn literal_to_value(l: Literal) -> Value {
17011    match l {
17012        Literal::Integer(n) => int_value_for(n),
17013        Literal::Float(x) => Value::Float(x),
17014        Literal::String(s) => Value::Text(s),
17015        Literal::Bool(b) => Value::Bool(b),
17016        Literal::Null => Value::Null,
17017        Literal::Vector(v) => Value::Vector(v),
17018        Literal::TextArray(items) => Value::TextArray(items),
17019        Literal::IntArray(items) => Value::IntArray(items),
17020        Literal::BigIntArray(items) => Value::BigIntArray(items),
17021        Literal::Interval { months, micros, .. } => Value::Interval { months, micros },
17022    }
17023}
17024
17025/// Pick `Int` (`i32`) when the literal fits, else `BigInt`. `INT` vs `BIGINT`
17026/// columns will still enforce the right tag downstream — this is just the
17027/// default we synthesise from an unannotated integer literal.
17028fn int_value_for(n: i64) -> Value {
17029    if let Ok(small) = i32::try_from(n) {
17030        Value::Int(small)
17031    } else {
17032        Value::BigInt(n)
17033    }
17034}
17035
17036/// Widen / narrow `v` to fit `expected`. Numerics permit safe widening
17037/// (`Int → BigInt`, `Int/BigInt → Float`) and best-effort narrowing
17038/// (`BigInt → Int` succeeds only when the value fits in `i32`). Everything
17039/// else returns `TypeMismatch` carrying the column name for caller diagnostics.
17040/// `NULL` is always permitted; the nullability check happens later in storage.
17041#[allow(clippy::too_many_lines)]
17042/// v7.17.0 Phase 4.4 — reject negative integer values on UNSIGNED
17043/// columns. Called after `coerce_value` at each INSERT / UPDATE
17044/// site that has ColumnSchema context. NULL passes through (a
17045/// nullable UNSIGNED column can legitimately hold NULL).
17046fn check_unsigned_range(
17047    v: &Value,
17048    schema: &ColumnSchema,
17049    position: usize,
17050) -> Result<(), EngineError> {
17051    if !schema.is_unsigned {
17052        return Ok(());
17053    }
17054    let n = match v {
17055        Value::SmallInt(x) => i64::from(*x),
17056        Value::Int(x) => i64::from(*x),
17057        Value::BigInt(x) => *x,
17058        _ => return Ok(()), // non-integer cells (NULL, default) skip
17059    };
17060    if n < 0 {
17061        return Err(EngineError::Unsupported(alloc::format!(
17062            "column {:?} is UNSIGNED but got negative value {n} at position {position}",
17063            schema.name
17064        )));
17065    }
17066    Ok(())
17067}
17068
17069fn coerce_value(
17070    v: Value,
17071    expected: DataType,
17072    col_name: &str,
17073    position: usize,
17074) -> Result<Value, EngineError> {
17075    if v.is_null() {
17076        return Ok(Value::Null);
17077    }
17078    let actual = v.data_type().expect("non-null");
17079    if actual == expected {
17080        return Ok(v);
17081    }
17082    let coerced = match (v, expected) {
17083        (Value::Int(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
17084        (Value::Int(n), DataType::Float) => Some(Value::Float(f64::from(n))),
17085        (Value::Int(n), DataType::SmallInt) => i16::try_from(n).ok().map(Value::SmallInt),
17086        (Value::Int(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
17087            i128::from(n),
17088            precision,
17089            scale,
17090            col_name,
17091        )?),
17092        (Value::SmallInt(n), DataType::Int) => Some(Value::Int(i32::from(n))),
17093        (Value::SmallInt(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
17094        (Value::SmallInt(n), DataType::Float) => Some(Value::Float(f64::from(n))),
17095        (Value::SmallInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
17096            i128::from(n),
17097            precision,
17098            scale,
17099            col_name,
17100        )?),
17101        (Value::BigInt(n), DataType::Int) => i32::try_from(n).ok().map(Value::Int),
17102        (Value::BigInt(n), DataType::SmallInt) => i16::try_from(n).ok().map(Value::SmallInt),
17103        #[allow(clippy::cast_precision_loss)]
17104        (Value::BigInt(n), DataType::Float) => Some(Value::Float(n as f64)),
17105        (Value::BigInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
17106            i128::from(n),
17107            precision,
17108            scale,
17109            col_name,
17110        )?),
17111        (Value::Float(x), DataType::Numeric { precision, scale }) => {
17112            Some(numeric_from_float(x, precision, scale, col_name)?)
17113        }
17114        // v7.17.0 Phase 3.P0-67 — Text → NUMERIC. Parse a
17115        // canonical decimal text (`"-1234.56"` / `"42"` /
17116        // `"0.0001"`) into `(mantissa, source_scale)` and rescale
17117        // to the column's declared scale. Required for prepared
17118        // binds: `value_to_literal` flattens a Value::Numeric
17119        // into a TEXT literal because Literal carries no native
17120        // Numeric variant, so the placeholder substitution path
17121        // reaches coerce_value as Text → Numeric. Without this
17122        // arm the round-trip surfaces a TypeMismatch even though
17123        // the cell already left the engine as a valid Numeric.
17124        (Value::Text(s), DataType::Numeric { precision, scale }) => {
17125            let Some((mantissa, src_scale)) = parse_numeric_text(&s) else {
17126                return Err(EngineError::Eval(EvalError::TypeMismatch {
17127                    detail: alloc::format!("cannot parse {s:?} as NUMERIC for column `{col_name}`"),
17128                }));
17129            };
17130            Some(numeric_rescale(
17131                mantissa, src_scale, precision, scale, col_name,
17132            )?)
17133        }
17134        // Text → DATE / TIMESTAMP: parse canonical text forms.
17135        (Value::Text(s), DataType::Date) => {
17136            let d = eval::parse_date_literal(&s).ok_or_else(|| {
17137                EngineError::Eval(EvalError::TypeMismatch {
17138                    detail: alloc::format!("cannot parse {s:?} as DATE for column `{col_name}`"),
17139                })
17140            })?;
17141            Some(Value::Date(d))
17142        }
17143        // v7.14.0 — MySQL DEFAULT clauses quote integer / float
17144        // / boolean literals (`DEFAULT '0'`, `DEFAULT '1'`,
17145        // `DEFAULT '3.14'`, `DEFAULT 'true'`). Coerce the text
17146        // form to the column's numeric / bool type at DEFAULT-
17147        // installation time so the storage check sees a typed
17148        // value. Parse failures fall through to TypeMismatch.
17149        (Value::Text(s), DataType::SmallInt) => s.parse::<i16>().ok().map(Value::SmallInt),
17150        (Value::Text(s), DataType::Int) => s.parse::<i32>().ok().map(Value::Int),
17151        (Value::Text(s), DataType::BigInt) => s.parse::<i64>().ok().map(Value::BigInt),
17152        (Value::Text(s), DataType::Float) => s.parse::<f64>().ok().map(Value::Float),
17153        (Value::Text(s), DataType::Bool) => match s.to_ascii_lowercase().as_str() {
17154            "0" | "false" | "f" | "no" | "off" => Some(Value::Bool(false)),
17155            "1" | "true" | "t" | "yes" | "on" => Some(Value::Bool(true)),
17156            _ => None,
17157        },
17158        // v7.17.0 Phase 3.P0-46 — MySQL TINYINT(1) (which Phase 4.3
17159        // classifies as DataType::Bool) is the storage shape every
17160        // mysqldump-restored boolean column lands in. mysqldump emits
17161        // the values as integer `0` / `1` literals, so int → bool
17162        // coerce on INSERT is required for a 0-change cutover. MySQL's
17163        // rule is "any non-zero is truthy"; we follow that for all
17164        // signed int widths so the same coerce path serves an
17165        // explicit `BOOLEAN` column too.
17166        (Value::Int(n), DataType::Bool) => Some(Value::Bool(n != 0)),
17167        (Value::SmallInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
17168        (Value::BigInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
17169        // v4.9: Text ↔ JSON coercion. No structural validation —
17170        // any text literal is accepted; the responsibility for
17171        // valid JSON lies with the producer.
17172        (Value::Text(s), DataType::Json | DataType::Jsonb) => Some(Value::Json(s)),
17173        (Value::Json(s), DataType::Text) => Some(Value::Text(s)),
17174        // v7.13.3 — mailrs round-7 S10. SPG's storage represents
17175        // both JSON and JSONB on-disk as `Value::Json(String)` —
17176        // they share the underlying text payload. The cast
17177        // `'<text>'::jsonb` produces a Value::Json that needs to
17178        // satisfy a DataType::Jsonb column. Identity coerce in
17179        // both directions so JSON ↔ JSONB assignments work at all
17180        // INSERT / ALTER COLUMN TYPE / DEFAULT contexts.
17181        (Value::Json(s), DataType::Jsonb | DataType::Json) => Some(Value::Json(s)),
17182        // v7.10.4 — Text → BYTEA. Decode PG-style literal forms:
17183        //   - Hex:    `\x48656c6c6f`  (case-insensitive hex pairs)
17184        //   - Escape: `Hello\\000world`  (backslash + octal triples)
17185        //   - Plain:  any string → raw UTF-8 bytes (PG also accepts)
17186        // Errors surface as TypeMismatch so the operator gets a
17187        // clear "this literal isn't a bytea literal" hint.
17188        (Value::Text(s), DataType::Bytes) => {
17189            let bytes = decode_bytea_literal(&s).map_err(|e| {
17190                EngineError::Eval(EvalError::TypeMismatch {
17191                    detail: alloc::format!(
17192                        "cannot parse {s:?} as BYTEA for column `{col_name}`: {e}"
17193                    ),
17194                })
17195            })?;
17196            Some(Value::Bytes(bytes))
17197        }
17198        // v7.10.4 — BYTEA → Text round-trip uses the PG hex
17199        // output (lowercase, `\x` prefix). Important when a
17200        // SELECT pulls a bytea cell through a Text column path.
17201        (Value::Bytes(b), DataType::Text) => Some(Value::Text(encode_bytea_hex(&b))),
17202        // v7.17.0 — Text → UUID. PG accepts canonical hyphenated,
17203        // unhyphenated, uppercase, and `{...}`-braced forms; we
17204        // funnel all four through `spg_storage::parse_uuid_str`.
17205        // A malformed literal surfaces as a SQL TypeMismatch
17206        // rather than silently inserting garbage — `0-change
17207        // cutover` requires that an app inserting bad UUID text
17208        // sees the same hard error PG would raise.
17209        (Value::Text(s), DataType::Uuid) => match spg_storage::parse_uuid_str(&s) {
17210            Some(b) => Some(Value::Uuid(b)),
17211            None => {
17212                return Err(EngineError::Eval(EvalError::TypeMismatch {
17213                    detail: alloc::format!(
17214                        "invalid input syntax for type uuid: {s:?} (column `{col_name}`)"
17215                    ),
17216                }));
17217            }
17218        },
17219        // v7.17.0 — UUID → Text canonical 8-4-4-4-12 lowercase.
17220        // Surfaces when a SELECT plucks a uuid cell through a
17221        // Text column path (e.g. INSERT INTO log SELECT id::text
17222        // FROM other_table).
17223        (Value::Uuid(b), DataType::Text) => Some(Value::Text(spg_storage::format_uuid(&b))),
17224        // v7.17.0 Phase 3.P0-32 — Text → TIME. Accepts
17225        // `HH:MM:SS` and `HH:MM:SS.ffffff` (1-6 fractional digits).
17226        // Out-of-range hour/min/sec is a hard SQL error (no
17227        // silent truncation — same 0-change-cutover discipline
17228        // we apply to UUID).
17229        (Value::Text(s), DataType::Time) => match parse_time_str(&s) {
17230            Some(us) => Some(Value::Time(us)),
17231            None => {
17232                return Err(EngineError::Eval(EvalError::TypeMismatch {
17233                    detail: alloc::format!(
17234                        "invalid input syntax for type time: {s:?} (column `{col_name}`)"
17235                    ),
17236                }));
17237            }
17238        },
17239        // v7.17.0 Phase 3.P0-32 — TIME → Text canonical `HH:MM:SS[.ffffff]`.
17240        (Value::Time(us), DataType::Text) => Some(Value::Text(eval::format_time(us))),
17241        // v7.17.0 Phase 3.P0-33 — int / bigint → YEAR. Range
17242        // check enforces the MySQL canonical 1901..=2155 + 0
17243        // sentinel; out-of-range is a hard SQL error (no silent
17244        // truncation, mirrors P0-32 / P0-25 discipline).
17245        (Value::SmallInt(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
17246        (Value::Int(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
17247        (Value::BigInt(n), DataType::Year) => Some(coerce_int_to_year(n, col_name)?),
17248        // Text → YEAR. Accepts the 4-digit decimal form only;
17249        // two-digit YEAR (`'99'` → 1999) was deprecated in MySQL
17250        // 5.7 and is out of scope for v7.17.0.
17251        (Value::Text(s), DataType::Year) => match s.trim().parse::<i64>() {
17252            Ok(n) => Some(coerce_int_to_year(n, col_name)?),
17253            Err(_) => {
17254                return Err(EngineError::Eval(EvalError::TypeMismatch {
17255                    detail: alloc::format!(
17256                        "invalid input syntax for type year: {s:?} (column `{col_name}`)"
17257                    ),
17258                }));
17259            }
17260        },
17261        // YEAR → Text 4-digit zero-padded.
17262        (Value::Year(y), DataType::Text) => Some(Value::Text(alloc::format!("{y:04}"))),
17263        // v7.17.0 Phase 3.P0-34 — Text → TIMETZ. Mandatory
17264        // signed offset suffix; missing offset is a hard error
17265        // (SPG has no session TZ wired into eval, unlike PG).
17266        (Value::Text(s), DataType::TimeTz) => match parse_timetz_str(&s) {
17267            Some((us, offset_secs)) => Some(Value::TimeTz { us, offset_secs }),
17268            None => {
17269                return Err(EngineError::Eval(EvalError::TypeMismatch {
17270                    detail: alloc::format!(
17271                        "invalid input syntax for type time with time zone: \
17272                         {s:?} (column `{col_name}`)"
17273                    ),
17274                }));
17275            }
17276        },
17277        // TIMETZ → Text canonical `HH:MM:SS[.ffffff]±HH[:MM]`.
17278        (Value::TimeTz { us, offset_secs }, DataType::Text) => {
17279            Some(Value::Text(eval::format_timetz(us, offset_secs)))
17280        }
17281        // v7.17.0 Phase 3.P0-35 — Text → MONEY. Accepts `$N.NN`,
17282        // `$N,NNN.NN`, optional leading `-`. Bare numeric literals
17283        // arrive via the Int/BigInt/Float/Numeric arms below.
17284        (Value::Text(s), DataType::Money) => match parse_money_str(&s) {
17285            Some(c) => Some(Value::Money(c)),
17286            None => {
17287                return Err(EngineError::Eval(EvalError::TypeMismatch {
17288                    detail: alloc::format!(
17289                        "invalid input syntax for type money: {s:?} (column `{col_name}`)"
17290                    ),
17291                }));
17292            }
17293        },
17294        // Int / BigInt / SmallInt / Float / Numeric → MONEY.
17295        // Bare numeric literal is interpreted as a major-unit
17296        // amount (matches PG: `100`::money → $100.00 = 10000 cents).
17297        (Value::SmallInt(n), DataType::Money) => {
17298            Some(Value::Money(i64::from(n).saturating_mul(100)))
17299        }
17300        (Value::Int(n), DataType::Money) => Some(Value::Money(i64::from(n).saturating_mul(100))),
17301        (Value::BigInt(n), DataType::Money) => Some(Value::Money(n.saturating_mul(100))),
17302        (Value::Float(x), DataType::Money) => {
17303            // Round half-away-from-zero to cents (no_std — no
17304            // `f64::round`, so hand-roll via biased truncation).
17305            let scaled = x * 100.0;
17306            let cents = if scaled >= 0.0 {
17307                (scaled + 0.5) as i64
17308            } else {
17309                (scaled - 0.5) as i64
17310            };
17311            Some(Value::Money(cents))
17312        }
17313        (Value::Numeric { scaled, scale }, DataType::Money) => {
17314            // Convert exact decimal to cents (scale 2). If scale > 2,
17315            // round half-away-from-zero. If scale < 2, multiply up.
17316            let cents = if scale == 2 {
17317                scaled
17318            } else if scale < 2 {
17319                let mult = 10_i128.pow(u32::from(2 - scale));
17320                scaled.saturating_mul(mult)
17321            } else {
17322                let div = 10_i128.pow(u32::from(scale - 2));
17323                let half = div / 2;
17324                let bias = if scaled >= 0 { half } else { -half };
17325                (scaled + bias) / div
17326            };
17327            Some(Value::Money(i64::try_from(cents).unwrap_or(i64::MAX)))
17328        }
17329        // MONEY → Text canonical `$N,NNN.CC`.
17330        (Value::Money(c), DataType::Text) => Some(Value::Text(eval::format_money(c))),
17331        // v7.17.0 Phase 3.P0-38 — Text → Range. Accepts canonical
17332        // PG forms: `'empty'`, `'[a,b)'`, `'(a,b]'`, `'[a,b]'`,
17333        // `'(a,b)'`, with empty lower or upper for unbounded.
17334        (Value::Text(s), DataType::Range(kind)) => match parse_range_str(&s, kind) {
17335            Some(v) => Some(v),
17336            None => {
17337                return Err(EngineError::Eval(EvalError::TypeMismatch {
17338                    detail: alloc::format!(
17339                        "invalid input syntax for range type: {s:?} (column `{col_name}`)"
17340                    ),
17341                }));
17342            }
17343        },
17344        // Range → Text canonical form (`[a,b)`, `'empty'`, etc).
17345        (v @ Value::Range { .. }, DataType::Text) => Some(Value::Text(format_range_str(&v))),
17346        // v7.17.0 Phase 3.P0-39 — Text → Hstore.
17347        (Value::Text(s), DataType::Hstore) => match parse_hstore_str(&s) {
17348            Some(pairs) => Some(Value::Hstore(pairs)),
17349            None => {
17350                return Err(EngineError::Eval(EvalError::TypeMismatch {
17351                    detail: alloc::format!(
17352                        "invalid input syntax for type hstore: {s:?} (column `{col_name}`)"
17353                    ),
17354                }));
17355            }
17356        },
17357        // Hstore → Text canonical `"k"=>"v"` form.
17358        (Value::Hstore(pairs), DataType::Text) => Some(Value::Text(format_hstore_str(&pairs))),
17359        // v7.17.0 Phase 3.P0-40 — Text → 2D arrays via PG
17360        // external `'{{a,b},{c,d}}'` literal.
17361        (Value::Text(s), DataType::IntArray2D) => match parse_int_2d_literal(&s) {
17362            Ok(m) => Some(Value::IntArray2D(m)),
17363            Err(e) => {
17364                return Err(EngineError::Eval(EvalError::TypeMismatch {
17365                    detail: alloc::format!(
17366                        "invalid input syntax for INT[][]: {s:?} (column `{col_name}`): {e}"
17367                    ),
17368                }));
17369            }
17370        },
17371        (Value::Text(s), DataType::BigIntArray2D) => match parse_bigint_2d_literal(&s) {
17372            Ok(m) => Some(Value::BigIntArray2D(m)),
17373            Err(e) => {
17374                return Err(EngineError::Eval(EvalError::TypeMismatch {
17375                    detail: alloc::format!(
17376                        "invalid input syntax for BIGINT[][]: {s:?} (column `{col_name}`): {e}"
17377                    ),
17378                }));
17379            }
17380        },
17381        (Value::Text(s), DataType::TextArray2D) => match parse_text_2d_literal(&s) {
17382            Ok(m) => Some(Value::TextArray2D(m)),
17383            Err(e) => {
17384                return Err(EngineError::Eval(EvalError::TypeMismatch {
17385                    detail: alloc::format!(
17386                        "invalid input syntax for TEXT[][]: {s:?} (column `{col_name}`): {e}"
17387                    ),
17388                }));
17389            }
17390        },
17391        // 2D arrays → Text canonical nested form.
17392        (Value::IntArray2D(rows), DataType::Text) => Some(Value::Text(format_int_2d_text(&rows))),
17393        (Value::BigIntArray2D(rows), DataType::Text) => {
17394            Some(Value::Text(format_bigint_2d_text(&rows)))
17395        }
17396        (Value::TextArray2D(rows), DataType::Text) => Some(Value::Text(format_text_2d_text(&rows))),
17397        // v7.10.11 — Text → TEXT[]. Decode PG's external array
17398        // form `'{a,b,NULL}'`. NULL element token (case-insensitive)
17399        // is the literal `NULL`; everything else is a quoted or
17400        // unquoted text element. mailrs `'{label1,label2}'::TEXT[]`.
17401        (Value::Text(s), DataType::TextArray) => {
17402            let arr = decode_text_array_literal(&s).map_err(|e| {
17403                EngineError::Eval(EvalError::TypeMismatch {
17404                    detail: alloc::format!(
17405                        "cannot parse {s:?} as TEXT[] for column `{col_name}`: {e}"
17406                    ),
17407                })
17408            })?;
17409            Some(Value::TextArray(arr))
17410        }
17411        // v7.16.0 — Text → IntArray / BigIntArray for the
17412        // spg-sqlx Bind path. Decode the PG external form
17413        // `{1,2,3}` as a TEXT array first, then parse each
17414        // element as int. Same shape as the TextArray decode
17415        // above with an element-wise narrow.
17416        (Value::Text(s), DataType::IntArray) => {
17417            let arr = decode_text_array_literal(&s).map_err(|e| {
17418                EngineError::Eval(EvalError::TypeMismatch {
17419                    detail: alloc::format!(
17420                        "cannot parse {s:?} as INT[] for column `{col_name}`: {e}"
17421                    ),
17422                })
17423            })?;
17424            let mut out: Vec<Option<i32>> = Vec::with_capacity(arr.len());
17425            for elem in arr {
17426                match elem {
17427                    None => out.push(None),
17428                    Some(t) => {
17429                        let n: i32 = t.parse().map_err(|_| {
17430                            EngineError::Eval(EvalError::TypeMismatch {
17431                                detail: alloc::format!(
17432                                    "cannot parse {t:?} as INT element for `{col_name}`"
17433                                ),
17434                            })
17435                        })?;
17436                        out.push(Some(n));
17437                    }
17438                }
17439            }
17440            Some(Value::IntArray(out))
17441        }
17442        (Value::Text(s), DataType::BigIntArray) => {
17443            let arr = decode_text_array_literal(&s).map_err(|e| {
17444                EngineError::Eval(EvalError::TypeMismatch {
17445                    detail: alloc::format!(
17446                        "cannot parse {s:?} as BIGINT[] for column `{col_name}`: {e}"
17447                    ),
17448                })
17449            })?;
17450            let mut out: Vec<Option<i64>> = Vec::with_capacity(arr.len());
17451            for elem in arr {
17452                match elem {
17453                    None => out.push(None),
17454                    Some(t) => {
17455                        let n: i64 = t.parse().map_err(|_| {
17456                            EngineError::Eval(EvalError::TypeMismatch {
17457                                detail: alloc::format!(
17458                                    "cannot parse {t:?} as BIGINT element for `{col_name}`"
17459                                ),
17460                            })
17461                        })?;
17462                        out.push(Some(n));
17463                    }
17464                }
17465            }
17466            Some(Value::BigIntArray(out))
17467        }
17468        // v7.10.11 — TEXT[] → Text round-trip uses PG's
17469        // external array form (`{a,b,NULL}`). Lets a SELECT
17470        // pull an array column through any Text-side codepath.
17471        (Value::TextArray(items), DataType::Text) => Some(Value::Text(encode_text_array(&items))),
17472        // v7.17.0 Phase 3.P0-68 — Text → VECTOR auto-coerce.
17473        // Matches the existing Text → TsVector arm and the
17474        // `::vector` cast: PG-canonical pgvector external form
17475        // (`'[1, 2, -3]'`) becomes a typed Vector value at the
17476        // column boundary. Dim mismatch surfaces as TypeMismatch.
17477        // For SQ8 / HALF encodings we chain through the standard
17478        // quantise helpers so the storage shape matches the
17479        // declared encoding without a second coerce pass.
17480        (Value::Text(s), DataType::Vector { dim, encoding }) => {
17481            let parsed = eval::parse_vector_text(&s).ok_or_else(|| {
17482                EngineError::Eval(EvalError::TypeMismatch {
17483                    detail: alloc::format!("cannot parse {s:?} as VECTOR for column `{col_name}`"),
17484                })
17485            })?;
17486            if parsed.len() != dim as usize {
17487                return Err(EngineError::Eval(EvalError::TypeMismatch {
17488                    detail: alloc::format!(
17489                        "VECTOR({dim}) column `{col_name}` rejects literal of length {}",
17490                        parsed.len()
17491                    ),
17492                }));
17493            }
17494            Some(match encoding {
17495                VecEncoding::F32 => Value::Vector(parsed),
17496                VecEncoding::Sq8 => Value::Sq8Vector(spg_storage::quantize::quantize(&parsed)),
17497                VecEncoding::F16 => {
17498                    Value::HalfVector(spg_storage::halfvec::HalfVector::from_f32_slice(&parsed))
17499                }
17500            })
17501        }
17502        // v7.16.1 — Text → TSVECTOR auto-coerce for the
17503        // INSERT-side wire path (mailrs round-9 A.2.a). PG
17504        // implicitly promotes the TEXT literal at INSERT into a
17505        // TSVECTOR column; SPG previously rejected with a hard
17506        // type mismatch, blocking 23,276 pg_dump rows into
17507        // `messages.search_vector`. We route through the same
17508        // `decode_tsvector_external` the `::tsvector` cast
17509        // already uses, so PG-canonical forms (`'word'`,
17510        // `'word:1A,2B'`, multi-lexeme, empty `''`) all parse.
17511        (Value::Text(s), DataType::TsVector) => {
17512            let lexs = eval::decode_tsvector_external(&s).map_err(|e| {
17513                EngineError::Eval(EvalError::TypeMismatch {
17514                    detail: alloc::format!(
17515                        "cannot parse {s:?} as TSVECTOR for column `{col_name}`: {e}"
17516                    ),
17517                })
17518            })?;
17519            Some(Value::TsVector(lexs))
17520        }
17521        (Value::Text(s), DataType::Timestamp | DataType::Timestamptz) => {
17522            let t = eval::parse_timestamp_literal(&s).ok_or_else(|| {
17523                EngineError::Eval(EvalError::TypeMismatch {
17524                    detail: alloc::format!(
17525                        "cannot parse {s:?} as TIMESTAMP for column `{col_name}`"
17526                    ),
17527                })
17528            })?;
17529            Some(Value::Timestamp(t))
17530        }
17531        // DATE ↔ TIMESTAMP convertibility (DATE → midnight,
17532        // TIMESTAMP → day truncation).
17533        (Value::Date(d), DataType::Timestamp | DataType::Timestamptz) => {
17534            Some(Value::Timestamp(i64::from(d) * 86_400_000_000))
17535        }
17536        // v7.9.21 — Value::Timestamp lands in either Timestamp
17537        // or Timestamptz columns; the on-disk layout is the
17538        // same i64 microseconds UTC.
17539        (Value::Timestamp(t), DataType::Timestamptz) => Some(Value::Timestamp(t)),
17540        (Value::Timestamp(t), DataType::Date) => {
17541            let days = t.div_euclid(86_400_000_000);
17542            i32::try_from(days).ok().map(Value::Date)
17543        }
17544        (
17545            Value::Numeric {
17546                scaled,
17547                scale: src_scale,
17548            },
17549            DataType::Numeric { precision, scale },
17550        ) => Some(numeric_rescale(
17551            scaled, src_scale, precision, scale, col_name,
17552        )?),
17553        #[allow(clippy::cast_precision_loss)]
17554        (Value::Numeric { scaled, scale }, DataType::Float) => {
17555            let mut div = 1.0_f64;
17556            for _ in 0..scale {
17557                div *= 10.0;
17558            }
17559            Some(Value::Float((scaled as f64) / div))
17560        }
17561        (Value::Numeric { scaled, scale }, DataType::Int) => {
17562            let truncated = numeric_truncate_to_integer(scaled, scale);
17563            i32::try_from(truncated).ok().map(Value::Int)
17564        }
17565        (Value::Numeric { scaled, scale }, DataType::BigInt) => {
17566            let truncated = numeric_truncate_to_integer(scaled, scale);
17567            i64::try_from(truncated).ok().map(Value::BigInt)
17568        }
17569        (Value::Numeric { scaled, scale }, DataType::SmallInt) => {
17570            let truncated = numeric_truncate_to_integer(scaled, scale);
17571            i16::try_from(truncated).ok().map(Value::SmallInt)
17572        }
17573        // VARCHAR(n) enforces an upper bound on character count.
17574        (Value::Text(s), DataType::Varchar(max)) => {
17575            if u32::try_from(s.chars().count()).unwrap_or(u32::MAX) <= max {
17576                Some(Value::Text(s))
17577            } else {
17578                return Err(EngineError::Unsupported(alloc::format!(
17579                    "value for VARCHAR({max}) column `{col_name}` exceeds length: \
17580                     {} chars",
17581                    s.chars().count()
17582                )));
17583            }
17584        }
17585        // v6.0.1: f32 → SQ8 INSERT-time quantisation. Triggered
17586        // when the column declares `VECTOR(N) USING SQ8` and
17587        // the INSERT VALUES expression yields a raw f32 vector
17588        // (the normal pgvector-shape literal). Dim mismatch
17589        // falls through the `_ => None` arm and surfaces as
17590        // `TypeMismatch` with the expected SQ8 column type —
17591        // matching the F32 path's existing error.
17592        (
17593            Value::Vector(v),
17594            DataType::Vector {
17595                dim,
17596                encoding: VecEncoding::Sq8,
17597            },
17598        ) if v.len() == dim as usize => Some(Value::Sq8Vector(spg_storage::quantize::quantize(&v))),
17599        // v6.0.3: f32 → f16 INSERT-time conversion for HALF
17600        // columns. Bit-exact at the storage layer (modulo
17601        // half-precision rounding); no rerank pass needed at
17602        // search time.
17603        (
17604            Value::Vector(v),
17605            DataType::Vector {
17606                dim,
17607                encoding: VecEncoding::F16,
17608            },
17609        ) if v.len() == dim as usize => Some(Value::HalfVector(
17610            spg_storage::halfvec::HalfVector::from_f32_slice(&v),
17611        )),
17612        // CHAR(n) right-pads with U+0020 to exactly n chars; if the input
17613        // is already longer we reject (PG truncates trailing-space-only;
17614        // staying strict for v1).
17615        (Value::Text(s), DataType::Char(size)) => {
17616            let len = u32::try_from(s.chars().count()).unwrap_or(u32::MAX);
17617            if len > size {
17618                return Err(EngineError::Unsupported(alloc::format!(
17619                    "value for CHAR({size}) column `{col_name}` exceeds length: \
17620                     {len} chars"
17621                )));
17622            }
17623            let need = (size - len) as usize;
17624            let mut padded = s;
17625            padded.reserve(need);
17626            for _ in 0..need {
17627                padded.push(' ');
17628            }
17629            Some(Value::Text(padded))
17630        }
17631        _ => None,
17632    };
17633    coerced.ok_or(EngineError::Storage(StorageError::TypeMismatch {
17634        column: col_name.into(),
17635        expected,
17636        actual,
17637        position,
17638    }))
17639}
17640
17641/// v7.12.4 — render a function arg list into the
17642/// canonical form the storage layer caches as
17643/// [`spg_storage::FunctionDef::args_repr`]. The catalogue uses
17644/// this string for both display + as a coarse signature key
17645/// for the (deferred) overload resolution v7.12.5+ adds.
17646fn render_function_args(args: &[spg_sql::ast::FunctionArg]) -> alloc::string::String {
17647    use core::fmt::Write;
17648    let mut out = alloc::string::String::from("(");
17649    for (i, a) in args.iter().enumerate() {
17650        if i > 0 {
17651            out.push_str(", ");
17652        }
17653        match a.mode {
17654            spg_sql::ast::FunctionArgMode::In => {}
17655            spg_sql::ast::FunctionArgMode::Out => out.push_str("OUT "),
17656            spg_sql::ast::FunctionArgMode::InOut => out.push_str("INOUT "),
17657        }
17658        if let Some(n) = &a.name {
17659            out.push_str(n);
17660            out.push(' ');
17661        }
17662        match &a.ty {
17663            spg_sql::ast::FunctionArgType::Typed(t) => {
17664                let _ = write!(out, "{t}");
17665            }
17666            spg_sql::ast::FunctionArgType::Raw(s) => out.push_str(s),
17667        }
17668    }
17669    out.push(')');
17670    out
17671}
17672
17673/// v7.19 P5 — true iff `expr` is `unnest(arg)` at the top level
17674/// (case-insensitive). Used by `exec_select_cancel`'s
17675/// projection loop to detect Set-Returning-Function rows that
17676/// need per-row expansion. Only the top-level call counts —
17677/// `coalesce(unnest(arr), 'x')` is NOT a SRF row from the
17678/// projection's perspective; it would surface as an "unknown
17679/// function" mismatch downstream, which is what we want
17680/// (multi-SRF / nested SRF is documented carve-out for v7.19).
17681fn is_top_level_unnest(expr: &spg_sql::ast::Expr) -> bool {
17682    match expr {
17683        spg_sql::ast::Expr::FunctionCall { name, args } => {
17684            name.eq_ignore_ascii_case("unnest") && args.len() == 1
17685        }
17686        _ => false,
17687    }
17688}
17689
17690/// v7.19 P5 — extract the array argument out of a top-level
17691/// `unnest(arg)` call. `None` if `expr` isn't a `unnest` call
17692/// of arity 1 (mirrors `is_top_level_unnest`).
17693fn top_level_unnest_arg(expr: &spg_sql::ast::Expr) -> Option<&spg_sql::ast::Expr> {
17694    match expr {
17695        spg_sql::ast::Expr::FunctionCall { name, args }
17696            if name.eq_ignore_ascii_case("unnest") && args.len() == 1 =>
17697        {
17698            Some(&args[0])
17699        }
17700        _ => None,
17701    }
17702}
17703
17704/// v7.19 P5 — turn an array-typed `Value` into the element list
17705/// `unnest()` projection emits. NULL → empty list (PG: `unnest(NULL)
17706/// = (no rows)`). Non-array values fall through to a type-mismatch
17707/// error.
17708fn array_value_to_elements(v: &Value) -> Result<Vec<Value>, EngineError> {
17709    match v {
17710        Value::Null => Ok(Vec::new()),
17711        Value::TextArray(items) => Ok(items
17712            .iter()
17713            .map(|opt| {
17714                opt.as_ref()
17715                    .map(|s| Value::Text(s.clone()))
17716                    .unwrap_or(Value::Null)
17717            })
17718            .collect()),
17719        Value::IntArray(items) => Ok(items
17720            .iter()
17721            .map(|opt| opt.map(Value::Int).unwrap_or(Value::Null))
17722            .collect()),
17723        Value::BigIntArray(items) => Ok(items
17724            .iter()
17725            .map(|opt| opt.map(Value::BigInt).unwrap_or(Value::Null))
17726            .collect()),
17727        other => Err(EngineError::Eval(EvalError::TypeMismatch {
17728            detail: alloc::format!(
17729                "unnest() expects an array argument, got {:?}",
17730                other.data_type()
17731            ),
17732        })),
17733    }
17734}
17735
17736#[cfg(test)]
17737mod tests {
17738    use super::*;
17739    use alloc::vec;
17740
17741    fn unwrap_command_ok(r: &QueryResult) -> usize {
17742        match r {
17743            QueryResult::CommandOk { affected, .. } => *affected,
17744            QueryResult::Rows { .. } => panic!("expected CommandOk, got Rows"),
17745        }
17746    }
17747
17748    #[test]
17749    fn update_seek_positions_engages_on_indexed_eq() {
17750        let mut e = Engine::new();
17751        e.execute("CREATE TABLE b (id INT NOT NULL, v INT NOT NULL)")
17752            .unwrap();
17753        e.execute("CREATE INDEX b_id ON b (id)").unwrap();
17754        for i in 0..100 {
17755            e.execute(&alloc::format!("INSERT INTO b VALUES ({i}, {i})"))
17756                .unwrap();
17757        }
17758        let stmt = spg_sql::parser::parse_statement("UPDATE b SET v = v + 1 WHERE id = 42")
17759            .expect("parse");
17760        let Statement::Update(u) = stmt else {
17761            panic!("expected Update, got {stmt:?}");
17762        };
17763        let w = u.where_.as_ref().expect("where");
17764        let table = e.catalog().get("b").unwrap();
17765        let schema_cols = table.schema().columns.clone();
17766        // step-by-step: each sub-resolution must succeed.
17767        let Expr::Binary { lhs, op, rhs } = w else {
17768            panic!("WHERE not Binary: {w:?}");
17769        };
17770        assert_eq!(*op, BinOp::Eq, "op not Eq");
17771        let pair = resolve_col_literal_pair(lhs, rhs, &schema_cols, "b");
17772        assert!(
17773            pair.is_some(),
17774            "resolve_col_literal_pair None: lhs={lhs:?} rhs={rhs:?}"
17775        );
17776        let (col_pos, value) = pair.unwrap();
17777        assert!(
17778            table.index_on(col_pos).is_some(),
17779            "no index on col {col_pos}"
17780        );
17781        assert!(
17782            IndexKey::from_value(&value).is_some(),
17783            "IndexKey::from_value None for {value:?}"
17784        );
17785        let positions = try_index_seek_positions(w, &schema_cols, table, "b");
17786        assert_eq!(positions, Some(vec![42]), "seek did not engage");
17787    }
17788
17789    #[test]
17790    fn create_table_registers_schema() {
17791        let mut e = Engine::new();
17792        e.execute("CREATE TABLE foo (a INT NOT NULL, b TEXT)")
17793            .unwrap();
17794        assert_eq!(e.catalog().table_count(), 1);
17795        let t = e.catalog().get("foo").unwrap();
17796        assert_eq!(t.schema().columns.len(), 2);
17797        assert_eq!(t.schema().columns[0].ty, DataType::Int);
17798        assert!(!t.schema().columns[0].nullable);
17799        assert_eq!(t.schema().columns[1].ty, DataType::Text);
17800    }
17801
17802    #[test]
17803    fn create_table_vector_default_is_f32_encoded() {
17804        let mut e = Engine::new();
17805        e.execute("CREATE TABLE t (v VECTOR(8))").unwrap();
17806        let t = e.catalog().get("t").unwrap();
17807        assert_eq!(
17808            t.schema().columns[0].ty,
17809            DataType::Vector {
17810                dim: 8,
17811                encoding: VecEncoding::F32,
17812            },
17813        );
17814    }
17815
17816    #[test]
17817    fn create_table_vector_using_sq8_succeeds() {
17818        // v6.0.1 step 3: the step-1 fence in `column_def_to_schema`
17819        // is lifted. CREATE TABLE persists an SQ8 column type in
17820        // the catalog; INSERT (next test) quantises raw f32 input.
17821        let mut e = Engine::new();
17822        e.execute("CREATE TABLE t (v VECTOR(8) USING SQ8)").unwrap();
17823        let t = e.catalog().get("t").unwrap();
17824        assert_eq!(
17825            t.schema().columns[0].ty,
17826            DataType::Vector {
17827                dim: 8,
17828                encoding: VecEncoding::Sq8,
17829            },
17830        );
17831    }
17832
17833    #[test]
17834    fn insert_into_sq8_column_quantises_f32_payload() {
17835        // v6.0.1 step 3: INSERT-time `coerce_value` rewrites a raw
17836        // `Value::Vector(Vec<f32>)` literal into the column's
17837        // quantised representation. The row that lands in the
17838        // catalog must therefore hold a `Value::Sq8Vector`, not the
17839        // original f32 buffer — that's the bit that delivers the
17840        // 4× compression target.
17841        let mut e = Engine::new();
17842        e.execute("CREATE TABLE t (v VECTOR(4) USING SQ8)").unwrap();
17843        e.execute("INSERT INTO t VALUES ([0.0, 0.25, 0.5, 1.0])")
17844            .unwrap();
17845        let t = e.catalog().get("t").unwrap();
17846        assert_eq!(t.rows().len(), 1);
17847        match &t.rows()[0].values[0] {
17848            Value::Sq8Vector(q) => {
17849                assert_eq!(q.bytes.len(), 4);
17850                // min/max are derived from the payload: min=0.0, max=1.0.
17851                assert!((q.min - 0.0).abs() < 1e-6);
17852                assert!((q.max - 1.0).abs() < 1e-6);
17853            }
17854            other => panic!("expected Sq8Vector cell, got {other:?}"),
17855        }
17856    }
17857
17858    #[test]
17859    fn create_table_vector_using_half_succeeds_and_insert_converts_to_f16() {
17860        // v6.0.3: CREATE TABLE accepts USING HALF; INSERT path
17861        // converts the incoming `Value::Vector(Vec<f32>)` cell
17862        // into `Value::HalfVector(HalfVector)` via the new
17863        // `coerce_value` arm. The dequantised round-trip is
17864        // bit-exact for f16-representable values, so 0.0 / 0.25
17865        // / 0.5 / 1.0 hit their grid points exactly.
17866        let mut e = Engine::new();
17867        e.execute("CREATE TABLE t (v VECTOR(4) USING HALF)")
17868            .unwrap();
17869        e.execute("INSERT INTO t VALUES ([0.0, 0.25, 0.5, 1.0])")
17870            .unwrap();
17871        let t = e.catalog().get("t").unwrap();
17872        assert_eq!(t.rows().len(), 1);
17873        match &t.rows()[0].values[0] {
17874            Value::HalfVector(h) => {
17875                assert_eq!(h.dim(), 4);
17876                let back = h.to_f32_vec();
17877                let expected = alloc::vec![0.0_f32, 0.25, 0.5, 1.0];
17878                for (g, e) in back.iter().zip(expected.iter()) {
17879                    assert!(
17880                        (g - e).abs() < 1e-6,
17881                        "{g} vs {e} should be exact on f16 grid"
17882                    );
17883                }
17884            }
17885            other => panic!("expected HalfVector cell, got {other:?}"),
17886        }
17887    }
17888
17889    #[test]
17890    fn alter_index_rebuild_in_place_succeeds() {
17891        // v6.0.4: bare REBUILD (no encoding switch) walks every
17892        // row again to rebuild the NSW graph. Verifies the engine
17893        // dispatch + storage helper plumbing without changing any
17894        // cell encoding.
17895        let mut e = Engine::new();
17896        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(3) NOT NULL)")
17897            .unwrap();
17898        for i in 0..8_i32 {
17899            #[allow(clippy::cast_precision_loss)]
17900            let base = (i as f32) * 0.1;
17901            e.execute(&alloc::format!(
17902                "INSERT INTO t VALUES ({i}, [{base}, {b1}, {b2}])",
17903                b1 = base + 0.01,
17904                b2 = base + 0.02,
17905            ))
17906            .unwrap();
17907        }
17908        e.execute("CREATE INDEX t_idx ON t USING hnsw (v)").unwrap();
17909        e.execute("ALTER INDEX t_idx REBUILD").unwrap();
17910        // Schema encoding stays F32 (no encoding clause).
17911        assert_eq!(
17912            e.catalog().get("t").unwrap().schema().columns[1].ty,
17913            DataType::Vector {
17914                dim: 3,
17915                encoding: VecEncoding::F32,
17916            },
17917        );
17918    }
17919
17920    #[test]
17921    fn alter_index_rebuild_with_encoding_switches_cell_type() {
17922        // v6.0.4: REBUILD WITH (encoding = SQ8) recodes every
17923        // stored cell from F32 → SQ8 + rebuilds the graph atop the
17924        // new encoding. Post-rebuild, cells must be Sq8Vector and
17925        // the schema must report encoding = Sq8.
17926        let mut e = Engine::new();
17927        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(4) NOT NULL)")
17928            .unwrap();
17929        e.execute("INSERT INTO t VALUES (1, [0.0, 0.25, 0.5, 1.0])")
17930            .unwrap();
17931        e.execute("CREATE INDEX t_idx ON t USING hnsw (v)").unwrap();
17932        e.execute("ALTER INDEX t_idx REBUILD WITH (encoding = SQ8)")
17933            .unwrap();
17934        let t = e.catalog().get("t").unwrap();
17935        assert_eq!(
17936            t.schema().columns[1].ty,
17937            DataType::Vector {
17938                dim: 4,
17939                encoding: VecEncoding::Sq8,
17940            },
17941        );
17942        assert!(matches!(t.rows()[0].values[1], Value::Sq8Vector(_)));
17943    }
17944
17945    #[test]
17946    fn alter_index_rebuild_unknown_index_errors() {
17947        let mut e = Engine::new();
17948        let err = e.execute("ALTER INDEX nope REBUILD").unwrap_err();
17949        assert!(
17950            matches!(
17951                &err,
17952                EngineError::Storage(StorageError::IndexNotFound { name }) if name == "nope"
17953            ),
17954            "got: {err}"
17955        );
17956    }
17957
17958    #[test]
17959    fn alter_index_rebuild_on_btree_index_errors() {
17960        // REBUILD on a B-tree index has no semantic meaning in
17961        // v6.0.4 — rejected at the storage layer with `Unsupported`.
17962        let mut e = Engine::new();
17963        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
17964        e.execute("INSERT INTO t VALUES (1)").unwrap();
17965        e.execute("CREATE INDEX t_idx ON t (id)").unwrap();
17966        let err = e.execute("ALTER INDEX t_idx REBUILD").unwrap_err();
17967        assert!(
17968            matches!(&err, EngineError::Storage(StorageError::Unsupported(_))),
17969            "got: {err}"
17970        );
17971    }
17972
17973    #[test]
17974    fn prepared_insert_substitutes_placeholders() {
17975        // v6.1.1: prepare() parses once; execute_prepared() walks the
17976        // AST and replaces $1/$2 with the param Values BEFORE the
17977        // dispatch sees them. Same logical result as a simple-query
17978        // INSERT, but parse happens once per *statement*, not per
17979        // execution.
17980        let mut e = Engine::new();
17981        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)")
17982            .unwrap();
17983        let stmt = e.prepare("INSERT INTO t VALUES ($1, $2)").unwrap();
17984        for (id, name) in [(1, "alice"), (2, "bob"), (3, "carol")] {
17985            e.execute_prepared(stmt.clone(), &[Value::Int(id), Value::Text(name.into())])
17986                .unwrap();
17987        }
17988        // Read back via simple-query SELECT.
17989        let rows_result = e.execute("SELECT id, name FROM t").unwrap();
17990        let QueryResult::Rows { rows, .. } = rows_result else {
17991            panic!("expected Rows")
17992        };
17993        assert_eq!(rows.len(), 3);
17994    }
17995
17996    #[test]
17997    fn prepared_select_with_placeholder_filters_rows() {
17998        let mut e = Engine::new();
17999        e.execute("CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)")
18000            .unwrap();
18001        for i in 0..10_i32 {
18002            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, {})", i * 7))
18003                .unwrap();
18004        }
18005        let stmt = e.prepare("SELECT id FROM t WHERE v = $1").unwrap();
18006        let QueryResult::Rows { rows, .. } = e.execute_prepared(stmt, &[Value::Int(35)]).unwrap()
18007        else {
18008            panic!("expected Rows")
18009        };
18010        // v = 35 means i*7 = 35 → i = 5.
18011        assert_eq!(rows.len(), 1);
18012        assert_eq!(rows[0].values[0], Value::Int(5));
18013    }
18014
18015    #[test]
18016    fn prepared_too_few_params_errors() {
18017        let mut e = Engine::new();
18018        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18019        let stmt = e.prepare("INSERT INTO t VALUES ($1)").unwrap();
18020        let err = e.execute_prepared(stmt, &[]).unwrap_err();
18021        assert!(
18022            matches!(
18023                &err,
18024                EngineError::Eval(EvalError::PlaceholderOutOfRange { n: 1, bound: 0 })
18025            ),
18026            "got: {err}"
18027        );
18028    }
18029
18030    #[test]
18031    fn bytea_cast_round_trips_text_input() {
18032        // v7.18 — `'hello'::bytea` produces the raw bytes. Closes
18033        // the mailrs D-pre #3 reverse-acceptance gap.
18034        let e = Engine::new();
18035        let r = e.execute_readonly("SELECT 'hello'::bytea").unwrap();
18036        let QueryResult::Rows { rows, .. } = r else {
18037            panic!("expected Rows")
18038        };
18039        assert_eq!(rows.len(), 1);
18040        assert_eq!(rows[0].values[0], Value::Bytes(b"hello".to_vec()));
18041    }
18042
18043    #[test]
18044    fn bytea_cast_pg_escape_hex_form() {
18045        // E'\\xdeadbeef'::bytea — E-string decodes to `\xdeadbeef`
18046        // (literal 10 chars), then ::bytea reads it as PG hex
18047        // form bytea literal → 4 bytes.
18048        let e = Engine::new();
18049        let r = e.execute_readonly(r"SELECT E'\\xdeadbeef'::bytea").unwrap();
18050        let QueryResult::Rows { rows, .. } = r else {
18051            panic!("expected Rows")
18052        };
18053        assert_eq!(
18054            rows[0].values[0],
18055            Value::Bytes(vec![0xde, 0xad, 0xbe, 0xef])
18056        );
18057    }
18058
18059    #[test]
18060    fn bytea_cast_chains_through_octet_length() {
18061        // octet_length('hello'::bytea) → 5. Confirms the cast
18062        // composes inside larger expressions, not just at top
18063        // level.
18064        let e = Engine::new();
18065        let r = e
18066            .execute_readonly("SELECT octet_length('hello'::bytea)")
18067            .unwrap();
18068        let QueryResult::Rows { rows, .. } = r else {
18069            panic!("expected Rows")
18070        };
18071        match &rows[0].values[0] {
18072            Value::Int(n) => assert_eq!(*n, 5),
18073            Value::BigInt(n) => assert_eq!(*n, 5),
18074            other => panic!("expected integer length, got {other:?}"),
18075        }
18076    }
18077
18078    #[test]
18079    fn readonly_prepared_on_snapshot_select_with_placeholder() {
18080        // v7.18 — sqlx Pool fan-out relies on running prepared
18081        // SELECTs against a frozen snapshot without re-entering
18082        // the writer engine. Mirrors the simple-query SELECT path
18083        // in `execute_readonly_on_snapshot` but takes a Statement
18084        // + bound params (the shape sqlx's Execute path produces).
18085        let mut e = Engine::new();
18086        e.execute("CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)")
18087            .unwrap();
18088        for i in 0..10_i32 {
18089            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, {})", i * 7))
18090                .unwrap();
18091        }
18092        let snapshot = e.clone_snapshot();
18093        let stmt = e.prepare("SELECT id FROM t WHERE v = $1").unwrap();
18094        let QueryResult::Rows { rows, .. } =
18095            Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(35)])
18096                .unwrap()
18097        else {
18098            panic!("expected Rows")
18099        };
18100        assert_eq!(rows.len(), 1);
18101        assert_eq!(rows[0].values[0], Value::Int(5));
18102    }
18103
18104    #[test]
18105    fn readonly_prepared_on_snapshot_rejects_writes() {
18106        // DDL / DML prepared statements on the readonly path must
18107        // surface `WriteRequired` so the spg-sqlx connection layer
18108        // routes them to the writer mutex instead of the snapshot.
18109        let mut e = Engine::new();
18110        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18111        let snapshot = e.clone_snapshot();
18112        let stmt = e.prepare("INSERT INTO t VALUES ($1)").unwrap();
18113        let err = Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(1)])
18114            .unwrap_err();
18115        assert!(matches!(&err, EngineError::WriteRequired), "got: {err}");
18116    }
18117
18118    #[test]
18119    fn readonly_prepared_on_snapshot_frozen_view() {
18120        // The snapshot reflects engine state at clone_snapshot()
18121        // time. Writes after the snapshot are NOT visible — caller
18122        // takes a fresh snapshot (or `AsyncReadHandle::refresh()`)
18123        // to see them. This is the contract the per-statement
18124        // refresh in spg-sqlx relies on.
18125        let mut e = Engine::new();
18126        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18127        e.execute("INSERT INTO t VALUES (1)").unwrap();
18128        let snapshot = e.clone_snapshot();
18129        e.execute("INSERT INTO t VALUES (2)").unwrap();
18130        let stmt = e.prepare("SELECT id FROM t WHERE id = $1").unwrap();
18131        let QueryResult::Rows { rows, .. } =
18132            Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(2)])
18133                .unwrap()
18134        else {
18135            panic!("expected Rows")
18136        };
18137        assert!(rows.is_empty(), "id=2 was inserted after snapshot");
18138    }
18139
18140    #[test]
18141    fn describe_prepared_on_snapshot_resolves_columns() {
18142        // v7.18 — sqlx's Executor::describe path on the readonly
18143        // fan-out needs to resolve column names + types against
18144        // the snapshot's catalog (not the live engine's catalog,
18145        // which may have moved on).
18146        let mut e = Engine::new();
18147        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)")
18148            .unwrap();
18149        let snapshot = e.clone_snapshot();
18150        let stmt = e.prepare("SELECT id, name FROM t WHERE id = $1").unwrap();
18151        let (_params, cols) = Engine::describe_prepared_on_snapshot(&snapshot, &stmt);
18152        assert_eq!(cols.len(), 2);
18153        assert_eq!(cols[0].name, "id");
18154        assert_eq!(cols[0].ty, DataType::Int);
18155        assert_eq!(cols[1].name, "name");
18156        assert_eq!(cols[1].ty, DataType::Text);
18157    }
18158
18159    #[test]
18160    fn insert_into_half_column_dim_mismatch_errors() {
18161        let mut e = Engine::new();
18162        e.execute("CREATE TABLE t (v VECTOR(4) USING HALF)")
18163            .unwrap();
18164        let err = e.execute("INSERT INTO t VALUES ([1.0, 2.0])").unwrap_err();
18165        assert!(matches!(
18166            &err,
18167            EngineError::Storage(StorageError::TypeMismatch { .. })
18168        ));
18169    }
18170
18171    #[test]
18172    fn insert_into_sq8_column_dim_mismatch_errors() {
18173        // Dim mismatch falls through the `coerce_value` Vector→Sq8
18174        // arm's guard and surfaces as `TypeMismatch` — the same
18175        // error the F32 path produces today, so client error
18176        // handling stays uniform across encodings.
18177        let mut e = Engine::new();
18178        e.execute("CREATE TABLE t (v VECTOR(4) USING SQ8)").unwrap();
18179        let err = e.execute("INSERT INTO t VALUES ([1.0, 2.0])").unwrap_err();
18180        assert!(
18181            matches!(
18182                &err,
18183                EngineError::Storage(StorageError::TypeMismatch { .. })
18184            ),
18185            "got: {err}",
18186        );
18187    }
18188
18189    #[test]
18190    fn create_table_duplicate_errors() {
18191        let mut e = Engine::new();
18192        e.execute("CREATE TABLE foo (a INT)").unwrap();
18193        let err = e.execute("CREATE TABLE foo (a INT)").unwrap_err();
18194        assert!(matches!(
18195            err,
18196            EngineError::Storage(StorageError::DuplicateTable { ref name }) if name == "foo"
18197        ));
18198    }
18199
18200    #[test]
18201    fn insert_into_unknown_table_errors() {
18202        let mut e = Engine::new();
18203        let err = e.execute("INSERT INTO ghost VALUES (1)").unwrap_err();
18204        assert!(matches!(
18205            err,
18206            EngineError::Storage(StorageError::TableNotFound { ref name }) if name == "ghost"
18207        ));
18208    }
18209
18210    #[test]
18211    fn insert_happy_path_reports_one_affected() {
18212        let mut e = Engine::new();
18213        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
18214        let r = e.execute("INSERT INTO foo VALUES (42)").unwrap();
18215        assert_eq!(unwrap_command_ok(&r), 1);
18216        assert_eq!(e.catalog().get("foo").unwrap().row_count(), 1);
18217    }
18218
18219    #[test]
18220    fn insert_arity_mismatch_propagates() {
18221        let mut e = Engine::new();
18222        e.execute("CREATE TABLE foo (a INT, b TEXT)").unwrap();
18223        let err = e.execute("INSERT INTO foo VALUES (1)").unwrap_err();
18224        assert!(matches!(
18225            err,
18226            EngineError::Storage(StorageError::ArityMismatch { .. })
18227        ));
18228    }
18229
18230    #[test]
18231    fn insert_negative_integer_via_unary_minus() {
18232        let mut e = Engine::new();
18233        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
18234        e.execute("INSERT INTO foo VALUES (-7)").unwrap();
18235        let rows = e.catalog().get("foo").unwrap().rows();
18236        assert_eq!(rows[0].values[0], Value::Int(-7));
18237    }
18238
18239    #[test]
18240    fn insert_expression_evaluated_against_empty_context() {
18241        // PG-canonical: INSERT VALUES accepts an arbitrary scalar
18242        // expression. The engine evaluates against an empty row
18243        // context — column references would error, but pure
18244        // arithmetic / function calls are fine.
18245        let mut e = Engine::new();
18246        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
18247        e.execute("INSERT INTO foo VALUES (1 + 2)").unwrap();
18248        let rows = e.catalog().get("foo").unwrap().rows();
18249        assert_eq!(rows[0].values[0], Value::Int(3));
18250    }
18251
18252    #[test]
18253    fn select_star_returns_all_rows_in_insertion_order() {
18254        let mut e = Engine::new();
18255        e.execute("CREATE TABLE foo (a INT NOT NULL, b TEXT NOT NULL)")
18256            .unwrap();
18257        e.execute("INSERT INTO foo VALUES (1, 'one')").unwrap();
18258        e.execute("INSERT INTO foo VALUES (2, 'two')").unwrap();
18259        e.execute("INSERT INTO foo VALUES (3, 'three')").unwrap();
18260
18261        let r = e.execute("SELECT * FROM foo").unwrap();
18262        let QueryResult::Rows { columns, rows } = r else {
18263            panic!("expected Rows")
18264        };
18265        assert_eq!(columns.len(), 2);
18266        assert_eq!(columns[0].name, "a");
18267        assert_eq!(rows.len(), 3);
18268        assert_eq!(
18269            rows[1].values,
18270            vec![Value::Int(2), Value::Text("two".into())]
18271        );
18272    }
18273
18274    #[test]
18275    fn select_star_on_empty_table_returns_zero_rows() {
18276        let mut e = Engine::new();
18277        e.execute("CREATE TABLE foo (a INT)").unwrap();
18278        let r = e.execute("SELECT * FROM foo").unwrap();
18279        match r {
18280            QueryResult::Rows { rows, .. } => assert!(rows.is_empty()),
18281            QueryResult::CommandOk { .. } => panic!("expected Rows"),
18282        }
18283    }
18284
18285    // --- v0.4: WHERE + projection ------------------------------------------
18286
18287    fn make_three_row_users(e: &mut Engine) {
18288        e.execute("CREATE TABLE users (id INT NOT NULL, name TEXT NOT NULL, score INT)")
18289            .unwrap();
18290        e.execute("INSERT INTO users VALUES (1, 'alice', 90)")
18291            .unwrap();
18292        e.execute("INSERT INTO users VALUES (2, 'bob', NULL)")
18293            .unwrap();
18294        e.execute("INSERT INTO users VALUES (3, 'cara', 70)")
18295            .unwrap();
18296    }
18297
18298    fn unwrap_rows(r: QueryResult) -> (Vec<ColumnSchema>, Vec<Row>) {
18299        match r {
18300            QueryResult::Rows { columns, rows } => (columns, rows),
18301            QueryResult::CommandOk { .. } => panic!("expected Rows"),
18302        }
18303    }
18304
18305    #[test]
18306    fn where_filter_passes_only_true_rows() {
18307        let mut e = Engine::new();
18308        make_three_row_users(&mut e);
18309        let r = e.execute("SELECT * FROM users WHERE id > 1").unwrap();
18310        let (_, rows) = unwrap_rows(r);
18311        assert_eq!(rows.len(), 2);
18312        assert_eq!(rows[0].values[0], Value::Int(2));
18313        assert_eq!(rows[1].values[0], Value::Int(3));
18314    }
18315
18316    #[test]
18317    fn where_with_null_result_filters_out_row() {
18318        let mut e = Engine::new();
18319        make_three_row_users(&mut e);
18320        // score is NULL for bob → score > 80 is NULL → row excluded
18321        let r = e.execute("SELECT * FROM users WHERE score > 80").unwrap();
18322        let (_, rows) = unwrap_rows(r);
18323        assert_eq!(rows.len(), 1);
18324        assert_eq!(rows[0].values[1], Value::Text("alice".into()));
18325    }
18326
18327    #[test]
18328    fn projection_named_columns() {
18329        let mut e = Engine::new();
18330        make_three_row_users(&mut e);
18331        let r = e.execute("SELECT name, score FROM users").unwrap();
18332        let (cols, rows) = unwrap_rows(r);
18333        assert_eq!(cols.len(), 2);
18334        assert_eq!(cols[0].name, "name");
18335        assert_eq!(cols[1].name, "score");
18336        assert_eq!(rows.len(), 3);
18337        assert_eq!(
18338            rows[0].values,
18339            vec![Value::Text("alice".into()), Value::Int(90)]
18340        );
18341    }
18342
18343    #[test]
18344    fn projection_with_column_alias() {
18345        let mut e = Engine::new();
18346        make_three_row_users(&mut e);
18347        let r = e
18348            .execute("SELECT name AS who FROM users WHERE id = 1")
18349            .unwrap();
18350        let (cols, rows) = unwrap_rows(r);
18351        assert_eq!(cols[0].name, "who");
18352        assert_eq!(rows.len(), 1);
18353        assert_eq!(rows[0].values[0], Value::Text("alice".into()));
18354    }
18355
18356    #[test]
18357    fn qualified_column_with_table_alias_resolves() {
18358        let mut e = Engine::new();
18359        make_three_row_users(&mut e);
18360        let r = e
18361            .execute("SELECT u.id, u.name FROM users AS u WHERE u.id < 3")
18362            .unwrap();
18363        let (cols, rows) = unwrap_rows(r);
18364        assert_eq!(cols.len(), 2);
18365        assert_eq!(rows.len(), 2);
18366    }
18367
18368    #[test]
18369    fn qualified_column_with_wrong_alias_errors() {
18370        let mut e = Engine::new();
18371        make_three_row_users(&mut e);
18372        let err = e.execute("SELECT x.id FROM users AS u").unwrap_err();
18373        assert!(matches!(
18374            err,
18375            EngineError::Eval(EvalError::UnknownQualifier { ref qualifier }) if qualifier == "x"
18376        ));
18377    }
18378
18379    #[test]
18380    fn select_unknown_column_errors_in_projection() {
18381        let mut e = Engine::new();
18382        make_three_row_users(&mut e);
18383        let err = e.execute("SELECT ghost FROM users").unwrap_err();
18384        assert!(matches!(
18385            err,
18386            EngineError::Eval(EvalError::ColumnNotFound { ref name }) if name == "ghost"
18387        ));
18388    }
18389
18390    #[test]
18391    fn where_unknown_column_errors() {
18392        let mut e = Engine::new();
18393        make_three_row_users(&mut e);
18394        let err = e
18395            .execute("SELECT * FROM users WHERE ghost = 1")
18396            .unwrap_err();
18397        assert!(matches!(
18398            err,
18399            EngineError::Eval(EvalError::ColumnNotFound { .. })
18400        ));
18401    }
18402
18403    #[test]
18404    fn expression_projection_evaluates_and_renders() {
18405        // Compound expressions in the SELECT list are evaluated per row;
18406        // the output column is typed TEXT, name defaults to the expression.
18407        let mut e = Engine::new();
18408        e.execute("CREATE TABLE t (a INT NOT NULL)").unwrap();
18409        e.execute("INSERT INTO t VALUES (3)").unwrap();
18410        let (_, rows) = unwrap_rows(e.execute("SELECT 1 + 2 FROM t").unwrap());
18411        assert_eq!(rows.len(), 1);
18412        // The expression evaluates to integer 3; rendered as the cell value
18413        // (storage::Value::Int(3) since arithmetic kept ints).
18414        assert_eq!(rows[0].values[0], Value::Int(3));
18415    }
18416
18417    #[test]
18418    fn select_unknown_table_errors() {
18419        let mut e = Engine::new();
18420        let err = e.execute("SELECT * FROM ghost").unwrap_err();
18421        assert!(matches!(
18422            err,
18423            EngineError::Storage(StorageError::TableNotFound { .. })
18424        ));
18425    }
18426
18427    #[test]
18428    fn invalid_sql_returns_parse_error() {
18429        // v4.4: UPDATE is now real SQL, so use a true syntactic
18430        // garbage payload for the parse-error path.
18431        let mut e = Engine::new();
18432        let err = e.execute("THIS_IS_NOT_A_KEYWORD foo bar baz").unwrap_err();
18433        assert!(matches!(err, EngineError::Parse(_)));
18434    }
18435
18436    // --- v0.8 CREATE INDEX + index seek ------------------------------------
18437
18438    #[test]
18439    fn create_index_registers_on_table() {
18440        let mut e = Engine::new();
18441        make_three_row_users(&mut e);
18442        e.execute("CREATE INDEX by_name ON users (name)").unwrap();
18443        let t = e.catalog().get("users").unwrap();
18444        assert_eq!(t.indices().len(), 1);
18445        assert_eq!(t.indices()[0].name, "by_name");
18446    }
18447
18448    #[test]
18449    fn create_index_on_unknown_table_errors() {
18450        let mut e = Engine::new();
18451        let err = e.execute("CREATE INDEX i ON ghost (a)").unwrap_err();
18452        assert!(matches!(
18453            err,
18454            EngineError::Storage(StorageError::TableNotFound { .. })
18455        ));
18456    }
18457
18458    #[test]
18459    fn create_index_on_unknown_column_errors() {
18460        let mut e = Engine::new();
18461        make_three_row_users(&mut e);
18462        let err = e.execute("CREATE INDEX i ON users (ghost)").unwrap_err();
18463        assert!(matches!(
18464            err,
18465            EngineError::Storage(StorageError::ColumnNotFound { .. })
18466        ));
18467    }
18468
18469    #[test]
18470    fn select_eq_uses_index_returns_same_rows_as_scan() {
18471        // Build two engines: one with an index, one without. Same query →
18472        // same row set (index is a planner optimisation, not a semantic
18473        // change).
18474        let mut without = Engine::new();
18475        make_three_row_users(&mut without);
18476        let mut with = Engine::new();
18477        make_three_row_users(&mut with);
18478        with.execute("CREATE INDEX by_id ON users (id)").unwrap();
18479
18480        let q = "SELECT * FROM users WHERE id = 2";
18481        let (_, no_idx_rows) = unwrap_rows(without.execute(q).unwrap());
18482        let (_, idx_rows) = unwrap_rows(with.execute(q).unwrap());
18483        assert_eq!(no_idx_rows, idx_rows);
18484        assert_eq!(idx_rows.len(), 1);
18485    }
18486
18487    #[test]
18488    fn select_eq_with_no_matching_index_value_returns_empty() {
18489        let mut e = Engine::new();
18490        make_three_row_users(&mut e);
18491        e.execute("CREATE INDEX by_id ON users (id)").unwrap();
18492        let (_, rows) = unwrap_rows(e.execute("SELECT * FROM users WHERE id = 999").unwrap());
18493        assert_eq!(rows.len(), 0);
18494    }
18495
18496    // --- v0.9 transactions -------------------------------------------------
18497
18498    #[test]
18499    fn begin_sets_in_transaction_flag() {
18500        let mut e = Engine::new();
18501        assert!(!e.in_transaction());
18502        e.execute("BEGIN").unwrap();
18503        assert!(e.in_transaction());
18504    }
18505
18506    #[test]
18507    fn double_begin_errors() {
18508        let mut e = Engine::new();
18509        e.execute("BEGIN").unwrap();
18510        let err = e.execute("BEGIN").unwrap_err();
18511        assert_eq!(err, EngineError::TransactionAlreadyOpen);
18512    }
18513
18514    #[test]
18515    fn commit_without_begin_errors() {
18516        let mut e = Engine::new();
18517        let err = e.execute("COMMIT").unwrap_err();
18518        assert_eq!(err, EngineError::NoActiveTransaction);
18519    }
18520
18521    #[test]
18522    fn rollback_without_begin_errors() {
18523        let mut e = Engine::new();
18524        let err = e.execute("ROLLBACK").unwrap_err();
18525        assert_eq!(err, EngineError::NoActiveTransaction);
18526    }
18527
18528    #[test]
18529    fn commit_applies_shadow_to_committed_catalog() {
18530        let mut e = Engine::new();
18531        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
18532        e.execute("BEGIN").unwrap();
18533        e.execute("INSERT INTO t VALUES (1)").unwrap();
18534        e.execute("INSERT INTO t VALUES (2)").unwrap();
18535        e.execute("COMMIT").unwrap();
18536        assert!(!e.in_transaction());
18537        assert_eq!(e.catalog().get("t").unwrap().row_count(), 2);
18538    }
18539
18540    #[test]
18541    fn rollback_discards_shadow() {
18542        let mut e = Engine::new();
18543        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
18544        e.execute("BEGIN").unwrap();
18545        e.execute("INSERT INTO t VALUES (1)").unwrap();
18546        e.execute("INSERT INTO t VALUES (2)").unwrap();
18547        e.execute("ROLLBACK").unwrap();
18548        assert!(!e.in_transaction());
18549        assert_eq!(e.catalog().get("t").unwrap().row_count(), 0);
18550    }
18551
18552    #[test]
18553    fn select_during_tx_sees_uncommitted_writes_own_session() {
18554        // The shadow catalog is read by SELECTs while a TX is open — the
18555        // session can see its own pending writes.
18556        let mut e = Engine::new();
18557        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
18558        e.execute("BEGIN").unwrap();
18559        e.execute("INSERT INTO t VALUES (42)").unwrap();
18560        let (_, rows) = unwrap_rows(e.execute("SELECT * FROM t").unwrap());
18561        assert_eq!(rows.len(), 1);
18562        assert_eq!(rows[0].values[0], Value::Int(42));
18563    }
18564
18565    #[test]
18566    fn snapshot_with_no_users_is_bare_catalog_format() {
18567        let mut e = Engine::new();
18568        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18569        let bytes = e.snapshot();
18570        assert_eq!(
18571            &bytes[..8],
18572            b"SPGDB001",
18573            "must be the bare v3.x catalog magic"
18574        );
18575        let e2 = Engine::restore_envelope(&bytes).unwrap();
18576        assert!(e2.users().is_empty());
18577        assert_eq!(e2.catalog().table_count(), 1);
18578    }
18579
18580    #[test]
18581    fn snapshot_with_users_round_trips_both_via_envelope() {
18582        let mut e = Engine::new();
18583        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18584        e.create_user("alice", "pw1", Role::Admin, [9; 16]).unwrap();
18585        e.create_user("bob", "pw2", Role::ReadOnly, [5; 16])
18586            .unwrap();
18587        let bytes = e.snapshot();
18588        assert_eq!(&bytes[..8], b"SPGENV01", "must be the v4.1 envelope magic");
18589        let e2 = Engine::restore_envelope(&bytes).unwrap();
18590        assert_eq!(e2.users().len(), 2);
18591        assert_eq!(e2.verify_user("alice", "pw1"), Some(Role::Admin));
18592        assert_eq!(e2.verify_user("bob", "pw2"), Some(Role::ReadOnly));
18593        assert_eq!(e2.verify_user("alice", "wrong"), None);
18594        assert_eq!(e2.catalog().table_count(), 1);
18595    }
18596
18597    #[test]
18598    fn ddl_inside_tx_also_rolled_back() {
18599        let mut e = Engine::new();
18600        e.execute("BEGIN").unwrap();
18601        e.execute("CREATE TABLE t (v INT)").unwrap();
18602        // Visible inside the TX.
18603        e.execute("SELECT * FROM t").unwrap();
18604        e.execute("ROLLBACK").unwrap();
18605        // Gone after rollback.
18606        let err = e.execute("SELECT * FROM t").unwrap_err();
18607        assert!(matches!(
18608            err,
18609            EngineError::Storage(StorageError::TableNotFound { .. })
18610        ));
18611    }
18612
18613    // ── v6.1.2: CREATE / DROP PUBLICATION (engine-side) ──────
18614
18615    #[test]
18616    fn create_publication_lands_in_catalog() {
18617        let mut e = Engine::new();
18618        assert!(e.publications().is_empty());
18619        e.execute("CREATE PUBLICATION pub_a").unwrap();
18620        assert_eq!(e.publications().len(), 1);
18621        assert!(e.publications().contains("pub_a"));
18622    }
18623
18624    #[test]
18625    fn create_publication_duplicate_errors() {
18626        let mut e = Engine::new();
18627        e.execute("CREATE PUBLICATION pub_a").unwrap();
18628        let err = e.execute("CREATE PUBLICATION pub_a").unwrap_err();
18629        assert!(
18630            alloc::format!("{err:?}").contains("DuplicateName"),
18631            "got {err:?}"
18632        );
18633    }
18634
18635    #[test]
18636    fn drop_publication_silent_when_absent() {
18637        let mut e = Engine::new();
18638        // PG-compatible: DROP a publication that doesn't exist
18639        // succeeds (no-op) but reports zero affected.
18640        let r = e.execute("DROP PUBLICATION nope").unwrap();
18641        match r {
18642            QueryResult::CommandOk { affected, .. } => assert_eq!(affected, 0),
18643            other => panic!("expected CommandOk, got {other:?}"),
18644        }
18645    }
18646
18647    #[test]
18648    fn drop_publication_present_reports_one_affected() {
18649        let mut e = Engine::new();
18650        e.execute("CREATE PUBLICATION pub_a").unwrap();
18651        let r = e.execute("DROP PUBLICATION pub_a").unwrap();
18652        match r {
18653            QueryResult::CommandOk {
18654                affected,
18655                modified_catalog,
18656            } => {
18657                assert_eq!(affected, 1);
18658                assert!(modified_catalog);
18659            }
18660            other => panic!("expected CommandOk, got {other:?}"),
18661        }
18662        assert!(e.publications().is_empty());
18663    }
18664
18665    #[test]
18666    fn publications_persist_across_snapshot_restore() {
18667        // The persist-across-restart ship-gate at the engine layer —
18668        // snapshot → restore_envelope round trip must preserve the
18669        // publication catalog. The spg-server e2e covers the
18670        // process-restart variant.
18671        let mut e = Engine::new();
18672        e.execute("CREATE PUBLICATION pub_a").unwrap();
18673        e.execute("CREATE PUBLICATION pub_b FOR ALL TABLES")
18674            .unwrap();
18675        let snap = e.snapshot();
18676        let e2 = Engine::restore_envelope(&snap).unwrap();
18677        assert_eq!(e2.publications().len(), 2);
18678        assert!(e2.publications().contains("pub_a"));
18679        assert!(e2.publications().contains("pub_b"));
18680    }
18681
18682    #[test]
18683    fn create_publication_allowed_inside_transaction() {
18684        // v6.1.4 dropped the v6.1.2 in-TX guard — PG allows
18685        // CREATE PUBLICATION inside a TX and the auto-commit
18686        // wrap path needs the same allowance.
18687        let mut e = Engine::new();
18688        e.execute("BEGIN").unwrap();
18689        e.execute("CREATE PUBLICATION pub_a").unwrap();
18690        e.execute("COMMIT").unwrap();
18691        assert!(e.publications().contains("pub_a"));
18692    }
18693
18694    // ── v6.1.3: SHOW PUBLICATIONS + FOR-list variants ───────
18695
18696    #[test]
18697    fn create_publication_for_table_list_lands_with_scope() {
18698        let mut e = Engine::new();
18699        e.execute("CREATE TABLE t1 (id INT NOT NULL)").unwrap();
18700        e.execute("CREATE TABLE t2 (id INT NOT NULL)").unwrap();
18701        e.execute("CREATE PUBLICATION pub_a FOR TABLE t1, t2")
18702            .unwrap();
18703        let scope = e.publications().get("pub_a").cloned();
18704        let Some(spg_sql::ast::PublicationScope::ForTables(ts)) = scope else {
18705            panic!("expected ForTables scope, got {scope:?}")
18706        };
18707        assert_eq!(ts, alloc::vec!["t1".to_string(), "t2".to_string()]);
18708    }
18709
18710    #[test]
18711    fn create_publication_all_tables_except_lands_with_scope() {
18712        let mut e = Engine::new();
18713        e.execute("CREATE PUBLICATION pub_a FOR ALL TABLES EXCEPT t3")
18714            .unwrap();
18715        let scope = e.publications().get("pub_a").cloned();
18716        let Some(spg_sql::ast::PublicationScope::AllTablesExcept(ts)) = scope else {
18717            panic!("expected AllTablesExcept scope, got {scope:?}")
18718        };
18719        assert_eq!(ts, alloc::vec!["t3".to_string()]);
18720    }
18721
18722    #[test]
18723    fn show_publications_empty_returns_zero_rows() {
18724        let e = Engine::new();
18725        let r = e.execute_readonly("SHOW PUBLICATIONS").unwrap();
18726        let QueryResult::Rows { rows, columns } = r else {
18727            panic!()
18728        };
18729        assert!(rows.is_empty());
18730        assert_eq!(columns.len(), 3);
18731        assert_eq!(columns[0].name, "name");
18732        assert_eq!(columns[1].name, "scope");
18733        assert_eq!(columns[2].name, "table_count");
18734    }
18735
18736    #[test]
18737    fn show_publications_returns_one_row_per_publication_ordered_by_name() {
18738        let mut e = Engine::new();
18739        e.execute("CREATE PUBLICATION z_pub").unwrap();
18740        e.execute("CREATE PUBLICATION a_pub FOR TABLE t1, t2")
18741            .unwrap();
18742        e.execute("CREATE PUBLICATION m_pub FOR ALL TABLES EXCEPT bad")
18743            .unwrap();
18744        let r = e.execute_readonly("SHOW PUBLICATIONS").unwrap();
18745        let QueryResult::Rows { rows, .. } = r else {
18746            panic!()
18747        };
18748        assert_eq!(rows.len(), 3);
18749        // Alphabetical order: a_pub, m_pub, z_pub.
18750        let names: Vec<&str> = rows
18751            .iter()
18752            .map(|r| {
18753                if let Value::Text(s) = &r.values[0] {
18754                    s.as_str()
18755                } else {
18756                    panic!()
18757                }
18758            })
18759            .collect();
18760        assert_eq!(names, alloc::vec!["a_pub", "m_pub", "z_pub"]);
18761        // Row 0 — a_pub scope summary + table_count = 2.
18762        match &rows[0].values[1] {
18763            Value::Text(s) => assert_eq!(s, "FOR TABLE t1, t2"),
18764            other => panic!("expected Text, got {other:?}"),
18765        }
18766        assert_eq!(rows[0].values[2], Value::Int(2));
18767        // Row 1 — m_pub.
18768        match &rows[1].values[1] {
18769            Value::Text(s) => assert_eq!(s, "FOR ALL TABLES EXCEPT bad"),
18770            other => panic!("expected Text, got {other:?}"),
18771        }
18772        assert_eq!(rows[1].values[2], Value::Int(1));
18773        // Row 2 — z_pub (AllTables → NULL count).
18774        match &rows[2].values[1] {
18775            Value::Text(s) => assert_eq!(s, "FOR ALL TABLES"),
18776            other => panic!("expected Text, got {other:?}"),
18777        }
18778        assert_eq!(rows[2].values[2], Value::Null);
18779    }
18780
18781    #[test]
18782    fn for_list_scopes_persist_across_snapshot() {
18783        // The v6.1.2 envelope-v3 round-trip exercised AllTables;
18784        // v6.1.3 needs the scope-1 / scope-2 tags to survive too.
18785        let mut e = Engine::new();
18786        e.execute("CREATE PUBLICATION p1 FOR TABLE t1, t2").unwrap();
18787        e.execute("CREATE PUBLICATION p2 FOR ALL TABLES EXCEPT bad, worse")
18788            .unwrap();
18789        let snap = e.snapshot();
18790        let e2 = Engine::restore_envelope(&snap).unwrap();
18791        assert_eq!(e2.publications().len(), 2);
18792        let p1 = e2.publications().get("p1").cloned();
18793        let Some(spg_sql::ast::PublicationScope::ForTables(ts)) = p1 else {
18794            panic!("p1 scope lost: {p1:?}")
18795        };
18796        assert_eq!(ts, alloc::vec!["t1".to_string(), "t2".to_string()]);
18797        let p2 = e2.publications().get("p2").cloned();
18798        let Some(spg_sql::ast::PublicationScope::AllTablesExcept(ts)) = p2 else {
18799            panic!("p2 scope lost: {p2:?}")
18800        };
18801        assert_eq!(ts, alloc::vec!["bad".to_string(), "worse".to_string()]);
18802    }
18803
18804    // ── v6.1.4: CREATE / DROP SUBSCRIPTION + SHOW + envelope v4 ─
18805
18806    #[test]
18807    fn create_subscription_lands_in_catalog_with_defaults() {
18808        let mut e = Engine::new();
18809        e.execute(
18810            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=127.0.0.1 port=20002' PUBLICATION pub_a",
18811        )
18812        .unwrap();
18813        let s = e.subscriptions().get("sub_a").cloned().expect("present");
18814        assert_eq!(s.conn_str, "host=127.0.0.1 port=20002");
18815        assert_eq!(s.publications, alloc::vec!["pub_a".to_string()]);
18816        assert!(s.enabled);
18817        assert_eq!(s.last_received_pos, 0);
18818    }
18819
18820    #[test]
18821    fn create_subscription_duplicate_name_errors() {
18822        let mut e = Engine::new();
18823        e.execute("CREATE SUBSCRIPTION s CONNECTION 'host=x' PUBLICATION p")
18824            .unwrap();
18825        let err = e
18826            .execute("CREATE SUBSCRIPTION s CONNECTION 'host=y' PUBLICATION p")
18827            .unwrap_err();
18828        assert!(
18829            alloc::format!("{err:?}").contains("DuplicateName"),
18830            "got {err:?}"
18831        );
18832    }
18833
18834    #[test]
18835    fn drop_subscription_silent_when_absent() {
18836        let mut e = Engine::new();
18837        let r = e.execute("DROP SUBSCRIPTION never").unwrap();
18838        match r {
18839            QueryResult::CommandOk { affected, .. } => assert_eq!(affected, 0),
18840            other => panic!("expected CommandOk, got {other:?}"),
18841        }
18842    }
18843
18844    #[test]
18845    fn subscription_advance_updates_last_pos_monotone() {
18846        let mut e = Engine::new();
18847        e.execute("CREATE SUBSCRIPTION s CONNECTION 'h=x' PUBLICATION p")
18848            .unwrap();
18849        assert!(e.subscription_advance("s", 100));
18850        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 100);
18851        assert!(e.subscription_advance("s", 50)); // stale → ignored
18852        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 100);
18853        assert!(e.subscription_advance("s", 200));
18854        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 200);
18855        assert!(!e.subscription_advance("missing", 1));
18856    }
18857
18858    #[test]
18859    fn show_subscriptions_returns_rows_ordered_by_name() {
18860        let mut e = Engine::new();
18861        e.execute("CREATE SUBSCRIPTION z_sub CONNECTION 'h=x' PUBLICATION p1, p2")
18862            .unwrap();
18863        e.execute("CREATE SUBSCRIPTION a_sub CONNECTION 'h=y' PUBLICATION p3")
18864            .unwrap();
18865        let r = e.execute_readonly("SHOW SUBSCRIPTIONS").unwrap();
18866        let QueryResult::Rows { rows, columns } = r else {
18867            panic!()
18868        };
18869        assert_eq!(rows.len(), 2);
18870        assert_eq!(columns.len(), 5);
18871        assert_eq!(columns[0].name, "name");
18872        assert_eq!(columns[4].name, "last_received_pos");
18873        // Alphabetical: a_sub, z_sub.
18874        let names: Vec<&str> = rows
18875            .iter()
18876            .map(|r| {
18877                if let Value::Text(s) = &r.values[0] {
18878                    s.as_str()
18879                } else {
18880                    panic!()
18881                }
18882            })
18883            .collect();
18884        assert_eq!(names, alloc::vec!["a_sub", "z_sub"]);
18885        // Row 0: a_sub
18886        assert_eq!(rows[0].values[1], Value::Text("h=y".to_string()));
18887        assert_eq!(rows[0].values[2], Value::Text("p3".to_string()));
18888        assert_eq!(rows[0].values[3], Value::Bool(true));
18889        assert_eq!(rows[0].values[4], Value::BigInt(0));
18890        // Row 1: z_sub — publications join with ", "
18891        assert_eq!(rows[1].values[2], Value::Text("p1, p2".to_string()));
18892    }
18893
18894    #[test]
18895    fn subscriptions_persist_across_snapshot_envelope_v4() {
18896        let mut e = Engine::new();
18897        e.execute("CREATE SUBSCRIPTION s1 CONNECTION 'h=A' PUBLICATION p1, p2")
18898            .unwrap();
18899        e.execute("CREATE SUBSCRIPTION s2 CONNECTION 'h=B' PUBLICATION p3")
18900            .unwrap();
18901        e.subscription_advance("s2", 42);
18902        let snap = e.snapshot();
18903        let e2 = Engine::restore_envelope(&snap).unwrap();
18904        assert_eq!(e2.subscriptions().len(), 2);
18905        let s1 = e2.subscriptions().get("s1").unwrap();
18906        assert_eq!(s1.conn_str, "h=A");
18907        assert_eq!(
18908            s1.publications,
18909            alloc::vec!["p1".to_string(), "p2".to_string()]
18910        );
18911        assert_eq!(s1.last_received_pos, 0);
18912        let s2 = e2.subscriptions().get("s2").unwrap();
18913        assert_eq!(s2.last_received_pos, 42);
18914    }
18915
18916    #[test]
18917    fn v3_envelope_loads_with_empty_subscriptions() {
18918        // v3 snapshot (publications-only). Forge it by hand so we
18919        // verify v6.1.4 readers don't panic — they must surface
18920        // empty subscriptions and a populated publication table.
18921        let mut e = Engine::new();
18922        e.execute("CREATE PUBLICATION pub_legacy").unwrap();
18923        let catalog = e.catalog.serialize();
18924        let users = crate::users::serialize_users(&e.users);
18925        let pubs = e.publications.serialize();
18926        let mut buf = Vec::new();
18927        buf.extend_from_slice(b"SPGENV01");
18928        buf.push(3u8); // v3
18929        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
18930        buf.extend_from_slice(&catalog);
18931        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
18932        buf.extend_from_slice(&users);
18933        buf.extend_from_slice(&u32::try_from(pubs.len()).unwrap().to_le_bytes());
18934        buf.extend_from_slice(&pubs);
18935        let crc = spg_crypto::crc32::crc32(&buf);
18936        buf.extend_from_slice(&crc.to_le_bytes());
18937
18938        let e2 = Engine::restore_envelope(&buf).expect("v3 envelope restores under v4 reader");
18939        assert!(e2.subscriptions().is_empty());
18940        assert!(e2.publications().contains("pub_legacy"));
18941    }
18942
18943    #[test]
18944    fn create_subscription_allowed_inside_transaction() {
18945        let mut e = Engine::new();
18946        e.execute("BEGIN").unwrap();
18947        e.execute("CREATE SUBSCRIPTION s CONNECTION 'h=x' PUBLICATION p")
18948            .unwrap();
18949        e.execute("COMMIT").unwrap();
18950        assert!(e.subscriptions().contains("s"));
18951    }
18952
18953    // ── v6.2.0: ANALYZE + spg_statistic + envelope v5 ──────────
18954    #[test]
18955    fn analyze_populates_histogram_bounds() {
18956        let mut e = Engine::new();
18957        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT)")
18958            .unwrap();
18959        for i in 0..50 {
18960            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, 'name{i}')"))
18961                .unwrap();
18962        }
18963        e.execute("ANALYZE t").unwrap();
18964        let stats = e.statistics();
18965        let id_stats = stats.get("t", "id").unwrap();
18966        assert!(id_stats.histogram_bounds.len() >= 2);
18967        assert_eq!(id_stats.histogram_bounds.first().unwrap(), "0");
18968        assert_eq!(id_stats.histogram_bounds.last().unwrap(), "49");
18969        assert!((id_stats.null_frac - 0.0).abs() < 1e-6);
18970        assert_eq!(id_stats.n_distinct, 50);
18971    }
18972
18973    #[test]
18974    fn reanalyze_overwrites_prior_stats() {
18975        let mut e = Engine::new();
18976        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18977        for i in 0..10 {
18978            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18979                .unwrap();
18980        }
18981        e.execute("ANALYZE t").unwrap();
18982        let n1 = e.statistics().get("t", "id").unwrap().n_distinct;
18983        assert_eq!(n1, 10);
18984        for i in 10..30 {
18985            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18986                .unwrap();
18987        }
18988        e.execute("ANALYZE t").unwrap();
18989        let n2 = e.statistics().get("t", "id").unwrap().n_distinct;
18990        assert_eq!(n2, 30);
18991    }
18992
18993    #[test]
18994    fn analyze_unknown_table_errors() {
18995        let mut e = Engine::new();
18996        let err = e.execute("ANALYZE nonexistent").unwrap_err();
18997        assert!(matches!(
18998            err,
18999            EngineError::Storage(StorageError::TableNotFound { .. })
19000        ));
19001    }
19002
19003    #[test]
19004    fn bare_analyze_covers_all_user_tables() {
19005        let mut e = Engine::new();
19006        e.execute("CREATE TABLE t1 (id INT NOT NULL)").unwrap();
19007        e.execute("CREATE TABLE t2 (name TEXT NOT NULL)").unwrap();
19008        e.execute("INSERT INTO t1 VALUES (1)").unwrap();
19009        e.execute("INSERT INTO t2 VALUES ('alice')").unwrap();
19010        let r = e.execute("ANALYZE").unwrap();
19011        match r {
19012            QueryResult::CommandOk {
19013                affected,
19014                modified_catalog,
19015            } => {
19016                assert_eq!(affected, 2);
19017                assert!(modified_catalog);
19018            }
19019            other => panic!("expected CommandOk, got {other:?}"),
19020        }
19021        assert!(e.statistics().get("t1", "id").is_some());
19022        assert!(e.statistics().get("t2", "name").is_some());
19023    }
19024
19025    #[test]
19026    fn select_from_spg_statistic_returns_rows_per_column() {
19027        let mut e = Engine::new();
19028        e.execute("CREATE TABLE t (id INT NOT NULL, label TEXT)")
19029            .unwrap();
19030        e.execute("INSERT INTO t VALUES (1, 'a')").unwrap();
19031        e.execute("INSERT INTO t VALUES (2, 'b')").unwrap();
19032        e.execute("ANALYZE t").unwrap();
19033        let r = e.execute_readonly("SELECT * FROM spg_statistic").unwrap();
19034        let QueryResult::Rows { rows, columns } = r else {
19035            panic!()
19036        };
19037        // v6.7.0 — spg_statistic gained a `cold_row_count` column.
19038        assert_eq!(columns.len(), 6);
19039        assert_eq!(columns[0].name, "table_name");
19040        assert_eq!(columns[4].name, "histogram_bounds");
19041        assert_eq!(columns[5].name, "cold_row_count");
19042        assert_eq!(rows.len(), 2, "one row per column of t");
19043        // Sorted by (table_name, column_name).
19044        match (&rows[0].values[0], &rows[0].values[1]) {
19045            (Value::Text(t), Value::Text(c)) => {
19046                assert_eq!(t, "t");
19047                // BTreeMap orders (table, column); columns "id" < "label".
19048                assert_eq!(c, "id");
19049            }
19050            _ => panic!(),
19051        }
19052    }
19053
19054    #[test]
19055    fn analyze_skips_vector_columns() {
19056        // Vector columns have their own stats shape (HNSW graph);
19057        // ANALYZE leaves them out of spg_statistic.
19058        let mut e = Engine::new();
19059        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(3) NOT NULL)")
19060            .unwrap();
19061        e.execute("INSERT INTO t VALUES (1, [1, 2, 3])").unwrap();
19062        e.execute("ANALYZE t").unwrap();
19063        assert!(e.statistics().get("t", "id").is_some());
19064        assert!(e.statistics().get("t", "v").is_none());
19065    }
19066
19067    #[test]
19068    fn statistics_persist_across_envelope_v5_round_trip() {
19069        let mut e = Engine::new();
19070        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19071        for i in 0..20 {
19072            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19073                .unwrap();
19074        }
19075        e.execute("ANALYZE").unwrap();
19076        let snap = e.snapshot();
19077        let e2 = Engine::restore_envelope(&snap).unwrap();
19078        let s = e2.statistics().get("t", "id").unwrap();
19079        assert_eq!(s.n_distinct, 20);
19080    }
19081
19082    // ── v6.2.1 auto-analyze threshold ───────────────────────────
19083
19084    #[test]
19085    fn auto_analyze_threshold_fires_after_10pct_of_min_rows_on_small_table() {
19086        // For a table with 0 rows then 10 inserts → modified=10,
19087        // row_count=10. Threshold = 0.1 × max(10, 100) = 10. So
19088        // after the 10th INSERT the threshold is met.
19089        let mut e = Engine::new();
19090        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19091        for i in 0..9 {
19092            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19093                .unwrap();
19094        }
19095        assert!(e.tables_needing_analyze().is_empty(), "9 < threshold");
19096        e.execute("INSERT INTO t VALUES (9)").unwrap();
19097        let needs = e.tables_needing_analyze();
19098        assert_eq!(needs, alloc::vec!["t".to_string()]);
19099    }
19100
19101    #[test]
19102    fn auto_analyze_threshold_uses_10pct_of_row_count_for_large_tables() {
19103        // After ANALYZE on 1000 rows, threshold = 0.1 × row_count.
19104        // Each new INSERT bumps both modified and row_count, so to
19105        // trigger from N=1000 we need modifications ≥ 0.1 × (1000+M),
19106        // i.e. M ≥ 112. The test inserts 50 (no fire), then 150
19107        // more (200 total mods, row_count=1200, threshold=120 → fire).
19108        let mut e = Engine::new();
19109        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19110        for i in 0..1000 {
19111            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19112                .unwrap();
19113        }
19114        e.execute("ANALYZE t").unwrap();
19115        assert!(e.tables_needing_analyze().is_empty(), "fresh ANALYZE");
19116        for i in 1000..1050 {
19117            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19118                .unwrap();
19119        }
19120        assert!(
19121            e.tables_needing_analyze().is_empty(),
19122            "50 inserts < threshold of ~105"
19123        );
19124        for i in 1050..1200 {
19125            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19126                .unwrap();
19127        }
19128        assert_eq!(
19129            e.tables_needing_analyze(),
19130            alloc::vec!["t".to_string()],
19131            "200 inserts > 0.1 × 1200 threshold"
19132        );
19133    }
19134
19135    #[test]
19136    fn auto_analyze_threshold_resets_after_analyze() {
19137        let mut e = Engine::new();
19138        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19139        for i in 0..200 {
19140            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19141                .unwrap();
19142        }
19143        assert!(!e.tables_needing_analyze().is_empty());
19144        e.execute("ANALYZE").unwrap();
19145        assert!(
19146            e.tables_needing_analyze().is_empty(),
19147            "ANALYZE must reset the counter"
19148        );
19149    }
19150
19151    #[test]
19152    fn auto_analyze_threshold_tracks_updates_and_deletes() {
19153        let mut e = Engine::new();
19154        e.execute("CREATE TABLE t (id INT NOT NULL, label TEXT)")
19155            .unwrap();
19156        for i in 0..50 {
19157            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, 'x')"))
19158                .unwrap();
19159        }
19160        e.execute("ANALYZE t").unwrap();
19161        // UPDATE 20 rows + DELETE 5 → modified=25. Threshold = 0.1
19162        // × max(50, 100) = 10. So 25 >= 10 → trigger.
19163        e.execute("UPDATE t SET label = 'y' WHERE id < 20").unwrap();
19164        e.execute("DELETE FROM t WHERE id >= 45").unwrap();
19165        assert_eq!(e.tables_needing_analyze(), alloc::vec!["t".to_string()]);
19166    }
19167
19168    #[test]
19169    fn v4_envelope_loads_with_empty_statistics() {
19170        // Forge a v4 envelope by hand: catalog + users + pubs +
19171        // subs trailer, no statistics. A v6.2.0 reader must accept
19172        // it and surface an empty Statistics.
19173        let mut e = Engine::new();
19174        e.create_user("alice", "secret", crate::users::Role::ReadOnly, [0u8; 16])
19175            .unwrap();
19176        let catalog = e.catalog.serialize();
19177        let users = crate::users::serialize_users(&e.users);
19178        let pubs = e.publications.serialize();
19179        let subs = e.subscriptions.serialize();
19180        let mut buf = Vec::new();
19181        buf.extend_from_slice(b"SPGENV01");
19182        buf.push(4u8);
19183        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
19184        buf.extend_from_slice(&catalog);
19185        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
19186        buf.extend_from_slice(&users);
19187        buf.extend_from_slice(&u32::try_from(pubs.len()).unwrap().to_le_bytes());
19188        buf.extend_from_slice(&pubs);
19189        buf.extend_from_slice(&u32::try_from(subs.len()).unwrap().to_le_bytes());
19190        buf.extend_from_slice(&subs);
19191        let crc = spg_crypto::crc32::crc32(&buf);
19192        buf.extend_from_slice(&crc.to_le_bytes());
19193        let e2 = Engine::restore_envelope(&buf).expect("v4 envelope restores");
19194        assert!(e2.statistics().is_empty());
19195    }
19196
19197    #[test]
19198    fn v1_v2_envelope_loads_with_empty_publications() {
19199        // A snapshot taken before v6.1.2 (no publication trailer,
19200        // envelope v2) must still deserialise — and the resulting
19201        // engine must report zero publications. Use the engine's own
19202        // round-trip with no publications: that emits v3 but with an
19203        // empty pubs block. Then forge a v2 envelope by hand to lock
19204        // the back-compat path.
19205        let mut e = Engine::new();
19206        // Force users to be non-empty so the snapshot takes the
19207        // envelope path rather than the bare-catalog fallback.
19208        e.create_user("alice", "secret", crate::users::Role::ReadOnly, [0u8; 16])
19209            .unwrap();
19210
19211        // Forge an envelope v2: same shape as v3 but no pubs trailer.
19212        let catalog = e.catalog.serialize();
19213        let users = crate::users::serialize_users(&e.users);
19214        let mut buf = Vec::new();
19215        buf.extend_from_slice(b"SPGENV01");
19216        buf.push(2u8); // v2
19217        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
19218        buf.extend_from_slice(&catalog);
19219        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
19220        buf.extend_from_slice(&users);
19221        let crc = spg_crypto::crc32::crc32(&buf);
19222        buf.extend_from_slice(&crc.to_le_bytes());
19223
19224        let e2 = Engine::restore_envelope(&buf).expect("v2 envelope restores");
19225        assert!(e2.publications().is_empty());
19226    }
19227}