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 copy;
11pub mod describe;
12pub mod eval;
13pub mod fts;
14pub mod json;
15pub mod memoize;
16pub mod plan_cache;
17pub mod publications;
18pub mod query_stats;
19pub mod reorder;
20pub mod selectivity;
21pub mod statistics;
22pub mod subscriptions;
23pub mod triggers;
24pub mod users;
25
26pub use crate::users::{Role, ScramSecrets, UserError, UserStore};
27
28use alloc::borrow::Cow;
29use alloc::boxed::Box;
30use alloc::collections::BTreeMap;
31use alloc::string::{String, ToString};
32use alloc::vec::Vec;
33use core::fmt;
34
35use spg_sql::ast::{
36    BinOp, ColumnDef, ColumnName, ColumnTypeName, CreateIndexStatement, CreatePublicationStatement,
37    CreateSubscriptionStatement, CreateTableStatement, CreateUserStatement, Expr, FrameBound,
38    FrameKind, FromClause, IndexMethod, InsertStatement, JoinKind, Literal, OrderBy, SelectItem,
39    SelectStatement, Statement, TableRef, UnOp, UnionKind, VecEncoding as SqlVecEncoding,
40    WindowFrame,
41};
42// v7.16.0 — re-export the parsed-statement AST so downstream
43// crates (spg-embedded → spg-sqlx) don't need a direct dep on
44// spg-sql for the prepare/bind handle.
45pub use spg_sql::ast::Statement as ParsedStatement;
46use spg_sql::parser::{self, ParseError};
47use spg_storage::{
48    Catalog, ColumnSchema, CompactReport, DataType, IndexKey, IndexKind, Row, StorageError, Table,
49    TableSchema, Value, VecEncoding,
50};
51
52use crate::eval::{EvalContext, EvalError};
53
54/// Result of executing one statement.
55#[derive(Debug, Clone, PartialEq)]
56#[non_exhaustive]
57pub enum QueryResult {
58    /// DDL or DML succeeded.
59    ///
60    /// `affected` is the row count for `INSERT` and 0 elsewhere.
61    /// `modified_catalog` tells the server whether this statement
62    /// caused the *committed* catalog to change — it's the signal to
63    /// snapshot/audit. False for `BEGIN`/`ROLLBACK`, false for writeful
64    /// statements executed inside a transaction (those only touch the
65    /// shadow), and true for `COMMIT` and for writes outside a TX.
66    CommandOk {
67        affected: usize,
68        modified_catalog: bool,
69    },
70    /// `SELECT` returned a (possibly empty) row set.
71    Rows {
72        columns: Vec<ColumnSchema>,
73        rows: Vec<Row>,
74    },
75}
76
77/// All errors the engine can return.
78///
79/// Marked `#[non_exhaustive]` from v7.5.0 onward: external `match`
80/// must include a `_` arm so new variants in subsequent v7.x releases
81/// are not breaking changes.
82#[derive(Debug, Clone, PartialEq)]
83#[non_exhaustive]
84pub enum EngineError {
85    Parse(ParseError),
86    Storage(StorageError),
87    Eval(EvalError),
88    /// Front-end accepted a construct that the v0.x executor doesn't support.
89    Unsupported(String),
90    /// `BEGIN` while another transaction is already open.
91    TransactionAlreadyOpen,
92    /// `COMMIT` / `ROLLBACK` with no active transaction.
93    NoActiveTransaction,
94    /// v4.0 sentinel: `execute_readonly` got a statement that
95    /// mutates engine state (INSERT / CREATE / BEGIN / COMMIT / …).
96    /// The caller should retake the write lock and dispatch through
97    /// `execute(&mut self)` instead.
98    WriteRequired,
99    /// v4.2: a SELECT would have returned more rows than the
100    /// configured `max_query_rows` cap. Carries the cap.
101    RowLimitExceeded(usize),
102    /// v4.5: cooperative cancellation — the host (server's
103    /// per-query watchdog) set the cancel flag while a long-running
104    /// SELECT / UPDATE / DELETE was scanning rows. The partial work
105    /// is discarded; the caller should surface this as a timeout
106    /// to the client.
107    Cancelled,
108}
109
110impl fmt::Display for EngineError {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        match self {
113            Self::Parse(e) => write!(f, "parse: {e}"),
114            Self::Storage(e) => write!(f, "storage: {e}"),
115            Self::Eval(e) => write!(f, "eval: {e}"),
116            Self::Unsupported(s) => write!(f, "unsupported: {s}"),
117            Self::TransactionAlreadyOpen => f.write_str("a transaction is already open"),
118            Self::NoActiveTransaction => f.write_str("no active transaction"),
119            Self::WriteRequired => {
120                f.write_str("statement requires a write lock (use execute, not execute_readonly)")
121            }
122            Self::RowLimitExceeded(n) => {
123                write!(f, "query exceeded max_query_rows={n}")
124            }
125            Self::Cancelled => f.write_str("query cancelled (timeout or client request)"),
126        }
127    }
128}
129
130impl From<ParseError> for EngineError {
131    fn from(e: ParseError) -> Self {
132        Self::Parse(e)
133    }
134}
135impl From<StorageError> for EngineError {
136    fn from(e: StorageError) -> Self {
137        Self::Storage(e)
138    }
139}
140impl From<EvalError> for EngineError {
141    fn from(e: EvalError) -> Self {
142        Self::Eval(e)
143    }
144}
145
146/// The execution engine. Holds the catalog and (later) other server-scope
147/// state. `Engine::new()` is intentionally cheap so callers can construct one
148/// per database, per test.
149/// Function pointer that returns "now" as microseconds since Unix
150/// epoch. The engine is `no_std`, so it can't reach for `std::time`
151/// itself — callers (`spg-server`, the sqllogictest runner) inject a
152/// concrete implementation. `None` means `NOW()` / `CURRENT_*` raise
153/// `Unsupported`.
154pub type ClockFn = fn() -> i64;
155
156/// Function pointer that produces 16 cryptographically random bytes.
157/// Like `ClockFn`, the engine is `no_std` and can't reach for /dev/urandom
158/// itself — host (`spg-server`) injects an OS-backed source. `None`
159/// means SQL-driven `CREATE USER` falls back to a deterministic salt
160/// derived from the username (acceptable in tests; the server always
161/// installs a real RNG so production paths never see this).
162pub type SaltFn = fn() -> [u8; 16];
163
164/// v4.5 cooperative cancellation token. A long-running SELECT /
165/// UPDATE / DELETE checks `is_cancelled` at row-loop checkpoints
166/// and bails with `EngineError::Cancelled`. The host
167/// (`spg-server`) creates an `AtomicBool` per query, spawns a
168/// watchdog thread that sets it after `SPG_QUERY_TIMEOUT_MS`,
169/// and passes it via `execute_with_cancel` / `execute_readonly_with_cancel`.
170///
171/// `CancelToken::none()` is a no-op — used by the legacy `execute`
172/// and `execute_readonly` entry points so existing callers don't
173/// change.
174/// v7.17.0 Phase 2.3 — monotonic time source for deadline-aware
175/// cancellation (PG `statement_timeout`). Returns microseconds
176/// since some host-stable monotonic origin (typically the first
177/// call into `Instant::now()` on the server). The engine never
178/// calls `Instant::now()` directly so the crate stays `#![no_std]`.
179pub type MonotonicNowFn = fn() -> u64;
180
181#[derive(Debug, Clone, Copy)]
182struct Deadline {
183    now_fn: MonotonicNowFn,
184    /// Absolute deadline in `now_fn()` units (microseconds).
185    deadline_us: u64,
186}
187
188#[derive(Debug, Clone, Copy)]
189pub struct CancelToken<'a> {
190    flag: Option<&'a core::sync::atomic::AtomicBool>,
191    // v7.17.0 Phase 2.3 — when set, every existing `cancel.check()`
192    // checkpoint also fires `EngineError::Cancelled` once
193    // `(now_fn)() >= deadline_us`. No new check sites, no thread
194    // spawn per query — the monotonic now-fn read is a vDSO
195    // `clock_gettime(CLOCK_MONOTONIC)` (~20ns) and only runs when
196    // the host actually wired a deadline (statement_timeout > 0).
197    deadline: Option<Deadline>,
198}
199
200impl<'a> CancelToken<'a> {
201    #[must_use]
202    pub const fn none() -> Self {
203        Self {
204            flag: None,
205            deadline: None,
206        }
207    }
208
209    #[must_use]
210    pub const fn from_flag(f: &'a core::sync::atomic::AtomicBool) -> Self {
211        Self {
212            flag: Some(f),
213            deadline: None,
214        }
215    }
216
217    /// v7.17.0 Phase 2.3 — attach a monotonic deadline. `now_fn`
218    /// must return microseconds since a stable origin; the token
219    /// trips when `now_fn() >= deadline_us`. Compose with
220    /// `from_flag(...)` when both a watchdog flag and a per-statement
221    /// timeout are in play (e.g. server-wide `SPG_QUERY_TIMEOUT_MS`
222    /// plus session `statement_timeout`); the tighter of the two
223    /// wins by virtue of either signaling first.
224    #[must_use]
225    pub const fn with_deadline(mut self, now_fn: MonotonicNowFn, deadline_us: u64) -> Self {
226        self.deadline = Some(Deadline {
227            now_fn,
228            deadline_us,
229        });
230        self
231    }
232
233    #[must_use]
234    pub fn is_cancelled(self) -> bool {
235        if self
236            .flag
237            .is_some_and(|f| f.load(core::sync::atomic::Ordering::Relaxed))
238        {
239            return true;
240        }
241        // Deadline check is the second branch so the "no timeout"
242        // hot path (`deadline: None`) elides the now-fn call —
243        // predicted-not-taken on the SLO INSERT loop.
244        if let Some(d) = self.deadline
245            && (d.now_fn)() >= d.deadline_us
246        {
247            return true;
248        }
249        false
250    }
251
252    /// Returns `Err(Cancelled)` if the token has been tripped.
253    /// Used at row-loop checkpoints to bail cooperatively without
254    /// scattering raw `is_cancelled` checks across the executor.
255    #[inline]
256    pub fn check(self) -> Result<(), EngineError> {
257        if self.is_cancelled() {
258            Err(EngineError::Cancelled)
259        } else {
260            Ok(())
261        }
262    }
263}
264
265// ---- snapshot envelope (v4.1, extended with CRC32 in v4.37,  ----
266// ----   publications in v6.1.2 v3, subscriptions in v6.1.4 v4) ----
267//
268// Wraps a catalog blob + a user blob behind a small header so the
269// server can persist both atomically without inventing a new file.
270// Bare catalog blobs (v3.x) still load via `restore_envelope` since
271// the magic check fails fast and the function falls back to
272// `Catalog::deserialize`.
273//
274// Layout — v1 (v4.1, no CRC):
275//   [8 bytes magic "SPGENV01"]
276//   [u8 version = 1]
277//   [u32 catalog_len][catalog bytes]
278//   [u32 users_len][users bytes]
279//
280// Layout — v2 (v4.37, CRC32 of body):
281//   [8 bytes magic "SPGENV01"]
282//   [u8 version = 2]
283//   [u32 catalog_len][catalog bytes]
284//   [u32 users_len][users bytes]
285//   [u32 crc32]                      ← CRC32 of every byte before it.
286//
287// Layout — v3 (v6.1.2, publications trailer):
288//   [8 bytes magic "SPGENV01"]
289//   [u8 version = 3]
290//   [u32 catalog_len][catalog bytes]
291//   [u32 users_len][users bytes]
292//   [u32 pubs_len][publications bytes]
293//   [u32 crc32]
294//
295// Layout — v4 (v6.1.4, subscriptions trailer):
296//   [8 bytes magic "SPGENV01"]
297//   [u8 version = 4]
298//   [u32 catalog_len][catalog bytes]
299//   [u32 users_len][users bytes]
300//   [u32 pubs_len][publications bytes]
301//   [u32 subs_len][subscriptions bytes]
302//   [u32 crc32]
303//
304// Layout — v5 (v6.2.0, statistics trailer):
305//   [8 bytes magic "SPGENV01"]
306//   [u8 version = 5]
307//   [u32 catalog_len][catalog bytes]
308//   [u32 users_len][users bytes]
309//   [u32 pubs_len][publications bytes]
310//   [u32 subs_len][subscriptions bytes]
311//   [u32 stats_len][statistics bytes]      ← NEW
312//   [u32 crc32]
313//
314// Writers emit v5 from v6.2.0 on. Readers accept all of {v1, v2,
315// v3, v4, v5}: v1/v2 load with empty publications / subscriptions /
316// statistics; v3 loads with empty subscriptions + statistics; v4
317// loads with empty statistics; v5 deserialises all three. Older
318// SPG versions reading a v5 envelope fall through the version
319// match to `EnvelopeParse::Bare` — pre-v6.2.0 binaries cannot
320// open v6.2.0+ snapshots (matches the v6.1.2 / v6.1.4 breaks).
321
322const ENVELOPE_MAGIC: &[u8; 8] = b"SPGENV01";
323const ENVELOPE_VERSION_V1: u8 = 1;
324const ENVELOPE_VERSION_V2: u8 = 2;
325const ENVELOPE_VERSION_V3: u8 = 3;
326const ENVELOPE_VERSION_V4: u8 = 4;
327const ENVELOPE_VERSION_V5: u8 = 5;
328
329fn build_envelope(catalog: &[u8], users: &[u8], pubs: &[u8], subs: &[u8], stats: &[u8]) -> Vec<u8> {
330    let mut out = Vec::with_capacity(
331        8 + 1
332            + 4
333            + catalog.len()
334            + 4
335            + users.len()
336            + 4
337            + pubs.len()
338            + 4
339            + subs.len()
340            + 4
341            + stats.len()
342            + 4,
343    );
344    out.extend_from_slice(ENVELOPE_MAGIC);
345    out.push(ENVELOPE_VERSION_V5);
346    out.extend_from_slice(
347        &u32::try_from(catalog.len())
348            .expect("≤ 4G catalog")
349            .to_le_bytes(),
350    );
351    out.extend_from_slice(catalog);
352    out.extend_from_slice(
353        &u32::try_from(users.len())
354            .expect("≤ 4G users")
355            .to_le_bytes(),
356    );
357    out.extend_from_slice(users);
358    out.extend_from_slice(
359        &u32::try_from(pubs.len())
360            .expect("≤ 4G publications")
361            .to_le_bytes(),
362    );
363    out.extend_from_slice(pubs);
364    out.extend_from_slice(
365        &u32::try_from(subs.len())
366            .expect("≤ 4G subscriptions")
367            .to_le_bytes(),
368    );
369    out.extend_from_slice(subs);
370    out.extend_from_slice(
371        &u32::try_from(stats.len())
372            .expect("≤ 4G statistics")
373            .to_le_bytes(),
374    );
375    out.extend_from_slice(stats);
376    let crc = spg_crypto::crc32::crc32(&out);
377    out.extend_from_slice(&crc.to_le_bytes());
378    out
379}
380
381/// Outcome of envelope parsing: either bare-catalog fallback, a
382/// successfully split section trio from a v1/v2/v3 envelope, or an
383/// explicit corruption error from a v2/v3 CRC mismatch. `Bare`
384/// (catalog-only fallback) preserves v3.x readability. v1/v2
385/// envelopes set `publications` to `None`; v3 sets it to the
386/// publications byte slice.
387enum EnvelopeParse<'a> {
388    Bare,
389    Pair {
390        catalog: &'a [u8],
391        users: &'a [u8],
392        publications: Option<&'a [u8]>,
393        subscriptions: Option<&'a [u8]>,
394        statistics: Option<&'a [u8]>,
395    },
396    CrcMismatch {
397        expected: u32,
398        computed: u32,
399    },
400}
401
402/// Returns `EnvelopeParse::Pair` for a valid v1 / v2 / v3 envelope,
403/// `Bare` for a buffer that doesn't look like an envelope (v3.x
404/// bare catalog fallback), and `CrcMismatch` for a v2/v3 envelope
405/// whose trailing CRC32 doesn't match the body.
406fn split_envelope(buf: &[u8]) -> EnvelopeParse<'_> {
407    if buf.len() < 8 + 1 + 4 || &buf[..8] != ENVELOPE_MAGIC {
408        return EnvelopeParse::Bare;
409    }
410    let version = buf[8];
411    if !matches!(
412        version,
413        ENVELOPE_VERSION_V1
414            | ENVELOPE_VERSION_V2
415            | ENVELOPE_VERSION_V3
416            | ENVELOPE_VERSION_V4
417            | ENVELOPE_VERSION_V5
418    ) {
419        return EnvelopeParse::Bare;
420    }
421    let mut p = 9usize;
422    let Some(cat_len_bytes) = buf.get(p..p + 4) else {
423        return EnvelopeParse::Bare;
424    };
425    let Ok(cat_len_arr) = cat_len_bytes.try_into() else {
426        return EnvelopeParse::Bare;
427    };
428    let cat_len = u32::from_le_bytes(cat_len_arr) as usize;
429    p += 4;
430    if p + cat_len + 4 > buf.len() {
431        return EnvelopeParse::Bare;
432    }
433    let catalog = &buf[p..p + cat_len];
434    p += cat_len;
435    let Some(user_len_bytes) = buf.get(p..p + 4) else {
436        return EnvelopeParse::Bare;
437    };
438    let Ok(user_len_arr) = user_len_bytes.try_into() else {
439        return EnvelopeParse::Bare;
440    };
441    let user_len = u32::from_le_bytes(user_len_arr) as usize;
442    p += 4;
443    if p + user_len > buf.len() {
444        return EnvelopeParse::Bare;
445    }
446    let users = &buf[p..p + user_len];
447    p += user_len;
448    let publications = if matches!(
449        version,
450        ENVELOPE_VERSION_V3 | ENVELOPE_VERSION_V4 | ENVELOPE_VERSION_V5
451    ) {
452        // [u32 pubs_len][publications bytes]
453        let Some(pubs_len_bytes) = buf.get(p..p + 4) else {
454            return EnvelopeParse::Bare;
455        };
456        let Ok(pubs_len_arr) = pubs_len_bytes.try_into() else {
457            return EnvelopeParse::Bare;
458        };
459        let pubs_len = u32::from_le_bytes(pubs_len_arr) as usize;
460        p += 4;
461        if p + pubs_len > buf.len() {
462            return EnvelopeParse::Bare;
463        }
464        let pubs_slice = &buf[p..p + pubs_len];
465        p += pubs_len;
466        Some(pubs_slice)
467    } else {
468        None
469    };
470    let subscriptions = if matches!(version, ENVELOPE_VERSION_V4 | ENVELOPE_VERSION_V5) {
471        // [u32 subs_len][subscriptions bytes]
472        let Some(subs_len_bytes) = buf.get(p..p + 4) else {
473            return EnvelopeParse::Bare;
474        };
475        let Ok(subs_len_arr) = subs_len_bytes.try_into() else {
476            return EnvelopeParse::Bare;
477        };
478        let subs_len = u32::from_le_bytes(subs_len_arr) as usize;
479        p += 4;
480        if p + subs_len > buf.len() {
481            return EnvelopeParse::Bare;
482        }
483        let subs_slice = &buf[p..p + subs_len];
484        p += subs_len;
485        Some(subs_slice)
486    } else {
487        None
488    };
489    let statistics = if version == ENVELOPE_VERSION_V5 {
490        // [u32 stats_len][statistics bytes]
491        let Some(stats_len_bytes) = buf.get(p..p + 4) else {
492            return EnvelopeParse::Bare;
493        };
494        let Ok(stats_len_arr) = stats_len_bytes.try_into() else {
495            return EnvelopeParse::Bare;
496        };
497        let stats_len = u32::from_le_bytes(stats_len_arr) as usize;
498        p += 4;
499        if p + stats_len > buf.len() {
500            return EnvelopeParse::Bare;
501        }
502        let stats_slice = &buf[p..p + stats_len];
503        p += stats_len;
504        Some(stats_slice)
505    } else {
506        None
507    };
508    if matches!(
509        version,
510        ENVELOPE_VERSION_V2 | ENVELOPE_VERSION_V3 | ENVELOPE_VERSION_V4 | ENVELOPE_VERSION_V5
511    ) {
512        if p + 4 != buf.len() {
513            return EnvelopeParse::Bare;
514        }
515        let Ok(crc_arr) = buf[p..p + 4].try_into() else {
516            return EnvelopeParse::Bare;
517        };
518        let expected = u32::from_le_bytes(crc_arr);
519        let computed = spg_crypto::crc32::crc32(&buf[..p]);
520        if expected != computed {
521            return EnvelopeParse::CrcMismatch { expected, computed };
522        }
523    } else if p != buf.len() {
524        // v1: must end exactly at the users section.
525        return EnvelopeParse::Bare;
526    }
527    EnvelopeParse::Pair {
528        catalog,
529        users,
530        publications,
531        subscriptions,
532        statistics,
533    }
534}
535
536/// v4.41.1 opaque transaction handle. Returned by `Engine::alloc_tx_id`,
537/// threaded through `Engine::execute_in` so dispatch can identify which
538/// in-flight TX a statement belongs to. `IMPLICIT_TX` is the reserved
539/// slot every legacy caller — engine self-tests, spg-cli, spg-embedded,
540/// startup replay — implicitly uses through the unchanged
541/// `Engine::execute(sql)` API. v4.41.1 keeps at most one active slot at
542/// runtime (dispatch holds `engine.write()` across the wrap, same as
543/// v4.34); the map shape is here to let v4.42 turn on N in-flight
544/// implicit TXs without reshuffling the engine internals.
545#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
546pub struct TxId(pub u64);
547
548/// Reserved slot used by `Engine::execute(sql)` — the legacy single-
549/// global-shadow path. New `alloc_tx_id` handles start at 1.
550pub const IMPLICIT_TX: TxId = TxId(0);
551
552/// v6.7.3 — default segment-size threshold used by `COMPACT COLD
553/// SEGMENTS` when no explicit target is supplied. Segments whose
554/// `OwnedSegment::bytes().len()` is **strictly** less than this
555/// value are eligible to merge. spg-server reads
556/// `SPG_COMPACTION_TARGET_SEGMENT_BYTES` to override.
557pub const COMPACTION_TARGET_DEFAULT_BYTES: u64 = 4 * 1024 * 1024;
558
559/// Per-slot transaction state. Held inside `tx_catalogs[tx_id]` for the
560/// lifetime of a BEGIN..COMMIT (or BEGIN..ROLLBACK) window. Drops when
561/// the TX commits (its `catalog` is moved over `Engine.catalog`) or
562/// rolls back (slot removed, catalog discarded).
563#[derive(Debug, Default, Clone)]
564struct TxState {
565    /// The TX's shadow copy of the catalog. Started as a clone of
566    /// `Engine.catalog` at BEGIN time; writes flow into it; COMMIT
567    /// installs it over `Engine.catalog`. `Catalog::clone()` is O(1)
568    /// since v4.40 (`PersistentVec` rows + `PersistentBTreeMap` indices).
569    catalog: Catalog,
570    /// Per-TX savepoint stack. Each entry pairs the savepoint name with
571    /// a clone of `catalog` at the moment `SAVEPOINT <name>` fired.
572    /// `ROLLBACK TO <name>` restores from the entry and pops everything
573    /// after it; `RELEASE <name>` discards the entry and everything
574    /// after; COMMIT/ROLLBACK clears the whole stack.
575    savepoints: Vec<(String, Catalog)>,
576}
577
578/// v7.11.0 — frozen read-only view of the engine's committed state.
579/// Constructed via [`Engine::clone_snapshot`]. Holds clones of the
580/// catalog, statistics, clock function, and row-cap config — the
581/// four fields the `execute_readonly` path actually reads. Cheap to
582/// `Clone` (each clone shares the underlying `PersistentVec` row
583/// storage; only the trie root pointers copy). Send + Sync so a
584/// snapshot can be moved across `tokio::task::spawn_blocking`
585/// boundaries without coordination.
586///
587/// The contract: a snapshot reflects the engine's state at the
588/// moment `clone_snapshot()` returned. Subsequent writes to the
589/// engine are NOT visible. Callers who need fresher data take a
590/// new snapshot.
591#[derive(Debug, Clone)]
592pub struct CatalogSnapshot {
593    catalog: Catalog,
594    statistics: statistics::Statistics,
595    clock: Option<ClockFn>,
596    max_query_rows: Option<usize>,
597}
598
599#[derive(Debug, Default)]
600pub struct Engine {
601    /// Committed catalog — what survives `Engine::snapshot()` and what
602    /// outside-TX `SELECT`s read.
603    catalog: Catalog,
604    /// Active TX slots, keyed by `TxId`. Empty when no TX is in flight.
605    /// v4.41.1 runtime invariant: at most one entry (single-writer
606    /// model unchanged). v4.42 will let dispatch hold multiple entries
607    /// concurrently for group commit + engine MVCC.
608    tx_catalogs: BTreeMap<TxId, TxState>,
609    /// Which slot the next exec_* call should mutate. Set by
610    /// `execute_in(sql, tx_id)` at the entry point; legacy `execute(sql)`
611    /// sets it to `IMPLICIT_TX`. None when no TX is in flight (read /
612    /// write goes straight against `catalog`).
613    current_tx: Option<TxId>,
614    /// Monotonic counter for `alloc_tx_id`. Starts at 1 — slot 0 is
615    /// reserved for `IMPLICIT_TX`.
616    next_tx_id: u64,
617    /// v7.22 (round-13 T3) — session string-literal dialect. `false`
618    /// (default) = PG semantics (backslash literal, `''` escape);
619    /// `true` = MySQL semantics (`\'` etc.). Flipped by the
620    /// deterministic session signals each dump emits: `SET sql_mode`
621    /// (only MySQL clients/dumps send it) turns it on,
622    /// `SET standard_conforming_strings = on` (every pg_dump
623    /// preamble) turns it off. The plan cache is cleared on every
624    /// flip — the same SQL text lexes differently per dialect.
625    backslash_escapes: bool,
626    /// Optional wall clock used to satisfy `NOW()` / `CURRENT_TIMESTAMP`
627    /// / `CURRENT_DATE`. Set by the host environment.
628    clock: Option<ClockFn>,
629    /// v4.1 cryptographic RNG for per-user password salt. Set by the
630    /// host. `None` means SQL-driven `CREATE USER` uses a
631    /// deterministic fallback — see `SaltFn`.
632    salt_fn: Option<SaltFn>,
633    /// v4.2 per-query row cap. `None` = unlimited. When set, a
634    /// SELECT that materialises more than `n` rows returns
635    /// `EngineError::RowLimitExceeded`. Enforced before the result
636    /// is shaped into wire frames so a runaway scan can't blow the
637    /// server's heap.
638    max_query_rows: Option<usize>,
639    /// v4.1 RBAC user table. Empty means "no RBAC configured yet" —
640    /// the server decides what that means at the auth boundary
641    /// (open mode vs legacy single-password mode). User CRUD goes
642    /// through `create_user`/`drop_user`/`verify_user`; persistence
643    /// rides the snapshot envelope alongside the catalog.
644    users: UserStore,
645    /// v6.1.2 logical-replication publication catalog. Empty until
646    /// `CREATE PUBLICATION` runs. Persistence rides the v3 envelope
647    /// trailer (see `build_envelope`).
648    publications: publications::Publications,
649    /// v6.1.4 logical-replication subscription catalog. Empty until
650    /// `CREATE SUBSCRIPTION` runs. Persistence rides the v4 envelope
651    /// trailer.
652    subscriptions: subscriptions::Subscriptions,
653    /// v6.2.0 — per-column statistics for the cost-based optimizer.
654    /// Populated by `ANALYZE`; queried via `spg_statistic` virtual
655    /// table. Persistence rides the v5 envelope trailer.
656    statistics: statistics::Statistics,
657    /// v6.3.0 — engine-level plan cache. Caches the post-`prepare()`
658    /// `Statement` keyed on SQL text. In-memory only — does NOT ride
659    /// the snapshot envelope (rebuilt on demand after restart).
660    plan_cache: plan_cache::PlanCache,
661    /// v6.5.1 — per-distinct-SQL execution stats. In-memory only,
662    /// surfaced via `spg_stat_query` virtual table. Updated by the
663    /// `execute_*` paths after a successful execute.
664    query_stats: query_stats::QueryStats,
665    /// v6.5.2 — connection-state provider callback. spg-server
666    /// registers a function at startup that snapshots its
667    /// per-pgwire-connection registry into `ActivityRow`s; engine
668    /// reads through it on every `SELECT * FROM spg_stat_activity`.
669    /// `None` ⇒ no-data (returns empty rows; matches the no_std
670    /// embedded callers that don't run pgwire).
671    activity_provider: Option<ActivityProvider>,
672    /// v6.5.3 — audit-chain provider + verifier. Same pattern as
673    /// activity_provider: spg-server registers both at startup;
674    /// engine reads through on `SELECT * FROM spg_audit_chain` and
675    /// `SELECT * FROM spg_audit_verify`. `None` ⇒ no-data.
676    audit_chain_provider: Option<AuditChainProvider>,
677    audit_verifier: Option<AuditVerifier>,
678    /// v6.5.6 — slow-query log threshold in microseconds. When set,
679    /// every successful execute whose elapsed exceeds the threshold
680    /// gets fed to the registered slow-query log callback (so
681    /// spg-server can emit a structured log line). Default `None`
682    /// = no slow-query logging.
683    slow_query_threshold_us: Option<u64>,
684    slow_query_logger: Option<SlowQueryLogger>,
685    /// v7.12.1 — session parameters set via `SET <name> = <value>`.
686    /// Only `default_text_search_config` is consumed by the engine
687    /// today (the FTS function dispatcher reads it when
688    /// `to_tsvector(text)` is called without an explicit config).
689    /// All other names are accepted + recorded so PG-dump output
690    /// loads, but have no behavioural effect.
691    session_params: BTreeMap<String, String>,
692    /// v7.12.7 — depth counter for trigger-emitted embedded SQL.
693    /// Each time the engine executes a `DeferredEmbeddedStmt` it
694    /// increments this; the recursive `execute_stmt_with_cancel`
695    /// inside that path checks against [`MAX_TRIGGER_RECURSION`]
696    /// to bound runaway cascades (trigger A's UPDATE on table B
697    /// fires trigger B which UPDATEs table A which fires trigger
698    /// A again…). Reset to 0 once the original DML returns.
699    trigger_recursion_depth: u32,
700    /// v7.14.0 — when `SET FOREIGN_KEY_CHECKS=0` is in effect
701    /// (mysqldump preamble), the FK existence + arity check at
702    /// CREATE TABLE time is deferred. FKs referencing a
703    /// not-yet-existing parent land in `pending_foreign_keys`
704    /// keyed by child table; `SET FOREIGN_KEY_CHECKS=1` drains
705    /// the queue and resolves each FK against the now-complete
706    /// catalog. Empty by default; the queue is drained on every
707    /// `RESET ALL` too.
708    foreign_key_checks: bool,
709    /// v7.16.2 — true on the temp Engine an outer
710    /// `exec_select_with_meta_views` builds, telling that
711    /// temp engine "stop short-circuiting into the meta-view
712    /// path — your catalog already has the materialised
713    /// tables; just run the regular SELECT." Without this we'd
714    /// infinite-loop since the meta-view name (e.g.
715    /// `__spg_info_columns`) still triggers
716    /// `select_references_meta_view`.
717    meta_views_materialised: bool,
718    pending_foreign_keys: Vec<(alloc::string::String, spg_sql::ast::ForeignKeyConstraint)>,
719}
720
721/// v7.12.7 — hard cap on nested trigger-emitted embedded SQL
722/// fires. 16 deep is well past anything a normal trigger graph
723/// uses while still preventing infinite-loop wedging.
724const MAX_TRIGGER_RECURSION: u32 = 16;
725
726/// v6.5.6 — callback signature for slow-query log emission. Called
727/// with `(sql, elapsed_us)` once per successful execute that crosses
728/// the threshold.
729pub type SlowQueryLogger = fn(&str, u64);
730
731/// v6.5.4 — synthesise a `CREATE TABLE` statement from catalog
732/// state. Round-trips through `Engine::execute` to recreate the
733/// same schema (sans data + indexes — indexes are emitted as a
734/// separate `CREATE INDEX` chain in `spg_database_ddl`).
735fn render_create_table(name: &str, columns: &[ColumnSchema]) -> String {
736    let mut out = alloc::format!("CREATE TABLE {name} (");
737    for (i, col) in columns.iter().enumerate() {
738        if i > 0 {
739            out.push_str(", ");
740        }
741        out.push_str(&col.name);
742        out.push(' ');
743        out.push_str(&render_data_type(col.ty));
744        if !col.nullable {
745            out.push_str(" NOT NULL");
746        }
747        if col.auto_increment {
748            out.push_str(" AUTO_INCREMENT");
749        }
750    }
751    out.push(')');
752    out
753}
754
755fn render_data_type(ty: DataType) -> String {
756    match ty {
757        DataType::SmallInt => "SMALLINT".into(),
758        DataType::Int => "INT".into(),
759        DataType::BigInt => "BIGINT".into(),
760        DataType::Float => "FLOAT".into(),
761        DataType::Text => "TEXT".into(),
762        DataType::Varchar(n) => alloc::format!("VARCHAR({n})"),
763        DataType::Char(n) => alloc::format!("CHAR({n})"),
764        DataType::Bool => "BOOL".into(),
765        DataType::Vector { dim, encoding } => match encoding {
766            spg_storage::VecEncoding::F32 => alloc::format!("VECTOR({dim})"),
767            spg_storage::VecEncoding::Sq8 => alloc::format!("VECTOR({dim}) USING SQ8"),
768            spg_storage::VecEncoding::F16 => alloc::format!("VECTOR({dim}) USING HALF"),
769        },
770        DataType::Numeric { precision, scale } => {
771            alloc::format!("NUMERIC({precision},{scale})")
772        }
773        DataType::Date => "DATE".into(),
774        DataType::Timestamp => "TIMESTAMP".into(),
775        DataType::Interval => "INTERVAL".into(),
776        DataType::Json => "JSON".into(),
777        DataType::Jsonb => "JSONB".into(),
778        DataType::Timestamptz => "TIMESTAMPTZ".into(),
779        DataType::Bytes => "BYTEA".into(),
780        DataType::TextArray => "TEXT[]".into(),
781        DataType::IntArray => "INT[]".into(),
782        DataType::BigIntArray => "BIGINT[]".into(),
783        DataType::TsVector => "TSVECTOR".into(),
784        DataType::TsQuery => "TSQUERY".into(),
785        DataType::Uuid => "UUID".into(),
786        DataType::Time => "TIME".into(),
787        DataType::Year => "YEAR".into(),
788        DataType::TimeTz => "TIMETZ".into(),
789        DataType::Money => "MONEY".into(),
790        DataType::Range(k) => k.keyword().into(),
791        DataType::Hstore => "HSTORE".into(),
792        DataType::IntArray2D => "INT[][]".into(),
793        DataType::BigIntArray2D => "BIGINT[][]".into(),
794        DataType::TextArray2D => "TEXT[][]".into(),
795    }
796}
797
798/// v6.5.2 — one row of `spg_stat_activity`. Engine-public so
799/// spg-server can construct rows without re-exporting internal
800/// dispatch types.
801#[derive(Debug, Clone)]
802pub struct ActivityRow {
803    pub pid: u32,
804    pub user: String,
805    pub started_at_us: i64,
806    pub current_sql: String,
807    pub wait_event: String,
808    pub elapsed_us: i64,
809    pub in_transaction: bool,
810    /// v7.17 Phase 2.4 — startup-param `application_name` (or the
811    /// last value the client sent via `SET application_name = '...'`).
812    /// Empty when the client never declared one.
813    pub application_name: String,
814}
815
816/// v6.5.2 — provider callback type. Fresh snapshot returned each
817/// call; engine doesn't cache the slice.
818pub type ActivityProvider = fn() -> Vec<ActivityRow>;
819
820/// v6.5.3 — one row of `spg_audit_chain`. Engine-public so
821/// spg-server can construct rows directly from `AuditEntry`.
822#[derive(Debug, Clone)]
823pub struct AuditRow {
824    pub seq: i64,
825    pub ts_ms: i64,
826    pub prev_hash_hex: String,
827    pub entry_hash_hex: String,
828    pub sql: String,
829}
830
831/// v6.5.3 — chain-table provider + verifier. spg-server registers
832/// fn pointers that snapshot / verify the audit log. `verify`
833/// returns `(verified_count, broken_at_seq)` — `broken_at_seq` is
834/// `-1` on a clean chain.
835pub type AuditChainProvider = fn() -> Vec<AuditRow>;
836pub type AuditVerifier = fn() -> (i64, i64);
837
838impl Engine {
839    pub fn new() -> Self {
840        Self {
841            catalog: Catalog::new(),
842            tx_catalogs: BTreeMap::new(),
843            current_tx: None,
844            backslash_escapes: false,
845            next_tx_id: 1,
846            clock: None,
847            salt_fn: None,
848            max_query_rows: None,
849            users: UserStore::new(),
850            publications: publications::Publications::new(),
851            subscriptions: subscriptions::Subscriptions::new(),
852            statistics: statistics::Statistics::new(),
853            plan_cache: plan_cache::PlanCache::new(),
854            query_stats: query_stats::QueryStats::new(),
855            activity_provider: None,
856            audit_chain_provider: None,
857            audit_verifier: None,
858            slow_query_threshold_us: None,
859            slow_query_logger: None,
860            session_params: BTreeMap::new(),
861            trigger_recursion_depth: 0,
862            foreign_key_checks: true,
863            meta_views_materialised: false,
864            pending_foreign_keys: Vec::new(),
865        }
866    }
867
868    /// v7.11.0 — clone the engine's committed catalog + read-time
869    /// state into a frozen `CatalogSnapshot`. Cheap (`Catalog` is
870    /// backed by `PersistentVec`; cloning is O(log n) per table).
871    /// Subsequent writes to this engine are invisible to the
872    /// snapshot; the snapshot is self-contained and can be moved
873    /// to another thread for concurrent `execute_readonly_on_snapshot`
874    /// calls. The basis for [`AsyncReadHandle`] in spg-embedded-tokio
875    /// and any other read-fanout pattern.
876    #[must_use]
877    pub fn clone_snapshot(&self) -> CatalogSnapshot {
878        CatalogSnapshot {
879            catalog: self.active_catalog().clone(),
880            statistics: self.statistics.clone(),
881            clock: self.clock,
882            max_query_rows: self.max_query_rows,
883        }
884    }
885
886    /// v7.11.1 — execute a read-only SQL statement against a
887    /// `CatalogSnapshot` without touching this engine. Same
888    /// semantics as `execute_readonly` but parameterised on the
889    /// snapshot's catalog. Reject DDL/DML the same way
890    /// `execute_readonly` does. Static-on-Self so the caller can
891    /// dispatch without holding an `Engine` borrow alongside the
892    /// snapshot.
893    pub fn execute_readonly_on_snapshot(
894        snapshot: &CatalogSnapshot,
895        sql: &str,
896    ) -> Result<QueryResult, EngineError> {
897        Self::execute_readonly_on_snapshot_with_cancel(snapshot, sql, CancelToken::none())
898    }
899
900    /// v7.11.1 — `execute_readonly_on_snapshot` with cooperative
901    /// cancellation. Builds a transient `Engine` over the snapshot
902    /// state, runs `execute_readonly_with_cancel`, drops. The
903    /// transient engine is cheap to construct (no I/O; everything
904    /// is just struct moves) and lets the existing read path stay
905    /// untouched.
906    pub fn execute_readonly_on_snapshot_with_cancel(
907        snapshot: &CatalogSnapshot,
908        sql: &str,
909        cancel: CancelToken<'_>,
910    ) -> Result<QueryResult, EngineError> {
911        let transient = Engine {
912            catalog: snapshot.catalog.clone(),
913            statistics: snapshot.statistics.clone(),
914            clock: snapshot.clock,
915            max_query_rows: snapshot.max_query_rows,
916            ..Engine::default()
917        };
918        transient.execute_readonly_with_cancel(sql, cancel)
919    }
920
921    /// v7.18 — execute a previously-prepared `Statement` against a
922    /// `CatalogSnapshot` in read-only mode. Mirror of
923    /// [`Engine::execute_prepared`] for the fan-out read path:
924    /// substitutes `Expr::Placeholder(n)` nodes from `params`, then
925    /// dispatches through [`Engine::execute_readonly_stmt_with_cancel`]
926    /// (writes / DDL hit `EngineError::WriteRequired`). Static-on-Self
927    /// so multiple readonly threads can dispatch against the same
928    /// snapshot concurrently without an `Engine` borrow.
929    ///
930    /// **Schema drift contract**. The `Statement` was prepared against
931    /// some prior catalog. If the snapshot's catalog has since
932    /// diverged (DDL renamed / dropped a referenced column / table),
933    /// execution surfaces the normal `EngineError` — same shape as
934    /// PG's "cached plan must not change result type". Caller decides
935    /// whether to re-prepare; engine does NOT auto-retry.
936    pub fn execute_readonly_prepared_on_snapshot(
937        snapshot: &CatalogSnapshot,
938        stmt: Statement,
939        params: &[Value],
940    ) -> Result<QueryResult, EngineError> {
941        Self::execute_readonly_prepared_on_snapshot_with_cancel(
942            snapshot,
943            stmt,
944            params,
945            CancelToken::none(),
946        )
947    }
948
949    /// v7.18 — cancellable variant of
950    /// [`Engine::execute_readonly_prepared_on_snapshot`].
951    pub fn execute_readonly_prepared_on_snapshot_with_cancel(
952        snapshot: &CatalogSnapshot,
953        mut stmt: Statement,
954        params: &[Value],
955        cancel: CancelToken<'_>,
956    ) -> Result<QueryResult, EngineError> {
957        cancel.check()?;
958        substitute_placeholders(&mut stmt, params)?;
959        let transient = Engine {
960            catalog: snapshot.catalog.clone(),
961            statistics: snapshot.statistics.clone(),
962            clock: snapshot.clock,
963            max_query_rows: snapshot.max_query_rows,
964            ..Engine::default()
965        };
966        transient.execute_readonly_stmt_with_cancel(stmt, cancel)
967    }
968
969    /// v7.18 — describe a prepared `Statement` against a
970    /// `CatalogSnapshot`. Same `(parameter_oids, output_columns)`
971    /// shape as [`Engine::describe_prepared`]; resolves names
972    /// against the snapshot's catalog instead of `self`. Pure
973    /// function — no engine state read.
974    pub fn describe_prepared_on_snapshot(
975        snapshot: &CatalogSnapshot,
976        stmt: &Statement,
977    ) -> (Vec<u32>, Vec<ColumnSchema>) {
978        describe::describe_prepared(stmt, &snapshot.catalog)
979    }
980
981    /// v7.18 — does this SQL string classify as read-only? Parses
982    /// `sql` with the engine parser and consults
983    /// `Statement::is_readonly()`. A parse error returns `false`
984    /// (route to the writer path so the user sees the canonical
985    /// parse error from the writer's simple-query dispatch).
986    /// Static-on-Self so the spg-sqlx connection layer can ask
987    /// without an `Engine` borrow.
988    #[must_use]
989    pub fn is_readonly_sql(sql: &str) -> bool {
990        parser::parse_statement(sql)
991            .as_ref()
992            .map(spg_sql::ast::Statement::is_readonly)
993            .unwrap_or(false)
994    }
995
996    /// v7.18 — parse + plan a SQL string against a
997    /// `CatalogSnapshot`. Mirror of [`Engine::prepare`] for the
998    /// readonly fan-out path: applies the same prepare-time
999    /// transforms (clock rewrite, `GROUP BY ALL` expansion, ORDER
1000    /// BY position resolve, cost-based JOIN reorder) but resolves
1001    /// catalog + statistics against the snapshot, not a live
1002    /// engine. Static-on-Self — `AsyncReadHandle::prepare` calls
1003    /// this without taking the writer lock so multiple read
1004    /// handles can prepare concurrently against frozen views.
1005    ///
1006    /// # Errors
1007    /// Propagates [`ParseError`] from the parser. Schema
1008    /// validation deferred to execute time, same as
1009    /// [`Engine::prepare`].
1010    pub fn prepare_on_snapshot(
1011        snapshot: &CatalogSnapshot,
1012        sql: &str,
1013    ) -> Result<Statement, ParseError> {
1014        let mut stmt = parser::parse_statement(sql)?;
1015        let now_micros = snapshot.clock.map(|f| f());
1016        rewrite_clock_calls(&mut stmt, now_micros);
1017        if let Statement::Select(s) = &mut stmt {
1018            expand_group_by_all(s);
1019            resolve_order_by_position(s);
1020            reorder::reorder_joins(s, &snapshot.catalog, &snapshot.statistics);
1021        }
1022        Ok(stmt)
1023    }
1024
1025    /// Construct an engine restored from a previously-snapshotted catalog
1026    /// (see `snapshot()`).
1027    pub fn restore(catalog: Catalog) -> Self {
1028        Self {
1029            catalog,
1030            tx_catalogs: BTreeMap::new(),
1031            current_tx: None,
1032            backslash_escapes: false,
1033            next_tx_id: 1,
1034            clock: None,
1035            salt_fn: None,
1036            max_query_rows: None,
1037            users: UserStore::new(),
1038            publications: publications::Publications::new(),
1039            subscriptions: subscriptions::Subscriptions::new(),
1040            statistics: statistics::Statistics::new(),
1041            plan_cache: plan_cache::PlanCache::new(),
1042            query_stats: query_stats::QueryStats::new(),
1043            activity_provider: None,
1044            audit_chain_provider: None,
1045            audit_verifier: None,
1046            slow_query_threshold_us: None,
1047            slow_query_logger: None,
1048            session_params: BTreeMap::new(),
1049            trigger_recursion_depth: 0,
1050            foreign_key_checks: true,
1051            meta_views_materialised: false,
1052            pending_foreign_keys: Vec::new(),
1053        }
1054    }
1055
1056    /// Restore an engine + user table from a v4.1 envelope produced
1057    /// by `snapshot_with_users()`. Falls back to plain catalog-only
1058    /// restore if the envelope magic isn't present (so v3.x snapshot
1059    /// files still load). v6.1.2 adds the optional publications
1060    /// trailer (envelope v3); a v1/v2 envelope deserialises to an
1061    /// empty publication table.
1062    pub fn restore_envelope(buf: &[u8]) -> Result<Self, EngineError> {
1063        match split_envelope(buf) {
1064            EnvelopeParse::Pair {
1065                catalog: catalog_bytes,
1066                users: user_bytes,
1067                publications: pub_bytes,
1068                subscriptions: sub_bytes,
1069                statistics: stats_bytes,
1070            } => {
1071                let catalog = Catalog::deserialize(catalog_bytes).map_err(EngineError::Storage)?;
1072                let users = users::deserialize_users(user_bytes)
1073                    .map_err(|e| EngineError::Unsupported(alloc::format!("users restore: {e}")))?;
1074                let publications = match pub_bytes {
1075                    Some(b) => publications::Publications::deserialize(b).map_err(|e| {
1076                        EngineError::Unsupported(alloc::format!("publications restore: {e:?}"))
1077                    })?,
1078                    None => publications::Publications::new(),
1079                };
1080                let subscriptions = match sub_bytes {
1081                    Some(b) => subscriptions::Subscriptions::deserialize(b).map_err(|e| {
1082                        EngineError::Unsupported(alloc::format!("subscriptions restore: {e:?}"))
1083                    })?,
1084                    None => subscriptions::Subscriptions::new(),
1085                };
1086                let statistics = match stats_bytes {
1087                    Some(b) => statistics::Statistics::deserialize(b).map_err(|e| {
1088                        EngineError::Unsupported(alloc::format!("statistics restore: {e:?}"))
1089                    })?,
1090                    None => statistics::Statistics::new(),
1091                };
1092                Ok(Self {
1093                    catalog,
1094                    tx_catalogs: BTreeMap::new(),
1095                    current_tx: None,
1096                    backslash_escapes: false,
1097                    next_tx_id: 1,
1098                    clock: None,
1099                    salt_fn: None,
1100                    max_query_rows: None,
1101                    users,
1102                    publications,
1103                    subscriptions,
1104                    statistics,
1105                    plan_cache: plan_cache::PlanCache::new(),
1106                    query_stats: query_stats::QueryStats::new(),
1107                    activity_provider: None,
1108                    audit_chain_provider: None,
1109                    audit_verifier: None,
1110                    slow_query_threshold_us: None,
1111                    slow_query_logger: None,
1112                    session_params: BTreeMap::new(),
1113                    trigger_recursion_depth: 0,
1114                    foreign_key_checks: true,
1115                    meta_views_materialised: false,
1116                    pending_foreign_keys: Vec::new(),
1117                })
1118            }
1119            EnvelopeParse::CrcMismatch { expected, computed } => {
1120                Err(EngineError::Storage(StorageError::Corrupt(alloc::format!(
1121                    "snapshot envelope CRC32 mismatch (expected={expected:#010x}, computed={computed:#010x})"
1122                ))))
1123            }
1124            EnvelopeParse::Bare => {
1125                let catalog = Catalog::deserialize(buf).map_err(EngineError::Storage)?;
1126                Ok(Self::restore(catalog))
1127            }
1128        }
1129    }
1130
1131    pub const fn users(&self) -> &UserStore {
1132        &self.users
1133    }
1134
1135    /// `salt` is supplied by the caller (the host has a random
1136    /// source; the engine is `no_std`). Caller should pass a fresh
1137    /// 16-byte random value per user.
1138    pub fn create_user(
1139        &mut self,
1140        name: &str,
1141        password: &str,
1142        role: Role,
1143        salt: [u8; 16],
1144    ) -> Result<(), UserError> {
1145        self.users.create(name, password, role, salt)?;
1146        // v4.8: also derive SCRAM-SHA-256 secrets so PG-wire SASL
1147        // auth can verify without re-running PBKDF2 per attempt.
1148        // Uses a fresh salt from the host RNG (falls back to a
1149        // deterministic per-username salt when no RNG is wired, same
1150        // as the legacy hash path).
1151        let scram_salt = self.salt_fn.map_or_else(
1152            || {
1153                let mut s = [0u8; users::SCRAM_SALT_LEN];
1154                let digest = spg_crypto::hash(name.as_bytes());
1155                // Use bytes 16..32 of BLAKE3 so we don't reuse the
1156                // exact same fallback salt as the BLAKE3 hash path.
1157                s.copy_from_slice(&digest[16..32]);
1158                s
1159            },
1160            |f| f(),
1161        );
1162        self.users
1163            .enable_scram(name, password, scram_salt, users::SCRAM_DEFAULT_ITERS)?;
1164        Ok(())
1165    }
1166
1167    pub fn drop_user(&mut self, name: &str) -> Result<(), UserError> {
1168        self.users.drop(name)
1169    }
1170
1171    pub fn verify_user(&self, name: &str, password: &str) -> Option<Role> {
1172        self.users.verify(name, password)
1173    }
1174
1175    /// Builder: attach a wall clock so `NOW()` / `CURRENT_TIMESTAMP` /
1176    /// `CURRENT_DATE` evaluate to a real value instead of erroring out.
1177    #[must_use]
1178    pub const fn with_clock(mut self, clock: ClockFn) -> Self {
1179        self.clock = Some(clock);
1180        self
1181    }
1182
1183    /// Builder: attach an OS-backed RNG for per-user password salts.
1184    /// The host (`spg-server`) typically wires this to `/dev/urandom`.
1185    #[must_use]
1186    pub const fn with_salt_fn(mut self, f: SaltFn) -> Self {
1187        self.salt_fn = Some(f);
1188        self
1189    }
1190
1191    /// Builder: cap the number of rows a single SELECT may return.
1192    /// Exceeding the cap raises `EngineError::RowLimitExceeded` —
1193    /// the bound is checked inside the executor so a runaway
1194    /// catalog scan can't allocate millions of rows before the
1195    /// server gets a chance to reject the result.
1196    #[must_use]
1197    pub const fn with_max_query_rows(mut self, n: usize) -> Self {
1198        self.max_query_rows = Some(n);
1199        self
1200    }
1201
1202    /// The *committed* catalog. Note: during a transaction this returns the
1203    /// pre-TX state — `SELECT` inside a TX goes through `execute()` and reads
1204    /// the shadow. Tests that inspect outside-TX state should use this.
1205    pub const fn catalog(&self) -> &Catalog {
1206        &self.catalog
1207    }
1208
1209    /// Serialize the *committed* catalog to bytes. v0.6 was full-snapshot; v0.9
1210    /// adds the rule that an open TX's shadow is never snapshotted — only the
1211    /// post-COMMIT state is persisted. v4.1 wraps the catalog in an envelope
1212    /// when there are users to persist; an empty user table snapshots as the
1213    /// bare catalog format (backwards-compat with v3.x readers). v6.1.2
1214    /// adds publications to the envelope condition: either non-empty
1215    /// users OR non-empty publications now triggers the envelope path.
1216    pub fn snapshot(&self) -> Vec<u8> {
1217        if self.users.is_empty()
1218            && self.publications.is_empty()
1219            && self.subscriptions.is_empty()
1220            && self.statistics.is_empty()
1221        {
1222            self.catalog.serialize()
1223        } else {
1224            build_envelope(
1225                &self.catalog.serialize(),
1226                &users::serialize_users(&self.users),
1227                &self.publications.serialize(),
1228                &self.subscriptions.serialize(),
1229                &self.statistics.serialize(),
1230            )
1231        }
1232    }
1233
1234    /// True when at least one TX slot is in flight. v4.41.1 runtime
1235    /// invariant: at most one slot active at a time (dispatch holds
1236    /// `engine.write()` across the entire wrap). v4.42 will let this
1237    /// return true with multiple slots concurrently.
1238    pub fn in_transaction(&self) -> bool {
1239        !self.tx_catalogs.is_empty()
1240    }
1241
1242    /// v4.41.1 allocate a fresh TX handle. Used by spg-server dispatch
1243    /// to scope each implicit-wrap BEGIN..stmt..COMMIT to its own slot
1244    /// in `tx_catalogs`. v4.42 — the commit-barrier leader allocates
1245    /// one of these per task in its group, runs `BEGIN`+sql+`COMMIT`
1246    /// sequentially under a single `engine.write()` so each task's
1247    /// mutations accumulate into shared state, then either keeps the
1248    /// accumulated state (fsync OK) or restores the pre-image via
1249    /// `replace_catalog` (fsync err).
1250    pub fn alloc_tx_id(&mut self) -> TxId {
1251        let id = TxId(self.next_tx_id);
1252        self.next_tx_id = self.next_tx_id.saturating_add(1);
1253        id
1254    }
1255
1256    /// v4.42 — atomically replace the live catalog. Used by the
1257    /// commit-barrier leader to roll back a group whose batched
1258    /// fsync failed: the leader snapshots `engine.catalog().clone()`
1259    /// (O(1) Arc bump after the v4.39/v4.40 persistent migration)
1260    /// at group start, sequentially applies each task's BEGIN+sql+
1261    /// COMMIT under the same write lock to accumulate mutations
1262    /// into shared state, batches the WAL bytes, fsyncs once, and
1263    /// on failure calls this with the pre-image to undo every
1264    /// task in the group at once.
1265    ///
1266    /// **Does NOT touch `tx_catalogs` / `current_tx`.** Any
1267    /// explicit-TX slot from a concurrent client (created via the
1268    /// legacy `IMPLICIT_TX`-less dispatch path or via the future
1269    /// MVCC-readers v5+ work) has its own snapshot baked into the
1270    /// slot — restoring `self.catalog` to the pre-image leaves
1271    /// those slots untouched, exactly as they were when the leader
1272    /// took the lock. The leader's own implicit-TX slots are all
1273    /// already discarded (`exec_commit` removed them as each
1274    /// task's COMMIT ran) by the time this is reached.
1275    pub fn replace_catalog(&mut self, catalog: Catalog) {
1276        self.catalog = catalog;
1277    }
1278
1279    /// v6.7.0 — public shim around `Catalog::freeze_oldest_to_cold`
1280    /// so tests + the spg-server freezer can drive a freeze without
1281    /// reaching into the private `active_catalog_mut`. v6.7.4
1282    /// parallel freezer will build on this surface.
1283    ///
1284    /// Marks the table's cached `cold_row_count` stale because the
1285    /// freeze added cold locators that ANALYZE hasn't yet refreshed.
1286    pub fn freeze_oldest_to_cold(
1287        &mut self,
1288        table_name: &str,
1289        index_name: &str,
1290        max_rows: usize,
1291    ) -> Result<spg_storage::FreezeReport, EngineError> {
1292        let report = self
1293            .active_catalog_mut()
1294            .freeze_oldest_to_cold(table_name, index_name, max_rows)
1295            .map_err(EngineError::Storage)?;
1296        if let Some(t) = self.active_catalog_mut().get_mut(table_name) {
1297            t.mark_cold_row_count_stale();
1298        }
1299        Ok(report)
1300    }
1301
1302    /// v6.7.5 — public shim used by the spg-server follower's
1303    /// segment-forwarding receiver. Registers a cold-tier segment
1304    /// at a specific id (the master's id, as transmitted on the
1305    /// wire) so the follower's BTree-Cold locators stay byte-
1306    /// identical with the master's. Wraps
1307    /// `Catalog::load_segment_bytes_at` under the standard
1308    /// clone-mutate-replace pattern.
1309    ///
1310    /// Returns `Ok(())` on success **and** on the "slot already
1311    /// occupied" case — a follower mid-reconnect may receive a
1312    /// segment chunk for a segment_id it already has on disk
1313    /// (forwarded last session); the caller should treat that
1314    /// path as a no-op rather than a fatal error.
1315    pub fn receive_cold_segment(
1316        &mut self,
1317        segment_id: u32,
1318        bytes: Vec<u8>,
1319    ) -> Result<(), EngineError> {
1320        let mut new_cat = self.catalog.clone();
1321        match new_cat.load_segment_bytes_at(segment_id, bytes) {
1322            Ok(()) => {
1323                self.replace_catalog(new_cat);
1324                Ok(())
1325            }
1326            Err(StorageError::Corrupt(msg)) if msg.contains("already occupied") => Ok(()),
1327            Err(e) => Err(EngineError::Storage(e)),
1328        }
1329    }
1330
1331    /// v6.7.3 — public shim around `Catalog::compact_cold_segments`
1332    /// driving every BTree index on every user table. Returns one
1333    /// `(table, index, report)` triple for each merge that
1334    /// actually happened (no-op (table, index) pairs are filtered
1335    /// out so callers can size persist-side work to the live
1336    /// merges). Caller is responsible for persisting each
1337    /// `report.merged_segment_bytes` and updating the on-disk
1338    /// segment registry; engine layer is no_std and never
1339    /// touches disk.
1340    ///
1341    /// Marks every touched table's cached `cold_row_count` stale
1342    /// — compaction GC'd some shadowed rows, so the count must be
1343    /// re-derived on the next ANALYZE.
1344    pub fn compact_cold_segments_with_target(
1345        &mut self,
1346        target_segment_bytes: u64,
1347    ) -> Result<Vec<(String, String, CompactReport)>, EngineError> {
1348        let table_names = self.active_catalog().table_names();
1349        let mut reports: Vec<(String, String, CompactReport)> = Vec::new();
1350        for tname in table_names {
1351            if is_internal_table_name(&tname) {
1352                continue;
1353            }
1354            let idx_names: Vec<String> = {
1355                let Some(t) = self.active_catalog().get(&tname) else {
1356                    continue;
1357                };
1358                t.indices()
1359                    .iter()
1360                    .filter(|i| matches!(i.kind, IndexKind::BTree(_)))
1361                    .map(|i| i.name.clone())
1362                    .collect()
1363            };
1364            for iname in idx_names {
1365                let report = self
1366                    .active_catalog_mut()
1367                    .compact_cold_segments(&tname, &iname, target_segment_bytes)
1368                    .map_err(EngineError::Storage)?;
1369                if report.merged_segment_id.is_some() {
1370                    if let Some(t) = self.active_catalog_mut().get_mut(&tname) {
1371                        t.mark_cold_row_count_stale();
1372                    }
1373                    reports.push((tname.clone(), iname, report));
1374                }
1375            }
1376        }
1377        Ok(reports)
1378    }
1379
1380    fn active_catalog(&self) -> &Catalog {
1381        match self.current_tx {
1382            Some(t) => self
1383                .tx_catalogs
1384                .get(&t)
1385                .map_or(&self.catalog, |s| &s.catalog),
1386            None => &self.catalog,
1387        }
1388    }
1389
1390    /// v7.12.4 — snapshot every row-level trigger on `table` that
1391    /// fires for `event` (`"INSERT"` / `"UPDATE"` / `"DELETE"`) at
1392    /// the given `timing` (`"BEFORE"` / `"AFTER"`), and clone its
1393    /// referenced function definition. Returned as a vec of owned
1394    /// `FunctionDef` so the row-write loop can fire them without
1395    /// holding a borrow on the catalog (which would conflict with
1396    /// the table.insert / update_row / delete mutable borrows).
1397    /// v7.16.2 — top-level DO block executor. Walks the
1398    /// PlPgSqlBlock via [`triggers::execute_do_block_top_level`],
1399    /// then runs each collected EmbeddedSql statement through
1400    /// the engine's regular execute path (NOT deferred — DO is
1401    /// outside any row-write borrow). Errors from any step
1402    /// abort the block and propagate verbatim.
1403    /// v7.16.2 — resolve every subquery inside a PlPgSqlBlock's
1404    /// expression slots so the downstream trigger-flavoured
1405    /// evaluator (which expects pre-resolved Expr::Literal /
1406    /// Binary chains) doesn't trip on raw Exists/ScalarSubquery
1407    /// nodes. Walks IF conditions, Assign values, RAISE args.
1408    /// EmbeddedSql statements re-enter the engine for execution
1409    /// later so their subqueries get the normal SELECT-side
1410    /// resolution.
1411    fn resolve_plpgsql_block_subqueries(
1412        &self,
1413        block: &mut spg_sql::ast::PlPgSqlBlock,
1414        cancel: CancelToken<'_>,
1415    ) -> Result<(), EngineError> {
1416        for d in &mut block.declarations {
1417            if let Some(e) = &mut d.default {
1418                self.resolve_expr_subqueries(e, cancel)?;
1419            }
1420        }
1421        self.resolve_plpgsql_stmts_subqueries(&mut block.statements, cancel)
1422    }
1423
1424    fn resolve_plpgsql_stmts_subqueries(
1425        &self,
1426        stmts: &mut [spg_sql::ast::PlPgSqlStmt],
1427        cancel: CancelToken<'_>,
1428    ) -> Result<(), EngineError> {
1429        use spg_sql::ast::PlPgSqlStmt;
1430        for stmt in stmts {
1431            match stmt {
1432                PlPgSqlStmt::Assign { value, .. } => {
1433                    self.resolve_expr_subqueries(value, cancel)?;
1434                }
1435                PlPgSqlStmt::Return(spg_sql::ast::ReturnTarget::Expr(e)) => {
1436                    self.resolve_expr_subqueries(e, cancel)?;
1437                }
1438                PlPgSqlStmt::Return(_) => {}
1439                PlPgSqlStmt::If {
1440                    branches,
1441                    else_branch,
1442                } => {
1443                    for (cond, body) in branches.iter_mut() {
1444                        self.resolve_expr_subqueries(cond, cancel)?;
1445                        self.resolve_plpgsql_stmts_subqueries(body, cancel)?;
1446                    }
1447                    self.resolve_plpgsql_stmts_subqueries(else_branch, cancel)?;
1448                }
1449                PlPgSqlStmt::Raise { args, .. } => {
1450                    for a in args {
1451                        self.resolve_expr_subqueries(a, cancel)?;
1452                    }
1453                }
1454                PlPgSqlStmt::EmbeddedSql(_) => {
1455                    // Embedded SQL goes back through execute_stmt
1456                    // _with_cancel which runs the SELECT-side
1457                    // resolver itself; nothing to do here.
1458                }
1459                PlPgSqlStmt::SelectInto { body, .. } => {
1460                    // SELECT INTO runs through Engine::execute
1461                    // when reached, so subquery resolution
1462                    // happens via the normal SELECT-side path.
1463                    // Still walk for nested subqueries inside
1464                    // the SELECT body so eval doesn't trip.
1465                    self.resolve_select_subqueries(body, cancel)?;
1466                }
1467            }
1468        }
1469        Ok(())
1470    }
1471
1472    fn exec_do_block(
1473        &mut self,
1474        body: spg_sql::ast::PlPgSqlBlock,
1475    ) -> Result<QueryResult, EngineError> {
1476        // v7.16.2 — pre-resolve every subquery the body's
1477        // expressions reach. `eval::eval_expr` errors on
1478        // unresolved Exists/ScalarSubquery/InSubquery; the
1479        // top-level SELECT path runs `resolve_select_subqueries`
1480        // for the caller — for plpgsql we have to do the
1481        // equivalent before the body walker runs. Catches the
1482        // mailrs idiom `IF EXISTS (SELECT 1 FROM
1483        // information_schema.columns WHERE …) THEN …`.
1484        let mut body = body;
1485        self.resolve_plpgsql_block_subqueries(&mut body, CancelToken::none())?;
1486        let dts = self
1487            .session_param("default_text_search_config")
1488            .map(String::from);
1489        // v7.16.2 — SELECT … INTO resolver. The walker calls
1490        // this synchronously when it hits a SelectInto stmt
1491        // so the IF / locals scope sees the result before the
1492        // next statement. Body walks for trigger paths (no
1493        // resolver) error loudly on SelectInto.
1494        // SAFETY: the closure shares this engine borrow with
1495        // the walker, but the walker only borrows for the
1496        // duration of `execute_do_block_top_level` and doesn't
1497        // reach back into the engine through any other path —
1498        // so the recursive `&mut` is sound. We use a `RefCell`
1499        // for interior mutability since the closure is
1500        // Fn-shaped.
1501        let engine_cell = core::cell::RefCell::new(&mut *self);
1502        let resolver_fn =
1503            |stmt: &spg_sql::ast::Statement| -> Result<Value, triggers::TriggerError> {
1504                let mut eng = engine_cell.borrow_mut();
1505                let r = eng
1506                    .execute_stmt_with_cancel(stmt.clone(), CancelToken::none())
1507                    .map_err(|e| triggers::TriggerError::EvalFailed {
1508                        function: "DO".into(),
1509                        cause: eval::EvalError::TypeMismatch {
1510                            detail: alloc::format!("SELECT … INTO failed: {e}"),
1511                        },
1512                    })?;
1513                match r {
1514                    QueryResult::Rows { rows, .. } => match rows.into_iter().next() {
1515                        Some(row) => Ok(row.values.into_iter().next().unwrap_or(Value::Null)),
1516                        None => Ok(Value::Null),
1517                    },
1518                    _ => Err(triggers::TriggerError::EvalFailed {
1519                        function: "DO".into(),
1520                        cause: eval::EvalError::TypeMismatch {
1521                            detail: "SELECT … INTO body must be a SELECT".into(),
1522                        },
1523                    }),
1524                }
1525            };
1526        let collected =
1527            triggers::execute_do_block_top_level(&body, dts.as_deref(), Some(&resolver_fn))
1528                .map_err(|e| {
1529                    EngineError::Storage(StorageError::Corrupt(alloc::format!("DO: {e}")))
1530                })?;
1531        // engine_cell goes out of scope here, releasing the &mut self borrow
1532        // Run each embedded statement against the engine. The
1533        // statements were already substitute-walked for NEW/OLD/
1534        // locals (those evaluate to engine literals before they
1535        // land here) so dispatch is plain execute_stmt_with_cancel.
1536        for stmt in collected {
1537            // v7.16.2 — preserve current_tx wrap so an outer
1538            // BEGIN/COMMIT around a DO block keeps the
1539            // EmbeddedSql writes inside that same tx slot.
1540            self.execute_stmt_with_cancel(stmt, CancelToken::none())?;
1541        }
1542        Ok(QueryResult::CommandOk {
1543            affected: 0,
1544            modified_catalog: !self.in_transaction(),
1545        })
1546    }
1547
1548    fn snapshot_row_triggers(
1549        &self,
1550        table: &str,
1551        event: &str,
1552        timing: &str,
1553    ) -> Vec<spg_storage::FunctionDef> {
1554        let cat = self.active_catalog();
1555        cat.triggers()
1556            .iter()
1557            .filter(|t| {
1558                // v7.16.1 — skip disabled triggers (mailrs
1559                // round-9 A.2.b — pg_dump --disable-triggers).
1560                t.enabled
1561                    && t.table == table
1562                    && t.timing.eq_ignore_ascii_case(timing)
1563                    && t.for_each.eq_ignore_ascii_case("row")
1564                    && t.events.iter().any(|e| e.eq_ignore_ascii_case(event))
1565            })
1566            .filter_map(|t| cat.functions().get(&t.function).cloned())
1567            .collect()
1568    }
1569
1570    /// v7.13.0 — UPDATE-side snapshot that pairs each trigger's
1571    /// function with its `UPDATE OF cols` filter (mailrs round-5
1572    /// G7). Empty filter Vec means "fire unconditionally", matching
1573    /// the v7.12 behaviour.
1574    fn snapshot_update_row_triggers(
1575        &self,
1576        table: &str,
1577        timing: &str,
1578    ) -> Vec<(spg_storage::FunctionDef, Vec<String>)> {
1579        let cat = self.active_catalog();
1580        cat.triggers()
1581            .iter()
1582            .filter(|t| {
1583                // v7.16.1 — skip disabled triggers.
1584                t.enabled
1585                    && t.table == table
1586                    && t.timing.eq_ignore_ascii_case(timing)
1587                    && t.for_each.eq_ignore_ascii_case("row")
1588                    && t.events.iter().any(|e| e.eq_ignore_ascii_case("UPDATE"))
1589            })
1590            .filter_map(|t| {
1591                cat.functions()
1592                    .get(&t.function)
1593                    .cloned()
1594                    .map(|fd| (fd, t.update_columns.clone()))
1595            })
1596            .collect()
1597    }
1598
1599    /// v7.12.7 — drain the trigger-emitted embedded SQL queue.
1600    /// Called by the INSERT / UPDATE / DELETE executors after
1601    /// their main row-write loop returns. Each statement runs
1602    /// inside the same cancel scope as the firing DML and bumps
1603    /// the recursion counter; nested embedded SQL beyond
1604    /// [`MAX_TRIGGER_RECURSION`] errors with a clear message so
1605    /// a trigger-graph cycle surfaces as a query failure instead
1606    /// of stack-blowing the engine.
1607    fn execute_deferred_trigger_stmts(
1608        &mut self,
1609        deferred: Vec<triggers::DeferredEmbeddedStmt>,
1610        cancel: CancelToken<'_>,
1611    ) -> Result<(), EngineError> {
1612        for d in deferred {
1613            if self.trigger_recursion_depth >= MAX_TRIGGER_RECURSION {
1614                return Err(EngineError::Storage(StorageError::Corrupt(alloc::format!(
1615                    "trigger embedded SQL recursion depth {} exceeded (trigger function \
1616                     {:?} would push past the {} cap — check for trigger cycles)",
1617                    self.trigger_recursion_depth,
1618                    d.function,
1619                    MAX_TRIGGER_RECURSION,
1620                ))));
1621            }
1622            self.trigger_recursion_depth += 1;
1623            let res = self.execute_stmt_with_cancel(d.stmt, cancel);
1624            self.trigger_recursion_depth -= 1;
1625            res?;
1626        }
1627        Ok(())
1628    }
1629
1630    fn active_catalog_mut(&mut self) -> &mut Catalog {
1631        let tx = self.current_tx;
1632        match tx {
1633            Some(t) => match self.tx_catalogs.get_mut(&t) {
1634                Some(s) => &mut s.catalog,
1635                None => &mut self.catalog,
1636            },
1637            None => &mut self.catalog,
1638        }
1639    }
1640
1641    /// Read-only execute path. Succeeds for `SELECT` / `SHOW TABLES`
1642    /// / `SHOW COLUMNS`; returns `EngineError::WriteRequired` for
1643    /// every other statement, so the caller can fall through to the
1644    /// `&mut self` `execute` path under a write lock. Engine state is
1645    /// not mutated even on the success path (`rewrite_clock_calls`
1646    /// and `resolve_order_by_position` both mutate the locally-owned
1647    /// AST, not `self`).
1648    ///
1649    /// **v4.0 concurrency**: this is the entry point the server takes
1650    /// under an `RwLock::read()` so multiple `SELECT` clients run in
1651    /// parallel without serialising on a single mutex.
1652    pub fn execute_readonly(&self, sql: &str) -> Result<QueryResult, EngineError> {
1653        self.execute_readonly_with_cancel(sql, CancelToken::none())
1654    }
1655
1656    /// v4.5 — read path with cooperative cancellation. Token's
1657    /// `is_cancelled` is checked at the start (so a watchdog that
1658    /// already fired returns Cancelled immediately) and at row-loop
1659    /// checkpoints inside `exec_select`. SHOW paths are O(small) and
1660    /// don't bother checking.
1661    pub fn execute_readonly_with_cancel(
1662        &self,
1663        sql: &str,
1664        cancel: CancelToken<'_>,
1665    ) -> Result<QueryResult, EngineError> {
1666        cancel.check()?;
1667        let mut stmt = parser::parse_statement_with(sql, self.backslash_escapes)?;
1668        let now_micros = self.clock.map(|f| f());
1669        rewrite_clock_calls(&mut stmt, now_micros);
1670        if let Statement::Select(s) = &mut stmt {
1671            resolve_order_by_position(s);
1672            // v6.2.3 — cost-based JOIN reorder (read path).
1673            reorder::reorder_joins(s, &self.catalog, &self.statistics);
1674        }
1675        self.execute_readonly_stmt_with_cancel(stmt, cancel)
1676    }
1677
1678    /// v7.18 — readonly dispatch on a pre-parsed `Statement`.
1679    /// Internal helper shared by the SQL-string path
1680    /// ([`Engine::execute_readonly_with_cancel`]) and the prepared-
1681    /// statement path ([`Engine::execute_readonly_prepared_on_snapshot_with_cancel`]).
1682    /// Statement-level transforms (clock rewrite, ORDER BY position,
1683    /// JOIN reorder, placeholder substitution) are the caller's
1684    /// responsibility — this helper assumes the AST is already
1685    /// execution-ready. Writes / DDL hit
1686    /// [`EngineError::WriteRequired`] the same way the SQL path does.
1687    fn execute_readonly_stmt_with_cancel(
1688        &self,
1689        stmt: Statement,
1690        cancel: CancelToken<'_>,
1691    ) -> Result<QueryResult, EngineError> {
1692        let result = match stmt {
1693            Statement::Select(s) => self.exec_select_cancel(&s, cancel),
1694            Statement::ShowTables => Ok(self.exec_show_tables()),
1695            Statement::ShowDatabases => Ok(self.exec_show_databases()),
1696            Statement::ShowCreateTable(name) => self.exec_show_create_table(&name),
1697            Statement::ShowIndexes(name) => self.exec_show_indexes(&name),
1698            Statement::ShowStatus => Ok(self.exec_show_status()),
1699            Statement::ShowVariables => Ok(self.exec_show_variables()),
1700            Statement::ShowProcesslist => Ok(self.exec_show_processlist()),
1701            Statement::ShowColumns(table) => self.exec_show_columns(&table),
1702            Statement::ShowUsers => Ok(self.exec_show_users()),
1703            Statement::ShowPublications => Ok(self.exec_show_publications()),
1704            Statement::ShowSubscriptions => Ok(self.exec_show_subscriptions()),
1705            Statement::WaitForWalPosition { .. } => Err(EngineError::Unsupported(
1706                "WAIT FOR WAL POSITION must be handled by the server layer".into(),
1707            )),
1708            Statement::Explain(e) => self.exec_explain(&e, cancel),
1709            _ => Err(EngineError::WriteRequired),
1710        };
1711        self.enforce_row_limit(result)
1712    }
1713
1714    /// v4.2: cap result-set size. Applied after the executor
1715    /// materialises rows but before they leave the engine — wrapping
1716    /// every Rows-returning exec_* function would scatter the check.
1717    fn enforce_row_limit(
1718        &self,
1719        result: Result<QueryResult, EngineError>,
1720    ) -> Result<QueryResult, EngineError> {
1721        if let (Ok(QueryResult::Rows { rows, .. }), Some(cap)) = (&result, self.max_query_rows)
1722            && rows.len() > cap
1723        {
1724            return Err(EngineError::RowLimitExceeded(cap));
1725        }
1726        result
1727    }
1728
1729    pub fn execute(&mut self, sql: &str) -> Result<QueryResult, EngineError> {
1730        self.execute_in_with_cancel(sql, IMPLICIT_TX, CancelToken::none())
1731    }
1732
1733    /// v4.5 — write path with cooperative cancellation. Same dispatch
1734    /// as `execute_in_with_cancel(sql, IMPLICIT_TX, cancel)`. Kept as
1735    /// a separate entry point for backward-compat with the v4.5
1736    /// public API.
1737    pub fn execute_with_cancel(
1738        &mut self,
1739        sql: &str,
1740        cancel: CancelToken<'_>,
1741    ) -> Result<QueryResult, EngineError> {
1742        self.execute_in_with_cancel(sql, IMPLICIT_TX, cancel)
1743    }
1744
1745    /// v4.41.1 multi-slot write entry. Routes `sql` through the TX
1746    /// slot identified by `tx_id` so spg-server dispatch can scope
1747    /// each implicit-wrap BEGIN..stmt..COMMIT to its own slot in
1748    /// `tx_catalogs`. `IMPLICIT_TX` is the legacy single-slot path
1749    /// every other caller (engine self-tests, replay, spg-embedded)
1750    /// implicitly takes via `execute()` / `execute_with_cancel()`.
1751    pub fn execute_in(&mut self, sql: &str, tx_id: TxId) -> Result<QueryResult, EngineError> {
1752        self.execute_in_with_cancel(sql, tx_id, CancelToken::none())
1753    }
1754
1755    /// v4.41.1 write path with cooperative cancellation + explicit TX
1756    /// scope. Sets `self.current_tx` for the duration of the call so
1757    /// every `exec_*` helper transparently sees its TX's shadow
1758    /// catalog and savepoint stack; restores on exit so the field is
1759    /// only valid mid-call (no leakage across calls).
1760    pub fn execute_in_with_cancel(
1761        &mut self,
1762        sql: &str,
1763        tx_id: TxId,
1764        cancel: CancelToken<'_>,
1765    ) -> Result<QueryResult, EngineError> {
1766        let saved = self.current_tx;
1767        self.current_tx = Some(tx_id);
1768        let result = self.execute_inner_with_cancel(sql, cancel);
1769        self.current_tx = saved;
1770        result
1771    }
1772
1773    /// v6.1.1 — parse and pre-process a SQL string ONCE so the
1774    /// resulting [`Statement`] can be cached and re-executed via
1775    /// [`Engine::execute_prepared`]. Returns the same `Statement`
1776    /// the simple-query path would synthesise internally (clock
1777    /// rewrites + ORDER BY position-ref resolution applied at
1778    /// prepare time, since both are session-independent). The
1779    /// `$N` placeholders in the SQL stay as `Expr::Placeholder(n)`
1780    /// nodes; they're resolved to concrete values per-call by
1781    /// `execute_prepared`'s substitution walk.
1782    ///
1783    /// Pgwire's `Parse` (P) message lands here.
1784    pub fn prepare(&self, sql: &str) -> Result<Statement, ParseError> {
1785        let mut stmt = parser::parse_statement_with(sql, self.backslash_escapes)?;
1786        let now_micros = self.clock.map(|f| f());
1787        rewrite_clock_calls(&mut stmt, now_micros);
1788        if let Statement::Select(s) = &mut stmt {
1789            // v6.4.1 — expand `GROUP BY ALL` to every non-aggregate
1790            // SELECT-list item BEFORE position / alias resolution so
1791            // downstream passes see the explicit list.
1792            expand_group_by_all(s);
1793            resolve_order_by_position(s);
1794            // v6.2.3 — cost-based JOIN reorder. No-op for
1795            // single-table FROMs or any non-INNER join shape.
1796            reorder::reorder_joins(s, &self.catalog, &self.statistics);
1797        }
1798        Ok(stmt)
1799    }
1800
1801    /// v6.3.0 — cached prepare. Returns a cloned `Statement` from
1802    /// the plan cache on hit, runs the full `prepare()` path on miss
1803    /// and inserts the resulting plan before returning. Skipping the
1804    /// parse + JOIN-reorder pipeline on hit is the dominant win for
1805    /// JDBC / sqlx / pgx clients that reuse the same SQL string.
1806    ///
1807    /// Returns a cloned `Statement` (not a borrow) because the
1808    /// pgwire layer owns its `PreparedStmt` map per-session and the
1809    /// engine-level cache must stay available for other sessions.
1810    /// Clone cost on a 5-table JOIN AST is well under the parse cost
1811    /// it replaces.
1812    pub fn prepare_cached(&mut self, sql: &str) -> Result<Statement, ParseError> {
1813        // v6.3.1 — version-aware lookup. If the cached plan was
1814        // prepared before the most recent ANALYZE, evict and replan.
1815        let current_version = self.statistics.version();
1816        if let Some(plan) = self.plan_cache.get(sql) {
1817            if plan.statistics_version == current_version {
1818                return Ok(plan.stmt.clone());
1819            }
1820            // Stale entry — fall through to evict + re-prepare.
1821        }
1822        self.plan_cache.evict(sql);
1823        let stmt = self.prepare(sql)?;
1824        let source_tables = plan_cache::collect_source_tables(&stmt);
1825        let plan = plan_cache::PreparedPlan {
1826            stmt: stmt.clone(),
1827            statistics_version: current_version,
1828            source_tables,
1829            describe_columns: alloc::vec::Vec::new(),
1830        };
1831        self.plan_cache.insert(String::from(sql), plan);
1832        Ok(stmt)
1833    }
1834
1835    /// v6.3.0 — read-only accessor for tests and v6.3.1 invalidation.
1836    pub fn plan_cache(&self) -> &plan_cache::PlanCache {
1837        &self.plan_cache
1838    }
1839
1840    /// v6.3.0 — mutable accessor for v6.3.1 invalidation hooks.
1841    pub fn plan_cache_mut(&mut self) -> &mut plan_cache::PlanCache {
1842        &mut self.plan_cache
1843    }
1844
1845    /// v6.3.3 — Describe a prepared `Statement` without executing.
1846    /// Returns `(parameter_oids, output_columns)`. Empty
1847    /// `output_columns` means the statement has no row-producing
1848    /// shape we could resolve here (JOIN, subquery, non-SELECT, …)
1849    /// — pgwire layer maps that to a `NoData` reply.
1850    pub fn describe_prepared(&self, stmt: &Statement) -> (Vec<u32>, Vec<ColumnSchema>) {
1851        describe::describe_prepared(stmt, self.active_catalog())
1852    }
1853
1854    /// v6.1.1 — execute a [`Statement`] previously returned by
1855    /// [`Engine::prepare`], substituting `Expr::Placeholder(n)`
1856    /// nodes for the corresponding [`Value`] in `params` (1-based
1857    /// per PG: `$1` → `params[0]`). Bind-time string parameters
1858    /// are decoded into typed `Value`s by the pgwire layer before
1859    /// this call so the resulting AST hits the same execution
1860    /// path as a simple query — no SQL re-parse.
1861    ///
1862    /// Pgwire's `Execute` (E) message after a `Bind` (B) lands here.
1863    pub fn execute_prepared(
1864        &mut self,
1865        stmt: Statement,
1866        params: &[Value],
1867    ) -> Result<QueryResult, EngineError> {
1868        self.execute_prepared_with_cancel(stmt, params, CancelToken::none())
1869    }
1870
1871    /// v7.17.0 Phase 2.3 — prepared-statement entry that honors a
1872    /// caller-supplied `CancelToken`. Mirrors `execute_prepared`'s
1873    /// `current_tx` save/restore so the extended-query path stays
1874    /// transactionally consistent with the simple-query path.
1875    pub fn execute_prepared_with_cancel(
1876        &mut self,
1877        mut stmt: Statement,
1878        params: &[Value],
1879        cancel: CancelToken<'_>,
1880    ) -> Result<QueryResult, EngineError> {
1881        substitute_placeholders(&mut stmt, params)?;
1882        // v7.16.0 — set `current_tx` for the duration of the
1883        // dispatch so the `exec_*` helpers see the right TX
1884        // slot (matches what `execute_in_with_cancel` does for
1885        // simple-query). Pre-v7.16 the simple-query path
1886        // worked because every public entry point routed
1887        // through `execute_in_with_cancel`; the prepared path
1888        // skipped the wrap and so its INSERTs/UPDATEs landed
1889        // in the no-tx default slot, silently invisible to a
1890        // BEGIN/COMMIT-bracketed flow. Caught by spg-sqlx's
1891        // first transaction-visibility test.
1892        let saved = self.current_tx;
1893        self.current_tx = Some(IMPLICIT_TX);
1894        let result = self.execute_stmt_with_cancel(stmt, cancel);
1895        self.current_tx = saved;
1896        result
1897    }
1898
1899    fn execute_inner_with_cancel(
1900        &mut self,
1901        sql: &str,
1902        cancel: CancelToken<'_>,
1903    ) -> Result<QueryResult, EngineError> {
1904        cancel.check()?;
1905        let stmt = self.prepare(sql)?;
1906        // v6.5.1 — wrap the executor with a wall-clock window so we
1907        // can record into spg_stat_query. Skip when the engine has
1908        // no clock attached (no_std embedded callers).
1909        let start_us = self.clock.map(|f| f());
1910        let result = self.execute_stmt_with_cancel(stmt, cancel);
1911        if let (Some(t0), Ok(_)) = (start_us, &result) {
1912            let now = self.clock.map_or(t0, |f| f());
1913            let elapsed = now.saturating_sub(t0).max(0) as u64;
1914            self.query_stats.record(sql, elapsed, now as u64);
1915            // v6.5.6 — slow-query log: fire callback when elapsed
1916            // exceeds the configured floor.
1917            if let (Some(threshold), Some(logger)) =
1918                (self.slow_query_threshold_us, self.slow_query_logger)
1919                && elapsed >= threshold
1920            {
1921                logger(sql, elapsed);
1922            }
1923        }
1924        result
1925    }
1926
1927    fn execute_stmt_with_cancel(
1928        &mut self,
1929        stmt: Statement,
1930        cancel: CancelToken<'_>,
1931    ) -> Result<QueryResult, EngineError> {
1932        cancel.check()?;
1933        // v7.17.0 Phase 1.1 — pre-resolve nextval / currval /
1934        // setval calls in the statement tree. Walks SELECT
1935        // projection, INSERT VALUES, UPDATE SET, DELETE WHERE,
1936        // and DEFAULT exprs; replaces sequence FunctionCall
1937        // nodes with concrete Literal values minted against the
1938        // catalog. This is the only place that mutates sequence
1939        // state from a SELECT-shaped path (exec_select_cancel is
1940        // `&self` and can't reach the catalog mutably).
1941        //
1942        // Fast-path: when no sequences exist anywhere in the
1943        // catalog (the typical hot-path INSERT load), skip the
1944        // walker entirely. Single map-emptiness check on the
1945        // catalog beats walking every expression on every call.
1946        let mut stmt = stmt;
1947        // v7.17 dump-compat — the fast-path check
1948        // `sequences().is_empty()` skips pre-resolve when no
1949        // sequence exists in the *currently active* catalog
1950        // snapshot. The committed catalog or the implicit-TX
1951        // catalog may legitimately disagree on this between
1952        // CREATE SEQUENCE and a later setval(): always run the
1953        // resolver — the walk is O(expr-count) and dwarfed by
1954        // the parse cost we just paid.
1955        self.pre_resolve_sequence_calls_in_statement(&mut stmt)?;
1956        let result = match stmt {
1957            Statement::CreateTable(s) => self.exec_create_table(s),
1958            // v7.9.15 — CREATE EXTENSION is a no-op on SPG. Returns
1959            // CommandOk with affected=0; modified_catalog=false so
1960            // the WAL doesn't grow a useless entry. mailrs F3.
1961            Statement::CreateExtension(_) => Ok(QueryResult::CommandOk {
1962                affected: 0,
1963                modified_catalog: false,
1964            }),
1965            // v7.16.2 — DO $$ ... $$ block. mailrs round-10 A.2
1966            // — the pre-v7.9.27 no-op SILENTLY swallowed every
1967            // mailrs migrate-038/-040/-042 idempotent rename
1968            // (the IF EXISTS … THEN ALTER … END block never
1969            // ran). v7.16.2 dispatches to exec_do_block which
1970            // runs the PlPgSqlBlock at top level via the same
1971            // execute_stmts machinery the trigger executor
1972            // uses (NEW=None, OLD=None — DO blocks have no
1973            // row context).
1974            Statement::DoBlock(body) => self.exec_do_block(body),
1975            // v7.14.0 — empty-statement no-op for pg_dump /
1976            // mysqldump preamble lines that collapse to nothing
1977            // after comment-stripping.
1978            Statement::Empty => Ok(QueryResult::CommandOk {
1979                affected: 0,
1980                modified_catalog: false,
1981            }),
1982            Statement::DropTable { names, if_exists } => self.exec_drop_table(names, if_exists),
1983            Statement::DropIndex { name, if_exists } => self.exec_drop_index(name, if_exists),
1984            Statement::CreateIndex(s) => self.exec_create_index(s),
1985            Statement::Insert(s) => self.exec_insert(s),
1986            Statement::Update(mut s) => {
1987                // Materialise uncorrelated subqueries in SET / WHERE
1988                // before the row walk — the SELECT path has done this
1989                // since v4.10; UPDATE gained it for mailrs's
1990                // `UPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP
1991                // LOCKED)` claim pattern (embed round-12).
1992                for (_, e) in &mut s.assignments {
1993                    self.resolve_expr_subqueries(e, cancel)?;
1994                }
1995                if let Some(w) = &mut s.where_ {
1996                    self.resolve_expr_subqueries(w, cancel)?;
1997                }
1998                self.exec_update_cancel(&s, cancel)
1999            }
2000            Statement::Delete(mut s) => {
2001                if let Some(w) = &mut s.where_ {
2002                    self.resolve_expr_subqueries(w, cancel)?;
2003                }
2004                self.exec_delete_cancel(&s, cancel)
2005            }
2006            Statement::Merge(s) => self.exec_merge_cancel(&s, cancel),
2007            Statement::Select(s) => self.exec_select_cancel(&s, cancel),
2008            Statement::Begin => self.exec_begin(),
2009            Statement::Commit => self.exec_commit(),
2010            Statement::Rollback => self.exec_rollback(),
2011            Statement::Savepoint(name) => self.exec_savepoint(name),
2012            Statement::RollbackToSavepoint(name) => self.exec_rollback_to_savepoint(&name),
2013            Statement::ReleaseSavepoint(name) => self.exec_release_savepoint(&name),
2014            Statement::ShowTables => Ok(self.exec_show_tables()),
2015            Statement::ShowDatabases => Ok(self.exec_show_databases()),
2016            Statement::ShowCreateTable(name) => self.exec_show_create_table(&name),
2017            Statement::ShowIndexes(name) => self.exec_show_indexes(&name),
2018            Statement::ShowStatus => Ok(self.exec_show_status()),
2019            Statement::ShowVariables => Ok(self.exec_show_variables()),
2020            Statement::ShowProcesslist => Ok(self.exec_show_processlist()),
2021            Statement::ShowColumns(table) => self.exec_show_columns(&table),
2022            Statement::ShowUsers => Ok(self.exec_show_users()),
2023            Statement::ShowPublications => Ok(self.exec_show_publications()),
2024            Statement::ShowSubscriptions => Ok(self.exec_show_subscriptions()),
2025            Statement::CreateUser(s) => self.exec_create_user(&s),
2026            Statement::DropUser(name) => self.exec_drop_user(&name),
2027            Statement::Explain(e) => self.exec_explain(&e, cancel),
2028            Statement::AlterIndex(s) => self.exec_alter_index(s),
2029            Statement::AlterTable(s) => self.exec_alter_table(s),
2030            Statement::CreatePublication(s) => self.exec_create_publication(s),
2031            Statement::DropPublication(name) => self.exec_drop_publication(&name),
2032            Statement::CreateSubscription(s) => self.exec_create_subscription(s),
2033            Statement::DropSubscription(name) => self.exec_drop_subscription(&name),
2034            // v6.1.7 — WAIT FOR WAL POSITION needs `lag_state`,
2035            // which lives in spg-server's ServerState. The engine
2036            // surfaces a clear error; the server-layer dispatch
2037            // intercepts the SQL before it reaches the engine on
2038            // a server build, so this arm only fires for
2039            // engine-only callers (spg-embedded, lib tests).
2040            Statement::WaitForWalPosition { .. } => Err(EngineError::Unsupported(
2041                "WAIT FOR WAL POSITION must be handled by the server layer".into(),
2042            )),
2043            // v6.2.0 — ANALYZE recomputes per-column histograms.
2044            Statement::Analyze(target) => self.exec_analyze(target.as_deref()),
2045            // v6.7.3 — COMPACT COLD SEGMENTS.
2046            Statement::CompactColdSegments => self.exec_compact_cold_segments(),
2047            // v7.12.1 — SET / RESET session parameter. Engine
2048            // tracks the value in `session_params`; FTS dispatcher
2049            // reads `default_text_search_config`. Everything else
2050            // is a recorded no-op (PG dump compat).
2051            Statement::SetParameter { name, value } => {
2052                self.set_session_param(name, value);
2053                Ok(QueryResult::CommandOk {
2054                    affected: 0,
2055                    modified_catalog: false,
2056                })
2057            }
2058            // v7.14.0 — MySQL multi-assignment SET. Each pair runs
2059            // through `set_session_param` so engine-known params
2060            // (FOREIGN_KEY_CHECKS, session_replication_role, …) take
2061            // effect; unknown pairs (including `@VAR` LHS from the
2062            // mysqldump preamble) are recorded then ignored.
2063            Statement::SetParameterList(pairs) => {
2064                for (name, value) in pairs {
2065                    self.set_session_param(name, value);
2066                }
2067                Ok(QueryResult::CommandOk {
2068                    affected: 0,
2069                    modified_catalog: false,
2070                })
2071            }
2072            // v7.12.4 — CREATE FUNCTION / CREATE TRIGGER / DROP …
2073            // for the PL/pgSQL trigger surface. exec_* methods are
2074            // defined alongside the existing CREATE handlers below.
2075            Statement::CreateFunction(s) => self.exec_create_function(s),
2076            Statement::CreateTrigger(s) => self.exec_create_trigger(s),
2077            Statement::DropTrigger {
2078                name,
2079                table,
2080                if_exists,
2081            } => self.exec_drop_trigger(&name, &table, if_exists),
2082            Statement::DropFunction { name, if_exists } => {
2083                self.exec_drop_function(&name, if_exists)
2084            }
2085            Statement::CreateSequence(s) => self.exec_create_sequence(s),
2086            Statement::AlterSequence(s) => self.exec_alter_sequence(s),
2087            Statement::DropSequence { names, if_exists } => {
2088                self.exec_drop_sequence(&names, if_exists)
2089            }
2090            Statement::CreateView(s) => self.exec_create_view(s),
2091            Statement::DropView { names, if_exists } => self.exec_drop_view(&names, if_exists),
2092            Statement::CreateMaterializedView(s) => self.exec_create_materialized_view(s),
2093            Statement::RefreshMaterializedView { name, with_data } => {
2094                self.exec_refresh_materialized_view(&name, with_data)
2095            }
2096            Statement::DropMaterializedView { names, if_exists } => {
2097                self.exec_drop_materialized_view(&names, if_exists)
2098            }
2099            Statement::CreateType(s) => self.exec_create_type(s),
2100            Statement::DropType { names, if_exists } => self.exec_drop_type(&names, if_exists),
2101            Statement::CreateDomain(s) => self.exec_create_domain(s),
2102            Statement::DropDomain { names, if_exists } => self.exec_drop_domain(&names, if_exists),
2103            Statement::CreateSchema {
2104                name,
2105                if_not_exists,
2106            } => self.exec_create_schema(name, if_not_exists),
2107            Statement::DropSchema { names, if_exists } => self.exec_drop_schema(&names, if_exists),
2108            Statement::ResetParameter(target) => {
2109                match target {
2110                    None => self.session_params.clear(),
2111                    Some(name) => {
2112                        self.session_params.remove(&name.to_ascii_lowercase());
2113                    }
2114                }
2115                Ok(QueryResult::CommandOk {
2116                    affected: 0,
2117                    modified_catalog: false,
2118                })
2119            }
2120        };
2121        self.enforce_row_limit(result)
2122    }
2123
2124    /// v6.1.2 — `CREATE PUBLICATION` runtime path. Duplicate names
2125    /// surface as `EngineError::Unsupported` so the existing PG-wire
2126    /// error mapping stays uniform; the message carries the name so
2127    /// operators can grep replication-log noise. Inside-transaction
2128    /// invocation is rejected (matches `CREATE USER` / `DROP USER`
2129    /// stance) — replication-catalog mutation is a connection-level
2130    /// administrative op, not a transactional one.
2131    fn exec_create_publication(
2132        &mut self,
2133        s: CreatePublicationStatement,
2134    ) -> Result<QueryResult, EngineError> {
2135        // v6.1.4 — the v6.1.2 "no DDL inside a transaction" guard
2136        // was over-cautious: it also blocked the auto-commit wrap
2137        // path (which begins an internal TX around every WAL-
2138        // logged statement). PG itself allows CREATE PUBLICATION
2139        // inside a transaction (it rolls back with the TX).
2140        self.publications
2141            .create(s.name, s.scope)
2142            .map_err(|e| EngineError::Unsupported(alloc::format!("CREATE PUBLICATION: {e:?}")))?;
2143        Ok(QueryResult::CommandOk {
2144            affected: 1,
2145            modified_catalog: true,
2146        })
2147    }
2148
2149    /// v6.1.2 — `DROP PUBLICATION` runtime path. PG-compatible silent
2150    /// no-op when the publication doesn't exist (returns `affected=0`
2151    /// in that case so the wire-level command tag distinguishes
2152    /// "dropped" from "no-op", though both succeed).
2153    fn exec_drop_publication(&mut self, name: &str) -> Result<QueryResult, EngineError> {
2154        let removed = self.publications.drop(name);
2155        Ok(QueryResult::CommandOk {
2156            affected: usize::from(removed),
2157            modified_catalog: removed,
2158        })
2159    }
2160
2161    /// v6.1.2 — read access to the publication catalog. Used by
2162    /// the v6.1.5 publisher-side WAL filter, by `SHOW PUBLICATIONS`
2163    /// (v6.1.3+), and by e2e tests that need to assert state without
2164    /// going through the wire.
2165    pub const fn publications(&self) -> &publications::Publications {
2166        &self.publications
2167    }
2168
2169    /// v6.1.4 — `CREATE SUBSCRIPTION` runtime path. Defaults
2170    /// `enabled = true` and `last_received_pos = 0` for a freshly-
2171    /// created subscription. The actual worker thread is spawned
2172    /// by spg-server once the engine returns success.
2173    fn exec_create_subscription(
2174        &mut self,
2175        s: CreateSubscriptionStatement,
2176    ) -> Result<QueryResult, EngineError> {
2177        // See exec_create_publication — the in_transaction gate
2178        // was over-cautious; the auto-commit wrap path holds an
2179        // internal TX that this check was incorrectly blocking.
2180        let sub = subscriptions::Subscription {
2181            conn_str: s.conn_str,
2182            publications: s.publications,
2183            enabled: true,
2184            last_received_pos: 0,
2185        };
2186        self.subscriptions
2187            .create(s.name, sub)
2188            .map_err(|e| EngineError::Unsupported(alloc::format!("CREATE SUBSCRIPTION: {e:?}")))?;
2189        Ok(QueryResult::CommandOk {
2190            affected: 1,
2191            modified_catalog: true,
2192        })
2193    }
2194
2195    /// v6.1.4 — `DROP SUBSCRIPTION`. Silent no-op when the name
2196    /// doesn't exist (PG-compatible). The associated worker is
2197    /// torn down by spg-server when it observes the catalog
2198    /// change at the next snapshot or via the engine's
2199    /// subscriptions accessor (the worker polls the catalog on
2200    /// reconnect; v6.1.5's filter-side will tighten this to an
2201    /// explicit signal).
2202    fn exec_drop_subscription(&mut self, name: &str) -> Result<QueryResult, EngineError> {
2203        let removed = self.subscriptions.drop(name);
2204        Ok(QueryResult::CommandOk {
2205            affected: usize::from(removed),
2206            modified_catalog: removed,
2207        })
2208    }
2209
2210    /// v6.1.4 — read access to the subscription catalog. Used by
2211    /// the subscription worker (read its own row to find its
2212    /// publications + last applied position), by SHOW SUBSCRIPTIONS,
2213    /// and by e2e tests asserting state directly.
2214    pub const fn subscriptions(&self) -> &subscriptions::Subscriptions {
2215        &self.subscriptions
2216    }
2217
2218    /// v6.1.4 — write access to `last_received_pos`. Worker
2219    /// calls this after each apply batch (under the engine's
2220    /// write-lock). Returns `false` when the subscription was
2221    /// dropped between when the worker received the record and
2222    /// when this call landed.
2223    pub fn subscription_advance(&mut self, name: &str, pos: u64) -> bool {
2224        self.subscriptions.update_last_received_pos(name, pos)
2225    }
2226
2227    /// v6.1.4 — `SHOW SUBSCRIPTIONS` row materialisation. Returns
2228    /// `(name, conn_str, publications, enabled, last_received_pos)`
2229    /// ordered by subscription name. The `publications` column is
2230    /// the comma-joined list ("p1, p2") for ergonomic SHOW output;
2231    /// callers wanting structured access read `Engine::subscriptions`.
2232    fn exec_show_subscriptions(&self) -> QueryResult {
2233        let columns = alloc::vec![
2234            ColumnSchema::new("name", DataType::Text, false),
2235            ColumnSchema::new("conn_str", DataType::Text, false),
2236            ColumnSchema::new("publications", DataType::Text, false),
2237            ColumnSchema::new("enabled", DataType::Bool, false),
2238            ColumnSchema::new("last_received_pos", DataType::BigInt, false),
2239        ];
2240        let rows: Vec<Row> = self
2241            .subscriptions
2242            .iter()
2243            .map(|(name, sub)| {
2244                Row::new(alloc::vec![
2245                    Value::Text(name.clone()),
2246                    Value::Text(sub.conn_str.clone()),
2247                    Value::Text(sub.publications.join(", ")),
2248                    Value::Bool(sub.enabled),
2249                    Value::BigInt(i64::try_from(sub.last_received_pos).unwrap_or(i64::MAX)),
2250                ])
2251            })
2252            .collect();
2253        QueryResult::Rows { columns, rows }
2254    }
2255
2256    /// v6.2.0 — materialise `spg_statistic` rows. One row per
2257    /// `(table, column)` pair tracked in `Statistics`, with
2258    /// `histogram_bounds` rendered as a `[v0, v1, ...]` string —
2259    /// the same canonical form vector literals use for round-trip.
2260    fn exec_spg_statistic(&self) -> QueryResult {
2261        let columns = alloc::vec![
2262            ColumnSchema::new("table_name", DataType::Text, false),
2263            ColumnSchema::new("column_name", DataType::Text, false),
2264            ColumnSchema::new("null_frac", DataType::Float, false),
2265            ColumnSchema::new("n_distinct", DataType::BigInt, false),
2266            ColumnSchema::new("histogram_bounds", DataType::Text, false),
2267            // v6.7.0 — appended column (v6.2.0 stability contract
2268            // allows APPEND to spg_statistic, not reorder/rename).
2269            // Reports the cached per-table cold-row count; same
2270            // value across every column row of the same table.
2271            ColumnSchema::new("cold_row_count", DataType::BigInt, false),
2272        ];
2273        let rows: Vec<Row> = self
2274            .statistics
2275            .iter()
2276            .map(|((t, c), s)| {
2277                let cold = self
2278                    .catalog
2279                    .get(t)
2280                    .map_or(0, |table| table.cold_row_count());
2281                Row::new(alloc::vec![
2282                    Value::Text(t.clone()),
2283                    Value::Text(c.clone()),
2284                    Value::Float(f64::from(s.null_frac)),
2285                    Value::BigInt(i64::try_from(s.n_distinct).unwrap_or(i64::MAX)),
2286                    Value::Text(render_histogram_bounds(&s.histogram_bounds)),
2287                    Value::BigInt(i64::try_from(cold).unwrap_or(i64::MAX)),
2288                ])
2289            })
2290            .collect();
2291        QueryResult::Rows { columns, rows }
2292    }
2293
2294    /// v6.5.0 — materialise `spg_stat_replication` rows. One row
2295    /// per subscription with `(name, conn_str, publications,
2296    /// last_received_pos, enabled)`. Surface mirrors
2297    /// `SHOW SUBSCRIPTIONS` but follows the virtual-table dispatch
2298    /// shape so it composes with SELECT clauses (WHERE, projection
2299    /// onto specific columns, etc).
2300    fn exec_spg_stat_replication(&self) -> QueryResult {
2301        let columns = alloc::vec![
2302            ColumnSchema::new("name", DataType::Text, false),
2303            ColumnSchema::new("conn_str", DataType::Text, false),
2304            ColumnSchema::new("publications", DataType::Text, false),
2305            ColumnSchema::new("last_received_pos", DataType::BigInt, false),
2306            ColumnSchema::new("enabled", DataType::Bool, false),
2307        ];
2308        let rows: Vec<Row> = self
2309            .subscriptions
2310            .iter()
2311            .map(|(name, sub)| {
2312                Row::new(alloc::vec![
2313                    Value::Text(name.clone()),
2314                    Value::Text(sub.conn_str.clone()),
2315                    Value::Text(sub.publications.join(",")),
2316                    Value::BigInt(i64::try_from(sub.last_received_pos).unwrap_or(i64::MAX)),
2317                    Value::Bool(sub.enabled),
2318                ])
2319            })
2320            .collect();
2321        QueryResult::Rows { columns, rows }
2322    }
2323
2324    /// v6.5.0 — materialise `spg_stat_segment` rows. One row per
2325    /// cold-tier segment with `(segment_id, num_rows, num_pages,
2326    /// total_bytes)`.
2327    ///
2328    /// v6.7.0 — appended `table_name` column resolves the v6.5.0
2329    /// carve-out. Walks every user table's BTree indices to find
2330    /// which table's Cold locators point at each segment. Empty
2331    /// string for orphan segments (loaded via SPG_PRELOAD_COLD_SEGMENT
2332    /// before any index registered a locator). The walk is
2333    /// O(tables × indices × keys); cached per call, not across
2334    /// calls — re-walked on every `SELECT * FROM spg_stat_segment`.
2335    fn exec_spg_stat_segment(&self) -> QueryResult {
2336        let columns = alloc::vec![
2337            ColumnSchema::new("segment_id", DataType::BigInt, false),
2338            ColumnSchema::new("table_name", DataType::Text, false),
2339            ColumnSchema::new("num_rows", DataType::BigInt, false),
2340            ColumnSchema::new("num_pages", DataType::BigInt, false),
2341            ColumnSchema::new("total_bytes", DataType::BigInt, false),
2342        ];
2343        // v6.7.0 — build a segment_id → table_name map by walking
2344        // every user table's BTree indices once. O(tables × indices
2345        // × keys) for the v6.5.0 carve-out resolution; acceptable
2346        // because spg_stat_segment is operator-facing (not on a
2347        // hot-loop path).
2348        let mut segment_owners: alloc::collections::BTreeMap<u32, String> = BTreeMap::new();
2349        for tname in self.catalog.table_names() {
2350            if is_internal_table_name(&tname) {
2351                continue;
2352            }
2353            let Some(t) = self.catalog.get(&tname) else {
2354                continue;
2355            };
2356            for idx in t.indices() {
2357                if let spg_storage::IndexKind::BTree(map) = &idx.kind {
2358                    for (_, locs) in map.iter() {
2359                        for loc in locs {
2360                            if let spg_storage::RowLocator::Cold { segment_id, .. } = loc {
2361                                segment_owners
2362                                    .entry(*segment_id)
2363                                    .or_insert_with(|| tname.clone());
2364                            }
2365                        }
2366                    }
2367                }
2368            }
2369        }
2370        let rows: Vec<Row> = self
2371            .catalog
2372            .cold_segment_ids_global()
2373            .iter()
2374            .filter_map(|&id| {
2375                let seg = self.catalog.cold_segment(id)?;
2376                let meta = seg.meta();
2377                let owner = segment_owners.get(&id).cloned().unwrap_or_default();
2378                Some(Row::new(alloc::vec![
2379                    Value::BigInt(i64::from(id)),
2380                    Value::Text(owner),
2381                    Value::BigInt(i64::try_from(meta.num_rows).unwrap_or(i64::MAX)),
2382                    Value::BigInt(i64::from(meta.num_pages)),
2383                    Value::BigInt(i64::try_from(meta.total_bytes).unwrap_or(i64::MAX)),
2384                ]))
2385            })
2386            .collect();
2387        QueryResult::Rows { columns, rows }
2388    }
2389
2390    /// v6.5.1 — materialise `spg_stat_query` rows. One row per
2391    /// distinct SQL text recorded since the engine booted, capped
2392    /// at `QUERY_STATS_MAX` (1024). Columns:
2393    ///   sql, exec_count, total_us, mean_us, max_us, last_seen_us
2394    /// mean_us = total_us / exec_count (saturating).
2395    fn exec_spg_stat_query(&self) -> QueryResult {
2396        let columns = alloc::vec![
2397            ColumnSchema::new("sql", DataType::Text, false),
2398            ColumnSchema::new("exec_count", DataType::BigInt, false),
2399            ColumnSchema::new("total_us", DataType::BigInt, false),
2400            ColumnSchema::new("mean_us", DataType::BigInt, false),
2401            ColumnSchema::new("max_us", DataType::BigInt, false),
2402            ColumnSchema::new("last_seen_us", DataType::BigInt, false),
2403        ];
2404        let rows: Vec<Row> = self
2405            .query_stats
2406            .snapshot()
2407            .into_iter()
2408            .map(|(sql, s)| {
2409                let mean = if s.exec_count == 0 {
2410                    0
2411                } else {
2412                    s.total_us / s.exec_count
2413                };
2414                Row::new(alloc::vec![
2415                    Value::Text(sql),
2416                    Value::BigInt(i64::try_from(s.exec_count).unwrap_or(i64::MAX)),
2417                    Value::BigInt(i64::try_from(s.total_us).unwrap_or(i64::MAX)),
2418                    Value::BigInt(i64::try_from(mean).unwrap_or(i64::MAX)),
2419                    Value::BigInt(i64::try_from(s.max_us).unwrap_or(i64::MAX)),
2420                    Value::BigInt(i64::try_from(s.last_seen_us).unwrap_or(i64::MAX)),
2421                ])
2422            })
2423            .collect();
2424        QueryResult::Rows { columns, rows }
2425    }
2426
2427    /// v6.5.2 — register a connection-state provider. spg-server
2428    /// calls this at startup with a function that snapshots its
2429    /// per-pgwire-connection registry. Engine reads through the
2430    /// callback on `SELECT * FROM spg_stat_activity`.
2431    #[must_use]
2432    pub const fn with_activity_provider(mut self, f: ActivityProvider) -> Self {
2433        self.activity_provider = Some(f);
2434        self
2435    }
2436
2437    /// v6.5.3 — register audit chain provider + verifier.
2438    #[must_use]
2439    pub const fn with_audit_providers(
2440        mut self,
2441        chain: AuditChainProvider,
2442        verify: AuditVerifier,
2443    ) -> Self {
2444        self.audit_chain_provider = Some(chain);
2445        self.audit_verifier = Some(verify);
2446        self
2447    }
2448
2449    /// v6.5.6 — register a slow-query log callback. `threshold_us`
2450    /// is the floor (in microseconds); only executes above the floor
2451    /// fire the callback. spg-server wires this from
2452    /// `SPG_SLOW_QUERY_THRESHOLD_MS` (default 100 ms).
2453    #[must_use]
2454    pub const fn with_slow_query_log(mut self, threshold_us: u64, logger: SlowQueryLogger) -> Self {
2455        self.slow_query_threshold_us = Some(threshold_us);
2456        self.slow_query_logger = Some(logger);
2457        self
2458    }
2459
2460    /// v6.5.6 — operator knob for plan cache cap. spg-server reads
2461    /// `SPG_PLAN_CACHE_MAX` env at startup; uses this to override
2462    /// the compile-time default of 256.
2463    pub fn set_plan_cache_max(&mut self, n: usize) {
2464        self.plan_cache.set_max_entries(n);
2465    }
2466
2467    /// v6.5.2 — materialise `spg_stat_activity` rows. Pulls a fresh
2468    /// snapshot from the registered `ActivityProvider`. Returns an
2469    /// empty result set when no provider is registered (the no_std
2470    /// embedded path with no pgwire layer).
2471    fn exec_spg_stat_activity(&self) -> QueryResult {
2472        let columns = alloc::vec![
2473            ColumnSchema::new("pid", DataType::Int, false),
2474            ColumnSchema::new("user", DataType::Text, false),
2475            ColumnSchema::new("started_at_us", DataType::BigInt, false),
2476            ColumnSchema::new("current_sql", DataType::Text, false),
2477            ColumnSchema::new("wait_event", DataType::Text, false),
2478            ColumnSchema::new("elapsed_us", DataType::BigInt, false),
2479            ColumnSchema::new("in_transaction", DataType::Bool, false),
2480            ColumnSchema::new("application_name", DataType::Text, false),
2481        ];
2482        let rows: Vec<Row> = self
2483            .activity_provider
2484            .map(|f| f())
2485            .unwrap_or_default()
2486            .into_iter()
2487            .map(|r| {
2488                Row::new(alloc::vec![
2489                    Value::Int(i32::try_from(r.pid).unwrap_or(i32::MAX)),
2490                    Value::Text(r.user),
2491                    Value::BigInt(r.started_at_us),
2492                    Value::Text(r.current_sql),
2493                    Value::Text(r.wait_event),
2494                    Value::BigInt(r.elapsed_us),
2495                    Value::Bool(r.in_transaction),
2496                    Value::Text(r.application_name),
2497                ])
2498            })
2499            .collect();
2500        QueryResult::Rows { columns, rows }
2501    }
2502
2503    /// v6.5.4 — materialise `spg_table_ddl` rows. One row per user
2504    /// table with `(table_name, ddl)`. Reconstructed from catalog
2505    /// state on demand.
2506    fn exec_spg_table_ddl(&self) -> QueryResult {
2507        let columns = alloc::vec![
2508            ColumnSchema::new("table_name", DataType::Text, false),
2509            ColumnSchema::new("ddl", DataType::Text, false),
2510        ];
2511        let rows: Vec<Row> = self
2512            .catalog
2513            .table_names()
2514            .into_iter()
2515            .filter(|n| !is_internal_table_name(n))
2516            .filter_map(|name| {
2517                let table = self.catalog.get(&name)?;
2518                let ddl = render_create_table(&name, &table.schema().columns);
2519                Some(Row::new(alloc::vec![Value::Text(name), Value::Text(ddl),]))
2520            })
2521            .collect();
2522        QueryResult::Rows { columns, rows }
2523    }
2524
2525    /// v6.5.4 — materialise `spg_role_ddl` rows. One row per user
2526    /// with `(role_name, ddl)`. Password is redacted (matches the
2527    /// `Statement::CreateUser` Display which prints `'<redacted>'`).
2528    fn exec_spg_role_ddl(&self) -> QueryResult {
2529        let columns = alloc::vec![
2530            ColumnSchema::new("role_name", DataType::Text, false),
2531            ColumnSchema::new("ddl", DataType::Text, false),
2532        ];
2533        let rows: Vec<Row> = self
2534            .users
2535            .iter()
2536            .map(|(name, rec)| {
2537                let ddl = alloc::format!(
2538                    "CREATE USER {name} WITH PASSWORD '<redacted>' ROLE '{}'",
2539                    rec.role.as_str(),
2540                );
2541                Row::new(alloc::vec![
2542                    Value::Text(String::from(name)),
2543                    Value::Text(ddl)
2544                ])
2545            })
2546            .collect();
2547        QueryResult::Rows { columns, rows }
2548    }
2549
2550    /// v6.5.4 — materialise `spg_database_ddl`: single row whose
2551    /// `ddl` column concatenates every user table's CREATE +
2552    /// every role's CREATE in deterministic catalog order. Suitable
2553    /// for piping back through `Engine::execute` to recreate a
2554    /// schema-equivalent database.
2555    fn exec_spg_database_ddl(&self) -> QueryResult {
2556        let columns = alloc::vec![ColumnSchema::new("ddl", DataType::Text, false)];
2557        let mut out = String::new();
2558        for (name, rec) in self.users.iter() {
2559            out.push_str(&alloc::format!(
2560                "CREATE USER {name} WITH PASSWORD '<redacted>' ROLE '{}';\n",
2561                rec.role.as_str(),
2562            ));
2563        }
2564        for name in self.catalog.table_names() {
2565            if is_internal_table_name(&name) {
2566                continue;
2567            }
2568            if let Some(table) = self.catalog.get(&name) {
2569                out.push_str(&render_create_table(&name, &table.schema().columns));
2570                out.push_str(";\n");
2571            }
2572        }
2573        QueryResult::Rows {
2574            columns,
2575            rows: alloc::vec![Row::new(alloc::vec![Value::Text(out)])],
2576        }
2577    }
2578
2579    /// v6.5.3 — materialise `spg_audit_chain` rows. Pulls a fresh
2580    /// snapshot from the registered provider; empty when no
2581    /// provider is set.
2582    fn exec_spg_audit_chain(&self) -> QueryResult {
2583        let columns = alloc::vec![
2584            ColumnSchema::new("seq", DataType::BigInt, false),
2585            ColumnSchema::new("ts_ms", DataType::BigInt, false),
2586            ColumnSchema::new("prev_hash", DataType::Text, false),
2587            ColumnSchema::new("entry_hash", DataType::Text, false),
2588            ColumnSchema::new("sql", DataType::Text, false),
2589        ];
2590        let rows: Vec<Row> = self
2591            .audit_chain_provider
2592            .map(|f| f())
2593            .unwrap_or_default()
2594            .into_iter()
2595            .map(|r| {
2596                Row::new(alloc::vec![
2597                    Value::BigInt(r.seq),
2598                    Value::BigInt(r.ts_ms),
2599                    Value::Text(r.prev_hash_hex),
2600                    Value::Text(r.entry_hash_hex),
2601                    Value::Text(r.sql),
2602                ])
2603            })
2604            .collect();
2605        QueryResult::Rows { columns, rows }
2606    }
2607
2608    /// v6.5.3 — materialise `spg_audit_verify` single-row result.
2609    /// `(verified_count, broken_at_seq)` — broken_at_seq is `-1`
2610    /// on a clean chain. Returns one row with both values 0 when
2611    /// no verifier is registered (no-data fallback for embedded
2612    /// callers).
2613    fn exec_spg_audit_verify(&self) -> QueryResult {
2614        let columns = alloc::vec![
2615            ColumnSchema::new("verified_count", DataType::BigInt, false),
2616            ColumnSchema::new("broken_at_seq", DataType::BigInt, false),
2617        ];
2618        let (verified, broken) = self.audit_verifier.map(|f| f()).unwrap_or((0, -1));
2619        let row = Row::new(alloc::vec![Value::BigInt(verified), Value::BigInt(broken),]);
2620        QueryResult::Rows {
2621            columns,
2622            rows: alloc::vec![row],
2623        }
2624    }
2625
2626    /// v6.5.1 — read-only accessor for tests + v6.5.6 ops resets.
2627    pub fn query_stats(&self) -> &query_stats::QueryStats {
2628        &self.query_stats
2629    }
2630
2631    /// v6.5.1 — mutable accessor (clear, etc).
2632    pub fn query_stats_mut(&mut self) -> &mut query_stats::QueryStats {
2633        &mut self.query_stats
2634    }
2635
2636    /// v6.2.0 — read access to the per-column statistics table.
2637    /// Used by the planner (v6.2.2 selectivity functions read this),
2638    /// by `SELECT * FROM spg_statistic`, and by e2e tests.
2639    pub const fn statistics(&self) -> &statistics::Statistics {
2640        &self.statistics
2641    }
2642
2643    /// v6.2.1 — return tables whose modified-row count crossed the
2644    /// auto-analyze threshold since the last ANALYZE on that table.
2645    /// The threshold is `0.1 × max(row_count, MIN_ROWS_FOR_AUTO_
2646    /// ANALYZE)` — combines PG-style fractional + absolute lower
2647    /// bound so a fresh / tiny table doesn't get hammered on every
2648    /// INSERT.
2649    ///
2650    /// Designed to be cheap: walks every user table's
2651    /// `Catalog::table_names()` + reads `statistics::modified_
2652    /// since_last_analyze()` (BTreeMap lookup). The background
2653    /// worker calls this under `engine.read()` then drops the lock
2654    /// before re-acquiring `engine.write()` for the actual ANALYZE.
2655    pub fn tables_needing_analyze(&self) -> Vec<String> {
2656        const MIN_ROWS: u64 = 100;
2657        let mut out = Vec::new();
2658        for name in self.catalog.table_names() {
2659            if is_internal_table_name(&name) {
2660                continue;
2661            }
2662            let Some(table) = self.catalog.get(&name) else {
2663                continue;
2664            };
2665            let row_count = table.rows().len() as u64;
2666            let modified = self.statistics.modified_since_last_analyze(&name);
2667            // Threshold: ceil(0.1 × max(row_count, MIN_ROWS)),
2668            // computed in integer arithmetic so spg-engine stays
2669            // no_std without pulling in libm. `(n + 9) / 10` is
2670            // `ceil(n / 10)` for non-negative `n`.
2671            let base = row_count.max(MIN_ROWS);
2672            let threshold = base.saturating_add(9) / 10;
2673            if modified >= threshold {
2674                out.push(name);
2675            }
2676        }
2677        out
2678    }
2679
2680    /// v6.2.0 — `ANALYZE [<table>]` runtime. Bare `ANALYZE` walks
2681    /// every user table; `ANALYZE <name>` re-stats one. For each
2682    /// target table, single-pass scan + per-column histogram +
2683    /// `null_frac` + `n_distinct`. Replaces the table's prior
2684    /// stats; resets the modified-row counter.
2685    ///
2686    /// v6.2.0 doesn't sample — it scans the full table. v6.2.x
2687    /// can add reservoir sampling at the > 100 K-row mark; not a
2688    /// scope blocker for the current commit since rows ≤ 100 K
2689    /// analyse in milliseconds.
2690    fn exec_analyze(&mut self, target: Option<&str>) -> Result<QueryResult, EngineError> {
2691        let names: Vec<String> = if let Some(name) = target {
2692            // Verify the table exists; surface a clear error if not.
2693            if self.catalog.get(name).is_none() {
2694                return Err(EngineError::Storage(StorageError::TableNotFound {
2695                    name: name.to_string(),
2696                }));
2697            }
2698            alloc::vec![name.to_string()]
2699        } else {
2700            self.catalog
2701                .table_names()
2702                .into_iter()
2703                .filter(|n| !is_internal_table_name(n))
2704                .collect()
2705        };
2706        let mut analysed = 0usize;
2707        for table_name in &names {
2708            self.analyze_one_table(table_name)?;
2709            analysed += 1;
2710        }
2711        // v6.3.1 — plan cache invalidation. Bump stats version so
2712        // future lookups see the new generation, and selectively
2713        // evict every plan whose `source_tables` overlap with the
2714        // ANALYZE target set. Bare ANALYZE (all tables) clears the
2715        // whole cache.
2716        if analysed > 0 {
2717            self.statistics.bump_version();
2718            if target.is_some() {
2719                for t in &names {
2720                    self.plan_cache.evict_referencing(t);
2721                }
2722            } else {
2723                self.plan_cache.clear();
2724            }
2725        }
2726        Ok(QueryResult::CommandOk {
2727            affected: analysed,
2728            modified_catalog: true,
2729        })
2730    }
2731
2732    /// v6.7.3 — `COMPACT COLD SEGMENTS` runtime path. Drives the
2733    /// engine-layer compaction shim with the default
2734    /// 4 MiB segment-size threshold. spg-server intercepts the
2735    /// SQL before it reaches the engine on a server build —
2736    /// it reads `SPG_COMPACTION_TARGET_SEGMENT_BYTES`, calls
2737    /// `Engine::compact_cold_segments_with_target` directly with
2738    /// the env value, and persists every merged segment to
2739    /// v7.12.1 — record a `SET <name> = <value>` parameter. Names
2740    /// are case-folded to lowercase to match PG; values keep their
2741    /// caller-supplied form so observability paths see what was
2742    /// requested. Only `default_text_search_config` is consulted by
2743    /// the engine today.
2744    fn set_session_param(&mut self, name: String, value: spg_sql::ast::SetValue) {
2745        let normalised = match value {
2746            spg_sql::ast::SetValue::String(s) => s,
2747            spg_sql::ast::SetValue::Ident(s) => s,
2748            spg_sql::ast::SetValue::Number(s) => s,
2749            spg_sql::ast::SetValue::Default => String::new(),
2750        };
2751        let key = name.to_ascii_lowercase();
2752        // v7.14.0 — mysqldump preamble emits
2753        // `SET FOREIGN_KEY_CHECKS=0` so it can CREATE TABLE in any
2754        // order despite cross-table FK references; the closing
2755        // section emits `SET FOREIGN_KEY_CHECKS=1` (or
2756        // `=@OLD_FOREIGN_KEY_CHECKS` which resolves to "ON" in our
2757        // session-variable-aware path). Match both shapes.
2758        // Also accept PG's `session_replication_role = 'replica'`
2759        // which suppresses trigger + FK enforcement during a
2760        // logical replication apply (pg_dump preserves this for
2761        // schema-only mode but it shows up in some restores).
2762        let value_off = matches!(
2763            normalised.to_ascii_lowercase().as_str(),
2764            "0" | "off" | "false"
2765        );
2766        let value_on = matches!(
2767            normalised.to_ascii_lowercase().as_str(),
2768            "1" | "on" | "true"
2769        );
2770        if key == "foreign_key_checks"
2771            || key == "session_replication_role" && normalised.eq_ignore_ascii_case("replica")
2772        {
2773            if value_off || key == "session_replication_role" {
2774                self.foreign_key_checks = false;
2775            } else if value_on
2776                || (key == "session_replication_role" && normalised.eq_ignore_ascii_case("origin"))
2777            {
2778                self.foreign_key_checks = true;
2779                // Drain pending FK queue against the now-complete
2780                // catalog. Errors here surface as the SET reply —
2781                // caller knows enabling checks revealed orphans.
2782                let _ = self.drain_pending_foreign_keys();
2783            }
2784        }
2785        // v7.22 (round-13 T3) — string-literal dialect signals.
2786        // `SET sql_mode = …` is something only MySQL clients and
2787        // mysqldump preambles emit → MySQL escape semantics.
2788        // `SET standard_conforming_strings = on|off` is PG's own
2789        // switch for exactly this behaviour (every pg_dump preamble
2790        // sets it to on). The same SQL text lexes differently per
2791        // dialect, so a flip invalidates the plan cache.
2792        let new_escapes = if key == "sql_mode" {
2793            Some(true)
2794        } else if key == "standard_conforming_strings" {
2795            Some(value_off)
2796        } else {
2797            None
2798        };
2799        if let Some(flag) = new_escapes
2800            && flag != self.backslash_escapes
2801        {
2802            self.backslash_escapes = flag;
2803            self.plan_cache.clear();
2804        }
2805        self.session_params.insert(key, normalised);
2806    }
2807
2808    /// v7.14.0 — resolve every queued FK whose installation was
2809    /// deferred (`SET FOREIGN_KEY_CHECKS=0` window). Called by
2810    /// `set_session_param` when checks flip back on and by the
2811    /// drop-import release gate. Each FK is resolved against the
2812    /// current catalog; remaining missing-parent errors propagate
2813    /// up so the caller knows the import was incomplete.
2814    fn drain_pending_foreign_keys(&mut self) -> Result<(), EngineError> {
2815        let pending = core::mem::take(&mut self.pending_foreign_keys);
2816        for (child, fk) in pending {
2817            // Resolve against the current catalog. Skip silently
2818            // when the child table itself was dropped between
2819            // queue + drain.
2820            let cols_snapshot = match self.active_catalog().get(&child) {
2821                Some(t) => t.schema().columns.clone(),
2822                None => continue,
2823            };
2824            let storage_fk =
2825                resolve_foreign_key(&child, &cols_snapshot, fk, self.active_catalog())?;
2826            let table = self
2827                .active_catalog_mut()
2828                .get_mut(&child)
2829                .expect("checked above");
2830            table.schema_mut().foreign_keys.push(storage_fk);
2831        }
2832        Ok(())
2833    }
2834
2835    /// v7.12.1 — read a session parameter set via `SET`. Used by
2836    /// the FTS function dispatcher to resolve the default config
2837    /// for `to_tsvector(text)` / `plainto_tsquery(text)` etc.
2838    #[must_use]
2839    pub fn session_param(&self, name: &str) -> Option<&str> {
2840        self.session_params
2841            .get(&name.to_ascii_lowercase())
2842            .map(String::as_str)
2843    }
2844
2845    /// v7.12.1 — build an `EvalContext` chained with the session's
2846    /// `default_text_search_config`. Engine-internal callers use
2847    /// this instead of `EvalContext::new` so the FTS function
2848    /// dispatcher sees the SET configuration.
2849    fn ev_ctx<'a>(
2850        &'a self,
2851        columns: &'a [ColumnSchema],
2852        alias: Option<&'a str>,
2853    ) -> EvalContext<'a> {
2854        EvalContext::new(columns, alias)
2855            .with_default_text_search_config(self.session_param("default_text_search_config"))
2856    }
2857
2858    /// `<db>.spg/segments/`. This arm only fires for engine-only
2859    /// callers (spg-embedded, lib tests); in that mode merged
2860    /// segments live in memory and are dropped at process exit.
2861    fn exec_compact_cold_segments(&mut self) -> Result<QueryResult, EngineError> {
2862        let target = COMPACTION_TARGET_DEFAULT_BYTES;
2863        let reports = self.compact_cold_segments_with_target(target)?;
2864        let columns = alloc::vec![
2865            ColumnSchema::new("table_name", DataType::Text, false),
2866            ColumnSchema::new("index_name", DataType::Text, false),
2867            ColumnSchema::new("sources_merged", DataType::BigInt, false),
2868            ColumnSchema::new("merged_segment_id", DataType::BigInt, false),
2869            ColumnSchema::new("merged_rows", DataType::BigInt, false),
2870            ColumnSchema::new("deleted_rows_pruned", DataType::BigInt, false),
2871            ColumnSchema::new("bytes_reclaimed_estimate", DataType::BigInt, false),
2872        ];
2873        let rows: Vec<Row> = reports
2874            .into_iter()
2875            .map(|(tname, iname, report)| {
2876                Row::new(alloc::vec![
2877                    Value::Text(tname),
2878                    Value::Text(iname),
2879                    Value::BigInt(i64::try_from(report.sources.len()).unwrap_or(i64::MAX)),
2880                    Value::BigInt(i64::from(report.merged_segment_id.unwrap_or(0))),
2881                    Value::BigInt(i64::try_from(report.merged_rows).unwrap_or(i64::MAX)),
2882                    Value::BigInt(i64::try_from(report.deleted_rows_pruned).unwrap_or(i64::MAX),),
2883                    Value::BigInt(
2884                        i64::try_from(report.bytes_reclaimed_estimate).unwrap_or(i64::MAX),
2885                    ),
2886                ])
2887            })
2888            .collect();
2889        Ok(QueryResult::Rows { columns, rows })
2890    }
2891
2892    /// Walk a single table's rows once and (re-)populate per-column
2893    /// stats. Drops the existing stats for `table` first so columns
2894    /// that have been DROP-ed between ANALYZEs don't leave stale
2895    /// rows.
2896    fn analyze_one_table(&mut self, table_name: &str) -> Result<(), EngineError> {
2897        let table = self.catalog.get(table_name).ok_or_else(|| {
2898            EngineError::Storage(StorageError::TableNotFound {
2899                name: table_name.to_string(),
2900            })
2901        })?;
2902        let schema = table.schema().clone();
2903        let row_count = table.rows().len();
2904        // For each column, collect (sorted) non-NULL textual values
2905        // + count NULLs; then ask `statistics::build_histogram` to
2906        // produce the 101 bounds and `estimate_n_distinct` the
2907        // distinct count.
2908        self.statistics.clear_table(table_name);
2909        for (col_pos, col_schema) in schema.columns.iter().enumerate() {
2910            // v6.2.0 skip: vector columns have their own stats
2911            // shape (HNSW graph topology). v6.2 deliberation #1.
2912            if matches!(col_schema.ty, DataType::Vector { .. }) {
2913                continue;
2914            }
2915            let mut non_null_values: Vec<Value> = Vec::with_capacity(row_count);
2916            let mut nulls: u64 = 0;
2917            for row in table.rows() {
2918                match row.values.get(col_pos) {
2919                    Some(Value::Null) | None => nulls += 1,
2920                    Some(v) => non_null_values.push(v.clone()),
2921                }
2922            }
2923            // Sort by type-aware ordering (Int as int, Text as
2924            // lex, etc.) so histogram bounds reflect the column's
2925            // natural order — not lexicographic on the string
2926            // representation, which would put "9" after "49".
2927            non_null_values.sort_by(|a, b| sort_values_for_histogram(a, b));
2928            let non_null: Vec<String> = non_null_values.iter().map(canonical_value_repr).collect();
2929            let null_frac = if row_count == 0 {
2930                0.0
2931            } else {
2932                #[allow(clippy::cast_precision_loss)]
2933                let f = nulls as f32 / row_count as f32;
2934                f
2935            };
2936            let n_distinct = statistics::estimate_n_distinct(&non_null);
2937            let histogram_bounds = statistics::build_histogram(&non_null);
2938            self.statistics.set(
2939                table_name.to_string(),
2940                col_schema.name.clone(),
2941                statistics::ColumnStats {
2942                    null_frac,
2943                    n_distinct,
2944                    histogram_bounds,
2945                },
2946            );
2947        }
2948        self.statistics.reset_modified(table_name);
2949        // v6.7.0 — refresh the per-table cold_rows cache. Walk the
2950        // BTree indices and count Cold locators (MAX across
2951        // indices); store the result on the table. Surfaced via
2952        // `spg_statistic.cold_row_count` (new column) and
2953        // `spg_stat_segment.table_name` (new column).
2954        let cold_count = {
2955            let table = self
2956                .active_catalog()
2957                .get(table_name)
2958                .expect("table still present");
2959            table.count_cold_locators()
2960        };
2961        let table_mut = self
2962            .active_catalog_mut()
2963            .get_mut(table_name)
2964            .expect("table still present");
2965        table_mut.set_cold_row_count(cold_count);
2966        Ok(())
2967    }
2968
2969    /// v6.1.3 — `SHOW PUBLICATIONS` row materialisation. Returns
2970    /// `(name, scope, table_count)` ordered by publication name.
2971    ///   - `scope` is the human-readable string:
2972    ///       `"FOR ALL TABLES"` /
2973    ///       `"FOR TABLE t1, t2"` /
2974    ///       `"FOR ALL TABLES EXCEPT t1, t2"`.
2975    ///   - `table_count` is NULL for `AllTables`, the list length
2976    ///     otherwise. NULLability lets clients distinguish "publish
2977    ///     everything" from "publish exactly 0 tables" (the v6.1.3
2978    ///     parser forbids the empty list, but the column shape is
2979    ///     ready for the v6.1.5 publisher-side semantics).
2980    fn exec_show_publications(&self) -> QueryResult {
2981        let columns = alloc::vec![
2982            ColumnSchema::new("name", DataType::Text, false),
2983            ColumnSchema::new("scope", DataType::Text, false),
2984            ColumnSchema::new("table_count", DataType::Int, true),
2985        ];
2986        let rows: Vec<Row> = self
2987            .publications
2988            .iter()
2989            .map(|(name, scope)| {
2990                let (scope_str, count_val) = match scope {
2991                    spg_sql::ast::PublicationScope::AllTables => {
2992                        ("FOR ALL TABLES".to_string(), Value::Null)
2993                    }
2994                    spg_sql::ast::PublicationScope::ForTables(ts) => (
2995                        alloc::format!("FOR TABLE {}", ts.join(", ")),
2996                        Value::Int(i32::try_from(ts.len()).unwrap_or(i32::MAX)),
2997                    ),
2998                    spg_sql::ast::PublicationScope::AllTablesExcept(ts) => (
2999                        alloc::format!("FOR ALL TABLES EXCEPT {}", ts.join(", ")),
3000                        Value::Int(i32::try_from(ts.len()).unwrap_or(i32::MAX)),
3001                    ),
3002                };
3003                Row::new(alloc::vec![
3004                    Value::Text(name.clone()),
3005                    Value::Text(scope_str),
3006                    count_val,
3007                ])
3008            })
3009            .collect();
3010        QueryResult::Rows { columns, rows }
3011    }
3012
3013    /// v4.1 `SHOW USERS` — `(name, role)` per row, ordered by name.
3014    fn exec_show_users(&self) -> QueryResult {
3015        let columns = alloc::vec![
3016            ColumnSchema::new("name", DataType::Text, false),
3017            ColumnSchema::new("role", DataType::Text, false),
3018        ];
3019        let rows: Vec<Row> = self
3020            .users
3021            .iter()
3022            .map(|(name, rec)| {
3023                Row::new(alloc::vec![
3024                    Value::Text(name.to_string()),
3025                    Value::Text(rec.role.as_str().to_string()),
3026                ])
3027            })
3028            .collect();
3029        QueryResult::Rows { columns, rows }
3030    }
3031
3032    fn exec_create_user(&mut self, s: &CreateUserStatement) -> Result<QueryResult, EngineError> {
3033        if self.in_transaction() {
3034            return Err(EngineError::Unsupported(
3035                "CREATE USER is not allowed inside a transaction".into(),
3036            ));
3037        }
3038        let role = users::Role::parse(&s.role).ok_or_else(|| {
3039            EngineError::Unsupported(alloc::format!("invalid role: {:?}", s.role))
3040        })?;
3041        // Prefer the host-injected RNG. Falls back to a deterministic
3042        // salt derived from the username only when no RNG is wired —
3043        // acceptable for tests; the server always installs one.
3044        let salt = self.salt_fn.map_or_else(
3045            || {
3046                let mut s_bytes = [0u8; 16];
3047                let digest = spg_crypto::hash(s.name.as_bytes());
3048                s_bytes.copy_from_slice(&digest[..16]);
3049                s_bytes
3050            },
3051            |f| f(),
3052        );
3053        self.users
3054            .create(&s.name, &s.password, role, salt)
3055            .map_err(|e| EngineError::Unsupported(alloc::format!("CREATE USER: {e}")))?;
3056        Ok(QueryResult::CommandOk {
3057            affected: 1,
3058            modified_catalog: true,
3059        })
3060    }
3061
3062    fn exec_drop_user(&mut self, name: &str) -> Result<QueryResult, EngineError> {
3063        if self.in_transaction() {
3064            return Err(EngineError::Unsupported(
3065                "DROP USER is not allowed inside a transaction".into(),
3066            ));
3067        }
3068        self.users
3069            .drop(name)
3070            .map_err(|e| EngineError::Unsupported(alloc::format!("DROP USER: {e}")))?;
3071        Ok(QueryResult::CommandOk {
3072            affected: 1,
3073            modified_catalog: true,
3074        })
3075    }
3076
3077    /// v7.12.4 — `CREATE [OR REPLACE] FUNCTION`. Stores the
3078    /// function metadata in the catalog. PL/pgSQL bodies are
3079    /// already parsed by the SQL parser; we re-canonicalise the
3080    /// body to source text for storage (the executor re-parses
3081    /// it at trigger fire time — see the trigger fire path).
3082    fn exec_create_function(
3083        &mut self,
3084        s: spg_sql::ast::CreateFunctionStatement,
3085    ) -> Result<QueryResult, EngineError> {
3086        let args_repr = render_function_args(&s.args);
3087        let returns = match &s.returns {
3088            spg_sql::ast::FunctionReturn::Trigger => alloc::string::String::from("TRIGGER"),
3089            spg_sql::ast::FunctionReturn::Void => alloc::string::String::from("VOID"),
3090            spg_sql::ast::FunctionReturn::Type(t) => alloc::format!("{t}"),
3091            spg_sql::ast::FunctionReturn::Other(s) => s.clone(),
3092        };
3093        let body_text = match &s.body {
3094            spg_sql::ast::FunctionBody::PlPgSql(b) => alloc::format!("{b}"),
3095            spg_sql::ast::FunctionBody::Raw(s) => s.clone(),
3096        };
3097        let def = spg_storage::FunctionDef {
3098            name: s.name.clone(),
3099            args_repr,
3100            returns,
3101            language: s.language.clone(),
3102            body: body_text,
3103        };
3104        self.active_catalog_mut()
3105            .create_function(def, s.or_replace)
3106            .map_err(EngineError::Storage)?;
3107        Ok(QueryResult::CommandOk {
3108            affected: 0,
3109            modified_catalog: true,
3110        })
3111    }
3112
3113    /// v7.12.4 — `CREATE [OR REPLACE] TRIGGER`. The referenced
3114    /// function must already exist in the catalog (forward
3115    /// references defer to a later release). Persists the
3116    /// trigger metadata for the row-write hooks below to consult.
3117    fn exec_create_trigger(
3118        &mut self,
3119        s: spg_sql::ast::CreateTriggerStatement,
3120    ) -> Result<QueryResult, EngineError> {
3121        let timing = match s.timing {
3122            spg_sql::ast::TriggerTiming::Before => "BEFORE",
3123            spg_sql::ast::TriggerTiming::After => "AFTER",
3124            spg_sql::ast::TriggerTiming::InsteadOf => "INSTEAD OF",
3125        };
3126        let events: Vec<alloc::string::String> = s
3127            .events
3128            .iter()
3129            .map(|e| match e {
3130                spg_sql::ast::TriggerEvent::Insert => alloc::string::String::from("INSERT"),
3131                spg_sql::ast::TriggerEvent::Update => alloc::string::String::from("UPDATE"),
3132                spg_sql::ast::TriggerEvent::Delete => alloc::string::String::from("DELETE"),
3133                spg_sql::ast::TriggerEvent::Truncate => alloc::string::String::from("TRUNCATE"),
3134            })
3135            .collect();
3136        let for_each = match s.for_each {
3137            spg_sql::ast::TriggerForEach::Row => "ROW",
3138            spg_sql::ast::TriggerForEach::Statement => "STATEMENT",
3139        };
3140        let def = spg_storage::TriggerDef {
3141            name: s.name.clone(),
3142            table: s.table.clone(),
3143            timing: alloc::string::String::from(timing),
3144            events,
3145            for_each: alloc::string::String::from(for_each),
3146            function: s.function.clone(),
3147            update_columns: s.update_columns.clone(),
3148            // v7.16.1 — every trigger is born enabled. Toggled
3149            // by ALTER TABLE … { ENABLE | DISABLE } TRIGGER.
3150            enabled: true,
3151        };
3152        self.active_catalog_mut()
3153            .create_trigger(def, s.or_replace)
3154            .map_err(EngineError::Storage)?;
3155        Ok(QueryResult::CommandOk {
3156            affected: 0,
3157            modified_catalog: true,
3158        })
3159    }
3160
3161    fn exec_drop_trigger(
3162        &mut self,
3163        name: &str,
3164        table: &str,
3165        if_exists: bool,
3166    ) -> Result<QueryResult, EngineError> {
3167        let removed = self.active_catalog_mut().drop_trigger(name, table);
3168        if !removed && !if_exists {
3169            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3170                alloc::format!("trigger {name:?} on {table:?} does not exist"),
3171            )));
3172        }
3173        Ok(QueryResult::CommandOk {
3174            affected: usize::from(removed),
3175            modified_catalog: removed,
3176        })
3177    }
3178
3179    fn exec_drop_function(
3180        &mut self,
3181        name: &str,
3182        if_exists: bool,
3183    ) -> Result<QueryResult, EngineError> {
3184        let removed = self.active_catalog_mut().drop_function(name);
3185        if !removed && !if_exists {
3186            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3187                alloc::format!("function {name:?} does not exist"),
3188            )));
3189        }
3190        Ok(QueryResult::CommandOk {
3191            affected: usize::from(removed),
3192            modified_catalog: removed,
3193        })
3194    }
3195
3196    /// v7.17.0 — `CREATE SEQUENCE` engine path. Resolves
3197    /// `min_value` / `max_value` / `start` against PG defaults
3198    /// when omitted, then installs the SequenceDef in the catalog.
3199    fn exec_create_sequence(
3200        &mut self,
3201        s: spg_sql::ast::CreateSequenceStatement,
3202    ) -> Result<QueryResult, EngineError> {
3203        use spg_sql::ast::{SeqBound, SequenceDataType as AstDt};
3204        use spg_storage::{SequenceDataType, SequenceDef};
3205        let dt = match s.data_type {
3206            None => SequenceDataType::BigInt,
3207            Some(AstDt::SmallInt) => SequenceDataType::SmallInt,
3208            Some(AstDt::Int) => SequenceDataType::Int,
3209            Some(AstDt::BigInt) => SequenceDataType::BigInt,
3210        };
3211        let increment = s.options.increment.unwrap_or(1);
3212        if increment == 0 {
3213            return Err(EngineError::Unsupported(
3214                "INCREMENT must not be zero".into(),
3215            ));
3216        }
3217        let (def_min, def_max) = dt.default_bounds(increment > 0);
3218        let min_value = match s.options.min_value {
3219            None | Some(SeqBound::NoBound) => def_min,
3220            Some(SeqBound::Value(n)) => n,
3221        };
3222        let max_value = match s.options.max_value {
3223            None | Some(SeqBound::NoBound) => def_max,
3224            Some(SeqBound::Value(n)) => n,
3225        };
3226        if min_value > max_value {
3227            return Err(EngineError::Unsupported(alloc::format!(
3228                "MINVALUE ({min_value}) must be <= MAXVALUE ({max_value})"
3229            )));
3230        }
3231        let start = s
3232            .options
3233            .start
3234            .unwrap_or(if increment > 0 { min_value } else { max_value });
3235        if start < min_value || start > max_value {
3236            return Err(EngineError::Unsupported(alloc::format!(
3237                "START WITH ({start}) is outside MINVALUE..MAXVALUE ({min_value}..{max_value})"
3238            )));
3239        }
3240        let cache = s.options.cache.unwrap_or(1);
3241        if cache < 1 {
3242            return Err(EngineError::Unsupported("CACHE must be >= 1".into()));
3243        }
3244        let cycle = s.options.cycle.unwrap_or(false);
3245        let owned_by = match s.options.owned_by {
3246            None | Some(spg_sql::ast::SequenceOwnedBy::None) => None,
3247            Some(spg_sql::ast::SequenceOwnedBy::Column { table, column }) => Some((table, column)),
3248        };
3249        let def = SequenceDef {
3250            name: s.name.clone(),
3251            data_type: dt,
3252            start,
3253            increment,
3254            min_value,
3255            max_value,
3256            cache,
3257            cycle,
3258            owned_by,
3259            last_value: start,
3260            is_called: false,
3261        };
3262        self.active_catalog_mut()
3263            .create_sequence(def, s.if_not_exists)
3264            .map_err(EngineError::Storage)?;
3265        Ok(QueryResult::CommandOk {
3266            affected: 0,
3267            modified_catalog: !self.in_transaction(),
3268        })
3269    }
3270
3271    /// v7.17.0 — `ALTER SEQUENCE` engine path. Re-uses the catalog
3272    /// `alter_sequence` merge helper.
3273    fn exec_alter_sequence(
3274        &mut self,
3275        s: spg_sql::ast::AlterSequenceStatement,
3276    ) -> Result<QueryResult, EngineError> {
3277        use spg_sql::ast::SeqBound;
3278        let cat = self.active_catalog_mut();
3279        if !cat.sequences().contains_key(&s.name) {
3280            if s.if_exists {
3281                return Ok(QueryResult::CommandOk {
3282                    affected: 0,
3283                    modified_catalog: false,
3284                });
3285            }
3286            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3287                alloc::format!("sequence {:?} does not exist", s.name),
3288            )));
3289        }
3290        let min_value = match s.options.min_value {
3291            None => None,
3292            Some(SeqBound::NoBound) => None, // NO MINVALUE → keep current
3293            Some(SeqBound::Value(n)) => Some(n),
3294        };
3295        let max_value = match s.options.max_value {
3296            None => None,
3297            Some(SeqBound::NoBound) => None,
3298            Some(SeqBound::Value(n)) => Some(n),
3299        };
3300        let owned_by = s.options.owned_by.map(|ob| match ob {
3301            spg_sql::ast::SequenceOwnedBy::None => None,
3302            spg_sql::ast::SequenceOwnedBy::Column { table, column } => Some((table, column)),
3303        });
3304        cat.alter_sequence(
3305            &s.name,
3306            s.options.increment,
3307            min_value,
3308            max_value,
3309            s.options.start,
3310            s.options.restart,
3311            s.options.cache,
3312            s.options.cycle,
3313            owned_by,
3314        )
3315        .map_err(EngineError::Storage)?;
3316        Ok(QueryResult::CommandOk {
3317            affected: 0,
3318            modified_catalog: !self.in_transaction(),
3319        })
3320    }
3321
3322    /// v7.17.0 Phase 1.1 — walk a Statement tree and pre-resolve
3323    /// any sequence FunctionCall nodes inside its Expr slots.
3324    /// Delegates per-statement-kind: SELECT projection +
3325    /// WHERE, INSERT VALUES, UPDATE SET, DELETE WHERE.
3326    fn pre_resolve_sequence_calls_in_statement(
3327        &mut self,
3328        stmt: &mut Statement,
3329    ) -> Result<(), EngineError> {
3330        match stmt {
3331            Statement::Select(s) => self.pre_resolve_sequence_calls_in_select(s),
3332            Statement::Insert(s) => {
3333                for tuple in &mut s.rows {
3334                    for cell in tuple.iter_mut() {
3335                        self.resolve_sequence_calls_in_expr(cell)?;
3336                    }
3337                }
3338                Ok(())
3339            }
3340            Statement::Update(s) => {
3341                for (_col, expr) in &mut s.assignments {
3342                    self.resolve_sequence_calls_in_expr(expr)?;
3343                }
3344                if let Some(w) = &mut s.where_ {
3345                    self.resolve_sequence_calls_in_expr(w)?;
3346                }
3347                Ok(())
3348            }
3349            Statement::Delete(s) => {
3350                if let Some(w) = &mut s.where_ {
3351                    self.resolve_sequence_calls_in_expr(w)?;
3352                }
3353                Ok(())
3354            }
3355            _ => Ok(()),
3356        }
3357    }
3358
3359    fn pre_resolve_sequence_calls_in_select(
3360        &mut self,
3361        s: &mut spg_sql::ast::SelectStatement,
3362    ) -> Result<(), EngineError> {
3363        for item in &mut s.items {
3364            match item {
3365                spg_sql::ast::SelectItem::Expr { expr, .. } => {
3366                    self.resolve_sequence_calls_in_expr(expr)?;
3367                }
3368                spg_sql::ast::SelectItem::Wildcard => {}
3369            }
3370        }
3371        if let Some(w) = &mut s.where_ {
3372            self.resolve_sequence_calls_in_expr(w)?;
3373        }
3374        Ok(())
3375    }
3376
3377    /// v7.17.0 Phase 1.1 — walk an Expr tree and pre-resolve any
3378    /// `nextval(name)` / `currval(name)` / `setval(name, value[,
3379    /// is_called])` FunctionCall nodes by calling the catalog and
3380    /// replacing the node with the resulting `Expr::Literal`.
3381    /// Used by INSERT VALUES / UPDATE SET / DEFAULT eval so the
3382    /// row-eval path sees pre-computed sequence values instead of
3383    /// needing mutable catalog access mid-eval.
3384    #[allow(clippy::too_many_lines)]
3385    fn resolve_sequence_calls_in_expr(&mut self, expr: &mut Expr) -> Result<(), EngineError> {
3386        match expr {
3387            Expr::Literal(_) | Expr::Column(_) | Expr::Placeholder(_) => Ok(()),
3388            Expr::FunctionCall { name, args } => {
3389                // Descend first so nested calls — e.g.
3390                // setval('seq', currval('other')) — resolve
3391                // innermost-first.
3392                for a in args.iter_mut() {
3393                    self.resolve_sequence_calls_in_expr(a)?;
3394                }
3395                let lc = name.to_ascii_lowercase();
3396                if lc == "nextval" || lc == "currval" || lc == "setval" {
3397                    let v = self.eval_sequence_call(&lc, args)?;
3398                    *expr = Expr::Literal(value_to_literal(v));
3399                }
3400                Ok(())
3401            }
3402            Expr::Binary { lhs, rhs, .. } => {
3403                self.resolve_sequence_calls_in_expr(lhs)?;
3404                self.resolve_sequence_calls_in_expr(rhs)
3405            }
3406            Expr::Unary { expr, .. } => self.resolve_sequence_calls_in_expr(expr),
3407            Expr::Cast { expr, .. } => self.resolve_sequence_calls_in_expr(expr),
3408            Expr::IsNull { expr, .. } => self.resolve_sequence_calls_in_expr(expr),
3409            Expr::Like { expr, pattern, .. } => {
3410                self.resolve_sequence_calls_in_expr(expr)?;
3411                self.resolve_sequence_calls_in_expr(pattern)
3412            }
3413            Expr::Extract { source, .. } => self.resolve_sequence_calls_in_expr(source),
3414            Expr::Array(items) => {
3415                for it in items.iter_mut() {
3416                    self.resolve_sequence_calls_in_expr(it)?;
3417                }
3418                Ok(())
3419            }
3420            // Window / subquery / etc — sequence calls inside these
3421            // are uncommon and require separate row-eval; leave
3422            // untouched for now and rely on the eval-time error
3423            // (no sequence_resolver attached).
3424            _ => Ok(()),
3425        }
3426    }
3427
3428    /// v7.17.0 Phase 1.1 — evaluate a single nextval/currval/
3429    /// setval call. `args` are already pre-resolved Expr nodes
3430    /// (literals) — we extract their constant values.
3431    fn eval_sequence_call(&mut self, op: &str, args: &[Expr]) -> Result<Value, EngineError> {
3432        if args.is_empty() {
3433            return Err(EngineError::Unsupported(alloc::format!(
3434                "{op}() takes at least one argument"
3435            )));
3436        }
3437        let seq_name = match &args[0] {
3438            Expr::Literal(spg_sql::ast::Literal::String(s)) => {
3439                // v7.17 dump-compat — pg_dump emits sequence
3440                // names schema-qualified (`'public.posts_id_seq'`).
3441                // SPG is single-schema; strip a leading
3442                // `public.` / `pg_catalog.` so the catalog lookup
3443                // matches the bare-name CREATE SEQUENCE used.
3444                let trimmed = s
3445                    .strip_prefix("public.")
3446                    .or_else(|| s.strip_prefix("pg_catalog."))
3447                    .unwrap_or(s);
3448                trimmed.to_string()
3449            }
3450            // v7.17 dump-compat — pg_dump also emits
3451            // `nextval('public.posts_id_seq'::regclass)`
3452            // where the cast wraps the literal. Peel the cast
3453            // and continue.
3454            Expr::Cast { expr, .. } => {
3455                if let Expr::Literal(spg_sql::ast::Literal::String(s)) = expr.as_ref() {
3456                    let trimmed = s
3457                        .strip_prefix("public.")
3458                        .or_else(|| s.strip_prefix("pg_catalog."))
3459                        .unwrap_or(s);
3460                    trimmed.to_string()
3461                } else {
3462                    return Err(EngineError::Unsupported(alloc::format!(
3463                        "{op}() first argument must be a literal sequence name"
3464                    )));
3465                }
3466            }
3467            other => {
3468                return Err(EngineError::Unsupported(alloc::format!(
3469                    "{op}() first argument must be a literal sequence name, got {other:?}"
3470                )));
3471            }
3472        };
3473        match op {
3474            "nextval" => {
3475                let v = self
3476                    .active_catalog_mut()
3477                    .sequence_next_value(&seq_name)
3478                    .map_err(EngineError::Storage)?;
3479                Ok(Value::BigInt(v))
3480            }
3481            "currval" => {
3482                let v = self
3483                    .active_catalog()
3484                    .sequence_current_value(&seq_name)
3485                    .map_err(EngineError::Storage)?;
3486                Ok(Value::BigInt(v))
3487            }
3488            "setval" => {
3489                if args.len() < 2 || args.len() > 3 {
3490                    return Err(EngineError::Unsupported(alloc::format!(
3491                        "setval() takes 2 or 3 arguments, got {}",
3492                        args.len()
3493                    )));
3494                }
3495                let value = match &args[1] {
3496                    Expr::Literal(spg_sql::ast::Literal::Integer(n)) => *n,
3497                    other => {
3498                        return Err(EngineError::Unsupported(alloc::format!(
3499                            "setval() value argument must be a literal integer, got {other:?}"
3500                        )));
3501                    }
3502                };
3503                let is_called = if args.len() == 3 {
3504                    match &args[2] {
3505                        Expr::Literal(spg_sql::ast::Literal::Bool(b)) => *b,
3506                        other => {
3507                            return Err(EngineError::Unsupported(alloc::format!(
3508                                "setval() is_called argument must be a literal BOOL, got {other:?}"
3509                            )));
3510                        }
3511                    }
3512                } else {
3513                    true
3514                };
3515                let v = self
3516                    .active_catalog_mut()
3517                    .sequence_set_value(&seq_name, value, is_called)
3518                    .map_err(EngineError::Storage)?;
3519                Ok(Value::BigInt(v))
3520            }
3521            other => Err(EngineError::Unsupported(alloc::format!(
3522                "unknown sequence op {other:?}"
3523            ))),
3524        }
3525    }
3526
3527    /// v7.17.0 Phase 1.2 — find every catalog VIEW referenced in
3528    /// the SELECT's FROM / JOIN graph, re-parse each view's body
3529    /// source, and prepend it as a synthetic CTE on the
3530    /// returned SelectStatement. Returns `None` when no view
3531    /// references are found (caller proceeds with the original
3532    /// statement); returns `Some(rewritten)` otherwise (caller
3533    /// re-runs exec_select_cancel on the rewritten form so the
3534    /// regular CTE materialiser handles it).
3535    fn expand_views_in_select(
3536        &self,
3537        stmt: &SelectStatement,
3538    ) -> Result<Option<SelectStatement>, EngineError> {
3539        let cat = self.active_catalog();
3540        let mut referenced: Vec<String> = Vec::new();
3541        if let Some(from) = &stmt.from {
3542            collect_view_refs(&from.primary, cat, &mut referenced);
3543            for j in &from.joins {
3544                collect_view_refs(&j.table, cat, &mut referenced);
3545            }
3546        }
3547        // Don't expand a view name that's already shadowed by a
3548        // CTE on the same SELECT — the CTE wins per PG.
3549        referenced.retain(|n| !stmt.ctes.iter().any(|c| c.name == *n));
3550        if referenced.is_empty() {
3551            return Ok(None);
3552        }
3553        let mut new_ctes: Vec<spg_sql::ast::Cte> = Vec::with_capacity(referenced.len());
3554        for name in &referenced {
3555            let view = cat.views().get(name).ok_or_else(|| {
3556                EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
3557                    "view {name:?} disappeared mid-expansion"
3558                )))
3559            })?;
3560            let parsed = spg_sql::parser::parse_statement(&view.body).map_err(|e| {
3561                EngineError::Unsupported(alloc::format!("view {name:?} body re-parse failed: {e}"))
3562            })?;
3563            let Statement::Select(body) = parsed else {
3564                return Err(EngineError::Unsupported(alloc::format!(
3565                    "view {name:?} body is not a SELECT (catalog corruption)"
3566                )));
3567            };
3568            new_ctes.push(spg_sql::ast::Cte {
3569                name: name.clone(),
3570                body,
3571                recursive: false,
3572                column_overrides: view.columns.clone(),
3573            });
3574        }
3575        let mut out = stmt.clone();
3576        // Prepend so view CTEs are visible to caller-supplied CTEs.
3577        new_ctes.extend(out.ctes);
3578        out.ctes = new_ctes;
3579        Ok(Some(out))
3580    }
3581
3582    /// v7.17.0 Phase 1.2 — `CREATE VIEW` engine path. Stores the
3583    /// Display-rendered body verbatim in the catalog; SELECT-from-
3584    /// view at exec time re-parses + prepends as a synthetic CTE.
3585    fn exec_create_view(
3586        &mut self,
3587        s: spg_sql::ast::CreateViewStatement,
3588    ) -> Result<QueryResult, EngineError> {
3589        // Render the SELECT body to canonical form so the catalog
3590        // round-trips a deterministic source (no whitespace /
3591        // comment surprises in the on-disk snapshot).
3592        let body_repr = alloc::format!("{}", spg_sql::ast::Statement::Select(s.body));
3593        let def = spg_storage::ViewDef {
3594            name: s.name.clone(),
3595            columns: s.columns,
3596            body: body_repr,
3597        };
3598        self.active_catalog_mut()
3599            .create_view(def, s.or_replace, s.if_not_exists)
3600            .map_err(EngineError::Storage)?;
3601        Ok(QueryResult::CommandOk {
3602            affected: 0,
3603            modified_catalog: !self.in_transaction(),
3604        })
3605    }
3606
3607    /// v7.17.0 Phase 1.4 — `CREATE TYPE name AS ENUM (…)` engine
3608    /// path. Registers the enum in the catalog with order-
3609    /// preserving labels. PG semantics: CREATE TYPE errors if the
3610    /// name is taken (no IF NOT EXISTS).
3611    fn exec_create_type(
3612        &mut self,
3613        s: spg_sql::ast::CreateTypeStatement,
3614    ) -> Result<QueryResult, EngineError> {
3615        // Name-collision check against tables / sequences / views /
3616        // materialized views.
3617        let cat = self.active_catalog();
3618        if cat.get(&s.name).is_some() {
3619            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3620                alloc::format!("type {:?} would shadow an existing table", s.name),
3621            )));
3622        }
3623        if cat.sequences().contains_key(&s.name) {
3624            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3625                alloc::format!("type {:?} would shadow an existing sequence", s.name),
3626            )));
3627        }
3628        if cat.views().contains_key(&s.name) {
3629            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3630                alloc::format!("type {:?} would shadow an existing view", s.name),
3631            )));
3632        }
3633        let def = match s.kind {
3634            spg_sql::ast::TypeKind::Enum { labels } => {
3635                if labels.is_empty() {
3636                    return Err(EngineError::Unsupported(
3637                        "CREATE TYPE … AS ENUM requires at least one label".into(),
3638                    ));
3639                }
3640                // Reject duplicate labels per PG.
3641                for i in 0..labels.len() {
3642                    for j in (i + 1)..labels.len() {
3643                        if labels[i] == labels[j] {
3644                            return Err(EngineError::Unsupported(alloc::format!(
3645                                "CREATE TYPE {:?}: duplicate ENUM label {:?}",
3646                                s.name,
3647                                labels[i]
3648                            )));
3649                        }
3650                    }
3651                }
3652                spg_storage::EnumDef {
3653                    name: s.name.clone(),
3654                    labels,
3655                }
3656            }
3657        };
3658        self.active_catalog_mut()
3659            .create_enum_type(def)
3660            .map_err(EngineError::Storage)?;
3661        Ok(QueryResult::CommandOk {
3662            affected: 0,
3663            modified_catalog: !self.in_transaction(),
3664        })
3665    }
3666
3667    /// v7.17.0 Phase 1.5 — `CREATE DOMAIN name AS base [DEFAULT
3668    /// expr] [NOT NULL] [CHECK (expr)]*` engine path. Stores the
3669    /// base type + Display-rendered CHECK / DEFAULT sources so
3670    /// INSERT/UPDATE on bound columns can re-eval the checks.
3671    fn exec_create_domain(
3672        &mut self,
3673        s: spg_sql::ast::CreateDomainStatement,
3674    ) -> Result<QueryResult, EngineError> {
3675        let cat = self.active_catalog();
3676        if cat.domain_types().contains_key(&s.name) {
3677            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3678                alloc::format!("domain {:?} already exists", s.name),
3679            )));
3680        }
3681        if cat.get(&s.name).is_some()
3682            || cat.sequences().contains_key(&s.name)
3683            || cat.views().contains_key(&s.name)
3684            || cat.enum_types().contains_key(&s.name)
3685        {
3686            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3687                alloc::format!("domain {:?} would shadow an existing object", s.name),
3688            )));
3689        }
3690        let base_type = column_type_to_data_type(s.base_type);
3691        let default = s.default.as_ref().map(|e| alloc::format!("{e}"));
3692        let checks = s
3693            .checks
3694            .iter()
3695            .map(|e| alloc::format!("{e}"))
3696            .collect::<Vec<_>>();
3697        let def = spg_storage::DomainDef {
3698            name: s.name.clone(),
3699            base_type,
3700            nullable: !s.not_null,
3701            default,
3702            checks,
3703        };
3704        self.active_catalog_mut()
3705            .create_domain_type(def)
3706            .map_err(EngineError::Storage)?;
3707        Ok(QueryResult::CommandOk {
3708            affected: 0,
3709            modified_catalog: !self.in_transaction(),
3710        })
3711    }
3712
3713    /// v7.17.0 Phase 1.5 — `DROP DOMAIN [IF EXISTS] names`.
3714    fn exec_drop_domain(
3715        &mut self,
3716        names: &[String],
3717        if_exists: bool,
3718    ) -> Result<QueryResult, EngineError> {
3719        let mut removed = 0usize;
3720        for name in names {
3721            let was_present = self.active_catalog_mut().drop_domain_type(name);
3722            if was_present {
3723                removed += 1;
3724            } else if !if_exists {
3725                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3726                    alloc::format!("domain {name:?} does not exist"),
3727                )));
3728            }
3729        }
3730        Ok(QueryResult::CommandOk {
3731            affected: removed,
3732            modified_catalog: removed > 0 && !self.in_transaction(),
3733        })
3734    }
3735
3736    /// v7.17.0 Phase 1.6 — `CREATE SCHEMA [IF NOT EXISTS] name`.
3737    /// Registers the schema in the catalog. Schema-qualified
3738    /// table references continue to strip the prefix at lookup
3739    /// time (prefix routing, not isolation — see project-next-
3740    /// docket for the v7.18+ real-isolation tracking).
3741    fn exec_create_schema(
3742        &mut self,
3743        name: String,
3744        if_not_exists: bool,
3745    ) -> Result<QueryResult, EngineError> {
3746        self.active_catalog_mut()
3747            .create_schema(name, if_not_exists)
3748            .map_err(EngineError::Storage)?;
3749        Ok(QueryResult::CommandOk {
3750            affected: 0,
3751            modified_catalog: !self.in_transaction(),
3752        })
3753    }
3754
3755    /// v7.17.0 Phase 1.6 — `DROP SCHEMA [IF EXISTS] names`.
3756    /// Built-in schemas always reject the drop with a clear
3757    /// error.
3758    fn exec_drop_schema(
3759        &mut self,
3760        names: &[String],
3761        if_exists: bool,
3762    ) -> Result<QueryResult, EngineError> {
3763        let mut removed = 0usize;
3764        for name in names {
3765            let was_present = self
3766                .active_catalog_mut()
3767                .drop_schema(name)
3768                .map_err(EngineError::Storage)?;
3769            if was_present {
3770                removed += 1;
3771            } else if !if_exists {
3772                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3773                    alloc::format!("schema {name:?} does not exist"),
3774                )));
3775            }
3776        }
3777        Ok(QueryResult::CommandOk {
3778            affected: removed,
3779            modified_catalog: removed > 0 && !self.in_transaction(),
3780        })
3781    }
3782
3783    /// v7.17.0 Phase 1.4 — `DROP TYPE [IF EXISTS] names`. Only
3784    /// ENUM types are catalogued today; other types silently
3785    /// no-op even outside IF EXISTS to mirror the prior
3786    /// "everything's text" lax stance.
3787    fn exec_drop_type(
3788        &mut self,
3789        names: &[String],
3790        if_exists: bool,
3791    ) -> Result<QueryResult, EngineError> {
3792        let mut removed = 0usize;
3793        for name in names {
3794            let was_present = self.active_catalog_mut().drop_enum_type(name);
3795            if was_present {
3796                removed += 1;
3797            } else if !if_exists {
3798                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3799                    alloc::format!("type {name:?} does not exist"),
3800                )));
3801            }
3802        }
3803        Ok(QueryResult::CommandOk {
3804            affected: removed,
3805            modified_catalog: removed > 0 && !self.in_transaction(),
3806        })
3807    }
3808
3809    /// v7.17.0 Phase 1.3 — `CREATE MATERIALIZED VIEW` engine path.
3810    /// Materialises the body at CREATE time (unless WITH NO DATA),
3811    /// stores the result as a regular `Table`, and registers the
3812    /// body source in the catalog so REFRESH can re-run it.
3813    fn exec_create_materialized_view(
3814        &mut self,
3815        s: spg_sql::ast::CreateMaterializedViewStatement,
3816    ) -> Result<QueryResult, EngineError> {
3817        // Name-collision check (table / view / sequence / mat-view).
3818        let cat = self.active_catalog();
3819        if cat.materialized_views().contains_key(&s.name) || cat.get(&s.name).is_some() {
3820            if s.if_not_exists {
3821                return Ok(QueryResult::CommandOk {
3822                    affected: 0,
3823                    modified_catalog: false,
3824                });
3825            }
3826            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3827                alloc::format!("materialized view {:?} already exists", s.name),
3828            )));
3829        }
3830        if cat.views().contains_key(&s.name) {
3831            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3832                alloc::format!(
3833                    "materialized view {:?} would shadow an existing view",
3834                    s.name
3835                ),
3836            )));
3837        }
3838        if cat.sequences().contains_key(&s.name) {
3839            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3840                alloc::format!(
3841                    "materialized view {:?} would shadow an existing sequence",
3842                    s.name
3843                ),
3844            )));
3845        }
3846        // Render the body to canonical form for the registry.
3847        let body_repr = alloc::format!("{}", spg_sql::ast::Statement::Select(s.body.clone()));
3848        // Execute the body to learn the columns. With WITH DATA we
3849        // also materialise the rows; with WITH NO DATA we only need
3850        // the schema, so re-use a LIMIT 0 wrap to keep the column
3851        // inference path uniform without paying for the rows.
3852        let result = self.exec_select_cancel(&s.body, CancelToken::none())?;
3853        let (mut cols, rows) = match result {
3854            QueryResult::Rows { columns, rows } => (columns, rows),
3855            other => {
3856                return Err(EngineError::Unsupported(alloc::format!(
3857                    "CREATE MATERIALIZED VIEW body did not return rows: {other:?}"
3858                )));
3859            }
3860        };
3861        // Apply the column-rename list per PG semantics.
3862        if !s.columns.is_empty() {
3863            if s.columns.len() != cols.len() {
3864                return Err(EngineError::Unsupported(alloc::format!(
3865                    "CREATE MATERIALIZED VIEW {:?}: column list has {} names but body returns {}",
3866                    s.name,
3867                    s.columns.len(),
3868                    cols.len()
3869                )));
3870            }
3871            for (c, name) in cols.iter_mut().zip(s.columns.iter()) {
3872                c.name.clone_from(name);
3873            }
3874        }
3875        // Promote any synthetic-Text projections to their actual
3876        // observed types so the backing table accepts the rows.
3877        cols = infer_column_types(&cols, &rows);
3878        let schema = spg_storage::TableSchema::new(s.name.clone(), cols);
3879        let cat = self.active_catalog_mut();
3880        cat.create_table(schema).map_err(EngineError::Storage)?;
3881        if s.with_data {
3882            let table = cat
3883                .get_mut(&s.name)
3884                .expect("just-created materialized-view backing table must exist");
3885            for row in rows {
3886                table.insert(row).map_err(EngineError::Storage)?;
3887            }
3888        }
3889        cat.register_materialized_view(s.name.clone(), body_repr);
3890        Ok(QueryResult::CommandOk {
3891            affected: 0,
3892            modified_catalog: !self.in_transaction(),
3893        })
3894    }
3895
3896    /// v7.17.0 Phase 1.3 — `REFRESH MATERIALIZED VIEW name [WITH
3897    /// [NO] DATA]`. Looks up the source, re-runs it, replaces the
3898    /// backing table's rows.
3899    fn exec_refresh_materialized_view(
3900        &mut self,
3901        name: &str,
3902        with_data: bool,
3903    ) -> Result<QueryResult, EngineError> {
3904        let source = self
3905            .active_catalog()
3906            .materialized_views()
3907            .get(name)
3908            .cloned()
3909            .ok_or_else(|| {
3910                EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
3911                    "materialized view {name:?} does not exist"
3912                )))
3913            })?;
3914        // Wipe the existing rows first (PG truncates the matview
3915        // and rebuilds; we approximate with an empty INSERT loop).
3916        {
3917            let cat = self.active_catalog_mut();
3918            let table = cat.get_mut(name).ok_or_else(|| {
3919                EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
3920                    "materialized view {name:?} backing table missing"
3921                )))
3922            })?;
3923            table.truncate();
3924        }
3925        if !with_data {
3926            return Ok(QueryResult::CommandOk {
3927                affected: 0,
3928                modified_catalog: !self.in_transaction(),
3929            });
3930        }
3931        let parsed = spg_sql::parser::parse_statement(&source).map_err(|e| {
3932            EngineError::Unsupported(alloc::format!(
3933                "materialized view {name:?} body re-parse failed: {e}"
3934            ))
3935        })?;
3936        let Statement::Select(body) = parsed else {
3937            return Err(EngineError::Unsupported(alloc::format!(
3938                "materialized view {name:?} body is not a SELECT (catalog corruption)"
3939            )));
3940        };
3941        let rows = match self.exec_select_cancel(&body, CancelToken::none())? {
3942            QueryResult::Rows { rows, .. } => rows,
3943            other => {
3944                return Err(EngineError::Unsupported(alloc::format!(
3945                    "REFRESH MATERIALIZED VIEW {name:?} body did not return rows: {other:?}"
3946                )));
3947            }
3948        };
3949        let cat = self.active_catalog_mut();
3950        let table = cat.get_mut(name).expect("backing table verified above");
3951        let affected = rows.len();
3952        for row in rows {
3953            table.insert(row).map_err(EngineError::Storage)?;
3954        }
3955        Ok(QueryResult::CommandOk {
3956            affected,
3957            modified_catalog: !self.in_transaction(),
3958        })
3959    }
3960
3961    /// v7.17.0 Phase 1.3 — `DROP MATERIALIZED VIEW [IF EXISTS]
3962    /// names`. Drops the backing table + unregisters the source.
3963    fn exec_drop_materialized_view(
3964        &mut self,
3965        names: &[String],
3966        if_exists: bool,
3967    ) -> Result<QueryResult, EngineError> {
3968        let mut removed = 0usize;
3969        for name in names {
3970            let was_present = self
3971                .active_catalog_mut()
3972                .drop_materialized_view_source(name);
3973            if was_present {
3974                // Drop the backing table too.
3975                self.active_catalog_mut().drop_table(name);
3976                removed += 1;
3977            } else if !if_exists {
3978                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3979                    alloc::format!("materialized view {name:?} does not exist"),
3980                )));
3981            }
3982        }
3983        Ok(QueryResult::CommandOk {
3984            affected: removed,
3985            modified_catalog: removed > 0 && !self.in_transaction(),
3986        })
3987    }
3988
3989    /// v7.17.0 Phase 1.2 — `DROP VIEW [IF EXISTS] name [, name…]`.
3990    fn exec_drop_view(
3991        &mut self,
3992        names: &[String],
3993        if_exists: bool,
3994    ) -> Result<QueryResult, EngineError> {
3995        let mut removed = 0usize;
3996        for name in names {
3997            let was_present = self.active_catalog_mut().drop_view(name);
3998            if !was_present && !if_exists {
3999                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
4000                    alloc::format!("view {name:?} does not exist"),
4001                )));
4002            }
4003            if was_present {
4004                removed += 1;
4005            }
4006        }
4007        Ok(QueryResult::CommandOk {
4008            affected: removed,
4009            modified_catalog: removed > 0 && !self.in_transaction(),
4010        })
4011    }
4012
4013    /// v7.17.0 — `DROP SEQUENCE [IF EXISTS] name [, name…]`.
4014    fn exec_drop_sequence(
4015        &mut self,
4016        names: &[String],
4017        if_exists: bool,
4018    ) -> Result<QueryResult, EngineError> {
4019        let mut removed = 0usize;
4020        for name in names {
4021            let was_present = self.active_catalog_mut().drop_sequence(name);
4022            if !was_present && !if_exists {
4023                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
4024                    alloc::format!("sequence {name:?} does not exist"),
4025                )));
4026            }
4027            if was_present {
4028                removed += 1;
4029            }
4030        }
4031        Ok(QueryResult::CommandOk {
4032            affected: removed,
4033            modified_catalog: removed > 0 && !self.in_transaction(),
4034        })
4035    }
4036
4037    /// v4.4 `UPDATE <table> SET col = expr [, ...] [WHERE cond]`.
4038    /// Filter pass uses the same WHERE eval as `exec_select`. Per
4039    /// matched row, evaluate each RHS expression against the *old*
4040    /// row, then call `Table::update_row` which rebuilds indices.
4041    /// Indexed columns are correctly reflected because rebuild
4042    /// happens after the cell rewrite.
4043    fn exec_update_cancel(
4044        &mut self,
4045        stmt: &spg_sql::ast::UpdateStatement,
4046        cancel: CancelToken<'_>,
4047    ) -> Result<QueryResult, EngineError> {
4048        // v7.12.5 — snapshot BEFORE/AFTER UPDATE row triggers + the
4049        // session FTS config before the table mut-borrow opens (the
4050        // INSERT path uses the same pattern). Empty vecs are the
4051        // common "no triggers on this table" fast path.
4052        // v7.13.0 — UPDATE triggers carry an optional `UPDATE OF
4053        // cols` filter. The filter is paired with each function so
4054        // the per-row fire loop can skip when no listed column
4055        // actually differs between OLD and NEW.
4056        let before_update_triggers = self.snapshot_update_row_triggers(&stmt.table, "BEFORE");
4057        let after_update_triggers = self.snapshot_update_row_triggers(&stmt.table, "AFTER");
4058        let trigger_session_cfg: Option<String> = self
4059            .session_params
4060            .get("default_text_search_config")
4061            .cloned();
4062        // v5.2.3: if the WHERE is a PK equality and matches a cold-
4063        // tier row, promote it back to the hot tier *before* the
4064        // hot-row walk. The promote pushes the row to the end of
4065        // `table.rows`, where the upcoming SET-evaluation loop will
4066        // pick it up and apply the assignments. Lookups for the key
4067        // never observe a gap because `promote_cold_row` inserts the
4068        // hot row before retiring the cold locator.
4069        if let Some(w) = &stmt.where_ {
4070            let schema_cols = self
4071                .active_catalog()
4072                .get(&stmt.table)
4073                .ok_or_else(|| {
4074                    EngineError::Storage(StorageError::TableNotFound {
4075                        name: stmt.table.clone(),
4076                    })
4077                })?
4078                .schema()
4079                .columns
4080                .clone();
4081            if let Some((col_pos, key)) = try_pk_predicate(w, &schema_cols, stmt.table.as_str())
4082                && let Some(idx_name) = self
4083                    .active_catalog()
4084                    .get(&stmt.table)
4085                    .and_then(|t| t.index_on(col_pos).map(|i| i.name.clone()))
4086            {
4087                // Promote may be a no-op (key is hot-only or absent);
4088                // we don't care about the return value here — the
4089                // subsequent hot walk will either match or not.
4090                let _ = self
4091                    .active_catalog_mut()
4092                    .promote_cold_row(&stmt.table, &idx_name, &key);
4093            }
4094        }
4095
4096        // v7.12.1 — cache session FTS config before the table
4097        // mut-borrow (same reason as exec_delete).
4098        let ts_cfg: Option<String> = self
4099            .session_param("default_text_search_config")
4100            .map(String::from);
4101        // v7.17.0 Phase 2.1 — snapshot the clock pointer before
4102        // we hold the catalog mutably so ON UPDATE runtime
4103        // overrides see the engine wall clock.
4104        let clock_for_on_update = self.clock;
4105        let table = self
4106            .active_catalog_mut()
4107            .get_mut(&stmt.table)
4108            .ok_or_else(|| {
4109                EngineError::Storage(StorageError::TableNotFound {
4110                    name: stmt.table.clone(),
4111                })
4112            })?;
4113        let schema_cols: Vec<ColumnSchema> = table.schema().columns.clone();
4114        // Resolve each SET target to a column position once, validate
4115        // up front so a typo'd column doesn't leave a partial mutation
4116        // behind.
4117        let mut targets: Vec<(usize, &Expr)> = Vec::with_capacity(stmt.assignments.len());
4118        for (col, expr) in &stmt.assignments {
4119            let pos = schema_cols
4120                .iter()
4121                .position(|c| c.name == *col)
4122                .ok_or_else(|| {
4123                    EngineError::Eval(EvalError::ColumnNotFound { name: col.clone() })
4124                })?;
4125            targets.push((pos, expr));
4126        }
4127        // v7.17.0 Phase 2.1 — for every column with an
4128        // `ON UPDATE CURRENT_TIMESTAMP` binding that the caller
4129        // did NOT explicitly set, schedule an automatic override.
4130        // Reuses `eval_runtime_default_free` so the same
4131        // canonical runtime-expression whitelist (now /
4132        // current_timestamp / current_date / …) governs both
4133        // DEFAULT and ON UPDATE.
4134        let mut on_update_overrides: Vec<(usize, String)> = Vec::new();
4135        for (i, col) in schema_cols.iter().enumerate() {
4136            if targets.iter().any(|(p, _)| *p == i) {
4137                continue;
4138            }
4139            if let Some(src) = &col.on_update_runtime {
4140                on_update_overrides.push((i, src.clone()));
4141            }
4142        }
4143        let ctx = EvalContext::new(&schema_cols, Some(stmt.table.as_str()))
4144            .with_default_text_search_config(ts_cfg.as_deref());
4145        // Walk candidate rows, evaluate WHERE then SET
4146        // expressions. We gather (position, new_values) tuples
4147        // first and apply them afterwards so the WHERE/RHS
4148        // evaluation reads the original row state — matches PG
4149        // semantics (UPDATE doesn't see its own writes).
4150        //
4151        // v7.20 P4 — index seek: a single-column equality WHERE
4152        // on an indexed column narrows the walk from
4153        // O(table.rows()) to O(matches). The full WHERE still
4154        // re-evaluates per candidate (the seek may be an
4155        // over-approximation under AND-composites), so semantics
4156        // are unchanged. profile: the bench's `UPDATE … WHERE
4157        // id = $1` on a 5 000-row table was a ~1.3 ms full scan
4158        // per statement; with the seek it's ~2 µs.
4159        let seek_positions: Option<Vec<usize>> = stmt
4160            .where_
4161            .as_ref()
4162            .and_then(|w| try_index_seek_positions(w, &schema_cols, table, stmt.table.as_str()));
4163        let mut planned: Vec<(usize, Vec<Value>)> = Vec::new();
4164        let candidate_positions: Vec<usize> = match &seek_positions {
4165            Some(list) => list.clone(),
4166            None => (0..table.row_count()).collect(),
4167        };
4168        for (loop_n, &i) in candidate_positions.iter().enumerate() {
4169            // v4.5: cooperative cancel checkpoint every 256 rows so
4170            // a runaway UPDATE without WHERE doesn't drag past the
4171            // server's query-timeout watchdog.
4172            if loop_n.is_multiple_of(256) {
4173                cancel.check()?;
4174            }
4175            let Some(row) = table.rows().get(i) else {
4176                continue;
4177            };
4178            if let Some(w) = &stmt.where_ {
4179                let cond = eval::eval_expr(w, row, &ctx)?;
4180                if !matches!(cond, Value::Bool(true)) {
4181                    continue;
4182                }
4183            }
4184            let mut new_vals = row.values.clone();
4185            for (pos, expr) in &targets {
4186                let v = eval::eval_expr(expr, row, &ctx)?;
4187                let coerced = coerce_value(v, schema_cols[*pos].ty, &schema_cols[*pos].name, *pos)?;
4188                check_unsigned_range(&coerced, &schema_cols[*pos], *pos)?;
4189                new_vals[*pos] = coerced;
4190            }
4191            // v7.17.0 Phase 2.1 — apply ON UPDATE overrides for
4192            // any column the SET clause didn't touch.
4193            for (pos, src) in &on_update_overrides {
4194                let v = eval_runtime_default_free(src, schema_cols[*pos].ty, clock_for_on_update)?;
4195                new_vals[*pos] = v;
4196            }
4197            planned.push((i, new_vals));
4198        }
4199        // planned must stay position-sorted: downstream passes
4200        // (FK pairing, trigger walks, the apply loop) iterate it
4201        // assuming ascending row order, which the full-scan path
4202        // guaranteed implicitly.
4203        planned.sort_by_key(|(i, _)| *i);
4204        // v7.6.6 — capture pre-update row values for the FK
4205        // enforcement passes below. `planned` carries new values
4206        // only; pair them with the old row.
4207        let plan_with_old: Vec<(usize, Vec<Value>, Vec<Value>)> = planned
4208            .iter()
4209            .map(|(pos, new_vals)| (*pos, table.rows()[*pos].values.clone(), new_vals.clone()))
4210            .collect();
4211        let self_fks = table.schema().foreign_keys.clone();
4212        // v7.12.5 — `affected` is computed post-BEFORE-trigger
4213        // below (triggers may RETURN NULL to skip individual
4214        // rows). The pre-trigger len shape is no longer accurate.
4215        // Release mutable borrow on `table` for the FK passes.
4216        let _ = table;
4217        // v7.6.6 — Stage 2a: outbound FK check. For every row whose
4218        // local FK columns changed, the new value must exist in the
4219        // parent.
4220        if !self_fks.is_empty() {
4221            let new_rows: Vec<Vec<Value>> = planned
4222                .iter()
4223                .map(|(_pos, new_vals)| new_vals.clone())
4224                .collect();
4225            enforce_fk_inserts(self.active_catalog(), &stmt.table, &self_fks, &new_rows)?;
4226        }
4227        // v7.13.0 — CHECK constraint enforcement on UPDATE
4228        // (mailrs round-5 G3). Predicates evaluated against the
4229        // candidate post-UPDATE row; false rejects the UPDATE.
4230        {
4231            let new_rows: Vec<Vec<Value>> = planned
4232                .iter()
4233                .map(|(_pos, new_vals)| new_vals.clone())
4234                .collect();
4235            enforce_check_constraints(self.active_catalog(), &stmt.table, &new_rows)?;
4236        }
4237        // v7.6.6 — Stage 2b: inbound FK check. For every row that
4238        // changed value in a column that *some other table* uses as
4239        // a FK parent column, react per `on_update` action.
4240        let child_plan =
4241            plan_fk_parent_updates(self.active_catalog(), &stmt.table, &plan_with_old)?;
4242        // Stage 3a — apply each child-side action.
4243        for step in &child_plan {
4244            apply_fk_child_step(self.active_catalog_mut(), step)?;
4245        }
4246        // Stage 3b — apply the original UPDATE.
4247        let table = self
4248            .active_catalog_mut()
4249            .get_mut(&stmt.table)
4250            .ok_or_else(|| {
4251                EngineError::Storage(StorageError::TableNotFound {
4252                    name: stmt.table.clone(),
4253                })
4254            })?;
4255        // v7.12.5 — fire BEFORE/AFTER UPDATE row-level triggers
4256        // around the apply loop. BEFORE sees NEW=candidate +
4257        // OLD=current; may rewrite NEW or RETURN NULL to skip.
4258        // AFTER sees NEW=post-write + OLD=pre-write (both read-
4259        // only).
4260        //
4261        // Filter `planned` through the BEFORE pass first so the
4262        // RETURNING snapshot reflects what actually got written
4263        // (triggers may rewrite cells, including a cancellation).
4264        let mut applied_after_before: Vec<(usize, Row, Row)> = Vec::with_capacity(planned.len());
4265        // v7.12.7 — embedded SQL queue.
4266        let mut deferred_embedded: Vec<triggers::DeferredEmbeddedStmt> = Vec::new();
4267        for (pos, new_vals) in &planned {
4268            let old_row = table.rows()[*pos].clone();
4269            let mut new_row = Row::new(new_vals.clone());
4270            let mut skip = false;
4271            for (fd, filter) in &before_update_triggers {
4272                // v7.13.0 — `UPDATE OF cols` filter (mailrs round-5
4273                // G7). Skip this trigger when the filter is set and
4274                // no listed column actually differs between OLD and
4275                // NEW for this row.
4276                if !filter.is_empty()
4277                    && !any_column_changed(filter, &schema_cols, &old_row, &new_row)
4278                {
4279                    continue;
4280                }
4281                let (outcome, deferred) = triggers::fire_row_trigger(
4282                    fd,
4283                    Some(new_row.clone()),
4284                    Some(&old_row),
4285                    &stmt.table,
4286                    &schema_cols,
4287                    &[],
4288                    trigger_session_cfg.as_deref(),
4289                    false,
4290                )
4291                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
4292                deferred_embedded.extend(deferred);
4293                match outcome {
4294                    triggers::TriggerOutcome::Row(r) => new_row = r,
4295                    triggers::TriggerOutcome::Skip => {
4296                        skip = true;
4297                        break;
4298                    }
4299                }
4300            }
4301            if !skip {
4302                applied_after_before.push((*pos, new_row, old_row));
4303            }
4304        }
4305        // v7.9.4 — snapshot post-update values for RETURNING (post-
4306        // BEFORE-trigger because triggers can rewrite cells).
4307        let updated_for_returning: Vec<Vec<Value>> = if stmt.returning.is_some() {
4308            applied_after_before
4309                .iter()
4310                .map(|(_pos, new_row, _old)| new_row.values.clone())
4311                .collect()
4312        } else {
4313            Vec::new()
4314        };
4315        let affected = applied_after_before.len();
4316        // Apply, then fire AFTER triggers per row. AFTER runs read-
4317        // only against the freshly-written row; v7.12.4-shape
4318        // assignment errors with a clear message.
4319        for (pos, new_row, old_row) in applied_after_before {
4320            table.update_row(pos, new_row.values.clone())?;
4321            for (fd, filter) in &after_update_triggers {
4322                if !filter.is_empty()
4323                    && !any_column_changed(filter, &schema_cols, &old_row, &new_row)
4324                {
4325                    continue;
4326                }
4327                let (_outcome, deferred) = triggers::fire_row_trigger(
4328                    fd,
4329                    Some(new_row.clone()),
4330                    Some(&old_row),
4331                    &stmt.table,
4332                    &schema_cols,
4333                    &[],
4334                    trigger_session_cfg.as_deref(),
4335                    true,
4336                )
4337                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
4338                deferred_embedded.extend(deferred);
4339            }
4340        }
4341        let _ = table;
4342        // v7.12.7 — drain trigger-emitted embedded SQL for this UPDATE.
4343        self.execute_deferred_trigger_stmts(deferred_embedded, cancel)?;
4344        // v6.2.1 — auto-analyze modified-row tracking for UPDATE.
4345        if !self.in_transaction() && affected > 0 {
4346            self.statistics
4347                .record_modifications(&stmt.table, affected as u64);
4348        }
4349        // v7.9.4 — RETURNING projection.
4350        if let Some(items) = &stmt.returning {
4351            return self.build_returning_rows(&stmt.table, items, updated_for_returning);
4352        }
4353        Ok(QueryResult::CommandOk {
4354            affected,
4355            modified_catalog: !self.in_transaction(),
4356        })
4357    }
4358
4359    /// v4.4 `DELETE FROM <table> [WHERE cond]`. Collects matching
4360    /// positions then delegates to `Table::delete_rows` (single index
4361    /// rebuild for the batch).
4362    /// v7.17.0 Phase 3.P0-42 — SQL:2003 / PG 15+ `MERGE` execution.
4363    ///
4364    /// Semantics:
4365    ///   * Resolve `target` and `source` tables (catalog reads).
4366    ///   * Build a combined `(target_alias.col, source_alias.col)`
4367    ///     schema so the ON / WHEN AND / SET / VALUES expressions
4368    ///     resolve through the standard qualifier-aware resolver.
4369    ///   * Pass 1: walk every source row × every target hot row,
4370    ///     evaluate ON, then pick the first WHEN clause that fits
4371    ///     (`Matched` if any target row matched, `NotMatched`
4372    ///     otherwise; AND-condition must hold). Collect the action
4373    ///     plan as `(deletes, updates, inserts)` so the apply pass
4374    ///     reads the original target row state.
4375    ///   * Pass 2: apply the plan against the target's mutable row
4376    ///     vector. Deletes execute by index in descending order so
4377    ///     earlier indices remain stable; updates next; inserts
4378    ///     last (matching PG's "INSERT branch sees the post-delete
4379    ///     state" behaviour for the common upsert shape).
4380    ///
4381    /// v7.17 simplifications (documented limitations):
4382    ///   * No triggers / WAL plumbing (MVP); MERGE rows don't fire
4383    ///     INSERT / UPDATE / DELETE row triggers in v7.17.
4384    ///   * No cardinality check (PG-canonical: "MERGE command
4385    ///     cannot affect row a second time" — SPG silently applies
4386    ///     the last action for a target row covered twice).
4387    ///   * Source must be a catalog-resolvable table (no subquery
4388    ///     source); RETURNING / BY SOURCE / BY TARGET unsupported.
4389    fn exec_merge_cancel(
4390        &mut self,
4391        stmt: &spg_sql::ast::MergeStatement,
4392        cancel: CancelToken<'_>,
4393    ) -> Result<QueryResult, EngineError> {
4394        let target_alias = stmt
4395            .target_alias
4396            .clone()
4397            .unwrap_or_else(|| stmt.target.clone());
4398        let source_alias = stmt
4399            .source_alias
4400            .clone()
4401            .unwrap_or_else(|| stmt.source.clone());
4402        let (target_cols, target_rows_snapshot) = {
4403            let t = self.active_catalog().get(&stmt.target).ok_or_else(|| {
4404                EngineError::Storage(StorageError::TableNotFound {
4405                    name: stmt.target.clone(),
4406                })
4407            })?;
4408            (
4409                t.schema().columns.clone(),
4410                t.rows().iter().cloned().collect::<Vec<Row>>(),
4411            )
4412        };
4413        let (source_cols, source_rows) = {
4414            let s = self.active_catalog().get(&stmt.source).ok_or_else(|| {
4415                EngineError::Storage(StorageError::TableNotFound {
4416                    name: stmt.source.clone(),
4417                })
4418            })?;
4419            (
4420                s.schema().columns.clone(),
4421                s.rows().iter().cloned().collect::<Vec<Row>>(),
4422            )
4423        };
4424        // Composite schema: target_alias.col ... source_alias.col ...
4425        let mut combined_schema: Vec<ColumnSchema> = Vec::new();
4426        for col in &target_cols {
4427            combined_schema.push(ColumnSchema::new(
4428                alloc::format!("{target_alias}.{}", col.name),
4429                col.ty,
4430                col.nullable,
4431            ));
4432        }
4433        for col in &source_cols {
4434            combined_schema.push(ColumnSchema::new(
4435                alloc::format!("{source_alias}.{}", col.name),
4436                col.ty,
4437                col.nullable,
4438            ));
4439        }
4440        let combined_ctx = EvalContext::new(&combined_schema, None);
4441        // Source-only context for WHEN NOT MATCHED actions (no
4442        // matched target row exists — the source-side qualified
4443        // columns must still resolve).
4444        let mut source_only_schema: Vec<ColumnSchema> = Vec::new();
4445        for col in &target_cols {
4446            source_only_schema.push(ColumnSchema::new(
4447                alloc::format!("{target_alias}.{}", col.name),
4448                col.ty,
4449                col.nullable,
4450            ));
4451        }
4452        for col in &source_cols {
4453            source_only_schema.push(ColumnSchema::new(
4454                alloc::format!("{source_alias}.{}", col.name),
4455                col.ty,
4456                col.nullable,
4457            ));
4458        }
4459        let source_only_ctx = EvalContext::new(&source_only_schema, None);
4460        let target_arity = target_cols.len();
4461        let source_arity = source_cols.len();
4462
4463        // Resolve INSERT column positions once (validate names).
4464        // For each clause that's an INSERT, map column names → target positions.
4465        let mut delete_indices: Vec<usize> = Vec::new();
4466        let mut updates: Vec<(usize, Vec<Value>)> = Vec::new();
4467        let mut inserts: Vec<Vec<Value>> = Vec::new();
4468        let mut affected: usize = 0;
4469
4470        for (src_idx, src_row) in source_rows.iter().enumerate() {
4471            if src_idx.is_multiple_of(256) {
4472                cancel.check()?;
4473            }
4474            // Find every matched target index (per the ON predicate).
4475            let mut matched_targets: Vec<usize> = Vec::new();
4476            for (t_idx, t_row) in target_rows_snapshot.iter().enumerate() {
4477                let mut combined_vals = t_row.values.clone();
4478                combined_vals.extend(src_row.values.iter().cloned());
4479                let combined_row = Row::new(combined_vals);
4480                let cond = eval::eval_expr(&stmt.on, &combined_row, &combined_ctx)?;
4481                if matches!(cond, Value::Bool(true)) {
4482                    matched_targets.push(t_idx);
4483                }
4484            }
4485            let is_matched = !matched_targets.is_empty();
4486            // Pick the first WHEN clause whose kind agrees with
4487            // `is_matched` and whose AND condition (if any) holds.
4488            // AND condition for MATCHED: evaluated against the
4489            // first matched target row × source. For NOT MATCHED:
4490            // evaluated with target side NULL-padded.
4491            let fired_clause = stmt.clauses.iter().find(|c| {
4492                let kind_ok = match c.matched {
4493                    spg_sql::ast::MergeMatched::Matched => is_matched,
4494                    spg_sql::ast::MergeMatched::NotMatched => !is_matched,
4495                };
4496                if !kind_ok {
4497                    return false;
4498                }
4499                let Some(cond_expr) = &c.condition else {
4500                    return true;
4501                };
4502                let row = if is_matched {
4503                    let t = &target_rows_snapshot[matched_targets[0]];
4504                    let mut vals = t.values.clone();
4505                    vals.extend(src_row.values.iter().cloned());
4506                    Row::new(vals)
4507                } else {
4508                    let mut vals: Vec<Value> = (0..target_arity).map(|_| Value::Null).collect();
4509                    vals.extend(src_row.values.iter().cloned());
4510                    Row::new(vals)
4511                };
4512                let ctx_ref = if is_matched {
4513                    &combined_ctx
4514                } else {
4515                    &source_only_ctx
4516                };
4517                matches!(
4518                    eval::eval_expr(cond_expr, &row, ctx_ref),
4519                    Ok(Value::Bool(true))
4520                )
4521            });
4522            let Some(clause) = fired_clause else { continue };
4523            match &clause.action {
4524                spg_sql::ast::MergeAction::DoNothing => {}
4525                spg_sql::ast::MergeAction::Delete => {
4526                    for &t_idx in &matched_targets {
4527                        if !delete_indices.contains(&t_idx) {
4528                            delete_indices.push(t_idx);
4529                            affected += 1;
4530                        }
4531                    }
4532                }
4533                spg_sql::ast::MergeAction::Update { assignments } => {
4534                    // Pre-resolve SET targets to target column positions.
4535                    let mut planned_sets: Vec<(usize, &Expr)> =
4536                        Vec::with_capacity(assignments.len());
4537                    for (col, expr) in assignments {
4538                        let pos =
4539                            target_cols
4540                                .iter()
4541                                .position(|c| c.name == *col)
4542                                .ok_or_else(|| {
4543                                    EngineError::Eval(EvalError::ColumnNotFound {
4544                                        name: col.clone(),
4545                                    })
4546                                })?;
4547                        planned_sets.push((pos, expr));
4548                    }
4549                    for &t_idx in &matched_targets {
4550                        let t_row = &target_rows_snapshot[t_idx];
4551                        let mut new_values = t_row.values.clone();
4552                        let mut combined_vals = t_row.values.clone();
4553                        combined_vals.extend(src_row.values.iter().cloned());
4554                        let combined_row = Row::new(combined_vals);
4555                        for (pos, expr) in &planned_sets {
4556                            let raw = eval::eval_expr(expr, &combined_row, &combined_ctx)?;
4557                            let coerced = coerce_value(
4558                                raw,
4559                                target_cols[*pos].ty,
4560                                &target_cols[*pos].name,
4561                                *pos,
4562                            )?;
4563                            new_values[*pos] = coerced;
4564                        }
4565                        updates.push((t_idx, new_values));
4566                        affected += 1;
4567                    }
4568                }
4569                spg_sql::ast::MergeAction::Insert { columns, values } => {
4570                    // For INSERT NOT MATCHED, target side is NULL-padded.
4571                    let mut vals: Vec<Value> = (0..target_arity).map(|_| Value::Null).collect();
4572                    vals.extend(src_row.values.iter().cloned());
4573                    let synth_row = Row::new(vals);
4574                    let mut new_row_values: Vec<Value> =
4575                        (0..target_arity).map(|_| Value::Null).collect();
4576                    for (col, expr) in columns.iter().zip(values.iter()) {
4577                        let pos =
4578                            target_cols
4579                                .iter()
4580                                .position(|c| c.name == *col)
4581                                .ok_or_else(|| {
4582                                    EngineError::Eval(EvalError::ColumnNotFound {
4583                                        name: col.clone(),
4584                                    })
4585                                })?;
4586                        let raw = eval::eval_expr(expr, &synth_row, &source_only_ctx)?;
4587                        let coerced =
4588                            coerce_value(raw, target_cols[pos].ty, &target_cols[pos].name, pos)?;
4589                        new_row_values[pos] = coerced;
4590                    }
4591                    inserts.push(new_row_values);
4592                    affected += 1;
4593                }
4594            }
4595        }
4596        let _ = source_arity; // captured for symmetry; cancellation cost negligible.
4597
4598        // Apply the plan to the target table.
4599        let table = self
4600            .active_catalog_mut()
4601            .get_mut(&stmt.target)
4602            .ok_or_else(|| {
4603                EngineError::Storage(StorageError::TableNotFound {
4604                    name: stmt.target.clone(),
4605                })
4606            })?;
4607        // Apply updates first (in-place), then deletes (one batch),
4608        // then inserts. The storage API uses `update_row(pos,
4609        // new_values)`, `delete_rows(&[positions])`, and `insert(row)`.
4610        for (idx, new_vals) in &updates {
4611            table
4612                .update_row(*idx, new_vals.clone())
4613                .map_err(EngineError::Storage)?;
4614        }
4615        if !delete_indices.is_empty() {
4616            table.delete_rows(&delete_indices);
4617        }
4618        for vals in inserts {
4619            table.insert(Row::new(vals)).map_err(EngineError::Storage)?;
4620        }
4621        Ok(QueryResult::CommandOk {
4622            affected,
4623            modified_catalog: affected > 0,
4624        })
4625    }
4626
4627    fn exec_delete_cancel(
4628        &mut self,
4629        stmt: &spg_sql::ast::DeleteStatement,
4630        cancel: CancelToken<'_>,
4631    ) -> Result<QueryResult, EngineError> {
4632        // v7.12.5 — snapshot BEFORE/AFTER DELETE row triggers + the
4633        // session FTS config before the mut borrow (same shape as
4634        // INSERT / UPDATE).
4635        let before_delete_triggers = self.snapshot_row_triggers(&stmt.table, "DELETE", "BEFORE");
4636        let after_delete_triggers = self.snapshot_row_triggers(&stmt.table, "DELETE", "AFTER");
4637        let trigger_session_cfg: Option<String> = self
4638            .session_params
4639            .get("default_text_search_config")
4640            .cloned();
4641        // v5.2.3: PK-targeted DELETE → first retire any cold-tier
4642        // locator for the key. The cold row body stays in the
4643        // segment (becoming shadowed garbage that a future
4644        // compaction pass reclaims) but the index no longer
4645        // resolves it. The shadow count contributes to the
4646        // affected total; the subsequent hot walk handles any hot
4647        // rows for the same key.
4648        let mut cold_shadow_count: usize = 0;
4649        if let Some(w) = &stmt.where_ {
4650            let schema_cols = self
4651                .active_catalog()
4652                .get(&stmt.table)
4653                .ok_or_else(|| {
4654                    EngineError::Storage(StorageError::TableNotFound {
4655                        name: stmt.table.clone(),
4656                    })
4657                })?
4658                .schema()
4659                .columns
4660                .clone();
4661            if let Some((col_pos, key)) = try_pk_predicate(w, &schema_cols, stmt.table.as_str())
4662                && let Some(idx_name) = self
4663                    .active_catalog()
4664                    .get(&stmt.table)
4665                    .and_then(|t| t.index_on(col_pos).map(|i| i.name.clone()))
4666            {
4667                cold_shadow_count = self
4668                    .active_catalog_mut()
4669                    .shadow_cold_row(&stmt.table, &idx_name, &key)
4670                    .unwrap_or(0);
4671            }
4672        }
4673
4674        // v7.12.1 — cache the session FTS config as an owned
4675        // String before the mutable table borrow below; the
4676        // ctx-builder then references it via `as_deref` so the
4677        // immutable read of `session_params` doesn't conflict
4678        // with the mut borrow chain.
4679        let ts_cfg: Option<String> = self
4680            .session_param("default_text_search_config")
4681            .map(String::from);
4682        let table = self
4683            .active_catalog_mut()
4684            .get_mut(&stmt.table)
4685            .ok_or_else(|| {
4686                EngineError::Storage(StorageError::TableNotFound {
4687                    name: stmt.table.clone(),
4688                })
4689            })?;
4690        let schema_cols: Vec<ColumnSchema> = table.schema().columns.clone();
4691        let ctx = EvalContext::new(&schema_cols, Some(stmt.table.as_str()))
4692            .with_default_text_search_config(ts_cfg.as_deref());
4693        let mut positions: Vec<usize> = Vec::new();
4694        // v7.6.3 — collect every to-delete row's full Value tuple
4695        // alongside its position, so the FK enforcement pass can
4696        // run after the mut borrow drops.
4697        let mut to_delete_rows: Vec<Vec<Value>> = Vec::new();
4698        // v7.20 P4 — index seek (same shape as exec_update_cancel):
4699        // an equality WHERE on an indexed column narrows the walk
4700        // to the matching hot positions; the full WHERE still
4701        // re-evaluates per candidate. Downstream passes assume
4702        // ascending position order, so the seek result is sorted.
4703        let seek_positions: Option<Vec<usize>> = stmt
4704            .where_
4705            .as_ref()
4706            .and_then(|w| try_index_seek_positions(w, &schema_cols, table, stmt.table.as_str()));
4707        let candidate_positions: Vec<usize> = match seek_positions {
4708            Some(mut list) => {
4709                list.sort_unstable();
4710                list
4711            }
4712            None => (0..table.row_count()).collect(),
4713        };
4714        for (loop_n, &i) in candidate_positions.iter().enumerate() {
4715            if loop_n.is_multiple_of(256) {
4716                cancel.check()?;
4717            }
4718            let Some(row) = table.rows().get(i) else {
4719                continue;
4720            };
4721            let keep = if let Some(w) = &stmt.where_ {
4722                let cond = eval::eval_expr(w, row, &ctx)?;
4723                !matches!(cond, Value::Bool(true))
4724            } else {
4725                false
4726            };
4727            if !keep {
4728                positions.push(i);
4729                to_delete_rows.push(row.values.clone());
4730            }
4731        }
4732        // v7.6.3 / v7.6.4 — Stage 2: FK enforcement on the immutable
4733        // catalog. Release the mut borrow and run reverse-scan
4734        // against every child table whose FK targets this table.
4735        // RESTRICT / NoAction raise an error; CASCADE returns a
4736        // cascade plan that stage 3 applies after the primary delete.
4737        // SET NULL / SET DEFAULT remain Unsupported until v7.6.5.
4738        let _ = table;
4739        // v7.12.5 — BEFORE DELETE row-level triggers. Each fires
4740        // with NEW=None / OLD=pre-delete row; RETURN OLD (or NEW)
4741        // = proceed, RETURN NULL = skip the row entirely. The
4742        // filter must run BEFORE the FK cascade plan so cascaded
4743        // child rows track the trigger's skip-decision on the
4744        // parent.
4745        // v7.12.7 — embedded SQL queue.
4746        let mut deferred_embedded: Vec<triggers::DeferredEmbeddedStmt> = Vec::new();
4747        if !before_delete_triggers.is_empty() {
4748            let mut filtered_positions: Vec<usize> = Vec::with_capacity(positions.len());
4749            let mut filtered_old_rows: Vec<Vec<Value>> = Vec::with_capacity(to_delete_rows.len());
4750            for (pos, old_vals) in positions.iter().zip(to_delete_rows.iter()) {
4751                let old_row = Row::new(old_vals.clone());
4752                let mut cancel_this = false;
4753                for fd in &before_delete_triggers {
4754                    let (outcome, deferred) = triggers::fire_row_trigger(
4755                        fd,
4756                        None,
4757                        Some(&old_row),
4758                        &stmt.table,
4759                        &schema_cols,
4760                        &[],
4761                        trigger_session_cfg.as_deref(),
4762                        false,
4763                    )
4764                    .map_err(|e| {
4765                        EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}")))
4766                    })?;
4767                    deferred_embedded.extend(deferred);
4768                    if matches!(outcome, triggers::TriggerOutcome::Skip) {
4769                        cancel_this = true;
4770                        break;
4771                    }
4772                }
4773                if !cancel_this {
4774                    filtered_positions.push(*pos);
4775                    filtered_old_rows.push(old_vals.clone());
4776                }
4777            }
4778            positions = filtered_positions;
4779            to_delete_rows = filtered_old_rows;
4780        }
4781        let cascade_plan = plan_fk_parent_deletions(
4782            self.active_catalog(),
4783            &stmt.table,
4784            &positions,
4785            &to_delete_rows,
4786        )?;
4787        // Stage 3a — apply each FK child step (SET NULL / SET
4788        // DEFAULT / CASCADE delete) before deleting the parent.
4789        // The plan is already ordered: nulls/defaults first, then
4790        // cascade deletes (so a row mutated and later deleted
4791        // surfaces as deleted — though v7.6.5 doesn't produce
4792        // that overlap today).
4793        for step in &cascade_plan {
4794            apply_fk_child_step(self.active_catalog_mut(), step)?;
4795        }
4796        // Stage 3b — actually delete the original target rows.
4797        let table = self
4798            .active_catalog_mut()
4799            .get_mut(&stmt.table)
4800            .ok_or_else(|| {
4801                EngineError::Storage(StorageError::TableNotFound {
4802                    name: stmt.table.clone(),
4803                })
4804            })?;
4805        let affected = table.delete_rows(&positions) + cold_shadow_count;
4806        let _ = table;
4807        // v7.12.5 — AFTER DELETE row-level triggers fire post-write
4808        // with NEW=None / OLD=pre-delete row (each from the
4809        // already-snapshotted to_delete_rows). Return value is
4810        // ignored (matches PG AFTER semantics).
4811        if !after_delete_triggers.is_empty() {
4812            for old_vals in &to_delete_rows {
4813                let old_row = Row::new(old_vals.clone());
4814                for fd in &after_delete_triggers {
4815                    let (_outcome, deferred) = triggers::fire_row_trigger(
4816                        fd,
4817                        None,
4818                        Some(&old_row),
4819                        &stmt.table,
4820                        &schema_cols,
4821                        &[],
4822                        trigger_session_cfg.as_deref(),
4823                        true,
4824                    )
4825                    .map_err(|e| {
4826                        EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}")))
4827                    })?;
4828                    deferred_embedded.extend(deferred);
4829                }
4830            }
4831        }
4832        // v7.12.7 — drain trigger-emitted embedded SQL for this DELETE.
4833        self.execute_deferred_trigger_stmts(deferred_embedded, cancel)?;
4834        // v6.2.1 — auto-analyze modified-row tracking for DELETE.
4835        if !self.in_transaction() && affected > 0 {
4836            self.statistics
4837                .record_modifications(&stmt.table, affected as u64);
4838        }
4839        // v7.9.4 — RETURNING projection over the soon-to-be-gone
4840        // rows. `to_delete_rows` was snapshotted in stage 1 before
4841        // mutation, so the projection sees the pre-delete state
4842        // (matches PG semantics: DELETE RETURNING returns the row
4843        // as it was just before removal).
4844        if let Some(items) = &stmt.returning {
4845            return self.build_returning_rows(&stmt.table, items, to_delete_rows);
4846        }
4847        Ok(QueryResult::CommandOk {
4848            affected,
4849            modified_catalog: !self.in_transaction(),
4850        })
4851    }
4852
4853    /// `SHOW TABLES` — one row per table in the active catalog.
4854    /// Column name is `name` so result-set consumers can downstream
4855    /// `SELECT name FROM ...` style logic if needed.
4856    /// v4.26: `EXPLAIN [ANALYZE] <select>`. Returns a single-column
4857    /// `QUERY PLAN` text table — first line names the top operator
4858    /// (Scan / Aggregate / Window / etc.), indented children list
4859    /// FROM joins, WHERE filters, ORDER BY / LIMIT, projection
4860    /// shape, and any active index hits. `ANALYZE` execs the inner
4861    /// SELECT and appends actual-row + elapsed-micros annotations.
4862    #[allow(clippy::format_push_string)]
4863    fn exec_explain(
4864        &self,
4865        e: &spg_sql::ast::ExplainStatement,
4866        cancel: CancelToken<'_>,
4867    ) -> Result<QueryResult, EngineError> {
4868        let mut lines = Vec::<String>::new();
4869        explain_select(&e.inner, self, 0, &mut lines);
4870        if e.suggest {
4871            // v6.8.3 — index advisor. Walks the SELECT's FROM
4872            // tables + WHERE column refs; for each (table, column)
4873            // pair that lacks an index, append a SUGGEST line with
4874            // a copy-pastable `CREATE INDEX` statement. This is a
4875            // pure-syntax heuristic — no cardinality estimation —
4876            // matching the v6.8.3 design intent of "tell the
4877            // operator where indexes are missing", not "give the
4878            // mathematically optimal index set".
4879            let suggestions = build_index_suggestions(&e.inner, self);
4880            for s in suggestions {
4881                lines.push(s);
4882            }
4883        } else if e.analyze {
4884            // v6.2.4 — EXPLAIN ANALYZE annotates each operator line
4885            // with `(rows=N)` where the row count is computable
4886            // without re-executing the full query:
4887            //   - Top-level operator (first non-indented line):
4888            //     rows = final result.len()
4889            //   - "From: <table> [full scan]" lines: rows =
4890            //     table.rows().len() (catalog read; no execution)
4891            //   - "From: <table> [index seek]": indeterminate —
4892            //     the index step would need re-execution; v6.2.5
4893            //     adds per-operator wall-clock + hot/cold rows
4894            //     instrumentation that makes this concrete.
4895            //   - Everything else: marked `(—)` so the surface
4896            //     stays well-defined without silently dropping
4897            //     stats. v6.2.5 fills in via inline executor
4898            //     instrumentation.
4899            // Total elapsed lands on a trailing `Total: …` line.
4900            let started = self.clock.map(|f| f());
4901            let exec = self.exec_select_cancel(&e.inner, cancel)?;
4902            let elapsed_micros = match (self.clock, started) {
4903                (Some(f), Some(s)) => Some(f().saturating_sub(s)),
4904                _ => None,
4905            };
4906            let row_count = if let QueryResult::Rows { rows, .. } = &exec {
4907                rows.len()
4908            } else {
4909                0
4910            };
4911            annotate_explain_lines(&mut lines, row_count, self);
4912            let mut total = alloc::format!("Total: rows={row_count}");
4913            if let Some(us) = elapsed_micros {
4914                total.push_str(&alloc::format!(" elapsed={us}us"));
4915            }
4916            lines.push(total);
4917        }
4918        let columns = alloc::vec![ColumnSchema::new("QUERY PLAN", DataType::Text, false)];
4919        let rows: Vec<Row> = lines
4920            .into_iter()
4921            .map(|l| Row::new(alloc::vec![Value::Text(l)]))
4922            .collect();
4923        Ok(QueryResult::Rows { columns, rows })
4924    }
4925
4926    fn exec_show_tables(&self) -> QueryResult {
4927        let columns = alloc::vec![ColumnSchema::new("name", DataType::Text, false)];
4928        let rows: Vec<Row> = self
4929            .active_catalog()
4930            .table_names()
4931            .into_iter()
4932            .map(|n| Row::new(alloc::vec![Value::Text(n)]))
4933            .collect();
4934        QueryResult::Rows { columns, rows }
4935    }
4936
4937    /// v7.17.0 Phase 3.P0-59 — `SHOW CREATE TABLE <t>`. Synthesise
4938    /// a minimal MySQL-flavoured CREATE TABLE DDL from the
4939    /// catalog's TableSchema so mysqldump round-trips load against
4940    /// SPG without splitting init scripts.
4941    fn exec_show_create_table(&self, name: &str) -> Result<QueryResult, EngineError> {
4942        let t = self.active_catalog().get(name).ok_or_else(|| {
4943            EngineError::Storage(StorageError::TableNotFound { name: name.into() })
4944        })?;
4945        let cols: Vec<String> = t
4946            .schema()
4947            .columns
4948            .iter()
4949            .map(|c| {
4950                let ty = render_data_type(c.ty);
4951                let nullable = if c.nullable { "" } else { " NOT NULL" };
4952                alloc::format!("  `{}` {}{}", c.name, ty, nullable)
4953            })
4954            .collect();
4955        let mut body = cols.join(",\n");
4956        // Append UNIQUE / PRIMARY KEY clauses.
4957        for uc in &t.schema().uniqueness_constraints {
4958            let col_names: Vec<String> = uc
4959                .columns
4960                .iter()
4961                .map(|&p| {
4962                    t.schema().columns.get(p).map_or_else(
4963                        || alloc::format!("col{p}"),
4964                        |c| alloc::format!("`{}`", c.name),
4965                    )
4966                })
4967                .collect();
4968            let kw = if uc.is_primary_key {
4969                "PRIMARY KEY"
4970            } else {
4971                "UNIQUE KEY"
4972            };
4973            body.push_str(",\n  ");
4974            body.push_str(&alloc::format!("{kw} ({})", col_names.join(", ")));
4975        }
4976        // Foreign keys.
4977        for fk in &t.schema().foreign_keys {
4978            let local: Vec<String> = fk
4979                .local_columns
4980                .iter()
4981                .map(|&p| {
4982                    t.schema().columns.get(p).map_or_else(
4983                        || alloc::format!("col{p}"),
4984                        |c| alloc::format!("`{}`", c.name),
4985                    )
4986                })
4987                .collect();
4988            let parent_cols: Vec<String> =
4989                if let Some(parent) = self.active_catalog().get(&fk.parent_table) {
4990                    fk.parent_columns
4991                        .iter()
4992                        .map(|&p| {
4993                            parent.schema().columns.get(p).map_or_else(
4994                                || alloc::format!("col{p}"),
4995                                |c| alloc::format!("`{}`", c.name),
4996                            )
4997                        })
4998                        .collect()
4999                } else {
5000                    fk.parent_columns
5001                        .iter()
5002                        .map(|p| alloc::format!("col{p}"))
5003                        .collect()
5004                };
5005            body.push_str(",\n  ");
5006            body.push_str(&alloc::format!(
5007                "FOREIGN KEY ({}) REFERENCES `{}` ({})",
5008                local.join(", "),
5009                fk.parent_table,
5010                parent_cols.join(", ")
5011            ));
5012        }
5013        let ddl = alloc::format!(
5014            "CREATE TABLE `{}` (\n{}\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
5015            name,
5016            body
5017        );
5018        let columns = alloc::vec![
5019            ColumnSchema::new("Table", DataType::Text, false),
5020            ColumnSchema::new("Create Table", DataType::Text, false),
5021        ];
5022        let rows = alloc::vec![Row::new(alloc::vec![
5023            Value::Text(name.into()),
5024            Value::Text(ddl),
5025        ])];
5026        Ok(QueryResult::Rows { columns, rows })
5027    }
5028
5029    /// v7.17.0 Phase 3.P0-60 — `SHOW INDEXES FROM <t>`. MySQL
5030    /// surface returns one row per (index × column) with 14
5031    /// columns; v7.17 ships the columns admin probes actually
5032    /// filter on: Table, Non_unique, Key_name, Seq_in_index,
5033    /// Column_name, Null, Index_type.
5034    fn exec_show_indexes(&self, name: &str) -> Result<QueryResult, EngineError> {
5035        let t = self.active_catalog().get(name).ok_or_else(|| {
5036            EngineError::Storage(StorageError::TableNotFound { name: name.into() })
5037        })?;
5038        let columns = alloc::vec![
5039            ColumnSchema::new("Table", DataType::Text, false),
5040            ColumnSchema::new("Non_unique", DataType::Int, false),
5041            ColumnSchema::new("Key_name", DataType::Text, false),
5042            ColumnSchema::new("Seq_in_index", DataType::Int, false),
5043            ColumnSchema::new("Column_name", DataType::Text, false),
5044            ColumnSchema::new("Null", DataType::Text, false),
5045            ColumnSchema::new("Index_type", DataType::Text, false),
5046        ];
5047        let mut rows: Vec<Row> = Vec::new();
5048        for idx in t.indices() {
5049            let col = t
5050                .schema()
5051                .columns
5052                .get(idx.column_position)
5053                .map_or("?".into(), |c| c.name.clone());
5054            let nullable = t
5055                .schema()
5056                .columns
5057                .get(idx.column_position)
5058                .map_or(true, |c| c.nullable);
5059            rows.push(Row::new(alloc::vec![
5060                Value::Text(name.into()),
5061                Value::Int(i32::from(!idx.is_unique)),
5062                Value::Text(idx.name.clone()),
5063                Value::Int(1),
5064                Value::Text(col),
5065                Value::Text(if nullable {
5066                    "YES".into()
5067                } else {
5068                    String::new()
5069                }),
5070                Value::Text("BTREE".into()),
5071            ]));
5072        }
5073        Ok(QueryResult::Rows { columns, rows })
5074    }
5075
5076    /// v7.17.0 Phase 3.P0-61 — `SHOW STATUS`. Returns canonical
5077    /// MySQL server-status counters (2-column `(Variable_name,
5078    /// Value)`).
5079    fn exec_show_status(&self) -> QueryResult {
5080        let columns = alloc::vec![
5081            ColumnSchema::new("Variable_name", DataType::Text, false),
5082            ColumnSchema::new("Value", DataType::Text, false),
5083        ];
5084        let pairs: &[(&str, &str)] = &[
5085            ("Uptime", "0"),
5086            ("Threads_connected", "1"),
5087            ("Threads_running", "1"),
5088            ("Questions", "0"),
5089            ("Slow_queries", "0"),
5090            ("Opened_tables", "0"),
5091            ("Innodb_buffer_pool_pages_total", "0"),
5092        ];
5093        let rows: Vec<Row> = pairs
5094            .iter()
5095            .map(|(k, v)| {
5096                Row::new(alloc::vec![
5097                    Value::Text((*k).into()),
5098                    Value::Text((*v).into())
5099                ])
5100            })
5101            .collect();
5102        QueryResult::Rows { columns, rows }
5103    }
5104
5105    /// v7.17.0 Phase 3.P0-61 — `SHOW VARIABLES`. Returns server-side
5106    /// variables MySQL/MariaDB clients probe at connect time.
5107    fn exec_show_variables(&self) -> QueryResult {
5108        let columns = alloc::vec![
5109            ColumnSchema::new("Variable_name", DataType::Text, false),
5110            ColumnSchema::new("Value", DataType::Text, false),
5111        ];
5112        let mut rows: Vec<Row> = Vec::new();
5113        let canonical: &[(&str, &str)] = &[
5114            ("version", "8.0.35-spg"),
5115            ("version_comment", "SPG dual-stack engine"),
5116            ("character_set_server", "utf8mb4"),
5117            ("collation_server", "utf8mb4_0900_ai_ci"),
5118            ("max_allowed_packet", "67108864"),
5119            ("autocommit", "ON"),
5120            ("sql_mode", "STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION"),
5121            ("time_zone", "SYSTEM"),
5122            ("transaction_isolation", "REPEATABLE-READ"),
5123        ];
5124        for &(k, v) in canonical {
5125            rows.push(Row::new(alloc::vec![
5126                Value::Text(k.into()),
5127                Value::Text(v.into()),
5128            ]));
5129        }
5130        // Session-set parameters surface here too.
5131        for (k, v) in &self.session_params {
5132            if !canonical.iter().any(|(n, _)| (*n).eq_ignore_ascii_case(k)) {
5133                rows.push(Row::new(alloc::vec![
5134                    Value::Text(k.clone()),
5135                    Value::Text(v.clone()),
5136                ]));
5137            }
5138        }
5139        QueryResult::Rows { columns, rows }
5140    }
5141
5142    /// v7.17.0 Phase 3.P0-62 — `SHOW PROCESSLIST`. SPG is
5143    /// single-process so the surface returns one synthetic row
5144    /// describing the current connection (Id, User, Host, db,
5145    /// Command, Time, State, Info).
5146    fn exec_show_processlist(&self) -> QueryResult {
5147        let columns = alloc::vec![
5148            ColumnSchema::new("Id", DataType::Int, false),
5149            ColumnSchema::new("User", DataType::Text, false),
5150            ColumnSchema::new("Host", DataType::Text, false),
5151            ColumnSchema::new("db", DataType::Text, true),
5152            ColumnSchema::new("Command", DataType::Text, false),
5153            ColumnSchema::new("Time", DataType::Int, false),
5154            ColumnSchema::new("State", DataType::Text, true),
5155            ColumnSchema::new("Info", DataType::Text, true),
5156        ];
5157        let rows = alloc::vec![Row::new(alloc::vec![
5158            Value::Int(1),
5159            Value::Text("postgres".into()),
5160            Value::Text("localhost".into()),
5161            Value::Text("postgres".into()),
5162            Value::Text("Query".into()),
5163            Value::Int(0),
5164            Value::Text("executing".into()),
5165            Value::Text("SHOW PROCESSLIST".into()),
5166        ])];
5167        QueryResult::Rows { columns, rows }
5168    }
5169
5170    /// v7.17.0 Phase 3.P0-58 — `SHOW DATABASES` / `SHOW SCHEMAS`.
5171    /// SPG is single-database so the result is the canonical MySQL
5172    /// set every mysql/MariaDB client expects at connect time:
5173    /// `information_schema`, `mysql`, `performance_schema`, `sys`,
5174    /// plus a `postgres` slot so dual-stack callers find their
5175    /// PG-compatible database too.
5176    fn exec_show_databases(&self) -> QueryResult {
5177        let columns = alloc::vec![ColumnSchema::new("Database", DataType::Text, false)];
5178        let names = [
5179            "information_schema",
5180            "mysql",
5181            "performance_schema",
5182            "sys",
5183            "postgres",
5184        ];
5185        let rows: Vec<Row> = names
5186            .iter()
5187            .map(|n| Row::new(alloc::vec![Value::Text((*n).into())]))
5188            .collect();
5189        QueryResult::Rows { columns, rows }
5190    }
5191
5192    /// `SHOW COLUMNS FROM <table>` — one row per column with the
5193    /// declared name, SQL type rendering, and nullability flag.
5194    fn exec_show_columns(&self, table_name: &str) -> Result<QueryResult, EngineError> {
5195        let table =
5196            self.active_catalog()
5197                .get(table_name)
5198                .ok_or_else(|| StorageError::TableNotFound {
5199                    name: table_name.into(),
5200                })?;
5201        let columns = alloc::vec![
5202            ColumnSchema::new("name", DataType::Text, false),
5203            ColumnSchema::new("type", DataType::Text, false),
5204            ColumnSchema::new("nullable", DataType::Bool, false),
5205        ];
5206        let rows: Vec<Row> = table
5207            .schema()
5208            .columns
5209            .iter()
5210            .map(|c| {
5211                Row::new(alloc::vec![
5212                    Value::Text(c.name.clone()),
5213                    Value::Text(alloc::format!("{}", c.ty)),
5214                    Value::Bool(c.nullable),
5215                ])
5216            })
5217            .collect();
5218        Ok(QueryResult::Rows { columns, rows })
5219    }
5220
5221    fn exec_begin(&mut self) -> Result<QueryResult, EngineError> {
5222        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5223        if self.tx_catalogs.contains_key(&tx_id) {
5224            return Err(EngineError::TransactionAlreadyOpen);
5225        }
5226        self.tx_catalogs.insert(
5227            tx_id,
5228            TxState {
5229                catalog: self.catalog.clone(),
5230                savepoints: Vec::new(),
5231            },
5232        );
5233        Ok(QueryResult::CommandOk {
5234            affected: 0,
5235            modified_catalog: false,
5236        })
5237    }
5238
5239    fn exec_commit(&mut self) -> Result<QueryResult, EngineError> {
5240        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5241        let state = self
5242            .tx_catalogs
5243            .remove(&tx_id)
5244            .ok_or(EngineError::NoActiveTransaction)?;
5245        self.catalog = state.catalog;
5246        // All savepoints become permanent at COMMIT and the stack
5247        // resets for the next TX (`state.savepoints` is discarded with
5248        // `state`).
5249        Ok(QueryResult::CommandOk {
5250            affected: 0,
5251            modified_catalog: true,
5252        })
5253    }
5254
5255    fn exec_rollback(&mut self) -> Result<QueryResult, EngineError> {
5256        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5257        if self.tx_catalogs.remove(&tx_id).is_none() {
5258            return Err(EngineError::NoActiveTransaction);
5259        }
5260        // savepoints discarded with the TxState
5261        Ok(QueryResult::CommandOk {
5262            affected: 0,
5263            modified_catalog: false,
5264        })
5265    }
5266
5267    fn exec_savepoint(&mut self, name: String) -> Result<QueryResult, EngineError> {
5268        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5269        let state = self
5270            .tx_catalogs
5271            .get_mut(&tx_id)
5272            .ok_or(EngineError::NoActiveTransaction)?;
5273        // PG re-uses an existing savepoint name by dropping the older
5274        // entry and pushing a fresh one — match that behaviour so
5275        // application code can `SAVEPOINT sp; ...; SAVEPOINT sp` freely.
5276        state.savepoints.retain(|(n, _)| n != &name);
5277        let snapshot = state.catalog.clone();
5278        state.savepoints.push((name, snapshot));
5279        Ok(QueryResult::CommandOk {
5280            affected: 0,
5281            modified_catalog: false,
5282        })
5283    }
5284
5285    fn exec_rollback_to_savepoint(&mut self, name: &str) -> Result<QueryResult, EngineError> {
5286        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5287        let state = self
5288            .tx_catalogs
5289            .get_mut(&tx_id)
5290            .ok_or(EngineError::NoActiveTransaction)?;
5291        let pos = state
5292            .savepoints
5293            .iter()
5294            .rposition(|(n, _)| n == name)
5295            .ok_or_else(|| {
5296                EngineError::Unsupported(alloc::format!("savepoint not found: {name}"))
5297            })?;
5298        // The savepoint stays on the stack (PG semantics): a later
5299        // `RELEASE` or further `ROLLBACK TO` is still allowed. Everything
5300        // after it is discarded.
5301        let snapshot = state.savepoints[pos].1.clone();
5302        state.savepoints.truncate(pos + 1);
5303        state.catalog = snapshot;
5304        Ok(QueryResult::CommandOk {
5305            affected: 0,
5306            modified_catalog: false,
5307        })
5308    }
5309
5310    fn exec_release_savepoint(&mut self, name: &str) -> Result<QueryResult, EngineError> {
5311        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5312        let state = self
5313            .tx_catalogs
5314            .get_mut(&tx_id)
5315            .ok_or(EngineError::NoActiveTransaction)?;
5316        let pos = state
5317            .savepoints
5318            .iter()
5319            .rposition(|(n, _)| n == name)
5320            .ok_or_else(|| {
5321                EngineError::Unsupported(alloc::format!("savepoint not found: {name}"))
5322            })?;
5323        // RELEASE keeps the work since the savepoint, just discards the
5324        // bookmark plus everything nested under it.
5325        state.savepoints.truncate(pos);
5326        Ok(QueryResult::CommandOk {
5327            affected: 0,
5328            modified_catalog: false,
5329        })
5330    }
5331
5332    /// v6.0.4 — synchronous `ALTER INDEX <name> REBUILD [WITH
5333    /// (encoding = …)]`. Walks every table in the active catalog
5334    /// looking for an index matching `stmt.name`, then delegates the
5335    /// rebuild (including any encoding switch) to
5336    /// `Table::rebuild_nsw_index`. The "live" non-blocking
5337    /// optimisation is v6.0.4.1 / v6.1.x territory.
5338    /// v6.7.2 — `ALTER TABLE t SET hot_tier_bytes = X`. Dispatch
5339    /// arm. Currently the only setting is `hot_tier_bytes`; later
5340    /// v6.7.x can extend `AlterTableTarget` without touching this
5341    /// arm structure.
5342    fn exec_alter_table(
5343        &mut self,
5344        s: spg_sql::ast::AlterTableStatement,
5345    ) -> Result<QueryResult, EngineError> {
5346        // v7.13.2 — mailrs round-6 S1: apply each subaction in order.
5347        // On first error the statement aborts; subactions already
5348        // applied stay (no transactional rollback in v7.13 — wrap in
5349        // BEGIN/COMMIT if atomicity matters).
5350        let table_name = s.name.clone();
5351        for target in s.targets {
5352            self.exec_alter_table_subaction(&table_name, target)?;
5353        }
5354        Ok(QueryResult::CommandOk {
5355            affected: 0,
5356            modified_catalog: !self.in_transaction(),
5357        })
5358    }
5359
5360    fn exec_alter_table_subaction(
5361        &mut self,
5362        table_name_outer: &str,
5363        target: spg_sql::ast::AlterTableTarget,
5364    ) -> Result<(), EngineError> {
5365        // Inner helper retains the s.name closure shape; alias to `s`
5366        // for minimal diff against the v7.13.0 body.
5367        struct S<'a> {
5368            name: &'a str,
5369        }
5370        let s = S {
5371            name: table_name_outer,
5372        };
5373        match target {
5374            spg_sql::ast::AlterTableTarget::SetHotTierBytes(n) => {
5375                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5376                    EngineError::Storage(StorageError::TableNotFound {
5377                        name: s.name.into(),
5378                    })
5379                })?;
5380                table.schema_mut().hot_tier_bytes = Some(n);
5381            }
5382            spg_sql::ast::AlterTableTarget::AddForeignKey(fk) => {
5383                // v7.6.8 — resolve FK against the live catalog first
5384                // (validates parent table, columns, indices). Then
5385                // verify every existing row in the child table
5386                // satisfies the new constraint. Then install it.
5387                let cols_snapshot = self
5388                    .active_catalog()
5389                    .get(s.name)
5390                    .ok_or_else(|| {
5391                        EngineError::Storage(StorageError::TableNotFound {
5392                            name: s.name.into(),
5393                        })
5394                    })?
5395                    .schema()
5396                    .columns
5397                    .clone();
5398                let storage_fk =
5399                    resolve_foreign_key(s.name, &cols_snapshot, fk, self.active_catalog())?;
5400                // Verify existing rows. Treat them as a virtual
5401                // INSERT batch — reusing the v7.6.2 enforce helper.
5402                let existing_rows: Vec<Vec<Value>> = self
5403                    .active_catalog()
5404                    .get(s.name)
5405                    .expect("checked above")
5406                    .rows()
5407                    .iter()
5408                    .map(|r| r.values.clone())
5409                    .collect();
5410                enforce_fk_inserts(
5411                    self.active_catalog(),
5412                    s.name,
5413                    core::slice::from_ref(&storage_fk),
5414                    &existing_rows,
5415                )?;
5416                // Reject duplicate constraint name.
5417                let table = self
5418                    .active_catalog_mut()
5419                    .get_mut(s.name)
5420                    .expect("checked above");
5421                if let Some(name) = &storage_fk.name
5422                    && table
5423                        .schema()
5424                        .foreign_keys
5425                        .iter()
5426                        .any(|f| f.name.as_ref() == Some(name))
5427                {
5428                    return Err(EngineError::Unsupported(alloc::format!(
5429                        "ALTER TABLE ADD CONSTRAINT: a constraint named {name:?} already exists"
5430                    )));
5431                }
5432                table.schema_mut().foreign_keys.push(storage_fk);
5433            }
5434            spg_sql::ast::AlterTableTarget::DropForeignKey { name, if_exists } => {
5435                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5436                    EngineError::Storage(StorageError::TableNotFound {
5437                        name: s.name.into(),
5438                    })
5439                })?;
5440                let fks = &mut table.schema_mut().foreign_keys;
5441                let before = fks.len();
5442                fks.retain(|f| f.name.as_ref() != Some(&name));
5443                if fks.len() == before && !if_exists {
5444                    return Err(EngineError::Unsupported(alloc::format!(
5445                        "ALTER TABLE DROP CONSTRAINT: no FK named {name:?} on {:?}",
5446                        s.name
5447                    )));
5448                }
5449                // v7.13.2 mailrs round-6 S7: IF EXISTS silences the miss.
5450            }
5451            spg_sql::ast::AlterTableTarget::AddColumn {
5452                column,
5453                if_not_exists,
5454            } => {
5455                // v7.13.0 — mailrs round-5 G1. Append-only column add
5456                // with back-fill of the DEFAULT (or NULL) into every
5457                // existing row. Column positions don't shift, so we
5458                // skip index rebuild.
5459                let clock = self.clock;
5460                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5461                    EngineError::Storage(StorageError::TableNotFound {
5462                        name: s.name.into(),
5463                    })
5464                })?;
5465                if table
5466                    .schema()
5467                    .columns
5468                    .iter()
5469                    .any(|c| c.name.eq_ignore_ascii_case(&column.name))
5470                {
5471                    if if_not_exists {
5472                        return Ok(());
5473                    }
5474                    return Err(EngineError::Unsupported(alloc::format!(
5475                        "ALTER TABLE ADD COLUMN: column {:?} already exists on {:?}",
5476                        column.name,
5477                        s.name
5478                    )));
5479                }
5480                let col_name = column.name.clone();
5481                let nullable = column.nullable;
5482                let has_default = column.default.is_some() || column.auto_increment;
5483                let col_schema = column_def_to_schema(column)?;
5484                let row_count = table.row_count();
5485                // Compute the back-fill value. Literal / runtime DEFAULT
5486                // funnels through the same resolver that INSERT uses
5487                // (v7.9.21 `resolve_column_default_free`). NULL when
5488                // the column is nullable and has no DEFAULT. NOT NULL
5489                // without DEFAULT errors when the table has existing
5490                // rows — same as PG.
5491                let fill_value: Value = if has_default || col_schema.runtime_default.is_some() {
5492                    resolve_column_default_free(&col_schema, clock)?
5493                } else if nullable || row_count == 0 {
5494                    Value::Null
5495                } else {
5496                    return Err(EngineError::Unsupported(alloc::format!(
5497                        "ALTER TABLE ADD COLUMN {col_name:?}: NOT NULL column requires DEFAULT \
5498                         when the table has existing rows"
5499                    )));
5500                };
5501                table.add_column(col_schema, fill_value);
5502            }
5503            spg_sql::ast::AlterTableTarget::AlterColumnType {
5504                column,
5505                new_type,
5506                using,
5507            } => {
5508                // v7.13.0 — mailrs round-5 G8. Re-evaluate each
5509                // row's column value (either through the USING
5510                // expression if supplied, or as a direct CAST of
5511                // the existing value) and re-coerce to the new
5512                // type. Indices on the column get rebuilt.
5513                let new_data_type = column_type_to_data_type(new_type);
5514                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5515                    EngineError::Storage(StorageError::TableNotFound {
5516                        name: s.name.into(),
5517                    })
5518                })?;
5519                let col_pos = table
5520                    .schema()
5521                    .columns
5522                    .iter()
5523                    .position(|c| c.name.eq_ignore_ascii_case(&column))
5524                    .ok_or_else(|| {
5525                        EngineError::Unsupported(alloc::format!(
5526                            "ALTER COLUMN TYPE: column {column:?} not found on {:?}",
5527                            s.name
5528                        ))
5529                    })?;
5530                let schema_cols = table.schema().columns.clone();
5531                let ctx = eval::EvalContext::new(&schema_cols, None);
5532                let mut new_values: alloc::vec::Vec<Value> =
5533                    alloc::vec::Vec::with_capacity(table.row_count());
5534                for row in table.rows().iter() {
5535                    let raw = match &using {
5536                        Some(expr) => eval::eval_expr(expr, row, &ctx).map_err(|e| {
5537                            EngineError::Unsupported(alloc::format!(
5538                                "ALTER COLUMN TYPE: USING expression failed: {e:?}"
5539                            ))
5540                        })?,
5541                        None => row.values.get(col_pos).cloned().unwrap_or(Value::Null),
5542                    };
5543                    let coerced = coerce_value(raw, new_data_type, &column, col_pos)?;
5544                    new_values.push(coerced);
5545                }
5546                table.schema_mut().columns[col_pos].ty = new_data_type;
5547                for (i, v) in new_values.into_iter().enumerate() {
5548                    let mut row_values = table
5549                        .rows()
5550                        .get(i)
5551                        .expect("bounds-checked above")
5552                        .values
5553                        .clone();
5554                    row_values[col_pos] = v;
5555                    table.update_row(i, row_values)?;
5556                }
5557            }
5558            spg_sql::ast::AlterTableTarget::AddTableConstraint(tc) => {
5559                // v7.14.0 — pg_dump emits PKs as a separate
5560                // ALTER TABLE ADD CONSTRAINT post-CREATE-TABLE.
5561                // For PRIMARY KEY / UNIQUE, install a UC entry
5562                // and the implicit BTree index on the leading
5563                // column. CHECK: append predicate to schema.
5564                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5565                    EngineError::Storage(StorageError::TableNotFound {
5566                        name: s.name.into(),
5567                    })
5568                })?;
5569                let is_pk = matches!(tc, spg_sql::ast::TableConstraint::PrimaryKey { .. });
5570                // v7.22 (mailrs round-13 gap 6) — carry the parsed
5571                // NULLS NOT DISTINCT flag through the ALTER path;
5572                // it was hardcoded false here while the CREATE
5573                // TABLE path honoured it since v7.13.
5574                let nnd = matches!(
5575                    tc,
5576                    spg_sql::ast::TableConstraint::Unique {
5577                        nulls_not_distinct: true,
5578                        ..
5579                    }
5580                );
5581                match tc {
5582                    spg_sql::ast::TableConstraint::PrimaryKey { columns, .. }
5583                    | spg_sql::ast::TableConstraint::Unique { columns, .. } => {
5584                        let positions: Vec<usize> = columns
5585                            .iter()
5586                            .map(|c| {
5587                                table
5588                                    .schema()
5589                                    .columns
5590                                    .iter()
5591                                    .position(|sc| sc.name.eq_ignore_ascii_case(c))
5592                                    .ok_or_else(|| {
5593                                        EngineError::Unsupported(alloc::format!(
5594                                            "ALTER TABLE ADD CONSTRAINT: column {c:?} not found on {:?}",
5595                                            s.name
5596                                        ))
5597                                    })
5598                            })
5599                            .collect::<Result<Vec<_>, _>>()?;
5600                        // Skip if an equivalent UC is already there
5601                        // (idempotent — pg_dump's PK + a prior inline
5602                        // PK shouldn't double-install).
5603                        let already = table
5604                            .schema()
5605                            .uniqueness_constraints
5606                            .iter()
5607                            .any(|u| u.columns == positions);
5608                        if !already {
5609                            table.schema_mut().uniqueness_constraints.push(
5610                                spg_storage::UniquenessConstraint {
5611                                    is_primary_key: is_pk,
5612                                    columns: positions.clone(),
5613                                    nulls_not_distinct: nnd,
5614                                },
5615                            );
5616                            // PK implies NOT NULL on referenced cols.
5617                            if is_pk {
5618                                for p in &positions {
5619                                    if let Some(c) = table.schema_mut().columns.get_mut(*p) {
5620                                        c.nullable = false;
5621                                    }
5622                                }
5623                            }
5624                            // Add a BTree index on the leading
5625                            // column for INSERT-side enforcement.
5626                            let leading = &columns[0];
5627                            let already_idx = table.indices().iter().any(|idx| {
5628                                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
5629                                    && table.schema().columns[idx.column_position].name == *leading
5630                            });
5631                            if !already_idx {
5632                                let suffix = if is_pk { "pkey" } else { "key" };
5633                                let idx_name = alloc::format!("{}_{leading}_{suffix}", s.name);
5634                                let _ = table.add_index(idx_name, leading);
5635                            }
5636                        }
5637                    }
5638                    spg_sql::ast::TableConstraint::Check { expr, .. } => {
5639                        table.schema_mut().checks.push(alloc::format!("{expr}"));
5640                    }
5641                    spg_sql::ast::TableConstraint::Index { name, columns } => {
5642                        // v7.15.0 — ALTER TABLE ADD KEY (cols).
5643                        // mysqldump occasionally emits this
5644                        // post-CREATE-TABLE shape; build a BTree
5645                        // on the leading column using the
5646                        // user-supplied or synthesised name.
5647                        let leading = &columns[0];
5648                        let already_idx = table.indices().iter().any(|idx| {
5649                            matches!(idx.kind, spg_storage::IndexKind::BTree(_))
5650                                && table.schema().columns[idx.column_position].name == *leading
5651                        });
5652                        if !already_idx {
5653                            let idx_name = name
5654                                .clone()
5655                                .unwrap_or_else(|| alloc::format!("{}_{leading}_idx", s.name));
5656                            let _ = table.add_index(idx_name, leading);
5657                        }
5658                    }
5659                    spg_sql::ast::TableConstraint::FulltextIndex { name, columns } => {
5660                        // v7.17.0 Phase 2.2 — ALTER TABLE ADD
5661                        // FULLTEXT KEY (cols). Builds one
5662                        // fulltext-GIN per named column so MATCH
5663                        // AGAINST gets a real inverted index.
5664                        // Multi-column declarations expand to
5665                        // per-column GINs (the leading column
5666                        // drives MATCH AGAINST planning).
5667                        for (k, col) in columns.iter().enumerate() {
5668                            let already_idx = table.indices().iter().any(|idx| {
5669                                matches!(idx.kind, spg_storage::IndexKind::GinFulltext(_))
5670                                    && table.schema().columns[idx.column_position].name == *col
5671                            });
5672                            if already_idx {
5673                                continue;
5674                            }
5675                            let idx_name = match (&name, columns.len(), k) {
5676                                (Some(n), 1, _) => n.clone(),
5677                                (Some(n), _, k) => alloc::format!("{n}_{k}"),
5678                                (None, _, _) => {
5679                                    alloc::format!("{}_{col}_ftidx", s.name)
5680                                }
5681                            };
5682                            let _ = table.add_gin_fulltext_index(idx_name, col);
5683                        }
5684                    }
5685                }
5686            }
5687            spg_sql::ast::AlterTableTarget::DropColumn {
5688                column,
5689                if_exists,
5690                cascade,
5691            } => {
5692                // v7.13.3 — mailrs round-7 S8. Remove the column +
5693                // every row's value at that position; drop any index
5694                // on the column. RESTRICT (default) rejects when an
5695                // FK on this table or partial-index predicate
5696                // references the column; CASCADE removes those
5697                // dependents first.
5698                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5699                    EngineError::Storage(StorageError::TableNotFound {
5700                        name: s.name.into(),
5701                    })
5702                })?;
5703                let col_pos = match table
5704                    .schema()
5705                    .columns
5706                    .iter()
5707                    .position(|c| c.name.eq_ignore_ascii_case(&column))
5708                {
5709                    Some(p) => p,
5710                    None => {
5711                        if if_exists {
5712                            return Ok(());
5713                        }
5714                        return Err(EngineError::Unsupported(alloc::format!(
5715                            "ALTER TABLE DROP COLUMN: column {column:?} not found on {:?}",
5716                            s.name
5717                        )));
5718                    }
5719                };
5720                // Dependent check: FKs whose local columns include
5721                // col_pos. CASCADE drops them; otherwise reject.
5722                let dependent_fks: Vec<usize> = table
5723                    .schema()
5724                    .foreign_keys
5725                    .iter()
5726                    .enumerate()
5727                    .filter_map(|(i, fk)| {
5728                        if fk.local_columns.contains(&col_pos) {
5729                            Some(i)
5730                        } else {
5731                            None
5732                        }
5733                    })
5734                    .collect();
5735                if !dependent_fks.is_empty() && !cascade {
5736                    return Err(EngineError::Unsupported(alloc::format!(
5737                        "ALTER TABLE DROP COLUMN {column:?}: column has FK dependents; \
5738                         use DROP COLUMN ... CASCADE to remove them"
5739                    )));
5740                }
5741                // CASCADE the FK removals first.
5742                if cascade {
5743                    // Drop in reverse so indices stay valid.
5744                    let mut sorted = dependent_fks.clone();
5745                    sorted.sort();
5746                    sorted.reverse();
5747                    let fks = &mut table.schema_mut().foreign_keys;
5748                    for i in sorted {
5749                        fks.remove(i);
5750                    }
5751                }
5752                // Drop the column. New helper on Table does the
5753                // row + schema + index shift atomically.
5754                table.drop_column(col_pos);
5755            }
5756            spg_sql::ast::AlterTableTarget::SetTriggerEnabled { which, enabled } => {
5757                // v7.16.1 — mailrs round-9 A.2.b. pg_dump
5758                // --disable-triggers wraps each table's data
5759                // block with `ALTER TABLE … DISABLE TRIGGER ALL`
5760                // / `… ENABLE TRIGGER ALL`. Toggle the enabled
5761                // flag on every matching trigger so the row-
5762                // write paths skip them; the catalog snapshot
5763                // persists the new state across restarts.
5764                let table_name = s.name.to_string();
5765                let trigs = self.active_catalog_mut().triggers_mut();
5766                let mut touched = false;
5767                for t in trigs.iter_mut() {
5768                    if !t.table.eq_ignore_ascii_case(&table_name) {
5769                        continue;
5770                    }
5771                    match &which {
5772                        spg_sql::ast::TriggerSelector::All => {
5773                            t.enabled = enabled;
5774                            touched = true;
5775                        }
5776                        spg_sql::ast::TriggerSelector::Named(name) => {
5777                            if t.name.eq_ignore_ascii_case(name) {
5778                                t.enabled = enabled;
5779                                touched = true;
5780                            }
5781                        }
5782                    }
5783                }
5784                // PG semantics: `ALL` on a table with no
5785                // triggers is a no-op (no error). A `Named`
5786                // form pointing at a non-existent trigger
5787                // raises in PG; v7.16.1 also raises so we
5788                // don't silently lose state.
5789                if !touched {
5790                    if let spg_sql::ast::TriggerSelector::Named(name) = &which {
5791                        return Err(EngineError::Unsupported(alloc::format!(
5792                            "ALTER TABLE {table_name:?} {} TRIGGER {name:?}: no such trigger on table",
5793                            if enabled { "ENABLE" } else { "DISABLE" },
5794                        )));
5795                    }
5796                }
5797            }
5798            spg_sql::ast::AlterTableTarget::SetColumnAutoIncrement { column, seq_name } => {
5799                // pg_dump's identity form names an IMPLICIT sequence
5800                // (`… AS IDENTITY ( SEQUENCE NAME s … )`) that never
5801                // gets its own CREATE SEQUENCE statement, while the
5802                // data section still calls `setval(s, …)`. Make the
5803                // sequence exist (idempotent) so those calls land.
5804                if let Some(seq) = seq_name {
5805                    let _ = self.exec_create_sequence(spg_sql::ast::CreateSequenceStatement {
5806                        name: seq,
5807                        if_not_exists: true,
5808                        temporary: false,
5809                        data_type: None,
5810                        options: spg_sql::ast::SequenceOptions::default(),
5811                    })?;
5812                }
5813                // v7.22 (round-13 T2) — pg_dump's serial/identity
5814                // spellings (`SET DEFAULT nextval(…)` / `ADD
5815                // GENERATED … AS IDENTITY`) lower here: flip the
5816                // column's auto-increment flag so post-import
5817                // INSERTs without an explicit value keep numbering
5818                // (max+1 semantics; the dump's setval() calls are
5819                // no-ops by construction).
5820                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5821                    EngineError::Storage(StorageError::TableNotFound {
5822                        name: s.name.into(),
5823                    })
5824                })?;
5825                let pos = table
5826                    .schema()
5827                    .columns
5828                    .iter()
5829                    .position(|c| c.name.eq_ignore_ascii_case(&column))
5830                    .ok_or_else(|| {
5831                        EngineError::Unsupported(alloc::format!(
5832                            "ALTER COLUMN {column:?}: no such column on {:?}",
5833                            s.name
5834                        ))
5835                    })?;
5836                let col = &table.schema().columns[pos];
5837                if !matches!(
5838                    col.ty,
5839                    spg_storage::DataType::SmallInt
5840                        | spg_storage::DataType::Int
5841                        | spg_storage::DataType::BigInt
5842                ) {
5843                    return Err(EngineError::Unsupported(alloc::format!(
5844                        "auto-increment applies to integer columns only ({column:?} is {:?})",
5845                        col.ty
5846                    )));
5847                }
5848                table.schema_mut().columns[pos].auto_increment = true;
5849            }
5850            spg_sql::ast::AlterTableTarget::RenameTable { new } => {
5851                // v7.16.2 — table-level rename (mailrs round-10
5852                // A.5 — used by migrate-042's `ALTER TABLE
5853                // contacts RENAME TO email_contacts`). Storage
5854                // helper updates the schema + by_name index +
5855                // dangling FK / trigger references in one
5856                // atomic step.
5857                let old = s.name.to_string();
5858                self.active_catalog_mut()
5859                    .rename_table(&old, &new)
5860                    .map_err(EngineError::Storage)?;
5861            }
5862            spg_sql::ast::AlterTableTarget::RenameColumn { old, new } => {
5863                // v7.15.0 — `ALTER TABLE t RENAME [COLUMN] old TO
5864                // new`. Rename the column in the schema; rewrite
5865                // every stored source string on this table that
5866                // references it as a (potentially-qualified)
5867                // column identifier: CHECK predicates, partial-
5868                // index predicates, runtime DEFAULT expressions.
5869                // Then walk catalog triggers on this table and
5870                // patch any `UPDATE OF` column list. Function and
5871                // trigger bodies are NOT auto-rewritten — that
5872                // surface is dynamic SQL territory; users update
5873                // those separately (matches PG plpgsql behavior:
5874                // a column rename invalidates name-referencing
5875                // plpgsql at call time, not rename time).
5876                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5877                    EngineError::Storage(StorageError::TableNotFound {
5878                        name: s.name.into(),
5879                    })
5880                })?;
5881                let col_pos = table
5882                    .schema()
5883                    .columns
5884                    .iter()
5885                    .position(|c| c.name.eq_ignore_ascii_case(&old))
5886                    .ok_or_else(|| {
5887                        EngineError::Unsupported(alloc::format!(
5888                            "ALTER TABLE RENAME COLUMN: column {old:?} not found on {:?}",
5889                            s.name
5890                        ))
5891                    })?;
5892                // Reject same-name (case-insensitive) collision.
5893                if table
5894                    .schema()
5895                    .columns
5896                    .iter()
5897                    .enumerate()
5898                    .any(|(i, c)| i != col_pos && c.name.eq_ignore_ascii_case(&new))
5899                {
5900                    return Err(EngineError::Unsupported(alloc::format!(
5901                        "ALTER TABLE RENAME COLUMN: column {new:?} already exists on {:?}",
5902                        s.name
5903                    )));
5904                }
5905                // Schema rename first — even idempotent same-name
5906                // rename (`ALTER TABLE t RENAME a TO a`) needs to
5907                // be a no-op, not an error.
5908                if old.eq_ignore_ascii_case(&new) {
5909                    return Ok(());
5910                }
5911                table.rename_column(col_pos, &new);
5912                // Rewrite per-column runtime_default sources on
5913                // every column of this table — a DEFAULT expression
5914                // on column X may reference column Y by name (rare,
5915                // but legal in PG when the value is supplied via a
5916                // function that takes the row).
5917                let n_cols = table.schema().columns.len();
5918                for i in 0..n_cols {
5919                    let rt = table.schema().columns[i].runtime_default.clone();
5920                    if let Some(src) = rt {
5921                        let rewritten = rewrite_column_in_source(&src, &old, &new)?;
5922                        table.schema_mut().columns[i].runtime_default = Some(rewritten);
5923                    }
5924                }
5925                // Rewrite table-level CHECK predicates.
5926                let checks = table.schema().checks.clone();
5927                let mut new_checks = Vec::with_capacity(checks.len());
5928                for chk in checks {
5929                    new_checks.push(rewrite_column_in_source(&chk, &old, &new)?);
5930                }
5931                table.schema_mut().checks = new_checks;
5932                // Rewrite per-index partial_predicate sources.
5933                let n_idx = table.indices().len();
5934                for i in 0..n_idx {
5935                    let pred = table.indices()[i].partial_predicate.clone();
5936                    if let Some(src) = pred {
5937                        let rewritten = rewrite_column_in_source(&src, &old, &new)?;
5938                        // SAFETY: indices_mut would be cleanest, but
5939                        // partial_predicate is the only mutable field
5940                        // here; reach in via the public mut accessor.
5941                        table.set_partial_predicate(i, Some(rewritten));
5942                    }
5943                }
5944                // Walk catalog triggers; patch `update_columns` on
5945                // triggers attached to this table.
5946                let table_name = s.name.to_string();
5947                for trig in self.active_catalog_mut().triggers_mut() {
5948                    if !trig.table.eq_ignore_ascii_case(&table_name) {
5949                        continue;
5950                    }
5951                    for c in &mut trig.update_columns {
5952                        if c.eq_ignore_ascii_case(&old) {
5953                            *c = new.clone();
5954                        }
5955                    }
5956                }
5957            }
5958        }
5959        Ok(())
5960    }
5961
5962    fn exec_alter_index(
5963        &mut self,
5964        stmt: spg_sql::ast::AlterIndexStatement,
5965    ) -> Result<QueryResult, EngineError> {
5966        // Translate the optional SQL-side encoding choice into the
5967        // storage-side enum; the same SqlVecEncoding -> VecEncoding
5968        // bridge `column_type_to_data_type` uses.
5969        let spg_sql::ast::AlterIndexStatement {
5970            name: idx_name,
5971            target,
5972        } = stmt;
5973        // v7.16.2 — RENAME TO branch (mailrs round-10 migrate-042).
5974        // IF EXISTS makes a missing index a no-op rather than an
5975        // error, mirroring PG semantics.
5976        if let spg_sql::ast::AlterIndexTarget::Rename { new, if_exists } = target {
5977            let renamed = self.active_catalog_mut().rename_index(&idx_name, &new);
5978            return match renamed {
5979                Ok(()) => Ok(QueryResult::CommandOk {
5980                    affected: 0,
5981                    modified_catalog: !self.in_transaction(),
5982                }),
5983                Err(StorageError::IndexNotFound { .. }) if if_exists => {
5984                    Ok(QueryResult::CommandOk {
5985                        affected: 0,
5986                        modified_catalog: false,
5987                    })
5988                }
5989                Err(e) => Err(EngineError::Storage(e)),
5990            };
5991        }
5992        let spg_sql::ast::AlterIndexTarget::Rebuild { encoding } = target else {
5993            unreachable!("Rename branch returned above");
5994        };
5995        let target = encoding.map(|e| match e {
5996            SqlVecEncoding::F32 => VecEncoding::F32,
5997            SqlVecEncoding::Sq8 => VecEncoding::Sq8,
5998            SqlVecEncoding::F16 => VecEncoding::F16,
5999        });
6000        // Linear scan: index names are globally unique within a
6001        // catalog (enforced by add_nsw_index_inner) so the first
6002        // match is the only one. Save the table name to avoid
6003        // borrowing while we then take a mut borrow.
6004        let table_name = {
6005            let cat = self.active_catalog();
6006            let mut found: Option<String> = None;
6007            for tname in cat.table_names() {
6008                if let Some(t) = cat.get(&tname)
6009                    && t.indices().iter().any(|i| i.name == idx_name)
6010                {
6011                    found = Some(tname);
6012                    break;
6013                }
6014            }
6015            found.ok_or_else(|| {
6016                EngineError::Storage(StorageError::IndexNotFound {
6017                    name: idx_name.clone(),
6018                })
6019            })?
6020        };
6021        let table = self
6022            .active_catalog_mut()
6023            .get_mut(&table_name)
6024            .expect("table found above");
6025        table.rebuild_nsw_index(&idx_name, target)?;
6026        // v6.3.1 — ALTER INDEX REBUILD potentially with new encoding
6027        // changes cost characteristics; evict any cached plans.
6028        self.plan_cache.evict_referencing(&table_name);
6029        Ok(QueryResult::CommandOk {
6030            affected: 0,
6031            modified_catalog: !self.in_transaction(),
6032        })
6033    }
6034
6035    fn exec_create_index(
6036        &mut self,
6037        stmt: CreateIndexStatement,
6038    ) -> Result<QueryResult, EngineError> {
6039        let table = self
6040            .active_catalog_mut()
6041            .get_mut(&stmt.table)
6042            .ok_or_else(|| {
6043                EngineError::Storage(StorageError::TableNotFound {
6044                    name: stmt.table.clone(),
6045                })
6046            })?;
6047        // `IF NOT EXISTS` reduces DuplicateIndex to a no-op CommandOk.
6048        if stmt.if_not_exists && table.indices().iter().any(|i| i.name == stmt.name) {
6049            return Ok(QueryResult::CommandOk {
6050                affected: 0,
6051                modified_catalog: false,
6052            });
6053        }
6054        // v7.9.14 — multi-column index parses through; engine
6055        // builds a single-column BTree on the leading column only.
6056        // The extras live on the AST so spg-server's dispatcher
6057        // can emit a PG-wire NoticeResponse / log line. Composite
6058        // BTree keys land in v7.10.
6059        let _ = &stmt.extra_columns; // intentional drop on engine side
6060        let table_name = stmt.table.clone();
6061        // v6.8.0 — resolve INCLUDE column names to positions. Done
6062        // before `add_index` so a typo error surfaces before any
6063        // catalog mutation lands.
6064        let included_positions: Vec<usize> = if stmt.included_columns.is_empty() {
6065            Vec::new()
6066        } else {
6067            let schema = table.schema();
6068            stmt.included_columns
6069                .iter()
6070                .map(|c| {
6071                    schema.column_position(c).ok_or_else(|| {
6072                        EngineError::Storage(StorageError::ColumnNotFound { column: c.clone() })
6073                    })
6074                })
6075                .collect::<Result<Vec<_>, _>>()?
6076        };
6077        match stmt.method {
6078            IndexMethod::BTree => table.add_index(stmt.name.clone(), &stmt.column)?,
6079            IndexMethod::Hnsw => {
6080                if !included_positions.is_empty() {
6081                    return Err(EngineError::Unsupported(
6082                        "INCLUDE columns are not supported on HNSW indexes".into(),
6083                    ));
6084                }
6085                table.add_nsw_index(stmt.name.clone(), &stmt.column, spg_storage::NSW_DEFAULT_M)?;
6086            }
6087            // v6.7.1 — BRIN. Pure metadata; no in-memory data.
6088            IndexMethod::Brin => {
6089                if !included_positions.is_empty() {
6090                    return Err(EngineError::Unsupported(
6091                        "INCLUDE columns are not supported on BRIN indexes".into(),
6092                    ));
6093                }
6094                table.add_brin_index(stmt.name.clone(), &stmt.column)?;
6095            }
6096            // v7.12.3 — GIN inverted index. Real posting-list-backed
6097            // GIN when the indexed column is `tsvector`; falls back
6098            // to a BTree on the leading column for any other column
6099            // type so v7.9.26b's `pg_dump` compatibility (GIN on
6100            // JSONB etc. silently loading as BTree) is preserved.
6101            // Operators see the real GIN only where it matters; old
6102            // schemas keep loading.
6103            IndexMethod::Gin => {
6104                if !included_positions.is_empty() {
6105                    return Err(EngineError::Unsupported(
6106                        "INCLUDE columns are not supported on GIN indexes".into(),
6107                    ));
6108                }
6109                let col_pos = table
6110                    .schema()
6111                    .column_position(&stmt.column)
6112                    .ok_or_else(|| {
6113                        EngineError::Storage(StorageError::ColumnNotFound {
6114                            column: stmt.column.clone(),
6115                        })
6116                    })?;
6117                let col_ty = table.schema().columns[col_pos].ty;
6118                // v7.15.0 — `gin_trgm_ops` on a TEXT/VARCHAR
6119                // column dispatches to the real trigram-shingle
6120                // GIN build (LIKE / similarity acceleration).
6121                // Other GIN opclasses fall through to the regular
6122                // tsvector-vs-BTree split below.
6123                let is_trgm = stmt
6124                    .opclass
6125                    .as_deref()
6126                    .is_some_and(|op| op.eq_ignore_ascii_case("gin_trgm_ops"));
6127                if is_trgm
6128                    && matches!(
6129                        col_ty,
6130                        spg_storage::DataType::Text | spg_storage::DataType::Varchar(_)
6131                    )
6132                {
6133                    table
6134                        .add_gin_trgm_index(stmt.name.clone(), &stmt.column)
6135                        .map_err(EngineError::Storage)?;
6136                } else if col_ty == spg_storage::DataType::TsVector {
6137                    table
6138                        .add_gin_index(stmt.name.clone(), &stmt.column)
6139                        .map_err(EngineError::Storage)?;
6140                } else {
6141                    // v7.9.26b BTree fallback — the catalog still
6142                    // gets an index entry on the leading column so
6143                    // pg_dump scripts that name GIN on JSONB / etc.
6144                    // load clean; query-time gain stays opt-in for
6145                    // tsvector callers.
6146                    table.add_index(stmt.name.clone(), &stmt.column)?;
6147                }
6148            }
6149        }
6150        if !included_positions.is_empty()
6151            && let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name)
6152        {
6153            idx.included_columns = included_positions;
6154        }
6155        // v6.8.1 — persist partial-index predicate. Stored as the
6156        // expression's Display form so the catalog snapshot stays
6157        // pure (storage has no spg-sql dependency). The runtime
6158        // maintenance path treats partial indexes identically to
6159        // full indexes for v6.8.1 (over-maintenance is safe; the
6160        // planner-side "use partial when query WHERE implies the
6161        // predicate" pass is STABILITY carve-out).
6162        if let Some(pred_expr) = &stmt.partial_predicate {
6163            let canonical = pred_expr.to_string();
6164            // v7.13.2 — mailrs round-6 S2. PG's `pg_trgm` uses
6165            // `CREATE INDEX … USING gin(col gin_trgm_ops) WHERE …`
6166            // routinely to slim trigram indexes. SPG now persists
6167            // the predicate for GIN / BRIN / HNSW the same way it
6168            // already does for BTree — same v6.8.1 "over-maintain
6169            // is safe; planner-side partial routing is STABILITY
6170            // carve-out" semantics. HNSW carries an additional
6171            // caveat: the predicate isn't applied at index build
6172            // time (would require per-row eval inside the NSW
6173            // construction loop), so the index oversamples; query
6174            // time the WHERE clause still filters correctly.
6175            if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
6176                idx.partial_predicate = Some(canonical);
6177            }
6178        }
6179        // v6.8.2 — persist expression index key. Same Display-form
6180        // storage; the runtime maintenance pass evaluates each
6181        // row's expression to derive the index key, but for v6.8.2
6182        // the engine falls through to the bare-column-reference
6183        // path and the expression is preserved for format-layer
6184        // round-trip + future planner work. Carved-out in
6185        // STABILITY § "Out of v6.8".
6186        if let Some(key_expr) = &stmt.expression {
6187            if matches!(
6188                stmt.method,
6189                IndexMethod::Hnsw | IndexMethod::Brin | IndexMethod::Gin
6190            ) {
6191                return Err(EngineError::Unsupported(
6192                    "Expression keys are not supported on HNSW or BRIN indexes".into(),
6193                ));
6194            }
6195            let canonical = key_expr.to_string();
6196            if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
6197                idx.expression = Some(canonical);
6198            }
6199        }
6200        // v7.9.29 — persist `is_unique` flag on the storage Index.
6201        // Combined with `partial_predicate`, INSERT enforcement
6202        // checks that no other row whose predicate evaluates true
6203        // shares the same indexed key. Parser already rejected
6204        // `UNIQUE` on HNSW / BRIN, so plain BTree here.
6205        // For multi-column UNIQUE INDEX the extras matter (the
6206        // full tuple is the uniqueness key), so resolve them to
6207        // column positions and persist on the index too.
6208        if stmt.is_unique {
6209            let mut extra_positions: alloc::vec::Vec<usize> = alloc::vec::Vec::new();
6210            for col_name in &stmt.extra_columns {
6211                let pos = table
6212                    .schema()
6213                    .columns
6214                    .iter()
6215                    .position(|c| c.name.eq_ignore_ascii_case(col_name))
6216                    .ok_or_else(|| {
6217                        EngineError::Unsupported(alloc::format!(
6218                            "UNIQUE INDEX {:?}: extra column {col_name:?} not in table {:?}",
6219                            stmt.name,
6220                            stmt.table
6221                        ))
6222                    })?;
6223                extra_positions.push(pos);
6224            }
6225            if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
6226                idx.is_unique = true;
6227                idx.extra_column_positions = extra_positions;
6228            }
6229            // At index-creation time, check the existing rows for
6230            // pre-existing duplicates that would have violated the
6231            // new constraint — otherwise CREATE UNIQUE INDEX would
6232            // silently leave duplicates in place.
6233            let snapshot_indices = table.indices().to_vec();
6234            let snapshot_rows: alloc::vec::Vec<spg_storage::Row> =
6235                table.rows().iter().cloned().collect();
6236            let snapshot_schema = table.schema().clone();
6237            let idx_ref = snapshot_indices
6238                .iter()
6239                .find(|i| i.name == stmt.name)
6240                .expect("just-added index");
6241            check_existing_unique_violation(idx_ref, &snapshot_schema, &snapshot_rows)?;
6242        }
6243        // v6.3.1 — adding an index can change the optimal plan for
6244        // any cached query that references this table.
6245        self.plan_cache.evict_referencing(&table_name);
6246        Ok(QueryResult::CommandOk {
6247            affected: 0,
6248            modified_catalog: !self.in_transaction(),
6249        })
6250    }
6251
6252    /// v7.13.3 — mailrs round-7 S9. SPG-specific reconciliation
6253    /// for `CREATE TABLE IF NOT EXISTS` when the table already
6254    /// exists. Adds missing columns + inline FKs from the new
6255    /// definition; existing columns / constraints stay untouched.
6256    /// New columns with a `NOT NULL` declaration without a
6257    /// `DEFAULT` are reported as a clear error rather than
6258    /// silently dropped — this is the "fail loud on real
6259    /// incompatibility, fail silent on schema-superset" tradeoff.
6260    fn reconcile_table_if_not_exists(
6261        &mut self,
6262        stmt: CreateTableStatement,
6263    ) -> Result<QueryResult, EngineError> {
6264        let table_name = stmt.name.clone();
6265        let clock = self.clock;
6266        let existing_col_names: alloc::collections::BTreeSet<String> = self
6267            .active_catalog()
6268            .get(&table_name)
6269            .expect("checked above")
6270            .schema()
6271            .columns
6272            .iter()
6273            .map(|c| c.name.to_ascii_lowercase())
6274            .collect();
6275        let row_count = self
6276            .active_catalog()
6277            .get(&table_name)
6278            .expect("checked above")
6279            .row_count();
6280        // Collect missing column defs in source order.
6281        let new_columns: alloc::vec::Vec<spg_sql::ast::ColumnDef> = stmt
6282            .columns
6283            .iter()
6284            .filter(|c| !existing_col_names.contains(&c.name.to_ascii_lowercase()))
6285            .cloned()
6286            .collect();
6287        for col_def in new_columns {
6288            let col_name = col_def.name.clone();
6289            let nullable = col_def.nullable;
6290            let has_default = col_def.default.is_some() || col_def.auto_increment;
6291            let col_schema = column_def_to_schema(col_def)?;
6292            let fill_value: Value = if has_default || col_schema.runtime_default.is_some() {
6293                resolve_column_default_free(&col_schema, clock)?
6294            } else if nullable || row_count == 0 {
6295                Value::Null
6296            } else {
6297                return Err(EngineError::Unsupported(alloc::format!(
6298                    "CREATE TABLE IF NOT EXISTS {table_name:?}: reconciling \
6299                     column {col_name:?} requires DEFAULT (existing rows would violate NOT NULL)"
6300                )));
6301            };
6302            let table = self
6303                .active_catalog_mut()
6304                .get_mut(&table_name)
6305                .expect("checked above");
6306            table.add_column(col_schema, fill_value);
6307        }
6308        // Resolve any newly-added inline FKs (column-level
6309        // REFERENCES forms) and install. Skip FKs whose local
6310        // columns we didn't have in the existing table.
6311        let table_cols_now = self
6312            .active_catalog()
6313            .get(&table_name)
6314            .expect("checked above")
6315            .schema()
6316            .columns
6317            .clone();
6318        for fk in stmt.foreign_keys {
6319            // Only install FKs whose every local column resolves
6320            // — older catalogs may have a column the new FK
6321            // references but not the column the new FK declares.
6322            let all_resolved = fk.columns.iter().all(|c| {
6323                table_cols_now
6324                    .iter()
6325                    .any(|sc| sc.name.eq_ignore_ascii_case(c))
6326            });
6327            if !all_resolved {
6328                continue;
6329            }
6330            let already_present = {
6331                let table = self
6332                    .active_catalog()
6333                    .get(&table_name)
6334                    .expect("checked above");
6335                table.schema().foreign_keys.iter().any(|f| {
6336                    f.parent_table.eq_ignore_ascii_case(&fk.parent_table)
6337                        && f.local_columns.len() == fk.columns.len()
6338                })
6339            };
6340            if already_present {
6341                continue;
6342            }
6343            let storage_fk =
6344                resolve_foreign_key(&table_name, &table_cols_now, fk, self.active_catalog())?;
6345            let table = self
6346                .active_catalog_mut()
6347                .get_mut(&table_name)
6348                .expect("checked above");
6349            table.schema_mut().foreign_keys.push(storage_fk);
6350        }
6351        Ok(QueryResult::CommandOk {
6352            affected: 0,
6353            modified_catalog: !self.in_transaction(),
6354        })
6355    }
6356
6357    /// v7.14.0 — DROP TABLE handler (pg_dump / mysqldump preamble).
6358    fn exec_drop_table(
6359        &mut self,
6360        names: Vec<String>,
6361        if_exists: bool,
6362    ) -> Result<QueryResult, EngineError> {
6363        for name in names {
6364            let dropped = self.active_catalog_mut().drop_table(&name);
6365            if !dropped && !if_exists {
6366                return Err(EngineError::Storage(StorageError::TableNotFound { name }));
6367            }
6368        }
6369        Ok(QueryResult::CommandOk {
6370            affected: 0,
6371            modified_catalog: !self.in_transaction(),
6372        })
6373    }
6374
6375    /// v7.14.0 — DROP INDEX handler.
6376    fn exec_drop_index(
6377        &mut self,
6378        name: String,
6379        if_exists: bool,
6380    ) -> Result<QueryResult, EngineError> {
6381        let dropped = self.active_catalog_mut().drop_named_index(&name);
6382        if !dropped && !if_exists {
6383            return Err(EngineError::Storage(StorageError::IndexNotFound { name }));
6384        }
6385        Ok(QueryResult::CommandOk {
6386            affected: 0,
6387            modified_catalog: !self.in_transaction(),
6388        })
6389    }
6390
6391    fn exec_create_table(
6392        &mut self,
6393        stmt: CreateTableStatement,
6394    ) -> Result<QueryResult, EngineError> {
6395        if stmt.if_not_exists && self.active_catalog().get(&stmt.name).is_some() {
6396            // v7.16.2 — PG-strict silent no-op (mailrs round-10
6397            // surfaced this). v7.13.3's "reconcile by adding
6398            // missing columns" was friendly for mailrs round-7
6399            // where init-schema's `contacts` and migrate-023's
6400            // CardDAV `contacts` collided; but it ALSO silently
6401            // added columns to existing tables when later
6402            // migrations had a duplicate `CREATE TABLE IF NOT
6403            // EXISTS <t> (different-shape-cols)` shape. mailrs's
6404            // migrate-030 has exactly that — re-declares
6405            // system_config with `key` even though init-schema
6406            // already created it with `config_key`. PG's silent
6407            // no-op leaves system_config at `config_key`;
6408            // v7.13.3 added a phantom `key` column that then
6409            // tripped migrate-040's idempotent rename guard.
6410            // mailrs v1.7.106 ships the proper PG-style
6411            // contacts rename via DO + IF EXISTS, so SPG can
6412            // revert to PG-strict here without re-breaking the
6413            // round-7 case.
6414            return Ok(QueryResult::CommandOk {
6415                affected: 0,
6416                modified_catalog: false,
6417            });
6418        }
6419        let table_name = stmt.name.clone();
6420        // v7.9.13 — pluck the names of any columns marked
6421        // `PRIMARY KEY` inline so the post-create-table pass can
6422        // build an implicit BTree index. mailrs F1.
6423        let inline_pk_columns: Vec<String> = stmt
6424            .columns
6425            .iter()
6426            .filter(|c| c.is_primary_key)
6427            .map(|c| c.name.clone())
6428            .collect();
6429        // v7.9.19 — table-level constraints: PRIMARY KEY (a, b, ...)
6430        // and UNIQUE (a, b, ...). Each builds a BTree index on the
6431        // leading column (the existing single-column storage tier)
6432        // and registers a UniquenessConstraint on the schema for
6433        // INSERT-time enforcement of the full tuple. mailrs G1/G6.
6434        let cols = stmt
6435            .columns
6436            .into_iter()
6437            .map(column_def_to_schema)
6438            .collect::<Result<Vec<_>, _>>()?;
6439        // v7.17.0 Phase 1.4 + 1.5 — classify every raw
6440        // user_type_ref (parked as user_enum_type by
6441        // column_def_to_schema) into either an enum binding or a
6442        // domain binding. For domains, also rewrite the column's
6443        // base DataType from the placeholder Text to the domain's
6444        // declared base. Unknown idents are still a hard error
6445        // here (same as Phase 1.4) so silent acceptance never
6446        // happens.
6447        let mut cols = cols;
6448        for col in cols.iter_mut() {
6449            let Some(name) = col.user_enum_type.take() else {
6450                continue;
6451            };
6452            let cat = self.active_catalog();
6453            if cat.enum_types().contains_key(&name) {
6454                col.user_enum_type = Some(name);
6455                continue;
6456            }
6457            if let Some(dom) = cat.domain_types().get(&name) {
6458                col.ty = dom.base_type;
6459                col.user_domain_type = Some(name);
6460                if !dom.nullable {
6461                    col.nullable = false;
6462                }
6463                continue;
6464            }
6465            return Err(EngineError::Unsupported(alloc::format!(
6466                "column {:?}: unknown column type {:?} (not a built-in, ENUM, or DOMAIN)",
6467                col.name,
6468                name
6469            )));
6470        }
6471        for tc in &stmt.table_constraints {
6472            if let spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } = tc {
6473                for col_name in columns {
6474                    if let Some(col) = cols.iter_mut().find(|c| c.name == *col_name) {
6475                        col.nullable = false;
6476                    }
6477                }
6478            }
6479        }
6480        // v7.6.1 — resolve every FK in the statement against the
6481        // already-known catalog. Validates: parent table exists,
6482        // parent column names exist, arity matches, parent columns
6483        // have a PK / UNIQUE index. Self-referencing FKs (parent
6484        // table == this table) resolve against the column list we
6485        // just built — they don't need the catalog yet.
6486        let mut fks: Vec<spg_storage::ForeignKeyConstraint> =
6487            Vec::with_capacity(stmt.foreign_keys.len());
6488        for fk in stmt.foreign_keys {
6489            // v7.14.0 — when SET FOREIGN_KEY_CHECKS=0 is in effect
6490            // (mysqldump preamble + bulk imports), defer FK
6491            // resolution if the parent table isn't in the catalog
6492            // yet. The FK is queued and resolved when checks flip
6493            // back on. Self-references stay in-band (the parent is
6494            // the same as the child we're building).
6495            let needs_parent = !fk.parent_table.eq_ignore_ascii_case(&table_name);
6496            if !self.foreign_key_checks
6497                && needs_parent
6498                && self.active_catalog().get(&fk.parent_table).is_none()
6499            {
6500                self.pending_foreign_keys.push((table_name.clone(), fk));
6501                continue;
6502            }
6503            fks.push(resolve_foreign_key(
6504                &table_name,
6505                &cols,
6506                fk,
6507                self.active_catalog(),
6508            )?);
6509        }
6510        let mut schema = TableSchema::new(table_name.clone(), cols);
6511        schema.foreign_keys = fks;
6512        // v7.9.19 — translate AST table_constraints to storage
6513        // UniquenessConstraints (column name → position) so the
6514        // INSERT enforcement helper sees positions directly.
6515        let mut uc_storage: Vec<spg_storage::UniquenessConstraint> = Vec::new();
6516        let mut check_exprs: Vec<String> = Vec::new();
6517        for tc in &stmt.table_constraints {
6518            let (is_pk, names, nnd) = match tc {
6519                spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } => {
6520                    (true, columns.clone(), false)
6521                }
6522                spg_sql::ast::TableConstraint::Unique {
6523                    columns,
6524                    nulls_not_distinct,
6525                    ..
6526                } => (false, columns.clone(), *nulls_not_distinct),
6527                spg_sql::ast::TableConstraint::Check { expr, .. } => {
6528                    // v7.13.0 — collect CHECK predicate sources;
6529                    // they get attached to the schema below.
6530                    check_exprs.push(alloc::format!("{expr}"));
6531                    continue;
6532                }
6533                // v7.15.0 — plain `KEY (cols)` from MySQL inline
6534                // is NOT a uniqueness constraint; skip the UC
6535                // build path entirely. The BTree index lands in
6536                // the post-create loop below alongside the PK/UQ
6537                // implicit indexes.
6538                spg_sql::ast::TableConstraint::Index { .. } => continue,
6539                // v7.17.0 Phase 2.2 — MySQL FULLTEXT KEY is not
6540                // a uniqueness constraint either; its GIN gets
6541                // built in the post-create loop below.
6542                spg_sql::ast::TableConstraint::FulltextIndex { .. } => continue,
6543            };
6544            let mut positions = Vec::with_capacity(names.len());
6545            for n in &names {
6546                let pos = schema
6547                    .columns
6548                    .iter()
6549                    .position(|c| c.name == *n)
6550                    .ok_or_else(|| {
6551                        EngineError::Unsupported(alloc::format!(
6552                            "table constraint references unknown column {n:?}"
6553                        ))
6554                    })?;
6555                positions.push(pos);
6556            }
6557            uc_storage.push(spg_storage::UniquenessConstraint {
6558                is_primary_key: is_pk,
6559                columns: positions,
6560                nulls_not_distinct: nnd,
6561            });
6562        }
6563        schema.uniqueness_constraints = uc_storage.clone();
6564        schema.checks = check_exprs;
6565        self.active_catalog_mut().create_table(schema)?;
6566        // v7.9.13 — implicit BTree per inline PK column +
6567        // v7.9.19 — implicit BTree on the leading column of every
6568        // table-level PRIMARY KEY / UNIQUE constraint.
6569        let table = self
6570            .active_catalog_mut()
6571            .get_mut(&table_name)
6572            .expect("just created");
6573        for (i, col_name) in inline_pk_columns.iter().enumerate() {
6574            let idx_name = if inline_pk_columns.len() == 1 {
6575                alloc::format!("{table_name}_pkey")
6576            } else {
6577                alloc::format!("{table_name}_pkey_{i}")
6578            };
6579            if let Err(e) = table.add_index(idx_name, col_name) {
6580                return Err(EngineError::Storage(e));
6581            }
6582        }
6583        for (i, tc) in stmt.table_constraints.iter().enumerate() {
6584            // v7.17.0 Phase 2.2 — FULLTEXT KEY lands a real
6585            // tsvector-GIN per declared column instead of the
6586            // BTree the PK / UQ / KEY paths build. Branch early
6587            // so the BTree loop never sees the FULLTEXT shape.
6588            if let spg_sql::ast::TableConstraint::FulltextIndex { name, columns } = tc {
6589                for (k, col) in columns.iter().enumerate() {
6590                    let already = table.indices().iter().any(|idx| {
6591                        matches!(idx.kind, spg_storage::IndexKind::GinFulltext(_))
6592                            && table.schema().columns[idx.column_position].name == *col
6593                    });
6594                    if already {
6595                        continue;
6596                    }
6597                    let idx_name = match (name.as_ref(), columns.len(), k) {
6598                        (Some(n), 1, _) => n.clone(),
6599                        (Some(n), _, k) => alloc::format!("{n}_{k}"),
6600                        (None, _, _) => {
6601                            alloc::format!("{table_name}_{col}_ftidx")
6602                        }
6603                    };
6604                    if let Err(e) = table.add_gin_fulltext_index(idx_name, col) {
6605                        return Err(EngineError::Storage(e));
6606                    }
6607                }
6608                continue;
6609            }
6610            // v7.15.0 — plain KEY/INDEX rides this same loop so
6611            // the implicit BTree gets built. It carries its own
6612            // user-supplied name; PK/UQ still synthesise.
6613            let (suffix, names, explicit_name): (&str, &Vec<String>, Option<&String>) = match tc {
6614                spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } => {
6615                    ("pkey", columns, None)
6616                }
6617                spg_sql::ast::TableConstraint::Unique { columns, .. } => ("key", columns, None),
6618                spg_sql::ast::TableConstraint::Index { name, columns } => {
6619                    ("idx", columns, name.as_ref())
6620                }
6621                spg_sql::ast::TableConstraint::Check { .. } => continue,
6622                // Handled by the early-branch above.
6623                spg_sql::ast::TableConstraint::FulltextIndex { .. } => continue,
6624            };
6625            let leading = &names[0];
6626            // Skip if a same-column BTree already exists (e.g.
6627            // inline PK on the leading column).
6628            let already = table.indices().iter().any(|idx| {
6629                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
6630                    && table.schema().columns[idx.column_position].name == *leading
6631            });
6632            if already {
6633                continue;
6634            }
6635            let idx_name = if let Some(n) = explicit_name {
6636                n.clone()
6637            } else if names.len() == 1 {
6638                alloc::format!("{table_name}_{leading}_{suffix}")
6639            } else {
6640                alloc::format!("{table_name}_{leading}_{suffix}_{i}")
6641            };
6642            if let Err(e) = table.add_index(idx_name, leading) {
6643                return Err(EngineError::Storage(e));
6644            }
6645        }
6646        Ok(QueryResult::CommandOk {
6647            affected: 0,
6648            modified_catalog: !self.in_transaction(),
6649        })
6650    }
6651
6652    fn exec_insert(&mut self, mut stmt: InsertStatement) -> Result<QueryResult, EngineError> {
6653        // v7.17.0 Phase 1.1 — pre-resolve any nextval / currval /
6654        // setval calls against the catalog before the row loop. We
6655        // walk each tuple expression and replace matching
6656        // FunctionCall nodes with their concrete Literal. This
6657        // keeps `literal_expr_to_value` free of `&mut self` and
6658        // lets multi-row INSERT VALUES (… nextval('seq') …)
6659        // mint a separate sequence value per row.
6660        for tuple in &mut stmt.rows {
6661            for cell in tuple.iter_mut() {
6662                self.resolve_sequence_calls_in_expr(cell)?;
6663            }
6664        }
6665        // v7.13.0 — `INSERT INTO t [(cols)] SELECT …` (mailrs
6666        // round-5 G4). Execute the inner SELECT first, then route
6667        // back through the regular VALUES code path with the
6668        // materialised rows.
6669        if let Some(select) = stmt.select_source.clone() {
6670            let select_result = self.exec_select_cancel(&select, CancelToken::none())?;
6671            let rows = match select_result {
6672                QueryResult::Rows { rows, .. } => rows,
6673                other => {
6674                    return Err(EngineError::Unsupported(alloc::format!(
6675                        "INSERT … SELECT: inner statement produced {other:?} instead of a row set"
6676                    )));
6677                }
6678            };
6679            let mut materialised: Vec<Vec<Expr>> = Vec::with_capacity(rows.len());
6680            for row in rows {
6681                let mut tuple: Vec<Expr> = Vec::with_capacity(row.values.len());
6682                for v in row.values {
6683                    tuple.push(value_to_literal_expr_permissive(v)?);
6684                }
6685                materialised.push(tuple);
6686            }
6687            let recurse = InsertStatement {
6688                table: stmt.table,
6689                columns: stmt.columns,
6690                rows: materialised,
6691                select_source: None,
6692                on_conflict: stmt.on_conflict,
6693                returning: stmt.returning,
6694            };
6695            return self.exec_insert(recurse);
6696        }
6697        // v7.9.21 — snapshot the clock fn pointer before the mut
6698        // borrow on the catalog opens; runtime DEFAULT eval needs
6699        // it inside the row hot loop.
6700        let clock = self.clock;
6701        // v7.12.4 — snapshot row-level triggers + their referenced
6702        // functions before the mut borrow on the catalog opens.
6703        // Cloned out so the row hot loop can fire them without
6704        // re-borrowing the catalog (which would conflict with
6705        // table.insert's mutable borrow).
6706        let before_insert_triggers = self.snapshot_row_triggers(&stmt.table, "INSERT", "BEFORE");
6707        let after_insert_triggers = self.snapshot_row_triggers(&stmt.table, "INSERT", "AFTER");
6708        let trigger_session_cfg: Option<alloc::string::String> = self
6709            .session_params
6710            .get("default_text_search_config")
6711            .cloned();
6712        // v7.17.0 Phase 1.4 — snapshot the enum label lookup BEFORE
6713        // opening the mutable borrow on the table below. We need
6714        // catalog-level read access (enum_types lives at the
6715        // catalog level, not the table) and the upcoming mutable
6716        // borrow shadows it.
6717        let pre_borrow_column_meta: Vec<ColumnSchema> = {
6718            let preview_table = self.active_catalog().get(&stmt.table).ok_or_else(|| {
6719                EngineError::Storage(StorageError::TableNotFound {
6720                    name: stmt.table.clone(),
6721                })
6722            })?;
6723            preview_table.schema().columns.clone()
6724        };
6725        let enum_label_lookup: alloc::collections::BTreeMap<usize, Vec<String>> =
6726            pre_borrow_column_meta
6727                .iter()
6728                .enumerate()
6729                .filter_map(|(i, col)| {
6730                    // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM
6731                    // variant lists take priority over the PG
6732                    // catalog enum_types lookup (they're
6733                    // column-local and authoritative when set).
6734                    if let Some(inline) = &col.inline_enum_variants {
6735                        return Some((i, inline.clone()));
6736                    }
6737                    col.user_enum_type.as_ref().and_then(|ename| {
6738                        self.active_catalog()
6739                            .enum_types()
6740                            .get(ename)
6741                            .map(|e| (i, e.labels.clone()))
6742                    })
6743                })
6744                .collect();
6745        // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant lists.
6746        // Distinct from enum_label_lookup: SET validates that
6747        // every comma-separated token is in the variant list, and
6748        // canonicalises the cell to definition-order de-duped text.
6749        let set_variant_lookup: alloc::collections::BTreeMap<usize, Vec<String>> =
6750            pre_borrow_column_meta
6751                .iter()
6752                .enumerate()
6753                .filter_map(|(i, col)| col.inline_set_variants.as_ref().map(|vs| (i, vs.clone())))
6754                .collect();
6755        let table = self
6756            .active_catalog_mut()
6757            .get_mut(&stmt.table)
6758            .ok_or_else(|| {
6759                EngineError::Storage(StorageError::TableNotFound {
6760                    name: stmt.table.clone(),
6761                })
6762            })?;
6763        // v3.1.5: clone the columns vector only (not the whole
6764        // TableSchema — saves one String alloc for the table name).
6765        // We need an owned snapshot because we'll call `table.insert`
6766        // (mutable borrow on `table`) inside the row loop while
6767        // reading schema fields.
6768        let column_meta: Vec<ColumnSchema> = table.schema().columns.clone();
6769        let schema_cols_len = column_meta.len();
6770        // Build a permutation `tuple_pos[c] = Some(j)` meaning schema
6771        // column `c` is filled from the `j`-th tuple slot; `None` means
6772        // "fill with NULL". Validated once and reused for every row.
6773        let tuple_pos: Option<Vec<Option<usize>>> = match &stmt.columns {
6774            None => None, // 1-1 mapping, fast path
6775            Some(cols) => {
6776                let mut map = alloc::vec![None; schema_cols_len];
6777                for (j, name) in cols.iter().enumerate() {
6778                    let idx = column_meta
6779                        .iter()
6780                        .position(|c| c.name == *name)
6781                        .ok_or_else(|| {
6782                            EngineError::Eval(EvalError::ColumnNotFound { name: name.clone() })
6783                        })?;
6784                    if map[idx].is_some() {
6785                        return Err(EngineError::Storage(StorageError::ArityMismatch {
6786                            expected: schema_cols_len,
6787                            actual: cols.len(),
6788                        }));
6789                    }
6790                    map[idx] = Some(j);
6791                }
6792                // Omitted columns must either be nullable, carry a
6793                // DEFAULT, or be AUTO_INCREMENT. Catch NOT NULL
6794                // omissions up front so the WAL stays clean.
6795                for (i, col) in column_meta.iter().enumerate() {
6796                    if map[i].is_none()
6797                        && !col.nullable
6798                        && col.default.is_none()
6799                        && col.runtime_default.is_none()
6800                        && !col.auto_increment
6801                    {
6802                        return Err(EngineError::Storage(StorageError::NullInNotNull {
6803                            column: col.name.clone(),
6804                        }));
6805                    }
6806                }
6807                Some(map)
6808            }
6809        };
6810        let expected_tuple_len = stmt.columns.as_ref().map_or(schema_cols_len, Vec::len);
6811        // v7.6.2 — snapshot this table's FK list before the
6812        // mutable-borrow window so we can run parent lookups
6813        // against the immutable catalog after parsing. Empty vec is
6814        // the no-FK fast path; clone cost is O(fks * arity) which
6815        // is < 100 ns for typical schemas.
6816        let fks = table.schema().foreign_keys.clone();
6817        let mut affected = 0usize;
6818        // Stage 1 — parse + AUTO_INC + coerce all rows under the
6819        // single mutable borrow.
6820        let mut all_values: Vec<Vec<Value>> = Vec::with_capacity(stmt.rows.len());
6821        for tuple in stmt.rows {
6822            if tuple.len() != expected_tuple_len {
6823                return Err(EngineError::Storage(StorageError::ArityMismatch {
6824                    expected: expected_tuple_len,
6825                    actual: tuple.len(),
6826                }));
6827            }
6828            // Fast path: no column-list permutation → tuple slot j
6829            // maps to schema column j. We can zip schema with tuple
6830            // and skip the `raw_tuple` staging allocation entirely.
6831            let values: Vec<Value> = if let Some(map) = &tuple_pos {
6832                // Permuted path: still need raw_tuple to index by `map[i]`.
6833                let raw_tuple: Vec<Value> = tuple
6834                    .into_iter()
6835                    .map(literal_expr_to_value)
6836                    .collect::<Result<_, _>>()?;
6837                let mut out = Vec::with_capacity(schema_cols_len);
6838                for (i, col) in column_meta.iter().enumerate() {
6839                    let mut raw = match map[i] {
6840                        Some(j) => raw_tuple[j].clone(),
6841                        None => resolve_column_default_free(col, clock)?,
6842                    };
6843                    if col.auto_increment && raw.is_null() {
6844                        let next = table.next_auto_value(i).ok_or_else(|| {
6845                            EngineError::Unsupported(alloc::format!(
6846                                "AUTO_INCREMENT applies to integer columns only (column `{}`)",
6847                                col.name
6848                            ))
6849                        })?;
6850                        raw = Value::BigInt(next);
6851                    }
6852                    let coerced = coerce_value(raw, col.ty, &col.name, i)?;
6853                    enforce_enum_label(&enum_label_lookup, i, &col.name, &coerced)?;
6854                    let coerced =
6855                        canonicalize_set_value(&set_variant_lookup, i, &col.name, coerced)?;
6856                    check_unsigned_range(&coerced, col, i)?;
6857                    out.push(coerced);
6858                }
6859                out
6860            } else {
6861                // 1-1 mapping fast path: single Vec alloc, no raw_tuple.
6862                let mut out = Vec::with_capacity(schema_cols_len);
6863                for (i, (col, expr)) in column_meta.iter().zip(tuple).enumerate() {
6864                    let mut raw = literal_expr_to_value(expr)?;
6865                    if col.auto_increment && raw.is_null() {
6866                        let next = table.next_auto_value(i).ok_or_else(|| {
6867                            EngineError::Unsupported(alloc::format!(
6868                                "AUTO_INCREMENT applies to integer columns only (column `{}`)",
6869                                col.name
6870                            ))
6871                        })?;
6872                        raw = Value::BigInt(next);
6873                    }
6874                    let coerced = coerce_value(raw, col.ty, &col.name, i)?;
6875                    enforce_enum_label(&enum_label_lookup, i, &col.name, &coerced)?;
6876                    let coerced =
6877                        canonicalize_set_value(&set_variant_lookup, i, &col.name, coerced)?;
6878                    check_unsigned_range(&coerced, col, i)?;
6879                    out.push(coerced);
6880                }
6881                out
6882            };
6883            all_values.push(values);
6884        }
6885        // Stage 2 — FK enforcement on the immutable catalog.
6886        // Non-lexical lifetimes release the mutable borrow on
6887        // `table` here since stage 1 was the last use. The
6888        // parent-table lookup runs before any row is committed.
6889        let uniqueness = table.schema().uniqueness_constraints.clone();
6890        let _ = table;
6891        if !fks.is_empty() {
6892            enforce_fk_inserts(self.active_catalog(), &stmt.table, &fks, &all_values)?;
6893        }
6894        // v7.13.0 — CHECK constraint enforcement (mailrs round-5 G3).
6895        enforce_check_constraints(self.active_catalog(), &stmt.table, &all_values)?;
6896        // NOTE (mailrs embed round-12): UNIQUE / PRIMARY KEY and
6897        // UNIQUE INDEX enforcement moved BELOW the ON CONFLICT
6898        // resolution pass. Running them first made every
6899        // `ON CONFLICT … DO UPDATE` upsert fail with a uniqueness
6900        // violation before the conflict handler could route the row
6901        // to an UPDATE — PG resolves the conflict action first and
6902        // only errors on rows no arbiter matched.
6903        // v7.9.8 / v7.9.9 — ON CONFLICT handling.
6904        //   - `DO NOTHING` filters `all_values` to non-conflicting
6905        //     rows + drops within-batch duplicates.
6906        //   - `DO UPDATE SET …` ALSO filters, but for each
6907        //     conflicting row it queues an UPDATE on the existing
6908        //     row using the incoming row's values as `EXCLUDED.*`.
6909        let mut pending_updates: Vec<(usize, Vec<Value>)> = Vec::new();
6910        let mut skipped_count = 0usize;
6911        if let Some(clause) = &stmt.on_conflict {
6912            let conflict_cols = resolve_on_conflict_columns(
6913                self.active_catalog(),
6914                &stmt.table,
6915                clause.target_columns.as_slice(),
6916            )?;
6917            let mut kept: Vec<Vec<Value>> = Vec::with_capacity(all_values.len());
6918            let mut seen_keys: Vec<Vec<Value>> = Vec::new();
6919            for values in all_values {
6920                let key_tuple: Vec<&Value> = conflict_cols.iter().map(|&c| &values[c]).collect();
6921                // SQL spec: NULL in any conflict column means "no
6922                // conflict possible" (NULL ≠ NULL for uniqueness).
6923                let has_null_key = key_tuple.iter().any(|v| matches!(v, Value::Null));
6924                let collides_with_table = !has_null_key
6925                    && on_conflict_keys_exist(
6926                        self.active_catalog(),
6927                        &stmt.table,
6928                        &conflict_cols,
6929                        &key_tuple,
6930                    );
6931                let key_tuple_owned: Vec<Value> = key_tuple.iter().map(|v| (*v).clone()).collect();
6932                let collides_with_batch =
6933                    !has_null_key && seen_keys.iter().any(|k| k == &key_tuple_owned);
6934                let collides = collides_with_table || collides_with_batch;
6935                match (&clause.action, collides) {
6936                    (_, false) => {
6937                        seen_keys.push(key_tuple_owned);
6938                        kept.push(values);
6939                    }
6940                    (spg_sql::ast::OnConflictAction::Nothing, true) => {
6941                        skipped_count += 1;
6942                    }
6943                    (
6944                        spg_sql::ast::OnConflictAction::Update {
6945                            assignments,
6946                            where_,
6947                        },
6948                        true,
6949                    ) => {
6950                        if !collides_with_table {
6951                            skipped_count += 1;
6952                            continue;
6953                        }
6954                        let target_pos = lookup_row_position_by_keys(
6955                            self.active_catalog(),
6956                            &stmt.table,
6957                            &conflict_cols,
6958                            &key_tuple,
6959                        )
6960                        .ok_or_else(|| {
6961                            EngineError::Unsupported(
6962                                "ON CONFLICT DO UPDATE: conflict detected but row \
6963                                 position could not be resolved (cold-tier row?)"
6964                                    .into(),
6965                            )
6966                        })?;
6967                        let updated = apply_on_conflict_assignments(
6968                            self.active_catalog(),
6969                            &stmt.table,
6970                            target_pos,
6971                            &values,
6972                            assignments,
6973                            where_.as_ref(),
6974                        )?;
6975                        if let Some(new_row) = updated {
6976                            pending_updates.push((target_pos, new_row));
6977                        } else {
6978                            skipped_count += 1;
6979                        }
6980                    }
6981                }
6982            }
6983            all_values = kept;
6984        }
6985        // v7.9.19 — composite UNIQUE / PRIMARY KEY enforcement.
6986        // v7.9.29 — CREATE UNIQUE INDEX [WHERE pred] enforcement.
6987        // Both run on the post-ON-CONFLICT row set: conflicting rows
6988        // already left `all_values` (DO NOTHING drop / DO UPDATE
6989        // reroute), so what remains must be genuinely unique.
6990        enforce_uniqueness_inserts(self.active_catalog(), &stmt.table, &uniqueness, &all_values)?;
6991        enforce_unique_index_inserts(self.active_catalog(), &stmt.table, &all_values)?;
6992        // Stage 3 — insert all rows under a fresh mutable borrow.
6993        let table = self
6994            .active_catalog_mut()
6995            .get_mut(&stmt.table)
6996            .ok_or_else(|| {
6997                EngineError::Storage(StorageError::TableNotFound {
6998                    name: stmt.table.clone(),
6999                })
7000            })?;
7001        // v7.9.4 — keep RETURNING projection rows separate per
7002        // INSERT and per UPDATE branch so DO UPDATE pushes the new
7003        // post-update state, not the incoming-only values.
7004        let mut returning_rows: Vec<Vec<Value>> = Vec::new();
7005        // v7.12.7 — collect embedded SQL emitted by any trigger
7006        // fire across the row loop; engine drains the queue after
7007        // the table mut borrow drops.
7008        let mut deferred_embedded: Vec<triggers::DeferredEmbeddedStmt> = Vec::new();
7009        'rowloop: for values in all_values {
7010            let mut row = Row::new(values);
7011            // v7.12.4 — BEFORE INSERT row-level triggers. Each
7012            // trigger may rewrite NEW cells (e.g. populate
7013            // `search_vector := to_tsvector(...)`) and may return
7014            // NULL to skip the row entirely.
7015            for fd in &before_insert_triggers {
7016                let (outcome, deferred) = triggers::fire_row_trigger(
7017                    fd,
7018                    Some(row.clone()),
7019                    None,
7020                    &stmt.table,
7021                    &column_meta,
7022                    &[],
7023                    trigger_session_cfg.as_deref(),
7024                    false,
7025                )
7026                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
7027                deferred_embedded.extend(deferred);
7028                match outcome {
7029                    triggers::TriggerOutcome::Row(r) => row = r,
7030                    triggers::TriggerOutcome::Skip => continue 'rowloop,
7031                }
7032            }
7033            if stmt.returning.is_some() {
7034                returning_rows.push(row.values.clone());
7035            }
7036            // v7.12.4 — clone for the AFTER trigger view; insert
7037            // moves the row into the table.
7038            let inserted = row.clone();
7039            table.insert(row)?;
7040            affected += 1;
7041            // v7.12.4 — AFTER INSERT row-level triggers fire post-
7042            // write. Return value is ignored (PG semantics); we
7043            // surface any error from the body up to the caller.
7044            for fd in &after_insert_triggers {
7045                let (_outcome, deferred) = triggers::fire_row_trigger(
7046                    fd,
7047                    Some(inserted.clone()),
7048                    None,
7049                    &stmt.table,
7050                    &column_meta,
7051                    &[],
7052                    trigger_session_cfg.as_deref(),
7053                    true,
7054                )
7055                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
7056                deferred_embedded.extend(deferred);
7057            }
7058        }
7059        // v7.9.9 — apply ON CONFLICT DO UPDATE rewrites collected
7060        // in the conflict-resolution pass. update_row handles
7061        // index maintenance + body re-encoding.
7062        for (pos, new_row) in pending_updates {
7063            if stmt.returning.is_some() {
7064                returning_rows.push(new_row.clone());
7065            }
7066            table.update_row(pos, new_row)?;
7067            affected += 1;
7068        }
7069        let _ = skipped_count;
7070        // v7.12.7 — drop the table mut borrow and drain any
7071        // trigger-emitted embedded SQL queued during this INSERT.
7072        // The borrow has to release first because each deferred
7073        // stmt may UPDATE / INSERT / DELETE the same (or another)
7074        // table — including, in principle, this one.
7075        let _ = table;
7076        self.execute_deferred_trigger_stmts(deferred_embedded, CancelToken::none())?;
7077        // v7.9.4/v7.9.9 — RETURNING streams the rows that ended
7078        // up in the table after this statement (insert or
7079        // post-update on conflict).
7080        if let Some(items) = &stmt.returning {
7081            return self.build_returning_rows(&stmt.table, items, returning_rows);
7082        }
7083        // v6.2.1 — auto-analyze: track per-table modified-row
7084        // counter so the background sweep can decide when to
7085        // re-ANALYZE. Cheap path on the autocommit-wrap hot loop
7086        // — one BTreeMap entry update per INSERT batch.
7087        if !self.in_transaction() && affected > 0 {
7088            self.statistics
7089                .record_modifications(&stmt.table, affected as u64);
7090        }
7091        Ok(QueryResult::CommandOk {
7092            affected,
7093            modified_catalog: !self.in_transaction(),
7094        })
7095    }
7096
7097    /// v4.5: SELECT with cooperative cancellation. The token is
7098    /// honoured between UNION peers and inside the bare-SELECT row
7099    /// loop; HNSW kNN graph walks and the aggregate executor don't
7100    /// honour it yet (deferred — those paths bound their work
7101    /// internally by `LIMIT k` and `GROUP BY` cardinality).
7102    /// v6.10.2 — cold-tier time-travel scan. Resolves the segment
7103    /// by id, decodes each row body against the table's current
7104    /// schema, applies the SELECT's projection + optional WHERE +
7105    /// optional LIMIT, returns a `Rows` result. JOINs / aggregates
7106    /// / ORDER BY are unsupported on this path (STABILITY carve-
7107    /// out); operators wanting them should restore the segment
7108    /// into a regular table first.
7109    fn exec_select_as_of_segment(
7110        &self,
7111        stmt: &SelectStatement,
7112        from: &spg_sql::ast::FromClause,
7113        segment_id: u32,
7114    ) -> Result<QueryResult, EngineError> {
7115        // v6.10.2 scope: no joins, no aggregates, no ORDER BY,
7116        // no GROUP BY / HAVING / UNION / OFFSET / DISTINCT.
7117        if !from.joins.is_empty()
7118            || stmt.group_by.is_some()
7119            || stmt.having.is_some()
7120            || !stmt.unions.is_empty()
7121            || !stmt.order_by.is_empty()
7122            || stmt.offset.is_some()
7123            || stmt.distinct
7124            || aggregate::uses_aggregate(stmt)
7125        {
7126            return Err(EngineError::Unsupported(
7127                "AS OF SEGMENT supports SELECT projection + WHERE + LIMIT only \
7128                 (joins / aggregates / ORDER BY are STABILITY § \"Out of v6.10\")"
7129                    .into(),
7130            ));
7131        }
7132        let table = self
7133            .active_catalog()
7134            .get(&from.primary.name)
7135            .ok_or_else(|| StorageError::TableNotFound {
7136                name: from.primary.name.clone(),
7137            })?;
7138        let schema = table.schema().clone();
7139        let schema_cols = &schema.columns;
7140        let alias = from
7141            .primary
7142            .alias
7143            .as_deref()
7144            .unwrap_or(from.primary.name.as_str());
7145        let ctx = EvalContext::new(schema_cols, Some(alias));
7146        let seg = self
7147            .active_catalog()
7148            .cold_segment(segment_id)
7149            .ok_or_else(|| {
7150                EngineError::Unsupported(alloc::format!(
7151                    "AS OF SEGMENT: cold segment {segment_id} not registered"
7152                ))
7153            })?;
7154        let mut out_rows: Vec<Row> = Vec::new();
7155        let mut limit_remaining: Option<usize> =
7156            stmt.limit_literal().and_then(|n| usize::try_from(n).ok());
7157        for (_key, body) in seg.scan() {
7158            let (row, _consumed) =
7159                spg_storage::decode_row_body_dense(&body, &schema).map_err(EngineError::Storage)?;
7160            if let Some(where_expr) = &stmt.where_ {
7161                let cond = self.eval_expr_simple(where_expr, &row, &ctx)?;
7162                if !matches!(cond, Value::Bool(true)) {
7163                    continue;
7164                }
7165            }
7166            // Projection.
7167            let projected = self.project_row_simple(&row, &stmt.items, schema_cols, alias)?;
7168            out_rows.push(projected);
7169            if let Some(rem) = limit_remaining.as_mut() {
7170                if *rem == 0 {
7171                    out_rows.pop();
7172                    break;
7173                }
7174                *rem -= 1;
7175            }
7176        }
7177        // Output column schema: derive from SELECT items.
7178        let columns = self.derive_output_columns(&stmt.items, schema_cols, alias);
7179        Ok(QueryResult::Rows {
7180            columns,
7181            rows: out_rows,
7182        })
7183    }
7184
7185    /// v6.10.2 — simple-path WHERE eval that doesn't go through
7186    /// the correlated-subquery / Memoize machinery. AS OF SEGMENT
7187    /// scan paths predicate against a snapshot frozen segment, no
7188    /// cross-row state.
7189    fn eval_expr_simple(
7190        &self,
7191        expr: &Expr,
7192        row: &Row,
7193        ctx: &EvalContext,
7194    ) -> Result<Value, EngineError> {
7195        let cancel = CancelToken::none();
7196        self.eval_expr_with_correlated(expr, row, ctx, cancel, None)
7197    }
7198
7199    /// v7.9.4 — INSERT / UPDATE / DELETE RETURNING projector.
7200    /// Given the table name, the user-supplied projection items,
7201    /// and the mutated rows (post-insert / post-update values, or
7202    /// pre-delete snapshot), build a `QueryResult::Rows` whose
7203    /// schema describes the projected columns. Mailrs migration
7204    /// blocker #1.
7205    fn build_returning_rows(
7206        &self,
7207        table_name: &str,
7208        items: &[SelectItem],
7209        mutated_rows: Vec<Vec<Value>>,
7210    ) -> Result<QueryResult, EngineError> {
7211        let table = self.active_catalog().get(table_name).ok_or_else(|| {
7212            EngineError::Storage(StorageError::TableNotFound {
7213                name: table_name.into(),
7214            })
7215        })?;
7216        let schema_cols = table.schema().columns.clone();
7217        let columns = self.derive_output_columns(items, &schema_cols, table_name);
7218        let mut out_rows: Vec<Row> = Vec::with_capacity(mutated_rows.len());
7219        for values in mutated_rows {
7220            let row = Row::new(values);
7221            let projected = self.project_row_simple(&row, items, &schema_cols, table_name)?;
7222            out_rows.push(projected);
7223        }
7224        Ok(QueryResult::Rows {
7225            columns,
7226            rows: out_rows,
7227        })
7228    }
7229
7230    /// v6.10.2 — projection for AS OF SEGMENT. Resolves
7231    /// `SelectItem::Wildcard` to all schema columns and
7232    /// `SelectItem::Expr` via the regular eval path.
7233    fn project_row_simple(
7234        &self,
7235        row: &Row,
7236        items: &[SelectItem],
7237        schema_cols: &[ColumnSchema],
7238        alias: &str,
7239    ) -> Result<Row, EngineError> {
7240        let ctx = EvalContext::new(schema_cols, Some(alias));
7241        let cancel = CancelToken::none();
7242        let mut out_vals = Vec::new();
7243        for item in items {
7244            match item {
7245                SelectItem::Wildcard => {
7246                    out_vals.extend(row.values.iter().cloned());
7247                }
7248                SelectItem::Expr { expr, .. } => {
7249                    let v = self.eval_expr_with_correlated(expr, row, &ctx, cancel, None)?;
7250                    out_vals.push(v);
7251                }
7252            }
7253        }
7254        Ok(Row::new(out_vals))
7255    }
7256
7257    /// v6.10.2 — derive the output `ColumnSchema` list for an
7258    /// AS OF SEGMENT projection. Wildcards take the full schema;
7259    /// expressions take the alias if present or a synthetic
7260    /// `?column?` (PG convention) otherwise.
7261    fn derive_output_columns(
7262        &self,
7263        items: &[SelectItem],
7264        schema_cols: &[ColumnSchema],
7265        _alias: &str,
7266    ) -> Vec<ColumnSchema> {
7267        let mut out = Vec::new();
7268        for item in items {
7269            match item {
7270                SelectItem::Wildcard => {
7271                    out.extend(schema_cols.iter().cloned());
7272                }
7273                SelectItem::Expr { expr, alias } => {
7274                    // Bare column references inherit the schema
7275                    // column's name + type — PG names `RETURNING id`
7276                    // "id" and types it BIGINT, and the sqlx embed
7277                    // path type-checks RowDescription against the
7278                    // Rust target (mailrs embed round-12).
7279                    if let Expr::Column(col) = expr
7280                        && let Some(sc) = schema_cols.iter().find(|c| c.name == col.name)
7281                    {
7282                        let name = alias.clone().unwrap_or_else(|| sc.name.clone());
7283                        out.push(ColumnSchema::new(name, sc.ty, sc.nullable));
7284                        continue;
7285                    }
7286                    let name = alias.clone().unwrap_or_else(|| "?column?".to_string());
7287                    // Default to Text; the caller's row values
7288                    // carry the actual type. v6.10.2 scope.
7289                    out.push(ColumnSchema::new(name, DataType::Text, true));
7290                }
7291            }
7292        }
7293        out
7294    }
7295
7296    fn exec_select_cancel(
7297        &self,
7298        stmt: &SelectStatement,
7299        cancel: CancelToken<'_>,
7300    ) -> Result<QueryResult, EngineError> {
7301        cancel.check()?;
7302        // v7.17.0 Phase 1.2 — user-defined VIEW expansion. If the
7303        // FROM / JOIN graph references any catalogued view name,
7304        // re-parse the view body and prepend it as a synthetic
7305        // CTE. Recurses on views-in-views via the regular CTE
7306        // dispatch below. Fast-path: skip the walker entirely when
7307        // the catalog has no views (the typical OLTP load).
7308        if !self.active_catalog().views().is_empty() {
7309            if let Some(rewritten) = self.expand_views_in_select(stmt)? {
7310                return self.exec_select_cancel(&rewritten, cancel);
7311            }
7312        }
7313        // v7.16.2 — information_schema / pg_catalog virtual
7314        // views (mailrs round-10 A.3). If the SELECT touches a
7315        // synthetic meta-table name (`__spg_info_*` /
7316        // `__spg_pg_*` — produced by the parser for
7317        // `information_schema.X` / `pg_catalog.X`), clone the
7318        // catalog, materialise the requested view as a real
7319        // temporary table, and re-execute against an enriched
7320        // engine. Same pattern as `exec_with_ctes` for CTEs.
7321        if !self.meta_views_materialised && select_references_meta_view(stmt) {
7322            return self.exec_select_with_meta_views(stmt, cancel);
7323        }
7324        // v6.10.2 — cold-tier time-travel short-circuit. When the
7325        // primary TableRef carries `AS OF SEGMENT '<id>'`, run a
7326        // dedicated cold-segment scan instead of the regular
7327        // hot+index path. The scope is intentionally narrow for
7328        // v6.10.2 — bare `SELECT * FROM <t> AS OF SEGMENT 'id'`,
7329        // optionally with a single-column-equality WHERE. JOINs /
7330        // aggregates / ORDER BY / subqueries on top of a time-
7331        // travelled scan are STABILITY § "Out of v6.10".
7332        if let Some(from) = &stmt.from
7333            && let Some(seg_id) = from.primary.as_of_segment
7334        {
7335            return self.exec_select_as_of_segment(stmt, from, seg_id);
7336        }
7337        // v6.2.0 / v6.5.0 — virtual-table short-circuits. Detected
7338        // pre-CTE because they don't read from the catalog and
7339        // shouldn't participate in regular FROM resolution.
7340        if let Some(from) = &stmt.from
7341            && from.joins.is_empty()
7342            && stmt.where_.is_none()
7343            && stmt.group_by.is_none()
7344            && stmt.having.is_none()
7345            && stmt.unions.is_empty()
7346            && stmt.order_by.is_empty()
7347            && stmt.limit.is_none()
7348            && stmt.offset.is_none()
7349            && !stmt.distinct
7350            && stmt.items.iter().all(|i| matches!(i, SelectItem::Wildcard))
7351        {
7352            let lower = from.primary.name.to_ascii_lowercase();
7353            match lower.as_str() {
7354                "spg_statistic" => return Ok(self.exec_spg_statistic()),
7355                // v6.5.0 — observability v2 virtual tables.
7356                "spg_stat_replication" => return Ok(self.exec_spg_stat_replication()),
7357                "spg_stat_segment" => return Ok(self.exec_spg_stat_segment()),
7358                "spg_stat_query" => return Ok(self.exec_spg_stat_query()),
7359                "spg_stat_activity" => return Ok(self.exec_spg_stat_activity()),
7360                "spg_audit_chain" => return Ok(self.exec_spg_audit_chain()),
7361                "spg_audit_verify" => return Ok(self.exec_spg_audit_verify()),
7362                "spg_table_ddl" => return Ok(self.exec_spg_table_ddl()),
7363                "spg_role_ddl" => return Ok(self.exec_spg_role_ddl()),
7364                "spg_database_ddl" => return Ok(self.exec_spg_database_ddl()),
7365                _ => {}
7366            }
7367        }
7368        // v4.11: CTEs materialise into a temporary enriched catalog
7369        // *before* anything else — the body SELECT can then refer
7370        // to CTE names via the regular FROM-clause resolution.
7371        // Uncorrelated only: each CTE body runs once against the
7372        // current catalog, not against later CTEs' results (left-
7373        // to-right materialisation would relax this, but we keep
7374        // it simple for v4.11 MVP).
7375        if !stmt.ctes.is_empty() {
7376            return self.exec_with_ctes(stmt, cancel);
7377        }
7378        // v4.10: subqueries (uncorrelated) are resolved here, before
7379        // the executor sees the row loop. We clone the statement so
7380        // we can mutate without disturbing the caller's AST — most
7381        // queries pass through with no subquery nodes and the clone
7382        // is cheap; with subqueries the materialisation cost
7383        // dominates anyway.
7384        let mut stmt_owned;
7385        let stmt_ref: &SelectStatement = if expr_tree_has_subquery(stmt) {
7386            stmt_owned = stmt.clone();
7387            self.resolve_select_subqueries(&mut stmt_owned, cancel)?;
7388            &stmt_owned
7389        } else {
7390            stmt
7391        };
7392        if stmt_ref.unions.is_empty() {
7393            return self.exec_bare_select_cancel(stmt_ref, cancel);
7394        }
7395        // UNION path: clone-strip the head into a bare block (its own
7396        // DISTINCT and any inner ORDER BY are dropped by parser rule —
7397        // the wrapper SelectStatement carries them), execute, then chain
7398        // peers with left-associative dedup semantics.
7399        let mut head = stmt_ref.clone();
7400        head.unions = Vec::new();
7401        head.order_by = Vec::new();
7402        head.limit = None;
7403        let QueryResult::Rows { columns, mut rows } =
7404            self.exec_bare_select_cancel(&head, cancel)?
7405        else {
7406            unreachable!("bare SELECT cannot return CommandOk")
7407        };
7408        for (kind, peer) in &stmt_ref.unions {
7409            let QueryResult::Rows {
7410                columns: peer_cols,
7411                rows: peer_rows,
7412            } = self.exec_bare_select_cancel(peer, cancel)?
7413            else {
7414                unreachable!("bare SELECT cannot return CommandOk")
7415            };
7416            if peer_cols.len() != columns.len() {
7417                return Err(EngineError::Unsupported(alloc::format!(
7418                    "UNION arity mismatch: head has {} columns, peer has {}",
7419                    columns.len(),
7420                    peer_cols.len()
7421                )));
7422            }
7423            rows.extend(peer_rows);
7424            if matches!(kind, UnionKind::Distinct) {
7425                rows = dedup_rows(rows);
7426            }
7427        }
7428        // ORDER BY at the top of a UNION applies to the combined result.
7429        // Eval against the projected schema (NOT the source table).
7430        if !stmt.order_by.is_empty() {
7431            let synth_ctx = EvalContext::new(&columns, None);
7432            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
7433            let mut tagged: Vec<(Vec<f64>, Row)> = Vec::with_capacity(rows.len());
7434            for r in rows {
7435                let keys = build_order_keys(&stmt.order_by, &r, &synth_ctx)?;
7436                tagged.push((keys, r));
7437            }
7438            sort_by_keys(&mut tagged, &descs);
7439            rows = tagged.into_iter().map(|(_, r)| r).collect();
7440        }
7441        apply_offset_and_limit(&mut rows, stmt.offset_literal(), stmt.limit_literal());
7442        Ok(QueryResult::Rows { columns, rows })
7443    }
7444
7445    #[allow(clippy::too_many_lines)]
7446    #[allow(clippy::too_many_lines)] // huge match — splitting fragments the planner
7447    /// v7.11.7 — execute `SELECT … FROM unnest(expr) [AS] alias …`.
7448    /// Synthesises a single-column virtual table whose column type
7449    /// is TEXT and whose rows are the array elements. Routes
7450    /// through the regular projection / WHERE / ORDER BY / LIMIT
7451    /// machinery so set-returning UNNEST composes naturally with
7452    /// the rest of the SELECT surface.
7453    fn exec_select_unnest(
7454        &self,
7455        stmt: &SelectStatement,
7456        primary: &TableRef,
7457        cancel: CancelToken<'_>,
7458    ) -> Result<QueryResult, EngineError> {
7459        let expr = primary
7460            .unnest_expr
7461            .as_deref()
7462            .expect("caller guards unnest_expr.is_some()");
7463        // Evaluate the array expression once. Empty schema / empty
7464        // row — uncorrelated UNNEST cannot reference outer columns.
7465        let empty_schema: alloc::vec::Vec<ColumnSchema> = alloc::vec::Vec::new();
7466        let ctx = EvalContext::new(&empty_schema, None);
7467        let dummy_row = Row::new(alloc::vec::Vec::new());
7468        // v7.11.13 — unnest dispatches per array element type so
7469        // INT[] / BIGINT[] surface their PG types in projection.
7470        let (elem_dtype, rows): (DataType, alloc::vec::Vec<Row>) =
7471            match eval::eval_expr(expr, &dummy_row, &ctx).map_err(EngineError::Eval)? {
7472                Value::Null => (DataType::Text, alloc::vec::Vec::new()),
7473                Value::TextArray(items) => {
7474                    let rows = items
7475                        .into_iter()
7476                        .map(|item| {
7477                            Row::new(alloc::vec![match item {
7478                                Some(s) => Value::Text(s),
7479                                None => Value::Null,
7480                            }])
7481                        })
7482                        .collect();
7483                    (DataType::Text, rows)
7484                }
7485                Value::IntArray(items) => {
7486                    let rows = items
7487                        .into_iter()
7488                        .map(|item| {
7489                            Row::new(alloc::vec![match item {
7490                                Some(n) => Value::Int(n),
7491                                None => Value::Null,
7492                            }])
7493                        })
7494                        .collect();
7495                    (DataType::Int, rows)
7496                }
7497                Value::BigIntArray(items) => {
7498                    let rows = items
7499                        .into_iter()
7500                        .map(|item| {
7501                            Row::new(alloc::vec![match item {
7502                                Some(n) => Value::BigInt(n),
7503                                None => Value::Null,
7504                            }])
7505                        })
7506                        .collect();
7507                    (DataType::BigInt, rows)
7508                }
7509                other => {
7510                    return Err(EngineError::Unsupported(alloc::format!(
7511                        "unnest() expects an array argument, got {:?}",
7512                        other.data_type()
7513                    )));
7514                }
7515            };
7516        let alias = primary
7517            .alias
7518            .clone()
7519            .unwrap_or_else(|| "unnest".to_string());
7520        // v7.13.2 — mailrs round-6 S5. Honour PG-standard
7521        // `UNNEST(arr) AS p(col_name)` column-list aliasing: the
7522        // first entry overrides the projected column's name.
7523        // Without the column list, fall back to the table alias
7524        // (pre-v7.13.2 behaviour).
7525        let col_name = primary
7526            .unnest_column_aliases
7527            .first()
7528            .cloned()
7529            .unwrap_or_else(|| alias.clone());
7530        let col_schema = ColumnSchema::new(col_name, elem_dtype, true);
7531        let schema_cols = alloc::vec![col_schema.clone()];
7532        let scan_ctx = EvalContext::new(&schema_cols, Some(&alias));
7533        // Apply WHERE.
7534        let filtered: alloc::vec::Vec<Row> = if let Some(w) = &stmt.where_ {
7535            let mut out = alloc::vec::Vec::with_capacity(rows.len());
7536            for row in rows {
7537                cancel.check()?;
7538                let v = eval::eval_expr(w, &row, &scan_ctx).map_err(EngineError::Eval)?;
7539                if matches!(v, Value::Bool(true)) {
7540                    out.push(row);
7541                }
7542            }
7543            out
7544        } else {
7545            rows
7546        };
7547        // v7.17.0 Phase 3.P0-48 — aggregate dispatch over the
7548        // unnest source. Same routing the relational scan path
7549        // already takes — without it `SELECT COUNT(*) FROM
7550        // unnest(ARRAY[…])` either errored at projection time or
7551        // returned the wrong shape.
7552        if aggregate::uses_aggregate(stmt) {
7553            let filtered_refs: alloc::vec::Vec<&Row> = filtered.iter().collect();
7554            let mut agg = aggregate::run(stmt, &filtered_refs, &schema_cols, Some(&alias))?;
7555            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
7556            return Ok(QueryResult::Rows {
7557                columns: agg.columns,
7558                rows: agg.rows,
7559            });
7560        }
7561        // Projection.
7562        let projection = build_projection(&stmt.items, &schema_cols, &alias)?;
7563        let mut projected_rows: alloc::vec::Vec<Row> =
7564            alloc::vec::Vec::with_capacity(filtered.len());
7565        // v7.19 P5 — Set-Returning-Function in projection
7566        // position (PG `SELECT unnest(arr) FROM t` shape). When a
7567        // SELECT item evaluates to a top-level unnest(arr) call,
7568        // expand it: for each input row, evaluate the array, emit
7569        // one output row per element, broadcasting non-SRF
7570        // projections from the same input row. Multi-SRF + LCM
7571        // padding stays a documented carve-out; mailrs uses
7572        // single-SRF for redirect_uris.
7573        let srf_position = projection.iter().position(|p| is_top_level_unnest(&p.expr));
7574        if let Some(srf_idx) = srf_position {
7575            let srf_arg = top_level_unnest_arg(&projection[srf_idx].expr)
7576                .expect("checked by is_top_level_unnest above");
7577            for row in &filtered {
7578                let arr_val =
7579                    eval::eval_expr(srf_arg, row, &scan_ctx).map_err(EngineError::Eval)?;
7580                let elements = array_value_to_elements(&arr_val)?;
7581                // Empty array → zero rows for this input row (PG
7582                // semantics: `SELECT unnest('{}'::int[])` returns
7583                // 0 rows, not a single NULL row).
7584                for elem in elements {
7585                    let mut vals = alloc::vec::Vec::with_capacity(projection.len());
7586                    for (i, p) in projection.iter().enumerate() {
7587                        if i == srf_idx {
7588                            vals.push(elem.clone());
7589                        } else {
7590                            vals.push(
7591                                eval::eval_expr(&p.expr, row, &scan_ctx)
7592                                    .map_err(EngineError::Eval)?,
7593                            );
7594                        }
7595                    }
7596                    projected_rows.push(Row::new(vals));
7597                }
7598            }
7599        } else {
7600            for row in &filtered {
7601                let mut vals = alloc::vec::Vec::with_capacity(projection.len());
7602                for p in &projection {
7603                    vals.push(eval::eval_expr(&p.expr, row, &scan_ctx).map_err(EngineError::Eval)?);
7604                }
7605                projected_rows.push(Row::new(vals));
7606            }
7607        }
7608        // ORDER BY / LIMIT — apply on the projected rows (cheap;
7609        // unnest result sets are small by design).
7610        let columns: alloc::vec::Vec<ColumnSchema> = projection
7611            .iter()
7612            .map(|p| ColumnSchema::new(p.output_name.clone(), p.ty, p.nullable))
7613            .collect();
7614        // Re-evaluate ORDER BY against the source schema (pre-projection
7615        // so col refs by name still resolve through `scan_ctx`).
7616        if !stmt.order_by.is_empty() {
7617            let mut indexed: alloc::vec::Vec<(usize, Vec<Value>)> = filtered
7618                .iter()
7619                .enumerate()
7620                .map(|(i, r)| -> Result<_, EngineError> {
7621                    let keys: Result<Vec<Value>, EngineError> = stmt
7622                        .order_by
7623                        .iter()
7624                        .map(|ob| {
7625                            eval::eval_expr(&ob.expr, r, &scan_ctx).map_err(EngineError::Eval)
7626                        })
7627                        .collect();
7628                    Ok((i, keys?))
7629                })
7630                .collect::<Result<_, _>>()?;
7631            indexed.sort_by(|a, b| {
7632                for (idx, (ka, kb)) in a.1.iter().zip(b.1.iter()).enumerate() {
7633                    let mut cmp = value_cmp(ka, kb);
7634                    if stmt.order_by[idx].desc {
7635                        cmp = cmp.reverse();
7636                    }
7637                    if cmp != core::cmp::Ordering::Equal {
7638                        return cmp;
7639                    }
7640                }
7641                core::cmp::Ordering::Equal
7642            });
7643            projected_rows = indexed
7644                .into_iter()
7645                .map(|(i, _)| projected_rows[i].clone())
7646                .collect();
7647        }
7648        // LIMIT / OFFSET — apply at the tail.
7649        if let Some(offset) = stmt.offset_literal() {
7650            let off = (offset as usize).min(projected_rows.len());
7651            projected_rows.drain(..off);
7652        }
7653        if let Some(limit) = stmt.limit_literal() {
7654            projected_rows.truncate(limit as usize);
7655        }
7656        Ok(QueryResult::Rows {
7657            columns,
7658            rows: projected_rows,
7659        })
7660    }
7661
7662    /// v7.17.0 Phase 3.10 — `FROM generate_series(start, stop [,
7663    /// step])` set-returning source. Mirrors `exec_select_unnest`'s
7664    /// shape: evaluate the arg list once against an empty row,
7665    /// materialise the row stream by stepping start → stop, then
7666    /// route through the standard WHERE / projection / ORDER BY /
7667    /// LIMIT pipeline. Two arg-type combos in v7.17:
7668    ///   * integer / integer [/ integer] — SmallInt, Int, BigInt
7669    ///     (widened to BigInt internally; step defaults to 1)
7670    ///   * timestamp / timestamp / interval — date-range
7671    ///     iteration (mailrs's daily-report pattern)
7672    fn exec_select_generate_series(
7673        &self,
7674        stmt: &SelectStatement,
7675        primary: &TableRef,
7676        cancel: CancelToken<'_>,
7677    ) -> Result<QueryResult, EngineError> {
7678        let args = primary
7679            .generate_series_args
7680            .as_ref()
7681            .expect("caller guards generate_series_args.is_some()");
7682        let empty_schema: alloc::vec::Vec<ColumnSchema> = alloc::vec::Vec::new();
7683        let ctx = EvalContext::new(&empty_schema, None);
7684        let dummy_row = Row::new(alloc::vec::Vec::new());
7685        let mut arg_values: alloc::vec::Vec<Value> = alloc::vec::Vec::with_capacity(args.len());
7686        for a in args {
7687            arg_values.push(eval::eval_expr(a, &dummy_row, &ctx).map_err(EngineError::Eval)?);
7688        }
7689        // Dispatch on the start value's shape. Reject mixed-shape
7690        // calls early (e.g. start = timestamp, stop = integer) so
7691        // the caller gets a clean error rather than a panic.
7692        let (elem_dtype, rows) = match arg_values.as_slice() {
7693            [Value::Timestamp(start), Value::Timestamp(stop), step] => {
7694                let interval_step = match step {
7695                    Value::Interval { .. } => step.clone(),
7696                    other => {
7697                        return Err(EngineError::Unsupported(alloc::format!(
7698                            "generate_series(timestamp, timestamp, …): \
7699                             step must be INTERVAL, got {:?}",
7700                            other.data_type()
7701                        )));
7702                    }
7703                };
7704                let rows = generate_series_timestamps(*start, *stop, interval_step, &cancel)?;
7705                (DataType::Timestamp, rows)
7706            }
7707            [start, stop, step]
7708                if value_is_integer(start) && value_is_integer(stop) && value_is_integer(step) =>
7709            {
7710                let s = value_to_i64(start);
7711                let e = value_to_i64(stop);
7712                let st = value_to_i64(step);
7713                let rows = generate_series_integers(s, e, st, &cancel)?;
7714                (DataType::BigInt, rows)
7715            }
7716            [start, stop] if value_is_integer(start) && value_is_integer(stop) => {
7717                let s = value_to_i64(start);
7718                let e = value_to_i64(stop);
7719                let rows = generate_series_integers(s, e, 1, &cancel)?;
7720                (DataType::BigInt, rows)
7721            }
7722            _ => {
7723                return Err(EngineError::Unsupported(alloc::format!(
7724                    "generate_series(): v7.17 supports integer or (timestamp, timestamp, interval) \
7725                     argument shapes; got {:?}",
7726                    arg_values
7727                        .iter()
7728                        .map(|v| v.data_type())
7729                        .collect::<alloc::vec::Vec<_>>()
7730                )));
7731            }
7732        };
7733        let alias = primary
7734            .alias
7735            .clone()
7736            .unwrap_or_else(|| "generate_series".to_string());
7737        let col_name = alias.clone();
7738        let col_schema = ColumnSchema::new(col_name, elem_dtype, true);
7739        let schema_cols = alloc::vec![col_schema.clone()];
7740        let scan_ctx = EvalContext::new(&schema_cols, Some(&alias));
7741        // WHERE.
7742        let filtered: alloc::vec::Vec<Row> = if let Some(w) = &stmt.where_ {
7743            let mut out = alloc::vec::Vec::with_capacity(rows.len());
7744            for row in rows {
7745                cancel.check()?;
7746                let v = eval::eval_expr(w, &row, &scan_ctx).map_err(EngineError::Eval)?;
7747                if matches!(v, Value::Bool(true)) {
7748                    out.push(row);
7749                }
7750            }
7751            out
7752        } else {
7753            rows
7754        };
7755        // v7.17.0 Phase 3.P0-48 — aggregate dispatch for set-
7756        // returning sources. When the SELECT projection contains
7757        // aggregate functions (COUNT/SUM/MIN/MAX/AVG/string_agg/
7758        // …) we route the filtered row stream through the same
7759        // aggregate executor the relational scan path uses, so
7760        // `SELECT COUNT(*) FROM generate_series(1, 100)` returns
7761        // a single 100 row instead of erroring at projection
7762        // time. GROUP BY / HAVING / ORDER BY over the aggregate
7763        // output all ride through `aggregate::run`.
7764        if aggregate::uses_aggregate(stmt) {
7765            let filtered_refs: alloc::vec::Vec<&Row> = filtered.iter().collect();
7766            let mut agg = aggregate::run(stmt, &filtered_refs, &schema_cols, Some(&alias))?;
7767            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
7768            return Ok(QueryResult::Rows {
7769                columns: agg.columns,
7770                rows: agg.rows,
7771            });
7772        }
7773        // Projection.
7774        let projection = build_projection(&stmt.items, &schema_cols, &alias)?;
7775        let mut projected_rows: alloc::vec::Vec<Row> =
7776            alloc::vec::Vec::with_capacity(filtered.len());
7777        for row in &filtered {
7778            let mut vals = alloc::vec::Vec::with_capacity(projection.len());
7779            for p in &projection {
7780                vals.push(eval::eval_expr(&p.expr, row, &scan_ctx).map_err(EngineError::Eval)?);
7781            }
7782            projected_rows.push(Row::new(vals));
7783        }
7784        let columns: alloc::vec::Vec<ColumnSchema> = projection
7785            .iter()
7786            .map(|p| ColumnSchema::new(p.output_name.clone(), p.ty, p.nullable))
7787            .collect();
7788        // ORDER BY against the source schema.
7789        if !stmt.order_by.is_empty() {
7790            let mut indexed: alloc::vec::Vec<(usize, Vec<Value>)> = filtered
7791                .iter()
7792                .enumerate()
7793                .map(|(i, r)| -> Result<_, EngineError> {
7794                    let keys: Result<Vec<Value>, EngineError> = stmt
7795                        .order_by
7796                        .iter()
7797                        .map(|ob| {
7798                            eval::eval_expr(&ob.expr, r, &scan_ctx).map_err(EngineError::Eval)
7799                        })
7800                        .collect();
7801                    Ok((i, keys?))
7802                })
7803                .collect::<Result<_, _>>()?;
7804            indexed.sort_by(|a, b| {
7805                for (idx, (ka, kb)) in a.1.iter().zip(b.1.iter()).enumerate() {
7806                    let mut cmp = value_cmp(ka, kb);
7807                    if stmt.order_by[idx].desc {
7808                        cmp = cmp.reverse();
7809                    }
7810                    if cmp != core::cmp::Ordering::Equal {
7811                        return cmp;
7812                    }
7813                }
7814                core::cmp::Ordering::Equal
7815            });
7816            projected_rows = indexed
7817                .into_iter()
7818                .map(|(i, _)| projected_rows[i].clone())
7819                .collect();
7820        }
7821        if let Some(offset) = stmt.offset_literal() {
7822            let off = (offset as usize).min(projected_rows.len());
7823            projected_rows.drain(..off);
7824        }
7825        if let Some(limit) = stmt.limit_literal() {
7826            projected_rows.truncate(limit as usize);
7827        }
7828        Ok(QueryResult::Rows {
7829            columns,
7830            rows: projected_rows,
7831        })
7832    }
7833
7834    fn exec_bare_select_cancel(
7835        &self,
7836        stmt: &SelectStatement,
7837        cancel: CancelToken<'_>,
7838    ) -> Result<QueryResult, EngineError> {
7839        // v7.17.0 Phase 3.P0-49 — `FETCH FIRST N ROWS WITH TIES`
7840        // is meaningless without an ORDER BY; PG raises a hard
7841        // error and SPG mirrors the surface so the same DDL/app
7842        // path behaves identically on cutover.
7843        check_with_ties_requires_order_by(stmt)?;
7844        // v7.16.2 — same meta-view dispatch as
7845        // `exec_select_cancel`, applied here too because
7846        // `subquery_replacement` enters this function directly
7847        // for Exists / ScalarSubquery / InSubquery resolution
7848        // (bypassing the top-level entry to avoid double
7849        // subquery walking). Without this dispatch the subquery
7850        // hits `__spg_info_columns` and reports TableNotFound.
7851        if !self.meta_views_materialised && select_references_meta_view(stmt) {
7852            return self.exec_select_with_meta_views(stmt, cancel);
7853        }
7854        // v4.12: window-function path. When the projection contains
7855        // any `name(args) OVER (...)` we route to the dedicated
7856        // executor — partition + sort + per-row window value before
7857        // the regular projection.
7858        if select_has_window(stmt) {
7859            return self.exec_select_with_window(stmt, cancel);
7860        }
7861        // Constant SELECT (no FROM) — evaluate each item once against an
7862        // empty dummy row. Useful for `SELECT 1`, `SELECT coalesce(...)`,
7863        // `SELECT '7'::INT`. Column references will surface as
7864        // ColumnNotFound on eval since the schema is empty.
7865        let Some(from) = &stmt.from else {
7866            let empty_schema: Vec<ColumnSchema> = Vec::new();
7867            let ctx = self.ev_ctx(&empty_schema, None);
7868            let projection = build_projection(&stmt.items, &empty_schema, "")?;
7869            let dummy_row = Row::new(Vec::new());
7870            let mut values = Vec::with_capacity(projection.len());
7871            for p in &projection {
7872                values.push(eval::eval_expr(&p.expr, &dummy_row, &ctx)?);
7873            }
7874            let columns: Vec<ColumnSchema> = projection
7875                .into_iter()
7876                .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
7877                .collect();
7878            return Ok(QueryResult::Rows {
7879                columns,
7880                rows: alloc::vec![Row::new(values)],
7881            });
7882        };
7883        // Multi-table FROM (one or more joined peers) goes through the
7884        // nested-loop join executor. Single-table FROM stays on the
7885        // existing scan + index-seek path.
7886        if !from.joins.is_empty() {
7887            return self.exec_joined_select(stmt, from);
7888        }
7889        // v7.11.7 — `FROM unnest(<expr>) [AS] <alias>`. Synthesise a
7890        // single-column table at SELECT entry by evaluating the
7891        // expression once against the empty row (UNNEST is
7892        // uncorrelated in v7.11; correlated / LATERAL unnest is a
7893        // v7.12 carve-out). Build a virtual `Table` in a heap-only
7894        // catalog, then route to the regular scan path.
7895        if from.primary.unnest_expr.is_some() {
7896            return self.exec_select_unnest(stmt, &from.primary, cancel);
7897        }
7898        // v7.17.0 Phase 3.10 — `FROM generate_series(start, stop
7899        // [, step])` set-returning source. Dispatch mirrors UNNEST:
7900        // materialise the row stream from a single eval pass, then
7901        // run the regular projection / WHERE / ORDER BY / LIMIT
7902        // pipeline over the synthetic single-column table.
7903        if from.primary.generate_series_args.is_some() {
7904            return self.exec_select_generate_series(stmt, &from.primary, cancel);
7905        }
7906        let primary = &from.primary;
7907        let table = self.active_catalog().get(&primary.name).ok_or_else(|| {
7908            StorageError::TableNotFound {
7909                name: primary.name.clone(),
7910            }
7911        })?;
7912        let schema_cols = &table.schema().columns;
7913        // The qualifier accepted on column refs is the alias (if any) else the
7914        // bare table name.
7915        let alias = primary.alias.as_deref().unwrap_or(primary.name.as_str());
7916        let ctx = self.ev_ctx(schema_cols, Some(alias));
7917
7918        // NSW kNN planner: `ORDER BY col <-> literal LIMIT k` with no
7919        // WHERE and an NSW index on `col` skips the full scan. The
7920        // walk returns rows already in ascending-distance order, so
7921        // ORDER BY / LIMIT are honoured implicitly.
7922        if let Some(nsw_rows) = try_nsw_knn(stmt, table, schema_cols, alias) {
7923            return materialise_in_order(stmt, table, schema_cols, alias, &nsw_rows);
7924        }
7925
7926        // Index seek: if WHERE is `col = literal` (or commuted) and the
7927        // referenced column has an index, dispatch each locator through
7928        // the catalog (hot tier → borrow, cold tier → page-read +
7929        // decode) and iterate just those rows. Otherwise fall back to a
7930        // full scan over the hot tier (cold-tier rows are only reached
7931        // via index seek in v5.1 — full table scans against cold-tier
7932        // data ship in v5.2 with the freezer's per-segment scan API).
7933        let indexed_rows: Option<Vec<Cow<'_, Row>>> = stmt.where_.as_ref().and_then(|w| {
7934            // BTree / col=literal seek first — covers the v7.11.3 multi-
7935            // column AND case and the leading-column equality lookup.
7936            try_index_seek(w, schema_cols, self.active_catalog(), table, alias)
7937                .or_else(|| {
7938                    // v7.12.3 — GIN-accelerated `WHERE col @@
7939                    // tsquery` when the column has a `USING gin`
7940                    // index. Returns an over-approximate candidate
7941                    // set; the WHERE re-eval loop below verifies
7942                    // the full `@@` predicate per row.
7943                    try_gin_seek(w, schema_cols, self.active_catalog(), table, alias, &ctx)
7944                })
7945                .or_else(|| {
7946                    // v7.15.0 — trigram-GIN-accelerated
7947                    // `WHERE col LIKE / ILIKE '<pat>'` when the
7948                    // column has a `gin_trgm_ops` GIN index.
7949                    // Over-approximate candidate set; the WHERE
7950                    // re-eval verifies the LIKE per row.
7951                    try_trgm_seek(w, schema_cols, table, alias)
7952                })
7953        });
7954
7955        // Aggregate path: filter rows first, then hand off to the
7956        // aggregate executor which does its own projection + ORDER BY.
7957        if aggregate::uses_aggregate(stmt) {
7958            let mut filtered: Vec<&Row> = Vec::new();
7959            // v6.2.6 — Memoize: per-query LRU cache for correlated
7960            // scalar subqueries. Fresh per row-loop entry so each
7961            // SELECT execution gets an isolated cache.
7962            let mut memo = memoize::MemoizeCache::new();
7963            if let Some(rows) = &indexed_rows {
7964                for cow in rows {
7965                    let row = cow.as_ref();
7966                    if let Some(where_expr) = &stmt.where_ {
7967                        let cond = self.eval_expr_with_correlated(
7968                            where_expr,
7969                            row,
7970                            &ctx,
7971                            cancel,
7972                            Some(&mut memo),
7973                        )?;
7974                        if !matches!(cond, Value::Bool(true)) {
7975                            continue;
7976                        }
7977                    }
7978                    filtered.push(row);
7979                }
7980            } else {
7981                for i in 0..table.row_count() {
7982                    let row = &table.rows()[i];
7983                    if let Some(where_expr) = &stmt.where_ {
7984                        let cond = self.eval_expr_with_correlated(
7985                            where_expr,
7986                            row,
7987                            &ctx,
7988                            cancel,
7989                            Some(&mut memo),
7990                        )?;
7991                        if !matches!(cond, Value::Bool(true)) {
7992                            continue;
7993                        }
7994                    }
7995                    filtered.push(row);
7996                }
7997            }
7998            let mut agg = aggregate::run(stmt, &filtered, schema_cols, Some(alias))?;
7999            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
8000            return Ok(QueryResult::Rows {
8001                columns: agg.columns,
8002                rows: agg.rows,
8003            });
8004        }
8005
8006        let projection = build_projection(&stmt.items, schema_cols, alias)?;
8007        // v7.19 P5 — single-table SELECT path for SRF
8008        // `SELECT unnest(arr) FROM t` shape. Detect a top-level
8009        // unnest in the projection list. When present, the
8010        // per-row processor emits one output row per array
8011        // element (broadcasting non-SRF projections from the
8012        // same input row). Empty / NULL arrays emit zero rows
8013        // for that input — PG semantics.
8014        let srf_position = projection.iter().position(|p| is_top_level_unnest(&p.expr));
8015
8016        // Materialise the filter pass into `(order_key, projected_row)`
8017        // tuples. The order key is `None` when there's no ORDER BY clause.
8018        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::new();
8019        // v6.2.6 — Memoize per-row WHERE eval shares one cache.
8020        let mut memo = memoize::MemoizeCache::new();
8021        // Inline the per-row work in a closure so the indexed and full-
8022        // scan branches share the body.
8023        let mut process_row = |row: &Row, loop_idx: usize| -> Result<(), EngineError> {
8024            if loop_idx.is_multiple_of(256) {
8025                cancel.check()?;
8026            }
8027            if let Some(where_expr) = &stmt.where_ {
8028                let cond =
8029                    self.eval_expr_with_correlated(where_expr, row, &ctx, cancel, Some(&mut memo))?;
8030                if !matches!(cond, Value::Bool(true)) {
8031                    return Ok(());
8032                }
8033            }
8034            let order_keys = if stmt.order_by.is_empty() {
8035                Vec::new()
8036            } else {
8037                build_order_keys(&stmt.order_by, row, &ctx)?
8038            };
8039            if let Some(srf_idx) = srf_position {
8040                let srf_arg = top_level_unnest_arg(&projection[srf_idx].expr)
8041                    .expect("checked by is_top_level_unnest above");
8042                let arr_val = eval::eval_expr(srf_arg, row, &ctx)?;
8043                let elements = array_value_to_elements(&arr_val)?;
8044                for elem in elements {
8045                    let mut values = Vec::with_capacity(projection.len());
8046                    for (i, p) in projection.iter().enumerate() {
8047                        if i == srf_idx {
8048                            values.push(elem.clone());
8049                        } else {
8050                            values.push(eval::eval_expr(&p.expr, row, &ctx)?);
8051                        }
8052                    }
8053                    tagged.push((order_keys.clone(), Row::new(values)));
8054                }
8055            } else {
8056                let mut values = Vec::with_capacity(projection.len());
8057                for p in &projection {
8058                    values.push(eval::eval_expr(&p.expr, row, &ctx)?);
8059                }
8060                tagged.push((order_keys, Row::new(values)));
8061            }
8062            Ok(())
8063        };
8064        if let Some(rows) = &indexed_rows {
8065            for (loop_idx, cow) in rows.iter().enumerate() {
8066                process_row(cow.as_ref(), loop_idx)?;
8067            }
8068        } else {
8069            for i in 0..table.row_count() {
8070                process_row(&table.rows()[i], i)?;
8071            }
8072        }
8073
8074        if !stmt.order_by.is_empty() {
8075            // Partial-sort fast path: when LIMIT is small relative to
8076            // the row count, select_nth_unstable + sort just the
8077            // prefix is O(n + k log k) instead of O(n log n). DISTINCT
8078            // requires the full sort because de-dup happens after.
8079            // WITH TIES likewise needs the full sort so the tie
8080            // extension can scan past `limit` to find rows that
8081            // share the last-kept row's key.
8082            let keep = if stmt.distinct || stmt.limit_with_ties {
8083                None
8084            } else {
8085                stmt.limit_literal()
8086                    .map(|l| l as usize + stmt.offset_literal().map_or(0, |o| o as usize))
8087            };
8088            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
8089            partial_sort_tagged(&mut tagged, keep, &descs);
8090        }
8091
8092        // v7.17.0 Phase 3.P0-49 — `FETCH FIRST … WITH TIES` extends
8093        // past the truncated tail through every row that shares the
8094        // last-kept row's ORDER BY key. The tie check uses the
8095        // already-computed `(order_keys, row)` pairs so it matches
8096        // the sort comparator exactly. DISTINCT + WITH TIES falls
8097        // through to the no-ties path (PG also disallows their
8098        // combination; SPG silently drops the tie extension here so
8099        // the customer doesn't see a hard error mid-query — the
8100        // user-visible result is still correct, just narrower).
8101        let output_rows: Vec<Row> = if stmt.limit_with_ties && !stmt.distinct {
8102            apply_offset_and_limit_tagged(
8103                &mut tagged,
8104                stmt.offset_literal(),
8105                stmt.limit_literal(),
8106                true,
8107            );
8108            tagged.into_iter().map(|(_, r)| r).collect()
8109        } else {
8110            let mut output_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
8111            if stmt.distinct {
8112                output_rows = dedup_rows(output_rows);
8113            }
8114            apply_offset_and_limit(
8115                &mut output_rows,
8116                stmt.offset_literal(),
8117                stmt.limit_literal(),
8118            );
8119            output_rows
8120        };
8121
8122        let columns: Vec<ColumnSchema> = projection
8123            .into_iter()
8124            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
8125            .collect();
8126
8127        Ok(QueryResult::Rows {
8128            columns,
8129            rows: output_rows,
8130        })
8131    }
8132
8133    /// Multi-table SELECT executor (one or more JOIN peers).
8134    ///
8135    /// v1.10 builds the joined row set up-front via nested-loop joins,
8136    /// then runs WHERE + projection + ORDER BY against the combined
8137    /// rows. No index seek. Aggregates and DISTINCT still work because
8138    /// the executor delegates projection through the same shared paths.
8139    #[allow(clippy::too_many_lines)]
8140    /// v7.13.2 — mailrs round-6 S5. Resolve a TableRef into an
8141    /// owned (rows, schema) pair. Catalog tables clone their hot
8142    /// rows + schema; UNNEST table refs evaluate their array
8143    /// expression once and synthesise a single-column row set
8144    /// using the same dispatch as `exec_select_unnest`. Used by
8145    /// the joined-select path so UNNEST can appear in any FROM
8146    /// position, not just as the primary.
8147    fn materialise_table_ref(
8148        &self,
8149        tref: &TableRef,
8150    ) -> Result<(Vec<Row>, Vec<ColumnSchema>), EngineError> {
8151        if let Some(expr) = tref.unnest_expr.as_deref() {
8152            let empty_schema: Vec<ColumnSchema> = Vec::new();
8153            let ctx = EvalContext::new(&empty_schema, None);
8154            let dummy_row = Row::new(Vec::new());
8155            let (elem_dtype, rows) =
8156                match eval::eval_expr(expr, &dummy_row, &ctx).map_err(EngineError::Eval)? {
8157                    Value::Null => (DataType::Text, Vec::new()),
8158                    Value::TextArray(items) => (
8159                        DataType::Text,
8160                        items
8161                            .into_iter()
8162                            .map(|item| {
8163                                Row::new(alloc::vec![match item {
8164                                    Some(s) => Value::Text(s),
8165                                    None => Value::Null,
8166                                }])
8167                            })
8168                            .collect(),
8169                    ),
8170                    Value::IntArray(items) => (
8171                        DataType::Int,
8172                        items
8173                            .into_iter()
8174                            .map(|item| {
8175                                Row::new(alloc::vec![match item {
8176                                    Some(n) => Value::Int(n),
8177                                    None => Value::Null,
8178                                }])
8179                            })
8180                            .collect(),
8181                    ),
8182                    Value::BigIntArray(items) => (
8183                        DataType::BigInt,
8184                        items
8185                            .into_iter()
8186                            .map(|item| {
8187                                Row::new(alloc::vec![match item {
8188                                    Some(n) => Value::BigInt(n),
8189                                    None => Value::Null,
8190                                }])
8191                            })
8192                            .collect(),
8193                    ),
8194                    other => {
8195                        return Err(EngineError::Unsupported(alloc::format!(
8196                            "unnest() expects an array argument, got {:?}",
8197                            other.data_type()
8198                        )));
8199                    }
8200                };
8201            let alias = tref.alias.clone().unwrap_or_else(|| "unnest".to_string());
8202            let col_name = tref.unnest_column_aliases.first().cloned().unwrap_or(alias);
8203            return Ok((
8204                rows,
8205                alloc::vec![ColumnSchema::new(col_name, elem_dtype, true)],
8206            ));
8207        }
8208        let table =
8209            self.active_catalog()
8210                .get(&tref.name)
8211                .ok_or_else(|| StorageError::TableNotFound {
8212                    name: tref.name.clone(),
8213                })?;
8214        let rows: Vec<Row> = table.rows().iter().cloned().collect();
8215        let cols = table.schema().columns.clone();
8216        Ok((rows, cols))
8217    }
8218
8219    /// v7.17.0 Phase 3.P0-43 — materialise a `FROM` with one or more
8220    /// JOINs into `(combined_schema, filtered_rows)`. The combined
8221    /// schema uses composite `alias.col` column names so the
8222    /// qualifier-aware column resolver finds every join peer by
8223    /// exact match; the filtered rows are the join cross-product
8224    /// after the optional WHERE clause is applied.
8225    ///
8226    /// Shared by `exec_joined_select` and the JOIN branch of
8227    /// `exec_select_with_window`; both paths used to inline the
8228    /// same nested-loop logic and the window path rejected JOIN
8229    /// outright.
8230    fn build_joined_filtered_rows(
8231        &self,
8232        from: &FromClause,
8233        where_: Option<&Expr>,
8234    ) -> Result<(Vec<ColumnSchema>, Vec<Row>), EngineError> {
8235        let (primary_rows, primary_cols) = self.materialise_table_ref(&from.primary)?;
8236        let primary_alias = from
8237            .primary
8238            .alias
8239            .as_deref()
8240            .unwrap_or(from.primary.name.as_str())
8241            .to_string();
8242        // v7.17.0 Phase 3.P0-41 — LATERAL peers can't be
8243        // pre-materialised because their rows depend on outer
8244        // columns. For each peer, build either an eager
8245        // (rows, schema) pair or a "lateral" sentinel carrying
8246        // just the schema and the inner SELECT to re-run per
8247        // outer row.
8248        #[allow(clippy::type_complexity)]
8249        let mut joined: Vec<JoinedPeer<'_>> = Vec::new();
8250        for j in &from.joins {
8251            let a = j
8252                .table
8253                .alias
8254                .as_deref()
8255                .unwrap_or(j.table.name.as_str())
8256                .to_string();
8257            if let Some(inner_box) = &j.table.lateral_subquery {
8258                // Probe schema by running the inner SELECT against a
8259                // NULL-padded outer context. The probe gives us the
8260                // projection's column shape; rows materialise per
8261                // left-row below.
8262                let schema = self.lateral_probe_schema(inner_box)?;
8263                joined.push(JoinedPeer {
8264                    eager_rows: None,
8265                    cols: schema,
8266                    alias: a,
8267                    kind: j.kind,
8268                    on: j.on.as_ref(),
8269                    lateral: Some(inner_box.as_ref()),
8270                });
8271            } else {
8272                let (rows, cols) = self.materialise_table_ref(&j.table)?;
8273                joined.push(JoinedPeer {
8274                    eager_rows: Some(rows),
8275                    cols,
8276                    alias: a,
8277                    kind: j.kind,
8278                    on: j.on.as_ref(),
8279                    lateral: None,
8280                });
8281            }
8282        }
8283        let mut combined_schema: Vec<ColumnSchema> = Vec::new();
8284        for col in &primary_cols {
8285            combined_schema.push(ColumnSchema::new(
8286                alloc::format!("{primary_alias}.{}", col.name),
8287                col.ty,
8288                col.nullable,
8289            ));
8290        }
8291        for peer in &joined {
8292            for col in &peer.cols {
8293                combined_schema.push(ColumnSchema::new(
8294                    alloc::format!("{}.{}", peer.alias, col.name),
8295                    col.ty,
8296                    col.nullable,
8297                ));
8298            }
8299        }
8300        let ctx = EvalContext::new(&combined_schema, None);
8301        let mut working: Vec<Row> = primary_rows;
8302        // Track the per-row width consumed by the outer left side so
8303        // each lateral evaluation sees the correct schema slice.
8304        let mut consumed_cols = primary_cols.len();
8305        for peer in &joined {
8306            let right_arity = peer.cols.len();
8307            let mut next: Vec<Row> = Vec::new();
8308            for left in &working {
8309                let mut left_matched = false;
8310                let per_left_rrows: alloc::borrow::Cow<'_, [Row]> = match peer.lateral {
8311                    Some(inner) => {
8312                        // Substitute outer columns and run the inner
8313                        // SELECT against the current left row's slice
8314                        // of the combined schema.
8315                        let outer_schema = &combined_schema[..consumed_cols];
8316                        let rows = self.materialise_lateral_for_outer(inner, outer_schema, left)?;
8317                        alloc::borrow::Cow::Owned(rows)
8318                    }
8319                    None => {
8320                        let r = peer.eager_rows.as_ref().expect("non-lateral peer eager");
8321                        alloc::borrow::Cow::Borrowed(r.as_slice())
8322                    }
8323                };
8324                for right in per_left_rrows.as_ref() {
8325                    let mut combined_vals = left.values.clone();
8326                    combined_vals.extend(right.values.iter().cloned());
8327                    let combined = Row::new(combined_vals);
8328                    let keep = if let Some(on_expr) = peer.on {
8329                        let cond = eval::eval_expr(on_expr, &combined, &ctx)?;
8330                        matches!(cond, Value::Bool(true))
8331                    } else {
8332                        true
8333                    };
8334                    if keep {
8335                        next.push(combined);
8336                        left_matched = true;
8337                    }
8338                }
8339                if !left_matched && matches!(peer.kind, JoinKind::Left) {
8340                    let mut combined_vals = left.values.clone();
8341                    for _ in 0..right_arity {
8342                        combined_vals.push(Value::Null);
8343                    }
8344                    next.push(Row::new(combined_vals));
8345                }
8346            }
8347            working = next;
8348            consumed_cols += right_arity;
8349            debug_assert!(consumed_cols <= combined_schema.len());
8350        }
8351        let mut filtered: Vec<Row> = Vec::new();
8352        for row in working {
8353            if let Some(where_expr) = where_ {
8354                let cond = eval::eval_expr(where_expr, &row, &ctx)?;
8355                if !matches!(cond, Value::Bool(true)) {
8356                    continue;
8357                }
8358            }
8359            filtered.push(row);
8360        }
8361        Ok((combined_schema, filtered))
8362    }
8363
8364    /// v7.17.0 Phase 3.P0-41 — probe a LATERAL subquery's projection
8365    /// schema by running it once with a NULL-padded outer context.
8366    /// The probe never materialises real outer rows; it just executes
8367    /// the inner SELECT with `outer_alias.col` references substituted
8368    /// to NULL so the projection's type inference is exercised.
8369    fn lateral_probe_schema(
8370        &self,
8371        inner: &SelectStatement,
8372    ) -> Result<Vec<ColumnSchema>, EngineError> {
8373        // Substitute every qualified column reference whose qualifier
8374        // does NOT match an in-subquery FROM alias with NULL. The
8375        // safest probe is to walk the inner SELECT and replace any
8376        // `<qual>.<col>` whose qual isn't bound inside the subquery
8377        // with a Null literal. For the v7.17 probe we just run the
8378        // unmodified subquery and surface the columns; if it fails
8379        // (e.g. references an outer column the probe can't resolve),
8380        // we synthesise a best-effort schema from the SELECT items
8381        // by inferring a single Text-typed column per projection.
8382        match self.execute_readonly_select_for_lateral_probe(inner) {
8383            Ok(QueryResult::Rows { columns, .. }) => Ok(columns),
8384            // Best-effort fallback: each SELECT item becomes a TEXT
8385            // column. Real schemas only differ when the inner SELECT
8386            // references outer columns at projection-time; those
8387            // queries surface via the substitution path during
8388            // per-row execution and still return the right values.
8389            _ => {
8390                let mut out: Vec<ColumnSchema> = Vec::new();
8391                for (i, item) in inner.items.iter().enumerate() {
8392                    let name = match item {
8393                        SelectItem::Expr { alias: Some(a), .. } => a.clone(),
8394                        SelectItem::Expr { expr, .. } => synth_lateral_col_name(expr, i),
8395                        SelectItem::Wildcard => alloc::format!("col{i}"),
8396                    };
8397                    out.push(ColumnSchema::new(name, DataType::Text, true));
8398                }
8399                Ok(out)
8400            }
8401        }
8402    }
8403
8404    /// v7.17.0 Phase 3.P0-41 — try the inner LATERAL subquery against
8405    /// the engine in read-only mode for schema-probe purposes. Failure
8406    /// is expected when the subquery references an outer column the
8407    /// probe can't resolve; the caller falls back to a best-effort
8408    /// schema based on the SELECT items.
8409    fn execute_readonly_select_for_lateral_probe(
8410        &self,
8411        inner: &SelectStatement,
8412    ) -> Result<QueryResult, EngineError> {
8413        self.exec_bare_select_cancel(inner, CancelToken::none())
8414    }
8415
8416    /// v7.17.0 Phase 3.P0-41 — materialise a LATERAL subquery's rows
8417    /// for one outer-row context. Walks the inner SELECT, replaces
8418    /// every `<outer_alias>.<col>` reference whose alias appears in
8419    /// the outer schema with the literal value from the outer row,
8420    /// then runs the rewritten SELECT against the engine.
8421    fn materialise_lateral_for_outer(
8422        &self,
8423        inner: &SelectStatement,
8424        outer_schema: &[ColumnSchema],
8425        outer_row: &Row,
8426    ) -> Result<Vec<Row>, EngineError> {
8427        let mut substituted = inner.clone();
8428        substitute_outer_columns_multi(&mut substituted, outer_row, outer_schema);
8429        let result = self.exec_bare_select_cancel(&substituted, CancelToken::none())?;
8430        match result {
8431            QueryResult::Rows { rows, .. } => Ok(rows),
8432            _ => Err(EngineError::Unsupported(
8433                "LATERAL subquery must be a SELECT (cannot be a write statement)".into(),
8434            )),
8435        }
8436    }
8437
8438    fn exec_joined_select(
8439        &self,
8440        stmt: &SelectStatement,
8441        from: &FromClause,
8442    ) -> Result<QueryResult, EngineError> {
8443        // v7.17.0 Phase 3.P0-43 + P0-41 — delegate the join +
8444        // WHERE materialisation to the shared helper so the LATERAL
8445        // / UNNEST / regular-catalog paths route through one place.
8446        // (`build_joined_filtered_rows` carries LATERAL support as
8447        // of Phase 3.P0-41.) Downstream we still handle aggregate /
8448        // projection / ORDER BY / DISTINCT / LIMIT inline because
8449        // those depend on the SelectStatement's items list.
8450        let (combined_schema, filtered) =
8451            self.build_joined_filtered_rows(from, stmt.where_.as_ref())?;
8452        let ctx = EvalContext::new(&combined_schema, None);
8453        // Aggregate path: handle GROUP BY / aggregate calls over the
8454        // joined+filtered rows.
8455        if aggregate::uses_aggregate(stmt) {
8456            let refs: Vec<&Row> = filtered.iter().collect();
8457            let mut agg = aggregate::run(stmt, &refs, &combined_schema, None)?;
8458            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
8459            return Ok(QueryResult::Rows {
8460                columns: agg.columns,
8461                rows: agg.rows,
8462            });
8463        }
8464
8465        let projection = build_projection(&stmt.items, &combined_schema, "")?;
8466        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::new();
8467        for row in &filtered {
8468            let mut values = Vec::with_capacity(projection.len());
8469            for p in &projection {
8470                values.push(eval::eval_expr(&p.expr, row, &ctx)?);
8471            }
8472            let order_keys = if stmt.order_by.is_empty() {
8473                Vec::new()
8474            } else {
8475                build_order_keys(&stmt.order_by, row, &ctx)?
8476            };
8477            tagged.push((order_keys, Row::new(values)));
8478        }
8479        if !stmt.order_by.is_empty() {
8480            let keep = if stmt.distinct {
8481                None
8482            } else {
8483                stmt.limit_literal()
8484                    .map(|l| l as usize + stmt.offset_literal().map_or(0, |o| o as usize))
8485            };
8486            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
8487            partial_sort_tagged(&mut tagged, keep, &descs);
8488        }
8489        let mut output_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
8490        if stmt.distinct {
8491            output_rows = dedup_rows(output_rows);
8492        }
8493        apply_offset_and_limit(
8494            &mut output_rows,
8495            stmt.offset_literal(),
8496            stmt.limit_literal(),
8497        );
8498        let columns: Vec<ColumnSchema> = projection
8499            .into_iter()
8500            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
8501            .collect();
8502        Ok(QueryResult::Rows {
8503            columns,
8504            rows: output_rows,
8505        })
8506    }
8507}
8508
8509/// One row-producing projection: an expression to evaluate, the resulting
8510/// column's user-visible name, its inferred type, and nullability.
8511#[derive(Debug, Clone)]
8512struct ProjectedItem {
8513    expr: Expr,
8514    output_name: String,
8515    ty: DataType,
8516    nullable: bool,
8517}
8518
8519/// Dedupe a row set, preserving first-seen order. `Row`'s `PartialEq` is
8520/// structural (`Vec<Value>` ⇒ pairwise `Value` equality), which gives SQL
8521/// `NULL = NULL → TRUE` and `NaN = NaN → FALSE`. The first agrees with
8522/// the spec's "two NULLs are not distinct"; the second is a tolerated
8523/// quirk for v1 (no NaN literals are reachable from the SQL surface).
8524fn dedup_rows(rows: Vec<Row>) -> Vec<Row> {
8525    let mut out: Vec<Row> = Vec::with_capacity(rows.len());
8526    for r in rows {
8527        if !out.iter().any(|seen| seen == &r) {
8528            out.push(r);
8529        }
8530    }
8531    out
8532}
8533
8534/// Coerce a `Value` to an `f64` sort key for ORDER BY. Numbers map directly;
8535/// NULL sorts last (treated as `+∞`); booleans are 0.0 / 1.0; text uses lex
8536/// order via the byte values; vectors are not sortable.
8537fn value_to_order_key(v: &Value) -> Result<f64, EngineError> {
8538    match v {
8539        Value::Null => Ok(f64::INFINITY),
8540        Value::SmallInt(n) => Ok(f64::from(*n)),
8541        Value::Int(n) => Ok(f64::from(*n)),
8542        Value::Date(d) => Ok(f64::from(*d)),
8543        #[allow(clippy::cast_precision_loss)]
8544        Value::Timestamp(t) => Ok(*t as f64),
8545        // v7.17.0 Phase 3.P0-32 — PG TIME ordered by underlying
8546        // i64 microseconds (matches wall-clock ordering).
8547        #[allow(clippy::cast_precision_loss)]
8548        Value::Time(us) => Ok(*us as f64),
8549        // v7.17.0 Phase 3.P0-33 — MySQL YEAR ordered by underlying
8550        // u16 (matches calendar ordering; zero-year sentinel
8551        // sorts before 1901).
8552        Value::Year(y) => Ok(f64::from(*y)),
8553        // v7.17.0 Phase 3.P0-34 — PG TIMETZ ordered by the
8554        // UTC-equivalent microseconds (local wall - offset). Two
8555        // values for the same physical instant in different zones
8556        // sort equal — matches PG TIMETZ index behaviour.
8557        #[allow(clippy::cast_precision_loss)]
8558        Value::TimeTz { us, offset_secs } => Ok((us - i64::from(*offset_secs) * 1_000_000) as f64),
8559        // v7.17.0 Phase 3.P0-35 — PG MONEY ordered by i64 cents.
8560        #[allow(clippy::cast_precision_loss)]
8561        Value::Money(c) => Ok(*c as f64),
8562        // v7.17.0 Phase 3.P0-38 — range ordering is not supported
8563        // in v7.17.0 (needs lex-then-inclusivity tiebreak).
8564        Value::Range { .. } => Err(EngineError::Unsupported(
8565            "ORDER BY of a range value is not supported in v7.17.0".into(),
8566        )),
8567        // v7.17.0 Phase 3.P0-39 — hstore is not orderable.
8568        Value::Hstore(_) => Err(EngineError::Unsupported(
8569            "ORDER BY of a hstore value is not supported".into(),
8570        )),
8571        // v7.17.0 Phase 3.P0-40 — 2D arrays not orderable.
8572        Value::IntArray2D(_) | Value::BigIntArray2D(_) | Value::TextArray2D(_) => Err(
8573            EngineError::Unsupported("ORDER BY of a 2D array is not supported in v7.17.0".into()),
8574        ),
8575        #[allow(clippy::cast_precision_loss)]
8576        Value::Numeric { scaled, scale } => {
8577            // Scaled integer / 10^scale, computed via f64 for sort
8578            // ordering only. Precision losses here only matter for
8579            // ORDER BY tie-breaks well past 15 significant digits.
8580            // `f64::powi` lives in std; we hand-roll the loop so the
8581            // no_std engine crate doesn't need it.
8582            let mut divisor = 1.0_f64;
8583            for _ in 0..*scale {
8584                divisor *= 10.0;
8585            }
8586            Ok((*scaled as f64) / divisor)
8587        }
8588        #[allow(clippy::cast_precision_loss)]
8589        Value::BigInt(n) => Ok(*n as f64),
8590        Value::Float(x) => Ok(*x),
8591        Value::Bool(b) => Ok(if *b { 1.0 } else { 0.0 }),
8592        Value::Text(s) => {
8593            // Lex order by codepoints — good enough for ORDER BY name.
8594            // Map first 8 bytes packed into u64 as a coarse key; ties fall to
8595            // partial_cmp Equal. v1.x can swap in a real string comparator.
8596            let mut key: u64 = 0;
8597            for &b in s.as_bytes().iter().take(8) {
8598                key = (key << 8) | u64::from(b);
8599            }
8600            #[allow(clippy::cast_precision_loss)]
8601            Ok(key as f64)
8602        }
8603        Value::Vector(_) | Value::Sq8Vector(_) | Value::HalfVector(_) => {
8604            Err(EngineError::Unsupported(
8605                "ORDER BY of a raw vector column is not meaningful — use `<->`".into(),
8606            ))
8607        }
8608        Value::Interval { .. } => Err(EngineError::Unsupported(
8609            "ORDER BY of an INTERVAL is not supported in v2.11 \
8610             (months vs micros has no single canonical ordering)"
8611                .into(),
8612        )),
8613        Value::Json(_) => Err(EngineError::Unsupported(
8614            "ORDER BY of a JSON value is not supported — cast the document to text first".into(),
8615        )),
8616        // v7.5.0 — Value is #[non_exhaustive]; future variants need
8617        // an explicit ORDER BY mapping. Surface as Unsupported until
8618        // engine support is added.
8619        _ => Err(EngineError::Unsupported(
8620            "ORDER BY of this value type is not supported".into(),
8621        )),
8622    }
8623}
8624
8625/// Try to plan a WHERE clause as an equality lookup against an existing
8626/// index. Returns the candidate row indices on success; `None` means the
8627/// caller should fall back to a full scan.
8628///
8629/// v0.8 recognises a single top-level `col = literal` (in either operand
8630/// order). AND chains and range scans land in later milestones.
8631/// Look for `ORDER BY col <dist-op> literal LIMIT k` against an
8632/// NSW-indexed vector column. Recognised distance ops: `<->` (L2),
8633/// `<#>` (inner product), `<=>` (cosine). When a WHERE clause is
8634/// present, the planner does an "over-fetch and filter" pass — it
8635/// asks the graph for `k * over_fetch` candidates, evaluates WHERE
8636/// against each, and trims back to `k`. Returns the row indices in
8637/// ascending-distance order when the plan applies.
8638fn try_nsw_knn(
8639    stmt: &SelectStatement,
8640    table: &Table,
8641    schema_cols: &[ColumnSchema],
8642    table_alias: &str,
8643) -> Option<Vec<usize>> {
8644    if stmt.distinct {
8645        return None;
8646    }
8647    let limit = usize::try_from(stmt.limit_literal()?).ok()?;
8648    if limit == 0 {
8649        return None;
8650    }
8651    // v6.4.0 — NSW kNN dispatch needs a single ORDER BY key on the
8652    // distance metric. Multi-key ORDER BY falls through to the
8653    // generic sort path.
8654    if stmt.order_by.len() != 1 {
8655        return None;
8656    }
8657    let order = &stmt.order_by[0];
8658    // NSW kNN returns rows ascending by distance — DESC inverts the
8659    // natural order, so the planner can't handle it without a sort
8660    // pass. Fall back to the generic ORDER BY path.
8661    if order.desc {
8662        return None;
8663    }
8664    let Expr::Binary { lhs, op, rhs } = &order.expr else {
8665        return None;
8666    };
8667    let metric = match op {
8668        BinOp::L2Distance => spg_storage::NswMetric::L2,
8669        BinOp::InnerProduct => spg_storage::NswMetric::InnerProduct,
8670        BinOp::CosineDistance => spg_storage::NswMetric::Cosine,
8671        _ => return None,
8672    };
8673    // Accept both `col <op> literal` and `literal <op> col`.
8674    let ((Expr::Column(col), literal) | (literal, Expr::Column(col))) =
8675        (lhs.as_ref(), rhs.as_ref())
8676    else {
8677        return None;
8678    };
8679    if let Some(q) = &col.qualifier
8680        && q != table_alias
8681    {
8682        return None;
8683    }
8684    let col_pos = schema_cols.iter().position(|s| s.name == col.name)?;
8685    let query = literal_to_vector(literal)?;
8686    let idx = spg_storage::nsw_index_on(table, col_pos)?;
8687    if let Some(where_expr) = &stmt.where_ {
8688        // Over-fetch and filter. The factor (10×) is a heuristic that
8689        // covers typical selectivity for the corpus tests; v2.x will
8690        // make it configurable.
8691        let over_fetch = limit.saturating_mul(10).max(NSW_OVER_FETCH_FLOOR);
8692        let candidates = spg_storage::nsw_query(table, &idx.name, &query, over_fetch, metric);
8693        let ctx = EvalContext::new(schema_cols, Some(table_alias));
8694        let mut kept: Vec<usize> = Vec::with_capacity(limit);
8695        for i in candidates {
8696            let row = &table.rows()[i];
8697            let cond = eval::eval_expr(where_expr, row, &ctx).ok()?;
8698            if matches!(cond, Value::Bool(true)) {
8699                kept.push(i);
8700                if kept.len() >= limit {
8701                    break;
8702                }
8703            }
8704        }
8705        Some(kept)
8706    } else {
8707        Some(spg_storage::nsw_query(
8708            table, &idx.name, &query, limit, metric,
8709        ))
8710    }
8711}
8712
8713/// Lower bound on the over-fetch pool when WHERE is present — even
8714/// for tiny `LIMIT 1` queries we keep enough candidates to absorb a
8715/// few WHERE rejections.
8716const NSW_OVER_FETCH_FLOOR: usize = 32;
8717
8718/// Pull a `Vec<f32>` out of a literal-or-cast expression. Returns
8719/// `None` for anything we can't fold at plan time.
8720fn literal_to_vector(e: &Expr) -> Option<Vec<f32>> {
8721    match e {
8722        Expr::Literal(Literal::Vector(v)) => Some(v.clone()),
8723        Expr::Cast { expr, .. } => literal_to_vector(expr),
8724        _ => None,
8725    }
8726}
8727
8728/// Materialise rows in a planner-supplied order (used by the NSW path)
8729/// without re-running ORDER BY. The projection + LIMIT slot mirror the
8730/// equivalent block in `exec_bare_select`.
8731fn materialise_in_order(
8732    stmt: &SelectStatement,
8733    table: &Table,
8734    schema_cols: &[ColumnSchema],
8735    table_alias: &str,
8736    ordered_rows: &[usize],
8737) -> Result<QueryResult, EngineError> {
8738    let ctx = EvalContext::new(schema_cols, Some(table_alias));
8739    let projection = build_projection(&stmt.items, schema_cols, table_alias)?;
8740    let mut output_rows: Vec<Row> = Vec::with_capacity(ordered_rows.len());
8741    for &i in ordered_rows {
8742        let row = &table.rows()[i];
8743        let mut values = Vec::with_capacity(projection.len());
8744        for p in &projection {
8745            values.push(eval::eval_expr(&p.expr, row, &ctx)?);
8746        }
8747        output_rows.push(Row::new(values));
8748    }
8749    apply_offset_and_limit(
8750        &mut output_rows,
8751        stmt.offset_literal(),
8752        stmt.limit_literal(),
8753    );
8754    let columns: Vec<ColumnSchema> = projection
8755        .into_iter()
8756        .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
8757        .collect();
8758    Ok(QueryResult::Rows {
8759        columns,
8760        rows: output_rows,
8761    })
8762}
8763
8764/// v7.20 P4 — hot-row POSITION seek for the mutation paths
8765/// (UPDATE / DELETE index their planned writes by position in
8766/// `table.rows()`, so the Cow-row shape `try_index_seek`
8767/// returns doesn't fit). Same top-level-AND recursion and
8768/// col=literal resolution; the caller re-applies the full WHERE
8769/// to every returned row so the index only narrows candidates.
8770///
8771/// Returns `None` (→ caller full-scans) when no equality leaf
8772/// hits an index OR any matching locator lives in the cold tier
8773/// — the mutation paths operate on hot rows, and the PK
8774/// promote-then-walk upstream already handles the
8775/// cold-single-row case.
8776fn try_index_seek_positions(
8777    where_expr: &Expr,
8778    schema_cols: &[ColumnSchema],
8779    table: &Table,
8780    table_alias: &str,
8781) -> Option<Vec<usize>> {
8782    if let Expr::Binary {
8783        lhs,
8784        op: BinOp::And,
8785        rhs,
8786    } = where_expr
8787    {
8788        if let Some(p) = try_index_seek_positions(lhs, schema_cols, table, table_alias) {
8789            return Some(p);
8790        }
8791        return try_index_seek_positions(rhs, schema_cols, table, table_alias);
8792    }
8793    let Expr::Binary {
8794        lhs,
8795        op: BinOp::Eq,
8796        rhs,
8797    } = where_expr
8798    else {
8799        return None;
8800    };
8801    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
8802        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
8803    let idx = table.index_on(col_pos)?;
8804    let key = IndexKey::from_value(&value)?;
8805    let locators = idx.lookup_eq(&key);
8806    let mut out = Vec::with_capacity(locators.len());
8807    for loc in locators {
8808        match *loc {
8809            spg_storage::RowLocator::Hot(i) => out.push(i),
8810            spg_storage::RowLocator::Cold { .. } => return None,
8811        }
8812    }
8813    Some(out)
8814}
8815
8816fn try_index_seek<'a>(
8817    where_expr: &Expr,
8818    schema_cols: &[ColumnSchema],
8819    catalog: &'a Catalog,
8820    table: &'a Table,
8821    table_alias: &str,
8822) -> Option<Vec<Cow<'a, Row>>> {
8823    // v7.11.3 — recurse through top-level `AND` so a PG-style
8824    // composite predicate like `WHERE id = 1 AND created_at > $1`
8825    // still hits the index on `id`. The caller re-applies the
8826    // full WHERE expression to each returned row, so dropping the
8827    // residual conjuncts here is correct — the index just narrows
8828    // the candidate set.
8829    if let Expr::Binary {
8830        lhs,
8831        op: BinOp::And,
8832        rhs,
8833    } = where_expr
8834    {
8835        // Try LHS first (typical convention: leading equality on
8836        // the indexed column comes first in user-written SQL).
8837        if let Some(rows) = try_index_seek(lhs, schema_cols, catalog, table, table_alias) {
8838            return Some(rows);
8839        }
8840        return try_index_seek(rhs, schema_cols, catalog, table, table_alias);
8841    }
8842    let Expr::Binary {
8843        lhs,
8844        op: BinOp::Eq,
8845        rhs,
8846    } = where_expr
8847    else {
8848        return None;
8849    };
8850    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
8851        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
8852    let idx = table.index_on(col_pos)?;
8853    let key = IndexKey::from_value(&value)?;
8854    let locators = idx.lookup_eq(&key);
8855    let table_name = table.schema().name.as_str();
8856    // v5.1: each locator dispatches to either the hot tier (zero-
8857    // copy borrow of `table.rows()[i]`) or a cold-tier segment
8858    // (one page read + dense row decode, ~µs scale). Cold rows are
8859    // returned as `Cow::Owned` so the caller's `&Row` iteration
8860    // doesn't see a tier distinction; pre-freezer (no cold
8861    // segments loaded) every locator is `Hot` and every entry is
8862    // `Cow::Borrowed` — identical cost to the pre-v5.1 path.
8863    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(locators.len());
8864    for loc in locators {
8865        match *loc {
8866            spg_storage::RowLocator::Hot(i) => {
8867                if let Some(row) = table.rows().get(i) {
8868                    out.push(Cow::Borrowed(row));
8869                }
8870            }
8871            spg_storage::RowLocator::Cold { segment_id, .. } => {
8872                if let Some(row) = catalog.resolve_cold_locator(table_name, segment_id, &key) {
8873                    out.push(Cow::Owned(row));
8874                }
8875            }
8876        }
8877    }
8878    Some(out)
8879}
8880
8881/// v7.12.3 — GIN-accelerated candidate seek for `WHERE col @@ <ts_query>`.
8882///
8883/// Recurses through top-level `AND` like [`try_index_seek`] so a
8884/// composite predicate `WHERE search_vector @@ q AND id > $1` still
8885/// hits the GIN index on `search_vector` — the caller re-applies the
8886/// full WHERE expression to each returned candidate, so dropping the
8887/// `id > $1` residual here stays semantically correct.
8888///
8889/// Returns `None` when:
8890///   - no leaf is a `col @@ <rhs>` shape on a GIN-indexed column;
8891///   - the RHS can't be const-evaluated to a `Value::TsQuery`
8892///     (typically because it references row columns);
8893///   - the resolved `TsQuery` uses query shapes the MVP doesn't
8894///     accelerate (`Not`, `Phrase` — those fall through to full scan).
8895///
8896/// On `Some(rows)` the caller iterates only `rows` and re-evaluates
8897/// the full `@@` predicate per row, so an over-approximate candidate
8898/// set is safe.
8899fn try_gin_seek<'a>(
8900    where_expr: &Expr,
8901    schema_cols: &[ColumnSchema],
8902    catalog: &'a Catalog,
8903    table: &'a Table,
8904    table_alias: &str,
8905    ctx: &eval::EvalContext<'_>,
8906) -> Option<Vec<Cow<'a, Row>>> {
8907    if let Expr::Binary {
8908        lhs,
8909        op: BinOp::And,
8910        rhs,
8911    } = where_expr
8912    {
8913        if let Some(rows) = try_gin_seek(lhs, schema_cols, catalog, table, table_alias, ctx) {
8914            return Some(rows);
8915        }
8916        return try_gin_seek(rhs, schema_cols, catalog, table, table_alias, ctx);
8917    }
8918    // v7.17.0 Phase 3.P0-44 — MySQL `MATCH(col1, col2) AGAINST (...)`
8919    // desugars into `(to_tsvector(col1) @@ q) OR (to_tsvector(col2) @@ q)`
8920    // in the parser. To accelerate the multi-column case, walk OR the same
8921    // way we walk AND: only emit a candidate set if BOTH sides can seek
8922    // (otherwise the OR result is unbounded and we must fall through to
8923    // the full scan). Candidates are union'd; the caller's WHERE re-eval
8924    // verifies the full predicate per row, so duplicates / supersets stay
8925    // semantically safe.
8926    if let Expr::Binary {
8927        lhs,
8928        op: BinOp::Or,
8929        rhs,
8930    } = where_expr
8931    {
8932        let left = try_gin_seek(lhs, schema_cols, catalog, table, table_alias, ctx)?;
8933        let right = try_gin_seek(rhs, schema_cols, catalog, table, table_alias, ctx)?;
8934        let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(left.len() + right.len());
8935        out.extend(left);
8936        out.extend(right);
8937        return Some(out);
8938    }
8939    let Expr::Binary {
8940        lhs,
8941        op: BinOp::TsMatch,
8942        rhs,
8943    } = where_expr
8944    else {
8945        return None;
8946    };
8947    // Either side can be the column; pgvector idiom (`vec @@ q`)
8948    // hits the first arm, FROM-clause-derived (`plainto_tsquery($1)
8949    // q ... WHERE search_vector @@ q`) the same. CROSS JOIN derived
8950    // tables resolve `q` to a Column too.
8951    let (col_pos, query) = resolve_gin_col_query(lhs, rhs, schema_cols, table_alias, ctx)
8952        .or_else(|| resolve_gin_col_query(rhs, lhs, schema_cols, table_alias, ctx))?;
8953    // v7.17.0 Phase 3.P0-44 — MySQL `FULLTEXT KEY` builds a
8954    // `IndexKind::GinFulltext` posting list (Phase 2.2). It shares
8955    // the same `gin_lookup_word` shape as the tsvector-typed GIN,
8956    // so the MATCH-AGAINST `@@` predicate (desugared by the parser
8957    // into `to_tsvector(col) @@ plainto_tsquery('term')`) routes
8958    // through the same candidate-set seek.
8959    let idx = table
8960        .indices()
8961        .iter()
8962        .find(|i| i.column_position == col_pos && (i.is_gin() || i.is_gin_fulltext()))?;
8963    let candidates = gin_query_candidates(idx, &query)?;
8964    let _ = catalog; // cold-tier row resolution unused in MVP; see below.
8965    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(candidates.len());
8966    for loc in candidates {
8967        match loc {
8968            spg_storage::RowLocator::Hot(i) => {
8969                if let Some(row) = table.rows().get(i) {
8970                    out.push(Cow::Borrowed(row));
8971                }
8972            }
8973            // GIN cold-tier rows in the MVP: skipped, matching the
8974            // full-scan `@@` path which itself only iterates
8975            // `table.rows()` (hot tier). When v7.13+ adds cold-tier
8976            // scan-time materialisation for `@@`, the parallel
8977            // resolution lands here; until then both paths see the
8978            // same hot-only candidate set so correctness is preserved.
8979            spg_storage::RowLocator::Cold { .. } => {}
8980        }
8981    }
8982    Some(out)
8983}
8984
8985/// v7.15.0 — trigram-GIN-accelerated candidate seek for
8986/// `WHERE col LIKE '<pat>'` and `WHERE col ILIKE '<pat>'` when
8987/// the column has a `gin_trgm_ops` GIN index.
8988///
8989/// Walks top-level `AND` so multi-predicate WHEREs (`col LIKE
8990/// 'foo%' AND id > 1`) still hit the trigram index; the caller
8991/// re-evaluates the full WHERE per candidate row, so dropping
8992/// non-LIKE conjuncts here stays semantically correct.
8993///
8994/// Returns `None` when:
8995///   - no leaf is `col LIKE/ILIKE <literal>` on a trigram-GIN-
8996///     indexed column;
8997///   - the pattern's literal runs are too short to constrain
8998///     (pattern decomposes into `< 3`-char runs, e.g. `%ab%`);
8999///   - the pattern doesn't const-evaluate to a TEXT.
9000fn try_trgm_seek<'a>(
9001    where_expr: &Expr,
9002    schema_cols: &[ColumnSchema],
9003    table: &'a Table,
9004    table_alias: &str,
9005) -> Option<Vec<Cow<'a, Row>>> {
9006    if let Expr::Binary {
9007        lhs,
9008        op: BinOp::And,
9009        rhs,
9010    } = where_expr
9011    {
9012        if let Some(rows) = try_trgm_seek(lhs, schema_cols, table, table_alias) {
9013            return Some(rows);
9014        }
9015        return try_trgm_seek(rhs, schema_cols, table, table_alias);
9016    }
9017    // LIKE node is what carries the column reference + pattern.
9018    // ILIKE is the same AST node — PG's LIKE/ILIKE both lower
9019    // through `Expr::Like { expr, pattern, negated }`. The trigram
9020    // index posting-list keys are already lower-cased and
9021    // case-folded, so we only need the pattern's literal runs.
9022    let Expr::Like { expr, pattern, .. } = where_expr else {
9023        return None;
9024    };
9025    // Column side.
9026    let Expr::Column(c) = expr.as_ref() else {
9027        return None;
9028    };
9029    if let Some(q) = &c.qualifier
9030        && q != table_alias
9031    {
9032        return None;
9033    }
9034    let col_pos = schema_cols
9035        .iter()
9036        .position(|s| s.name.eq_ignore_ascii_case(&c.name))?;
9037    // Index must exist on that column AND be a trigram-GIN.
9038    let idx = table
9039        .indices()
9040        .iter()
9041        .find(|i| i.column_position == col_pos && i.is_gin_trgm())?;
9042    // Pattern side must be a literal TEXT — anything else (column
9043    // ref, function call, parameter that hasn't been bound yet)
9044    // falls through to full scan.
9045    let Expr::Literal(spg_sql::ast::Literal::String(pat)) = pattern.as_ref() else {
9046        return None;
9047    };
9048    let trigrams = spg_storage::trgm::trigrams_from_like_pattern(pat)?;
9049    // Intersect every trigram's posting list. Empty intersection
9050    // → empty candidate set (caller short-circuits its row loop).
9051    let mut iter = trigrams.iter();
9052    let first = iter.next()?;
9053    let mut acc: Vec<spg_storage::RowLocator> = {
9054        let mut v = idx.gin_trgm_lookup(first).to_vec();
9055        v.sort_by_key(locator_sort_key);
9056        v.dedup_by_key(|l| locator_sort_key(l));
9057        v
9058    };
9059    for tri in iter {
9060        let mut next: Vec<spg_storage::RowLocator> = idx.gin_trgm_lookup(tri).to_vec();
9061        next.sort_by_key(locator_sort_key);
9062        next.dedup_by_key(|l| locator_sort_key(l));
9063        // Sorted-merge intersection.
9064        let mut merged: Vec<spg_storage::RowLocator> =
9065            Vec::with_capacity(acc.len().min(next.len()));
9066        let (mut i, mut j) = (0usize, 0usize);
9067        while i < acc.len() && j < next.len() {
9068            let lk = locator_sort_key(&acc[i]);
9069            let rk = locator_sort_key(&next[j]);
9070            match lk.cmp(&rk) {
9071                core::cmp::Ordering::Less => i += 1,
9072                core::cmp::Ordering::Greater => j += 1,
9073                core::cmp::Ordering::Equal => {
9074                    merged.push(acc[i]);
9075                    i += 1;
9076                    j += 1;
9077                }
9078            }
9079        }
9080        acc = merged;
9081        if acc.is_empty() {
9082            break;
9083        }
9084    }
9085    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(acc.len());
9086    for loc in acc {
9087        if let spg_storage::RowLocator::Hot(i) = loc
9088            && let Some(row) = table.rows().get(i)
9089        {
9090            out.push(Cow::Borrowed(row));
9091        }
9092        // Cold-tier rows: skipped in MVP (same as try_gin_seek).
9093    }
9094    Some(out)
9095}
9096
9097/// v7.12.3 — extract `(column_position, TsQueryAst)` when one side of
9098/// the binary is a column reference to a GIN-indexed tsvector column
9099/// and the other side const-evaluates to a `Value::TsQuery`. Returns
9100/// `None` if the column reference is for the wrong table alias, or if
9101/// the RHS expression depends on row data.
9102fn resolve_gin_col_query(
9103    col_side: &Expr,
9104    query_side: &Expr,
9105    schema_cols: &[ColumnSchema],
9106    table_alias: &str,
9107    ctx: &eval::EvalContext<'_>,
9108) -> Option<(usize, spg_storage::TsQueryAst)> {
9109    // v7.17.0 Phase 3.P0-44 — the MATCH AGAINST desugar wraps the
9110    // column in `to_tsvector('simple', col)`, so we peel that wrapper
9111    // before the column lookup. Direct `col @@ tsquery` paths (the
9112    // tsvector-typed v7.12 surface) skip the wrapper entirely.
9113    let column = match col_side {
9114        Expr::Column(c) => c,
9115        Expr::FunctionCall { name, args }
9116            if name.eq_ignore_ascii_case("to_tsvector") && !args.is_empty() =>
9117        {
9118            // PG `to_tsvector` accepts either `to_tsvector(col)` or
9119            // `to_tsvector(config, col)`. In both shapes the column
9120            // we care about is the final argument.
9121            if let Expr::Column(c) = args.last().unwrap() {
9122                c
9123            } else {
9124                return None;
9125            }
9126        }
9127        _ => return None,
9128    };
9129    let c = column;
9130    if let Some(q) = &c.qualifier
9131        && q != table_alias
9132    {
9133        return None;
9134    }
9135    let pos = schema_cols.iter().position(|s| s.name == c.name)?;
9136    // Const-evaluate the query side with an empty row — fails fast
9137    // (with a `ColumnNotFound` / similar) if the expression actually
9138    // depends on row data, which is exactly the bail signal we want.
9139    let empty_row = Row::new(Vec::new());
9140    let v = eval::eval_expr(query_side, &empty_row, ctx).ok()?;
9141    let Value::TsQuery(q) = v else { return None };
9142    Some((pos, q))
9143}
9144
9145/// v7.12.3 — walk a `TsQueryAst` against an [`IndexKind::Gin`] index
9146/// to produce a candidate row-locator set. Returns `None` for query
9147/// shapes the MVP doesn't accelerate (`Not` / `Phrase` — both bail to
9148/// full scan since their semantics need either complementation across
9149/// the whole row set or positional verification beyond what the
9150/// posting list carries).
9151///
9152/// Candidate sets are over-approximate — the caller re-applies the
9153/// full `@@` predicate per row, so reporting "row was in some
9154/// posting list" without verifying positions / weights stays correct.
9155fn gin_query_candidates(
9156    idx: &spg_storage::Index,
9157    query: &spg_storage::TsQueryAst,
9158) -> Option<Vec<spg_storage::RowLocator>> {
9159    use spg_storage::TsQueryAst;
9160    match query {
9161        TsQueryAst::Term { word, .. } => {
9162            let mut v: Vec<spg_storage::RowLocator> = idx.gin_lookup_word(word).to_vec();
9163            v.sort_by_key(locator_sort_key);
9164            v.dedup_by_key(|l| locator_sort_key(l));
9165            Some(v)
9166        }
9167        TsQueryAst::And(l, r) => {
9168            let mut left = gin_query_candidates(idx, l)?;
9169            let mut right = gin_query_candidates(idx, r)?;
9170            left.sort_by_key(locator_sort_key);
9171            right.sort_by_key(locator_sort_key);
9172            // Sorted-merge intersection.
9173            let mut out: Vec<spg_storage::RowLocator> = Vec::new();
9174            let (mut i, mut j) = (0usize, 0usize);
9175            while i < left.len() && j < right.len() {
9176                let lk = locator_sort_key(&left[i]);
9177                let rk = locator_sort_key(&right[j]);
9178                match lk.cmp(&rk) {
9179                    core::cmp::Ordering::Less => i += 1,
9180                    core::cmp::Ordering::Greater => j += 1,
9181                    core::cmp::Ordering::Equal => {
9182                        out.push(left[i]);
9183                        i += 1;
9184                        j += 1;
9185                    }
9186                }
9187            }
9188            Some(out)
9189        }
9190        TsQueryAst::Or(l, r) => {
9191            let mut out = gin_query_candidates(idx, l)?;
9192            out.extend(gin_query_candidates(idx, r)?);
9193            out.sort_by_key(locator_sort_key);
9194            out.dedup_by_key(|l| locator_sort_key(l));
9195            Some(out)
9196        }
9197        // Not / Phrase bail to full scan in the MVP. Not needs
9198        // complementation against the whole row set (not represented
9199        // in the posting-list view); Phrase needs positional
9200        // verification beyond what `word → rows` carries.
9201        TsQueryAst::Not(_) | TsQueryAst::Phrase { .. } => None,
9202    }
9203}
9204
9205/// v7.12.3 — total ordering on `RowLocator` for sort/dedup purposes
9206/// inside the GIN intersection / union loops. Hot rows order by their
9207/// row index; Cold rows order after all Hot rows, then by
9208/// `(segment_id, the cold sub-key)`.
9209fn locator_sort_key(l: &spg_storage::RowLocator) -> (u8, u64, u64) {
9210    match *l {
9211        spg_storage::RowLocator::Hot(i) => (0, i as u64, 0),
9212        spg_storage::RowLocator::Cold {
9213            segment_id,
9214            page_offset,
9215        } => (1, u64::from(segment_id), u64::from(page_offset)),
9216    }
9217}
9218
9219/// v5.2.3: extract `(column_position, IndexKey)` when `where_expr`
9220/// is a simple `col = literal` predicate suitable for a `BTree` index
9221/// seek. Used by `exec_update_cancel` / `exec_delete_cancel` to
9222/// decide whether a write touches a cold-tier row (which requires
9223/// promote-on-write / shadow-on-delete) before falling through to
9224/// the hot-tier row walk.
9225///
9226/// Returns `None` for any predicate shape the planner can't push
9227/// down to an index seek — complex WHERE clauses always take the
9228/// hot-only path (cold rows are immutable to non-indexed writes
9229/// until a future scan-fanout sub-version).
9230fn try_pk_predicate(
9231    where_expr: &Expr,
9232    schema_cols: &[ColumnSchema],
9233    table_alias: &str,
9234) -> Option<(usize, IndexKey)> {
9235    let Expr::Binary {
9236        lhs,
9237        op: BinOp::Eq,
9238        rhs,
9239    } = where_expr
9240    else {
9241        return None;
9242    };
9243    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
9244        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
9245    let key = IndexKey::from_value(&value)?;
9246    Some((col_pos, key))
9247}
9248
9249fn resolve_col_literal_pair(
9250    col_side: &Expr,
9251    lit_side: &Expr,
9252    schema_cols: &[ColumnSchema],
9253    table_alias: &str,
9254) -> Option<(usize, Value)> {
9255    let Expr::Column(c) = col_side else {
9256        return None;
9257    };
9258    if let Some(q) = &c.qualifier
9259        && q != table_alias
9260    {
9261        return None;
9262    }
9263    let pos = schema_cols.iter().position(|s| s.name == c.name)?;
9264    let Expr::Literal(l) = lit_side else {
9265        return None;
9266    };
9267    let v = match l {
9268        Literal::Integer(n) => {
9269            if let Ok(small) = i32::try_from(*n) {
9270                Value::Int(small)
9271            } else {
9272                Value::BigInt(*n)
9273            }
9274        }
9275        Literal::Float(x) => Value::Float(*x),
9276        Literal::String(s) => Value::Text(s.clone()),
9277        Literal::Bool(b) => Value::Bool(*b),
9278        Literal::Null => Value::Null,
9279        // Vector, array and Interval literals can't be used as B-tree
9280        // index keys. Tell the planner to fall back to full-scan.
9281        Literal::Vector(_)
9282        | Literal::Interval { .. }
9283        | Literal::TextArray(_)
9284        | Literal::IntArray(_)
9285        | Literal::BigIntArray(_) => return None,
9286    };
9287    Some((pos, v))
9288}
9289
9290/// Find the schema entry that a SELECT-list `Expr::Column` refers to.
9291/// Mirrors `resolve_column` in `eval.rs`, but returns a proper
9292/// `EngineError` so the projection-build path keeps `UnknownQualifier`
9293/// vs `ColumnNotFound` distinct.
9294fn resolve_projection_column<'a>(
9295    c: &ColumnName,
9296    schema_cols: &'a [ColumnSchema],
9297    table_alias: &str,
9298) -> Result<&'a ColumnSchema, EngineError> {
9299    if let Some(q) = &c.qualifier {
9300        let composite = alloc::format!("{q}.{name}", name = c.name);
9301        if let Some(s) = schema_cols.iter().find(|s| s.name == composite) {
9302            return Ok(s);
9303        }
9304        // Single-table case: the qualifier may equal the active alias —
9305        // then look for the bare column name.
9306        if q == table_alias
9307            && let Some(s) = schema_cols.iter().find(|s| s.name == c.name)
9308        {
9309            return Ok(s);
9310        }
9311        // For multi-table schemas the qualifier is unknown only if no
9312        // column bears the "<q>." prefix. For single-table, the alias
9313        // mismatch alone is enough.
9314        let prefix = alloc::format!("{q}.");
9315        let qualifier_known =
9316            q == table_alias || schema_cols.iter().any(|s| s.name.starts_with(&prefix));
9317        if !qualifier_known {
9318            return Err(EngineError::Eval(EvalError::UnknownQualifier {
9319                qualifier: q.clone(),
9320            }));
9321        }
9322        return Err(EngineError::Eval(EvalError::ColumnNotFound {
9323            name: c.name.clone(),
9324        }));
9325    }
9326    if let Some(s) = schema_cols.iter().find(|s| s.name == c.name) {
9327        return Ok(s);
9328    }
9329    let suffix = alloc::format!(".{name}", name = c.name);
9330    let mut matches = schema_cols.iter().filter(|s| s.name.ends_with(&suffix));
9331    let first = matches.next();
9332    let extra = matches.next();
9333    match (first, extra) {
9334        (Some(s), None) => Ok(s),
9335        (Some(_), Some(_)) => Err(EngineError::Eval(EvalError::TypeMismatch {
9336            detail: alloc::format!("ambiguous column reference: {}", c.name),
9337        })),
9338        _ => Err(EngineError::Eval(EvalError::ColumnNotFound {
9339            name: c.name.clone(),
9340        })),
9341    }
9342}
9343
9344fn build_projection(
9345    items: &[SelectItem],
9346    schema_cols: &[ColumnSchema],
9347    table_alias: &str,
9348) -> Result<Vec<ProjectedItem>, EngineError> {
9349    let mut out = Vec::new();
9350    for item in items {
9351        match item {
9352            SelectItem::Wildcard => {
9353                for col in schema_cols {
9354                    out.push(ProjectedItem {
9355                        expr: Expr::Column(ColumnName {
9356                            qualifier: None,
9357                            name: col.name.clone(),
9358                        }),
9359                        output_name: col.name.clone(),
9360                        ty: col.ty,
9361                        nullable: col.nullable,
9362                    });
9363                }
9364            }
9365            SelectItem::Expr { expr, alias } => {
9366                // Plain column ref keeps full schema info (real type +
9367                // nullability). For compound expressions try the
9368                // describe-side function-return-type table first
9369                // (e.g. `SELECT now()` → Timestamptz, `SELECT
9370                // concat(…)` → Text). Falls back to nullable Text
9371                // for shapes the describe path can't resolve.
9372                if let Expr::Column(c) = expr {
9373                    let sch = resolve_projection_column(c, schema_cols, table_alias)?;
9374                    let output_name = alias.clone().unwrap_or_else(|| c.name.clone());
9375                    out.push(ProjectedItem {
9376                        expr: expr.clone(),
9377                        output_name,
9378                        ty: sch.ty,
9379                        nullable: sch.nullable,
9380                    });
9381                } else if let Some(shape) = describe::describe_expr(expr, schema_cols) {
9382                    let output_name = alias.clone().unwrap_or_else(|| expr.to_string());
9383                    out.push(ProjectedItem {
9384                        expr: expr.clone(),
9385                        output_name,
9386                        ty: shape.ty,
9387                        nullable: shape.nullable,
9388                    });
9389                } else {
9390                    let output_name = alias.clone().unwrap_or_else(|| expr.to_string());
9391                    out.push(ProjectedItem {
9392                        expr: expr.clone(),
9393                        output_name,
9394                        ty: DataType::Text,
9395                        nullable: true,
9396                    });
9397                }
9398            }
9399        }
9400    }
9401    Ok(out)
9402}
9403
9404/// Promote an integer to a NUMERIC value at the requested scale.
9405/// Rejects values that, after scaling, would overflow the column's
9406/// precision budget.
9407fn numeric_from_integer(
9408    n: i128,
9409    precision: u8,
9410    scale: u8,
9411    col_name: &str,
9412) -> Result<Value, EngineError> {
9413    let factor = pow10_i128(scale);
9414    let scaled = n.checked_mul(factor).ok_or_else(|| {
9415        EngineError::Unsupported(alloc::format!(
9416            "integer overflow scaling value for column `{col_name}` to scale {scale}"
9417        ))
9418    })?;
9419    check_precision(scaled, precision, col_name)?;
9420    Ok(Value::Numeric { scaled, scale })
9421}
9422
9423/// Float → NUMERIC. Uses round-half-away-from-zero on `x * 10^scale`,
9424/// then verifies the result fits the column's precision.
9425#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
9426fn numeric_from_float(
9427    x: f64,
9428    precision: u8,
9429    scale: u8,
9430    col_name: &str,
9431) -> Result<Value, EngineError> {
9432    if !x.is_finite() {
9433        return Err(EngineError::Unsupported(alloc::format!(
9434            "cannot store non-finite float in NUMERIC column `{col_name}`"
9435        )));
9436    }
9437    let mut factor = 1.0_f64;
9438    for _ in 0..scale {
9439        factor *= 10.0;
9440    }
9441    // Round half-away-from-zero by biasing then casting (`as i128`
9442    // truncates toward zero, so the bias + truncation gives the
9443    // desired rounding). `f64::floor` / `ceil` live in std; we don't
9444    // need them — the cast handles the truncation step.
9445    let shifted = x * factor;
9446    let biased = if shifted >= 0.0 {
9447        shifted + 0.5
9448    } else {
9449        shifted - 0.5
9450    };
9451    // Range-check before casting back to i128 — the cast itself is
9452    // saturating in Rust, which would silently truncate huge inputs.
9453    if !(-1e38..=1e38).contains(&biased) {
9454        return Err(EngineError::Unsupported(alloc::format!(
9455            "value {x} overflows NUMERIC range for column `{col_name}`"
9456        )));
9457    }
9458    let scaled = biased as i128;
9459    check_precision(scaled, precision, col_name)?;
9460    Ok(Value::Numeric { scaled, scale })
9461}
9462
9463/// v7.17.0 Phase 3.P0-67 — parse PG-canonical decimal text into
9464/// `(mantissa: i128, source_scale: u8)`. Accepts optional sign,
9465/// optional integer part, optional fractional part. Rejects
9466/// scientific notation, embedded spaces, locale-specific
9467/// thousand separators. Returns None on bad input — coerce_value
9468/// turns that into a TypeMismatch error.
9469fn parse_numeric_text(s: &str) -> Option<(i128, u8)> {
9470    let s = s.trim();
9471    if s.is_empty() {
9472        return None;
9473    }
9474    let (negative, rest) = match s.as_bytes()[0] {
9475        b'-' => (true, &s[1..]),
9476        b'+' => (false, &s[1..]),
9477        _ => (false, s),
9478    };
9479    if rest.is_empty() {
9480        return None;
9481    }
9482    // Reject scientific notation — bigdecimal collapses it before
9483    // hitting the wire, and we want a clear error if a stray `e`
9484    // sneaks in.
9485    if rest.bytes().any(|b| b == b'e' || b == b'E') {
9486        return None;
9487    }
9488    let (int_part, frac_part) = match rest.find('.') {
9489        Some(idx) => (&rest[..idx], &rest[idx + 1..]),
9490        None => (rest, ""),
9491    };
9492    if int_part.is_empty() && frac_part.is_empty() {
9493        return None;
9494    }
9495    if int_part.bytes().any(|b| !b.is_ascii_digit()) {
9496        return None;
9497    }
9498    if frac_part.bytes().any(|b| !b.is_ascii_digit()) {
9499        return None;
9500    }
9501    let scale_u32 = u32::try_from(frac_part.len()).ok()?;
9502    if scale_u32 > u32::from(u8::MAX) {
9503        return None;
9504    }
9505    let scale = scale_u32 as u8;
9506    let mut digits = alloc::string::String::with_capacity(int_part.len() + frac_part.len() + 1);
9507    if negative {
9508        digits.push('-');
9509    }
9510    digits.push_str(int_part);
9511    digits.push_str(frac_part);
9512    // Strip a leading "+0..0" so parse doesn't choke on "00" etc.
9513    let digits = if digits == "-" {
9514        return None;
9515    } else if digits.is_empty() {
9516        "0"
9517    } else {
9518        digits.as_str()
9519    };
9520    let mantissa: i128 = digits.parse().ok()?;
9521    Some((mantissa, scale))
9522}
9523
9524/// Move a Numeric value from `src_scale` to `dst_scale`. Going up
9525/// multiplies by 10; going down rounds half-away-from-zero.
9526fn numeric_rescale(
9527    scaled: i128,
9528    src_scale: u8,
9529    precision: u8,
9530    dst_scale: u8,
9531    col_name: &str,
9532) -> Result<Value, EngineError> {
9533    let new_scaled = if dst_scale >= src_scale {
9534        let bump = pow10_i128(dst_scale - src_scale);
9535        scaled.checked_mul(bump).ok_or_else(|| {
9536            EngineError::Unsupported(alloc::format!(
9537                "overflow rescaling NUMERIC for column `{col_name}`"
9538            ))
9539        })?
9540    } else {
9541        let drop = pow10_i128(src_scale - dst_scale);
9542        let half = drop / 2;
9543        if scaled >= 0 {
9544            (scaled + half) / drop
9545        } else {
9546            (scaled - half) / drop
9547        }
9548    };
9549    check_precision(new_scaled, precision, col_name)?;
9550    Ok(Value::Numeric {
9551        scaled: new_scaled,
9552        scale: dst_scale,
9553    })
9554}
9555
9556/// Drop the fractional part of a scaled integer, returning the integer
9557/// portion (toward zero). Used for NUMERIC → INT casts.
9558const fn numeric_truncate_to_integer(scaled: i128, scale: u8) -> i128 {
9559    if scale == 0 {
9560        return scaled;
9561    }
9562    let factor = pow10_i128_const(scale);
9563    scaled / factor
9564}
9565
9566/// Verify a scaled NUMERIC value fits the column's declared precision.
9567/// `precision == 0` is the "unconstrained" form (bare `NUMERIC`); we
9568/// skip the check there.
9569fn check_precision(scaled: i128, precision: u8, col_name: &str) -> Result<(), EngineError> {
9570    if precision == 0 {
9571        return Ok(());
9572    }
9573    let limit = pow10_i128(precision);
9574    if scaled.unsigned_abs() >= limit.unsigned_abs() {
9575        return Err(EngineError::Unsupported(alloc::format!(
9576            "NUMERIC value exceeds precision {precision} for column `{col_name}`"
9577        )));
9578    }
9579    Ok(())
9580}
9581
9582const fn pow10_i128_const(p: u8) -> i128 {
9583    let mut acc: i128 = 1;
9584    let mut i = 0;
9585    while i < p {
9586        acc *= 10;
9587        i += 1;
9588    }
9589    acc
9590}
9591
9592fn pow10_i128(p: u8) -> i128 {
9593    pow10_i128_const(p)
9594}
9595
9596/// Walk a parsed `Statement`, swapping any `NOW()` /
9597/// `CURRENT_TIMESTAMP()` / `CURRENT_DATE()` function calls for a
9598/// literal cast that wraps the engine's per-statement clock reading.
9599/// When `now_micros` is `None`, calls stay as-is and surface as
9600/// `unknown function` at eval time — keeps the error path explicit.
9601/// v4.10: pre-walk the WHERE / projection / etc. of a SELECT and
9602/// replace every subquery node with a materialised literal. SPG
9603/// only supports uncorrelated subqueries — the inner SELECT does
9604/// not see outer-row columns, so the result is the same for every
9605/// outer row and can be evaluated once.
9606///
9607/// Returns the rewritten statement; the caller passes this to the
9608/// regular row-loop executor which no longer sees Subquery nodes
9609/// in its tree.
9610impl Engine {
9611    /// v4.12 window executor. Implements `ROW_NUMBER` / `RANK` /
9612    /// `DENSE_RANK` and the partition-aware aggregates `SUM` /
9613    /// `AVG` / `COUNT` / `MIN` / `MAX`. The plan is:
9614    /// 1. Apply the WHERE filter.
9615    /// 2. For each unique `WindowFunction` node in the projection,
9616    ///    partition + sort, compute the per-row value.
9617    /// 3. Append the window values as synthetic columns (`__win_N`)
9618    ///    to the row schema.
9619    /// 4. Rewrite the projection to read those columns.
9620    /// 5. Hand off to the regular project / ORDER BY / LIMIT pipe.
9621    #[allow(
9622        clippy::too_many_lines,
9623        clippy::type_complexity,
9624        clippy::needless_range_loop
9625    )] // window-eval is one cohesive pipe; splitting fragments
9626    fn exec_select_with_window(
9627        &self,
9628        stmt: &SelectStatement,
9629        cancel: CancelToken<'_>,
9630    ) -> Result<QueryResult, EngineError> {
9631        let from = stmt.from.as_ref().ok_or_else(|| {
9632            EngineError::Unsupported("window functions require a FROM clause".into())
9633        })?;
9634        // v7.17.0 Phase 3.P0-43 — JOIN + window functions. Phase
9635        // 3.6 rejected this combination outright ("queued for
9636        // v5.x"); P0-43 materialises the join + WHERE through the
9637        // existing nested-loop helper and runs the window pipeline
9638        // on the joined row set with the combined `alias.col`
9639        // schema. The window expressions resolve through the
9640        // qualifier-aware column resolver same as the aggregate /
9641        // projection paths on JOIN.
9642        let (schema_cols_owned, alias_opt): (Vec<ColumnSchema>, Option<&str>);
9643        let filtered: Vec<Row>;
9644        if from.joins.is_empty() {
9645            let primary = &from.primary;
9646            let table = self.active_catalog().get(&primary.name).ok_or_else(|| {
9647                StorageError::TableNotFound {
9648                    name: primary.name.clone(),
9649                }
9650            })?;
9651            let alias = primary.alias.as_deref().unwrap_or(primary.name.as_str());
9652            schema_cols_owned = table.schema().columns.clone();
9653            alias_opt = Some(alias);
9654            // Materialise WHERE-filtered rows owned so the JOIN
9655            // and single-table paths share a single downstream
9656            // shape. The clone is cheap relative to the window
9657            // computation that follows.
9658            let ctx = self.ev_ctx(&schema_cols_owned, alias_opt);
9659            let mut owned: Vec<Row> = Vec::new();
9660            for (i, row) in table.rows().iter().enumerate() {
9661                if i.is_multiple_of(256) {
9662                    cancel.check()?;
9663                }
9664                if let Some(w) = &stmt.where_ {
9665                    let cond = eval::eval_expr(w, row, &ctx)?;
9666                    if !matches!(cond, Value::Bool(true)) {
9667                        continue;
9668                    }
9669                }
9670                owned.push(row.clone());
9671            }
9672            filtered = owned;
9673        } else {
9674            let (combined_schema, rows) =
9675                self.build_joined_filtered_rows(from, stmt.where_.as_ref())?;
9676            schema_cols_owned = combined_schema;
9677            alias_opt = None;
9678            filtered = rows;
9679        }
9680        let schema_cols = &schema_cols_owned;
9681        let ctx = self.ev_ctx(schema_cols, alias_opt);
9682        let alias = alias_opt.unwrap_or("");
9683        let n_rows = filtered.len();
9684        // Borrow refs into the owned row vec once so the downstream
9685        // `compute_window_partition` call (which takes `&[&Row]`) and
9686        // the per-row eval loops share a single backing buffer.
9687        let filtered_refs: Vec<&Row> = filtered.iter().collect();
9688
9689        // 2) Collect unique window function nodes from projection.
9690        let mut window_nodes: Vec<Expr> = Vec::new();
9691        for item in &stmt.items {
9692            if let SelectItem::Expr { expr, .. } = item {
9693                collect_window_nodes(expr, &mut window_nodes);
9694            }
9695        }
9696
9697        // 3) For each window, compute per-row value.
9698        // Index: same order as window_nodes; for row i, win_vals[w][i].
9699        let mut win_vals: Vec<Vec<Value>> = Vec::with_capacity(window_nodes.len());
9700        for wnode in &window_nodes {
9701            let Expr::WindowFunction {
9702                name,
9703                args,
9704                partition_by,
9705                order_by,
9706                frame,
9707                null_treatment,
9708            } = wnode
9709            else {
9710                unreachable!("collect_window_nodes pushes only WindowFunction");
9711            };
9712            // Compute (partition_key, order_key, original_index) for each row.
9713            let mut indexed: Vec<(Vec<Value>, Vec<(Value, bool)>, usize)> =
9714                Vec::with_capacity(n_rows);
9715            for (i, row) in filtered.iter().enumerate() {
9716                let pkey: Vec<Value> = partition_by
9717                    .iter()
9718                    .map(|p| eval::eval_expr(p, row, &ctx))
9719                    .collect::<Result<_, _>>()?;
9720                let okey: Vec<(Value, bool)> = order_by
9721                    .iter()
9722                    .map(|(e, desc)| eval::eval_expr(e, row, &ctx).map(|v| (v, *desc)))
9723                    .collect::<Result<_, _>>()?;
9724                indexed.push((pkey, okey, i));
9725            }
9726            // Sort by (partition_key, order_key). Partition key uses
9727            // a stable encoded form; order key respects ASC/DESC.
9728            indexed.sort_by(|a, b| {
9729                let p_cmp = partition_key_cmp(&a.0, &b.0);
9730                if p_cmp != core::cmp::Ordering::Equal {
9731                    return p_cmp;
9732                }
9733                order_key_cmp(&a.1, &b.1)
9734            });
9735            // Per-partition compute.
9736            let mut out_vals: Vec<Value> = alloc::vec![Value::Null; n_rows];
9737            let mut p_start = 0;
9738            while p_start < indexed.len() {
9739                let mut p_end = p_start + 1;
9740                while p_end < indexed.len()
9741                    && partition_key_cmp(&indexed[p_start].0, &indexed[p_end].0)
9742                        == core::cmp::Ordering::Equal
9743                {
9744                    p_end += 1;
9745                }
9746                // Compute the function within this partition slice.
9747                compute_window_partition(
9748                    name,
9749                    args,
9750                    !order_by.is_empty(),
9751                    frame.as_ref(),
9752                    *null_treatment,
9753                    &indexed[p_start..p_end],
9754                    &filtered_refs,
9755                    &ctx,
9756                    &mut out_vals,
9757                )?;
9758                p_start = p_end;
9759            }
9760            win_vals.push(out_vals);
9761        }
9762
9763        // 4) Build extended schema: original columns + synthetic.
9764        let mut ext_cols = schema_cols.clone();
9765        for i in 0..window_nodes.len() {
9766            ext_cols.push(ColumnSchema::new(
9767                alloc::format!("__win_{i}"),
9768                DataType::Text, // type doesn't matter for projection eval
9769                true,
9770            ));
9771        }
9772        // 5) Build extended rows: each row gets its window values appended.
9773        let mut ext_rows: Vec<Row> = Vec::with_capacity(n_rows);
9774        for i in 0..n_rows {
9775            let mut values = filtered[i].values.clone();
9776            for w in 0..window_nodes.len() {
9777                values.push(win_vals[w][i].clone());
9778            }
9779            ext_rows.push(Row::new(values));
9780        }
9781        // 6) Rewrite the projection: WindowFunction nodes → Column(__win_N).
9782        let mut rewritten_items: Vec<SelectItem> = Vec::with_capacity(stmt.items.len());
9783        for item in &stmt.items {
9784            let new_item = match item {
9785                SelectItem::Wildcard => SelectItem::Wildcard,
9786                SelectItem::Expr { expr, alias } => {
9787                    let mut e = expr.clone();
9788                    rewrite_window_to_columns(&mut e, &window_nodes);
9789                    SelectItem::Expr {
9790                        expr: e,
9791                        alias: alias.clone(),
9792                    }
9793                }
9794            };
9795            rewritten_items.push(new_item);
9796        }
9797
9798        // 7) Project into final rows. JOIN case uses None so the
9799        // qualifier check in `resolve_column` falls through to the
9800        // composite `alias.col` schema lookup; single-table case
9801        // keeps the bare alias so `bare_col` resolution still
9802        // works for the projection's per-row column references.
9803        let ext_ctx = EvalContext::new(&ext_cols, alias_opt);
9804        let projection = build_projection(&rewritten_items, &ext_cols, alias)?;
9805        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::with_capacity(n_rows);
9806        for (i, row) in ext_rows.iter().enumerate() {
9807            if i.is_multiple_of(256) {
9808                cancel.check()?;
9809            }
9810            let mut values = Vec::with_capacity(projection.len());
9811            for p in &projection {
9812                values.push(eval::eval_expr(&p.expr, row, &ext_ctx)?);
9813            }
9814            let order_keys = if stmt.order_by.is_empty() {
9815                Vec::new()
9816            } else {
9817                let mut keys = Vec::with_capacity(stmt.order_by.len());
9818                for o in &stmt.order_by {
9819                    let mut e = o.expr.clone();
9820                    rewrite_window_to_columns(&mut e, &window_nodes);
9821                    let key = eval::eval_expr(&e, row, &ext_ctx)?;
9822                    keys.push(value_to_order_key(&key)?);
9823                }
9824                keys
9825            };
9826            tagged.push((order_keys, Row::new(values)));
9827        }
9828        // ORDER BY + LIMIT/OFFSET on the projected rows.
9829        if !stmt.order_by.is_empty() {
9830            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
9831            sort_by_keys(&mut tagged, &descs);
9832        }
9833        let mut out_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
9834        apply_offset_and_limit(&mut out_rows, stmt.offset_literal(), stmt.limit_literal());
9835        let final_cols: Vec<ColumnSchema> = projection
9836            .into_iter()
9837            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
9838            .collect();
9839        Ok(QueryResult::Rows {
9840            columns: final_cols,
9841            rows: out_rows,
9842        })
9843    }
9844
9845    /// v4.11: materialise each CTE into a temp table inside a
9846    /// cloned catalog, then run the body SELECT against a fresh
9847    /// engine instance that owns the enriched catalog. The clone
9848    /// is moderately expensive — only paid by CTE-bearing queries.
9849    /// Subqueries inside CTE bodies / the main body resolve as
9850    /// usual; `clock_fn` is propagated so `NOW()` lines up.
9851    /// v7.16.2 — mailrs round-10 A.3. Materialise the
9852    /// `information_schema.*` / `pg_catalog.*` virtual views
9853    /// the SELECT references, then re-execute the SELECT
9854    /// against an enriched catalog where those views are real
9855    /// tables. Same pattern as `exec_with_ctes`. The temp
9856    /// engine carries `meta_views_materialised = true` so its
9857    /// own meta-dispatch short-circuits — without that we'd
9858    /// infinite-recurse since the temp catalog's view name
9859    /// still starts with `__spg_info_` and re-triggers the
9860    /// check.
9861    fn exec_select_with_meta_views(
9862        &self,
9863        stmt: &SelectStatement,
9864        cancel: CancelToken<'_>,
9865    ) -> Result<QueryResult, EngineError> {
9866        let mut needed: alloc::collections::BTreeSet<String> = alloc::collections::BTreeSet::new();
9867        collect_meta_view_names(stmt, &mut needed);
9868        let mut catalog = self.active_catalog().clone();
9869        for view in &needed {
9870            if catalog.get(view).is_some() {
9871                continue;
9872            }
9873            match view.as_str() {
9874                "__spg_info_columns" => {
9875                    let (schema, rows) = synth_information_schema_columns(self.active_catalog());
9876                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9877                }
9878                "__spg_info_tables" => {
9879                    let (schema, rows) = synth_information_schema_tables(self.active_catalog());
9880                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9881                }
9882                "__spg_pg_class" => {
9883                    let (schema, rows) = synth_pg_class(self.active_catalog());
9884                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9885                }
9886                "__spg_pg_attribute" => {
9887                    let (schema, rows) = synth_pg_attribute(self.active_catalog());
9888                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9889                }
9890                // v7.17.0 Phase 3.P0-50 — pg_catalog.pg_type for
9891                // sqlx / SQLAlchemy / Diesel / pgAdmin lookups.
9892                "__spg_pg_type" => {
9893                    let (schema, rows) = synth_pg_type(self.active_catalog());
9894                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9895                }
9896                // v7.17.0 Phase 3.P0-51 — pg_catalog.pg_proc for
9897                // function-name introspection (ORM / pgAdmin).
9898                "__spg_pg_proc" => {
9899                    let (schema, rows) = synth_pg_proc(self.active_catalog());
9900                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9901                }
9902                // v7.17.0 Phase 3.P0-52 — pg_catalog.pg_namespace
9903                // (schema list for admin tools' tree views).
9904                "__spg_pg_namespace" => {
9905                    let (schema, rows) = synth_pg_namespace(self.active_catalog());
9906                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9907                }
9908                // v7.17.0 Phase 3.P0-53 — pg_catalog.pg_indexes view
9909                // for pgAdmin / DataGrip "indexes per table" listings.
9910                "__spg_pg_indexes" => {
9911                    let (schema, rows) = synth_pg_indexes(self.active_catalog());
9912                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9913                }
9914                // v7.17.0 Phase 3.P0-53 — pg_catalog.pg_index (raw)
9915                // for index introspection by ORM compilers.
9916                "__spg_pg_index" => {
9917                    let (schema, rows) = synth_pg_index_raw(self.active_catalog());
9918                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9919                }
9920                // v7.17.0 Phase 3.P0-54 — pg_catalog.pg_constraint
9921                // for FK / UNIQUE / PK / CHECK introspection.
9922                "__spg_pg_constraint" => {
9923                    let (schema, rows) = synth_pg_constraint(self.active_catalog());
9924                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9925                }
9926                // v7.17.0 Phase 3.P0-55 — pg_catalog.pg_database /
9927                // pg_roles / pg_user. SPG is single-database so
9928                // pg_database surfaces just `postgres`; pg_roles
9929                // / pg_user walk the engine's UserStore.
9930                "__spg_pg_database" => {
9931                    let (schema, rows) = synth_pg_database(self.active_catalog());
9932                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9933                }
9934                "__spg_pg_roles" | "__spg_pg_user" => {
9935                    let (schema, rows) = synth_pg_roles(self);
9936                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9937                }
9938                // v7.17.0 Phase 3.P0-56 — pg_catalog.pg_views. PG's
9939                // pg_views surfaces every CREATE VIEW result; SPG
9940                // ships one row per declared view from the catalog.
9941                "__spg_pg_views" => {
9942                    let (schema, rows) = synth_pg_views(self.active_catalog());
9943                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9944                }
9945                // v7.17.0 Phase 3.P0-56 — pg_catalog.pg_matviews.
9946                // SPG has no materialised view surface yet so the
9947                // table shares pg_views's schema but stays empty.
9948                "__spg_pg_matviews" => {
9949                    let (schema, _) = synth_pg_views(self.active_catalog());
9950                    materialise_meta_view(&mut catalog, view, schema, Vec::new())?;
9951                }
9952                // pg_catalog.pg_extension — native capability list
9953                // (mailrs embed round-12).
9954                "__spg_pg_extension" => {
9955                    let (schema, rows) = synth_pg_extension();
9956                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9957                }
9958                // v7.17.0 Phase 3.P0-57 — pg_catalog.pg_settings.
9959                "__spg_pg_settings" => {
9960                    let (schema, rows) = synth_pg_settings(self);
9961                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9962                }
9963                // v7.17.0 Phase 3.P0-63 — information_schema.KEY_COLUMN_USAGE.
9964                "__spg_info_key_column_usage" => {
9965                    let (schema, rows) = synth_info_key_column_usage(self.active_catalog());
9966                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9967                }
9968                // v7.17.0 Phase 3.P0-64 — information_schema.REFERENTIAL_CONSTRAINTS.
9969                "__spg_info_referential_constraints" => {
9970                    let (schema, rows) = synth_info_referential_constraints(self.active_catalog());
9971                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9972                }
9973                // v7.17.0 Phase 3.P0-64 — information_schema.STATISTICS.
9974                "__spg_info_statistics" => {
9975                    let (schema, rows) = synth_info_statistics(self.active_catalog());
9976                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9977                }
9978                // v7.17.0 Phase 3.P0-64 — information_schema.ROUTINES.
9979                "__spg_info_routines" => {
9980                    let (schema, rows) = synth_info_routines();
9981                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9982                }
9983                // v7.17.0 Phase 3.P0-65 — mysql.user / mysql.db.
9984                "__spg_mysql_user" => {
9985                    let (schema, rows) = synth_mysql_user(self);
9986                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9987                }
9988                "__spg_mysql_db" => {
9989                    let (schema, rows) = synth_mysql_db();
9990                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9991                }
9992                _ => {
9993                    return Err(EngineError::Unsupported(alloc::format!(
9994                        "meta view {view:?} is not yet materialisable; \
9995                         v7.16.2 covers information_schema.columns / .tables \
9996                         and pg_catalog.pg_class / pg_attribute; \
9997                         v7.17.0 P0-50..P0-57 add pg_type / pg_proc / pg_namespace / \
9998                         pg_indexes / pg_index / pg_constraint / pg_database / pg_roles / \
9999                         pg_user / pg_views / pg_matviews / pg_settings"
10000                    )));
10001                }
10002            }
10003        }
10004        let mut temp = Engine::restore(catalog);
10005        if let Some(c) = self.clock {
10006            temp = temp.with_clock(c);
10007        }
10008        if let Some(f) = self.salt_fn {
10009            temp = temp.with_salt_fn(f);
10010        }
10011        temp.meta_views_materialised = true;
10012        temp.exec_select_cancel(stmt, cancel)
10013    }
10014
10015    fn exec_with_ctes(
10016        &self,
10017        stmt: &SelectStatement,
10018        cancel: CancelToken<'_>,
10019    ) -> Result<QueryResult, EngineError> {
10020        cancel.check()?;
10021        let mut catalog = self.active_catalog().clone();
10022        for cte in &stmt.ctes {
10023            if catalog.get(&cte.name).is_some() {
10024                return Err(EngineError::Unsupported(alloc::format!(
10025                    "CTE name {:?} shadows an existing table; rename the CTE",
10026                    cte.name
10027                )));
10028            }
10029            let (columns, rows) = if cte.recursive {
10030                self.materialise_recursive_cte(cte, &catalog, cancel)?
10031            } else {
10032                let body_result = self.exec_select_cancel(&cte.body, cancel)?;
10033                let QueryResult::Rows { columns, rows } = body_result else {
10034                    return Err(EngineError::Unsupported(alloc::format!(
10035                        "CTE {:?} body did not return rows",
10036                        cte.name
10037                    )));
10038                };
10039                (columns, rows)
10040            };
10041            // v4.22: the projection builder labels any non-column
10042            // expression as Text — including literal SELECT 1.
10043            // Promote each column's type to whatever the rows
10044            // actually carry so the CTE storage table accepts them.
10045            let inferred = infer_column_types(&columns, &rows);
10046            let mut columns = inferred;
10047            // v4.22: apply optional `WITH name(a, b, c)` overrides.
10048            if !cte.column_overrides.is_empty() {
10049                if cte.column_overrides.len() != columns.len() {
10050                    return Err(EngineError::Unsupported(alloc::format!(
10051                        "CTE {:?} column list has {} names but body returns {} columns",
10052                        cte.name,
10053                        cte.column_overrides.len(),
10054                        columns.len()
10055                    )));
10056                }
10057                for (col, name) in columns.iter_mut().zip(cte.column_overrides.iter()) {
10058                    col.name.clone_from(name);
10059                }
10060            }
10061            let schema = TableSchema::new(cte.name.clone(), columns);
10062            catalog.create_table(schema).map_err(EngineError::Storage)?;
10063            let table = catalog
10064                .get_mut(&cte.name)
10065                .expect("just-created CTE table must exist");
10066            for row in rows {
10067                table.insert(row).map_err(EngineError::Storage)?;
10068            }
10069        }
10070        // Strip CTEs from the body before running on the temp engine
10071        // so we don't recurse forever.
10072        let mut body = stmt.clone();
10073        body.ctes = Vec::new();
10074        let mut temp = Engine::restore(catalog);
10075        if let Some(c) = self.clock {
10076            temp = temp.with_clock(c);
10077        }
10078        if let Some(f) = self.salt_fn {
10079            temp = temp.with_salt_fn(f);
10080        }
10081        temp.exec_select_cancel(&body, cancel)
10082    }
10083
10084    /// v4.22: materialise a WITH RECURSIVE CTE. The body must be a
10085    /// UNION (or UNION ALL) of an anchor that does not reference
10086    /// the CTE name, and one or more recursive terms that do. The
10087    /// anchor runs first; each subsequent iteration runs the
10088    /// recursive term against a temp catalog where the CTE name is
10089    /// bound to the *previous* iteration's output. Iteration stops
10090    /// when the recursive term yields no rows; UNION (DISTINCT)
10091    /// deduplicates against the accumulated result, UNION ALL does
10092    /// not. A hard cap on total rows prevents runaway queries.
10093    #[allow(clippy::too_many_lines)]
10094    fn materialise_recursive_cte(
10095        &self,
10096        cte: &spg_sql::ast::Cte,
10097        base_catalog: &Catalog,
10098        cancel: CancelToken<'_>,
10099    ) -> Result<(Vec<ColumnSchema>, Vec<Row>), EngineError> {
10100        const MAX_TOTAL_ROWS: usize = 1_000_000;
10101        const MAX_ITERATIONS: usize = 100_000;
10102        cancel.check()?;
10103        if cte.body.unions.is_empty() {
10104            return Err(EngineError::Unsupported(alloc::format!(
10105                "WITH RECURSIVE {:?} body must be a UNION of an anchor and a recursive term",
10106                cte.name
10107            )));
10108        }
10109        // Anchor: the body's leading SELECT, with unions stripped.
10110        let mut anchor = cte.body.clone();
10111        let union_terms = core::mem::take(&mut anchor.unions);
10112        anchor.ctes = Vec::new();
10113        // Anchor must not reference the CTE name.
10114        if select_refers_to(&anchor, &cte.name) {
10115            return Err(EngineError::Unsupported(alloc::format!(
10116                "WITH RECURSIVE {:?}: the anchor must not reference the CTE itself",
10117                cte.name
10118            )));
10119        }
10120        let anchor_result = self.exec_select_cancel(&anchor, cancel)?;
10121        let QueryResult::Rows {
10122            columns: anchor_cols,
10123            rows: anchor_rows,
10124        } = anchor_result
10125        else {
10126            return Err(EngineError::Unsupported(alloc::format!(
10127                "WITH RECURSIVE {:?}: anchor did not return rows",
10128                cte.name
10129            )));
10130        };
10131        // The projection builder labels non-column expressions Text;
10132        // refine column types from the anchor's actual values so the
10133        // intermediate iter-catalog tables accept them.
10134        let mut columns = infer_column_types(&anchor_cols, &anchor_rows);
10135        if !cte.column_overrides.is_empty() {
10136            if cte.column_overrides.len() != columns.len() {
10137                return Err(EngineError::Unsupported(alloc::format!(
10138                    "CTE {:?} column list has {} names but anchor returns {} columns",
10139                    cte.name,
10140                    cte.column_overrides.len(),
10141                    columns.len()
10142                )));
10143            }
10144            for (col, name) in columns.iter_mut().zip(cte.column_overrides.iter()) {
10145                col.name.clone_from(name);
10146            }
10147        }
10148        let mut all_rows: Vec<Row> = anchor_rows.clone();
10149        let mut working_set: Vec<Row> = anchor_rows;
10150        let mut seen: alloc::collections::BTreeSet<Vec<u8>> = alloc::collections::BTreeSet::new();
10151        // Track at least one "all UNION ALL" flag — if every union
10152        // kind is ALL we skip the dedup step (faster + matches PG).
10153        let all_union_all = union_terms.iter().all(|(k, _)| matches!(k, UnionKind::All));
10154        if !all_union_all {
10155            for r in &all_rows {
10156                seen.insert(encode_row_key(r));
10157            }
10158        }
10159        for iter in 0..MAX_ITERATIONS {
10160            cancel.check()?;
10161            if working_set.is_empty() {
10162                break;
10163            }
10164            // Build a fresh catalog: base + CTE bound to working_set.
10165            let mut iter_catalog = base_catalog.clone();
10166            let schema = TableSchema::new(cte.name.clone(), columns.clone());
10167            iter_catalog
10168                .create_table(schema)
10169                .map_err(EngineError::Storage)?;
10170            {
10171                let table = iter_catalog.get_mut(&cte.name).expect("just-created");
10172                for row in &working_set {
10173                    table.insert(row.clone()).map_err(EngineError::Storage)?;
10174                }
10175            }
10176            let mut iter_engine = Engine::restore(iter_catalog);
10177            if let Some(c) = self.clock {
10178                iter_engine = iter_engine.with_clock(c);
10179            }
10180            if let Some(f) = self.salt_fn {
10181                iter_engine = iter_engine.with_salt_fn(f);
10182            }
10183            // Run each recursive term in sequence and collect new rows.
10184            let mut next_set: Vec<Row> = Vec::new();
10185            for (_, term) in &union_terms {
10186                let mut term = term.clone();
10187                term.ctes = Vec::new();
10188                let r = iter_engine.exec_select_cancel(&term, cancel)?;
10189                let QueryResult::Rows {
10190                    columns: rc,
10191                    rows: rs,
10192                } = r
10193                else {
10194                    return Err(EngineError::Unsupported(alloc::format!(
10195                        "WITH RECURSIVE {:?}: recursive term did not return rows",
10196                        cte.name
10197                    )));
10198                };
10199                if rc.len() != columns.len() {
10200                    return Err(EngineError::Unsupported(alloc::format!(
10201                        "WITH RECURSIVE {:?}: column count of recursive term ({}) does not match anchor ({})",
10202                        cte.name,
10203                        rc.len(),
10204                        columns.len()
10205                    )));
10206                }
10207                for row in rs {
10208                    if !all_union_all {
10209                        let key = encode_row_key(&row);
10210                        if !seen.insert(key) {
10211                            continue;
10212                        }
10213                    }
10214                    next_set.push(row);
10215                }
10216            }
10217            if next_set.is_empty() {
10218                break;
10219            }
10220            all_rows.extend(next_set.iter().cloned());
10221            working_set = next_set;
10222            if all_rows.len() > MAX_TOTAL_ROWS {
10223                return Err(EngineError::Unsupported(alloc::format!(
10224                    "WITH RECURSIVE {:?}: produced more than {MAX_TOTAL_ROWS} rows — likely runaway recursion",
10225                    cte.name
10226                )));
10227            }
10228            if iter + 1 == MAX_ITERATIONS {
10229                return Err(EngineError::Unsupported(alloc::format!(
10230                    "WITH RECURSIVE {:?}: exceeded {MAX_ITERATIONS} iterations",
10231                    cte.name
10232                )));
10233            }
10234        }
10235        Ok((columns, all_rows))
10236    }
10237
10238    fn resolve_select_subqueries(
10239        &self,
10240        stmt: &mut SelectStatement,
10241        cancel: CancelToken<'_>,
10242    ) -> Result<(), EngineError> {
10243        for item in &mut stmt.items {
10244            if let SelectItem::Expr { expr, .. } = item {
10245                self.resolve_expr_subqueries(expr, cancel)?;
10246            }
10247        }
10248        if let Some(w) = &mut stmt.where_ {
10249            self.resolve_expr_subqueries(w, cancel)?;
10250        }
10251        if let Some(gs) = &mut stmt.group_by {
10252            for g in gs {
10253                self.resolve_expr_subqueries(g, cancel)?;
10254            }
10255        }
10256        if let Some(h) = &mut stmt.having {
10257            self.resolve_expr_subqueries(h, cancel)?;
10258        }
10259        for o in &mut stmt.order_by {
10260            self.resolve_expr_subqueries(&mut o.expr, cancel)?;
10261        }
10262        for (_, peer) in &mut stmt.unions {
10263            self.resolve_select_subqueries(peer, cancel)?;
10264        }
10265        Ok(())
10266    }
10267
10268    #[allow(clippy::only_used_in_recursion)] // engine handle reads aren't really pure
10269    fn resolve_expr_subqueries(
10270        &self,
10271        e: &mut Expr,
10272        cancel: CancelToken<'_>,
10273    ) -> Result<(), EngineError> {
10274        // Replace-on-this-node cases first.
10275        if let Some(replacement) = self.subquery_replacement(e, cancel)? {
10276            *e = replacement;
10277            return Ok(());
10278        }
10279        match e {
10280            Expr::Binary { lhs, rhs, .. } => {
10281                self.resolve_expr_subqueries(lhs, cancel)?;
10282                self.resolve_expr_subqueries(rhs, cancel)?;
10283            }
10284            Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
10285                self.resolve_expr_subqueries(expr, cancel)?;
10286            }
10287            Expr::FunctionCall { args, .. } => {
10288                for a in args {
10289                    self.resolve_expr_subqueries(a, cancel)?;
10290                }
10291            }
10292            Expr::Like { expr, pattern, .. } => {
10293                self.resolve_expr_subqueries(expr, cancel)?;
10294                self.resolve_expr_subqueries(pattern, cancel)?;
10295            }
10296            Expr::Extract { source, .. } => self.resolve_expr_subqueries(source, cancel)?,
10297            // v4.12 window functions — recurse into args + ORDER BY
10298            // + PARTITION BY in case they carry inner subqueries.
10299            Expr::WindowFunction {
10300                args,
10301                partition_by,
10302                order_by,
10303                ..
10304            } => {
10305                for a in args {
10306                    self.resolve_expr_subqueries(a, cancel)?;
10307                }
10308                for p in partition_by {
10309                    self.resolve_expr_subqueries(p, cancel)?;
10310                }
10311                for (e, _) in order_by {
10312                    self.resolve_expr_subqueries(e, cancel)?;
10313                }
10314            }
10315            // Subquery nodes are handled in subquery_replacement
10316            // (which returned None — defensive no-op); Literal /
10317            // Column are leaves.
10318            Expr::ScalarSubquery(_)
10319            | Expr::Exists { .. }
10320            | Expr::InSubquery { .. }
10321            | Expr::Literal(_)
10322            | Expr::Placeholder(_)
10323            | Expr::Column(_) => {}
10324            // v7.10.10 — recurse children.
10325            Expr::Array(items) => {
10326                for elem in items {
10327                    self.resolve_expr_subqueries(elem, cancel)?;
10328                }
10329            }
10330            Expr::ArraySubscript { target, index } => {
10331                self.resolve_expr_subqueries(target, cancel)?;
10332                self.resolve_expr_subqueries(index, cancel)?;
10333            }
10334            Expr::AnyAll { expr, array, .. } => {
10335                self.resolve_expr_subqueries(expr, cancel)?;
10336                self.resolve_expr_subqueries(array, cancel)?;
10337            }
10338            Expr::Case {
10339                operand,
10340                branches,
10341                else_branch,
10342            } => {
10343                if let Some(o) = operand {
10344                    self.resolve_expr_subqueries(o, cancel)?;
10345                }
10346                for (w, t) in branches {
10347                    self.resolve_expr_subqueries(w, cancel)?;
10348                    self.resolve_expr_subqueries(t, cancel)?;
10349                }
10350                if let Some(e) = else_branch {
10351                    self.resolve_expr_subqueries(e, cancel)?;
10352                }
10353            }
10354        }
10355        Ok(())
10356    }
10357
10358    /// v4.23: per-row eval that handles correlated subqueries.
10359    /// Equivalent to `eval::eval_expr` when the expression has no
10360    /// subqueries; otherwise clones the expression, substitutes
10361    /// outer-row columns into each surviving subquery node, runs
10362    /// the inner SELECT, and replaces the node with the literal
10363    /// result. Only the WHERE-filter call sites use this path so
10364    /// the uncorrelated fast path is preserved everywhere else.
10365    fn eval_expr_with_correlated(
10366        &self,
10367        expr: &Expr,
10368        row: &Row,
10369        ctx: &EvalContext<'_>,
10370        cancel: CancelToken<'_>,
10371        memo: Option<&mut memoize::MemoizeCache>,
10372    ) -> Result<Value, EngineError> {
10373        if !expr_has_subquery(expr) {
10374            return eval::eval_expr(expr, row, ctx).map_err(EngineError::Eval);
10375        }
10376        let mut e = expr.clone();
10377        self.resolve_correlated_in_expr(&mut e, row, ctx, cancel, memo)?;
10378        eval::eval_expr(&e, row, ctx).map_err(EngineError::Eval)
10379    }
10380
10381    fn resolve_correlated_in_expr(
10382        &self,
10383        e: &mut Expr,
10384        row: &Row,
10385        ctx: &EvalContext<'_>,
10386        cancel: CancelToken<'_>,
10387        mut memo: Option<&mut memoize::MemoizeCache>,
10388    ) -> Result<(), EngineError> {
10389        match e {
10390            Expr::ScalarSubquery(inner) => {
10391                // v6.2.6 — Memoize: build the cache key from the
10392                // pre-substitution subquery repr + the outer row's
10393                // values. Two outer rows with identical correlated
10394                // values hit the same entry.
10395                let cache_key = memo.as_ref().map(|_| memoize::CacheKey {
10396                    subquery_repr: alloc::format!("{}", **inner),
10397                    outer_values: row.values.clone(),
10398                });
10399                if let (Some(cache), Some(k)) = (memo.as_deref_mut(), cache_key.as_ref())
10400                    && let Some(cached) = cache.get(k)
10401                {
10402                    *e = value_to_literal_expr(cached)?;
10403                    return Ok(());
10404                }
10405                let mut s = (**inner).clone();
10406                substitute_outer_columns(&mut s, row, ctx);
10407                let r = self.exec_select_cancel(&s, cancel)?;
10408                let QueryResult::Rows { rows, .. } = r else {
10409                    return Err(EngineError::Unsupported(
10410                        "scalar subquery: inner did not return rows".into(),
10411                    ));
10412                };
10413                let value = match rows.as_slice() {
10414                    [] => Value::Null,
10415                    [r0] => r0.values.first().cloned().unwrap_or(Value::Null),
10416                    _ => {
10417                        return Err(EngineError::Unsupported(alloc::format!(
10418                            "scalar subquery returned {} rows; expected 0 or 1",
10419                            rows.len()
10420                        )));
10421                    }
10422                };
10423                if let (Some(cache), Some(k)) = (memo.as_deref_mut(), cache_key) {
10424                    cache.insert(k, value.clone());
10425                }
10426                *e = value_to_literal_expr(value)?;
10427            }
10428            Expr::Exists { subquery, negated } => {
10429                let mut s = (**subquery).clone();
10430                substitute_outer_columns(&mut s, row, ctx);
10431                let r = self.exec_select_cancel(&s, cancel)?;
10432                let exists = matches!(r, QueryResult::Rows { rows, .. } if !rows.is_empty());
10433                let bit = if *negated { !exists } else { exists };
10434                *e = Expr::Literal(Literal::Bool(bit));
10435            }
10436            Expr::InSubquery {
10437                expr: lhs,
10438                subquery,
10439                negated,
10440            } => {
10441                self.resolve_correlated_in_expr(lhs, row, ctx, cancel, memo.as_deref_mut())?;
10442                let lhs_val = eval::eval_expr(lhs, row, ctx).map_err(EngineError::Eval)?;
10443                let mut s = (**subquery).clone();
10444                substitute_outer_columns(&mut s, row, ctx);
10445                let r = self.exec_select_cancel(&s, cancel)?;
10446                let QueryResult::Rows { columns, rows, .. } = r else {
10447                    return Err(EngineError::Unsupported(
10448                        "IN-subquery: inner did not return rows".into(),
10449                    ));
10450                };
10451                if columns.len() != 1 {
10452                    return Err(EngineError::Unsupported(alloc::format!(
10453                        "IN-subquery must project exactly one column; got {}",
10454                        columns.len()
10455                    )));
10456                }
10457                let mut found = false;
10458                let mut any_null = false;
10459                for r0 in rows {
10460                    let v = r0.values.into_iter().next().unwrap_or(Value::Null);
10461                    if v.is_null() {
10462                        any_null = true;
10463                        continue;
10464                    }
10465                    if value_cmp(&v, &lhs_val) == core::cmp::Ordering::Equal {
10466                        found = true;
10467                        break;
10468                    }
10469                }
10470                let bit = if found {
10471                    !*negated
10472                } else if any_null {
10473                    return Err(EngineError::Unsupported(
10474                        "IN-subquery with NULL in result and no match: NULL semantics not yet implemented".into(),
10475                    ));
10476                } else {
10477                    *negated
10478                };
10479                *e = Expr::Literal(Literal::Bool(bit));
10480            }
10481            Expr::Binary { lhs, rhs, .. } => {
10482                self.resolve_correlated_in_expr(lhs, row, ctx, cancel, memo.as_deref_mut())?;
10483                self.resolve_correlated_in_expr(rhs, row, ctx, cancel, memo.as_deref_mut())?;
10484            }
10485            Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
10486                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
10487            }
10488            Expr::Like { expr, pattern, .. } => {
10489                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
10490                self.resolve_correlated_in_expr(pattern, row, ctx, cancel, memo.as_deref_mut())?;
10491            }
10492            Expr::FunctionCall { args, .. } => {
10493                for a in args {
10494                    self.resolve_correlated_in_expr(a, row, ctx, cancel, memo.as_deref_mut())?;
10495                }
10496            }
10497            Expr::Extract { source, .. } => {
10498                self.resolve_correlated_in_expr(source, row, ctx, cancel, memo.as_deref_mut())?;
10499            }
10500            Expr::WindowFunction { .. }
10501            | Expr::Literal(_)
10502            | Expr::Placeholder(_)
10503            | Expr::Column(_) => {}
10504            // v7.10.10 — recurse children.
10505            Expr::Array(items) => {
10506                for elem in items {
10507                    self.resolve_correlated_in_expr(elem, row, ctx, cancel, memo.as_deref_mut())?;
10508                }
10509            }
10510            Expr::ArraySubscript { target, index } => {
10511                self.resolve_correlated_in_expr(target, row, ctx, cancel, memo.as_deref_mut())?;
10512                self.resolve_correlated_in_expr(index, row, ctx, cancel, memo.as_deref_mut())?;
10513            }
10514            Expr::AnyAll { expr, array, .. } => {
10515                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
10516                self.resolve_correlated_in_expr(array, row, ctx, cancel, memo.as_deref_mut())?;
10517            }
10518            Expr::Case {
10519                operand,
10520                branches,
10521                else_branch,
10522            } => {
10523                if let Some(o) = operand {
10524                    self.resolve_correlated_in_expr(o, row, ctx, cancel, memo.as_deref_mut())?;
10525                }
10526                for (w, t) in branches {
10527                    self.resolve_correlated_in_expr(w, row, ctx, cancel, memo.as_deref_mut())?;
10528                    self.resolve_correlated_in_expr(t, row, ctx, cancel, memo.as_deref_mut())?;
10529                }
10530                if let Some(e) = else_branch {
10531                    self.resolve_correlated_in_expr(e, row, ctx, cancel, memo.as_deref_mut())?;
10532                }
10533            }
10534        }
10535        Ok(())
10536    }
10537
10538    fn subquery_replacement(
10539        &self,
10540        e: &Expr,
10541        cancel: CancelToken<'_>,
10542    ) -> Result<Option<Expr>, EngineError> {
10543        match e {
10544            Expr::ScalarSubquery(inner) => {
10545                let mut s = (**inner).clone();
10546                // Recurse into the inner SELECT first so nested
10547                // subqueries materialise bottom-up.
10548                self.resolve_select_subqueries(&mut s, cancel)?;
10549                let r = match self.exec_bare_select_cancel(&s, cancel) {
10550                    Ok(r) => r,
10551                    Err(e) if is_correlation_error(&e) => return Ok(None),
10552                    Err(e) => return Err(e),
10553                };
10554                let QueryResult::Rows { rows, .. } = r else {
10555                    return Err(EngineError::Unsupported(
10556                        "scalar subquery: inner statement did not return rows".into(),
10557                    ));
10558                };
10559                let value = match rows.as_slice() {
10560                    [] => Value::Null,
10561                    [row] => row.values.first().cloned().unwrap_or(Value::Null),
10562                    _ => {
10563                        return Err(EngineError::Unsupported(alloc::format!(
10564                            "scalar subquery returned {} rows; expected 0 or 1",
10565                            rows.len()
10566                        )));
10567                    }
10568                };
10569                Ok(Some(value_to_literal_expr(value)?))
10570            }
10571            Expr::Exists { subquery, negated } => {
10572                let mut s = (**subquery).clone();
10573                self.resolve_select_subqueries(&mut s, cancel)?;
10574                let r = match self.exec_bare_select_cancel(&s, cancel) {
10575                    Ok(r) => r,
10576                    Err(e) if is_correlation_error(&e) => return Ok(None),
10577                    Err(e) => return Err(e),
10578                };
10579                let exists = match r {
10580                    QueryResult::Rows { rows, .. } => !rows.is_empty(),
10581                    QueryResult::CommandOk { .. } => false,
10582                };
10583                let bit = if *negated { !exists } else { exists };
10584                Ok(Some(Expr::Literal(Literal::Bool(bit))))
10585            }
10586            Expr::InSubquery {
10587                expr,
10588                subquery,
10589                negated,
10590            } => {
10591                let mut s = (**subquery).clone();
10592                self.resolve_select_subqueries(&mut s, cancel)?;
10593                let r = match self.exec_bare_select_cancel(&s, cancel) {
10594                    Ok(r) => r,
10595                    Err(e) if is_correlation_error(&e) => return Ok(None),
10596                    Err(e) => return Err(e),
10597                };
10598                let QueryResult::Rows { columns, rows, .. } = r else {
10599                    return Err(EngineError::Unsupported(
10600                        "IN-subquery: inner statement did not return rows".into(),
10601                    ));
10602                };
10603                if columns.len() != 1 {
10604                    return Err(EngineError::Unsupported(alloc::format!(
10605                        "IN-subquery must project exactly one column; got {}",
10606                        columns.len()
10607                    )));
10608                }
10609                // Build the same OR-Eq chain the parse-time literal-list
10610                // path constructs, with each value lifted into a Literal.
10611                let mut acc: Option<Expr> = None;
10612                for row in rows {
10613                    let v = row.values.into_iter().next().unwrap_or(Value::Null);
10614                    let lit = value_to_literal_expr(v)?;
10615                    let cmp = Expr::Binary {
10616                        lhs: expr.clone(),
10617                        op: BinOp::Eq,
10618                        rhs: Box::new(lit),
10619                    };
10620                    acc = Some(match acc {
10621                        None => cmp,
10622                        Some(prev) => Expr::Binary {
10623                            lhs: Box::new(prev),
10624                            op: BinOp::Or,
10625                            rhs: Box::new(cmp),
10626                        },
10627                    });
10628                }
10629                let combined = acc.unwrap_or(Expr::Literal(Literal::Bool(false)));
10630                let final_expr = if *negated {
10631                    Expr::Unary {
10632                        op: UnOp::Not,
10633                        expr: Box::new(combined),
10634                    }
10635                } else {
10636                    combined
10637                };
10638                Ok(Some(final_expr))
10639            }
10640            _ => Ok(None),
10641        }
10642    }
10643}
10644
10645// ---- v4.12 window-function helpers ----
10646// The (partition-key, order-key, original-index) tuple shape used
10647// across these helpers is intrinsic to the planner. Factoring it
10648// into a typedef adds indirection without making the code clearer,
10649// so several lints are allowed inline on the affected functions
10650// rather than module-wide.
10651
10652/// v4.22: cheap structural scan for `FROM <name>` (qualified or
10653/// not) inside a SELECT — used to verify the anchor of a WITH
10654/// RECURSIVE CTE doesn't recurse into itself. Conservative: walks
10655/// FROM joins, subqueries, and unions.
10656fn select_refers_to(stmt: &SelectStatement, target: &str) -> bool {
10657    if let Some(from) = &stmt.from
10658        && from_refers_to(from, target)
10659    {
10660        return true;
10661    }
10662    for (_, peer) in &stmt.unions {
10663        if select_refers_to(peer, target) {
10664            return true;
10665        }
10666    }
10667    for item in &stmt.items {
10668        if let SelectItem::Expr { expr, .. } = item
10669            && expr_refers_to(expr, target)
10670        {
10671            return true;
10672        }
10673    }
10674    if let Some(w) = &stmt.where_
10675        && expr_refers_to(w, target)
10676    {
10677        return true;
10678    }
10679    false
10680}
10681
10682fn from_refers_to(from: &FromClause, target: &str) -> bool {
10683    if from.primary.name.eq_ignore_ascii_case(target) {
10684        return true;
10685    }
10686    from.joins
10687        .iter()
10688        .any(|j| j.table.name.eq_ignore_ascii_case(target))
10689}
10690
10691fn expr_refers_to(e: &Expr, target: &str) -> bool {
10692    match e {
10693        Expr::ScalarSubquery(s) => select_refers_to(s, target),
10694        Expr::Exists { subquery, .. } | Expr::InSubquery { subquery, .. } => {
10695            select_refers_to(subquery, target)
10696        }
10697        Expr::Binary { lhs, rhs, .. } => expr_refers_to(lhs, target) || expr_refers_to(rhs, target),
10698        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
10699            expr_refers_to(expr, target)
10700        }
10701        Expr::Like { expr, pattern, .. } => {
10702            expr_refers_to(expr, target) || expr_refers_to(pattern, target)
10703        }
10704        Expr::FunctionCall { args, .. } => args.iter().any(|a| expr_refers_to(a, target)),
10705        Expr::Extract { source, .. } => expr_refers_to(source, target),
10706        Expr::WindowFunction {
10707            args,
10708            partition_by,
10709            order_by,
10710            ..
10711        } => {
10712            args.iter().any(|a| expr_refers_to(a, target))
10713                || partition_by.iter().any(|p| expr_refers_to(p, target))
10714                || order_by.iter().any(|(o, _)| expr_refers_to(o, target))
10715        }
10716        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => false,
10717        Expr::Array(items) => items.iter().any(|e| expr_refers_to(e, target)),
10718        Expr::ArraySubscript { target: t, index } => {
10719            expr_refers_to(t, target) || expr_refers_to(index, target)
10720        }
10721        Expr::AnyAll { expr, array, .. } => {
10722            expr_refers_to(expr, target) || expr_refers_to(array, target)
10723        }
10724        Expr::Case {
10725            operand,
10726            branches,
10727            else_branch,
10728        } => {
10729            operand
10730                .as_deref()
10731                .is_some_and(|o| expr_refers_to(o, target))
10732                || branches
10733                    .iter()
10734                    .any(|(w, t)| expr_refers_to(w, target) || expr_refers_to(t, target))
10735                || else_branch
10736                    .as_deref()
10737                    .is_some_and(|e| expr_refers_to(e, target))
10738        }
10739    }
10740}
10741
10742/// v4.22: pick more specific column types from observed rows when
10743/// the projection builder defaulted to Text (the v1.x behavior for
10744/// non-column expressions). Lets `WITH t(n) AS (SELECT 1 ...)`
10745/// land an Int column in the CTE storage table rather than failing
10746/// the insert with "expected TEXT, got INT".
10747/// v7.16.2 — map an SPG [`DataType`] to the PG-canonical
10748/// `information_schema.columns.data_type` text. Covers the
10749/// values mailrs's migrations probe (`'ARRAY'`, `'integer'`,
10750/// `'text'`, …). Unknown variants fall back to the SPG name
10751/// downcased — better than panicking on a future DataType.
10752fn pg_data_type_text(ty: DataType) -> alloc::string::String {
10753    let s = match ty {
10754        DataType::Int => "integer",
10755        DataType::BigInt => "bigint",
10756        DataType::SmallInt => "smallint",
10757        DataType::Float => "double precision",
10758        DataType::Bool => "boolean",
10759        DataType::Text => "text",
10760        DataType::Varchar(_) => "character varying",
10761        DataType::Date => "date",
10762        DataType::Timestamp => "timestamp without time zone",
10763        DataType::Timestamptz => "timestamp with time zone",
10764        DataType::Json => "jsonb",
10765        DataType::Bytes => "bytea",
10766        DataType::TextArray | DataType::IntArray | DataType::BigIntArray => "ARRAY",
10767        DataType::TsVector => "tsvector",
10768        DataType::TsQuery => "tsquery",
10769        DataType::Vector { .. } => "USER-DEFINED",
10770        // Non-exhaustive — fall back to "USER-DEFINED" the way
10771        // PG labels any pg_type it doesn't recognise.
10772        _ => "USER-DEFINED",
10773    };
10774    alloc::string::String::from(s)
10775}
10776
10777/// v7.16.2 — synthesise `information_schema.columns`. mailrs
10778/// queries are of shape `SELECT 1 FROM information_schema.columns
10779/// WHERE table_name = … AND column_name = … AND data_type = …` —
10780/// the v7.16.2 view returns the columns mailrs probes; broader
10781/// PG-spec parity (ordinal_position, is_nullable, character_
10782/// maximum_length, udt_name, …) lands as needed.
10783fn synth_information_schema_columns(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10784    let schema = alloc::vec![
10785        ColumnSchema::new("table_catalog", DataType::Text, false),
10786        ColumnSchema::new("table_schema", DataType::Text, false),
10787        ColumnSchema::new("table_name", DataType::Text, false),
10788        ColumnSchema::new("column_name", DataType::Text, false),
10789        ColumnSchema::new("ordinal_position", DataType::Int, false),
10790        ColumnSchema::new("is_nullable", DataType::Text, false),
10791        ColumnSchema::new("data_type", DataType::Text, false),
10792    ];
10793    let mut rows: Vec<Row> = Vec::new();
10794    for tname in cat.table_names() {
10795        let Some(t) = cat.get(&tname) else { continue };
10796        for (i, col) in t.schema().columns.iter().enumerate() {
10797            #[allow(clippy::cast_possible_wrap)]
10798            let ordinal = (i + 1) as i32;
10799            rows.push(Row::new(alloc::vec![
10800                Value::Text("spg".into()),
10801                Value::Text("public".into()),
10802                Value::Text(tname.clone()),
10803                Value::Text(col.name.clone()),
10804                Value::Int(ordinal),
10805                Value::Text(if col.nullable {
10806                    "YES".into()
10807                } else {
10808                    "NO".into()
10809                }),
10810                Value::Text(pg_data_type_text(col.ty)),
10811            ]));
10812        }
10813    }
10814    (schema, rows)
10815}
10816
10817/// v7.16.2 — synthesise `information_schema.tables`.
10818fn synth_information_schema_tables(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10819    let schema = alloc::vec![
10820        ColumnSchema::new("table_catalog", DataType::Text, false),
10821        ColumnSchema::new("table_schema", DataType::Text, false),
10822        ColumnSchema::new("table_name", DataType::Text, false),
10823        ColumnSchema::new("table_type", DataType::Text, false),
10824    ];
10825    let mut rows: Vec<Row> = Vec::new();
10826    for tname in cat.table_names() {
10827        rows.push(Row::new(alloc::vec![
10828            Value::Text("spg".into()),
10829            Value::Text("public".into()),
10830            Value::Text(tname.clone()),
10831            Value::Text("BASE TABLE".into()),
10832        ]));
10833    }
10834    (schema, rows)
10835}
10836
10837/// v7.16.2 — synthesise `pg_catalog.pg_class`. Minimum shape
10838/// for psql `\d` / ORM probes: `relname` + `relkind`. Each
10839/// user table emits one row.
10840fn synth_pg_class(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10841    let schema = alloc::vec![
10842        ColumnSchema::new("relname", DataType::Text, false),
10843        ColumnSchema::new("relkind", DataType::Text, false),
10844        ColumnSchema::new("relnamespace", DataType::BigInt, false),
10845    ];
10846    let mut rows: Vec<Row> = Vec::new();
10847    for tname in cat.table_names() {
10848        rows.push(Row::new(alloc::vec![
10849            Value::Text(tname.clone()),
10850            Value::Text("r".into()),
10851            Value::BigInt(2200), // PG's `public` namespace OID
10852        ]));
10853    }
10854    (schema, rows)
10855}
10856
10857/// v7.16.2 — synthesise `pg_catalog.pg_attribute`. Minimum
10858/// shape: `attrelid` (text — SPG has no OID), `attname`,
10859/// `attnum`, `atttypid` (text), `attnotnull`.
10860fn synth_pg_attribute(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10861    let schema = alloc::vec![
10862        ColumnSchema::new("attrelid", DataType::Text, false),
10863        ColumnSchema::new("attname", DataType::Text, false),
10864        ColumnSchema::new("attnum", DataType::Int, false),
10865        ColumnSchema::new("atttypid", DataType::Text, false),
10866        ColumnSchema::new("attnotnull", DataType::Bool, false),
10867    ];
10868    let mut rows: Vec<Row> = Vec::new();
10869    for tname in cat.table_names() {
10870        let Some(t) = cat.get(&tname) else { continue };
10871        for (i, col) in t.schema().columns.iter().enumerate() {
10872            #[allow(clippy::cast_possible_wrap)]
10873            let ordinal = (i + 1) as i32;
10874            rows.push(Row::new(alloc::vec![
10875                Value::Text(tname.clone()),
10876                Value::Text(col.name.clone()),
10877                Value::Int(ordinal),
10878                Value::Text(pg_data_type_text(col.ty)),
10879                Value::Bool(!col.nullable),
10880            ]));
10881        }
10882    }
10883    (schema, rows)
10884}
10885
10886/// v7.17.0 Phase 3.P0-50 — synthesise `pg_catalog.pg_type`. The
10887/// returned rows cover every built-in scalar / array type sqlx,
10888/// SQLAlchemy, Diesel and pgAdmin look up at compile / connect
10889/// time. PG-canonical schema columns we expose:
10890///   * oid           — type OID (the lookup key sqlx uses)
10891///   * typname       — canonical type name (`int4`, `text`, …)
10892///   * typlen        — width in bytes (-1 for var-length)
10893///   * typtype       — `b`ase / `c`omposite / `e`num / etc.
10894///   * typcategory   — PG type category single-char
10895///   * typelem       — element OID for arrays (0 otherwise)
10896///   * typarray      — array-type OID (0 if no array type)
10897///   * typnamespace  — schema OID (always `public` = 2200)
10898///
10899/// Other pg_type columns (typowner, typinput/typoutput, etc.)
10900/// land in follow-up work — sqlx encoders don't query them at
10901/// connect time.
10902fn synth_pg_type(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10903    let schema = alloc::vec![
10904        ColumnSchema::new("oid", DataType::BigInt, false),
10905        ColumnSchema::new("typname", DataType::Text, false),
10906        ColumnSchema::new("typlen", DataType::SmallInt, false),
10907        ColumnSchema::new("typtype", DataType::Text, false),
10908        ColumnSchema::new("typcategory", DataType::Text, false),
10909        ColumnSchema::new("typelem", DataType::BigInt, false),
10910        ColumnSchema::new("typarray", DataType::BigInt, false),
10911        ColumnSchema::new("typnamespace", DataType::BigInt, false),
10912    ];
10913    // (oid, name, len, type, cat, elem, array_oid). PG OID
10914    // numbers come straight from `pg_type.dat`.
10915    let scalars: &[(i64, &str, i16, &str, &str, i64, i64)] = &[
10916        // bool
10917        (16, "bool", 1, "b", "B", 0, 1000),
10918        (17, "bytea", -1, "b", "U", 0, 1001),
10919        (18, "char", 1, "b", "S", 0, 1002),
10920        (19, "name", 64, "b", "S", 0, 1003),
10921        (20, "int8", 8, "b", "N", 0, 1016),
10922        (21, "int2", 2, "b", "N", 0, 1005),
10923        (23, "int4", 4, "b", "N", 0, 1007),
10924        (24, "regproc", 4, "b", "N", 0, 1008),
10925        (25, "text", -1, "b", "S", 0, 1009),
10926        (26, "oid", 4, "b", "N", 0, 1028),
10927        (114, "json", -1, "b", "U", 0, 199),
10928        (142, "xml", -1, "b", "U", 0, 143),
10929        (700, "float4", 4, "b", "N", 0, 1021),
10930        (701, "float8", 8, "b", "N", 0, 1022),
10931        (650, "cidr", -1, "b", "I", 0, 651),
10932        (869, "inet", -1, "b", "I", 0, 1041),
10933        (829, "macaddr", 6, "b", "U", 0, 1040),
10934        (1042, "bpchar", -1, "b", "S", 0, 1014),
10935        (1043, "varchar", -1, "b", "S", 0, 1015),
10936        (1082, "date", 4, "b", "D", 0, 1182),
10937        (1083, "time", 8, "b", "D", 0, 1183),
10938        (1114, "timestamp", 8, "b", "D", 0, 1115),
10939        (1184, "timestamptz", 8, "b", "D", 0, 1185),
10940        (1186, "interval", 16, "b", "T", 0, 1187),
10941        (1266, "timetz", 12, "b", "D", 0, 1270),
10942        (1700, "numeric", -1, "b", "N", 0, 1231),
10943        (790, "money", 8, "b", "N", 0, 791),
10944        (2950, "uuid", 16, "b", "U", 0, 2951),
10945        (3802, "jsonb", -1, "b", "U", 0, 3807),
10946        (3614, "tsvector", -1, "b", "U", 0, 3643),
10947        (3615, "tsquery", -1, "b", "U", 0, 3645),
10948        // hstore + range types — typcategory 'U' (user) / 'R' (range).
10949        (3908, "tstzrange", -1, "r", "R", 0, 3909),
10950        (3910, "tsrange", -1, "r", "R", 0, 3911),
10951        (3904, "int4range", -1, "r", "R", 0, 3905),
10952        (3926, "int8range", -1, "r", "R", 0, 3927),
10953        (3906, "numrange", -1, "r", "R", 0, 3907),
10954        (3912, "daterange", -1, "r", "R", 0, 3913),
10955    ];
10956    // Array companion types share the typelem / typcategory='A'.
10957    // We emit just the array OIDs the scalars reference.
10958    let arrays: &[(i64, &str, i64)] = &[
10959        (1000, "_bool", 16),
10960        (1001, "_bytea", 17),
10961        (1002, "_char", 18),
10962        (1003, "_name", 19),
10963        (1016, "_int8", 20),
10964        (1005, "_int2", 21),
10965        (1007, "_int4", 23),
10966        (1008, "_regproc", 24),
10967        (1009, "_text", 25),
10968        (1028, "_oid", 26),
10969        (199, "_json", 114),
10970        (143, "_xml", 142),
10971        (1021, "_float4", 700),
10972        (1022, "_float8", 701),
10973        (651, "_cidr", 650),
10974        (1041, "_inet", 869),
10975        (1040, "_macaddr", 829),
10976        (1014, "_bpchar", 1042),
10977        (1015, "_varchar", 1043),
10978        (1182, "_date", 1082),
10979        (1183, "_time", 1083),
10980        (1115, "_timestamp", 1114),
10981        (1185, "_timestamptz", 1184),
10982        (1187, "_interval", 1186),
10983        (1270, "_timetz", 1266),
10984        (1231, "_numeric", 1700),
10985        (791, "_money", 790),
10986        (2951, "_uuid", 2950),
10987        (3807, "_jsonb", 3802),
10988        (3643, "_tsvector", 3614),
10989        (3645, "_tsquery", 3615),
10990    ];
10991    let mut rows: Vec<Row> = Vec::with_capacity(scalars.len() + arrays.len());
10992    for &(oid, name, len, ty, cat, elem, arr) in scalars {
10993        rows.push(Row::new(alloc::vec![
10994            Value::BigInt(oid),
10995            Value::Text(name.into()),
10996            Value::SmallInt(len),
10997            Value::Text(ty.into()),
10998            Value::Text(cat.into()),
10999            Value::BigInt(elem),
11000            Value::BigInt(arr),
11001            Value::BigInt(2200),
11002        ]));
11003    }
11004    for &(oid, name, elem) in arrays {
11005        rows.push(Row::new(alloc::vec![
11006            Value::BigInt(oid),
11007            Value::Text(name.into()),
11008            Value::SmallInt(-1),
11009            Value::Text("b".into()),
11010            Value::Text("A".into()),
11011            Value::BigInt(elem),
11012            Value::BigInt(0),
11013            Value::BigInt(2200),
11014        ]));
11015    }
11016    (schema, rows)
11017}
11018
11019/// v7.17.0 Phase 3.P0-51 — synthesise `pg_catalog.pg_proc`. ORM /
11020/// pgAdmin probes look up functions by name; SPG synthesises rows
11021/// for the built-in scalar functions / aggregates / window funcs
11022/// the engine actually dispatches. SPG has no user-defined
11023/// functions yet so the table is a stable static list.
11024///
11025/// Schema columns exposed:
11026///   * oid (BigInt) — function OID from PG's pg_proc.dat
11027///   * proname (Text) — function name (lowercase)
11028///   * pronamespace (BigInt) — 11 (`pg_catalog`)
11029///   * prokind (Text) — 'f' function, 'a' aggregate, 'w' window
11030///   * pronargs (SmallInt) — declared arg count (-1 for variadic)
11031///   * prorettype (BigInt) — return type OID (matches synth_pg_type)
11032fn synth_pg_proc(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11033    let schema = alloc::vec![
11034        ColumnSchema::new("oid", DataType::BigInt, false),
11035        ColumnSchema::new("proname", DataType::Text, false),
11036        ColumnSchema::new("pronamespace", DataType::BigInt, false),
11037        ColumnSchema::new("prokind", DataType::Text, false),
11038        ColumnSchema::new("pronargs", DataType::Int, false),
11039        ColumnSchema::new("prorettype", DataType::BigInt, false),
11040    ];
11041    // (oid, name, kind, nargs, rettype). OIDs taken from PG's
11042    // pg_proc.dat for the common subset.
11043    let funcs: &[(i64, &str, &str, i32, i64)] = &[
11044        // Scalar functions.
11045        (1318, "length", "f", 1, 23),
11046        (871, "upper", "f", 1, 25),
11047        (870, "lower", "f", 1, 25),
11048        (936, "substring", "f", 3, 25),
11049        (937, "substring", "f", 2, 25),
11050        (3055, "btrim", "f", 1, 25),
11051        (885, "btrim", "f", 2, 25),
11052        (3056, "ltrim", "f", 1, 25),
11053        (875, "ltrim", "f", 2, 25),
11054        (3057, "rtrim", "f", 1, 25),
11055        (876, "rtrim", "f", 2, 25),
11056        (1397, "abs", "f", 1, 23),
11057        (1396, "abs", "f", 1, 20),
11058        (1606, "round", "f", 1, 1700),
11059        (1707, "round", "f", 2, 1700),
11060        (2308, "ceil", "f", 1, 701),
11061        (2309, "ceiling", "f", 1, 701),
11062        (2310, "floor", "f", 1, 701),
11063        (1376, "sqrt", "f", 1, 701),
11064        (1369, "ln", "f", 1, 701),
11065        (1373, "exp", "f", 1, 701),
11066        (1368, "power", "f", 2, 701),
11067        (2228, "random", "f", 0, 701),
11068        // Date / time.
11069        (1299, "now", "f", 0, 1184),
11070        (1274, "current_timestamp", "f", 0, 1184),
11071        (1140, "current_date", "f", 0, 1082),
11072        (2050, "current_time", "f", 0, 1083),
11073        (1158, "date_trunc", "f", 2, 1184),
11074        (1171, "date_part", "f", 2, 701),
11075        (1172, "age", "f", 1, 1186),
11076        (936, "to_char", "f", 2, 25),
11077        // Session / introspection.
11078        (861, "current_database", "f", 0, 19),
11079        (745, "current_user", "f", 0, 19),
11080        (745, "session_user", "f", 0, 19),
11081        (1402, "current_schema", "f", 0, 19),
11082        // String concat / format.
11083        (3058, "concat", "f", -1, 25),
11084        (3059, "concat_ws", "f", -1, 25),
11085        (3539, "format", "f", -1, 25),
11086        // Type introspection.
11087        (2877, "pg_typeof", "f", 1, 2206),
11088        // JSON.
11089        (3198, "json_build_object", "f", -1, 114),
11090        (3199, "jsonb_build_object", "f", -1, 3802),
11091        (3271, "json_build_array", "f", -1, 114),
11092        (3272, "jsonb_build_array", "f", -1, 3802),
11093        // UUID.
11094        (3253, "gen_random_uuid", "f", 0, 2950),
11095        (3252, "uuid_generate_v4", "f", 0, 2950),
11096        // Aggregates.
11097        (2147, "count", "a", 0, 20),
11098        (2803, "count", "a", -1, 20),
11099        (2116, "max", "a", 1, 23),
11100        (2132, "min", "a", 1, 23),
11101        (2108, "sum", "a", 1, 20),
11102        (2100, "avg", "a", 1, 1700),
11103        (2517, "string_agg", "a", 2, 25),
11104        (2747, "array_agg", "a", 1, 1009),
11105        (2517, "bool_and", "a", 1, 16),
11106        (2518, "bool_or", "a", 1, 16),
11107        (2519, "every", "a", 1, 16),
11108        // Window functions.
11109        (3100, "row_number", "w", 0, 20),
11110        (3101, "rank", "w", 0, 20),
11111        (3102, "dense_rank", "w", 0, 20),
11112        (3103, "percent_rank", "w", 0, 701),
11113        (3104, "cume_dist", "w", 0, 701),
11114        (3105, "lag", "w", -1, 2283),
11115        (3106, "lead", "w", -1, 2283),
11116        (3107, "first_value", "w", 1, 2283),
11117        (3108, "last_value", "w", 1, 2283),
11118        (3109, "nth_value", "w", 2, 2283),
11119    ];
11120    let mut rows: Vec<Row> = Vec::with_capacity(funcs.len());
11121    for &(oid, name, kind, nargs, rettype) in funcs {
11122        rows.push(Row::new(alloc::vec![
11123            Value::BigInt(oid),
11124            Value::Text(name.into()),
11125            Value::BigInt(11),
11126            Value::Text(kind.into()),
11127            Value::Int(nargs),
11128            Value::BigInt(rettype),
11129        ]));
11130    }
11131    (schema, rows)
11132}
11133
11134/// v7.17.0 Phase 3.P0-65 — synthesise `mysql.user`. MySQL admin
11135/// queries (`SELECT user, host FROM mysql.user`) probe this at
11136/// connect time to list accounts. SPG ships one row per
11137/// UserStore entry plus a synthetic `root` superuser row for
11138/// MySQL bootstrap compat.
11139fn synth_mysql_user(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
11140    let schema = alloc::vec![
11141        ColumnSchema::new("user", DataType::Text, false),
11142        ColumnSchema::new("host", DataType::Text, false),
11143        ColumnSchema::new("select_priv", DataType::Text, false),
11144    ];
11145    let mut rows: Vec<Row> = Vec::new();
11146    rows.push(Row::new(alloc::vec![
11147        Value::Text("root".into()),
11148        Value::Text("localhost".into()),
11149        Value::Text("Y".into()),
11150    ]));
11151    for (name, _) in engine.users.iter() {
11152        if name != "root" {
11153            rows.push(Row::new(alloc::vec![
11154                Value::Text(name.to_string()),
11155                Value::Text("%".into()),
11156                Value::Text("Y".into()),
11157            ]));
11158        }
11159    }
11160    (schema, rows)
11161}
11162
11163/// v7.17.0 Phase 3.P0-65 — synthesise `mysql.db`. The
11164/// per-database privileges table. SPG is single-database so the
11165/// table surfaces one row per declared user with full privileges
11166/// on the canonical `postgres` database.
11167fn synth_mysql_db() -> (Vec<ColumnSchema>, Vec<Row>) {
11168    let schema = alloc::vec![
11169        ColumnSchema::new("host", DataType::Text, false),
11170        ColumnSchema::new("db", DataType::Text, false),
11171        ColumnSchema::new("user", DataType::Text, false),
11172        ColumnSchema::new("select_priv", DataType::Text, false),
11173    ];
11174    let rows = alloc::vec![Row::new(alloc::vec![
11175        Value::Text("localhost".into()),
11176        Value::Text("postgres".into()),
11177        Value::Text("root".into()),
11178        Value::Text("Y".into()),
11179    ])];
11180    (schema, rows)
11181}
11182
11183/// v7.17.0 Phase 3.P0-63 — synthesise
11184/// `information_schema.KEY_COLUMN_USAGE`. ORM migration tools
11185/// (Alembic, Sequelize, TypeORM) walk this view to discover FK
11186/// relationships in MySQL-flavoured introspection queries.
11187///
11188/// Schema columns exposed:
11189///   * CONSTRAINT_NAME (Text)
11190///   * TABLE_NAME (Text)
11191///   * COLUMN_NAME (Text)
11192///   * ORDINAL_POSITION (Int)
11193///   * REFERENCED_TABLE_NAME (Text) — empty for non-FK rows
11194///   * REFERENCED_COLUMN_NAME (Text) — empty for non-FK rows
11195fn synth_info_key_column_usage(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11196    let schema = alloc::vec![
11197        ColumnSchema::new("constraint_name", DataType::Text, false),
11198        ColumnSchema::new("table_name", DataType::Text, false),
11199        ColumnSchema::new("column_name", DataType::Text, false),
11200        ColumnSchema::new("ordinal_position", DataType::Int, false),
11201        ColumnSchema::new("referenced_table_name", DataType::Text, false),
11202        ColumnSchema::new("referenced_column_name", DataType::Text, false),
11203    ];
11204    let mut rows: Vec<Row> = Vec::new();
11205    for tname in cat.table_names() {
11206        let Some(t) = cat.get(&tname) else { continue };
11207        let cols = &t.schema().columns;
11208        let col_name_at = |pos: usize| -> String {
11209            cols.get(pos)
11210                .map_or_else(|| alloc::format!("col{pos}"), |c| c.name.clone())
11211        };
11212        // FKs.
11213        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
11214            let conname = fk
11215                .name
11216                .clone()
11217                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
11218            for (i, (&local, &parent)) in fk
11219                .local_columns
11220                .iter()
11221                .zip(fk.parent_columns.iter())
11222                .enumerate()
11223            {
11224                let parent_name = cat
11225                    .get(&fk.parent_table)
11226                    .and_then(|pt| pt.schema().columns.get(parent).map(|c| c.name.clone()))
11227                    .unwrap_or_else(|| alloc::format!("col{parent}"));
11228                #[allow(clippy::cast_possible_wrap)]
11229                let ordinal = (i + 1) as i32;
11230                rows.push(Row::new(alloc::vec![
11231                    Value::Text(conname.clone()),
11232                    Value::Text(tname.clone()),
11233                    Value::Text(col_name_at(local)),
11234                    Value::Int(ordinal),
11235                    Value::Text(fk.parent_table.clone()),
11236                    Value::Text(parent_name),
11237                ]));
11238            }
11239        }
11240        // PK / composite UC entries.
11241        for (ci, uc) in t.schema().uniqueness_constraints.iter().enumerate() {
11242            let conname = if uc.is_primary_key {
11243                alloc::format!("{}_pkey", tname)
11244            } else {
11245                alloc::format!("{}_uniq{ci}", tname)
11246            };
11247            for (i, &local) in uc.columns.iter().enumerate() {
11248                #[allow(clippy::cast_possible_wrap)]
11249                let ordinal = (i + 1) as i32;
11250                rows.push(Row::new(alloc::vec![
11251                    Value::Text(conname.clone()),
11252                    Value::Text(tname.clone()),
11253                    Value::Text(col_name_at(local)),
11254                    Value::Int(ordinal),
11255                    Value::Text(String::new()),
11256                    Value::Text(String::new()),
11257                ]));
11258            }
11259        }
11260    }
11261    (schema, rows)
11262}
11263
11264/// v7.17.0 Phase 3.P0-64 — synthesise
11265/// `information_schema.REFERENTIAL_CONSTRAINTS`. One row per FK.
11266fn synth_info_referential_constraints(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11267    let schema = alloc::vec![
11268        ColumnSchema::new("constraint_name", DataType::Text, false),
11269        ColumnSchema::new("table_name", DataType::Text, false),
11270        ColumnSchema::new("referenced_table_name", DataType::Text, false),
11271        ColumnSchema::new("update_rule", DataType::Text, false),
11272        ColumnSchema::new("delete_rule", DataType::Text, false),
11273    ];
11274    fn rule_name(a: spg_storage::FkAction) -> &'static str {
11275        match a {
11276            spg_storage::FkAction::Cascade => "CASCADE",
11277            spg_storage::FkAction::SetNull => "SET NULL",
11278            spg_storage::FkAction::SetDefault => "SET DEFAULT",
11279            spg_storage::FkAction::Restrict => "RESTRICT",
11280            spg_storage::FkAction::NoAction => "NO ACTION",
11281        }
11282    }
11283    let mut rows: Vec<Row> = Vec::new();
11284    for tname in cat.table_names() {
11285        let Some(t) = cat.get(&tname) else { continue };
11286        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
11287            let conname = fk
11288                .name
11289                .clone()
11290                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
11291            rows.push(Row::new(alloc::vec![
11292                Value::Text(conname),
11293                Value::Text(tname.clone()),
11294                Value::Text(fk.parent_table.clone()),
11295                Value::Text(rule_name(fk.on_update).into()),
11296                Value::Text(rule_name(fk.on_delete).into()),
11297            ]));
11298        }
11299    }
11300    (schema, rows)
11301}
11302
11303/// v7.17.0 Phase 3.P0-64 — synthesise `information_schema.STATISTICS`.
11304/// One row per (index × column) — admin tools walk this to
11305/// surface index-cardinality estimates.
11306fn synth_info_statistics(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11307    let schema = alloc::vec![
11308        ColumnSchema::new("table_name", DataType::Text, false),
11309        ColumnSchema::new("index_name", DataType::Text, false),
11310        ColumnSchema::new("column_name", DataType::Text, false),
11311        ColumnSchema::new("seq_in_index", DataType::Int, false),
11312        ColumnSchema::new("non_unique", DataType::Int, false),
11313        ColumnSchema::new("index_type", DataType::Text, false),
11314    ];
11315    let mut rows: Vec<Row> = Vec::new();
11316    for tname in cat.table_names() {
11317        let Some(t) = cat.get(&tname) else { continue };
11318        for idx in t.indices() {
11319            let col = t
11320                .schema()
11321                .columns
11322                .get(idx.column_position)
11323                .map_or("?".into(), |c| c.name.clone());
11324            rows.push(Row::new(alloc::vec![
11325                Value::Text(tname.clone()),
11326                Value::Text(idx.name.clone()),
11327                Value::Text(col),
11328                Value::Int(1),
11329                Value::Int(i32::from(!idx.is_unique)),
11330                Value::Text("BTREE".into()),
11331            ]));
11332        }
11333    }
11334    (schema, rows)
11335}
11336
11337/// v7.17.0 Phase 3.P0-64 — synthesise `information_schema.ROUTINES`.
11338/// SPG has no user-defined functions in v7.17 so the surface is
11339/// always empty; admin tools just need the table to exist.
11340fn synth_info_routines() -> (Vec<ColumnSchema>, Vec<Row>) {
11341    let schema = alloc::vec![
11342        ColumnSchema::new("routine_name", DataType::Text, false),
11343        ColumnSchema::new("routine_type", DataType::Text, false),
11344        ColumnSchema::new("data_type", DataType::Text, false),
11345    ];
11346    (schema, Vec::new())
11347}
11348
11349/// v7.17.0 Phase 3.P0-54 — synthesise `pg_catalog.pg_constraint`.
11350/// ORM compilers (Diesel, sea-orm) and admin tools probe this for
11351/// FK / UNIQUE / PK / CHECK definitions to surface relationship
11352/// graphs and validation rules. SPG ships one row per
11353/// uniqueness constraint + foreign key declared in the catalog.
11354///
11355/// Schema columns exposed:
11356///   * conname (Text) — constraint name (synthetic when anonymous)
11357///   * contype (Text) — `p` PK, `u` UNIQUE, `f` FK, `c` CHECK
11358///   * conrelid (Text) — owner table name
11359///   * confrelid (Text) — referenced parent table (FK only;
11360///     empty string otherwise)
11361///   * conkey (Text) — comma-separated column names
11362///   * confkey (Text) — comma-separated parent column names (FK only)
11363fn synth_pg_constraint(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11364    let schema = alloc::vec![
11365        ColumnSchema::new("conname", DataType::Text, false),
11366        ColumnSchema::new("contype", DataType::Text, false),
11367        ColumnSchema::new("conrelid", DataType::Text, false),
11368        ColumnSchema::new("confrelid", DataType::Text, false),
11369        ColumnSchema::new("conkey", DataType::Text, false),
11370        ColumnSchema::new("confkey", DataType::Text, false),
11371    ];
11372    let mut rows: Vec<Row> = Vec::new();
11373    for tname in cat.table_names() {
11374        let Some(t) = cat.get(&tname) else { continue };
11375        let cols = &t.schema().columns;
11376        let col_name_at = |pos: usize| -> String {
11377            cols.get(pos)
11378                .map_or_else(|| alloc::format!("col{pos}"), |c| c.name.clone())
11379        };
11380        // Uniqueness constraints (composite UNIQUE / PRIMARY KEY).
11381        for (ci, uc) in t.schema().uniqueness_constraints.iter().enumerate() {
11382            let kind = if uc.is_primary_key { "p" } else { "u" };
11383            let conname = if uc.is_primary_key {
11384                alloc::format!("{}_pkey", tname)
11385            } else {
11386                alloc::format!("{}_uniq{ci}", tname)
11387            };
11388            let conkey: Vec<String> = uc.columns.iter().map(|&p| col_name_at(p)).collect();
11389            rows.push(Row::new(alloc::vec![
11390                Value::Text(conname),
11391                Value::Text(kind.into()),
11392                Value::Text(tname.clone()),
11393                Value::Text(String::new()),
11394                Value::Text(conkey.join(",")),
11395                Value::Text(String::new()),
11396            ]));
11397        }
11398        // Single-column PK / UNIQUE indexes that have no
11399        // matching entry in `uniqueness_constraints` (the engine
11400        // creates only the BTree index for the bare-column case;
11401        // composite forms ride the UC path above).
11402        for idx in t.indices() {
11403            if !idx.is_unique {
11404                continue;
11405            }
11406            let is_primary = idx.name.ends_with("_pkey");
11407            let conname = idx.name.clone();
11408            let kind = if is_primary { "p" } else { "u" };
11409            let col_name = col_name_at(idx.column_position);
11410            // Skip if already emitted via the UC loop above (same
11411            // tuple shape — single-column).
11412            let already = t
11413                .schema()
11414                .uniqueness_constraints
11415                .iter()
11416                .any(|uc| uc.columns.len() == 1 && uc.columns[0] == idx.column_position);
11417            if already {
11418                continue;
11419            }
11420            rows.push(Row::new(alloc::vec![
11421                Value::Text(conname),
11422                Value::Text(kind.into()),
11423                Value::Text(tname.clone()),
11424                Value::Text(String::new()),
11425                Value::Text(col_name),
11426                Value::Text(String::new()),
11427            ]));
11428        }
11429        // Foreign keys.
11430        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
11431            let conname = fk
11432                .name
11433                .clone()
11434                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
11435            let conkey: Vec<String> = fk.local_columns.iter().map(|&p| col_name_at(p)).collect();
11436            // Parent column names: look up the parent table's
11437            // schema if it exists; otherwise emit positions.
11438            let confkey: Vec<String> = if let Some(parent) = cat.get(&fk.parent_table) {
11439                fk.parent_columns
11440                    .iter()
11441                    .map(|&p| {
11442                        parent
11443                            .schema()
11444                            .columns
11445                            .get(p)
11446                            .map_or_else(|| alloc::format!("col{p}"), |c| c.name.clone())
11447                    })
11448                    .collect()
11449            } else {
11450                fk.parent_columns
11451                    .iter()
11452                    .map(|p| alloc::format!("col{p}"))
11453                    .collect()
11454            };
11455            rows.push(Row::new(alloc::vec![
11456                Value::Text(conname),
11457                Value::Text("f".into()),
11458                Value::Text(tname.clone()),
11459                Value::Text(fk.parent_table.clone()),
11460                Value::Text(conkey.join(",")),
11461                Value::Text(confkey.join(",")),
11462            ]));
11463        }
11464    }
11465    (schema, rows)
11466}
11467
11468/// v7.17.0 Phase 3.P0-55 — synthesise `pg_catalog.pg_database`.
11469/// SPG is single-database so we surface a single row keyed on the
11470/// canonical `postgres` database name (matching what every PG
11471/// admin tool's startup screen expects to find).
11472fn synth_pg_database(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11473    let schema = alloc::vec![
11474        ColumnSchema::new("oid", DataType::BigInt, false),
11475        ColumnSchema::new("datname", DataType::Text, false),
11476        ColumnSchema::new("datdba", DataType::BigInt, false),
11477        ColumnSchema::new("encoding", DataType::Int, false),
11478        ColumnSchema::new("datcollate", DataType::Text, false),
11479    ];
11480    let rows = alloc::vec![Row::new(alloc::vec![
11481        Value::BigInt(16384),
11482        Value::Text("postgres".into()),
11483        Value::BigInt(10),
11484        Value::Int(6), // UTF8
11485        Value::Text("en_US.UTF-8".into()),
11486    ])];
11487    (schema, rows)
11488}
11489
11490/// v7.17.0 Phase 3.P0-55 — synthesise `pg_catalog.pg_roles`. PG's
11491/// pg_roles is a view over pg_authid showing all roles. SPG ships
11492/// one row per declared user from the engine's UserStore so admin
11493/// tool startup screens can populate.
11494fn synth_pg_roles(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
11495    let schema = alloc::vec![
11496        ColumnSchema::new("oid", DataType::BigInt, false),
11497        ColumnSchema::new("rolname", DataType::Text, false),
11498        ColumnSchema::new("rolsuper", DataType::Bool, false),
11499        ColumnSchema::new("rolinherit", DataType::Bool, false),
11500        ColumnSchema::new("rolcanlogin", DataType::Bool, false),
11501    ];
11502    let mut rows: Vec<Row> = Vec::new();
11503    let oid: i64 = 10;
11504    for (i, (name, _)) in engine.users.iter().enumerate() {
11505        rows.push(Row::new(alloc::vec![
11506            Value::BigInt(oid + (i as i64) + 1),
11507            Value::Text(name.to_string()),
11508            Value::Bool(false),
11509            Value::Bool(true),
11510            Value::Bool(true),
11511        ]));
11512    }
11513    // Always include `postgres` as the bootstrap superuser if not
11514    // already present — admin tools probe for it.
11515    if !rows
11516        .iter()
11517        .any(|r| matches!(&r.values[1], Value::Text(s) if s == "postgres"))
11518    {
11519        rows.insert(
11520            0,
11521            Row::new(alloc::vec![
11522                Value::BigInt(10),
11523                Value::Text("postgres".into()),
11524                Value::Bool(true),
11525                Value::Bool(true),
11526                Value::Bool(true),
11527            ]),
11528        );
11529    }
11530    (schema, rows)
11531}
11532
11533/// v7.17.0 Phase 3.P0-56 — synthesise `pg_catalog.pg_views`. PG's
11534/// pg_views is a view listing every catalog view; SPG ships one
11535/// row per declared view + its definition text.
11536/// Synthesise `pg_catalog.pg_extension`. SPG ships its "extension"
11537/// surfaces natively (vector, pg_trgm, plpgsql-shaped DO blocks), so
11538/// the table lists those as installed — `SELECT … FROM pg_extension
11539/// WHERE extname = 'vector'` probes from PG clients (mailrs embed
11540/// round-12) answer truthfully about capability presence.
11541fn synth_pg_extension() -> (Vec<ColumnSchema>, Vec<Row>) {
11542    let schema = alloc::vec![
11543        ColumnSchema::new("oid", DataType::BigInt, false),
11544        ColumnSchema::new("extname", DataType::Text, false),
11545        ColumnSchema::new("extversion", DataType::Text, false),
11546        ColumnSchema::new("extnamespace", DataType::Text, false),
11547    ];
11548    let exts: &[(&str, &str)] = &[("plpgsql", "1.0"), ("vector", "0.8.0"), ("pg_trgm", "1.6")];
11549    let rows = exts
11550        .iter()
11551        .enumerate()
11552        .map(|(i, (name, ver))| {
11553            Row::new(alloc::vec![
11554                Value::BigInt(16384 + i as i64),
11555                Value::Text((*name).into()),
11556                Value::Text((*ver).into()),
11557                Value::Text("pg_catalog".into()),
11558            ])
11559        })
11560        .collect();
11561    (schema, rows)
11562}
11563
11564fn synth_pg_views(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11565    let schema = alloc::vec![
11566        ColumnSchema::new("schemaname", DataType::Text, false),
11567        ColumnSchema::new("viewname", DataType::Text, false),
11568        ColumnSchema::new("definition", DataType::Text, false),
11569    ];
11570    let mut rows: Vec<Row> = Vec::new();
11571    for (name, def) in cat.views() {
11572        rows.push(Row::new(alloc::vec![
11573            Value::Text("public".into()),
11574            Value::Text(name.clone()),
11575            Value::Text(def.body.clone()),
11576        ]));
11577    }
11578    (schema, rows)
11579}
11580
11581/// v7.17.0 Phase 3.P0-57 — synthesise `pg_catalog.pg_settings`. ORM
11582/// connection-checkers (sqlx pre-flight, Diesel migrator) and admin
11583/// tools read `pg_settings` to discover server-side configuration.
11584/// SPG surfaces every session_param + a small set of canonical PG
11585/// defaults so the pre-flight queries match.
11586fn synth_pg_settings(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
11587    let schema = alloc::vec![
11588        ColumnSchema::new("name", DataType::Text, false),
11589        ColumnSchema::new("setting", DataType::Text, false),
11590        ColumnSchema::new("category", DataType::Text, false),
11591    ];
11592    let mut rows: Vec<Row> = Vec::new();
11593    // Canonical defaults every admin tool expects to find.
11594    let defaults: &[(&str, &str, &str)] = &[
11595        ("server_version", "16.0 (spg)", "Preset Options"),
11596        ("server_encoding", "UTF8", "Client Connection Defaults"),
11597        ("client_encoding", "UTF8", "Client Connection Defaults"),
11598        ("DateStyle", "ISO, MDY", "Client Connection Defaults"),
11599        ("TimeZone", "UTC", "Client Connection Defaults"),
11600        ("standard_conforming_strings", "on", "Compatibility"),
11601        ("integer_datetimes", "on", "Compatibility"),
11602        ("max_connections", "100", "Connections and Authentication"),
11603    ];
11604    for &(name, val, cat) in defaults {
11605        rows.push(Row::new(alloc::vec![
11606            Value::Text(name.into()),
11607            Value::Text(val.into()),
11608            Value::Text(cat.into()),
11609        ]));
11610    }
11611    // Session-set params override the static defaults.
11612    for (k, v) in &engine.session_params {
11613        if !defaults
11614            .iter()
11615            .any(|(n, _, _)| (*n).eq_ignore_ascii_case(k))
11616        {
11617            rows.push(Row::new(alloc::vec![
11618                Value::Text(k.clone()),
11619                Value::Text(v.clone()),
11620                Value::Text("Session".into()),
11621            ]));
11622        }
11623    }
11624    (schema, rows)
11625}
11626
11627/// v7.17.0 Phase 3.P0-53 — synthesise `pg_catalog.pg_indexes`.
11628/// PG's pg_indexes is a real view on pg_index + pg_class + pg_attribute.
11629/// SPG ships it as a synthesised flat table so admin tools (pgAdmin,
11630/// DataGrip) can list indexes by tablename without joining four catalogs.
11631///
11632/// Schema columns exposed:
11633///   * schemaname (Text) — always `public`
11634///   * tablename (Text)
11635///   * indexname (Text)
11636///   * indexdef (Text) — best-effort CREATE INDEX DDL
11637fn synth_pg_indexes(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11638    let schema = alloc::vec![
11639        ColumnSchema::new("schemaname", DataType::Text, false),
11640        ColumnSchema::new("tablename", DataType::Text, false),
11641        ColumnSchema::new("indexname", DataType::Text, false),
11642        ColumnSchema::new("indexdef", DataType::Text, false),
11643    ];
11644    let mut rows: Vec<Row> = Vec::new();
11645    for tname in cat.table_names() {
11646        let Some(t) = cat.get(&tname) else { continue };
11647        for idx in t.indices() {
11648            let col_name = t
11649                .schema()
11650                .columns
11651                .get(idx.column_position)
11652                .map_or("?".into(), |c| c.name.clone());
11653            let unique_kw = if idx.is_unique { "UNIQUE " } else { "" };
11654            let indexdef = alloc::format!(
11655                "CREATE {unique_kw}INDEX {} ON public.{} ({})",
11656                idx.name,
11657                tname,
11658                col_name
11659            );
11660            rows.push(Row::new(alloc::vec![
11661                Value::Text("public".into()),
11662                Value::Text(tname.clone()),
11663                Value::Text(idx.name.clone()),
11664                Value::Text(indexdef),
11665            ]));
11666        }
11667    }
11668    (schema, rows)
11669}
11670
11671/// v7.17.0 Phase 3.P0-53 — synthesise `pg_catalog.pg_index`. The
11672/// "raw" pg_index catalog used by PG-internal tooling for index
11673/// flags and ordinal information. SPG ships the columns ORM probes
11674/// actually filter on.
11675///
11676/// Schema columns exposed:
11677///   * indexrelid (BigInt) — index OID (synthetic = position+1)
11678///   * indrelid (BigInt) — table OID (synthetic = position+1)
11679///   * indnatts (Int) — number of indexed columns
11680///   * indisunique (Bool)
11681///   * indisprimary (Bool)
11682fn synth_pg_index_raw(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11683    let schema = alloc::vec![
11684        ColumnSchema::new("indexrelid", DataType::BigInt, false),
11685        ColumnSchema::new("indrelid", DataType::BigInt, false),
11686        ColumnSchema::new("indnatts", DataType::Int, false),
11687        ColumnSchema::new("indisunique", DataType::Bool, false),
11688        ColumnSchema::new("indisprimary", DataType::Bool, false),
11689    ];
11690    let mut rows: Vec<Row> = Vec::new();
11691    let mut idx_oid: i64 = 100_000;
11692    for (table_idx, tname) in cat.table_names().iter().enumerate() {
11693        let Some(t) = cat.get(tname) else { continue };
11694        for idx in t.indices() {
11695            idx_oid += 1;
11696            #[allow(clippy::cast_possible_wrap)]
11697            let nattrs = (1 + idx.extra_column_positions.len()) as i32;
11698            // is_primary: SPG / PG flag the primary via the
11699            // index name convention `<table>_pkey`.
11700            let is_primary = idx.name.ends_with("_pkey");
11701            rows.push(Row::new(alloc::vec![
11702                Value::BigInt(idx_oid),
11703                Value::BigInt((table_idx + 1) as i64),
11704                Value::Int(nattrs),
11705                Value::Bool(idx.is_unique),
11706                Value::Bool(is_primary),
11707            ]));
11708        }
11709    }
11710    (schema, rows)
11711}
11712
11713/// v7.17.0 Phase 3.P0-52 — synthesise `pg_catalog.pg_namespace`.
11714/// SPG is single-schema so we expose the canonical PG schemas:
11715/// `public` (user-facing), `pg_catalog` (built-in), and
11716/// `information_schema` (PG meta).
11717fn synth_pg_namespace(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11718    let schema = alloc::vec![
11719        ColumnSchema::new("oid", DataType::BigInt, false),
11720        ColumnSchema::new("nspname", DataType::Text, false),
11721        ColumnSchema::new("nspowner", DataType::BigInt, false),
11722    ];
11723    let rows = alloc::vec![
11724        Row::new(alloc::vec![
11725            Value::BigInt(11),
11726            Value::Text("pg_catalog".into()),
11727            Value::BigInt(10),
11728        ]),
11729        Row::new(alloc::vec![
11730            Value::BigInt(2200),
11731            Value::Text("public".into()),
11732            Value::BigInt(10),
11733        ]),
11734        Row::new(alloc::vec![
11735            Value::BigInt(13000),
11736            Value::Text("information_schema".into()),
11737            Value::BigInt(10),
11738        ]),
11739    ];
11740    (schema, rows)
11741}
11742
11743/// v7.16.2 — drop the synthesised meta view into the enriched
11744/// catalog so the regular FROM-resolution path can see it.
11745fn materialise_meta_view(
11746    catalog: &mut Catalog,
11747    name: &str,
11748    columns: Vec<ColumnSchema>,
11749    rows: Vec<Row>,
11750) -> Result<(), EngineError> {
11751    let schema = TableSchema::new(name.to_string(), columns);
11752    catalog.create_table(schema).map_err(EngineError::Storage)?;
11753    let table = catalog
11754        .get_mut(name)
11755        .expect("just-created meta view must exist");
11756    for row in rows {
11757        table.insert(row).map_err(EngineError::Storage)?;
11758    }
11759    Ok(())
11760}
11761
11762/// v7.16.2 — true when the SELECT statement references any
11763/// `__spg_info_*` or `__spg_pg_*` synthetic table name (the
11764/// parser produces these for `information_schema.X` /
11765/// `pg_catalog.X`). Used by `exec_select_cancel` to short-
11766/// circuit into the meta-view materialisation path.
11767/// v7.17.0 Phase 1.2 — append the names of any catalog-known
11768/// views referenced by `tref` to `into`. Helper for
11769/// `Engine::expand_views_in_select`. A view that's been already
11770/// materialised as a table (e.g. via the synthetic CTE pass for
11771/// SELECT FROM v) is skipped — the table form wins so the
11772/// recursive exec_select_cancel call inside exec_with_ctes
11773/// doesn't re-expand and trigger the CTE-shadow guard.
11774fn collect_view_refs(
11775    tref: &spg_sql::ast::TableRef,
11776    cat: &spg_storage::Catalog,
11777    into: &mut Vec<String>,
11778) {
11779    if cat.views().contains_key(&tref.name)
11780        && cat.get(&tref.name).is_none()
11781        && !into.iter().any(|n| n == &tref.name)
11782    {
11783        into.push(tref.name.clone());
11784    }
11785}
11786
11787fn select_references_meta_view(stmt: &SelectStatement) -> bool {
11788    fn is_meta(name: &str) -> bool {
11789        name.starts_with("__spg_info_")
11790            || name.starts_with("__spg_pg_")
11791            || name.starts_with("__spg_mysql_")
11792    }
11793    if let Some(from) = &stmt.from {
11794        if is_meta(&from.primary.name) {
11795            return true;
11796        }
11797        for j in &from.joins {
11798            if is_meta(&j.table.name) {
11799                return true;
11800            }
11801        }
11802    }
11803    for cte in &stmt.ctes {
11804        if select_references_meta_view(&cte.body) {
11805            return true;
11806        }
11807    }
11808    false
11809}
11810
11811/// v7.16.2 — collect every meta-view name a SELECT touches.
11812/// Returns a deduplicated, sorted list. Caller materialises
11813/// each one into the enriched catalog before re-running the
11814/// SELECT. Walks JOINs, CTEs, and the primary FROM.
11815fn collect_meta_view_names(
11816    stmt: &SelectStatement,
11817    into: &mut alloc::collections::BTreeSet<String>,
11818) {
11819    fn is_meta(name: &str) -> bool {
11820        name.starts_with("__spg_info_")
11821            || name.starts_with("__spg_pg_")
11822            || name.starts_with("__spg_mysql_")
11823    }
11824    if let Some(from) = &stmt.from {
11825        if is_meta(&from.primary.name) {
11826            into.insert(from.primary.name.clone());
11827        }
11828        for j in &from.joins {
11829            if is_meta(&j.table.name) {
11830                into.insert(j.table.name.clone());
11831            }
11832        }
11833    }
11834    for cte in &stmt.ctes {
11835        collect_meta_view_names(&cte.body, into);
11836    }
11837}
11838
11839fn infer_column_types(columns: &[ColumnSchema], rows: &[Row]) -> Vec<ColumnSchema> {
11840    let mut out = columns.to_vec();
11841    for (col_idx, col) in out.iter_mut().enumerate() {
11842        if col.ty != DataType::Text {
11843            continue;
11844        }
11845        let mut inferred: Option<DataType> = None;
11846        let mut all_null = true;
11847        for row in rows {
11848            let Some(v) = row.values.get(col_idx) else {
11849                continue;
11850            };
11851            let ty = match v {
11852                Value::Null => continue,
11853                Value::SmallInt(_) => DataType::SmallInt,
11854                Value::Int(_) => DataType::Int,
11855                Value::BigInt(_) => DataType::BigInt,
11856                Value::Float(_) => DataType::Float,
11857                Value::Bool(_) => DataType::Bool,
11858                Value::Vector(_) => DataType::Vector {
11859                    dim: 0,
11860                    encoding: VecEncoding::F32,
11861                },
11862                _ => DataType::Text,
11863            };
11864            all_null = false;
11865            inferred = Some(match inferred {
11866                None => ty,
11867                Some(prev) if prev == ty => prev,
11868                Some(_) => DataType::Text,
11869            });
11870        }
11871        if let Some(t) = inferred {
11872            col.ty = t;
11873            col.nullable = true;
11874        } else if all_null {
11875            col.nullable = true;
11876        }
11877    }
11878    out
11879}
11880
11881/// v4.26: render a human-readable plan tree for `EXPLAIN <select>`.
11882/// Lines are pushed into `out`; `depth` controls indentation. We
11883/// describe the rewritten SELECT — what the executor *would* do —
11884/// using the engine handle to spot indexed lookups and table shapes.
11885#[allow(clippy::too_many_lines, clippy::format_push_string)]
11886/// v6.2.4 — Walk every line of the rendered plan tree and append
11887/// per-operator stats. Lines that name a known operator get
11888/// `(rows=N)` (`actual_rows` of the top-level operator equals the
11889/// final result row count; scans report their catalog row count
11890/// as the rows-considered metric). Other lines — Filter / Join /
11891/// GroupBy / OrderBy etc. — are marked `(—)` so the surface is
11892/// complete-by-construction; v6.2.5 fills these in via inline
11893/// executor counters.
11894/// v6.8.3 — surface "CREATE INDEX …" suggestions for every
11895/// `(table, column)` pair the query touches via WHERE / JOIN
11896/// that doesn't already have an index on the owning table.
11897/// Walks the SELECT's FROM clauses + WHERE expression tree;
11898/// returns one line per missing index. Deterministic order:
11899/// FROM-clause iteration order, then column-reference walk
11900/// order inside each WHERE. Each suggestion is a copy-pastable
11901/// DDL string.
11902fn build_index_suggestions(stmt: &SelectStatement, engine: &Engine) -> Vec<String> {
11903    use alloc::collections::BTreeSet;
11904    let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
11905    let mut out: Vec<String> = Vec::new();
11906    let cat = engine.active_catalog();
11907    // Build a (table, qualifier-or-alias) list from the FROM clause
11908    // so unqualified column refs in WHERE resolve to the correct
11909    // table.
11910    let Some(from) = &stmt.from else {
11911        return out;
11912    };
11913    let mut tables: Vec<String> = Vec::new();
11914    tables.push(from.primary.name.clone());
11915    for j in &from.joins {
11916        tables.push(j.table.name.clone());
11917    }
11918    // Collect column refs from the WHERE expression. JOIN ON
11919    // predicates also feed in.
11920    let mut col_refs: Vec<spg_sql::ast::ColumnName> = Vec::new();
11921    if let Some(w) = &stmt.where_ {
11922        collect_column_refs(w, &mut col_refs);
11923    }
11924    for j in &from.joins {
11925        if let Some(on) = &j.on {
11926            collect_column_refs(on, &mut col_refs);
11927        }
11928    }
11929    for cn in &col_refs {
11930        // Resolve owner table: explicit qualifier first, else
11931        // first table in FROM that has a column of this name.
11932        let owner: Option<String> = if let Some(q) = &cn.qualifier {
11933            tables.iter().find(|t| t == &q).cloned()
11934        } else {
11935            tables.iter().find_map(|t| {
11936                cat.get(t).and_then(|tbl| {
11937                    if tbl.schema().column_position(&cn.name).is_some() {
11938                        Some(t.clone())
11939                    } else {
11940                        None
11941                    }
11942                })
11943            })
11944        };
11945        let Some(owner) = owner else {
11946            continue;
11947        };
11948        let Some(tbl) = cat.get(&owner) else {
11949            continue;
11950        };
11951        let Some(col_pos) = tbl.schema().column_position(&cn.name) else {
11952            continue;
11953        };
11954        // Skip if any BTree index already covers this column as
11955        // its key.
11956        let already_indexed = tbl.indices().iter().any(|i| {
11957            matches!(i.kind, spg_storage::IndexKind::BTree(_))
11958                && i.column_position == col_pos
11959                && i.expression.is_none()
11960                && i.partial_predicate.is_none()
11961        });
11962        if already_indexed {
11963            continue;
11964        }
11965        if seen.insert((owner.clone(), cn.name.clone())) {
11966            out.push(alloc::format!(
11967                "SUGGEST: CREATE INDEX ix_{}_{} ON {} ({})",
11968                owner,
11969                cn.name,
11970                owner,
11971                cn.name
11972            ));
11973        }
11974    }
11975    out
11976}
11977
11978/// Walks an `Expr` and pushes every `ColumnName` it references.
11979/// Order is depth-first, left-to-right.
11980fn collect_column_refs(expr: &Expr, out: &mut Vec<spg_sql::ast::ColumnName>) {
11981    match expr {
11982        Expr::Column(cn) => out.push(cn.clone()),
11983        Expr::FunctionCall { args, .. } => {
11984            for a in args {
11985                collect_column_refs(a, out);
11986            }
11987        }
11988        Expr::Binary { lhs, rhs, .. } => {
11989            collect_column_refs(lhs, out);
11990            collect_column_refs(rhs, out);
11991        }
11992        Expr::Unary { expr: e, .. } => collect_column_refs(e, out),
11993        _ => {}
11994    }
11995}
11996
11997fn annotate_explain_lines(lines: &mut [String], total_rows: usize, engine: &Engine) {
11998    let catalog = engine.active_catalog();
11999    let cold_ids = catalog.cold_segment_ids_global();
12000    let any_cold = !cold_ids.is_empty();
12001    let cold_ids_repr = if any_cold {
12002        let mut s = alloc::string::String::from("[");
12003        for (i, id) in cold_ids.iter().enumerate() {
12004            if i > 0 {
12005                s.push(',');
12006            }
12007            s.push_str(&alloc::format!("{id}"));
12008        }
12009        s.push(']');
12010        s
12011    } else {
12012        alloc::string::String::new()
12013    };
12014    for (idx, line) in lines.iter_mut().enumerate() {
12015        let trimmed = line.trim_start();
12016        let is_top_level = idx == 0;
12017        if is_top_level {
12018            line.push_str(&alloc::format!(" (rows={total_rows})"));
12019            continue;
12020        }
12021        if let Some(rest) = trimmed.strip_prefix("From: ") {
12022            let (name, scan_kind) = match rest.split_once(" [") {
12023                Some((n, k)) => (n.trim(), k.trim_end_matches(']')),
12024                None => (rest.trim(), ""),
12025            };
12026            let bare = name.split_whitespace().next().unwrap_or(name);
12027            let hot = catalog.get(bare).map(|t| t.rows().len());
12028            // v6.2.7 — `cold_segments=[id0,id1,…]` enumerates every
12029            // cold-tier segment the scan COULD have walked. v6.2.x
12030            // can tighten to per-table by walking the table's
12031            // BTree-index cold locators.
12032            let annot = match (hot, scan_kind) {
12033                (Some(h), "full scan") => {
12034                    let mut s = alloc::format!(" (hot_rows={h}");
12035                    if any_cold {
12036                        s.push_str(&alloc::format!(
12037                            ", cold_tier=present, cold_segments={cold_ids_repr}"
12038                        ));
12039                    }
12040                    s.push(')');
12041                    s
12042                }
12043                (Some(h), "index seek") => {
12044                    let mut s = alloc::format!(" (hot_rows≤{h}");
12045                    if any_cold {
12046                        s.push_str(&alloc::format!(
12047                            ", cold_tier=present, cold_segments={cold_ids_repr}"
12048                        ));
12049                    }
12050                    s.push(')');
12051                    s
12052                }
12053                _ => " (rows=—)".to_string(),
12054            };
12055            line.push_str(&annot);
12056            continue;
12057        }
12058        // Filter / GroupBy / Having / OrderBy / Limit / Join etc.
12059        line.push_str(" (rows=—)");
12060    }
12061}
12062
12063fn explain_select(stmt: &SelectStatement, engine: &Engine, depth: usize, out: &mut Vec<String>) {
12064    let pad = "  ".repeat(depth);
12065    // 1) Top-level operator label.
12066    let top = if !stmt.ctes.is_empty() {
12067        if stmt.ctes.iter().any(|c| c.recursive) {
12068            "CTEScan (WITH RECURSIVE)"
12069        } else {
12070            "CTEScan (WITH)"
12071        }
12072    } else if !stmt.unions.is_empty() {
12073        "UnionScan"
12074    } else if select_has_window(stmt) {
12075        "WindowAgg"
12076    } else if aggregate::uses_aggregate(stmt) {
12077        "Aggregate"
12078    } else if stmt.distinct {
12079        "Distinct"
12080    } else if stmt.from.is_some() {
12081        "TableScan"
12082    } else {
12083        "Result"
12084    };
12085    out.push(alloc::format!("{pad}{top}"));
12086    let child = "  ".repeat(depth + 1);
12087    // 2) CTE bodies.
12088    for cte in &stmt.ctes {
12089        let head = if cte.recursive {
12090            alloc::format!("{child}CTE (recursive): {}", cte.name)
12091        } else {
12092            alloc::format!("{child}CTE: {}", cte.name)
12093        };
12094        out.push(head);
12095        explain_select(&cte.body, engine, depth + 2, out);
12096    }
12097    // 3) FROM details — primary table + joins, index hits.
12098    if let Some(from) = &stmt.from {
12099        let mut tag = alloc::format!("{child}From: {}", from.primary.name);
12100        if let Some(alias) = &from.primary.alias {
12101            tag.push_str(&alloc::format!(" AS {alias}"));
12102        }
12103        // Try to detect an index-seek opportunity on WHERE against
12104        // the primary table — same heuristic the executor uses.
12105        if let Some(w) = &stmt.where_
12106            && let Some(table) = engine.active_catalog().get(&from.primary.name)
12107        {
12108            let alias = from.primary.alias.as_deref().unwrap_or(&from.primary.name);
12109            let cols = &table.schema().columns;
12110            if try_index_seek(w, cols, engine.active_catalog(), table, alias).is_some() {
12111                tag.push_str(" [index seek]");
12112            } else {
12113                tag.push_str(" [full scan]");
12114            }
12115        } else {
12116            tag.push_str(" [full scan]");
12117        }
12118        out.push(tag);
12119        for j in &from.joins {
12120            let kind = match j.kind {
12121                spg_sql::ast::JoinKind::Inner => "INNER JOIN",
12122                spg_sql::ast::JoinKind::Left => "LEFT JOIN",
12123                spg_sql::ast::JoinKind::Cross => "CROSS JOIN",
12124            };
12125            let mut s = alloc::format!("{child}{kind}: {}", j.table.name);
12126            if let Some(alias) = &j.table.alias {
12127                s.push_str(&alloc::format!(" AS {alias}"));
12128            }
12129            if j.on.is_some() {
12130                s.push_str(" (ON …)");
12131            }
12132            out.push(s);
12133        }
12134    }
12135    // 4) WHERE / GROUP BY / HAVING / ORDER BY / LIMIT / OFFSET.
12136    if let Some(w) = &stmt.where_ {
12137        let mut s = alloc::format!("{child}Filter: {w}");
12138        if expr_has_subquery(w) {
12139            s.push_str(" [subquery]");
12140        }
12141        out.push(s);
12142    }
12143    if let Some(gs) = &stmt.group_by {
12144        let mut parts = Vec::new();
12145        for g in gs {
12146            parts.push(alloc::format!("{g}"));
12147        }
12148        out.push(alloc::format!("{child}GroupBy: {}", parts.join(", ")));
12149    }
12150    if let Some(h) = &stmt.having {
12151        out.push(alloc::format!("{child}Having: {h}"));
12152    }
12153    for o in &stmt.order_by {
12154        let dir = if o.desc { "DESC" } else { "ASC" };
12155        out.push(alloc::format!("{child}OrderBy: {} {dir}", o.expr));
12156    }
12157    if let Some(lim) = stmt.limit {
12158        out.push(alloc::format!("{child}Limit: {lim}"));
12159    }
12160    if let Some(off) = stmt.offset {
12161        out.push(alloc::format!("{child}Offset: {off}"));
12162    }
12163    // 5) Projection — collapse Wildcard or render N items.
12164    if stmt
12165        .items
12166        .iter()
12167        .any(|it| matches!(it, SelectItem::Wildcard))
12168    {
12169        out.push(alloc::format!("{child}Project: *"));
12170    } else {
12171        out.push(alloc::format!(
12172            "{child}Project: {} item(s)",
12173            stmt.items.len()
12174        ));
12175    }
12176    // 6) Recurse into UNION peers.
12177    for (kind, peer) in &stmt.unions {
12178        let label = match kind {
12179            UnionKind::All => "UNION ALL",
12180            UnionKind::Distinct => "UNION",
12181        };
12182        out.push(alloc::format!("{child}{label}"));
12183        explain_select(peer, engine, depth + 2, out);
12184    }
12185}
12186
12187/// v4.23: recognise the engine errors that indicate the inner
12188/// SELECT couldn't be evaluated in isolation because it references
12189/// an outer column — used by `subquery_replacement` to skip
12190/// materialisation and let row-eval handle it instead.
12191fn is_correlation_error(e: &EngineError) -> bool {
12192    matches!(
12193        e,
12194        EngineError::Eval(
12195            eval::EvalError::ColumnNotFound { .. } | eval::EvalError::UnknownQualifier { .. }
12196        )
12197    )
12198}
12199
12200/// v4.23: walk every Expr in `stmt` and replace each Column ref
12201/// that targets the outer scope (qualifier matches the outer
12202/// table alias) with a Literal carrying the outer row's value.
12203/// Conservative: only qualified refs are substituted, so the user
12204/// must write `outer_alias.col` to reference an outer column. This
12205/// matches PG's lexical scoping for correlated subqueries and
12206/// avoids accidentally rebinding inner columns of the same name.
12207/// v7.17.0 Phase 3.P0-41 — LATERAL peer descriptor. Either eagerly
12208/// materialised (every regular table / unnest / generate_series) or
12209/// lateral (subquery re-evaluated per outer row).
12210struct JoinedPeer<'a> {
12211    eager_rows: Option<Vec<Row>>,
12212    cols: Vec<ColumnSchema>,
12213    alias: String,
12214    kind: JoinKind,
12215    on: Option<&'a Expr>,
12216    lateral: Option<&'a SelectStatement>,
12217}
12218
12219/// v7.17.0 Phase 3.P0-41 — synthesise a column name for a LATERAL
12220/// projection item that has no explicit alias. PG names anonymous
12221/// projection items by the function call's name or by `column<i>`.
12222/// SPG mirrors the latter (lower-overhead than walking arbitrary
12223/// Expr shapes) so the probe-schema fallback path produces stable
12224/// names for the lateral peer's columns.
12225fn synth_lateral_col_name(expr: &Expr, idx: usize) -> String {
12226    match expr {
12227        // Bare column reference — use the column's own name.
12228        Expr::Column(c) => c.name.clone(),
12229        // Function call — use the function name (PG canonical:
12230        // `count` / `max` / `lower` …).
12231        Expr::FunctionCall { name, .. } => name.clone(),
12232        // Cast — drill into the inner expression.
12233        Expr::Cast { expr: inner, .. } => synth_lateral_col_name(inner, idx),
12234        // Everything else falls back to PG's `column<N>` placeholder.
12235        _ => alloc::format!("column{}", idx + 1),
12236    }
12237}
12238
12239/// v7.17.0 Phase 3.P0-41 — substitute every `<alias>.<col>` Expr
12240/// reference whose `<alias>.<col>` exists in the outer composite
12241/// schema with the matching value from the outer row. Walks the
12242/// entire SELECT body (items, WHERE, GROUP BY, HAVING, ORDER BY,
12243/// UNION peers) so any depth of outer reference inside the
12244/// LATERAL subquery resolves before execution.
12245fn substitute_outer_columns_multi(
12246    stmt: &mut SelectStatement,
12247    outer_row: &Row,
12248    outer_schema: &[ColumnSchema],
12249) {
12250    substitute_outer_in_select(stmt, outer_row, outer_schema);
12251}
12252
12253fn substitute_outer_in_select(
12254    stmt: &mut SelectStatement,
12255    outer_row: &Row,
12256    outer_schema: &[ColumnSchema],
12257) {
12258    for item in &mut stmt.items {
12259        if let SelectItem::Expr { expr, .. } = item {
12260            substitute_outer_in_expr(expr, outer_row, outer_schema);
12261        }
12262    }
12263    if let Some(w) = &mut stmt.where_ {
12264        substitute_outer_in_expr(w, outer_row, outer_schema);
12265    }
12266    if let Some(gs) = &mut stmt.group_by {
12267        for g in gs {
12268            substitute_outer_in_expr(g, outer_row, outer_schema);
12269        }
12270    }
12271    if let Some(h) = &mut stmt.having {
12272        substitute_outer_in_expr(h, outer_row, outer_schema);
12273    }
12274    for o in &mut stmt.order_by {
12275        substitute_outer_in_expr(&mut o.expr, outer_row, outer_schema);
12276    }
12277    for (_, peer) in &mut stmt.unions {
12278        substitute_outer_in_select(peer, outer_row, outer_schema);
12279    }
12280}
12281
12282fn substitute_outer_in_expr(e: &mut Expr, outer_row: &Row, outer_schema: &[ColumnSchema]) {
12283    if let Expr::Column(c) = e
12284        && let Some(qual) = &c.qualifier
12285    {
12286        let composite = alloc::format!("{qual}.{}", c.name);
12287        if let Some(idx) = outer_schema
12288            .iter()
12289            .position(|sc| sc.name.eq_ignore_ascii_case(&composite))
12290        {
12291            let v = outer_row.values.get(idx).cloned().unwrap_or(Value::Null);
12292            if let Ok(lit) = value_to_literal_expr(v) {
12293                *e = lit;
12294                return;
12295            }
12296        }
12297    }
12298    match e {
12299        Expr::Binary { lhs, rhs, .. } => {
12300            substitute_outer_in_expr(lhs, outer_row, outer_schema);
12301            substitute_outer_in_expr(rhs, outer_row, outer_schema);
12302        }
12303        Expr::Unary { expr: inner, .. } => {
12304            substitute_outer_in_expr(inner, outer_row, outer_schema);
12305        }
12306        Expr::FunctionCall { args, .. } => {
12307            for a in args {
12308                substitute_outer_in_expr(a, outer_row, outer_schema);
12309            }
12310        }
12311        Expr::Cast { expr: inner, .. } => {
12312            substitute_outer_in_expr(inner, outer_row, outer_schema);
12313        }
12314        Expr::Case {
12315            operand,
12316            branches,
12317            else_branch,
12318        } => {
12319            if let Some(op) = operand {
12320                substitute_outer_in_expr(op, outer_row, outer_schema);
12321            }
12322            for (cond, val) in branches {
12323                substitute_outer_in_expr(cond, outer_row, outer_schema);
12324                substitute_outer_in_expr(val, outer_row, outer_schema);
12325            }
12326            if let Some(e) = else_branch {
12327                substitute_outer_in_expr(e, outer_row, outer_schema);
12328            }
12329        }
12330        _ => {}
12331    }
12332}
12333
12334fn substitute_outer_columns(stmt: &mut SelectStatement, row: &Row, ctx: &EvalContext<'_>) {
12335    let Some(outer_alias) = ctx.table_alias else {
12336        return;
12337    };
12338    substitute_in_select(stmt, row, ctx, outer_alias);
12339}
12340
12341fn substitute_in_select(
12342    stmt: &mut SelectStatement,
12343    row: &Row,
12344    ctx: &EvalContext<'_>,
12345    outer_alias: &str,
12346) {
12347    for item in &mut stmt.items {
12348        if let SelectItem::Expr { expr, .. } = item {
12349            substitute_in_expr(expr, row, ctx, outer_alias);
12350        }
12351    }
12352    if let Some(w) = &mut stmt.where_ {
12353        substitute_in_expr(w, row, ctx, outer_alias);
12354    }
12355    if let Some(gs) = &mut stmt.group_by {
12356        for g in gs {
12357            substitute_in_expr(g, row, ctx, outer_alias);
12358        }
12359    }
12360    if let Some(h) = &mut stmt.having {
12361        substitute_in_expr(h, row, ctx, outer_alias);
12362    }
12363    for o in &mut stmt.order_by {
12364        substitute_in_expr(&mut o.expr, row, ctx, outer_alias);
12365    }
12366    for (_, peer) in &mut stmt.unions {
12367        substitute_in_select(peer, row, ctx, outer_alias);
12368    }
12369}
12370
12371fn substitute_in_expr(e: &mut Expr, row: &Row, ctx: &EvalContext<'_>, outer_alias: &str) {
12372    if let Expr::Column(c) = e
12373        && let Some(qual) = &c.qualifier
12374        && qual.eq_ignore_ascii_case(outer_alias)
12375    {
12376        // Look up the column's index in the outer schema.
12377        if let Some(idx) = ctx
12378            .columns
12379            .iter()
12380            .position(|sc| sc.name.eq_ignore_ascii_case(&c.name))
12381        {
12382            let v = row.values.get(idx).cloned().unwrap_or(Value::Null);
12383            if let Ok(lit) = value_to_literal_expr(v) {
12384                *e = lit;
12385                return;
12386            }
12387        }
12388    }
12389    match e {
12390        Expr::Binary { lhs, rhs, .. } => {
12391            substitute_in_expr(lhs, row, ctx, outer_alias);
12392            substitute_in_expr(rhs, row, ctx, outer_alias);
12393        }
12394        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12395            substitute_in_expr(expr, row, ctx, outer_alias);
12396        }
12397        Expr::Like { expr, pattern, .. } => {
12398            substitute_in_expr(expr, row, ctx, outer_alias);
12399            substitute_in_expr(pattern, row, ctx, outer_alias);
12400        }
12401        Expr::FunctionCall { args, .. } => {
12402            for a in args {
12403                substitute_in_expr(a, row, ctx, outer_alias);
12404            }
12405        }
12406        Expr::Extract { source, .. } => substitute_in_expr(source, row, ctx, outer_alias),
12407        Expr::WindowFunction {
12408            args,
12409            partition_by,
12410            order_by,
12411            ..
12412        } => {
12413            for a in args {
12414                substitute_in_expr(a, row, ctx, outer_alias);
12415            }
12416            for p in partition_by {
12417                substitute_in_expr(p, row, ctx, outer_alias);
12418            }
12419            for (o, _) in order_by {
12420                substitute_in_expr(o, row, ctx, outer_alias);
12421            }
12422        }
12423        Expr::ScalarSubquery(s) => substitute_in_select(s, row, ctx, outer_alias),
12424        Expr::Exists { subquery, .. } | Expr::InSubquery { subquery, .. } => {
12425            substitute_in_select(subquery, row, ctx, outer_alias);
12426        }
12427        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => {}
12428        Expr::Array(items) => {
12429            for elem in items {
12430                substitute_in_expr(elem, row, ctx, outer_alias);
12431            }
12432        }
12433        Expr::ArraySubscript { target, index } => {
12434            substitute_in_expr(target, row, ctx, outer_alias);
12435            substitute_in_expr(index, row, ctx, outer_alias);
12436        }
12437        Expr::AnyAll { expr, array, .. } => {
12438            substitute_in_expr(expr, row, ctx, outer_alias);
12439            substitute_in_expr(array, row, ctx, outer_alias);
12440        }
12441        Expr::Case {
12442            operand,
12443            branches,
12444            else_branch,
12445        } => {
12446            if let Some(o) = operand {
12447                substitute_in_expr(o, row, ctx, outer_alias);
12448            }
12449            for (w, t) in branches {
12450                substitute_in_expr(w, row, ctx, outer_alias);
12451                substitute_in_expr(t, row, ctx, outer_alias);
12452            }
12453            if let Some(e) = else_branch {
12454                substitute_in_expr(e, row, ctx, outer_alias);
12455            }
12456        }
12457    }
12458}
12459
12460/// v4.22: encode a Row to a comparable byte key for UNION-DISTINCT
12461/// dedup inside the recursive iteration. Crude but deterministic
12462/// — Debug prints embed type discriminants so NULL ≠ "" ≠ 0.
12463fn encode_row_key(row: &Row) -> Vec<u8> {
12464    let mut out = Vec::new();
12465    for v in &row.values {
12466        let s = alloc::format!("{v:?}|");
12467        out.extend_from_slice(s.as_bytes());
12468    }
12469    out
12470}
12471
12472fn select_has_window(stmt: &SelectStatement) -> bool {
12473    for item in &stmt.items {
12474        if let SelectItem::Expr { expr, .. } = item
12475            && expr_has_window(expr)
12476        {
12477            return true;
12478        }
12479    }
12480    false
12481}
12482
12483fn expr_has_window(e: &Expr) -> bool {
12484    match e {
12485        Expr::WindowFunction { .. } => true,
12486        Expr::Binary { lhs, rhs, .. } => expr_has_window(lhs) || expr_has_window(rhs),
12487        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12488            expr_has_window(expr)
12489        }
12490        Expr::FunctionCall { args, .. } => args.iter().any(expr_has_window),
12491        Expr::Like { expr, pattern, .. } => expr_has_window(expr) || expr_has_window(pattern),
12492        Expr::Extract { source, .. } => expr_has_window(source),
12493        Expr::ScalarSubquery(_)
12494        | Expr::Exists { .. }
12495        | Expr::InSubquery { .. }
12496        | Expr::Literal(_)
12497        | Expr::Placeholder(_)
12498        | Expr::Column(_) => false,
12499        Expr::Array(items) => items.iter().any(expr_has_window),
12500        Expr::ArraySubscript { target, index } => expr_has_window(target) || expr_has_window(index),
12501        Expr::AnyAll { expr, array, .. } => expr_has_window(expr) || expr_has_window(array),
12502        Expr::Case {
12503            operand,
12504            branches,
12505            else_branch,
12506        } => {
12507            operand.as_deref().is_some_and(expr_has_window)
12508                || branches
12509                    .iter()
12510                    .any(|(w, t)| expr_has_window(w) || expr_has_window(t))
12511                || else_branch.as_deref().is_some_and(expr_has_window)
12512        }
12513    }
12514}
12515
12516fn collect_window_nodes(e: &Expr, out: &mut Vec<Expr>) {
12517    if let Expr::WindowFunction { .. } = e {
12518        // Deduplicate by structural equality on the expression
12519        // (cheap because window args + partition + order are
12520        // small). Without dedup we'd recompute identical windows
12521        // once per occurrence in the projection.
12522        if !out.iter().any(|x| x == e) {
12523            out.push(e.clone());
12524        }
12525        return;
12526    }
12527    match e {
12528        // Already handled by the early-return at the top.
12529        Expr::WindowFunction { .. } => unreachable!(),
12530        Expr::Binary { lhs, rhs, .. } => {
12531            collect_window_nodes(lhs, out);
12532            collect_window_nodes(rhs, out);
12533        }
12534        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12535            collect_window_nodes(expr, out);
12536        }
12537        Expr::FunctionCall { args, .. } => {
12538            for a in args {
12539                collect_window_nodes(a, out);
12540            }
12541        }
12542        Expr::Like { expr, pattern, .. } => {
12543            collect_window_nodes(expr, out);
12544            collect_window_nodes(pattern, out);
12545        }
12546        Expr::Extract { source, .. } => collect_window_nodes(source, out),
12547        _ => {}
12548    }
12549}
12550
12551fn rewrite_window_to_columns(e: &mut Expr, window_nodes: &[Expr]) {
12552    if let Expr::WindowFunction { .. } = e
12553        && let Some(idx) = window_nodes.iter().position(|w| w == e)
12554    {
12555        *e = Expr::Column(spg_sql::ast::ColumnName {
12556            qualifier: None,
12557            name: alloc::format!("__win_{idx}"),
12558        });
12559        return;
12560    }
12561    match e {
12562        Expr::Binary { lhs, rhs, .. } => {
12563            rewrite_window_to_columns(lhs, window_nodes);
12564            rewrite_window_to_columns(rhs, window_nodes);
12565        }
12566        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12567            rewrite_window_to_columns(expr, window_nodes);
12568        }
12569        Expr::FunctionCall { args, .. } => {
12570            for a in args {
12571                rewrite_window_to_columns(a, window_nodes);
12572            }
12573        }
12574        Expr::Like { expr, pattern, .. } => {
12575            rewrite_window_to_columns(expr, window_nodes);
12576            rewrite_window_to_columns(pattern, window_nodes);
12577        }
12578        Expr::Extract { source, .. } => rewrite_window_to_columns(source, window_nodes),
12579        _ => {}
12580    }
12581}
12582
12583/// Total order over partition-key tuples. NULL sorts as the
12584/// lowest value (matches the `<` partial order's NULL-last
12585/// behaviour with `INFINITY` flipped).
12586fn partition_key_cmp(a: &[Value], b: &[Value]) -> core::cmp::Ordering {
12587    for (x, y) in a.iter().zip(b.iter()) {
12588        let c = value_cmp(x, y);
12589        if c != core::cmp::Ordering::Equal {
12590            return c;
12591        }
12592    }
12593    a.len().cmp(&b.len())
12594}
12595
12596fn order_key_cmp(a: &[(Value, bool)], b: &[(Value, bool)]) -> core::cmp::Ordering {
12597    for ((va, desc), (vb, _)) in a.iter().zip(b.iter()) {
12598        let c = value_cmp(va, vb);
12599        let c = if *desc { c.reverse() } else { c };
12600        if c != core::cmp::Ordering::Equal {
12601            return c;
12602        }
12603    }
12604    a.len().cmp(&b.len())
12605}
12606
12607/// v7.17.0 Phase 3.10 — true when the Value is one of the
12608/// integer-shaped variants `generate_series` accepts as a start
12609/// / stop / step component. Float / NUMERIC are rejected — PG's
12610/// `generate_series(numeric, numeric)` overload is out of v7.17
12611/// scope.
12612const fn value_is_integer(v: &Value) -> bool {
12613    matches!(v, Value::SmallInt(_) | Value::Int(_) | Value::BigInt(_))
12614}
12615
12616/// v7.17.0 Phase 3.10 — widen any integer-shaped Value to i64 for
12617/// the generate_series iteration loop. Non-integer inputs panic;
12618/// caller guards via `value_is_integer`.
12619const fn value_to_i64(v: &Value) -> i64 {
12620    match v {
12621        Value::SmallInt(n) => *n as i64,
12622        Value::Int(n) => *n as i64,
12623        Value::BigInt(n) => *n,
12624        _ => panic!("value_to_i64 called on non-integer Value"),
12625    }
12626}
12627
12628/// v7.17.0 Phase 3.10 — integer-mode generate_series materialiser.
12629/// Step direction follows the sign: positive step iterates upward
12630/// (stops when current > stop); negative iterates downward; zero
12631/// errors. Caller-facing row stream is `BigInt`-typed so a single
12632/// projection schema covers SmallInt / Int / BigInt callers.
12633fn generate_series_integers(
12634    start: i64,
12635    stop: i64,
12636    step: i64,
12637    cancel: &CancelToken<'_>,
12638) -> Result<alloc::vec::Vec<Row>, EngineError> {
12639    if step == 0 {
12640        return Err(EngineError::Unsupported(
12641            "generate_series(): step argument cannot be zero".into(),
12642        ));
12643    }
12644    let mut out = alloc::vec::Vec::new();
12645    let mut cur = start;
12646    // Hard cap to keep a runaway call from eating all memory. PG
12647    // has no such cap but does honour query timeout; SPG's cancel
12648    // token will fire too — this is a defense-in-depth backstop.
12649    const MAX_ROWS: usize = 10_000_000;
12650    loop {
12651        cancel.check()?;
12652        if step > 0 && cur > stop {
12653            break;
12654        }
12655        if step < 0 && cur < stop {
12656            break;
12657        }
12658        out.push(Row::new(alloc::vec![Value::BigInt(cur)]));
12659        if out.len() > MAX_ROWS {
12660            return Err(EngineError::Unsupported(alloc::format!(
12661                "generate_series(): exceeded {MAX_ROWS} rows; \
12662                 narrow start/stop or use a larger step"
12663            )));
12664        }
12665        cur = match cur.checked_add(step) {
12666            Some(n) => n,
12667            None => break,
12668        };
12669    }
12670    Ok(out)
12671}
12672
12673/// v7.17.0 Phase 3.10 — timestamp-mode generate_series. step is a
12674/// `Value::Interval { months, micros }` per the caller's guard;
12675/// each iteration adds the interval via `apply_binary_interval`
12676/// so month-shifting handles short-month rollover (PG semantics).
12677fn generate_series_timestamps(
12678    start: i64,
12679    stop: i64,
12680    step: Value,
12681    cancel: &CancelToken<'_>,
12682) -> Result<alloc::vec::Vec<Row>, EngineError> {
12683    let (months, micros) = match &step {
12684        Value::Interval { months, micros } => (*months, *micros),
12685        _ => unreachable!("caller guards step.is_interval"),
12686    };
12687    if months == 0 && micros == 0 {
12688        return Err(EngineError::Unsupported(
12689            "generate_series(): INTERVAL step cannot be zero".into(),
12690        ));
12691    }
12692    let ascending = months > 0 || micros > 0;
12693    let mut out = alloc::vec::Vec::new();
12694    let mut cur = Value::Timestamp(start);
12695    const MAX_ROWS: usize = 10_000_000;
12696    loop {
12697        cancel.check()?;
12698        let cur_t = match cur {
12699            Value::Timestamp(t) => t,
12700            _ => unreachable!("loop invariant: cur is Timestamp"),
12701        };
12702        if ascending && cur_t > stop {
12703            break;
12704        }
12705        if !ascending && cur_t < stop {
12706            break;
12707        }
12708        out.push(Row::new(alloc::vec![Value::Timestamp(cur_t)]));
12709        if out.len() > MAX_ROWS {
12710            return Err(EngineError::Unsupported(alloc::format!(
12711                "generate_series(): exceeded {MAX_ROWS} rows; \
12712                 narrow start/stop or use a larger step"
12713            )));
12714        }
12715        let next = eval::apply_binary_interval(
12716            spg_sql::ast::BinOp::Add,
12717            &cur,
12718            &Value::Interval { months, micros },
12719        )
12720        .map_err(EngineError::Eval)?;
12721        cur = match next {
12722            Some(v) => v,
12723            None => break,
12724        };
12725    }
12726    Ok(out)
12727}
12728
12729#[allow(clippy::match_same_arms)] // explicit arms per type document the supported pairs
12730fn value_cmp(a: &Value, b: &Value) -> core::cmp::Ordering {
12731    use core::cmp::Ordering;
12732    match (a, b) {
12733        (Value::Null, Value::Null) => Ordering::Equal,
12734        (Value::Null, _) => Ordering::Less,
12735        (_, Value::Null) => Ordering::Greater,
12736        (Value::Int(x), Value::Int(y)) => x.cmp(y),
12737        (Value::BigInt(x), Value::BigInt(y)) => x.cmp(y),
12738        (Value::SmallInt(x), Value::SmallInt(y)) => x.cmp(y),
12739        (Value::Text(x), Value::Text(y)) => x.cmp(y),
12740        (Value::Bool(x), Value::Bool(y)) => x.cmp(y),
12741        (Value::Float(x), Value::Float(y)) => x.partial_cmp(y).unwrap_or(Ordering::Equal),
12742        (Value::Date(x), Value::Date(y)) => x.cmp(y),
12743        (Value::Timestamp(x), Value::Timestamp(y)) => x.cmp(y),
12744        // Cross-type compare: fall back to the debug rendering —
12745        // same-partition is the goal, exact order is irrelevant.
12746        _ => alloc::format!("{a:?}").cmp(&alloc::format!("{b:?}")),
12747    }
12748}
12749
12750/// Compute the window function's per-row output for one partition.
12751/// `slice` has (partition key, order key, original-row-index)
12752/// tuples already sorted by order key. `filtered_rows` is the
12753/// full row list indexed by original-row-index. `out_vals` is
12754/// the destination, also indexed by original-row-index.
12755#[allow(
12756    clippy::too_many_arguments,
12757    clippy::cast_possible_truncation,
12758    clippy::cast_possible_wrap,
12759    clippy::cast_precision_loss,
12760    clippy::cast_sign_loss,
12761    clippy::doc_markdown,
12762    clippy::too_many_lines,
12763    clippy::type_complexity,
12764    clippy::match_same_arms
12765)]
12766fn compute_window_partition(
12767    name: &str,
12768    args: &[Expr],
12769    ordered: bool,
12770    frame: Option<&WindowFrame>,
12771    null_treatment: spg_sql::ast::NullTreatment,
12772    slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)],
12773    filtered_rows: &[&Row],
12774    ctx: &EvalContext<'_>,
12775    out_vals: &mut [Value],
12776) -> Result<(), EngineError> {
12777    let ignore_nulls = matches!(null_treatment, spg_sql::ast::NullTreatment::Ignore);
12778    let lower = name.to_ascii_lowercase();
12779    match lower.as_str() {
12780        "row_number" => {
12781            for (rank, (_, _, idx)) in slice.iter().enumerate() {
12782                out_vals[*idx] = Value::BigInt((rank + 1) as i64);
12783            }
12784            Ok(())
12785        }
12786        "rank" => {
12787            let mut prev_key: Option<&[(Value, bool)]> = None;
12788            let mut current_rank: i64 = 1;
12789            for (i, (_, okey, idx)) in slice.iter().enumerate() {
12790                if let Some(p) = prev_key
12791                    && order_key_cmp(p, okey) != core::cmp::Ordering::Equal
12792                {
12793                    current_rank = (i + 1) as i64;
12794                }
12795                if prev_key.is_none() {
12796                    current_rank = 1;
12797                }
12798                out_vals[*idx] = Value::BigInt(current_rank);
12799                prev_key = Some(okey.as_slice());
12800            }
12801            Ok(())
12802        }
12803        "dense_rank" => {
12804            let mut prev_key: Option<&[(Value, bool)]> = None;
12805            let mut current_rank: i64 = 0;
12806            for (_, okey, idx) in slice {
12807                if prev_key.is_none_or(|p| order_key_cmp(p, okey) != core::cmp::Ordering::Equal) {
12808                    current_rank += 1;
12809                }
12810                out_vals[*idx] = Value::BigInt(current_rank);
12811                prev_key = Some(okey.as_slice());
12812            }
12813            Ok(())
12814        }
12815        "sum" | "avg" | "min" | "max" | "count" | "count_star" => {
12816            // Pre-evaluate the function arg per row in the slice
12817            // (count_star has no arg).
12818            let arg_values: Vec<Value> = if lower == "count_star" || args.is_empty() {
12819                slice.iter().map(|_| Value::Null).collect()
12820            } else {
12821                slice
12822                    .iter()
12823                    .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
12824                    .collect::<Result<_, _>>()
12825                    .map_err(EngineError::Eval)?
12826            };
12827            // v4.20: pick the effective frame. Explicit frame
12828            // overrides the implicit default (running for ordered,
12829            // whole-partition for unordered).
12830            let eff = effective_frame(frame, ordered)?;
12831            #[allow(clippy::needless_range_loop)]
12832            for i in 0..slice.len() {
12833                let (lo, hi) = frame_bounds_for_row(&eff, i, slice);
12834                let mut sum: f64 = 0.0;
12835                let mut count: i64 = 0;
12836                let mut min_v: Option<f64> = None;
12837                let mut max_v: Option<f64> = None;
12838                let mut row_count: i64 = 0;
12839                if lo <= hi {
12840                    for j in lo..=hi {
12841                        let v = &arg_values[j];
12842                        match lower.as_str() {
12843                            "count_star" => row_count += 1,
12844                            "count" => {
12845                                if !v.is_null() {
12846                                    count += 1;
12847                                }
12848                            }
12849                            _ => {
12850                                if let Some(x) = value_to_f64(v) {
12851                                    sum += x;
12852                                    count += 1;
12853                                    min_v = Some(min_v.map_or(x, |m| m.min(x)));
12854                                    max_v = Some(max_v.map_or(x, |m| m.max(x)));
12855                                }
12856                            }
12857                        }
12858                    }
12859                }
12860                let value = match lower.as_str() {
12861                    "count_star" => Value::BigInt(row_count),
12862                    "count" => Value::BigInt(count),
12863                    "sum" => Value::Float(sum),
12864                    "avg" => {
12865                        if count == 0 {
12866                            Value::Null
12867                        } else {
12868                            Value::Float(sum / count as f64)
12869                        }
12870                    }
12871                    "min" => min_v.map_or(Value::Null, Value::Float),
12872                    "max" => max_v.map_or(Value::Null, Value::Float),
12873                    _ => unreachable!(),
12874                };
12875                let (_, _, idx) = &slice[i];
12876                out_vals[*idx] = value;
12877            }
12878            Ok(())
12879        }
12880        "lag" | "lead" => {
12881            // lag(expr [, offset [, default]])
12882            // lead(expr [, offset [, default]])
12883            if args.is_empty() {
12884                return Err(EngineError::Unsupported(alloc::format!(
12885                    "{lower}() requires at least one argument"
12886                )));
12887            }
12888            let offset: i64 = if args.len() >= 2 {
12889                let v = eval::eval_expr(&args[1], filtered_rows[slice[0].2], ctx)
12890                    .map_err(EngineError::Eval)?;
12891                match v {
12892                    Value::SmallInt(n) => i64::from(n),
12893                    Value::Int(n) => i64::from(n),
12894                    Value::BigInt(n) => n,
12895                    _ => {
12896                        return Err(EngineError::Unsupported(alloc::format!(
12897                            "{lower}() offset must be integer"
12898                        )));
12899                    }
12900                }
12901            } else {
12902                1
12903            };
12904            let default: Value = if args.len() >= 3 {
12905                eval::eval_expr(&args[2], filtered_rows[slice[0].2], ctx)
12906                    .map_err(EngineError::Eval)?
12907            } else {
12908                Value::Null
12909            };
12910            let values: Vec<Value> = slice
12911                .iter()
12912                .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
12913                .collect::<Result<_, _>>()
12914                .map_err(EngineError::Eval)?;
12915            let n = slice.len();
12916            for (i, (_, _, idx)) in slice.iter().enumerate() {
12917                let signed_offset = if lower == "lag" { -offset } else { offset };
12918                let v = if ignore_nulls {
12919                    // v6.4.2 — IGNORE NULLS: walk in the offset direction
12920                    // skipping NULL values; the `offset`-th non-NULL
12921                    // encountered is the result.
12922                    let step: i64 = if signed_offset >= 0 { 1 } else { -1 };
12923                    let needed: i64 = signed_offset.abs();
12924                    if needed == 0 {
12925                        values[i].clone()
12926                    } else {
12927                        let mut j: i64 = i as i64;
12928                        let mut hits: i64 = 0;
12929                        let mut found: Option<Value> = None;
12930                        loop {
12931                            j += step;
12932                            if j < 0 || j >= n as i64 {
12933                                break;
12934                            }
12935                            #[allow(clippy::cast_sign_loss)]
12936                            let v = &values[j as usize];
12937                            if !v.is_null() {
12938                                hits += 1;
12939                                if hits == needed {
12940                                    found = Some(v.clone());
12941                                    break;
12942                                }
12943                            }
12944                        }
12945                        found.unwrap_or_else(|| default.clone())
12946                    }
12947                } else {
12948                    let target_signed = i64::try_from(i).unwrap_or(i64::MAX) + signed_offset;
12949                    if target_signed < 0 || target_signed >= i64::try_from(n).unwrap_or(i64::MAX) {
12950                        default.clone()
12951                    } else {
12952                        #[allow(clippy::cast_sign_loss)]
12953                        {
12954                            values[target_signed as usize].clone()
12955                        }
12956                    }
12957                };
12958                out_vals[*idx] = v;
12959            }
12960            Ok(())
12961        }
12962        "first_value" | "last_value" | "nth_value" => {
12963            if args.is_empty() {
12964                return Err(EngineError::Unsupported(alloc::format!(
12965                    "{lower}() requires at least one argument"
12966                )));
12967            }
12968            let values: Vec<Value> = slice
12969                .iter()
12970                .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
12971                .collect::<Result<_, _>>()
12972                .map_err(EngineError::Eval)?;
12973            let nth: usize = if lower == "nth_value" {
12974                if args.len() < 2 {
12975                    return Err(EngineError::Unsupported(
12976                        "nth_value() requires (expr, n)".into(),
12977                    ));
12978                }
12979                let v = eval::eval_expr(&args[1], filtered_rows[slice[0].2], ctx)
12980                    .map_err(EngineError::Eval)?;
12981                let raw = match v {
12982                    Value::SmallInt(n) => i64::from(n),
12983                    Value::Int(n) => i64::from(n),
12984                    Value::BigInt(n) => n,
12985                    _ => {
12986                        return Err(EngineError::Unsupported(
12987                            "nth_value() n must be integer".into(),
12988                        ));
12989                    }
12990                };
12991                if raw < 1 {
12992                    return Err(EngineError::Unsupported(
12993                        "nth_value() n must be >= 1".into(),
12994                    ));
12995                }
12996                #[allow(clippy::cast_sign_loss)]
12997                {
12998                    raw as usize
12999                }
13000            } else {
13001                0
13002            };
13003            let eff = effective_frame(frame, ordered)?;
13004            for i in 0..slice.len() {
13005                let (lo, hi) = frame_bounds_for_row(&eff, i, slice);
13006                let (_, _, idx) = &slice[i];
13007                let v = if lo > hi {
13008                    Value::Null
13009                } else if ignore_nulls && matches!(lower.as_str(), "first_value" | "last_value") {
13010                    // v6.4.2 — IGNORE NULLS: skip NULL cells when
13011                    // selecting the boundary value within the frame.
13012                    if lower == "first_value" {
13013                        (lo..=hi)
13014                            .find_map(|j| {
13015                                let v = &values[j];
13016                                (!v.is_null()).then(|| v.clone())
13017                            })
13018                            .unwrap_or(Value::Null)
13019                    } else {
13020                        (lo..=hi)
13021                            .rev()
13022                            .find_map(|j| {
13023                                let v = &values[j];
13024                                (!v.is_null()).then(|| v.clone())
13025                            })
13026                            .unwrap_or(Value::Null)
13027                    }
13028                } else {
13029                    match lower.as_str() {
13030                        "first_value" => values[lo].clone(),
13031                        "last_value" => values[hi].clone(),
13032                        "nth_value" => {
13033                            let pos = lo + nth - 1;
13034                            if pos > hi {
13035                                Value::Null
13036                            } else {
13037                                values[pos].clone()
13038                            }
13039                        }
13040                        _ => unreachable!(),
13041                    }
13042                };
13043                out_vals[*idx] = v;
13044            }
13045            Ok(())
13046        }
13047        "ntile" => {
13048            if args.is_empty() {
13049                return Err(EngineError::Unsupported(
13050                    "ntile(n) requires an integer argument".into(),
13051                ));
13052            }
13053            let v = eval::eval_expr(&args[0], filtered_rows[slice[0].2], ctx)
13054                .map_err(EngineError::Eval)?;
13055            let bucket_count: i64 = match v {
13056                Value::SmallInt(n) => i64::from(n),
13057                Value::Int(n) => i64::from(n),
13058                Value::BigInt(n) => n,
13059                _ => {
13060                    return Err(EngineError::Unsupported(
13061                        "ntile() argument must be integer".into(),
13062                    ));
13063                }
13064            };
13065            if bucket_count < 1 {
13066                return Err(EngineError::Unsupported(
13067                    "ntile() argument must be >= 1".into(),
13068                ));
13069            }
13070            #[allow(clippy::cast_sign_loss)]
13071            let buckets = bucket_count as usize;
13072            let n = slice.len();
13073            // Each bucket gets `base` rows; the first `extras` buckets
13074            // get one extra. PG semantics.
13075            let base = n / buckets;
13076            let extras = n % buckets;
13077            let mut bucket: usize = 1;
13078            let mut remaining_in_bucket = if extras > 0 { base + 1 } else { base };
13079            let mut buckets_with_extra_remaining = extras;
13080            for (_, _, idx) in slice {
13081                if remaining_in_bucket == 0 {
13082                    bucket += 1;
13083                    buckets_with_extra_remaining = buckets_with_extra_remaining.saturating_sub(1);
13084                    remaining_in_bucket = if buckets_with_extra_remaining > 0 {
13085                        base + 1
13086                    } else {
13087                        base
13088                    };
13089                    // Edge: if base==0 and extras==0, all rows fit;
13090                    // shouldn't reach here, but guard anyway.
13091                    if remaining_in_bucket == 0 {
13092                        remaining_in_bucket = 1;
13093                    }
13094                }
13095                out_vals[*idx] = Value::BigInt(i64::try_from(bucket).unwrap_or(i64::MAX));
13096                remaining_in_bucket -= 1;
13097            }
13098            Ok(())
13099        }
13100        "percent_rank" => {
13101            // (rank - 1) / (n - 1) where rank is the standard RANK().
13102            // Single-row partitions get 0.
13103            let n = slice.len();
13104            let mut prev_key: Option<&[(Value, bool)]> = None;
13105            let mut current_rank: i64 = 1;
13106            for (i, (_, okey, idx)) in slice.iter().enumerate() {
13107                if let Some(p) = prev_key
13108                    && order_key_cmp(p, okey) != core::cmp::Ordering::Equal
13109                {
13110                    current_rank = i64::try_from(i + 1).unwrap_or(i64::MAX);
13111                }
13112                if prev_key.is_none() {
13113                    current_rank = 1;
13114                }
13115                #[allow(clippy::cast_precision_loss)]
13116                let pr = if n <= 1 {
13117                    0.0
13118                } else {
13119                    (current_rank - 1) as f64 / (n - 1) as f64
13120                };
13121                out_vals[*idx] = Value::Float(pr);
13122                prev_key = Some(okey.as_slice());
13123            }
13124            Ok(())
13125        }
13126        "cume_dist" => {
13127            // # rows up to and including this row's peer group / n.
13128            let n = slice.len();
13129            // First pass: find peer-group-end rank for each row.
13130            for i in 0..slice.len() {
13131                let peer_end = peer_group_end(slice, i);
13132                #[allow(clippy::cast_precision_loss)]
13133                let cd = (peer_end + 1) as f64 / n as f64;
13134                let (_, _, idx) = &slice[i];
13135                out_vals[*idx] = Value::Float(cd);
13136            }
13137            Ok(())
13138        }
13139        other => Err(EngineError::Unsupported(alloc::format!(
13140            "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)"
13141        ))),
13142    }
13143}
13144
13145/// v4.20: resolve the user-provided frame down to a normalised
13146/// `(kind, start, end)`. `None` means default — derive from
13147/// `ordered`: ordered ⇒ RANGE UNBOUNDED PRECEDING AND CURRENT ROW,
13148/// unordered ⇒ ROWS UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
13149/// Single-bound shorthand (e.g. `ROWS 5 PRECEDING`) normalises
13150/// end → CURRENT ROW per the PG spec.
13151fn effective_frame(
13152    frame: Option<&WindowFrame>,
13153    ordered: bool,
13154) -> Result<(FrameKind, FrameBound, FrameBound), EngineError> {
13155    match frame {
13156        None => {
13157            if ordered {
13158                Ok((
13159                    FrameKind::Range,
13160                    FrameBound::UnboundedPreceding,
13161                    FrameBound::CurrentRow,
13162                ))
13163            } else {
13164                Ok((
13165                    FrameKind::Rows,
13166                    FrameBound::UnboundedPreceding,
13167                    FrameBound::UnboundedFollowing,
13168                ))
13169            }
13170        }
13171        Some(fr) => {
13172            let end = fr.end.clone().unwrap_or(FrameBound::CurrentRow);
13173            // Reject start > end (a few impossible combinations).
13174            if matches!(fr.start, FrameBound::UnboundedFollowing)
13175                || matches!(end, FrameBound::UnboundedPreceding)
13176            {
13177                return Err(EngineError::Unsupported(alloc::format!(
13178                    "invalid frame: start={:?} end={:?}",
13179                    fr.start,
13180                    end
13181                )));
13182            }
13183            // RANGE OFFSET PRECEDING / FOLLOWING needs value-typed
13184            // arithmetic on the ORDER BY key (e.g. `RANGE BETWEEN
13185            // INTERVAL '1 day' PRECEDING AND CURRENT ROW`). Not
13186            // implemented in v4.20.
13187            if fr.kind == FrameKind::Range
13188                && (matches!(
13189                    fr.start,
13190                    FrameBound::OffsetPreceding(_) | FrameBound::OffsetFollowing(_)
13191                ) || matches!(
13192                    end,
13193                    FrameBound::OffsetPreceding(_) | FrameBound::OffsetFollowing(_)
13194                ))
13195            {
13196                return Err(EngineError::Unsupported(
13197                    "RANGE with explicit offset bounds is not supported (v4.20: only UNBOUNDED / CURRENT ROW for RANGE)".into(),
13198                ));
13199            }
13200            Ok((fr.kind, fr.start.clone(), end))
13201        }
13202    }
13203}
13204
13205/// Compute `(lo, hi)` row-index bounds inside the partition slice
13206/// for the row at position `i`. Inclusive, clamped to
13207/// `[0, slice.len()-1]`. Empty result if `lo > hi`.
13208#[allow(clippy::type_complexity)]
13209fn frame_bounds_for_row(
13210    eff: &(FrameKind, FrameBound, FrameBound),
13211    i: usize,
13212    slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)],
13213) -> (usize, usize) {
13214    let (kind, start, end) = eff;
13215    let n = slice.len();
13216    let last = n.saturating_sub(1);
13217    let (mut lo, mut hi) = match kind {
13218        FrameKind::Rows => {
13219            let lo = match start {
13220                FrameBound::UnboundedPreceding => 0,
13221                FrameBound::OffsetPreceding(k) => {
13222                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
13223                    i.saturating_sub(k)
13224                }
13225                FrameBound::CurrentRow => i,
13226                FrameBound::OffsetFollowing(k) => {
13227                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
13228                    i.saturating_add(k).min(last)
13229                }
13230                FrameBound::UnboundedFollowing => last,
13231            };
13232            let hi = match end {
13233                FrameBound::UnboundedPreceding => 0,
13234                FrameBound::OffsetPreceding(k) => {
13235                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
13236                    i.saturating_sub(k)
13237                }
13238                FrameBound::CurrentRow => i,
13239                FrameBound::OffsetFollowing(k) => {
13240                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
13241                    i.saturating_add(k).min(last)
13242                }
13243                FrameBound::UnboundedFollowing => last,
13244            };
13245            (lo, hi)
13246        }
13247        FrameKind::Range => {
13248            // RANGE bounds are peer-aware. With only UNBOUNDED and
13249            // CURRENT ROW supported (rejected at effective_frame for
13250            // explicit offsets), the start/end map to the
13251            // partition's full extent at the same-order-key peer
13252            // group boundary.
13253            let lo = match start {
13254                FrameBound::UnboundedPreceding => 0,
13255                FrameBound::CurrentRow => peer_group_start(slice, i),
13256                FrameBound::UnboundedFollowing => last,
13257                _ => unreachable!("offset bounds rejected for RANGE"),
13258            };
13259            let hi = match end {
13260                FrameBound::UnboundedPreceding => 0,
13261                FrameBound::CurrentRow => peer_group_end(slice, i),
13262                FrameBound::UnboundedFollowing => last,
13263                _ => unreachable!("offset bounds rejected for RANGE"),
13264            };
13265            (lo, hi)
13266        }
13267    };
13268    if hi >= n {
13269        hi = last;
13270    }
13271    if lo >= n {
13272        lo = last;
13273    }
13274    (lo, hi)
13275}
13276
13277/// Find the inclusive index of the first row with the same ORDER
13278/// BY key as `slice[i]`. Slice is already sorted by partition then
13279/// order, so peers are contiguous.
13280#[allow(clippy::type_complexity)]
13281fn peer_group_start(slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)], i: usize) -> usize {
13282    let key = &slice[i].1;
13283    let mut j = i;
13284    while j > 0 && order_key_cmp(&slice[j - 1].1, key) == core::cmp::Ordering::Equal {
13285        j -= 1;
13286    }
13287    j
13288}
13289
13290/// Find the inclusive index of the last row with the same ORDER
13291/// BY key as `slice[i]`.
13292#[allow(clippy::type_complexity)]
13293fn peer_group_end(slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)], i: usize) -> usize {
13294    let key = &slice[i].1;
13295    let mut j = i;
13296    while j + 1 < slice.len() && order_key_cmp(&slice[j + 1].1, key) == core::cmp::Ordering::Equal {
13297        j += 1;
13298    }
13299    j
13300}
13301
13302fn value_to_f64(v: &Value) -> Option<f64> {
13303    match v {
13304        Value::SmallInt(n) => Some(f64::from(*n)),
13305        Value::Int(n) => Some(f64::from(*n)),
13306        #[allow(clippy::cast_precision_loss)]
13307        Value::BigInt(n) => Some(*n as f64),
13308        Value::Float(x) => Some(*x),
13309        _ => None,
13310    }
13311}
13312
13313/// Quick scan for any subquery-bearing node in a SELECT's WHERE /
13314/// projection / `order_by` — saves cloning the AST when there are
13315/// none (the common case).
13316fn expr_tree_has_subquery(stmt: &SelectStatement) -> bool {
13317    let mut any = false;
13318    for item in &stmt.items {
13319        if let SelectItem::Expr { expr, .. } = item {
13320            any = any || expr_has_subquery(expr);
13321        }
13322    }
13323    if let Some(w) = &stmt.where_ {
13324        any = any || expr_has_subquery(w);
13325    }
13326    if let Some(h) = &stmt.having {
13327        any = any || expr_has_subquery(h);
13328    }
13329    for o in &stmt.order_by {
13330        any = any || expr_has_subquery(&o.expr);
13331    }
13332    for (_, peer) in &stmt.unions {
13333        any = any || expr_tree_has_subquery(peer);
13334    }
13335    any
13336}
13337
13338fn expr_has_subquery(e: &Expr) -> bool {
13339    match e {
13340        Expr::ScalarSubquery(_) | Expr::Exists { .. } | Expr::InSubquery { .. } => true,
13341        Expr::Binary { lhs, rhs, .. } => expr_has_subquery(lhs) || expr_has_subquery(rhs),
13342        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13343            expr_has_subquery(expr)
13344        }
13345        Expr::FunctionCall { args, .. } => args.iter().any(expr_has_subquery),
13346        Expr::Like { expr, pattern, .. } => expr_has_subquery(expr) || expr_has_subquery(pattern),
13347        Expr::Extract { source, .. } => expr_has_subquery(source),
13348        Expr::WindowFunction {
13349            args,
13350            partition_by,
13351            order_by,
13352            ..
13353        } => {
13354            args.iter().any(expr_has_subquery)
13355                || partition_by.iter().any(expr_has_subquery)
13356                || order_by.iter().any(|(e, _)| expr_has_subquery(e))
13357        }
13358        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => false,
13359        Expr::Array(items) => items.iter().any(expr_has_subquery),
13360        Expr::ArraySubscript { target, index } => {
13361            expr_has_subquery(target) || expr_has_subquery(index)
13362        }
13363        Expr::AnyAll { expr, array, .. } => expr_has_subquery(expr) || expr_has_subquery(array),
13364        Expr::Case {
13365            operand,
13366            branches,
13367            else_branch,
13368        } => {
13369            operand.as_deref().is_some_and(expr_has_subquery)
13370                || branches
13371                    .iter()
13372                    .any(|(w, t)| expr_has_subquery(w) || expr_has_subquery(t))
13373                || else_branch.as_deref().is_some_and(expr_has_subquery)
13374        }
13375    }
13376}
13377
13378/// v4.10 helper: materialise a runtime `Value` back into an AST
13379/// `Expr::Literal` for the subquery-rewrite path. Supports the
13380/// types `Literal` can represent (Integer / Float / Text / Bool /
13381/// Null). Date / Timestamp / Numeric / Vector / Interval / JSON
13382/// would lose precision through Literal and aren't supported in
13383/// uncorrelated-subquery results; they error with a clear hint.
13384fn value_to_literal_expr(v: Value) -> Result<Expr, EngineError> {
13385    let lit = match v {
13386        Value::Null => Literal::Null,
13387        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
13388        Value::Int(n) => Literal::Integer(i64::from(n)),
13389        Value::BigInt(n) => Literal::Integer(n),
13390        Value::Float(x) => Literal::Float(x),
13391        Value::Text(s) | Value::Json(s) => Literal::String(s),
13392        Value::Bool(b) => Literal::Bool(b),
13393        other => {
13394            return Err(EngineError::Unsupported(alloc::format!(
13395                "subquery result type {:?} not yet materialisable; cast to text or integer in the inner SELECT",
13396                other.data_type()
13397            )));
13398        }
13399    };
13400    Ok(Expr::Literal(lit))
13401}
13402
13403/// v7.13.0 — wider helper used by `INSERT … SELECT` (mailrs
13404/// round-5 G4). Covers the most common `Value` variants. Types
13405/// that need lossy textual round-trip (BYTEA, arrays, ts*)
13406/// surface as an Unsupported error so the caller can add a cast
13407/// in the inner SELECT.
13408fn value_to_literal_expr_permissive(v: Value) -> Result<Expr, EngineError> {
13409    let lit = match v {
13410        Value::Null => Literal::Null,
13411        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
13412        Value::Int(n) => Literal::Integer(i64::from(n)),
13413        Value::BigInt(n) => Literal::Integer(n),
13414        Value::Float(x) => Literal::Float(x),
13415        Value::Text(s) | Value::Json(s) => Literal::String(s),
13416        Value::Bool(b) => Literal::Bool(b),
13417        Value::Vector(xs) => Literal::Vector(xs),
13418        // Date / Timestamp / Timestamptz / Numeric round-trip
13419        // through a TEXT literal that `coerce_value` re-parses
13420        // against the target column type.
13421        Value::Date(days) => {
13422            let micros = (i64::from(days)) * 86_400_000_000;
13423            Literal::String(format_timestamp_micros_as_date(micros))
13424        }
13425        Value::Timestamp(us) => Literal::String(format_timestamp_micros(us)),
13426        Value::Numeric { scaled, scale } => Literal::String(format_numeric(scaled, scale)),
13427        other => {
13428            return Err(EngineError::Unsupported(alloc::format!(
13429                "INSERT … SELECT cannot materialise value of type {:?}; \
13430                 add an explicit CAST in the inner SELECT",
13431                other.data_type()
13432            )));
13433        }
13434    };
13435    Ok(Expr::Literal(lit))
13436}
13437
13438fn format_timestamp_micros(us: i64) -> String {
13439    // Same Y/M/D split used by the wire layer; epoch-relative.
13440    let days = us.div_euclid(86_400_000_000);
13441    let intra_day = us.rem_euclid(86_400_000_000);
13442    let date = format_timestamp_micros_as_date(days * 86_400_000_000);
13443    let secs = intra_day / 1_000_000;
13444    let us_rem = intra_day % 1_000_000;
13445    let h = (secs / 3600) % 24;
13446    let m = (secs / 60) % 60;
13447    let s = secs % 60;
13448    if us_rem == 0 {
13449        alloc::format!("{date} {h:02}:{m:02}:{s:02}")
13450    } else {
13451        alloc::format!("{date} {h:02}:{m:02}:{s:02}.{us_rem:06}")
13452    }
13453}
13454
13455fn format_timestamp_micros_as_date(us: i64) -> String {
13456    // Days since 1970-01-01 → calendar Y-M-D via the proleptic
13457    // Gregorian conversion used by spg-engine's date helpers.
13458    let days = us.div_euclid(86_400_000_000);
13459    // 1970-01-01 = JDN 2440588.
13460    let jdn = days + 2_440_588;
13461    let (y, mo, d) = jdn_to_ymd(jdn);
13462    alloc::format!("{y:04}-{mo:02}-{d:02}")
13463}
13464
13465fn jdn_to_ymd(jdn: i64) -> (i64, u32, u32) {
13466    // Fliegel & Van Flandern (1968) — works for all positive JDNs.
13467    let l = jdn + 68569;
13468    let n = (4 * l) / 146_097;
13469    let l = l - (146_097 * n + 3) / 4;
13470    let i = (4000 * (l + 1)) / 1_461_001;
13471    let l = l - (1461 * i) / 4 + 31;
13472    let j = (80 * l) / 2447;
13473    let day = (l - (2447 * j) / 80) as u32;
13474    let l = j / 11;
13475    let month = (j + 2 - 12 * l) as u32;
13476    let year = 100 * (n - 49) + i + l;
13477    (year, month, day)
13478}
13479
13480fn format_numeric(scaled: i128, scale: u8) -> String {
13481    if scale == 0 {
13482        return alloc::format!("{scaled}");
13483    }
13484    let abs = scaled.unsigned_abs();
13485    let divisor = 10u128.pow(u32::from(scale));
13486    let whole = abs / divisor;
13487    let frac = abs % divisor;
13488    let sign = if scaled < 0 { "-" } else { "" };
13489    alloc::format!("{sign}{whole}.{frac:0width$}", width = usize::from(scale))
13490}
13491
13492/// v6.1.1 — walk the prepared `Statement` AST and replace every
13493/// `Expr::Placeholder(n)` with `Expr::Literal(value_to_literal(
13494/// params[n-1]))`. The dispatch downstream sees a `Statement`
13495/// indistinguishable from a simple-query parse, so the exec path
13496/// stays unchanged.
13497///
13498/// Errors fall into one shape: a `$N` references past the bound
13499/// `params.len()`. Out-of-range happens when the Bind didn't
13500/// supply enough values; pgwire surfaces this as a protocol error
13501/// to the client.
13502/// v7.15.0 — rewrite every (potentially-qualified) column
13503/// identifier matching `old` to `new` in a stored SQL source
13504/// string. Used by `ALTER TABLE … RENAME COLUMN` to patch
13505/// CHECK predicate sources, partial-index predicate sources,
13506/// and runtime DEFAULT expression sources before they get
13507/// re-parsed on the next INSERT/UPDATE.
13508///
13509/// Round-trips through the parser, so the rewritten output is
13510/// the canonical Display form (matches what the engine stores
13511/// for fresh predicates). If the source doesn't parse, surfaces
13512/// the parse error — the invariant that stored predicates are
13513/// in canonical Display form means a parse failure here is a
13514/// real bug, not a user mistake to swallow.
13515fn rewrite_column_in_source(
13516    src: &str,
13517    old: &str,
13518    new: &str,
13519) -> Result<alloc::string::String, EngineError> {
13520    let mut expr = spg_sql::parser::parse_expression(src).map_err(|e| {
13521        EngineError::Unsupported(alloc::format!(
13522            "ALTER TABLE RENAME COLUMN: stored predicate source {src:?} \
13523             failed to parse for rewrite ({e})"
13524        ))
13525    })?;
13526    rewrite_column_in_expr(&mut expr, old, new);
13527    Ok(alloc::format!("{expr}"))
13528}
13529
13530/// v7.15.0 — Expr walker that swaps `Expr::Column { name: old, .. }`
13531/// for `Expr::Column { name: new, .. }`. Qualifier is preserved
13532/// (e.g. `t.old` → `t.new`); a foreign-table qualifier still
13533/// gets rewritten because the AST has no way to tell us this
13534/// predicate is on table T versus table T2 — predicate sources
13535/// in SPG are always scoped to the owning table, so any
13536/// qualifier present is either redundant or wrong.
13537fn rewrite_column_in_expr(e: &mut Expr, old: &str, new: &str) {
13538    match e {
13539        Expr::Column(c) => {
13540            if c.name.eq_ignore_ascii_case(old) {
13541                c.name = new.to_string();
13542            }
13543        }
13544        Expr::Binary { lhs, rhs, .. } => {
13545            rewrite_column_in_expr(lhs, old, new);
13546            rewrite_column_in_expr(rhs, old, new);
13547        }
13548        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13549            rewrite_column_in_expr(expr, old, new);
13550        }
13551        Expr::FunctionCall { args, .. } => {
13552            for a in args {
13553                rewrite_column_in_expr(a, old, new);
13554            }
13555        }
13556        Expr::Like { expr, pattern, .. } => {
13557            rewrite_column_in_expr(expr, old, new);
13558            rewrite_column_in_expr(pattern, old, new);
13559        }
13560        Expr::Extract { source, .. } => rewrite_column_in_expr(source, old, new),
13561        Expr::WindowFunction {
13562            args,
13563            partition_by,
13564            order_by,
13565            ..
13566        } => {
13567            for a in args {
13568                rewrite_column_in_expr(a, old, new);
13569            }
13570            for p in partition_by {
13571                rewrite_column_in_expr(p, old, new);
13572            }
13573            for (o, _) in order_by {
13574                rewrite_column_in_expr(o, old, new);
13575            }
13576        }
13577        Expr::Array(items) => {
13578            for elem in items {
13579                rewrite_column_in_expr(elem, old, new);
13580            }
13581        }
13582        Expr::ArraySubscript { target, index } => {
13583            rewrite_column_in_expr(target, old, new);
13584            rewrite_column_in_expr(index, old, new);
13585        }
13586        Expr::AnyAll { expr, array, .. } => {
13587            rewrite_column_in_expr(expr, old, new);
13588            rewrite_column_in_expr(array, old, new);
13589        }
13590        Expr::Case {
13591            operand,
13592            branches,
13593            else_branch,
13594        } => {
13595            if let Some(o) = operand {
13596                rewrite_column_in_expr(o, old, new);
13597            }
13598            for (w, t) in branches {
13599                rewrite_column_in_expr(w, old, new);
13600                rewrite_column_in_expr(t, old, new);
13601            }
13602            if let Some(e) = else_branch {
13603                rewrite_column_in_expr(e, old, new);
13604            }
13605        }
13606        // Stored predicate sources never contain subqueries —
13607        // CHECK / partial-index / runtime_default are all scalar.
13608        // If a future feature changes that, recurse here.
13609        Expr::ScalarSubquery(_) | Expr::Exists { .. } | Expr::InSubquery { .. } => {}
13610        Expr::Literal(_) | Expr::Placeholder(_) => {}
13611    }
13612}
13613
13614/// v7.16.0 — walks a parsed statement and replaces every
13615/// `Expr::Placeholder(N)` with the corresponding `params[N-1]`
13616/// re-encoded as an `Expr::Literal`. Used internally by
13617/// `Engine::execute_prepared` AND surfaced for the spg-embedded
13618/// WAL path (which needs the bind-final AST so replay sees a
13619/// simple-query-shaped statement, not a `$1`-shaped one). Errors
13620/// when a placeholder references an index past the params slice.
13621pub fn substitute_placeholders(stmt: &mut Statement, params: &[Value]) -> Result<(), EngineError> {
13622    match stmt {
13623        Statement::Select(s) => substitute_select(s, params)?,
13624        Statement::Insert(ins) => {
13625            for row in &mut ins.rows {
13626                for e in row {
13627                    substitute_expr(e, params)?;
13628                }
13629            }
13630            // ON CONFLICT DO UPDATE assignments / WHERE can carry
13631            // placeholders too (`… DO UPDATE SET reason = $2` —
13632            // mailrs embed round-12).
13633            if let Some(clause) = &mut ins.on_conflict
13634                && let spg_sql::ast::OnConflictAction::Update {
13635                    assignments,
13636                    where_,
13637                } = &mut clause.action
13638            {
13639                for (_, e) in assignments.iter_mut() {
13640                    substitute_expr(e, params)?;
13641                }
13642                if let Some(w) = where_ {
13643                    substitute_expr(w, params)?;
13644                }
13645            }
13646        }
13647        Statement::Update(u) => {
13648            for (_, e) in &mut u.assignments {
13649                substitute_expr(e, params)?;
13650            }
13651            if let Some(w) = &mut u.where_ {
13652                substitute_expr(w, params)?;
13653            }
13654        }
13655        Statement::Delete(d) => {
13656            if let Some(w) = &mut d.where_ {
13657                substitute_expr(w, params)?;
13658            }
13659        }
13660        Statement::Explain(e) => substitute_select(&mut e.inner, params)?,
13661        // Other statements (CREATE / BEGIN / SHOW / …) have no
13662        // expression slots; no walk needed.
13663        _ => {}
13664    }
13665    Ok(())
13666}
13667
13668fn substitute_select(s: &mut SelectStatement, params: &[Value]) -> Result<(), EngineError> {
13669    for item in &mut s.items {
13670        if let SelectItem::Expr { expr, .. } = item {
13671            substitute_expr(expr, params)?;
13672        }
13673    }
13674    if let Some(w) = &mut s.where_ {
13675        substitute_expr(w, params)?;
13676    }
13677    if let Some(gs) = &mut s.group_by {
13678        for g in gs {
13679            substitute_expr(g, params)?;
13680        }
13681    }
13682    if let Some(h) = &mut s.having {
13683        substitute_expr(h, params)?;
13684    }
13685    for o in &mut s.order_by {
13686        substitute_expr(&mut o.expr, params)?;
13687    }
13688    for (_, peer) in &mut s.unions {
13689        substitute_select(peer, params)?;
13690    }
13691    // v7.9.24 — LIMIT $N / OFFSET $N placeholder resolution.
13692    // mailrs H2. After this pass each LIMIT/OFFSET that was a
13693    // Placeholder is rewritten to Literal so the existing
13694    // `LimitExpr::as_literal` path consumes a concrete u32.
13695    if let Some(le) = s.limit {
13696        s.limit = Some(resolve_limit_placeholder(le, params)?);
13697    }
13698    if let Some(le) = s.offset {
13699        s.offset = Some(resolve_limit_placeholder(le, params)?);
13700    }
13701    Ok(())
13702}
13703
13704fn resolve_limit_placeholder(
13705    le: spg_sql::ast::LimitExpr,
13706    params: &[Value],
13707) -> Result<spg_sql::ast::LimitExpr, EngineError> {
13708    use spg_sql::ast::LimitExpr;
13709    match le {
13710        LimitExpr::Literal(_) => Ok(le),
13711        LimitExpr::Placeholder(n) => {
13712            let idx = usize::from(n).saturating_sub(1);
13713            let v = params.get(idx).ok_or_else(|| {
13714                EngineError::Eval(EvalError::PlaceholderOutOfRange {
13715                    n,
13716                    bound: u16::try_from(params.len()).unwrap_or(u16::MAX),
13717                })
13718            })?;
13719            let int = match v {
13720                Value::SmallInt(x) => Some(i64::from(*x)),
13721                Value::Int(x) => Some(i64::from(*x)),
13722                Value::BigInt(x) => Some(*x),
13723                _ => None,
13724            }
13725            .ok_or_else(|| {
13726                EngineError::Unsupported(alloc::format!(
13727                    "LIMIT/OFFSET ${n} bound to non-integer {v:?}"
13728                ))
13729            })?;
13730            if int < 0 {
13731                return Err(EngineError::Unsupported(alloc::format!(
13732                    "LIMIT/OFFSET ${n} bound to negative value {int}"
13733                )));
13734            }
13735            let bounded = u32::try_from(int).map_err(|_| {
13736                EngineError::Unsupported(alloc::format!(
13737                    "LIMIT/OFFSET ${n} value {int} exceeds u32 range"
13738                ))
13739            })?;
13740            Ok(LimitExpr::Literal(bounded))
13741        }
13742    }
13743}
13744
13745fn substitute_expr(e: &mut Expr, params: &[Value]) -> Result<(), EngineError> {
13746    if let Expr::Placeholder(n) = e {
13747        let idx = usize::from(*n).saturating_sub(1);
13748        let v = params.get(idx).ok_or_else(|| {
13749            EngineError::Eval(EvalError::PlaceholderOutOfRange {
13750                n: *n,
13751                bound: u16::try_from(params.len()).unwrap_or(u16::MAX),
13752            })
13753        })?;
13754        *e = Expr::Literal(value_to_literal(v.clone()));
13755        return Ok(());
13756    }
13757    match e {
13758        Expr::Binary { lhs, rhs, .. } => {
13759            substitute_expr(lhs, params)?;
13760            substitute_expr(rhs, params)?;
13761        }
13762        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13763            substitute_expr(expr, params)?;
13764        }
13765        Expr::FunctionCall { args, .. } => {
13766            for a in args {
13767                substitute_expr(a, params)?;
13768            }
13769        }
13770        Expr::Like { expr, pattern, .. } => {
13771            substitute_expr(expr, params)?;
13772            substitute_expr(pattern, params)?;
13773        }
13774        Expr::Extract { source, .. } => substitute_expr(source, params)?,
13775        Expr::ScalarSubquery(s) => substitute_select(s, params)?,
13776        Expr::Exists { subquery, .. } => substitute_select(subquery, params)?,
13777        Expr::InSubquery { expr, subquery, .. } => {
13778            substitute_expr(expr, params)?;
13779            substitute_select(subquery, params)?;
13780        }
13781        Expr::WindowFunction {
13782            args,
13783            partition_by,
13784            order_by,
13785            ..
13786        } => {
13787            for a in args {
13788                substitute_expr(a, params)?;
13789            }
13790            for p in partition_by {
13791                substitute_expr(p, params)?;
13792            }
13793            for (e, _) in order_by {
13794                substitute_expr(e, params)?;
13795            }
13796        }
13797        Expr::Literal(_) | Expr::Column(_) => {}
13798        // Already handled above.
13799        Expr::Placeholder(_) => unreachable!("Placeholder handled at top of fn"),
13800        Expr::Array(items) => {
13801            for elem in items {
13802                substitute_expr(elem, params)?;
13803            }
13804        }
13805        Expr::ArraySubscript { target, index } => {
13806            substitute_expr(target, params)?;
13807            substitute_expr(index, params)?;
13808        }
13809        Expr::AnyAll { expr, array, .. } => {
13810            substitute_expr(expr, params)?;
13811            substitute_expr(array, params)?;
13812        }
13813        Expr::Case {
13814            operand,
13815            branches,
13816            else_branch,
13817        } => {
13818            if let Some(o) = operand {
13819                substitute_expr(o, params)?;
13820            }
13821            for (w, t) in branches {
13822                substitute_expr(w, params)?;
13823                substitute_expr(t, params)?;
13824            }
13825            if let Some(e) = else_branch {
13826                substitute_expr(e, params)?;
13827            }
13828        }
13829    }
13830    Ok(())
13831}
13832
13833/// v6.1.1 — convert a runtime `Value` into the closest matching
13834/// `Literal` for the substitute walker. Lossless for the simple
13835/// scalars (Int / Float / Text / Bool); Numeric / Date / Timestamp
13836/// / Json / Interval render as their canonical text form so the
13837/// downstream coerce_value can re-parse against the target column
13838/// type. SQ8 / HalfVector cells are NOT expected as bind params;
13839/// pgwire's Bind decodes vector params to the f32 representation
13840/// before they reach this helper.
13841/// v6.2.0 — total ordering on `Value`s used by ANALYZE to sort a
13842/// column's non-NULL sample before histogram building. Cross-type
13843/// pairs (Int vs Float, Date vs Timestamp, …) compare via the
13844/// same widening the eval-side `compare` operator uses; everything
13845/// else (the genuinely-incompatible pairs) falls back to ordering
13846/// by canonical string form so the sort is still total + stable.
13847/// Vector / SQ8 / Half / Json / Numeric / Interval values reach
13848/// here only via the string-fallback path because vector columns
13849/// are filtered out upstream.
13850fn sort_values_for_histogram(a: &Value, b: &Value) -> core::cmp::Ordering {
13851    use core::cmp::Ordering;
13852    match (a, b) {
13853        (Value::SmallInt(a), Value::SmallInt(b)) => a.cmp(b),
13854        (Value::Int(a), Value::Int(b)) => a.cmp(b),
13855        (Value::BigInt(a), Value::BigInt(b)) => a.cmp(b),
13856        (Value::SmallInt(a), Value::Int(b)) => i32::from(*a).cmp(b),
13857        (Value::Int(a), Value::SmallInt(b)) => a.cmp(&i32::from(*b)),
13858        (Value::Int(a), Value::BigInt(b)) => i64::from(*a).cmp(b),
13859        (Value::BigInt(a), Value::Int(b)) => a.cmp(&i64::from(*b)),
13860        (Value::SmallInt(a), Value::BigInt(b)) => i64::from(*a).cmp(b),
13861        (Value::BigInt(a), Value::SmallInt(b)) => a.cmp(&i64::from(*b)),
13862        (Value::Float(a), Value::Float(b)) => a.partial_cmp(b).unwrap_or(Ordering::Equal),
13863        (Value::Text(a), Value::Text(b)) | (Value::Json(a), Value::Json(b)) => a.cmp(b),
13864        (Value::Bool(a), Value::Bool(b)) => a.cmp(b),
13865        (Value::Date(a), Value::Date(b)) => a.cmp(b),
13866        (Value::Timestamp(a), Value::Timestamp(b)) => a.cmp(b),
13867        // Mixed numeric/float — widen to f64 and compare.
13868        (Value::SmallInt(n), Value::Float(x)) => {
13869            (f64::from(*n)).partial_cmp(x).unwrap_or(Ordering::Equal)
13870        }
13871        (Value::Float(x), Value::SmallInt(n)) => {
13872            x.partial_cmp(&f64::from(*n)).unwrap_or(Ordering::Equal)
13873        }
13874        (Value::Int(n), Value::Float(x)) => {
13875            (f64::from(*n)).partial_cmp(x).unwrap_or(Ordering::Equal)
13876        }
13877        (Value::Float(x), Value::Int(n)) => {
13878            x.partial_cmp(&f64::from(*n)).unwrap_or(Ordering::Equal)
13879        }
13880        (Value::BigInt(n), Value::Float(x)) => {
13881            #[allow(clippy::cast_precision_loss)]
13882            let nf = *n as f64;
13883            nf.partial_cmp(x).unwrap_or(Ordering::Equal)
13884        }
13885        (Value::Float(x), Value::BigInt(n)) => {
13886            #[allow(clippy::cast_precision_loss)]
13887            let nf = *n as f64;
13888            x.partial_cmp(&nf).unwrap_or(Ordering::Equal)
13889        }
13890        // Cross-type fallback: lexicographic on canonical form.
13891        // Total + stable so the sort is well-defined.
13892        _ => canonical_value_repr(a).cmp(&canonical_value_repr(b)),
13893    }
13894}
13895
13896/// v6.2.0 — render the histogram bounds list as a `[v0, v1, ...]`
13897/// string for the `spg_statistic.histogram_bounds` column. Values
13898/// containing `,` or `[` / `]` are JSON-style escaped so the
13899/// rendering round-trips through a future parser; v6.2.0 only
13900/// uses the rendered form for human consumption, so the escaping
13901/// is conservative.
13902fn render_histogram_bounds(bounds: &[alloc::string::String]) -> alloc::string::String {
13903    let mut out = alloc::string::String::with_capacity(bounds.len() * 8 + 2);
13904    out.push('[');
13905    for (i, b) in bounds.iter().enumerate() {
13906        if i > 0 {
13907            out.push_str(", ");
13908        }
13909        let needs_quote = b.contains([',', '[', ']', '"']) || b.is_empty();
13910        if needs_quote {
13911            out.push('"');
13912            for ch in b.chars() {
13913                if ch == '"' || ch == '\\' {
13914                    out.push('\\');
13915                }
13916                out.push(ch);
13917            }
13918            out.push('"');
13919        } else {
13920            out.push_str(b);
13921        }
13922    }
13923    out.push(']');
13924    out
13925}
13926
13927/// v6.2.0 — canonical textual form of a `Value` for histogram
13928/// bound storage. Strings used by ANALYZE for sort + bound output.
13929/// INT / BIGINT → decimal; FLOAT → shortest-round-trip via
13930/// `{:?}`; TEXT pass-through; BOOL → `t` / `f`; DATE / TIMESTAMP →
13931/// the same form `format_date` / `format_timestamp` produce for
13932/// SQL Display. Vector / SQ8 / Half / Json / Numeric / Interval
13933/// reach this only via a non-Vector column (vector columns are
13934/// skipped upstream); they fall back to a Debug-derived form so
13935/// stats still serialise without crashing.
13936pub(crate) fn canonical_value_repr(v: &Value) -> alloc::string::String {
13937    match v {
13938        Value::Null => "NULL".to_string(),
13939        Value::SmallInt(n) => alloc::format!("{n}"),
13940        Value::Int(n) => alloc::format!("{n}"),
13941        Value::BigInt(n) => alloc::format!("{n}"),
13942        Value::Float(x) => alloc::format!("{x:?}"),
13943        Value::Text(s) | Value::Json(s) => s.clone(),
13944        Value::Bool(b) => if *b { "t" } else { "f" }.to_string(),
13945        Value::Date(d) => eval::format_date(*d),
13946        Value::Timestamp(t) => eval::format_timestamp(*t),
13947        // v7.17.0 Phase 3.P0-32 — PG TIME canonical text form.
13948        Value::Time(us) => eval::format_time(*us),
13949        // v7.17.0 Phase 3.P0-33 — MySQL YEAR 4-digit zero-padded.
13950        Value::Year(y) => alloc::format!("{y:04}"),
13951        // v7.17.0 Phase 3.P0-34 — PG TIMETZ canonical text form.
13952        Value::TimeTz { us, offset_secs } => eval::format_timetz(*us, *offset_secs),
13953        // v7.17.0 Phase 3.P0-35 — PG MONEY canonical en_US text form.
13954        Value::Money(c) => eval::format_money(*c),
13955        // v7.17.0 Phase 3.P0-38 — PG range canonical text form.
13956        v @ Value::Range { .. } => format_range_str(v),
13957        // v7.17.0 Phase 3.P0-39 — PG hstore canonical text form.
13958        Value::Hstore(pairs) => format_hstore_str(pairs),
13959        // v7.17.0 Phase 3.P0-40 — 2D array canonical text form.
13960        Value::IntArray2D(rows) => format_int_2d_text(rows),
13961        Value::BigIntArray2D(rows) => format_bigint_2d_text(rows),
13962        Value::TextArray2D(rows) => format_text_2d_text(rows),
13963        Value::Interval { months, micros } => eval::format_interval(*months, *micros),
13964        Value::Numeric { scaled, scale } => eval::format_numeric(*scaled, *scale),
13965        Value::Vector(_) | Value::Sq8Vector(_) | Value::HalfVector(_) => {
13966            // Unreachable in practice (vector columns are filtered
13967            // out before this). Defensive fallback so a future
13968            // vector-stats path doesn't crash.
13969            alloc::format!("{v:?}")
13970        }
13971        // v7.5.0 — Value is #[non_exhaustive] for downstream
13972        // forward-compat. Future variants fall through to Debug
13973        // form here (same shape as the vector fallback above).
13974        _ => alloc::format!("{v:?}"),
13975    }
13976}
13977
13978/// v6.2.0 — true for engine-managed catalog tables that the bare
13979/// `ANALYZE` (no target) should skip. v6.2.0 has no internal
13980/// tables yet (publications / subscriptions / users / statistics
13981/// all live as engine fields, not catalog tables), so this is a
13982/// reserved future-proofing hook — every existing user table is
13983/// analysed.
13984const fn is_internal_table_name(_name: &str) -> bool {
13985    false
13986}
13987
13988fn value_to_literal(v: Value) -> Literal {
13989    match v {
13990        Value::Null => Literal::Null,
13991        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
13992        Value::Int(n) => Literal::Integer(i64::from(n)),
13993        Value::BigInt(n) => Literal::Integer(n),
13994        Value::Float(x) => Literal::Float(x),
13995        Value::Text(s) | Value::Json(s) => Literal::String(s),
13996        Value::Bool(b) => Literal::Bool(b),
13997        Value::Vector(v) => Literal::Vector(v),
13998        Value::Numeric { scaled, scale } => Literal::String(eval::format_numeric(scaled, scale)),
13999        Value::Date(d) => Literal::String(eval::format_date(d)),
14000        Value::Timestamp(t) => Literal::String(eval::format_timestamp(t)),
14001        // v7.17.0 Phase 3.P0-69 — UUID round-trips via canonical
14002        // hyphenated text. Without this arm the fallback below
14003        // renders `Debug` form ("Uuid([85, …])") which the
14004        // engine's Text → Uuid coerce can't parse, breaking
14005        // prepared-bind round-trip from the spg-sqlx adapter.
14006        Value::Uuid(b) => Literal::String(spg_storage::format_uuid(&b)),
14007        // v7.16.0 — BYTEA round-trip for the spg-sqlx Bind path.
14008        // PG-canonical text rep is `\x` + lowercase hex; the
14009        // engine's coerce_value already accepts that on the
14010        // text → bytea direction.
14011        Value::Bytes(b) => Literal::String(eval::format_bytea_hex(&b)),
14012        // Arrays ride the AST natively (mailrs embed round-12) —
14013        // the prior `{a,b,c}` text form only worked where a column
14014        // type drove the re-parse; `= ANY($1)` has no column
14015        // context and saw a bare Text value.
14016        Value::TextArray(items) => Literal::TextArray(items),
14017        Value::IntArray(items) => Literal::IntArray(items),
14018        Value::BigIntArray(items) => Literal::BigIntArray(items),
14019        Value::Interval { months, micros } => Literal::Interval {
14020            months,
14021            micros,
14022            text: eval::format_interval(months, micros),
14023        },
14024        // SQ8 / halfvec cells dequantise to f32 before reaching the
14025        // substitute walker; pgwire's Bind path handles that.
14026        Value::Sq8Vector(q) => Literal::Vector(spg_storage::quantize::dequantize(&q)),
14027        Value::HalfVector(h) => Literal::Vector(h.to_f32_vec()),
14028        // v7.5.0 — Value is #[non_exhaustive]; future variants
14029        // render as Debug-form String literal until explicit
14030        // mapping is added.
14031        v => Literal::String(alloc::format!("{v:?}")),
14032    }
14033}
14034
14035fn rewrite_clock_calls(stmt: &mut Statement, now_micros: Option<i64>) {
14036    let Some(now) = now_micros else {
14037        return;
14038    };
14039    match stmt {
14040        Statement::Select(s) => rewrite_select_clock(s, now),
14041        Statement::Insert(ins) => {
14042            for row in &mut ins.rows {
14043                for e in row {
14044                    rewrite_expr_clock(e, now);
14045                }
14046            }
14047            // `ON CONFLICT … DO UPDATE SET created_at = NOW()` —
14048            // the upsert assignments carry clock calls too (mailrs
14049            // embed round-12).
14050            if let Some(clause) = &mut ins.on_conflict
14051                && let spg_sql::ast::OnConflictAction::Update {
14052                    assignments,
14053                    where_,
14054                } = &mut clause.action
14055            {
14056                for (_, e) in assignments.iter_mut() {
14057                    rewrite_expr_clock(e, now);
14058                }
14059                if let Some(w) = where_ {
14060                    rewrite_expr_clock(w, now);
14061                }
14062            }
14063        }
14064        // `UPDATE … SET seen_at = NOW() WHERE …` / `DELETE … WHERE
14065        // ts < NOW()` (mailrs embed round-12 — previously only
14066        // SELECT / INSERT-rows were walked).
14067        Statement::Update(u) => {
14068            for (_, e) in &mut u.assignments {
14069                rewrite_expr_clock(e, now);
14070            }
14071            if let Some(w) = &mut u.where_ {
14072                rewrite_expr_clock(w, now);
14073            }
14074        }
14075        Statement::Delete(d) => {
14076            if let Some(w) = &mut d.where_ {
14077                rewrite_expr_clock(w, now);
14078            }
14079        }
14080        _ => {}
14081    }
14082}
14083
14084fn rewrite_select_clock(s: &mut SelectStatement, now: i64) {
14085    for item in &mut s.items {
14086        if let SelectItem::Expr { expr, .. } = item {
14087            rewrite_expr_clock(expr, now);
14088        }
14089    }
14090    if let Some(w) = &mut s.where_ {
14091        rewrite_expr_clock(w, now);
14092    }
14093    if let Some(gs) = &mut s.group_by {
14094        for g in gs {
14095            rewrite_expr_clock(g, now);
14096        }
14097    }
14098    if let Some(h) = &mut s.having {
14099        rewrite_expr_clock(h, now);
14100    }
14101    for o in &mut s.order_by {
14102        rewrite_expr_clock(&mut o.expr, now);
14103    }
14104    for (_, peer) in &mut s.unions {
14105        rewrite_select_clock(peer, now);
14106    }
14107}
14108
14109/// v3.0.3 hot path: every recursion lands in exactly one `match` arm.
14110/// Literal / Column-with-qualifier (the dominant cases on a typical
14111/// AST) take a single pattern dispatch and exit. The clock-rewrite
14112/// targets (zero-arg `NOW` / `CURRENT_TIMESTAMP` / `CURRENT_DATE`
14113/// functions, and bare `CURRENT_TIMESTAMP` / `CURRENT_DATE` column
14114/// refs) sit on their own arms with match guards so the fall-through
14115/// to the recursive arms is unambiguous.
14116fn rewrite_expr_clock(e: &mut Expr, now: i64) {
14117    // Fast-path test on the no-recursion shapes first. We can't fold
14118    // them into the big match below because they need to *replace* `e`
14119    // outright; the recursive arms below match on its sub-fields.
14120    if let Some(replacement) = clock_replacement_for(e, now) {
14121        *e = replacement;
14122        return;
14123    }
14124    match e {
14125        Expr::Binary { lhs, rhs, .. } => {
14126            rewrite_expr_clock(lhs, now);
14127            rewrite_expr_clock(rhs, now);
14128        }
14129        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
14130            rewrite_expr_clock(expr, now);
14131        }
14132        Expr::FunctionCall { args, .. } => {
14133            for a in args {
14134                rewrite_expr_clock(a, now);
14135            }
14136        }
14137        Expr::Like { expr, pattern, .. } => {
14138            rewrite_expr_clock(expr, now);
14139            rewrite_expr_clock(pattern, now);
14140        }
14141        Expr::Extract { source, .. } => rewrite_expr_clock(source, now),
14142        // v4.10 subquery nodes — recurse into the inner SELECT's
14143        // expression slots so e.g. SELECT NOW() in a scalar
14144        // subquery picks up the same instant as the outer query.
14145        Expr::ScalarSubquery(s) => rewrite_select_clock(s, now),
14146        Expr::Exists { subquery, .. } => rewrite_select_clock(subquery, now),
14147        Expr::InSubquery { expr, subquery, .. } => {
14148            rewrite_expr_clock(expr, now);
14149            rewrite_select_clock(subquery, now);
14150        }
14151        // v4.12 window functions — args + PARTITION BY + ORDER BY
14152        // may all reference clock literals.
14153        Expr::WindowFunction {
14154            args,
14155            partition_by,
14156            order_by,
14157            ..
14158        } => {
14159            for a in args {
14160                rewrite_expr_clock(a, now);
14161            }
14162            for p in partition_by {
14163                rewrite_expr_clock(p, now);
14164            }
14165            for (e, _) in order_by {
14166                rewrite_expr_clock(e, now);
14167            }
14168        }
14169        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => {}
14170        Expr::Array(items) => {
14171            for elem in items {
14172                rewrite_expr_clock(elem, now);
14173            }
14174        }
14175        Expr::ArraySubscript { target, index } => {
14176            rewrite_expr_clock(target, now);
14177            rewrite_expr_clock(index, now);
14178        }
14179        Expr::AnyAll { expr, array, .. } => {
14180            rewrite_expr_clock(expr, now);
14181            rewrite_expr_clock(array, now);
14182        }
14183        Expr::Case {
14184            operand,
14185            branches,
14186            else_branch,
14187        } => {
14188            if let Some(o) = operand {
14189                rewrite_expr_clock(o, now);
14190            }
14191            for (w, t) in branches {
14192                rewrite_expr_clock(w, now);
14193                rewrite_expr_clock(t, now);
14194            }
14195            if let Some(e) = else_branch {
14196                rewrite_expr_clock(e, now);
14197            }
14198        }
14199    }
14200}
14201
14202/// Returns `Some(Expr)` when `e` is one of the clock-call shapes that
14203/// must be rewritten; otherwise `None` so the caller falls through to
14204/// the recursive walk. Identifies both function-call forms (`NOW()` /
14205/// `CURRENT_TIMESTAMP()` / `CURRENT_DATE()`) and bare-identifier forms
14206/// (`CURRENT_TIMESTAMP` / `CURRENT_DATE` as unqualified column refs,
14207/// which is how PG accepts them without parens).
14208fn clock_replacement_for(e: &Expr, now: i64) -> Option<Expr> {
14209    let (kind, name) = match e {
14210        Expr::FunctionCall { name, args } if args.is_empty() => (ClockSite::Fn, name.as_str()),
14211        Expr::Column(c) if c.qualifier.is_none() => (ClockSite::BareIdent, c.name.as_str()),
14212        _ => return None,
14213    };
14214    // ASCII case-insensitive name match. Each entry decides what
14215    // synthetic literal the call expands to.
14216    //
14217    // v7.17.0 Phase 3.P0-29 — `unix_timestamp` (no args) joins this
14218    // table as MySQL's epoch-seconds equivalent of `now()`. Folded
14219    // to a BigInt literal here so apply_function never needs a
14220    // clock dependency.
14221    enum ClockShape {
14222        Timestamp,
14223        Date,
14224        UnixSeconds,
14225    }
14226    let shape = match name.len() {
14227        3 if kind == ClockSite::Fn && name.eq_ignore_ascii_case("now") => {
14228            Some(ClockShape::Timestamp)
14229        }
14230        12 if name.eq_ignore_ascii_case("current_date") => Some(ClockShape::Date),
14231        14 if kind == ClockSite::Fn && name.eq_ignore_ascii_case("unix_timestamp") => {
14232            Some(ClockShape::UnixSeconds)
14233        }
14234        17 if name.eq_ignore_ascii_case("current_timestamp") => Some(ClockShape::Timestamp),
14235        _ => None,
14236    };
14237    let shape = shape?;
14238    let payload = match shape {
14239        ClockShape::Timestamp => now,
14240        ClockShape::Date => now.div_euclid(86_400_000_000),
14241        ClockShape::UnixSeconds => now.div_euclid(1_000_000),
14242    };
14243    let target = match shape {
14244        ClockShape::Timestamp => spg_sql::ast::CastTarget::Timestamp,
14245        ClockShape::Date => spg_sql::ast::CastTarget::Date,
14246        ClockShape::UnixSeconds => spg_sql::ast::CastTarget::BigInt,
14247    };
14248    Some(Expr::Cast {
14249        expr: alloc::boxed::Box::new(Expr::Literal(spg_sql::ast::Literal::Integer(payload))),
14250        target,
14251    })
14252}
14253
14254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14255enum ClockSite {
14256    Fn,
14257    BareIdent,
14258}
14259
14260/// `ORDER BY <integer>` references the N-th SELECT item (1-based).
14261/// Swap the integer literal for the matching item's expression so the
14262/// executor doesn't need a special-case branch. Recurses into UNION
14263/// peers because each peer keeps its own SELECT list.
14264/// v6.4.1 — expand `GROUP BY ALL` to every non-aggregate SELECT-list
14265/// item. Mirrors DuckDB / PG 19 semantics. Wildcards (`SELECT * …`)
14266/// are NOT expanded by GROUP BY ALL (PG 19 leaves the wildcard intact
14267/// and groups by whatever explicit non-aggregates remain — none in
14268/// the wildcard-only case, which still works for non-aggregate
14269/// queries).
14270fn expand_group_by_all(s: &mut SelectStatement) {
14271    if !s.group_by_all {
14272        for (_, peer) in &mut s.unions {
14273            expand_group_by_all(peer);
14274        }
14275        return;
14276    }
14277    let mut groups: Vec<Expr> = Vec::new();
14278    for item in &s.items {
14279        if let SelectItem::Expr { expr, .. } = item
14280            && !aggregate::contains_aggregate(expr)
14281        {
14282            groups.push(expr.clone());
14283        }
14284    }
14285    s.group_by = Some(groups);
14286    s.group_by_all = false;
14287    for (_, peer) in &mut s.unions {
14288        expand_group_by_all(peer);
14289    }
14290}
14291
14292fn resolve_order_by_position(s: &mut SelectStatement) {
14293    // v6.4.0 — iterate every ORDER BY key. Position references
14294    // (`ORDER BY 2`) bind to the 1-based projection index;
14295    // identifier references that match a SELECT-list alias bind to
14296    // the projected expression (Step 4 of L3a).
14297    for order in &mut s.order_by {
14298        match &order.expr {
14299            Expr::Literal(Literal::Integer(n)) if *n >= 1 => {
14300                if let Ok(idx_one_based) = usize::try_from(*n) {
14301                    let idx = idx_one_based - 1;
14302                    if idx < s.items.len()
14303                        && let SelectItem::Expr { expr, .. } = &s.items[idx]
14304                    {
14305                        order.expr = expr.clone();
14306                    }
14307                }
14308            }
14309            Expr::Column(c) if c.qualifier.is_none() => {
14310                // Alias-in-ORDER-BY lookup.
14311                for item in &s.items {
14312                    if let SelectItem::Expr {
14313                        expr,
14314                        alias: Some(a),
14315                    } = item
14316                        && a == &c.name
14317                    {
14318                        order.expr = expr.clone();
14319                        break;
14320                    }
14321                }
14322            }
14323            _ => {}
14324        }
14325    }
14326    for (_, peer) in &mut s.unions {
14327        resolve_order_by_position(peer);
14328    }
14329}
14330
14331/// Sort `tagged` by `f64` key, reversing the comparator under DESC.
14332/// Used by the UNION ORDER BY path; per-block paths inline the same
14333/// comparator because they already hold `&OrderBy` directly.
14334/// v3.1.1: partial-sort helper. When `keep` (= offset + limit) is
14335/// strictly less than `tagged.len()`, run `select_nth_unstable_by` to
14336/// partition the prefix in O(n), then sort just that prefix in O(k
14337/// log k). Total O(n + k log k), vs O(n log n) for a full sort. The
14338/// caller decides what `keep` is; passing `None` (no LIMIT) keeps the
14339/// full-sort behaviour.
14340///
14341/// `tagged` holds `(Option<f64>, Row)` (the SELECT path) — `None` keys
14342/// sort last in ascending order, mirroring NULL-sorts-last in SQL.
14343fn partial_sort_tagged(tagged: &mut Vec<(Vec<f64>, Row)>, keep: Option<usize>, descs: &[bool]) {
14344    let cmp = |a: &(Vec<f64>, Row), b: &(Vec<f64>, Row)| cmp_multi_key(&a.0, &b.0, descs);
14345    match keep {
14346        Some(k) if k < tagged.len() && k > 0 => {
14347            let pivot = k - 1;
14348            tagged.select_nth_unstable_by(pivot, cmp);
14349            tagged[..k].sort_by(cmp);
14350            tagged.truncate(k);
14351        }
14352        _ => {
14353            tagged.sort_by(cmp);
14354        }
14355    }
14356}
14357
14358fn sort_by_keys(tagged: &mut [(Vec<f64>, Row)], descs: &[bool]) {
14359    tagged.sort_by(|a, b| cmp_multi_key(&a.0, &b.0, descs));
14360}
14361
14362/// v6.4.0 — multi-key ORDER BY comparator. Each key's per-key DESC
14363/// flag is honored independently. NULL is encoded as `f64::INFINITY`
14364/// so it sorts last in ASC and first in DESC (matches PG default).
14365fn cmp_multi_key(a: &[f64], b: &[f64], descs: &[bool]) -> core::cmp::Ordering {
14366    use core::cmp::Ordering;
14367    for (i, (ka, kb)) in a.iter().zip(b.iter()).enumerate() {
14368        let ord = ka.partial_cmp(kb).unwrap_or(Ordering::Equal);
14369        let ord = if descs.get(i).copied().unwrap_or(false) {
14370            ord.reverse()
14371        } else {
14372            ord
14373        };
14374        if ord != Ordering::Equal {
14375            return ord;
14376        }
14377    }
14378    Ordering::Equal
14379}
14380
14381/// v6.4.0 — eval every ORDER BY expression for a row and pack the
14382/// resulting keys into a `Vec<f64>`. NULL → `f64::INFINITY`.
14383fn build_order_keys(
14384    order_by: &[OrderBy],
14385    row: &Row,
14386    ctx: &EvalContext,
14387) -> Result<Vec<f64>, EngineError> {
14388    let mut keys = Vec::with_capacity(order_by.len());
14389    for o in order_by {
14390        let v = eval::eval_expr(&o.expr, row, ctx)?;
14391        keys.push(value_to_order_key(&v)?);
14392    }
14393    Ok(keys)
14394}
14395
14396/// Drop the first `offset` rows then truncate to `limit`. PG / `MySQL`
14397/// agree: OFFSET applies *after* ORDER BY but *before* LIMIT (so
14398/// `LIMIT 10 OFFSET 5` keeps rows 6..=15).
14399fn apply_offset_and_limit(rows: &mut Vec<Row>, offset: Option<u32>, limit: Option<u32>) {
14400    if let Some(off) = offset {
14401        let off = off as usize;
14402        if off >= rows.len() {
14403            rows.clear();
14404        } else {
14405            rows.drain(..off);
14406        }
14407    }
14408    if let Some(n) = limit {
14409        rows.truncate(n as usize);
14410    }
14411}
14412
14413/// v7.17.0 Phase 3.P0-49 — offset + limit applied to a tagged
14414/// `(order_keys, row)` sequence, with optional SQL:2008 `WITH
14415/// TIES` extension. When `with_ties` is set, the truncated tail
14416/// is extended through every subsequent row whose order keys
14417/// equal the last-kept row's keys (so a "top 3 by score" with
14418/// WITH TIES emits row 4 too when row 4 ties row 3 on `score`).
14419///
14420/// The order-key vector is the per-row sort key the caller already
14421/// computed via `build_order_keys`; equal-key detection therefore
14422/// matches the sort comparator exactly.
14423fn apply_offset_and_limit_tagged(
14424    tagged: &mut Vec<(Vec<f64>, Row)>,
14425    offset: Option<u32>,
14426    limit: Option<u32>,
14427    with_ties: bool,
14428) {
14429    if let Some(off) = offset {
14430        let off = off as usize;
14431        if off >= tagged.len() {
14432            tagged.clear();
14433        } else {
14434            tagged.drain(..off);
14435        }
14436    }
14437    if let Some(n) = limit {
14438        let n = n as usize;
14439        if with_ties && n > 0 && n < tagged.len() {
14440            let cutoff_key = tagged[n - 1].0.clone();
14441            let mut end = n;
14442            while end < tagged.len() && tagged[end].0 == cutoff_key {
14443                end += 1;
14444            }
14445            tagged.truncate(end);
14446        } else {
14447            tagged.truncate(n);
14448        }
14449    }
14450}
14451
14452/// v7.17.0 Phase 3.P0-49 — PG-canonical: `FETCH FIRST <n> ROWS
14453/// WITH TIES` requires an `ORDER BY`. Without one, there's no
14454/// way to identify "ties" deterministically, so PG errors at
14455/// plan time. SPG mirrors that surface so the same DDL / app
14456/// behaviour holds on cutover.
14457fn check_with_ties_requires_order_by(stmt: &SelectStatement) -> Result<(), EngineError> {
14458    if stmt.limit_with_ties && stmt.order_by.is_empty() {
14459        return Err(EngineError::Unsupported(alloc::string::String::from(
14460            "FETCH FIRST … ROWS WITH TIES requires an ORDER BY clause",
14461        )));
14462    }
14463    Ok(())
14464}
14465
14466/// v7.6.1 — resolve a parser-level `ForeignKeyConstraint` (column
14467/// names + parent table name) into the storage-layer shape (column
14468/// indices + same parent table). Validates everything the engine
14469/// needs to know about the FK at CREATE TABLE time:
14470///
14471///   - parent table exists (catalog lookup, unless self-referencing)
14472///   - parent columns exist on the parent table
14473///   - parent column list matches the local arity (defaults to the
14474///     parent's primary index column when omitted)
14475///   - parent columns are covered by a `BTree` UNIQUE-class index
14476///     (SPG's stand-in for `PRIMARY KEY`/`UNIQUE`) — required so
14477///     the v7.6.2 INSERT path can do an O(log n) parent lookup
14478///   - local columns exist on the table being created
14479fn resolve_foreign_key(
14480    local_table_name: &str,
14481    local_cols: &[ColumnSchema],
14482    fk: spg_sql::ast::ForeignKeyConstraint,
14483    catalog: &Catalog,
14484) -> Result<spg_storage::ForeignKeyConstraint, EngineError> {
14485    // Resolve local columns.
14486    let mut local_columns = Vec::with_capacity(fk.columns.len());
14487    for name in &fk.columns {
14488        let pos = local_cols
14489            .iter()
14490            .position(|c| c.name == *name)
14491            .ok_or_else(|| {
14492                EngineError::Unsupported(alloc::format!(
14493                    "FOREIGN KEY references unknown local column {name:?}"
14494                ))
14495            })?;
14496        local_columns.push(pos);
14497    }
14498    // Self-referencing FK: parent table is the one we're creating.
14499    // The parent column resolution uses the local column list since
14500    // the catalog doesn't have this table yet.
14501    let is_self_ref = fk.parent_table == local_table_name;
14502    let (parent_cols_for_lookup, parent_table_str): (&[ColumnSchema], &str) = if is_self_ref {
14503        (local_cols, local_table_name)
14504    } else {
14505        let parent_table = catalog.get(&fk.parent_table).ok_or_else(|| {
14506            EngineError::Storage(StorageError::TableNotFound {
14507                name: fk.parent_table.clone(),
14508            })
14509        })?;
14510        (
14511            parent_table.schema().columns.as_slice(),
14512            fk.parent_table.as_str(),
14513        )
14514    };
14515    // Resolve parent column names → positions. If the FK omitted the
14516    // parent column list, fall back to the parent's primary index
14517    // column (single-column only — composite default is rejected
14518    // because there's no unambiguous "PK" in SPG's index list).
14519    let parent_columns: Vec<usize> = if fk.parent_columns.is_empty() {
14520        if fk.columns.len() != 1 {
14521            return Err(EngineError::Unsupported(
14522                "composite FOREIGN KEY without explicit parent column list is not supported \
14523                 — list the parent columns explicitly"
14524                    .into(),
14525            ));
14526        }
14527        // Find a single BTree index on the parent and use its column.
14528        let pos = pick_pk_index_column(catalog, parent_table_str, is_self_ref, local_cols)
14529            .ok_or_else(|| {
14530                EngineError::Unsupported(alloc::format!(
14531                    "parent table {parent_table_str:?} has no PRIMARY-key / UNIQUE BTree index \
14532                     to default the FOREIGN KEY against"
14533                ))
14534            })?;
14535        alloc::vec![pos]
14536    } else {
14537        let mut out = Vec::with_capacity(fk.parent_columns.len());
14538        for name in &fk.parent_columns {
14539            let pos = parent_cols_for_lookup
14540                .iter()
14541                .position(|c| c.name == *name)
14542                .ok_or_else(|| {
14543                    EngineError::Unsupported(alloc::format!(
14544                        "FOREIGN KEY references unknown parent column \
14545                         {name:?} on table {parent_table_str:?}"
14546                    ))
14547                })?;
14548            out.push(pos);
14549        }
14550        out
14551    };
14552    if parent_columns.len() != local_columns.len() {
14553        return Err(EngineError::Unsupported(alloc::format!(
14554            "FOREIGN KEY arity mismatch: {} local columns vs {} parent columns",
14555            local_columns.len(),
14556            parent_columns.len()
14557        )));
14558    }
14559    // For non-self-referencing FKs, verify the parent column set is
14560    // covered by a BTree index. SPG doesn't have a `PRIMARY KEY`
14561    // declaration; the convention is "the parent column for FK
14562    // purposes must have a BTree index" — which the user creates via
14563    // `CREATE INDEX ... USING btree (col)` (the default). We accept
14564    // any single-column BTree index that covers a parent column;
14565    // composite parent column lists require an index whose `column_position`
14566    // matches the first parent column (multi-column BTree indices
14567    // are not in the v7.x roadmap).
14568    if !is_self_ref {
14569        let parent_table = catalog.get(&fk.parent_table).expect("checked above");
14570        let primary_parent_col = parent_columns[0];
14571        let has_btree = parent_table
14572            .schema()
14573            .columns
14574            .get(primary_parent_col)
14575            .is_some()
14576            && parent_table.indices().iter().any(|idx| {
14577                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14578                    && idx.column_position == primary_parent_col
14579                    && idx.partial_predicate.is_none()
14580            });
14581        if !has_btree {
14582            return Err(EngineError::Unsupported(alloc::format!(
14583                "FOREIGN KEY parent column on {:?} is not covered by an unconditional BTree \
14584                 index — create one with `CREATE INDEX ... ON {} ({})` first",
14585                parent_table_str,
14586                parent_table_str,
14587                parent_table.schema().columns[primary_parent_col].name,
14588            )));
14589        }
14590    }
14591    let on_delete = fk_action_sql_to_storage(fk.on_delete);
14592    let on_update = fk_action_sql_to_storage(fk.on_update);
14593    Ok(spg_storage::ForeignKeyConstraint {
14594        name: fk.name,
14595        local_columns,
14596        parent_table: fk.parent_table,
14597        parent_columns,
14598        on_delete,
14599        on_update,
14600    })
14601}
14602
14603/// v7.6.1 — pick a sentinel "primary key" column from the parent
14604/// table when the FK didn't name parent columns. Picks the first
14605/// single-column unconditional BTree index — that's the closest
14606/// thing SPG has to a PRIMARY KEY today. Self-referencing FKs use
14607/// `local_cols` as the column source.
14608fn pick_pk_index_column(
14609    catalog: &Catalog,
14610    parent_name: &str,
14611    is_self_ref: bool,
14612    local_cols: &[ColumnSchema],
14613) -> Option<usize> {
14614    if is_self_ref {
14615        // Self-ref FK omitted parent columns: pick column 0 by
14616        // convention (no catalog entry yet). Engine will widen this
14617        // when v7.6.7 lands; v7.6.1 only handles the explicit form.
14618        let _ = local_cols;
14619        return Some(0);
14620    }
14621    let parent = catalog.get(parent_name)?;
14622    parent.indices().iter().find_map(|idx| {
14623        if matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14624            && idx.partial_predicate.is_none()
14625            && idx.included_columns.is_empty()
14626            && idx.expression.is_none()
14627        {
14628            Some(idx.column_position)
14629        } else {
14630            None
14631        }
14632    })
14633}
14634
14635/// v7.9.8 / v7.9.10 — resolve the column positions that
14636/// identify a conflict for ON CONFLICT. Returns a Vec of
14637/// column positions (1 element for single-column form, N for
14638/// composite). When the user wrote bare `ON CONFLICT DO …`,
14639/// falls back to the table's first unconditional BTree index
14640/// (always single-column today).
14641fn resolve_on_conflict_columns(
14642    catalog: &Catalog,
14643    table_name: &str,
14644    target: &[String],
14645) -> Result<Vec<usize>, EngineError> {
14646    let table = catalog.get(table_name).ok_or_else(|| {
14647        EngineError::Storage(StorageError::TableNotFound {
14648            name: table_name.into(),
14649        })
14650    })?;
14651    if target.is_empty() {
14652        // v7.13.2 — mailrs round-6 S5 follow-up. Composite UNIQUE
14653        // constraints carry a multi-column tuple; the prior code
14654        // path picked only the leading column of the first BTree
14655        // index, which caused `ON CONFLICT DO NOTHING` to dedup
14656        // by leading column alone (3 rows with same group_id but
14657        // different permission collapsed to 1). PG semantics use
14658        // the full tuple. Prefer a UniquenessConstraint's full
14659        // column list when one exists; fall back to the leading
14660        // BTree column for legacy single-column UNIQUE.
14661        if let Some(uc) = table.schema().uniqueness_constraints.first() {
14662            return Ok(uc.columns.clone());
14663        }
14664        let pos = table
14665            .indices()
14666            .iter()
14667            .find_map(|idx| {
14668                if matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14669                    && idx.partial_predicate.is_none()
14670                    && idx.included_columns.is_empty()
14671                    && idx.expression.is_none()
14672                {
14673                    Some(idx.column_position)
14674                } else {
14675                    None
14676                }
14677            })
14678            .ok_or_else(|| {
14679                EngineError::Unsupported(alloc::format!(
14680                    "ON CONFLICT without target requires a UNIQUE BTree index on {table_name:?}"
14681                ))
14682            })?;
14683        return Ok(alloc::vec![pos]);
14684    }
14685    let mut out = Vec::with_capacity(target.len());
14686    for name in target {
14687        let pos = table
14688            .schema()
14689            .columns
14690            .iter()
14691            .position(|c| c.name == *name)
14692            .ok_or_else(|| {
14693                EngineError::Unsupported(alloc::format!(
14694                    "ON CONFLICT target column {name:?} not found on {table_name:?}"
14695                ))
14696            })?;
14697        out.push(pos);
14698    }
14699    Ok(out)
14700}
14701
14702/// v7.9.8 — check whether the BTree index on `column_pos` of
14703/// `table_name` already has a row with this key.
14704fn on_conflict_key_exists(
14705    catalog: &Catalog,
14706    table_name: &str,
14707    column_pos: usize,
14708    key: &Value,
14709) -> bool {
14710    let Some(table) = catalog.get(table_name) else {
14711        return false;
14712    };
14713    let Some(idx_key) = spg_storage::IndexKey::from_value(key) else {
14714        return false;
14715    };
14716    table.indices().iter().any(|idx| {
14717        matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14718            && idx.column_position == column_pos
14719            && idx.partial_predicate.is_none()
14720            && !idx.lookup_eq(&idx_key).is_empty()
14721    })
14722}
14723
14724/// v7.9.9 / v7.9.10 — look up an existing row's position by
14725/// matching all `column_positions` against the incoming `key`
14726/// tuple. Single-column shape (one column) reduces to the
14727/// canonical PK lookup; composite shapes scan linearly until
14728/// every position matches.
14729fn lookup_row_position_by_keys(
14730    catalog: &Catalog,
14731    table_name: &str,
14732    column_positions: &[usize],
14733    key: &[&Value],
14734) -> Option<usize> {
14735    let table = catalog.get(table_name)?;
14736    table.rows().iter().position(|r| {
14737        column_positions
14738            .iter()
14739            .enumerate()
14740            .all(|(i, &pos)| r.values.get(pos) == Some(key[i]))
14741    })
14742}
14743
14744/// v7.9.10 — does the table already contain a row whose
14745/// `column_positions` tuple equals `key`? Single-column shape
14746/// uses the existing BTree fast path; composite shapes fall
14747/// back to a row scan.
14748fn on_conflict_keys_exist(
14749    catalog: &Catalog,
14750    table_name: &str,
14751    column_positions: &[usize],
14752    key: &[&Value],
14753) -> bool {
14754    if column_positions.len() == 1 {
14755        return on_conflict_key_exists(catalog, table_name, column_positions[0], key[0]);
14756    }
14757    let Some(table) = catalog.get(table_name) else {
14758        return false;
14759    };
14760    table.rows().iter().any(|r| {
14761        column_positions
14762            .iter()
14763            .enumerate()
14764            .all(|(i, &pos)| r.values.get(pos) == Some(key[i]))
14765    })
14766}
14767
14768/// v7.9.9 — apply ON CONFLICT DO UPDATE SET assignments to an
14769/// existing row.
14770///
14771/// `incoming` is the rejected INSERT row (used to resolve
14772/// `EXCLUDED.col` references in the assignment exprs);
14773/// `target_pos` is the position of the existing row in the table.
14774/// Each assignment substitutes `EXCLUDED.col` with the matching
14775/// incoming value, evaluates the resulting expression against
14776/// the existing row, and writes the new value into the
14777/// corresponding column of the returned `Vec<Value>`. If
14778/// `where_` evaluates falsy, returns Ok(None) — PG behaviour:
14779/// the conflicting row is silently kept unchanged.
14780fn apply_on_conflict_assignments(
14781    catalog: &Catalog,
14782    table_name: &str,
14783    target_pos: usize,
14784    incoming: &[Value],
14785    assignments: &[(String, Expr)],
14786    where_: Option<&Expr>,
14787) -> Result<Option<Vec<Value>>, EngineError> {
14788    let table = catalog.get(table_name).ok_or_else(|| {
14789        EngineError::Storage(StorageError::TableNotFound {
14790            name: table_name.into(),
14791        })
14792    })?;
14793    let schema_cols = table.schema().columns.clone();
14794    let existing = table
14795        .rows()
14796        .get(target_pos)
14797        .ok_or_else(|| {
14798            EngineError::Unsupported(alloc::format!(
14799                "ON CONFLICT DO UPDATE: row position {target_pos} out of bounds on {table_name:?}"
14800            ))
14801        })?
14802        .clone();
14803    let ctx = eval::EvalContext::new(&schema_cols, Some(table_name));
14804    // Optional WHERE filter on the conflict row.
14805    if let Some(w) = where_ {
14806        let pred = w.clone();
14807        let pred = substitute_excluded_refs(pred, &schema_cols, incoming);
14808        let v = eval::eval_expr(&pred, &existing, &ctx)?;
14809        if !matches!(v, Value::Bool(true)) {
14810            return Ok(None);
14811        }
14812    }
14813    let mut new_values = existing.values.clone();
14814    for (col_name, expr) in assignments {
14815        let target_idx = schema_cols
14816            .iter()
14817            .position(|c| c.name == *col_name)
14818            .ok_or_else(|| {
14819                EngineError::Eval(EvalError::ColumnNotFound {
14820                    name: col_name.clone(),
14821                })
14822            })?;
14823        let sub = substitute_excluded_refs(expr.clone(), &schema_cols, incoming);
14824        let v = eval::eval_expr(&sub, &existing, &ctx)?;
14825        let coerced = coerce_value(v, schema_cols[target_idx].ty, col_name, target_idx)?;
14826        check_unsigned_range(&coerced, &schema_cols[target_idx], target_idx)?;
14827        new_values[target_idx] = coerced;
14828    }
14829    Ok(Some(new_values))
14830}
14831
14832/// v7.9.9 — walk an `Expr` tree replacing any `Column { qualifier:
14833/// "EXCLUDED", name }` reference with a `Literal` of the matching
14834/// value from the incoming-row vec. Resolution against the
14835/// child-table column list (by name).
14836fn substitute_excluded_refs(expr: Expr, schema_cols: &[ColumnSchema], incoming: &[Value]) -> Expr {
14837    use spg_sql::ast::ColumnName;
14838    match expr {
14839        Expr::Column(ColumnName { qualifier, name })
14840            if qualifier
14841                .as_deref()
14842                .is_some_and(|q| q.eq_ignore_ascii_case("excluded")) =>
14843        {
14844            let pos = schema_cols.iter().position(|c| c.name == name);
14845            match pos {
14846                Some(p) => {
14847                    let v = incoming.get(p).cloned().unwrap_or(Value::Null);
14848                    value_to_literal_expr(v)
14849                        .unwrap_or_else(|_| Expr::Literal(spg_sql::ast::Literal::Null))
14850                }
14851                None => Expr::Column(ColumnName { qualifier, name }),
14852            }
14853        }
14854        Expr::Binary { op, lhs, rhs } => Expr::Binary {
14855            op,
14856            lhs: Box::new(substitute_excluded_refs(*lhs, schema_cols, incoming)),
14857            rhs: Box::new(substitute_excluded_refs(*rhs, schema_cols, incoming)),
14858        },
14859        Expr::Unary { op, expr } => Expr::Unary {
14860            op,
14861            expr: Box::new(substitute_excluded_refs(*expr, schema_cols, incoming)),
14862        },
14863        Expr::FunctionCall { name, args } => Expr::FunctionCall {
14864            name,
14865            args: args
14866                .into_iter()
14867                .map(|a| substitute_excluded_refs(a, schema_cols, incoming))
14868                .collect(),
14869        },
14870        other => other,
14871    }
14872}
14873
14874/// v7.6.2 / v7.6.7 — INSERT-side FK enforcement. For every row
14875/// about to be inserted into `child_table`, every FK declared on
14876/// that table is checked: the row's FK columns must either be
14877/// NULL (SQL spec skip) or match an existing parent row via the
14878/// parent's BTree PK / UNIQUE index.
14879///
14880/// Returns `EngineError::Unsupported` with a `FOREIGN KEY violation`
14881/// payload on first failure.
14882///
14883/// **Self-referencing FKs (v7.6.7 widening):** when `fk.parent_table
14884/// == child_table`, the parent rows visible to this check are
14885///  (a) rows already committed to the table, plus
14886///  (b) earlier rows from the *same* `rows` batch.
14887/// This makes `INSERT INTO tree VALUES (1, NULL), (2, 1), (3, 2)`
14888/// work in a single statement — common pattern for bulk-loading
14889/// hierarchies.
14890/// v7.9.19 — enforce table-level UNIQUE / PRIMARY KEY tuple
14891/// constraints at INSERT time. For each constraint declared on
14892/// the target table, check that no existing row + no earlier row
14893/// in the same batch has the same full-column tuple. NULL in
14894/// any column lifts the row out of the check (SQL spec: NULL
14895/// ≠ NULL for uniqueness). mailrs G1 + G6.
14896fn enforce_uniqueness_inserts(
14897    catalog: &Catalog,
14898    child_table: &str,
14899    constraints: &[spg_storage::UniquenessConstraint],
14900    rows: &[Vec<Value>],
14901) -> Result<(), EngineError> {
14902    if constraints.is_empty() {
14903        return Ok(());
14904    }
14905    let table = catalog.get(child_table).ok_or_else(|| {
14906        EngineError::Storage(StorageError::TableNotFound {
14907            name: child_table.into(),
14908        })
14909    })?;
14910    let schema = table.schema();
14911    for uc in constraints {
14912        for (batch_idx, row_values) in rows.iter().enumerate() {
14913            // v7.17.0 Phase 3.P0-45 — fold each key cell by its
14914            // column's declared Collation before comparing. Phase
14915            // 2.5b wired Collation into GROUP BY / ORDER BY / `=`
14916            // but the UNIQUE-constraint enforcement still compared
14917            // Text byte-wise; a `*_ci` column would let
14918            // `('Foo')` and `('FOO')` coexist when MySQL would
14919            // reject the second. Owned Values so the fold and
14920            // the borrow live in the same scope.
14921            let key: Vec<Value> = uc
14922                .columns
14923                .iter()
14924                .map(|&i| collated_key_cell(&row_values[i], i, schema))
14925                .collect();
14926            let has_null = key.iter().any(|v| matches!(v, Value::Null));
14927            // v7.13.0 — `NULLS NOT DISTINCT` (mailrs round-5 G10,
14928            // PG 15+): two rows whose constrained columns are all
14929            // NULL collide. SQL-standard `NULLS DISTINCT` lets any
14930            // NULL skip the check.
14931            if has_null && !uc.nulls_not_distinct {
14932                continue;
14933            }
14934            // Table-side collision: scan existing rows.
14935            let collides_in_table = table.rows().iter().any(|prow| {
14936                uc.columns.iter().enumerate().all(|(i, &p)| {
14937                    prow.values
14938                        .get(p)
14939                        .is_some_and(|v| collated_key_cell(v, p, schema) == key[i])
14940                })
14941            });
14942            // Batch-side collision: earlier rows in the same INSERT.
14943            let collides_in_batch = rows[..batch_idx].iter().any(|earlier| {
14944                uc.columns.iter().enumerate().all(|(i, &p)| {
14945                    earlier
14946                        .get(p)
14947                        .is_some_and(|v| collated_key_cell(v, p, schema) == key[i])
14948                })
14949            });
14950            if collides_in_table || collides_in_batch {
14951                let kind = if uc.is_primary_key {
14952                    "PRIMARY KEY"
14953                } else {
14954                    "UNIQUE"
14955                };
14956                let col_names: Vec<String> = uc
14957                    .columns
14958                    .iter()
14959                    .map(|&i| table.schema().columns[i].name.clone())
14960                    .collect();
14961                return Err(EngineError::Unsupported(alloc::format!(
14962                    "{kind} violation on {child_table:?} columns {col_names:?}: \
14963                     row #{batch_idx} duplicates an existing key"
14964                )));
14965            }
14966        }
14967    }
14968    Ok(())
14969}
14970
14971/// v7.17.0 Phase 3.P0-45 — return a key cell folded by its column's
14972/// declared `Collation`. For `CaseInsensitive`, fold Text payloads to
14973/// ASCII lowercase (matches Phase 2.5's `*_ci` semantics: ASCII case-
14974/// fold only, non-ASCII bytes stay byte-wise). For `Binary` or non-Text
14975/// values, the cell passes through unchanged. The caller compares the
14976/// folded values with `==`.
14977fn collated_key_cell(
14978    v: &spg_storage::Value,
14979    column_position: usize,
14980    schema: &spg_storage::TableSchema,
14981) -> spg_storage::Value {
14982    match (v, schema.columns.get(column_position).map(|c| c.collation)) {
14983        (spg_storage::Value::Text(s), Some(spg_storage::Collation::CaseInsensitive)) => {
14984            spg_storage::Value::Text(s.to_ascii_lowercase())
14985        }
14986        _ => v.clone(),
14987    }
14988}
14989
14990/// v7.9.29 — `true` iff `v` counts as a truthy SQL value for a
14991/// WHERE-style predicate. NULL → false (three-valued logic
14992/// collapses to "skip this row" for index inclusion). Numeric
14993/// non-zero, BIGINT non-zero, TINYINT non-zero, BOOLEAN true → true.
14994/// Everything else (strings, vectors, JSON, …) is not a valid
14995/// predicate result and surfaces as `false` so a malformed
14996/// predicate degrades to "row not in index" rather than panicking.
14997fn predicate_truthy(v: &spg_storage::Value) -> bool {
14998    use spg_storage::Value as V;
14999    match v {
15000        V::Bool(b) => *b,
15001        V::Int(n) => *n != 0,
15002        V::BigInt(n) => *n != 0,
15003        V::SmallInt(n) => *n != 0,
15004        _ => false,
15005    }
15006}
15007
15008/// v7.9.29 — at CREATE UNIQUE INDEX time, scan the table's
15009/// committed rows for pre-existing duplicates. If any pair of rows
15010/// matches the predicate AND has the same index key, refuse to
15011/// create the index so the user fixes the data before retrying.
15012fn check_existing_unique_violation(
15013    idx: &spg_storage::Index,
15014    schema: &spg_storage::TableSchema,
15015    rows: &[spg_storage::Row],
15016) -> Result<(), EngineError> {
15017    let predicate_expr = match idx.partial_predicate.as_deref() {
15018        Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
15019            EngineError::Unsupported(alloc::format!(
15020                "stored partial predicate {s:?} failed to re-parse: {e:?}"
15021            ))
15022        })?),
15023        None => None,
15024    };
15025    let ctx = eval::EvalContext::new(&schema.columns, None);
15026    let key_positions = unique_key_positions(idx);
15027    let mut seen: alloc::vec::Vec<alloc::vec::Vec<spg_storage::Value>> = alloc::vec::Vec::new();
15028    for row in rows {
15029        if let Some(expr) = &predicate_expr {
15030            let v = eval::eval_expr(expr, row, &ctx).map_err(|e| {
15031                EngineError::Unsupported(alloc::format!(
15032                    "evaluating UNIQUE INDEX predicate against existing row: {e:?}"
15033                ))
15034            })?;
15035            if !predicate_truthy(&v) {
15036                continue;
15037            }
15038        }
15039        let key: alloc::vec::Vec<spg_storage::Value> = key_positions
15040            .iter()
15041            .map(|&p| {
15042                let v = row
15043                    .values
15044                    .get(p)
15045                    .cloned()
15046                    .unwrap_or(spg_storage::Value::Null);
15047                collated_key_cell(&v, p, schema)
15048            })
15049            .collect();
15050        if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
15051            continue;
15052        }
15053        if seen.iter().any(|other| *other == key) {
15054            return Err(EngineError::Unsupported(alloc::format!(
15055                "CREATE UNIQUE INDEX {:?}: existing rows already violate the constraint",
15056                idx.name
15057            )));
15058        }
15059        seen.push(key);
15060    }
15061    Ok(())
15062}
15063
15064/// v7.9.29 — full key tuple for a UNIQUE INDEX (leading +
15065/// extra positions). For single-column indexes this is just
15066/// `[column_position]`.
15067fn unique_key_positions(idx: &spg_storage::Index) -> alloc::vec::Vec<usize> {
15068    let mut out = alloc::vec::Vec::with_capacity(1 + idx.extra_column_positions.len());
15069    out.push(idx.column_position);
15070    out.extend_from_slice(&idx.extra_column_positions);
15071    out
15072}
15073
15074/// v7.9.29 — at INSERT time, walk every `is_unique` index on the
15075/// target table. For each, eval the index's optional predicate
15076/// against (a) the candidate row and (b) every committed row plus
15077/// earlier batch rows; only rows where the predicate is truthy
15078/// participate. A duplicate key among predicate-matching rows is a
15079/// uniqueness violation. NULL keys lift the row out of the check
15080/// (matching PG's "UNIQUE allows multiple NULLs" semantics).
15081fn enforce_unique_index_inserts(
15082    catalog: &Catalog,
15083    table_name: &str,
15084    rows: &[alloc::vec::Vec<spg_storage::Value>],
15085) -> Result<(), EngineError> {
15086    let table = catalog.get(table_name).ok_or_else(|| {
15087        EngineError::Storage(StorageError::TableNotFound {
15088            name: table_name.into(),
15089        })
15090    })?;
15091    let schema = table.schema();
15092    let ctx = eval::EvalContext::new(&schema.columns, None);
15093    for idx in table.indices() {
15094        if !idx.is_unique {
15095            continue;
15096        }
15097        // Re-parse the predicate once per index per batch.
15098        let predicate_expr = match idx.partial_predicate.as_deref() {
15099            Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
15100                EngineError::Unsupported(alloc::format!(
15101                    "UNIQUE INDEX {:?} predicate {s:?} failed to re-parse: {e:?}",
15102                    idx.name
15103                ))
15104            })?),
15105            None => None,
15106        };
15107        let key_positions = unique_key_positions(idx);
15108        let key_of = |values: &[spg_storage::Value]| -> alloc::vec::Vec<spg_storage::Value> {
15109            // v7.17.0 Phase 3.P0-45 — fold per-column collation
15110            // before building the comparison key so a `*_ci`
15111            // column treats `'Foo'` and `'FOO'` as equal.
15112            key_positions
15113                .iter()
15114                .map(|&p| {
15115                    let v = values.get(p).cloned().unwrap_or(spg_storage::Value::Null);
15116                    collated_key_cell(&v, p, schema)
15117                })
15118                .collect()
15119        };
15120        // Helper: does `values` participate in this index? (predicate
15121        // truthy when present.) Wraps `values` into a transient Row
15122        // because eval_expr requires &Row.
15123        let participates = |values: &[spg_storage::Value]| -> Result<bool, EngineError> {
15124            let Some(expr) = &predicate_expr else {
15125                return Ok(true);
15126            };
15127            let tmp_row = spg_storage::Row {
15128                values: values.to_vec(),
15129            };
15130            let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
15131                EngineError::Unsupported(alloc::format!(
15132                    "UNIQUE INDEX {:?} predicate eval: {e:?}",
15133                    idx.name
15134                ))
15135            })?;
15136            Ok(predicate_truthy(&v))
15137        };
15138        for (batch_idx, row_values) in rows.iter().enumerate() {
15139            if !participates(row_values)? {
15140                continue;
15141            }
15142            let key = key_of(row_values);
15143            if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
15144                continue;
15145            }
15146            // Committed-table collision.
15147            for prow in table.rows() {
15148                if !participates(&prow.values)? {
15149                    continue;
15150                }
15151                if key_of(&prow.values) == key {
15152                    return Err(EngineError::Unsupported(alloc::format!(
15153                        "UNIQUE INDEX {:?} violation on {table_name:?}: \
15154                         row #{batch_idx} duplicates an existing key",
15155                        idx.name
15156                    )));
15157                }
15158            }
15159            // Within-batch collision: earlier rows in the same INSERT.
15160            for earlier in &rows[..batch_idx] {
15161                if !participates(earlier)? {
15162                    continue;
15163                }
15164                if key_of(earlier) == key {
15165                    return Err(EngineError::Unsupported(alloc::format!(
15166                        "UNIQUE INDEX {:?} violation on {table_name:?}: \
15167                         row #{batch_idx} duplicates an earlier row in the same batch",
15168                        idx.name
15169                    )));
15170                }
15171            }
15172        }
15173    }
15174    Ok(())
15175}
15176
15177/// v7.13.0 — `UPDATE OF cols` filter helper (mailrs round-5 G7).
15178/// Returns `true` when at least one of `filter_cols` has a
15179/// different value in `new_row` vs `old_row`. Column lookup is
15180/// case-insensitive against `schema_cols`; unknown filter columns
15181/// are treated as "not changed" (the trigger therefore won't
15182/// fire on them — surfacing a parse-time error would be too
15183/// strict for catalog reloads where the schema may have drifted).
15184fn any_column_changed(
15185    filter_cols: &[String],
15186    schema_cols: &[ColumnSchema],
15187    old_row: &Row,
15188    new_row: &Row,
15189) -> bool {
15190    for col_name in filter_cols {
15191        let Some(pos) = schema_cols
15192            .iter()
15193            .position(|c| c.name.eq_ignore_ascii_case(col_name))
15194        else {
15195            continue;
15196        };
15197        let old_v = old_row.values.get(pos);
15198        let new_v = new_row.values.get(pos);
15199        if old_v != new_v {
15200            return true;
15201        }
15202    }
15203    false
15204}
15205
15206/// v7.13.0 — evaluate every CHECK predicate on the schema against
15207/// each candidate row. Mirrors PG semantics: a `false` result
15208/// rejects the mutation; a NULL result *passes* (CHECK rejects
15209/// only on definite-false, not on unknown). mailrs round-5 G3.
15210fn enforce_check_constraints(
15211    catalog: &Catalog,
15212    table_name: &str,
15213    rows: &[alloc::vec::Vec<spg_storage::Value>],
15214) -> Result<(), EngineError> {
15215    let table = catalog.get(table_name).ok_or_else(|| {
15216        EngineError::Storage(StorageError::TableNotFound {
15217            name: table_name.into(),
15218        })
15219    })?;
15220    let schema = table.schema();
15221    // v7.17.0 Phase 1.5 — domain-level CHECKs are enforced in
15222    // parallel with table-level CHECKs. Collect both lists up
15223    // front; if neither exists we early-out.
15224    let mut domain_checks_per_col: alloc::vec::Vec<(usize, alloc::vec::Vec<Expr>)> =
15225        alloc::vec::Vec::new();
15226    for (idx, col) in schema.columns.iter().enumerate() {
15227        let Some(dname) = &col.user_domain_type else {
15228            continue;
15229        };
15230        let Some(dom) = catalog.domain_types().get(dname) else {
15231            continue;
15232        };
15233        let mut parsed_for_col: alloc::vec::Vec<Expr> =
15234            alloc::vec::Vec::with_capacity(dom.checks.len());
15235        for src in &dom.checks {
15236            let expr = spg_sql::parser::parse_expression(src).map_err(|e| {
15237                EngineError::Unsupported(alloc::format!(
15238                    "DOMAIN {dname:?} CHECK ({src:?}) on column {:?}: re-parse failed: {e:?}",
15239                    col.name
15240                ))
15241            })?;
15242            parsed_for_col.push(expr);
15243        }
15244        if !parsed_for_col.is_empty() {
15245            domain_checks_per_col.push((idx, parsed_for_col));
15246        }
15247    }
15248    if schema.checks.is_empty() && domain_checks_per_col.is_empty() {
15249        return Ok(());
15250    }
15251    let ctx = eval::EvalContext::new(&schema.columns, None);
15252    let mut parsed: alloc::vec::Vec<(usize, Expr)> = alloc::vec::Vec::new();
15253    for (i, src) in schema.checks.iter().enumerate() {
15254        let expr = spg_sql::parser::parse_expression(src).map_err(|e| {
15255            EngineError::Unsupported(alloc::format!(
15256                "CHECK constraint #{i} on {table_name:?} ({src:?}) failed to re-parse: {e:?}"
15257            ))
15258        })?;
15259        parsed.push((i, expr));
15260    }
15261    for (batch_idx, row_values) in rows.iter().enumerate() {
15262        let tmp_row = spg_storage::Row {
15263            values: row_values.clone(),
15264        };
15265        for (i, expr) in &parsed {
15266            let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
15267                EngineError::Unsupported(alloc::format!(
15268                    "CHECK constraint #{i} on {table_name:?} eval at row #{batch_idx}: {e:?}"
15269                ))
15270            })?;
15271            // PG: NULL passes (CHECK rejects on definite-false only).
15272            if matches!(v, spg_storage::Value::Bool(false)) {
15273                return Err(EngineError::Unsupported(alloc::format!(
15274                    "CHECK constraint violation on {table_name:?} (row #{batch_idx}): {:?}",
15275                    schema.checks[*i]
15276                )));
15277            }
15278        }
15279        // v7.17.0 Phase 1.5 — domain-level CHECKs. Each CHECK
15280        // expression references VALUE as a column-name; we
15281        // substitute the per-row cell into the eval context by
15282        // synthesising a single-column row of just that value
15283        // under a temporary `value` column schema.
15284        for (col_idx, checks) in &domain_checks_per_col {
15285            let cell = row_values
15286                .get(*col_idx)
15287                .cloned()
15288                .unwrap_or(spg_storage::Value::Null);
15289            let synth_cols = alloc::vec![spg_storage::ColumnSchema::new(
15290                "value",
15291                schema.columns[*col_idx].ty,
15292                schema.columns[*col_idx].nullable,
15293            )];
15294            let synth_ctx = eval::EvalContext::new(&synth_cols, None);
15295            let synth_row = spg_storage::Row {
15296                values: alloc::vec![cell],
15297            };
15298            for (ci, expr) in checks.iter().enumerate() {
15299                let v = eval::eval_expr(expr, &synth_row, &synth_ctx).map_err(|e| {
15300                    EngineError::Unsupported(alloc::format!(
15301                        "DOMAIN CHECK #{ci} on column {:?} eval at row #{batch_idx}: {e:?}",
15302                        schema.columns[*col_idx].name
15303                    ))
15304                })?;
15305                if matches!(v, spg_storage::Value::Bool(false)) {
15306                    return Err(EngineError::Unsupported(alloc::format!(
15307                        "DOMAIN CHECK violation on column {:?} (row #{batch_idx})",
15308                        schema.columns[*col_idx].name
15309                    )));
15310                }
15311            }
15312        }
15313    }
15314    Ok(())
15315}
15316
15317fn enforce_fk_inserts(
15318    catalog: &Catalog,
15319    child_table: &str,
15320    fks: &[spg_storage::ForeignKeyConstraint],
15321    rows: &[Vec<Value>],
15322) -> Result<(), EngineError> {
15323    for fk in fks {
15324        let parent_is_self = fk.parent_table == child_table;
15325        let parent = if parent_is_self {
15326            // Self-ref: read the current state of the same table.
15327            // The mut borrow on child has been dropped by the caller.
15328            catalog.get(child_table).ok_or_else(|| {
15329                EngineError::Storage(StorageError::TableNotFound {
15330                    name: child_table.into(),
15331                })
15332            })?
15333        } else {
15334            catalog.get(&fk.parent_table).ok_or_else(|| {
15335                EngineError::Storage(StorageError::TableNotFound {
15336                    name: fk.parent_table.clone(),
15337                })
15338            })?
15339        };
15340        for (batch_idx, row_values) in rows.iter().enumerate() {
15341            // Single-column FK fast path: try the parent's BTree
15342            // index for an O(log n) lookup. Composite FKs fall back
15343            // to a parent-row scan.
15344            if fk.local_columns.len() == 1 {
15345                let v = &row_values[fk.local_columns[0]];
15346                if matches!(v, Value::Null) {
15347                    continue;
15348                }
15349                let parent_col = fk.parent_columns[0];
15350                let key = spg_storage::IndexKey::from_value(v).ok_or_else(|| {
15351                    EngineError::Unsupported(alloc::format!(
15352                        "FOREIGN KEY column value of type {:?} is not index-eligible",
15353                        v.data_type()
15354                    ))
15355                })?;
15356                let present_committed = parent.indices().iter().any(|idx| {
15357                    matches!(idx.kind, spg_storage::IndexKind::BTree(_))
15358                        && idx.column_position == parent_col
15359                        && idx.partial_predicate.is_none()
15360                        && !idx.lookup_eq(&key).is_empty()
15361                });
15362                // v7.6.7 self-ref widening: also accept a match
15363                // against earlier rows in this same batch when the
15364                // FK points at the table being inserted into.
15365                let present_in_batch = parent_is_self
15366                    && rows[..batch_idx]
15367                        .iter()
15368                        .any(|earlier| earlier.get(parent_col) == Some(v));
15369                if !(present_committed || present_in_batch) {
15370                    return Err(EngineError::Unsupported(alloc::format!(
15371                        "FOREIGN KEY violation: no parent row in {:?} where {} = {:?}",
15372                        fk.parent_table,
15373                        parent
15374                            .schema()
15375                            .columns
15376                            .get(parent_col)
15377                            .map_or("?", |c| c.name.as_str()),
15378                        v,
15379                    )));
15380                }
15381            } else {
15382                // Composite FK: scan parent rows. v7.6.7 also
15383                // accepts a match against earlier rows in the same
15384                // batch (self-ref bulk-loading of hierarchies).
15385                if fk
15386                    .local_columns
15387                    .iter()
15388                    .all(|&i| matches!(row_values.get(i), Some(Value::Null)))
15389                {
15390                    continue;
15391                }
15392                let local: Vec<&Value> = fk.local_columns.iter().map(|&i| &row_values[i]).collect();
15393                let parent_match_committed = parent.rows().iter().any(|prow| {
15394                    fk.parent_columns
15395                        .iter()
15396                        .enumerate()
15397                        .all(|(i, &pi)| prow.values.get(pi) == Some(local[i]))
15398                });
15399                let parent_match_in_batch = parent_is_self
15400                    && rows[..batch_idx].iter().any(|earlier| {
15401                        fk.parent_columns
15402                            .iter()
15403                            .enumerate()
15404                            .all(|(i, &pi)| earlier.get(pi) == Some(local[i]))
15405                    });
15406                if !(parent_match_committed || parent_match_in_batch) {
15407                    return Err(EngineError::Unsupported(alloc::format!(
15408                        "FOREIGN KEY violation: no parent row in {:?} matching composite key",
15409                        fk.parent_table,
15410                    )));
15411                }
15412            }
15413        }
15414    }
15415    Ok(())
15416}
15417
15418/// v7.6.4 / v7.6.5 — one step of the FK action plan computed for a
15419/// DELETE on a parent. The plan is a list of these steps, stacked
15420/// across the FK graph by `plan_fk_parent_deletions`.
15421#[derive(Debug, Clone)]
15422struct FkChildStep {
15423    child_table: String,
15424    action: FkChildAction,
15425}
15426
15427#[derive(Debug, Clone)]
15428enum FkChildAction {
15429    /// CASCADE — remove these rows. Sorted, deduplicated positions.
15430    Delete { positions: Vec<usize> },
15431    /// SET NULL — for each (row, column) in the flat list, write
15432    /// NULL into that child cell. Multiple FKs on the same row may
15433    /// produce overlapping entries (deduped at plan time).
15434    SetNull {
15435        positions: Vec<usize>,
15436        columns: Vec<usize>,
15437    },
15438    /// SET DEFAULT — same shape as SetNull but writes the column's
15439    /// declared DEFAULT value (resolved at plan time). Columns
15440    /// without a DEFAULT raise an error during planning.
15441    SetDefault {
15442        positions: Vec<usize>,
15443        columns: Vec<usize>,
15444        defaults: Vec<Value>,
15445    },
15446}
15447
15448/// v7.6.3 → v7.6.5 — plan FK fallout for a DELETE on a parent table.
15449///
15450/// Walks every table in the catalog looking for FKs whose
15451/// `parent_table` is `parent_table_name`. For each such FK + each
15452/// to-be-deleted parent row:
15453///
15454///   - RESTRICT / NoAction → error, no plan returned
15455///   - CASCADE → child rows get scheduled for deletion; recursive
15456///   - SetNull → child FK column(s) scheduled to be NULL-ed.
15457///     Verified NULL-able at plan time.
15458///   - SetDefault → child FK column(s) scheduled to be reset to
15459///     their declared DEFAULT. Columns without a DEFAULT raise.
15460///
15461/// SET NULL / SET DEFAULT do NOT cascade further — the child row
15462/// stays; only one of its columns mutates.
15463fn plan_fk_parent_deletions(
15464    catalog: &Catalog,
15465    parent_table_name: &str,
15466    to_delete_positions: &[usize],
15467    to_delete_rows: &[Vec<Value>],
15468) -> Result<Vec<FkChildStep>, EngineError> {
15469    use alloc::collections::{BTreeMap, BTreeSet};
15470    if to_delete_rows.is_empty() {
15471        return Ok(Vec::new());
15472    }
15473    let mut delete_plan: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
15474    // setnull / setdefault keyed by child_table → (row_idx, col_idx) → optional default
15475    let mut setnull_plan: BTreeMap<String, BTreeSet<(usize, usize)>> = BTreeMap::new();
15476    let mut setdefault_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
15477    let mut visited: BTreeSet<(String, usize)> = BTreeSet::new();
15478    for &p in to_delete_positions {
15479        visited.insert((parent_table_name.to_string(), p));
15480    }
15481    let mut work: Vec<(String, Vec<Value>)> = to_delete_rows
15482        .iter()
15483        .map(|r| (parent_table_name.to_string(), r.clone()))
15484        .collect();
15485    while let Some((cur_parent, parent_row)) = work.pop() {
15486        for child_name in catalog.table_names() {
15487            let child = catalog
15488                .get(&child_name)
15489                .expect("table_names → catalog.get round-trip is total");
15490            for fk in &child.schema().foreign_keys {
15491                if fk.parent_table != cur_parent {
15492                    continue;
15493                }
15494                let parent_key: Vec<&Value> = fk
15495                    .parent_columns
15496                    .iter()
15497                    .map(|&pi| &parent_row[pi])
15498                    .collect();
15499                if parent_key.iter().any(|v| matches!(v, Value::Null)) {
15500                    continue;
15501                }
15502                for (child_row_idx, child_row) in child.rows().iter().enumerate() {
15503                    if child_name == cur_parent
15504                        && visited.contains(&(child_name.clone(), child_row_idx))
15505                    {
15506                        continue;
15507                    }
15508                    let matches_key = fk
15509                        .local_columns
15510                        .iter()
15511                        .enumerate()
15512                        .all(|(i, &li)| child_row.values.get(li) == Some(parent_key[i]));
15513                    if !matches_key {
15514                        continue;
15515                    }
15516                    match fk.on_delete {
15517                        spg_storage::FkAction::Restrict | spg_storage::FkAction::NoAction => {
15518                            return Err(EngineError::Unsupported(alloc::format!(
15519                                "FOREIGN KEY violation: DELETE on {cur_parent:?} is \
15520                                 restricted by FK from {child_name:?}.{:?}",
15521                                fk.local_columns,
15522                            )));
15523                        }
15524                        spg_storage::FkAction::Cascade => {
15525                            if visited.insert((child_name.clone(), child_row_idx)) {
15526                                delete_plan
15527                                    .entry(child_name.clone())
15528                                    .or_default()
15529                                    .insert(child_row_idx);
15530                                work.push((child_name.clone(), child_row.values.clone()));
15531                            }
15532                        }
15533                        spg_storage::FkAction::SetNull => {
15534                            // Verify every local FK column is NULL-able.
15535                            for &li in &fk.local_columns {
15536                                let col = child.schema().columns.get(li).ok_or_else(|| {
15537                                    EngineError::Unsupported(alloc::format!(
15538                                        "FK local column {li} missing in {child_name:?}"
15539                                    ))
15540                                })?;
15541                                if !col.nullable {
15542                                    return Err(EngineError::Unsupported(alloc::format!(
15543                                        "FOREIGN KEY ON DELETE SET NULL: column \
15544                                         {child_name:?}.{:?} is NOT NULL — cannot SET NULL",
15545                                        col.name,
15546                                    )));
15547                                }
15548                            }
15549                            let entry = setnull_plan.entry(child_name.clone()).or_default();
15550                            for &li in &fk.local_columns {
15551                                entry.insert((child_row_idx, li));
15552                            }
15553                        }
15554                        spg_storage::FkAction::SetDefault => {
15555                            // Resolve the DEFAULT for every local FK col.
15556                            let entry = setdefault_plan.entry(child_name.clone()).or_default();
15557                            for &li in &fk.local_columns {
15558                                let col = child.schema().columns.get(li).ok_or_else(|| {
15559                                    EngineError::Unsupported(alloc::format!(
15560                                        "FK local column {li} missing in {child_name:?}"
15561                                    ))
15562                                })?;
15563                                let default = col.default.clone().ok_or_else(|| {
15564                                    EngineError::Unsupported(alloc::format!(
15565                                        "FOREIGN KEY ON DELETE SET DEFAULT: column \
15566                                         {child_name:?}.{:?} has no DEFAULT declared",
15567                                        col.name,
15568                                    ))
15569                                })?;
15570                                entry.insert((child_row_idx, li), default);
15571                            }
15572                        }
15573                    }
15574                }
15575            }
15576        }
15577    }
15578    // Flatten the three plans into the ordered `FkChildStep` list.
15579    // Deletes are applied last per child (after any null/default
15580    // re-writes on the same child) so a child row that's both
15581    // re-written and then cascade-deleted only ends up deleted —
15582    // but in v7.6.5 SetNull/Cascade never overlap on the same row
15583    // (a single FK chooses exactly one action), so the order is
15584    // mostly a precaution.
15585    let mut steps: Vec<FkChildStep> = Vec::new();
15586    for (child_table, entries) in setnull_plan {
15587        let (positions, columns): (Vec<usize>, Vec<usize>) = entries.into_iter().unzip();
15588        steps.push(FkChildStep {
15589            child_table,
15590            action: FkChildAction::SetNull { positions, columns },
15591        });
15592    }
15593    for (child_table, entries) in setdefault_plan {
15594        let mut positions = Vec::with_capacity(entries.len());
15595        let mut columns = Vec::with_capacity(entries.len());
15596        let mut defaults = Vec::with_capacity(entries.len());
15597        for ((p, c), v) in entries {
15598            positions.push(p);
15599            columns.push(c);
15600            defaults.push(v);
15601        }
15602        steps.push(FkChildStep {
15603            child_table,
15604            action: FkChildAction::SetDefault {
15605                positions,
15606                columns,
15607                defaults,
15608            },
15609        });
15610    }
15611    for (child_table, positions) in delete_plan {
15612        steps.push(FkChildStep {
15613            child_table,
15614            action: FkChildAction::Delete {
15615                positions: positions.into_iter().collect(),
15616            },
15617        });
15618    }
15619    Ok(steps)
15620}
15621
15622/// v7.6.6 — plan FK fallout for an UPDATE that mutates parent-side
15623/// PK/UNIQUE columns. Walks every other table whose FK references
15624/// `parent_table_name`; for each FK whose parent_columns overlap a
15625/// mutated column, decides the action by `fk.on_update`.
15626///
15627///   - RESTRICT / NoAction → error if any child references the OLD
15628///     value
15629///   - CASCADE → child FK columns get rewritten to the NEW parent
15630///     value (a SetNull-style update step with the new value)
15631///   - SetNull → child FK columns set to NULL
15632///   - SetDefault → child FK columns set to declared default
15633///
15634/// `plan_with_old` is `(row_position, old_values, new_values)` so
15635/// the planner can detect "did this row's parent key actually
15636/// change?" — only rows where at least one referenced parent
15637/// column moved trigger inbound work.
15638fn plan_fk_parent_updates(
15639    catalog: &Catalog,
15640    parent_table_name: &str,
15641    plan_with_old: &[(usize, Vec<Value>, Vec<Value>)],
15642) -> Result<Vec<FkChildStep>, EngineError> {
15643    use alloc::collections::BTreeMap;
15644    if plan_with_old.is_empty() {
15645        return Ok(Vec::new());
15646    }
15647    // For each child table we may touch, build per-child step
15648    // lists. UPDATE never deletes children — `delete_plan` stays
15649    // empty here but is kept structurally aligned with
15650    // `plan_fk_parent_deletions` for future use.
15651    let delete_plan: BTreeMap<String, alloc::collections::BTreeSet<usize>> = BTreeMap::new();
15652    let mut setnull_plan: BTreeMap<String, alloc::collections::BTreeSet<(usize, usize)>> =
15653        BTreeMap::new();
15654    let mut setdefault_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
15655    // Cascade-update plan: child_table → row_idx → col_idx → new_value
15656    let mut cascade_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
15657
15658    for child_name in catalog.table_names() {
15659        let child = catalog
15660            .get(&child_name)
15661            .expect("table_names → catalog.get total");
15662        for fk in &child.schema().foreign_keys {
15663            if fk.parent_table != parent_table_name {
15664                continue;
15665            }
15666            for (_pos, old_row, new_row) in plan_with_old {
15667                // Did any parent FK column change?
15668                let key_changed = fk
15669                    .parent_columns
15670                    .iter()
15671                    .any(|&pi| old_row.get(pi) != new_row.get(pi));
15672                if !key_changed {
15673                    continue;
15674                }
15675                // The OLD parent key — used to find referring children.
15676                let old_key: Vec<&Value> =
15677                    fk.parent_columns.iter().map(|&pi| &old_row[pi]).collect();
15678                if old_key.iter().any(|v| matches!(v, Value::Null)) {
15679                    // NULL parent has no children — skip.
15680                    continue;
15681                }
15682                let new_key: Vec<&Value> =
15683                    fk.parent_columns.iter().map(|&pi| &new_row[pi]).collect();
15684                for (child_row_idx, child_row) in child.rows().iter().enumerate() {
15685                    // Self-ref same-row updates: a row updating its
15686                    // own PK doesn't restrict itself.
15687                    if child_name == parent_table_name
15688                        && plan_with_old.iter().any(|(p, _, _)| *p == child_row_idx)
15689                    {
15690                        continue;
15691                    }
15692                    let matches_key = fk
15693                        .local_columns
15694                        .iter()
15695                        .enumerate()
15696                        .all(|(i, &li)| child_row.values.get(li) == Some(old_key[i]));
15697                    if !matches_key {
15698                        continue;
15699                    }
15700                    match fk.on_update {
15701                        spg_storage::FkAction::Restrict | spg_storage::FkAction::NoAction => {
15702                            return Err(EngineError::Unsupported(alloc::format!(
15703                                "FOREIGN KEY violation: UPDATE on {parent_table_name:?} PK is \
15704                                 restricted by FK from {child_name:?}.{:?}",
15705                                fk.local_columns,
15706                            )));
15707                        }
15708                        spg_storage::FkAction::Cascade => {
15709                            // Rewrite child FK columns to new key.
15710                            let entry = cascade_plan.entry(child_name.clone()).or_default();
15711                            for (i, &li) in fk.local_columns.iter().enumerate() {
15712                                entry.insert((child_row_idx, li), new_key[i].clone());
15713                            }
15714                        }
15715                        spg_storage::FkAction::SetNull => {
15716                            for &li in &fk.local_columns {
15717                                let col = child.schema().columns.get(li).ok_or_else(|| {
15718                                    EngineError::Unsupported(alloc::format!(
15719                                        "FK local column {li} missing in {child_name:?}"
15720                                    ))
15721                                })?;
15722                                if !col.nullable {
15723                                    return Err(EngineError::Unsupported(alloc::format!(
15724                                        "FOREIGN KEY ON UPDATE SET NULL: column \
15725                                         {child_name:?}.{:?} is NOT NULL",
15726                                        col.name,
15727                                    )));
15728                                }
15729                            }
15730                            let entry = setnull_plan.entry(child_name.clone()).or_default();
15731                            for &li in &fk.local_columns {
15732                                entry.insert((child_row_idx, li));
15733                            }
15734                        }
15735                        spg_storage::FkAction::SetDefault => {
15736                            let entry = setdefault_plan.entry(child_name.clone()).or_default();
15737                            for &li in &fk.local_columns {
15738                                let col = child.schema().columns.get(li).ok_or_else(|| {
15739                                    EngineError::Unsupported(alloc::format!(
15740                                        "FK local column {li} missing in {child_name:?}"
15741                                    ))
15742                                })?;
15743                                let default = col.default.clone().ok_or_else(|| {
15744                                    EngineError::Unsupported(alloc::format!(
15745                                        "FOREIGN KEY ON UPDATE SET DEFAULT: column \
15746                                         {child_name:?}.{:?} has no DEFAULT",
15747                                        col.name,
15748                                    ))
15749                                })?;
15750                                entry.insert((child_row_idx, li), default);
15751                            }
15752                        }
15753                    }
15754                }
15755            }
15756        }
15757    }
15758    // Flatten into FkChildStep list. UPDATE doesn't produce
15759    // DeleteSteps (CASCADE on UPDATE just rewrites FK values).
15760    let mut steps: Vec<FkChildStep> = Vec::new();
15761    for (child_table, entries) in cascade_plan {
15762        let mut positions = Vec::with_capacity(entries.len());
15763        let mut columns = Vec::with_capacity(entries.len());
15764        let mut defaults = Vec::with_capacity(entries.len());
15765        for ((p, c), v) in entries {
15766            positions.push(p);
15767            columns.push(c);
15768            defaults.push(v);
15769        }
15770        // We reuse `FkChildAction::SetDefault` for cascade-update:
15771        // both shapes are "write a known value into specific cells"
15772        // — `apply_per_cell_writes` doesn't care whether the value
15773        // came from a DEFAULT declaration or a new parent key.
15774        steps.push(FkChildStep {
15775            child_table,
15776            action: FkChildAction::SetDefault {
15777                positions,
15778                columns,
15779                defaults,
15780            },
15781        });
15782    }
15783    for (child_table, entries) in setnull_plan {
15784        let (positions, columns): (Vec<usize>, Vec<usize>) = entries.into_iter().unzip();
15785        steps.push(FkChildStep {
15786            child_table,
15787            action: FkChildAction::SetNull { positions, columns },
15788        });
15789    }
15790    for (child_table, entries) in setdefault_plan {
15791        let mut positions = Vec::with_capacity(entries.len());
15792        let mut columns = Vec::with_capacity(entries.len());
15793        let mut defaults = Vec::with_capacity(entries.len());
15794        for ((p, c), v) in entries {
15795            positions.push(p);
15796            columns.push(c);
15797            defaults.push(v);
15798        }
15799        steps.push(FkChildStep {
15800            child_table,
15801            action: FkChildAction::SetDefault {
15802                positions,
15803                columns,
15804                defaults,
15805            },
15806        });
15807    }
15808    let _ = delete_plan; // UPDATE never deletes children.
15809    Ok(steps)
15810}
15811
15812/// v7.6.5 — apply one FK child step to the catalog. Encapsulates
15813/// the three action variants so the DELETE executor stays a
15814/// simple loop over the planned steps.
15815fn apply_fk_child_step(catalog: &mut Catalog, step: &FkChildStep) -> Result<(), EngineError> {
15816    let child = catalog.get_mut(&step.child_table).ok_or_else(|| {
15817        EngineError::Storage(StorageError::TableNotFound {
15818            name: step.child_table.clone(),
15819        })
15820    })?;
15821    match &step.action {
15822        FkChildAction::Delete { positions } => {
15823            let _ = child.delete_rows(positions);
15824        }
15825        FkChildAction::SetNull { positions, columns } => {
15826            apply_per_cell_writes(child, positions, columns, |_| Value::Null)?;
15827        }
15828        FkChildAction::SetDefault {
15829            positions,
15830            columns,
15831            defaults,
15832        } => {
15833            apply_per_cell_writes(child, positions, columns, |i| defaults[i].clone())?;
15834        }
15835    }
15836    Ok(())
15837}
15838
15839/// v7.6.5 — write new values into selected child cells via
15840/// `Table::update_row` (the catalog's existing UPDATE entry).
15841/// Groups writes by row position so multi-column updates on the
15842/// same row only call `update_row` once. `value_for(i)` produces
15843/// the new value for the i-th (position, column) entry.
15844fn apply_per_cell_writes(
15845    child: &mut spg_storage::Table,
15846    positions: &[usize],
15847    columns: &[usize],
15848    mut value_for: impl FnMut(usize) -> Value,
15849) -> Result<(), EngineError> {
15850    use alloc::collections::BTreeMap;
15851    let mut by_row: BTreeMap<usize, Vec<(usize, Value)>> = BTreeMap::new();
15852    for i in 0..positions.len() {
15853        by_row
15854            .entry(positions[i])
15855            .or_default()
15856            .push((columns[i], value_for(i)));
15857    }
15858    for (pos, mutations) in by_row {
15859        let mut new_values = child.rows()[pos].values.clone();
15860        for (col, v) in mutations {
15861            if let Some(slot) = new_values.get_mut(col) {
15862                *slot = v;
15863            }
15864        }
15865        child
15866            .update_row(pos, new_values)
15867            .map_err(EngineError::Storage)?;
15868    }
15869    Ok(())
15870}
15871
15872fn fk_action_sql_to_storage(a: spg_sql::ast::FkAction) -> spg_storage::FkAction {
15873    match a {
15874        spg_sql::ast::FkAction::Restrict => spg_storage::FkAction::Restrict,
15875        spg_sql::ast::FkAction::Cascade => spg_storage::FkAction::Cascade,
15876        spg_sql::ast::FkAction::SetNull => spg_storage::FkAction::SetNull,
15877        spg_sql::ast::FkAction::SetDefault => spg_storage::FkAction::SetDefault,
15878        spg_sql::ast::FkAction::NoAction => spg_storage::FkAction::NoAction,
15879    }
15880}
15881
15882/// v7.9.21 — resolve a column's DEFAULT for INSERT-time
15883/// default-fill. Free fn (rather than `&self`) so callers
15884/// with an active `&mut Table` borrow can still use it.
15885/// Literal defaults take the cached path (`col.default`);
15886/// runtime defaults hit `clock_fn` at each call. mailrs G4.
15887fn resolve_column_default_free(
15888    col: &ColumnSchema,
15889    clock_fn: Option<ClockFn>,
15890) -> Result<Value, EngineError> {
15891    if let Some(rt) = &col.runtime_default {
15892        return eval_runtime_default_free(rt, col.ty, clock_fn);
15893    }
15894    Ok(col.default.clone().unwrap_or(Value::Null))
15895}
15896
15897fn eval_runtime_default_free(
15898    rt: &str,
15899    ty: DataType,
15900    clock_fn: Option<ClockFn>,
15901) -> Result<Value, EngineError> {
15902    let s = rt.trim().to_ascii_lowercase();
15903    // v7.17.0 Phase 2.1 — also strip `(N)` precision suffix
15904    // so MySQL `CURRENT_TIMESTAMP(6)` resolves the same as
15905    // bare `CURRENT_TIMESTAMP`. SPG stores TIMESTAMP at fixed
15906    // microsecond resolution; the precision modifier is
15907    // parser-only.
15908    let with_no_parens = s.trim_end_matches("()");
15909    let canonical: &str = if let Some(open_idx) = with_no_parens.find('(') {
15910        if with_no_parens.ends_with(')') {
15911            &with_no_parens[..open_idx]
15912        } else {
15913            with_no_parens
15914        }
15915    } else {
15916        with_no_parens
15917    };
15918    let now_us = match clock_fn {
15919        Some(f) => f(),
15920        None => 0,
15921    };
15922    let v = match canonical {
15923        "now" | "current_timestamp" | "localtimestamp" => Value::Timestamp(now_us),
15924        "current_date" => Value::Date((now_us / 86_400_000_000) as i32),
15925        "current_time" | "localtime" => Value::Timestamp(now_us),
15926        // v7.17.0 — UUID generators in DEFAULT clauses. Required
15927        // for the canonical Django / Rails / Hibernate `id UUID
15928        // PRIMARY KEY DEFAULT gen_random_uuid()` pattern. Each
15929        // INSERT evaluates the function fresh; the per-row UUID
15930        // is the storage value, not a cached literal.
15931        "gen_random_uuid" | "uuid_generate_v4" => Value::Uuid(eval::gen_random_uuid_bytes()),
15932        other => {
15933            return Err(EngineError::Unsupported(alloc::format!(
15934                "runtime DEFAULT expression {other:?} not supported \
15935                 (v7.17.0 whitelist: now() / current_timestamp / \
15936                 current_date / current_time / localtimestamp / \
15937                 localtime / gen_random_uuid() / \
15938                 uuid_generate_v4())"
15939            )));
15940        }
15941    };
15942    coerce_value(v, ty, "DEFAULT", 0)
15943}
15944
15945/// v7.9.21 — true when a DEFAULT expression needs INSERT-time
15946/// evaluation rather than being cacheable as a literal Value.
15947/// FunctionCall is the immediate case (`now()`,
15948/// `current_timestamp`). Literal expressions and simple sign-
15949/// flipped numerics still take the static-cache path.
15950fn is_runtime_default_expr(expr: &Expr) -> bool {
15951    match expr {
15952        Expr::FunctionCall { .. } => true,
15953        Expr::Unary { expr, .. } => is_runtime_default_expr(expr),
15954        _ => false,
15955    }
15956}
15957
15958/// v7.17.0 Phase 1.4 — INSERT/UPDATE-time enum label check. When
15959/// `col_idx` has a registered label list, the cell value must be
15960/// NULL or one of the labels (case-sensitive per PG).
15961/// v7.17.0 Phase 3.P0-37 — validate + canonicalise a MySQL inline
15962/// SET cell. For non-SET columns this is a no-op pass-through.
15963///
15964/// Semantics:
15965///   * NULL preserved.
15966///   * Empty string → `''` (zero flags).
15967///   * Otherwise split on ',', trim each token, validate every
15968///     token against the column's variant list (error on miss),
15969///     de-dup, then re-emit in DEFINITION order joined by ','.
15970fn canonicalize_set_value(
15971    lookup: &alloc::collections::BTreeMap<usize, Vec<String>>,
15972    col_idx: usize,
15973    col_name: &str,
15974    value: Value,
15975) -> Result<Value, EngineError> {
15976    let Some(variants) = lookup.get(&col_idx) else {
15977        return Ok(value);
15978    };
15979    match value {
15980        Value::Null => Ok(Value::Null),
15981        Value::Text(s) => {
15982            if s.is_empty() {
15983                return Ok(Value::Text(alloc::string::String::new()));
15984            }
15985            // Collect a presence-set of variant indices to keep
15986            // definition order + handle de-dup in one pass.
15987            let mut present = alloc::vec![false; variants.len()];
15988            for raw in s.split(',') {
15989                let tok = raw.trim();
15990                if tok.is_empty() {
15991                    continue;
15992                }
15993                let idx = variants.iter().position(|v| v == tok).ok_or_else(|| {
15994                    EngineError::Unsupported(alloc::format!(
15995                        "column {col_name:?}: invalid SET token {tok:?}; \
15996                         allowed: {variants:?}"
15997                    ))
15998                })?;
15999                present[idx] = true;
16000            }
16001            // Re-emit in definition order.
16002            let mut out = alloc::string::String::new();
16003            let mut first = true;
16004            for (i, keep) in present.iter().enumerate() {
16005                if !keep {
16006                    continue;
16007                }
16008                if !first {
16009                    out.push(',');
16010                }
16011                first = false;
16012                out.push_str(&variants[i]);
16013            }
16014            Ok(Value::Text(out))
16015        }
16016        other => Err(EngineError::Unsupported(alloc::format!(
16017            "column {col_name:?}: SET-typed column expects TEXT, got {:?}",
16018            other.data_type()
16019        ))),
16020    }
16021}
16022
16023fn enforce_enum_label(
16024    lookup: &alloc::collections::BTreeMap<usize, Vec<String>>,
16025    col_idx: usize,
16026    col_name: &str,
16027    value: &Value,
16028) -> Result<(), EngineError> {
16029    if let Some(labels) = lookup.get(&col_idx) {
16030        match value {
16031            Value::Null => Ok(()),
16032            Value::Text(s) => {
16033                if labels.iter().any(|l| l == s) {
16034                    Ok(())
16035                } else {
16036                    Err(EngineError::Unsupported(alloc::format!(
16037                        "column {col_name:?}: invalid enum label {s:?}; allowed: {labels:?}"
16038                    )))
16039                }
16040            }
16041            other => Err(EngineError::Unsupported(alloc::format!(
16042                "column {col_name:?}: enum-typed column expects TEXT, got {:?}",
16043                other.data_type()
16044            ))),
16045        }
16046    } else {
16047        Ok(())
16048    }
16049}
16050
16051fn column_def_to_schema(c: ColumnDef) -> Result<ColumnSchema, EngineError> {
16052    let ty = column_type_to_data_type(c.ty);
16053    let mut schema = ColumnSchema::new(c.name.clone(), ty, c.nullable);
16054    // user_type_ref is the raw ident the parser couldn't resolve
16055    // to a built-in; classification into enum vs domain happens
16056    // at exec_create_table where we have catalog access. We
16057    // park it temporarily as user_enum_type and the engine
16058    // promotes domain bindings to user_domain_type before the
16059    // table is stored.
16060    if let Some(name) = c.user_type_ref {
16061        schema.user_enum_type = Some(name);
16062    }
16063    // v7.17.0 Phase 2.1 — render the ON UPDATE expression to
16064    // canonical text (the engine re-parses at UPDATE time).
16065    if let Some(expr) = c.on_update_runtime {
16066        schema.on_update_runtime = Some(alloc::format!("{expr}"));
16067    }
16068    // v7.17.0 Phase 2.5 — bridge the AST `Collation` enum to the
16069    // storage one. Same variants, different crates (spg-storage
16070    // owns no dep on spg-sql).
16071    schema.collation = match c.collation {
16072        spg_sql::ast::Collation::Binary => spg_storage::Collation::Binary,
16073        spg_sql::ast::Collation::CaseInsensitive => spg_storage::Collation::CaseInsensitive,
16074    };
16075    // v7.17.0 Phase 4.4 — MySQL `UNSIGNED` flag propagates to
16076    // storage so engine INSERT / UPDATE can range-check.
16077    schema.is_unsigned = c.is_unsigned;
16078    // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM variant list.
16079    // INSERT validation lives in coerce_value (Text → Text path
16080    // with the column's variant list as the accept-set).
16081    schema.inline_enum_variants = c.inline_enum_variants;
16082    // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant list.
16083    // INSERT canonicalisation (de-dup + sort by definition order)
16084    // lives in the exec_insert path next to the ENUM check.
16085    schema.inline_set_variants = c.inline_set_variants;
16086    if let Some(default_expr) = c.default {
16087        // v7.9.21 — distinguish literal defaults (evaluated once
16088        // at CREATE TABLE) from expression defaults (deferred to
16089        // INSERT). Function calls (`now()`, `current_timestamp`
16090        // — see v7.9.20 keyword promotion) take the runtime path.
16091        // Literals continue to cache. mailrs G4.
16092        if is_runtime_default_expr(&default_expr) {
16093            let display = alloc::format!("{default_expr}");
16094            schema = schema.with_runtime_default(display);
16095        } else {
16096            let raw = literal_expr_to_value(default_expr)?;
16097            let coerced = coerce_value(raw, ty, &c.name, 0)?;
16098            schema = schema.with_default(coerced);
16099        }
16100    }
16101    if c.auto_increment {
16102        // AUTO_INCREMENT only makes sense on integer-shaped columns.
16103        if !matches!(ty, DataType::SmallInt | DataType::Int | DataType::BigInt) {
16104            return Err(EngineError::Unsupported(alloc::format!(
16105                "AUTO_INCREMENT requires an integer column type, got {ty:?}"
16106            )));
16107        }
16108        schema = schema.with_auto_increment();
16109    }
16110    Ok(schema)
16111}
16112
16113/// v7.10.4 — decode a BYTEA literal. Accepts:
16114///   * `\xDEADBEEF` (case-insensitive hex; whitespace stripped)
16115///   * `Hello\000world` (backslash escape form; `\\` for literal backslash)
16116///   * Anything else → raw UTF-8 bytes of the input (PG accepts this too).
16117fn decode_bytea_literal(s: &str) -> Result<alloc::vec::Vec<u8>, &'static str> {
16118    let s = s.trim();
16119    if let Some(hex) = s.strip_prefix("\\x").or_else(|| s.strip_prefix("\\X")) {
16120        // Hex form. Each pair of hex digits → one byte.
16121        let cleaned: alloc::string::String = hex.chars().filter(|c| !c.is_whitespace()).collect();
16122        if cleaned.len() % 2 != 0 {
16123            return Err("odd-length hex literal");
16124        }
16125        let mut out = alloc::vec::Vec::with_capacity(cleaned.len() / 2);
16126        let cleaned_bytes = cleaned.as_bytes();
16127        for i in (0..cleaned_bytes.len()).step_by(2) {
16128            let hi = hex_nibble(cleaned_bytes[i])?;
16129            let lo = hex_nibble(cleaned_bytes[i + 1])?;
16130            out.push((hi << 4) | lo);
16131        }
16132        return Ok(out);
16133    }
16134    // Escape form or raw. Walk char-by-char; `\\` and `\NNN` octal
16135    // sequences decode; anything else is a literal byte.
16136    let bytes = s.as_bytes();
16137    let mut out = alloc::vec::Vec::with_capacity(bytes.len());
16138    let mut i = 0;
16139    while i < bytes.len() {
16140        let b = bytes[i];
16141        if b == b'\\' && i + 1 < bytes.len() {
16142            let n = bytes[i + 1];
16143            if n == b'\\' {
16144                out.push(b'\\');
16145                i += 2;
16146                continue;
16147            }
16148            if n.is_ascii_digit()
16149                && i + 3 < bytes.len()
16150                && bytes[i + 2].is_ascii_digit()
16151                && bytes[i + 3].is_ascii_digit()
16152            {
16153                let oct = |x: u8| (x - b'0') as u32;
16154                let v = oct(n) * 64 + oct(bytes[i + 2]) * 8 + oct(bytes[i + 3]);
16155                if v <= 0xFF {
16156                    out.push(v as u8);
16157                    i += 4;
16158                    continue;
16159                }
16160            }
16161        }
16162        out.push(b);
16163        i += 1;
16164    }
16165    Ok(out)
16166}
16167
16168fn hex_nibble(b: u8) -> Result<u8, &'static str> {
16169    match b {
16170        b'0'..=b'9' => Ok(b - b'0'),
16171        b'a'..=b'f' => Ok(b - b'a' + 10),
16172        b'A'..=b'F' => Ok(b - b'A' + 10),
16173        _ => Err("invalid hex digit"),
16174    }
16175}
16176
16177/// v7.10.11 — decode a PG TEXT[] external array form
16178/// (`{a,b,NULL}` with optional double-quoted elements). The
16179/// engine takes a leading/trailing `{`/`}` and splits at commas.
16180/// Quoted elements (`"hello, world"`) preserve embedded commas;
16181/// `\\` and `\"` decode to literal backslash / quote. Plain
16182/// unquoted `NULL` (case-insensitive) maps to `None`.
16183/// v7.11.13 — pick the array type for `ARRAY[lit, …]` from the
16184/// element values. Single-element-type rules:
16185///   - all NULL / all Text → TextArray
16186///   - all Int (or Int+NULL) → IntArray
16187///   - any BigInt without Text → BigIntArray (widening)
16188///   - any Text → TextArray (fallback; non-string elements
16189///     render as text)
16190fn array_literal_widen(items: alloc::vec::Vec<Value>) -> Value {
16191    let mut has_text = false;
16192    let mut has_bigint = false;
16193    let mut has_int = false;
16194    for v in &items {
16195        match v {
16196            Value::Null => {}
16197            Value::Text(_) | Value::Json(_) => has_text = true,
16198            Value::BigInt(_) => has_bigint = true,
16199            Value::Int(_) | Value::SmallInt(_) => has_int = true,
16200            _ => has_text = true,
16201        }
16202    }
16203    if has_text || (!has_bigint && !has_int) {
16204        let out: alloc::vec::Vec<Option<alloc::string::String>> = items
16205            .into_iter()
16206            .map(|v| match v {
16207                Value::Null => None,
16208                Value::Text(s) | Value::Json(s) => Some(s),
16209                other => Some(alloc::format!("{other:?}")),
16210            })
16211            .collect();
16212        return Value::TextArray(out);
16213    }
16214    if has_bigint {
16215        let out: alloc::vec::Vec<Option<i64>> = items
16216            .into_iter()
16217            .map(|v| match v {
16218                Value::Null => None,
16219                Value::Int(n) => Some(i64::from(n)),
16220                Value::SmallInt(n) => Some(i64::from(n)),
16221                Value::BigInt(n) => Some(n),
16222                _ => unreachable!("widen: unexpected non-integer in BigInt path"),
16223            })
16224            .collect();
16225        return Value::BigIntArray(out);
16226    }
16227    let out: alloc::vec::Vec<Option<i32>> = items
16228        .into_iter()
16229        .map(|v| match v {
16230            Value::Null => None,
16231            Value::Int(n) => Some(n),
16232            Value::SmallInt(n) => Some(i32::from(n)),
16233            _ => unreachable!("widen: unexpected non-i32-compatible in Int path"),
16234        })
16235        .collect();
16236    Value::IntArray(out)
16237}
16238
16239fn decode_text_array_literal(
16240    s: &str,
16241) -> Result<alloc::vec::Vec<Option<alloc::string::String>>, &'static str> {
16242    let trimmed = s.trim();
16243    let inner = trimmed
16244        .strip_prefix('{')
16245        .and_then(|x| x.strip_suffix('}'))
16246        .ok_or("TEXT[] literal must be enclosed in '{...}'")?;
16247    let mut out: alloc::vec::Vec<Option<alloc::string::String>> = alloc::vec::Vec::new();
16248    if inner.trim().is_empty() {
16249        return Ok(out);
16250    }
16251    let bytes = inner.as_bytes();
16252    let mut i = 0;
16253    while i <= bytes.len() {
16254        // Skip leading whitespace.
16255        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
16256            i += 1;
16257        }
16258        // Quoted element.
16259        if i < bytes.len() && bytes[i] == b'"' {
16260            i += 1; // open quote
16261            let mut buf = alloc::string::String::new();
16262            while i < bytes.len() && bytes[i] != b'"' {
16263                if bytes[i] == b'\\' && i + 1 < bytes.len() {
16264                    buf.push(bytes[i + 1] as char);
16265                    i += 2;
16266                } else {
16267                    buf.push(bytes[i] as char);
16268                    i += 1;
16269                }
16270            }
16271            if i >= bytes.len() {
16272                return Err("unterminated quoted element");
16273            }
16274            i += 1; // close quote
16275            out.push(Some(buf));
16276        } else {
16277            // Unquoted element — read until next comma or end.
16278            let start = i;
16279            while i < bytes.len() && bytes[i] != b',' {
16280                i += 1;
16281            }
16282            let raw = inner[start..i].trim();
16283            if raw.eq_ignore_ascii_case("NULL") {
16284                out.push(None);
16285            } else {
16286                out.push(Some(alloc::string::ToString::to_string(raw)));
16287            }
16288        }
16289        // Skip whitespace, expect comma or end.
16290        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
16291            i += 1;
16292        }
16293        if i >= bytes.len() {
16294            break;
16295        }
16296        if bytes[i] != b',' {
16297            return Err("expected ',' between TEXT[] elements");
16298        }
16299        i += 1;
16300    }
16301    Ok(out)
16302}
16303
16304/// v7.10.11 — encode a TEXT[] back into the PG external array
16305/// form. NULL elements become the literal `NULL`; elements
16306/// containing commas, quotes, backslashes, or braces are
16307/// double-quoted with `\\` / `\"` escapes.
16308fn encode_text_array(items: &[Option<alloc::string::String>]) -> alloc::string::String {
16309    let mut out = alloc::string::String::with_capacity(2 + items.len() * 8);
16310    out.push('{');
16311    for (i, item) in items.iter().enumerate() {
16312        if i > 0 {
16313            out.push(',');
16314        }
16315        match item {
16316            None => out.push_str("NULL"),
16317            Some(s) => {
16318                let needs_quote = s.is_empty()
16319                    || s.eq_ignore_ascii_case("NULL")
16320                    || s.chars()
16321                        .any(|c| matches!(c, ',' | '{' | '}' | '"' | '\\' | ' ' | '\t'));
16322                if needs_quote {
16323                    out.push('"');
16324                    for c in s.chars() {
16325                        if c == '"' || c == '\\' {
16326                            out.push('\\');
16327                        }
16328                        out.push(c);
16329                    }
16330                    out.push('"');
16331                } else {
16332                    out.push_str(s);
16333                }
16334            }
16335        }
16336    }
16337    out.push('}');
16338    out
16339}
16340
16341/// v7.10.4 — encode BYTEA bytes in PG hex output format
16342/// (`\x` prefix, lowercase hex pairs). Used by Text-side
16343/// round-trip + the wire layer's text-mode encoder.
16344fn encode_bytea_hex(b: &[u8]) -> alloc::string::String {
16345    let mut out = alloc::string::String::with_capacity(2 + 2 * b.len());
16346    out.push_str("\\x");
16347    for byte in b {
16348        let hi = byte >> 4;
16349        let lo = byte & 0x0F;
16350        out.push(hex_digit(hi));
16351        out.push(hex_digit(lo));
16352    }
16353    out
16354}
16355
16356const fn hex_digit(n: u8) -> char {
16357    match n {
16358        0..=9 => (b'0' + n) as char,
16359        10..=15 => (b'a' + n - 10) as char,
16360        _ => '?',
16361    }
16362}
16363
16364/// v7.17.0 Phase 3.P0-39 — parse a PG `hstore` text literal into
16365/// a flat key→value map. Empty string → empty map. Duplicate
16366/// keys take last-write-wins (matches PG `hstore_in`).
16367///
16368/// Accepted shapes (minimal subset):
16369///   * `'a=>1, b=>2'`            — bareword keys/values
16370///   * `'"a"=>"1", "b"=>"2"'`    — quoted keys/values
16371///   * `'a=>NULL'`               — case-insensitive NULL token
16372///     surfaces as `None` (no quotes around NULL)
16373///
16374/// Returns None on parse failure → caller surfaces as hard error.
16375fn parse_hstore_str(
16376    s: &str,
16377) -> Option<Vec<(alloc::string::String, Option<alloc::string::String>)>> {
16378    let bytes = s.as_bytes();
16379    let mut i = 0;
16380    let mut out: Vec<(alloc::string::String, Option<alloc::string::String>)> = Vec::new();
16381    let skip_ws = |bytes: &[u8], i: &mut usize| {
16382        while *i < bytes.len() && matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r') {
16383            *i += 1;
16384        }
16385    };
16386    let parse_token = |bytes: &[u8], i: &mut usize| -> Option<alloc::string::String> {
16387        if *i >= bytes.len() {
16388            return None;
16389        }
16390        if bytes[*i] == b'"' {
16391            *i += 1;
16392            let mut out = alloc::string::String::new();
16393            while *i < bytes.len() {
16394                match bytes[*i] {
16395                    b'"' => {
16396                        *i += 1;
16397                        return Some(out);
16398                    }
16399                    b'\\' if *i + 1 < bytes.len() => {
16400                        out.push(bytes[*i + 1] as char);
16401                        *i += 2;
16402                    }
16403                    c => {
16404                        out.push(c as char);
16405                        *i += 1;
16406                    }
16407                }
16408            }
16409            None
16410        } else {
16411            let start = *i;
16412            while *i < bytes.len()
16413                && !matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r' | b',' | b'=')
16414            {
16415                *i += 1;
16416            }
16417            if *i == start {
16418                return None;
16419            }
16420            Some(alloc::str::from_utf8(&bytes[start..*i]).ok()?.to_string())
16421        }
16422    };
16423    skip_ws(bytes, &mut i);
16424    while i < bytes.len() {
16425        let key = parse_token(bytes, &mut i)?;
16426        skip_ws(bytes, &mut i);
16427        if i + 1 >= bytes.len() || bytes[i] != b'=' || bytes[i + 1] != b'>' {
16428            return None;
16429        }
16430        i += 2;
16431        skip_ws(bytes, &mut i);
16432        // Check for unquoted NULL token (case-insensitive).
16433        let val_token = if i + 4 <= bytes.len()
16434            && bytes[i..i + 4].eq_ignore_ascii_case(b"NULL")
16435            && (i + 4 == bytes.len() || matches!(bytes[i + 4], b' ' | b'\t' | b',' | b'\n' | b'\r'))
16436        {
16437            i += 4;
16438            None
16439        } else {
16440            Some(parse_token(bytes, &mut i)?)
16441        };
16442        // Replace any existing entry with the same key (last-wins).
16443        if let Some(pos) = out.iter().position(|(k, _)| k == &key) {
16444            out[pos] = (key, val_token);
16445        } else {
16446            out.push((key, val_token));
16447        }
16448        skip_ws(bytes, &mut i);
16449        if i >= bytes.len() {
16450            break;
16451        }
16452        if bytes[i] == b',' {
16453            i += 1;
16454            skip_ws(bytes, &mut i);
16455            continue;
16456        }
16457        return None;
16458    }
16459    Some(out)
16460}
16461
16462/// v7.17.0 Phase 3.P0-39 — render a hstore as canonical PG text
16463/// form `"k"=>"v"` (keys and non-NULL values always quoted;
16464/// NULL token is bare).
16465fn format_hstore_str(
16466    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
16467) -> alloc::string::String {
16468    let mut out = alloc::string::String::new();
16469    for (i, (k, v)) in pairs.iter().enumerate() {
16470        if i > 0 {
16471            out.push_str(", ");
16472        }
16473        out.push('"');
16474        out.push_str(k);
16475        out.push_str("\"=>");
16476        match v {
16477            None => out.push_str("NULL"),
16478            Some(val) => {
16479                out.push('"');
16480                out.push_str(val);
16481                out.push('"');
16482            }
16483        }
16484    }
16485    out
16486}
16487
16488/// v7.17.0 Phase 3.P0-39 — pub re-export so pgwire + sqllogictest
16489/// share the single hstore renderer.
16490pub fn format_hstore_text(
16491    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
16492) -> alloc::string::String {
16493    format_hstore_str(pairs)
16494}
16495
16496// ─── v7.17.0 Phase 3.P0-40 — 2D array parse + display ─────────
16497
16498/// Split a PG external 2D-array literal `'{{a,b},{c,d}}'` into
16499/// per-row token lists. Returns Err on shape mismatch.
16500fn split_2d_literal(s: &str) -> Result<Vec<Vec<alloc::string::String>>, &'static str> {
16501    let s = s.trim();
16502    let outer = s
16503        .strip_prefix('{')
16504        .and_then(|x| x.strip_suffix('}'))
16505        .ok_or("missing outer '{...}' braces")?;
16506    let trimmed = outer.trim();
16507    if trimmed.is_empty() {
16508        return Ok(Vec::new());
16509    }
16510    let mut rows: Vec<Vec<alloc::string::String>> = Vec::new();
16511    let mut i = 0;
16512    let bytes = trimmed.as_bytes();
16513    while i < bytes.len() {
16514        while i < bytes.len() && matches!(bytes[i], b' ' | b'\t' | b'\n' | b'\r' | b',') {
16515            i += 1;
16516        }
16517        if i >= bytes.len() {
16518            break;
16519        }
16520        if bytes[i] != b'{' {
16521            return Err("expected '{' opening a row");
16522        }
16523        i += 1;
16524        let row_start = i;
16525        let mut depth = 1;
16526        while i < bytes.len() && depth > 0 {
16527            match bytes[i] {
16528                b'{' => depth += 1,
16529                b'}' => depth -= 1,
16530                _ => {}
16531            }
16532            if depth > 0 {
16533                i += 1;
16534            }
16535        }
16536        if depth != 0 {
16537            return Err("unbalanced '{...}' in row");
16538        }
16539        let row_text = &trimmed[row_start..i];
16540        i += 1;
16541        let cells: Vec<alloc::string::String> = if row_text.trim().is_empty() {
16542            Vec::new()
16543        } else {
16544            row_text.split(',').map(|t| t.trim().to_string()).collect()
16545        };
16546        rows.push(cells);
16547    }
16548    if let Some(first) = rows.first() {
16549        let cols = first.len();
16550        for r in &rows {
16551            if r.len() != cols {
16552                return Err("ragged 2D array (rows have different column counts)");
16553            }
16554        }
16555    }
16556    Ok(rows)
16557}
16558
16559fn parse_int_2d_literal(s: &str) -> Result<Vec<Vec<Option<i32>>>, &'static str> {
16560    let raw = split_2d_literal(s)?;
16561    raw.into_iter()
16562        .map(|row| {
16563            row.into_iter()
16564                .map(|cell| {
16565                    if cell.eq_ignore_ascii_case("NULL") {
16566                        Ok(None)
16567                    } else {
16568                        cell.parse::<i32>()
16569                            .map(Some)
16570                            .map_err(|_| "invalid int element")
16571                    }
16572                })
16573                .collect()
16574        })
16575        .collect()
16576}
16577
16578fn parse_bigint_2d_literal(s: &str) -> Result<Vec<Vec<Option<i64>>>, &'static str> {
16579    let raw = split_2d_literal(s)?;
16580    raw.into_iter()
16581        .map(|row| {
16582            row.into_iter()
16583                .map(|cell| {
16584                    if cell.eq_ignore_ascii_case("NULL") {
16585                        Ok(None)
16586                    } else {
16587                        cell.parse::<i64>()
16588                            .map(Some)
16589                            .map_err(|_| "invalid bigint element")
16590                    }
16591                })
16592                .collect()
16593        })
16594        .collect()
16595}
16596
16597fn parse_text_2d_literal(s: &str) -> Result<Vec<Vec<Option<alloc::string::String>>>, &'static str> {
16598    let raw = split_2d_literal(s)?;
16599    Ok(raw
16600        .into_iter()
16601        .map(|row| {
16602            row.into_iter()
16603                .map(|cell| {
16604                    if cell.eq_ignore_ascii_case("NULL") {
16605                        None
16606                    } else {
16607                        Some(cell.trim_matches('"').to_string())
16608                    }
16609                })
16610                .collect()
16611        })
16612        .collect())
16613}
16614
16615fn format_int_2d_text(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
16616    let mut out = alloc::string::String::from("{");
16617    for (i, row) in rows.iter().enumerate() {
16618        if i > 0 {
16619            out.push(',');
16620        }
16621        out.push('{');
16622        for (j, cell) in row.iter().enumerate() {
16623            if j > 0 {
16624                out.push(',');
16625            }
16626            match cell {
16627                None => out.push_str("NULL"),
16628                Some(n) => out.push_str(&alloc::format!("{n}")),
16629            }
16630        }
16631        out.push('}');
16632    }
16633    out.push('}');
16634    out
16635}
16636
16637fn format_bigint_2d_text(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
16638    let mut out = alloc::string::String::from("{");
16639    for (i, row) in rows.iter().enumerate() {
16640        if i > 0 {
16641            out.push(',');
16642        }
16643        out.push('{');
16644        for (j, cell) in row.iter().enumerate() {
16645            if j > 0 {
16646                out.push(',');
16647            }
16648            match cell {
16649                None => out.push_str("NULL"),
16650                Some(n) => out.push_str(&alloc::format!("{n}")),
16651            }
16652        }
16653        out.push('}');
16654    }
16655    out.push('}');
16656    out
16657}
16658
16659fn format_text_2d_text(rows: &[Vec<Option<alloc::string::String>>]) -> alloc::string::String {
16660    let mut out = alloc::string::String::from("{");
16661    for (i, row) in rows.iter().enumerate() {
16662        if i > 0 {
16663            out.push(',');
16664        }
16665        out.push('{');
16666        for (j, cell) in row.iter().enumerate() {
16667            if j > 0 {
16668                out.push(',');
16669            }
16670            match cell {
16671                None => out.push_str("NULL"),
16672                Some(s) => out.push_str(s),
16673            }
16674        }
16675        out.push('}');
16676    }
16677    out.push('}');
16678    out
16679}
16680
16681/// v7.17.0 Phase 3.P0-40 — pub re-exports so pgwire + sqllogictest
16682/// share the single 2D-array renderer.
16683pub fn format_int_2d_text_pub(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
16684    format_int_2d_text(rows)
16685}
16686pub fn format_bigint_2d_text_pub(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
16687    format_bigint_2d_text(rows)
16688}
16689pub fn format_text_2d_text_pub(
16690    rows: &[Vec<Option<alloc::string::String>>],
16691) -> alloc::string::String {
16692    format_text_2d_text(rows)
16693}
16694
16695/// v7.17.0 Phase 3.P0-38 — parse a PG range literal of the form
16696/// `'[lo,up)'` / `'(lo,up]'` / `'[lo,up]'` / `'(lo,up)'` /
16697/// `'empty'`. Lower / upper may be empty (unbounded). Returns
16698/// `None` on any parse failure; caller surfaces as hard error.
16699fn parse_range_str(s: &str, kind: spg_storage::RangeKind) -> Option<Value> {
16700    let s = s.trim();
16701    if s.eq_ignore_ascii_case("empty") {
16702        return Some(Value::Range {
16703            kind,
16704            lower: None,
16705            upper: None,
16706            lower_inc: false,
16707            upper_inc: false,
16708            empty: true,
16709        });
16710    }
16711    let bytes = s.as_bytes();
16712    if bytes.len() < 3 {
16713        return None;
16714    }
16715    let lower_inc = match bytes[0] {
16716        b'[' => true,
16717        b'(' => false,
16718        _ => return None,
16719    };
16720    let upper_inc = match bytes[bytes.len() - 1] {
16721        b']' => true,
16722        b')' => false,
16723        _ => return None,
16724    };
16725    let inner = &s[1..s.len() - 1];
16726    let (lo_text, up_text) = inner.split_once(',')?;
16727    let lower = if lo_text.is_empty() {
16728        None
16729    } else {
16730        Some(alloc::boxed::Box::new(parse_range_element(lo_text, kind)?))
16731    };
16732    let upper = if up_text.is_empty() {
16733        None
16734    } else {
16735        Some(alloc::boxed::Box::new(parse_range_element(up_text, kind)?))
16736    };
16737    Some(Value::Range {
16738        kind,
16739        lower,
16740        upper,
16741        lower_inc,
16742        upper_inc,
16743        empty: false,
16744    })
16745}
16746
16747/// v7.17.0 Phase 3.P0-38 — parse a single range bound text into
16748/// the matching element Value for the RangeKind.
16749fn parse_range_element(text: &str, kind: spg_storage::RangeKind) -> Option<Value> {
16750    let text = text.trim().trim_matches('"');
16751    use spg_storage::RangeKind as K;
16752    match kind {
16753        K::Int4 => text.parse::<i32>().ok().map(Value::Int),
16754        K::Int8 => text.parse::<i64>().ok().map(Value::BigInt),
16755        K::Num => {
16756            // Reuse the Numeric parse via the engine's text-coercion
16757            // path; bail to None on failure.
16758            let dot = text.find('.');
16759            let scale: u8 = dot.map_or(0, |p| (text.len() - p - 1) as u8);
16760            let digits: alloc::string::String = text
16761                .chars()
16762                .filter(|c| *c == '-' || c.is_ascii_digit())
16763                .collect();
16764            let scaled: i128 = digits.parse().ok()?;
16765            Some(Value::Numeric { scaled, scale })
16766        }
16767        K::Ts | K::TsTz => {
16768            // Reuse the existing timestamp parse path. v7.17.0
16769            // expects `'YYYY-MM-DD HH:MM:SS[.ffffff]'` in range
16770            // bounds (TZ offset on TsTz is OOS for the initial
16771            // P0-38; ship plain Timestamp shape).
16772            crate::eval::parse_timestamp_literal(text).map(Value::Timestamp)
16773        }
16774        K::Date => crate::eval::parse_date_literal(text).map(Value::Date),
16775    }
16776}
16777
16778/// v7.17.0 Phase 3.P0-38 — render a Range value as its canonical
16779/// PG text form. Re-exported via [`format_range_text`] for use
16780/// from spg-server's pgwire layer.
16781pub fn format_range_text(v: &Value) -> alloc::string::String {
16782    format_range_str(v)
16783}
16784
16785fn format_range_str(v: &Value) -> alloc::string::String {
16786    let Value::Range {
16787        lower,
16788        upper,
16789        lower_inc,
16790        upper_inc,
16791        empty,
16792        ..
16793    } = v
16794    else {
16795        return alloc::string::String::new();
16796    };
16797    if *empty {
16798        return "empty".into();
16799    }
16800    let mut out = alloc::string::String::new();
16801    out.push(if *lower_inc { '[' } else { '(' });
16802    if let Some(l) = lower {
16803        out.push_str(&format_range_element(l));
16804    }
16805    out.push(',');
16806    if let Some(u) = upper {
16807        out.push_str(&format_range_element(u));
16808    }
16809    out.push(if *upper_inc { ']' } else { ')' });
16810    out
16811}
16812
16813fn format_range_element(v: &Value) -> alloc::string::String {
16814    match v {
16815        Value::Int(n) => alloc::format!("{n}"),
16816        Value::BigInt(n) => alloc::format!("{n}"),
16817        Value::Date(d) => crate::eval::format_date(*d),
16818        Value::Timestamp(t) => crate::eval::format_timestamp(*t),
16819        Value::Numeric { scaled, scale } => crate::eval::format_numeric(*scaled, *scale),
16820        other => alloc::format!("{other:?}"),
16821    }
16822}
16823
16824/// v7.17.0 Phase 3.P0-35 — parse a PG `money` literal into i64
16825/// cents. Accepts:
16826///   * Optional leading `-` (negative)
16827///   * Optional `$` prefix
16828///   * Integer portion with optional `,` thousands separators
16829///   * Optional `.` followed by 1-2 digits (cents); 1 digit
16830///     auto-pads to 2 (`.5` → 50 cents).
16831///
16832/// Returns None on any parse failure — caller surfaces as hard
16833/// SQL error.
16834fn parse_money_str(s: &str) -> Option<i64> {
16835    let s = s.trim();
16836    let (neg, rest) = match s.strip_prefix('-') {
16837        Some(r) => (true, r.trim_start()),
16838        None => (false, s),
16839    };
16840    let rest = rest.strip_prefix('$').unwrap_or(rest).trim_start();
16841    let (int_part, frac_part) = match rest.split_once('.') {
16842        Some((i, f)) => (i, Some(f)),
16843        None => (rest, None),
16844    };
16845    if int_part.is_empty() {
16846        return None;
16847    }
16848    // Validate + strip commas from the integer portion.
16849    let mut int_digits = alloc::string::String::with_capacity(int_part.len());
16850    for b in int_part.bytes() {
16851        match b {
16852            b',' => {}
16853            b'0'..=b'9' => int_digits.push(b as char),
16854            _ => return None,
16855        }
16856    }
16857    if int_digits.is_empty() {
16858        return None;
16859    }
16860    let dollars: i64 = int_digits.parse().ok()?;
16861    let cents: i64 = match frac_part {
16862        None => 0,
16863        Some(f) => {
16864            if f.is_empty() || f.len() > 2 || !f.bytes().all(|b| b.is_ascii_digit()) {
16865                return None;
16866            }
16867            let padded = if f.len() == 1 {
16868                alloc::format!("{f}0")
16869            } else {
16870                f.to_string()
16871            };
16872            padded.parse().ok()?
16873        }
16874    };
16875    let total = dollars.checked_mul(100)?.checked_add(cents)?;
16876    Some(if neg { -total } else { total })
16877}
16878
16879/// v7.17.0 Phase 3.P0-34 — parse a PG `timetz` literal
16880/// `HH:MM:SS[.fraction]±HH[:MM]` into (us, offset_secs).
16881///
16882/// The offset suffix is MANDATORY: SPG doesn't have a session TZ
16883/// wired into eval, so a bare `HH:MM:SS` literal would be
16884/// ambiguous. Returns None for any parse failure or out-of-range
16885/// component — caller surfaces as a hard SQL error.
16886///
16887/// Offset range: ±14 hours (±50400 seconds), matching PG's
16888/// internal limit.
16889fn parse_timetz_str(s: &str) -> Option<(i64, i32)> {
16890    let s = s.trim();
16891    // Find the offset sign — scan from right since the time part
16892    // never contains '+' / '-' (after the optional fractional dot
16893    // it's all digits and ':').
16894    let bytes = s.as_bytes();
16895    let sign_pos = bytes
16896        .iter()
16897        .enumerate()
16898        .rev()
16899        .find(|&(_, &b)| b == b'+' || b == b'-')
16900        .map(|(i, _)| i)?;
16901    if sign_pos == 0 {
16902        return None; // bare sign — no time component
16903    }
16904    let time_part = &s[..sign_pos];
16905    let offset_part = &s[sign_pos..];
16906    let us = parse_time_str(time_part)?;
16907    let sign: i32 = if offset_part.starts_with('+') { 1 } else { -1 };
16908    let offset_body = &offset_part[1..];
16909    let (hh_str, mm_str) = match offset_body.split_once(':') {
16910        Some((h, m)) => (h, m),
16911        None => (offset_body, "0"),
16912    };
16913    let hh: i32 = hh_str.parse().ok()?;
16914    let mm: i32 = mm_str.parse().ok()?;
16915    if !(0..=14).contains(&hh) || !(0..=59).contains(&mm) {
16916        return None;
16917    }
16918    let total = sign * (hh * 3600 + mm * 60);
16919    if total.abs() > 50_400 {
16920        return None;
16921    }
16922    Some((us, total))
16923}
16924
16925/// v7.17.0 Phase 3.P0-33 — funnel an integer literal through MySQL
16926/// YEAR range validation: 0 sentinel or 1901..=2155. Out-of-range
16927/// surfaces as a hard SQL error (no silent truncation, mirrors PG
16928/// `time_in` / `uuid_in` discipline).
16929fn coerce_int_to_year(n: i64, col_name: &str) -> Result<Value, EngineError> {
16930    if n == 0 || (1901..=2155).contains(&n) {
16931        // u16::try_from cannot fail in this range; the cast also
16932        // covers the 0 sentinel.
16933        return Ok(Value::Year(n as u16));
16934    }
16935    Err(EngineError::Eval(EvalError::TypeMismatch {
16936        detail: alloc::format!(
16937            "year value out of range: {n} (column `{col_name}`; \
16938             MySQL accepts 0 or 1901..=2155)"
16939        ),
16940    }))
16941}
16942
16943/// v7.17.0 Phase 3.P0-32 — parse a PG `time` literal
16944/// `HH:MM:SS[.fraction]` into microseconds since 00:00:00.
16945///
16946/// Accepts:
16947///   * `HH:MM:SS`            — exact-second precision
16948///   * `HH:MM:SS.f` .. `.ffffff` — 1-6 fractional digits, right-padded
16949///     with zeros to microseconds
16950///
16951/// Range: hour 0..=23, minute 0..=59, second 0..=59. Anything else
16952/// returns None — caller surfaces as a hard SQL error (no silent
16953/// truncation, matches PG's `time_in` behaviour).
16954fn parse_time_str(s: &str) -> Option<i64> {
16955    let s = s.trim();
16956    let (hms, frac) = match s.split_once('.') {
16957        Some((h, f)) => (h, Some(f)),
16958        None => (s, None),
16959    };
16960    let mut parts = hms.split(':');
16961    let hh: u32 = parts.next()?.parse().ok()?;
16962    let mm: u32 = parts.next()?.parse().ok()?;
16963    let ss: u32 = parts.next()?.parse().ok()?;
16964    if parts.next().is_some() {
16965        return None;
16966    }
16967    if hh > 23 || mm > 59 || ss > 59 {
16968        return None;
16969    }
16970    let frac_us: i64 = match frac {
16971        None => 0,
16972        Some(f) => {
16973            if f.is_empty() || f.len() > 6 || !f.bytes().all(|b| b.is_ascii_digit()) {
16974                return None;
16975            }
16976            // Right-pad with zeros so '.5' = 500000 µsec.
16977            let mut padded = alloc::string::String::with_capacity(6);
16978            padded.push_str(f);
16979            while padded.len() < 6 {
16980                padded.push('0');
16981            }
16982            padded.parse().ok()?
16983        }
16984    };
16985    Some(
16986        i64::from(hh) * 3_600_000_000
16987            + i64::from(mm) * 60_000_000
16988            + i64::from(ss) * 1_000_000
16989            + frac_us,
16990    )
16991}
16992
16993const fn column_type_to_data_type(t: ColumnTypeName) -> DataType {
16994    match t {
16995        ColumnTypeName::SmallInt => DataType::SmallInt,
16996        ColumnTypeName::Int => DataType::Int,
16997        ColumnTypeName::BigInt => DataType::BigInt,
16998        ColumnTypeName::Float => DataType::Float,
16999        ColumnTypeName::Text => DataType::Text,
17000        ColumnTypeName::Varchar(n) => DataType::Varchar(n),
17001        ColumnTypeName::Char(n) => DataType::Char(n),
17002        ColumnTypeName::Bool => DataType::Bool,
17003        ColumnTypeName::Vector { dim, encoding } => DataType::Vector {
17004            dim,
17005            encoding: match encoding {
17006                SqlVecEncoding::F32 => VecEncoding::F32,
17007                SqlVecEncoding::Sq8 => VecEncoding::Sq8,
17008                SqlVecEncoding::F16 => VecEncoding::F16,
17009            },
17010        },
17011        ColumnTypeName::Numeric(precision, scale) => DataType::Numeric { precision, scale },
17012        ColumnTypeName::Date => DataType::Date,
17013        ColumnTypeName::Timestamp => DataType::Timestamp,
17014        ColumnTypeName::Timestamptz => DataType::Timestamptz,
17015        ColumnTypeName::Json => DataType::Json,
17016        ColumnTypeName::Jsonb => DataType::Jsonb,
17017        ColumnTypeName::Bytes => DataType::Bytes,
17018        ColumnTypeName::TextArray => DataType::TextArray,
17019        ColumnTypeName::IntArray => DataType::IntArray,
17020        ColumnTypeName::BigIntArray => DataType::BigIntArray,
17021        ColumnTypeName::TsVector => DataType::TsVector,
17022        ColumnTypeName::TsQuery => DataType::TsQuery,
17023        ColumnTypeName::Uuid => DataType::Uuid,
17024        ColumnTypeName::Time => DataType::Time,
17025        ColumnTypeName::Year => DataType::Year,
17026        ColumnTypeName::TimeTz => DataType::TimeTz,
17027        ColumnTypeName::Money => DataType::Money,
17028        ColumnTypeName::Range(k) => DataType::Range(match k {
17029            spg_sql::ast::RangeKindAst::Int4 => spg_storage::RangeKind::Int4,
17030            spg_sql::ast::RangeKindAst::Int8 => spg_storage::RangeKind::Int8,
17031            spg_sql::ast::RangeKindAst::Num => spg_storage::RangeKind::Num,
17032            spg_sql::ast::RangeKindAst::Ts => spg_storage::RangeKind::Ts,
17033            spg_sql::ast::RangeKindAst::TsTz => spg_storage::RangeKind::TsTz,
17034            spg_sql::ast::RangeKindAst::Date => spg_storage::RangeKind::Date,
17035        }),
17036        ColumnTypeName::Hstore => DataType::Hstore,
17037        ColumnTypeName::IntArray2D => DataType::IntArray2D,
17038        ColumnTypeName::BigIntArray2D => DataType::BigIntArray2D,
17039        ColumnTypeName::TextArray2D => DataType::TextArray2D,
17040    }
17041}
17042
17043/// Convert an INSERT VALUES expression to a storage Value. Supports literal
17044/// expressions, unary-minus over numeric literals, and pgvector-style
17045/// `'[..]'::vector` cast (v1.2). Anything more complex returns `Unsupported`.
17046fn literal_expr_to_value(expr: Expr) -> Result<Value, EngineError> {
17047    match expr {
17048        Expr::Literal(l) => Ok(literal_to_value(l)),
17049        Expr::Cast { expr, target } => {
17050            let inner_value = literal_expr_to_value(*expr)?;
17051            crate::eval::cast_value(inner_value, target).map_err(EngineError::Eval)
17052        }
17053        Expr::Unary {
17054            op: UnOp::Neg,
17055            expr,
17056        } => match *expr {
17057            Expr::Literal(Literal::Integer(n)) => {
17058                // Fold to i32 if it fits, else BigInt. Parser emits Integer(i64)
17059                // — overflow on negate of i64::MIN is the one edge case.
17060                let neg = n.checked_neg().ok_or_else(|| {
17061                    EngineError::Unsupported("integer literal overflow on negation".into())
17062                })?;
17063                Ok(int_value_for(neg))
17064            }
17065            Expr::Literal(Literal::Float(x)) => Ok(Value::Float(-x)),
17066            other => Err(EngineError::Unsupported(alloc::format!(
17067                "unary minus over non-literal expression: {other:?}"
17068            ))),
17069        },
17070        // v7.10.10 — `ARRAY[lit, lit, …]` constructor accepted at
17071        // INSERT-time. Each element must reduce to a Value through
17072        // `literal_expr_to_value`; NULL elements become `None`.
17073        // v7.11.13 — deduce shape from element values: all Int →
17074        // IntArray; any BigInt → BigIntArray (widening); any Text
17075        // → TextArray. Cast targets (`ARRAY[]::INT[]`) flow through
17076        // the outer Cast arm before reaching here and re-coerce.
17077        Expr::Array(items) => {
17078            let mut materialised: alloc::vec::Vec<Value> =
17079                alloc::vec::Vec::with_capacity(items.len());
17080            for elem in items {
17081                materialised.push(literal_expr_to_value(elem)?);
17082            }
17083            Ok(array_literal_widen(materialised))
17084        }
17085        // Any other Expr shape — fall back to a general evaluation
17086        // against an empty row + empty schema. This unblocks the
17087        // app-common patterns where INSERT VALUES carries a
17088        // non-correlated function call:
17089        //   INSERT INTO t VALUES (concat('U-', 42))
17090        //   INSERT INTO t VALUES (now())
17091        //   INSERT INTO t VALUES (format('%s-%s', 'a', 'b'))
17092        // Any expression that references a column or `$N`
17093        // placeholder fails cleanly inside `eval_expr` with a
17094        // descriptive error; literals + casts + ARRAY[…] continue
17095        // to take the fast paths above so the hot INSERT path is
17096        // unchanged on the common case.
17097        other => {
17098            let empty_schema: alloc::vec::Vec<spg_storage::ColumnSchema> = alloc::vec::Vec::new();
17099            let ctx = EvalContext::new(&empty_schema, None);
17100            let empty_row = spg_storage::Row::new(alloc::vec::Vec::new());
17101            crate::eval::eval_expr(&other, &empty_row, &ctx).map_err(EngineError::Eval)
17102        }
17103    }
17104}
17105
17106fn literal_to_value(l: Literal) -> Value {
17107    match l {
17108        Literal::Integer(n) => int_value_for(n),
17109        Literal::Float(x) => Value::Float(x),
17110        Literal::String(s) => Value::Text(s),
17111        Literal::Bool(b) => Value::Bool(b),
17112        Literal::Null => Value::Null,
17113        Literal::Vector(v) => Value::Vector(v),
17114        Literal::TextArray(items) => Value::TextArray(items),
17115        Literal::IntArray(items) => Value::IntArray(items),
17116        Literal::BigIntArray(items) => Value::BigIntArray(items),
17117        Literal::Interval { months, micros, .. } => Value::Interval { months, micros },
17118    }
17119}
17120
17121/// Pick `Int` (`i32`) when the literal fits, else `BigInt`. `INT` vs `BIGINT`
17122/// columns will still enforce the right tag downstream — this is just the
17123/// default we synthesise from an unannotated integer literal.
17124fn int_value_for(n: i64) -> Value {
17125    if let Ok(small) = i32::try_from(n) {
17126        Value::Int(small)
17127    } else {
17128        Value::BigInt(n)
17129    }
17130}
17131
17132/// Widen / narrow `v` to fit `expected`. Numerics permit safe widening
17133/// (`Int → BigInt`, `Int/BigInt → Float`) and best-effort narrowing
17134/// (`BigInt → Int` succeeds only when the value fits in `i32`). Everything
17135/// else returns `TypeMismatch` carrying the column name for caller diagnostics.
17136/// `NULL` is always permitted; the nullability check happens later in storage.
17137#[allow(clippy::too_many_lines)]
17138/// v7.17.0 Phase 4.4 — reject negative integer values on UNSIGNED
17139/// columns. Called after `coerce_value` at each INSERT / UPDATE
17140/// site that has ColumnSchema context. NULL passes through (a
17141/// nullable UNSIGNED column can legitimately hold NULL).
17142fn check_unsigned_range(
17143    v: &Value,
17144    schema: &ColumnSchema,
17145    position: usize,
17146) -> Result<(), EngineError> {
17147    if !schema.is_unsigned {
17148        return Ok(());
17149    }
17150    let n = match v {
17151        Value::SmallInt(x) => i64::from(*x),
17152        Value::Int(x) => i64::from(*x),
17153        Value::BigInt(x) => *x,
17154        _ => return Ok(()), // non-integer cells (NULL, default) skip
17155    };
17156    if n < 0 {
17157        return Err(EngineError::Unsupported(alloc::format!(
17158            "column {:?} is UNSIGNED but got negative value {n} at position {position}",
17159            schema.name
17160        )));
17161    }
17162    Ok(())
17163}
17164
17165fn coerce_value(
17166    v: Value,
17167    expected: DataType,
17168    col_name: &str,
17169    position: usize,
17170) -> Result<Value, EngineError> {
17171    if v.is_null() {
17172        return Ok(Value::Null);
17173    }
17174    let actual = v.data_type().expect("non-null");
17175    if actual == expected {
17176        return Ok(v);
17177    }
17178    let coerced = match (v, expected) {
17179        (Value::Int(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
17180        (Value::Int(n), DataType::Float) => Some(Value::Float(f64::from(n))),
17181        (Value::Int(n), DataType::SmallInt) => i16::try_from(n).ok().map(Value::SmallInt),
17182        (Value::Int(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
17183            i128::from(n),
17184            precision,
17185            scale,
17186            col_name,
17187        )?),
17188        (Value::SmallInt(n), DataType::Int) => Some(Value::Int(i32::from(n))),
17189        (Value::SmallInt(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
17190        (Value::SmallInt(n), DataType::Float) => Some(Value::Float(f64::from(n))),
17191        (Value::SmallInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
17192            i128::from(n),
17193            precision,
17194            scale,
17195            col_name,
17196        )?),
17197        (Value::BigInt(n), DataType::Int) => i32::try_from(n).ok().map(Value::Int),
17198        (Value::BigInt(n), DataType::SmallInt) => i16::try_from(n).ok().map(Value::SmallInt),
17199        #[allow(clippy::cast_precision_loss)]
17200        (Value::BigInt(n), DataType::Float) => Some(Value::Float(n as f64)),
17201        (Value::BigInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
17202            i128::from(n),
17203            precision,
17204            scale,
17205            col_name,
17206        )?),
17207        (Value::Float(x), DataType::Numeric { precision, scale }) => {
17208            Some(numeric_from_float(x, precision, scale, col_name)?)
17209        }
17210        // v7.17.0 Phase 3.P0-67 — Text → NUMERIC. Parse a
17211        // canonical decimal text (`"-1234.56"` / `"42"` /
17212        // `"0.0001"`) into `(mantissa, source_scale)` and rescale
17213        // to the column's declared scale. Required for prepared
17214        // binds: `value_to_literal` flattens a Value::Numeric
17215        // into a TEXT literal because Literal carries no native
17216        // Numeric variant, so the placeholder substitution path
17217        // reaches coerce_value as Text → Numeric. Without this
17218        // arm the round-trip surfaces a TypeMismatch even though
17219        // the cell already left the engine as a valid Numeric.
17220        (Value::Text(s), DataType::Numeric { precision, scale }) => {
17221            let Some((mantissa, src_scale)) = parse_numeric_text(&s) else {
17222                return Err(EngineError::Eval(EvalError::TypeMismatch {
17223                    detail: alloc::format!("cannot parse {s:?} as NUMERIC for column `{col_name}`"),
17224                }));
17225            };
17226            Some(numeric_rescale(
17227                mantissa, src_scale, precision, scale, col_name,
17228            )?)
17229        }
17230        // Text → DATE / TIMESTAMP: parse canonical text forms.
17231        (Value::Text(s), DataType::Date) => {
17232            let d = eval::parse_date_literal(&s).ok_or_else(|| {
17233                EngineError::Eval(EvalError::TypeMismatch {
17234                    detail: alloc::format!("cannot parse {s:?} as DATE for column `{col_name}`"),
17235                })
17236            })?;
17237            Some(Value::Date(d))
17238        }
17239        // v7.14.0 — MySQL DEFAULT clauses quote integer / float
17240        // / boolean literals (`DEFAULT '0'`, `DEFAULT '1'`,
17241        // `DEFAULT '3.14'`, `DEFAULT 'true'`). Coerce the text
17242        // form to the column's numeric / bool type at DEFAULT-
17243        // installation time so the storage check sees a typed
17244        // value. Parse failures fall through to TypeMismatch.
17245        (Value::Text(s), DataType::SmallInt) => s.parse::<i16>().ok().map(Value::SmallInt),
17246        (Value::Text(s), DataType::Int) => s.parse::<i32>().ok().map(Value::Int),
17247        (Value::Text(s), DataType::BigInt) => s.parse::<i64>().ok().map(Value::BigInt),
17248        (Value::Text(s), DataType::Float) => s.parse::<f64>().ok().map(Value::Float),
17249        (Value::Text(s), DataType::Bool) => match s.to_ascii_lowercase().as_str() {
17250            "0" | "false" | "f" | "no" | "off" => Some(Value::Bool(false)),
17251            "1" | "true" | "t" | "yes" | "on" => Some(Value::Bool(true)),
17252            _ => None,
17253        },
17254        // v7.17.0 Phase 3.P0-46 — MySQL TINYINT(1) (which Phase 4.3
17255        // classifies as DataType::Bool) is the storage shape every
17256        // mysqldump-restored boolean column lands in. mysqldump emits
17257        // the values as integer `0` / `1` literals, so int → bool
17258        // coerce on INSERT is required for a 0-change cutover. MySQL's
17259        // rule is "any non-zero is truthy"; we follow that for all
17260        // signed int widths so the same coerce path serves an
17261        // explicit `BOOLEAN` column too.
17262        (Value::Int(n), DataType::Bool) => Some(Value::Bool(n != 0)),
17263        (Value::SmallInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
17264        (Value::BigInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
17265        // v4.9: Text ↔ JSON coercion. No structural validation —
17266        // any text literal is accepted; the responsibility for
17267        // valid JSON lies with the producer.
17268        (Value::Text(s), DataType::Json | DataType::Jsonb) => Some(Value::Json(s)),
17269        (Value::Json(s), DataType::Text) => Some(Value::Text(s)),
17270        // v7.13.3 — mailrs round-7 S10. SPG's storage represents
17271        // both JSON and JSONB on-disk as `Value::Json(String)` —
17272        // they share the underlying text payload. The cast
17273        // `'<text>'::jsonb` produces a Value::Json that needs to
17274        // satisfy a DataType::Jsonb column. Identity coerce in
17275        // both directions so JSON ↔ JSONB assignments work at all
17276        // INSERT / ALTER COLUMN TYPE / DEFAULT contexts.
17277        (Value::Json(s), DataType::Jsonb | DataType::Json) => Some(Value::Json(s)),
17278        // v7.10.4 — Text → BYTEA. Decode PG-style literal forms:
17279        //   - Hex:    `\x48656c6c6f`  (case-insensitive hex pairs)
17280        //   - Escape: `Hello\\000world`  (backslash + octal triples)
17281        //   - Plain:  any string → raw UTF-8 bytes (PG also accepts)
17282        // Errors surface as TypeMismatch so the operator gets a
17283        // clear "this literal isn't a bytea literal" hint.
17284        (Value::Text(s), DataType::Bytes) => {
17285            let bytes = decode_bytea_literal(&s).map_err(|e| {
17286                EngineError::Eval(EvalError::TypeMismatch {
17287                    detail: alloc::format!(
17288                        "cannot parse {s:?} as BYTEA for column `{col_name}`: {e}"
17289                    ),
17290                })
17291            })?;
17292            Some(Value::Bytes(bytes))
17293        }
17294        // v7.10.4 — BYTEA → Text round-trip uses the PG hex
17295        // output (lowercase, `\x` prefix). Important when a
17296        // SELECT pulls a bytea cell through a Text column path.
17297        (Value::Bytes(b), DataType::Text) => Some(Value::Text(encode_bytea_hex(&b))),
17298        // v7.17.0 — Text → UUID. PG accepts canonical hyphenated,
17299        // unhyphenated, uppercase, and `{...}`-braced forms; we
17300        // funnel all four through `spg_storage::parse_uuid_str`.
17301        // A malformed literal surfaces as a SQL TypeMismatch
17302        // rather than silently inserting garbage — `0-change
17303        // cutover` requires that an app inserting bad UUID text
17304        // sees the same hard error PG would raise.
17305        (Value::Text(s), DataType::Uuid) => match spg_storage::parse_uuid_str(&s) {
17306            Some(b) => Some(Value::Uuid(b)),
17307            None => {
17308                return Err(EngineError::Eval(EvalError::TypeMismatch {
17309                    detail: alloc::format!(
17310                        "invalid input syntax for type uuid: {s:?} (column `{col_name}`)"
17311                    ),
17312                }));
17313            }
17314        },
17315        // v7.17.0 — UUID → Text canonical 8-4-4-4-12 lowercase.
17316        // Surfaces when a SELECT plucks a uuid cell through a
17317        // Text column path (e.g. INSERT INTO log SELECT id::text
17318        // FROM other_table).
17319        (Value::Uuid(b), DataType::Text) => Some(Value::Text(spg_storage::format_uuid(&b))),
17320        // v7.17.0 Phase 3.P0-32 — Text → TIME. Accepts
17321        // `HH:MM:SS` and `HH:MM:SS.ffffff` (1-6 fractional digits).
17322        // Out-of-range hour/min/sec is a hard SQL error (no
17323        // silent truncation — same 0-change-cutover discipline
17324        // we apply to UUID).
17325        (Value::Text(s), DataType::Time) => match parse_time_str(&s) {
17326            Some(us) => Some(Value::Time(us)),
17327            None => {
17328                return Err(EngineError::Eval(EvalError::TypeMismatch {
17329                    detail: alloc::format!(
17330                        "invalid input syntax for type time: {s:?} (column `{col_name}`)"
17331                    ),
17332                }));
17333            }
17334        },
17335        // v7.17.0 Phase 3.P0-32 — TIME → Text canonical `HH:MM:SS[.ffffff]`.
17336        (Value::Time(us), DataType::Text) => Some(Value::Text(eval::format_time(us))),
17337        // v7.17.0 Phase 3.P0-33 — int / bigint → YEAR. Range
17338        // check enforces the MySQL canonical 1901..=2155 + 0
17339        // sentinel; out-of-range is a hard SQL error (no silent
17340        // truncation, mirrors P0-32 / P0-25 discipline).
17341        (Value::SmallInt(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
17342        (Value::Int(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
17343        (Value::BigInt(n), DataType::Year) => Some(coerce_int_to_year(n, col_name)?),
17344        // Text → YEAR. Accepts the 4-digit decimal form only;
17345        // two-digit YEAR (`'99'` → 1999) was deprecated in MySQL
17346        // 5.7 and is out of scope for v7.17.0.
17347        (Value::Text(s), DataType::Year) => match s.trim().parse::<i64>() {
17348            Ok(n) => Some(coerce_int_to_year(n, col_name)?),
17349            Err(_) => {
17350                return Err(EngineError::Eval(EvalError::TypeMismatch {
17351                    detail: alloc::format!(
17352                        "invalid input syntax for type year: {s:?} (column `{col_name}`)"
17353                    ),
17354                }));
17355            }
17356        },
17357        // YEAR → Text 4-digit zero-padded.
17358        (Value::Year(y), DataType::Text) => Some(Value::Text(alloc::format!("{y:04}"))),
17359        // v7.17.0 Phase 3.P0-34 — Text → TIMETZ. Mandatory
17360        // signed offset suffix; missing offset is a hard error
17361        // (SPG has no session TZ wired into eval, unlike PG).
17362        (Value::Text(s), DataType::TimeTz) => match parse_timetz_str(&s) {
17363            Some((us, offset_secs)) => Some(Value::TimeTz { us, offset_secs }),
17364            None => {
17365                return Err(EngineError::Eval(EvalError::TypeMismatch {
17366                    detail: alloc::format!(
17367                        "invalid input syntax for type time with time zone: \
17368                         {s:?} (column `{col_name}`)"
17369                    ),
17370                }));
17371            }
17372        },
17373        // TIMETZ → Text canonical `HH:MM:SS[.ffffff]±HH[:MM]`.
17374        (Value::TimeTz { us, offset_secs }, DataType::Text) => {
17375            Some(Value::Text(eval::format_timetz(us, offset_secs)))
17376        }
17377        // v7.17.0 Phase 3.P0-35 — Text → MONEY. Accepts `$N.NN`,
17378        // `$N,NNN.NN`, optional leading `-`. Bare numeric literals
17379        // arrive via the Int/BigInt/Float/Numeric arms below.
17380        (Value::Text(s), DataType::Money) => match parse_money_str(&s) {
17381            Some(c) => Some(Value::Money(c)),
17382            None => {
17383                return Err(EngineError::Eval(EvalError::TypeMismatch {
17384                    detail: alloc::format!(
17385                        "invalid input syntax for type money: {s:?} (column `{col_name}`)"
17386                    ),
17387                }));
17388            }
17389        },
17390        // Int / BigInt / SmallInt / Float / Numeric → MONEY.
17391        // Bare numeric literal is interpreted as a major-unit
17392        // amount (matches PG: `100`::money → $100.00 = 10000 cents).
17393        (Value::SmallInt(n), DataType::Money) => {
17394            Some(Value::Money(i64::from(n).saturating_mul(100)))
17395        }
17396        (Value::Int(n), DataType::Money) => Some(Value::Money(i64::from(n).saturating_mul(100))),
17397        (Value::BigInt(n), DataType::Money) => Some(Value::Money(n.saturating_mul(100))),
17398        (Value::Float(x), DataType::Money) => {
17399            // Round half-away-from-zero to cents (no_std — no
17400            // `f64::round`, so hand-roll via biased truncation).
17401            let scaled = x * 100.0;
17402            let cents = if scaled >= 0.0 {
17403                (scaled + 0.5) as i64
17404            } else {
17405                (scaled - 0.5) as i64
17406            };
17407            Some(Value::Money(cents))
17408        }
17409        (Value::Numeric { scaled, scale }, DataType::Money) => {
17410            // Convert exact decimal to cents (scale 2). If scale > 2,
17411            // round half-away-from-zero. If scale < 2, multiply up.
17412            let cents = if scale == 2 {
17413                scaled
17414            } else if scale < 2 {
17415                let mult = 10_i128.pow(u32::from(2 - scale));
17416                scaled.saturating_mul(mult)
17417            } else {
17418                let div = 10_i128.pow(u32::from(scale - 2));
17419                let half = div / 2;
17420                let bias = if scaled >= 0 { half } else { -half };
17421                (scaled + bias) / div
17422            };
17423            Some(Value::Money(i64::try_from(cents).unwrap_or(i64::MAX)))
17424        }
17425        // MONEY → Text canonical `$N,NNN.CC`.
17426        (Value::Money(c), DataType::Text) => Some(Value::Text(eval::format_money(c))),
17427        // v7.17.0 Phase 3.P0-38 — Text → Range. Accepts canonical
17428        // PG forms: `'empty'`, `'[a,b)'`, `'(a,b]'`, `'[a,b]'`,
17429        // `'(a,b)'`, with empty lower or upper for unbounded.
17430        (Value::Text(s), DataType::Range(kind)) => match parse_range_str(&s, kind) {
17431            Some(v) => Some(v),
17432            None => {
17433                return Err(EngineError::Eval(EvalError::TypeMismatch {
17434                    detail: alloc::format!(
17435                        "invalid input syntax for range type: {s:?} (column `{col_name}`)"
17436                    ),
17437                }));
17438            }
17439        },
17440        // Range → Text canonical form (`[a,b)`, `'empty'`, etc).
17441        (v @ Value::Range { .. }, DataType::Text) => Some(Value::Text(format_range_str(&v))),
17442        // v7.17.0 Phase 3.P0-39 — Text → Hstore.
17443        (Value::Text(s), DataType::Hstore) => match parse_hstore_str(&s) {
17444            Some(pairs) => Some(Value::Hstore(pairs)),
17445            None => {
17446                return Err(EngineError::Eval(EvalError::TypeMismatch {
17447                    detail: alloc::format!(
17448                        "invalid input syntax for type hstore: {s:?} (column `{col_name}`)"
17449                    ),
17450                }));
17451            }
17452        },
17453        // Hstore → Text canonical `"k"=>"v"` form.
17454        (Value::Hstore(pairs), DataType::Text) => Some(Value::Text(format_hstore_str(&pairs))),
17455        // v7.17.0 Phase 3.P0-40 — Text → 2D arrays via PG
17456        // external `'{{a,b},{c,d}}'` literal.
17457        (Value::Text(s), DataType::IntArray2D) => match parse_int_2d_literal(&s) {
17458            Ok(m) => Some(Value::IntArray2D(m)),
17459            Err(e) => {
17460                return Err(EngineError::Eval(EvalError::TypeMismatch {
17461                    detail: alloc::format!(
17462                        "invalid input syntax for INT[][]: {s:?} (column `{col_name}`): {e}"
17463                    ),
17464                }));
17465            }
17466        },
17467        (Value::Text(s), DataType::BigIntArray2D) => match parse_bigint_2d_literal(&s) {
17468            Ok(m) => Some(Value::BigIntArray2D(m)),
17469            Err(e) => {
17470                return Err(EngineError::Eval(EvalError::TypeMismatch {
17471                    detail: alloc::format!(
17472                        "invalid input syntax for BIGINT[][]: {s:?} (column `{col_name}`): {e}"
17473                    ),
17474                }));
17475            }
17476        },
17477        (Value::Text(s), DataType::TextArray2D) => match parse_text_2d_literal(&s) {
17478            Ok(m) => Some(Value::TextArray2D(m)),
17479            Err(e) => {
17480                return Err(EngineError::Eval(EvalError::TypeMismatch {
17481                    detail: alloc::format!(
17482                        "invalid input syntax for TEXT[][]: {s:?} (column `{col_name}`): {e}"
17483                    ),
17484                }));
17485            }
17486        },
17487        // 2D arrays → Text canonical nested form.
17488        (Value::IntArray2D(rows), DataType::Text) => Some(Value::Text(format_int_2d_text(&rows))),
17489        (Value::BigIntArray2D(rows), DataType::Text) => {
17490            Some(Value::Text(format_bigint_2d_text(&rows)))
17491        }
17492        (Value::TextArray2D(rows), DataType::Text) => Some(Value::Text(format_text_2d_text(&rows))),
17493        // v7.10.11 — Text → TEXT[]. Decode PG's external array
17494        // form `'{a,b,NULL}'`. NULL element token (case-insensitive)
17495        // is the literal `NULL`; everything else is a quoted or
17496        // unquoted text element. mailrs `'{label1,label2}'::TEXT[]`.
17497        (Value::Text(s), DataType::TextArray) => {
17498            let arr = decode_text_array_literal(&s).map_err(|e| {
17499                EngineError::Eval(EvalError::TypeMismatch {
17500                    detail: alloc::format!(
17501                        "cannot parse {s:?} as TEXT[] for column `{col_name}`: {e}"
17502                    ),
17503                })
17504            })?;
17505            Some(Value::TextArray(arr))
17506        }
17507        // v7.16.0 — Text → IntArray / BigIntArray for the
17508        // spg-sqlx Bind path. Decode the PG external form
17509        // `{1,2,3}` as a TEXT array first, then parse each
17510        // element as int. Same shape as the TextArray decode
17511        // above with an element-wise narrow.
17512        (Value::Text(s), DataType::IntArray) => {
17513            let arr = decode_text_array_literal(&s).map_err(|e| {
17514                EngineError::Eval(EvalError::TypeMismatch {
17515                    detail: alloc::format!(
17516                        "cannot parse {s:?} as INT[] for column `{col_name}`: {e}"
17517                    ),
17518                })
17519            })?;
17520            let mut out: Vec<Option<i32>> = Vec::with_capacity(arr.len());
17521            for elem in arr {
17522                match elem {
17523                    None => out.push(None),
17524                    Some(t) => {
17525                        let n: i32 = t.parse().map_err(|_| {
17526                            EngineError::Eval(EvalError::TypeMismatch {
17527                                detail: alloc::format!(
17528                                    "cannot parse {t:?} as INT element for `{col_name}`"
17529                                ),
17530                            })
17531                        })?;
17532                        out.push(Some(n));
17533                    }
17534                }
17535            }
17536            Some(Value::IntArray(out))
17537        }
17538        (Value::Text(s), DataType::BigIntArray) => {
17539            let arr = decode_text_array_literal(&s).map_err(|e| {
17540                EngineError::Eval(EvalError::TypeMismatch {
17541                    detail: alloc::format!(
17542                        "cannot parse {s:?} as BIGINT[] for column `{col_name}`: {e}"
17543                    ),
17544                })
17545            })?;
17546            let mut out: Vec<Option<i64>> = Vec::with_capacity(arr.len());
17547            for elem in arr {
17548                match elem {
17549                    None => out.push(None),
17550                    Some(t) => {
17551                        let n: i64 = t.parse().map_err(|_| {
17552                            EngineError::Eval(EvalError::TypeMismatch {
17553                                detail: alloc::format!(
17554                                    "cannot parse {t:?} as BIGINT element for `{col_name}`"
17555                                ),
17556                            })
17557                        })?;
17558                        out.push(Some(n));
17559                    }
17560                }
17561            }
17562            Some(Value::BigIntArray(out))
17563        }
17564        // v7.10.11 — TEXT[] → Text round-trip uses PG's
17565        // external array form (`{a,b,NULL}`). Lets a SELECT
17566        // pull an array column through any Text-side codepath.
17567        (Value::TextArray(items), DataType::Text) => Some(Value::Text(encode_text_array(&items))),
17568        // v7.17.0 Phase 3.P0-68 — Text → VECTOR auto-coerce.
17569        // Matches the existing Text → TsVector arm and the
17570        // `::vector` cast: PG-canonical pgvector external form
17571        // (`'[1, 2, -3]'`) becomes a typed Vector value at the
17572        // column boundary. Dim mismatch surfaces as TypeMismatch.
17573        // For SQ8 / HALF encodings we chain through the standard
17574        // quantise helpers so the storage shape matches the
17575        // declared encoding without a second coerce pass.
17576        (Value::Text(s), DataType::Vector { dim, encoding }) => {
17577            let parsed = eval::parse_vector_text(&s).ok_or_else(|| {
17578                EngineError::Eval(EvalError::TypeMismatch {
17579                    detail: alloc::format!("cannot parse {s:?} as VECTOR for column `{col_name}`"),
17580                })
17581            })?;
17582            if parsed.len() != dim as usize {
17583                return Err(EngineError::Eval(EvalError::TypeMismatch {
17584                    detail: alloc::format!(
17585                        "VECTOR({dim}) column `{col_name}` rejects literal of length {}",
17586                        parsed.len()
17587                    ),
17588                }));
17589            }
17590            Some(match encoding {
17591                VecEncoding::F32 => Value::Vector(parsed),
17592                VecEncoding::Sq8 => Value::Sq8Vector(spg_storage::quantize::quantize(&parsed)),
17593                VecEncoding::F16 => {
17594                    Value::HalfVector(spg_storage::halfvec::HalfVector::from_f32_slice(&parsed))
17595                }
17596            })
17597        }
17598        // v7.16.1 — Text → TSVECTOR auto-coerce for the
17599        // INSERT-side wire path (mailrs round-9 A.2.a). PG
17600        // implicitly promotes the TEXT literal at INSERT into a
17601        // TSVECTOR column; SPG previously rejected with a hard
17602        // type mismatch, blocking 23,276 pg_dump rows into
17603        // `messages.search_vector`. We route through the same
17604        // `decode_tsvector_external` the `::tsvector` cast
17605        // already uses, so PG-canonical forms (`'word'`,
17606        // `'word:1A,2B'`, multi-lexeme, empty `''`) all parse.
17607        (Value::Text(s), DataType::TsVector) => {
17608            let lexs = eval::decode_tsvector_external(&s).map_err(|e| {
17609                EngineError::Eval(EvalError::TypeMismatch {
17610                    detail: alloc::format!(
17611                        "cannot parse {s:?} as TSVECTOR for column `{col_name}`: {e}"
17612                    ),
17613                })
17614            })?;
17615            Some(Value::TsVector(lexs))
17616        }
17617        (Value::Text(s), DataType::Timestamp | DataType::Timestamptz) => {
17618            let t = eval::parse_timestamp_literal(&s).ok_or_else(|| {
17619                EngineError::Eval(EvalError::TypeMismatch {
17620                    detail: alloc::format!(
17621                        "cannot parse {s:?} as TIMESTAMP for column `{col_name}`"
17622                    ),
17623                })
17624            })?;
17625            Some(Value::Timestamp(t))
17626        }
17627        // DATE ↔ TIMESTAMP convertibility (DATE → midnight,
17628        // TIMESTAMP → day truncation).
17629        (Value::Date(d), DataType::Timestamp | DataType::Timestamptz) => {
17630            Some(Value::Timestamp(i64::from(d) * 86_400_000_000))
17631        }
17632        // v7.9.21 — Value::Timestamp lands in either Timestamp
17633        // or Timestamptz columns; the on-disk layout is the
17634        // same i64 microseconds UTC.
17635        (Value::Timestamp(t), DataType::Timestamptz) => Some(Value::Timestamp(t)),
17636        (Value::Timestamp(t), DataType::Date) => {
17637            let days = t.div_euclid(86_400_000_000);
17638            i32::try_from(days).ok().map(Value::Date)
17639        }
17640        (
17641            Value::Numeric {
17642                scaled,
17643                scale: src_scale,
17644            },
17645            DataType::Numeric { precision, scale },
17646        ) => Some(numeric_rescale(
17647            scaled, src_scale, precision, scale, col_name,
17648        )?),
17649        #[allow(clippy::cast_precision_loss)]
17650        (Value::Numeric { scaled, scale }, DataType::Float) => {
17651            let mut div = 1.0_f64;
17652            for _ in 0..scale {
17653                div *= 10.0;
17654            }
17655            Some(Value::Float((scaled as f64) / div))
17656        }
17657        (Value::Numeric { scaled, scale }, DataType::Int) => {
17658            let truncated = numeric_truncate_to_integer(scaled, scale);
17659            i32::try_from(truncated).ok().map(Value::Int)
17660        }
17661        (Value::Numeric { scaled, scale }, DataType::BigInt) => {
17662            let truncated = numeric_truncate_to_integer(scaled, scale);
17663            i64::try_from(truncated).ok().map(Value::BigInt)
17664        }
17665        (Value::Numeric { scaled, scale }, DataType::SmallInt) => {
17666            let truncated = numeric_truncate_to_integer(scaled, scale);
17667            i16::try_from(truncated).ok().map(Value::SmallInt)
17668        }
17669        // VARCHAR(n) enforces an upper bound on character count.
17670        (Value::Text(s), DataType::Varchar(max)) => {
17671            if u32::try_from(s.chars().count()).unwrap_or(u32::MAX) <= max {
17672                Some(Value::Text(s))
17673            } else {
17674                return Err(EngineError::Unsupported(alloc::format!(
17675                    "value for VARCHAR({max}) column `{col_name}` exceeds length: \
17676                     {} chars",
17677                    s.chars().count()
17678                )));
17679            }
17680        }
17681        // v6.0.1: f32 → SQ8 INSERT-time quantisation. Triggered
17682        // when the column declares `VECTOR(N) USING SQ8` and
17683        // the INSERT VALUES expression yields a raw f32 vector
17684        // (the normal pgvector-shape literal). Dim mismatch
17685        // falls through the `_ => None` arm and surfaces as
17686        // `TypeMismatch` with the expected SQ8 column type —
17687        // matching the F32 path's existing error.
17688        (
17689            Value::Vector(v),
17690            DataType::Vector {
17691                dim,
17692                encoding: VecEncoding::Sq8,
17693            },
17694        ) if v.len() == dim as usize => Some(Value::Sq8Vector(spg_storage::quantize::quantize(&v))),
17695        // v6.0.3: f32 → f16 INSERT-time conversion for HALF
17696        // columns. Bit-exact at the storage layer (modulo
17697        // half-precision rounding); no rerank pass needed at
17698        // search time.
17699        (
17700            Value::Vector(v),
17701            DataType::Vector {
17702                dim,
17703                encoding: VecEncoding::F16,
17704            },
17705        ) if v.len() == dim as usize => Some(Value::HalfVector(
17706            spg_storage::halfvec::HalfVector::from_f32_slice(&v),
17707        )),
17708        // CHAR(n) right-pads with U+0020 to exactly n chars; if the input
17709        // is already longer we reject (PG truncates trailing-space-only;
17710        // staying strict for v1).
17711        (Value::Text(s), DataType::Char(size)) => {
17712            let len = u32::try_from(s.chars().count()).unwrap_or(u32::MAX);
17713            if len > size {
17714                return Err(EngineError::Unsupported(alloc::format!(
17715                    "value for CHAR({size}) column `{col_name}` exceeds length: \
17716                     {len} chars"
17717                )));
17718            }
17719            let need = (size - len) as usize;
17720            let mut padded = s;
17721            padded.reserve(need);
17722            for _ in 0..need {
17723                padded.push(' ');
17724            }
17725            Some(Value::Text(padded))
17726        }
17727        _ => None,
17728    };
17729    coerced.ok_or(EngineError::Storage(StorageError::TypeMismatch {
17730        column: col_name.into(),
17731        expected,
17732        actual,
17733        position,
17734    }))
17735}
17736
17737/// v7.12.4 — render a function arg list into the
17738/// canonical form the storage layer caches as
17739/// [`spg_storage::FunctionDef::args_repr`]. The catalogue uses
17740/// this string for both display + as a coarse signature key
17741/// for the (deferred) overload resolution v7.12.5+ adds.
17742fn render_function_args(args: &[spg_sql::ast::FunctionArg]) -> alloc::string::String {
17743    use core::fmt::Write;
17744    let mut out = alloc::string::String::from("(");
17745    for (i, a) in args.iter().enumerate() {
17746        if i > 0 {
17747            out.push_str(", ");
17748        }
17749        match a.mode {
17750            spg_sql::ast::FunctionArgMode::In => {}
17751            spg_sql::ast::FunctionArgMode::Out => out.push_str("OUT "),
17752            spg_sql::ast::FunctionArgMode::InOut => out.push_str("INOUT "),
17753        }
17754        if let Some(n) = &a.name {
17755            out.push_str(n);
17756            out.push(' ');
17757        }
17758        match &a.ty {
17759            spg_sql::ast::FunctionArgType::Typed(t) => {
17760                let _ = write!(out, "{t}");
17761            }
17762            spg_sql::ast::FunctionArgType::Raw(s) => out.push_str(s),
17763        }
17764    }
17765    out.push(')');
17766    out
17767}
17768
17769/// v7.19 P5 — true iff `expr` is `unnest(arg)` at the top level
17770/// (case-insensitive). Used by `exec_select_cancel`'s
17771/// projection loop to detect Set-Returning-Function rows that
17772/// need per-row expansion. Only the top-level call counts —
17773/// `coalesce(unnest(arr), 'x')` is NOT a SRF row from the
17774/// projection's perspective; it would surface as an "unknown
17775/// function" mismatch downstream, which is what we want
17776/// (multi-SRF / nested SRF is documented carve-out for v7.19).
17777fn is_top_level_unnest(expr: &spg_sql::ast::Expr) -> bool {
17778    match expr {
17779        spg_sql::ast::Expr::FunctionCall { name, args } => {
17780            name.eq_ignore_ascii_case("unnest") && args.len() == 1
17781        }
17782        _ => false,
17783    }
17784}
17785
17786/// v7.19 P5 — extract the array argument out of a top-level
17787/// `unnest(arg)` call. `None` if `expr` isn't a `unnest` call
17788/// of arity 1 (mirrors `is_top_level_unnest`).
17789fn top_level_unnest_arg(expr: &spg_sql::ast::Expr) -> Option<&spg_sql::ast::Expr> {
17790    match expr {
17791        spg_sql::ast::Expr::FunctionCall { name, args }
17792            if name.eq_ignore_ascii_case("unnest") && args.len() == 1 =>
17793        {
17794            Some(&args[0])
17795        }
17796        _ => None,
17797    }
17798}
17799
17800/// v7.19 P5 — turn an array-typed `Value` into the element list
17801/// `unnest()` projection emits. NULL → empty list (PG: `unnest(NULL)
17802/// = (no rows)`). Non-array values fall through to a type-mismatch
17803/// error.
17804fn array_value_to_elements(v: &Value) -> Result<Vec<Value>, EngineError> {
17805    match v {
17806        Value::Null => Ok(Vec::new()),
17807        Value::TextArray(items) => Ok(items
17808            .iter()
17809            .map(|opt| {
17810                opt.as_ref()
17811                    .map(|s| Value::Text(s.clone()))
17812                    .unwrap_or(Value::Null)
17813            })
17814            .collect()),
17815        Value::IntArray(items) => Ok(items
17816            .iter()
17817            .map(|opt| opt.map(Value::Int).unwrap_or(Value::Null))
17818            .collect()),
17819        Value::BigIntArray(items) => Ok(items
17820            .iter()
17821            .map(|opt| opt.map(Value::BigInt).unwrap_or(Value::Null))
17822            .collect()),
17823        other => Err(EngineError::Eval(EvalError::TypeMismatch {
17824            detail: alloc::format!(
17825                "unnest() expects an array argument, got {:?}",
17826                other.data_type()
17827            ),
17828        })),
17829    }
17830}
17831
17832#[cfg(test)]
17833mod tests {
17834    use super::*;
17835    use alloc::vec;
17836
17837    fn unwrap_command_ok(r: &QueryResult) -> usize {
17838        match r {
17839            QueryResult::CommandOk { affected, .. } => *affected,
17840            QueryResult::Rows { .. } => panic!("expected CommandOk, got Rows"),
17841        }
17842    }
17843
17844    #[test]
17845    fn update_seek_positions_engages_on_indexed_eq() {
17846        let mut e = Engine::new();
17847        e.execute("CREATE TABLE b (id INT NOT NULL, v INT NOT NULL)")
17848            .unwrap();
17849        e.execute("CREATE INDEX b_id ON b (id)").unwrap();
17850        for i in 0..100 {
17851            e.execute(&alloc::format!("INSERT INTO b VALUES ({i}, {i})"))
17852                .unwrap();
17853        }
17854        let stmt = spg_sql::parser::parse_statement("UPDATE b SET v = v + 1 WHERE id = 42")
17855            .expect("parse");
17856        let Statement::Update(u) = stmt else {
17857            panic!("expected Update, got {stmt:?}");
17858        };
17859        let w = u.where_.as_ref().expect("where");
17860        let table = e.catalog().get("b").unwrap();
17861        let schema_cols = table.schema().columns.clone();
17862        // step-by-step: each sub-resolution must succeed.
17863        let Expr::Binary { lhs, op, rhs } = w else {
17864            panic!("WHERE not Binary: {w:?}");
17865        };
17866        assert_eq!(*op, BinOp::Eq, "op not Eq");
17867        let pair = resolve_col_literal_pair(lhs, rhs, &schema_cols, "b");
17868        assert!(
17869            pair.is_some(),
17870            "resolve_col_literal_pair None: lhs={lhs:?} rhs={rhs:?}"
17871        );
17872        let (col_pos, value) = pair.unwrap();
17873        assert!(
17874            table.index_on(col_pos).is_some(),
17875            "no index on col {col_pos}"
17876        );
17877        assert!(
17878            IndexKey::from_value(&value).is_some(),
17879            "IndexKey::from_value None for {value:?}"
17880        );
17881        let positions = try_index_seek_positions(w, &schema_cols, table, "b");
17882        assert_eq!(positions, Some(vec![42]), "seek did not engage");
17883    }
17884
17885    #[test]
17886    fn create_table_registers_schema() {
17887        let mut e = Engine::new();
17888        e.execute("CREATE TABLE foo (a INT NOT NULL, b TEXT)")
17889            .unwrap();
17890        assert_eq!(e.catalog().table_count(), 1);
17891        let t = e.catalog().get("foo").unwrap();
17892        assert_eq!(t.schema().columns.len(), 2);
17893        assert_eq!(t.schema().columns[0].ty, DataType::Int);
17894        assert!(!t.schema().columns[0].nullable);
17895        assert_eq!(t.schema().columns[1].ty, DataType::Text);
17896    }
17897
17898    #[test]
17899    fn create_table_vector_default_is_f32_encoded() {
17900        let mut e = Engine::new();
17901        e.execute("CREATE TABLE t (v VECTOR(8))").unwrap();
17902        let t = e.catalog().get("t").unwrap();
17903        assert_eq!(
17904            t.schema().columns[0].ty,
17905            DataType::Vector {
17906                dim: 8,
17907                encoding: VecEncoding::F32,
17908            },
17909        );
17910    }
17911
17912    #[test]
17913    fn create_table_vector_using_sq8_succeeds() {
17914        // v6.0.1 step 3: the step-1 fence in `column_def_to_schema`
17915        // is lifted. CREATE TABLE persists an SQ8 column type in
17916        // the catalog; INSERT (next test) quantises raw f32 input.
17917        let mut e = Engine::new();
17918        e.execute("CREATE TABLE t (v VECTOR(8) USING SQ8)").unwrap();
17919        let t = e.catalog().get("t").unwrap();
17920        assert_eq!(
17921            t.schema().columns[0].ty,
17922            DataType::Vector {
17923                dim: 8,
17924                encoding: VecEncoding::Sq8,
17925            },
17926        );
17927    }
17928
17929    #[test]
17930    fn insert_into_sq8_column_quantises_f32_payload() {
17931        // v6.0.1 step 3: INSERT-time `coerce_value` rewrites a raw
17932        // `Value::Vector(Vec<f32>)` literal into the column's
17933        // quantised representation. The row that lands in the
17934        // catalog must therefore hold a `Value::Sq8Vector`, not the
17935        // original f32 buffer — that's the bit that delivers the
17936        // 4× compression target.
17937        let mut e = Engine::new();
17938        e.execute("CREATE TABLE t (v VECTOR(4) USING SQ8)").unwrap();
17939        e.execute("INSERT INTO t VALUES ([0.0, 0.25, 0.5, 1.0])")
17940            .unwrap();
17941        let t = e.catalog().get("t").unwrap();
17942        assert_eq!(t.rows().len(), 1);
17943        match &t.rows()[0].values[0] {
17944            Value::Sq8Vector(q) => {
17945                assert_eq!(q.bytes.len(), 4);
17946                // min/max are derived from the payload: min=0.0, max=1.0.
17947                assert!((q.min - 0.0).abs() < 1e-6);
17948                assert!((q.max - 1.0).abs() < 1e-6);
17949            }
17950            other => panic!("expected Sq8Vector cell, got {other:?}"),
17951        }
17952    }
17953
17954    #[test]
17955    fn create_table_vector_using_half_succeeds_and_insert_converts_to_f16() {
17956        // v6.0.3: CREATE TABLE accepts USING HALF; INSERT path
17957        // converts the incoming `Value::Vector(Vec<f32>)` cell
17958        // into `Value::HalfVector(HalfVector)` via the new
17959        // `coerce_value` arm. The dequantised round-trip is
17960        // bit-exact for f16-representable values, so 0.0 / 0.25
17961        // / 0.5 / 1.0 hit their grid points exactly.
17962        let mut e = Engine::new();
17963        e.execute("CREATE TABLE t (v VECTOR(4) USING HALF)")
17964            .unwrap();
17965        e.execute("INSERT INTO t VALUES ([0.0, 0.25, 0.5, 1.0])")
17966            .unwrap();
17967        let t = e.catalog().get("t").unwrap();
17968        assert_eq!(t.rows().len(), 1);
17969        match &t.rows()[0].values[0] {
17970            Value::HalfVector(h) => {
17971                assert_eq!(h.dim(), 4);
17972                let back = h.to_f32_vec();
17973                let expected = alloc::vec![0.0_f32, 0.25, 0.5, 1.0];
17974                for (g, e) in back.iter().zip(expected.iter()) {
17975                    assert!(
17976                        (g - e).abs() < 1e-6,
17977                        "{g} vs {e} should be exact on f16 grid"
17978                    );
17979                }
17980            }
17981            other => panic!("expected HalfVector cell, got {other:?}"),
17982        }
17983    }
17984
17985    #[test]
17986    fn alter_index_rebuild_in_place_succeeds() {
17987        // v6.0.4: bare REBUILD (no encoding switch) walks every
17988        // row again to rebuild the NSW graph. Verifies the engine
17989        // dispatch + storage helper plumbing without changing any
17990        // cell encoding.
17991        let mut e = Engine::new();
17992        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(3) NOT NULL)")
17993            .unwrap();
17994        for i in 0..8_i32 {
17995            #[allow(clippy::cast_precision_loss)]
17996            let base = (i as f32) * 0.1;
17997            e.execute(&alloc::format!(
17998                "INSERT INTO t VALUES ({i}, [{base}, {b1}, {b2}])",
17999                b1 = base + 0.01,
18000                b2 = base + 0.02,
18001            ))
18002            .unwrap();
18003        }
18004        e.execute("CREATE INDEX t_idx ON t USING hnsw (v)").unwrap();
18005        e.execute("ALTER INDEX t_idx REBUILD").unwrap();
18006        // Schema encoding stays F32 (no encoding clause).
18007        assert_eq!(
18008            e.catalog().get("t").unwrap().schema().columns[1].ty,
18009            DataType::Vector {
18010                dim: 3,
18011                encoding: VecEncoding::F32,
18012            },
18013        );
18014    }
18015
18016    #[test]
18017    fn alter_index_rebuild_with_encoding_switches_cell_type() {
18018        // v6.0.4: REBUILD WITH (encoding = SQ8) recodes every
18019        // stored cell from F32 → SQ8 + rebuilds the graph atop the
18020        // new encoding. Post-rebuild, cells must be Sq8Vector and
18021        // the schema must report encoding = Sq8.
18022        let mut e = Engine::new();
18023        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(4) NOT NULL)")
18024            .unwrap();
18025        e.execute("INSERT INTO t VALUES (1, [0.0, 0.25, 0.5, 1.0])")
18026            .unwrap();
18027        e.execute("CREATE INDEX t_idx ON t USING hnsw (v)").unwrap();
18028        e.execute("ALTER INDEX t_idx REBUILD WITH (encoding = SQ8)")
18029            .unwrap();
18030        let t = e.catalog().get("t").unwrap();
18031        assert_eq!(
18032            t.schema().columns[1].ty,
18033            DataType::Vector {
18034                dim: 4,
18035                encoding: VecEncoding::Sq8,
18036            },
18037        );
18038        assert!(matches!(t.rows()[0].values[1], Value::Sq8Vector(_)));
18039    }
18040
18041    #[test]
18042    fn alter_index_rebuild_unknown_index_errors() {
18043        let mut e = Engine::new();
18044        let err = e.execute("ALTER INDEX nope REBUILD").unwrap_err();
18045        assert!(
18046            matches!(
18047                &err,
18048                EngineError::Storage(StorageError::IndexNotFound { name }) if name == "nope"
18049            ),
18050            "got: {err}"
18051        );
18052    }
18053
18054    #[test]
18055    fn alter_index_rebuild_on_btree_index_errors() {
18056        // REBUILD on a B-tree index has no semantic meaning in
18057        // v6.0.4 — rejected at the storage layer with `Unsupported`.
18058        let mut e = Engine::new();
18059        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18060        e.execute("INSERT INTO t VALUES (1)").unwrap();
18061        e.execute("CREATE INDEX t_idx ON t (id)").unwrap();
18062        let err = e.execute("ALTER INDEX t_idx REBUILD").unwrap_err();
18063        assert!(
18064            matches!(&err, EngineError::Storage(StorageError::Unsupported(_))),
18065            "got: {err}"
18066        );
18067    }
18068
18069    #[test]
18070    fn prepared_insert_substitutes_placeholders() {
18071        // v6.1.1: prepare() parses once; execute_prepared() walks the
18072        // AST and replaces $1/$2 with the param Values BEFORE the
18073        // dispatch sees them. Same logical result as a simple-query
18074        // INSERT, but parse happens once per *statement*, not per
18075        // execution.
18076        let mut e = Engine::new();
18077        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)")
18078            .unwrap();
18079        let stmt = e.prepare("INSERT INTO t VALUES ($1, $2)").unwrap();
18080        for (id, name) in [(1, "alice"), (2, "bob"), (3, "carol")] {
18081            e.execute_prepared(stmt.clone(), &[Value::Int(id), Value::Text(name.into())])
18082                .unwrap();
18083        }
18084        // Read back via simple-query SELECT.
18085        let rows_result = e.execute("SELECT id, name FROM t").unwrap();
18086        let QueryResult::Rows { rows, .. } = rows_result else {
18087            panic!("expected Rows")
18088        };
18089        assert_eq!(rows.len(), 3);
18090    }
18091
18092    #[test]
18093    fn prepared_select_with_placeholder_filters_rows() {
18094        let mut e = Engine::new();
18095        e.execute("CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)")
18096            .unwrap();
18097        for i in 0..10_i32 {
18098            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, {})", i * 7))
18099                .unwrap();
18100        }
18101        let stmt = e.prepare("SELECT id FROM t WHERE v = $1").unwrap();
18102        let QueryResult::Rows { rows, .. } = e.execute_prepared(stmt, &[Value::Int(35)]).unwrap()
18103        else {
18104            panic!("expected Rows")
18105        };
18106        // v = 35 means i*7 = 35 → i = 5.
18107        assert_eq!(rows.len(), 1);
18108        assert_eq!(rows[0].values[0], Value::Int(5));
18109    }
18110
18111    #[test]
18112    fn prepared_too_few_params_errors() {
18113        let mut e = Engine::new();
18114        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18115        let stmt = e.prepare("INSERT INTO t VALUES ($1)").unwrap();
18116        let err = e.execute_prepared(stmt, &[]).unwrap_err();
18117        assert!(
18118            matches!(
18119                &err,
18120                EngineError::Eval(EvalError::PlaceholderOutOfRange { n: 1, bound: 0 })
18121            ),
18122            "got: {err}"
18123        );
18124    }
18125
18126    #[test]
18127    fn bytea_cast_round_trips_text_input() {
18128        // v7.18 — `'hello'::bytea` produces the raw bytes. Closes
18129        // the mailrs D-pre #3 reverse-acceptance gap.
18130        let e = Engine::new();
18131        let r = e.execute_readonly("SELECT 'hello'::bytea").unwrap();
18132        let QueryResult::Rows { rows, .. } = r else {
18133            panic!("expected Rows")
18134        };
18135        assert_eq!(rows.len(), 1);
18136        assert_eq!(rows[0].values[0], Value::Bytes(b"hello".to_vec()));
18137    }
18138
18139    #[test]
18140    fn bytea_cast_pg_escape_hex_form() {
18141        // E'\\xdeadbeef'::bytea — E-string decodes to `\xdeadbeef`
18142        // (literal 10 chars), then ::bytea reads it as PG hex
18143        // form bytea literal → 4 bytes.
18144        let e = Engine::new();
18145        let r = e.execute_readonly(r"SELECT E'\\xdeadbeef'::bytea").unwrap();
18146        let QueryResult::Rows { rows, .. } = r else {
18147            panic!("expected Rows")
18148        };
18149        assert_eq!(
18150            rows[0].values[0],
18151            Value::Bytes(vec![0xde, 0xad, 0xbe, 0xef])
18152        );
18153    }
18154
18155    #[test]
18156    fn bytea_cast_chains_through_octet_length() {
18157        // octet_length('hello'::bytea) → 5. Confirms the cast
18158        // composes inside larger expressions, not just at top
18159        // level.
18160        let e = Engine::new();
18161        let r = e
18162            .execute_readonly("SELECT octet_length('hello'::bytea)")
18163            .unwrap();
18164        let QueryResult::Rows { rows, .. } = r else {
18165            panic!("expected Rows")
18166        };
18167        match &rows[0].values[0] {
18168            Value::Int(n) => assert_eq!(*n, 5),
18169            Value::BigInt(n) => assert_eq!(*n, 5),
18170            other => panic!("expected integer length, got {other:?}"),
18171        }
18172    }
18173
18174    #[test]
18175    fn readonly_prepared_on_snapshot_select_with_placeholder() {
18176        // v7.18 — sqlx Pool fan-out relies on running prepared
18177        // SELECTs against a frozen snapshot without re-entering
18178        // the writer engine. Mirrors the simple-query SELECT path
18179        // in `execute_readonly_on_snapshot` but takes a Statement
18180        // + bound params (the shape sqlx's Execute path produces).
18181        let mut e = Engine::new();
18182        e.execute("CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)")
18183            .unwrap();
18184        for i in 0..10_i32 {
18185            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, {})", i * 7))
18186                .unwrap();
18187        }
18188        let snapshot = e.clone_snapshot();
18189        let stmt = e.prepare("SELECT id FROM t WHERE v = $1").unwrap();
18190        let QueryResult::Rows { rows, .. } =
18191            Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(35)])
18192                .unwrap()
18193        else {
18194            panic!("expected Rows")
18195        };
18196        assert_eq!(rows.len(), 1);
18197        assert_eq!(rows[0].values[0], Value::Int(5));
18198    }
18199
18200    #[test]
18201    fn readonly_prepared_on_snapshot_rejects_writes() {
18202        // DDL / DML prepared statements on the readonly path must
18203        // surface `WriteRequired` so the spg-sqlx connection layer
18204        // routes them to the writer mutex instead of the snapshot.
18205        let mut e = Engine::new();
18206        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18207        let snapshot = e.clone_snapshot();
18208        let stmt = e.prepare("INSERT INTO t VALUES ($1)").unwrap();
18209        let err = Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(1)])
18210            .unwrap_err();
18211        assert!(matches!(&err, EngineError::WriteRequired), "got: {err}");
18212    }
18213
18214    #[test]
18215    fn readonly_prepared_on_snapshot_frozen_view() {
18216        // The snapshot reflects engine state at clone_snapshot()
18217        // time. Writes after the snapshot are NOT visible — caller
18218        // takes a fresh snapshot (or `AsyncReadHandle::refresh()`)
18219        // to see them. This is the contract the per-statement
18220        // refresh in spg-sqlx relies on.
18221        let mut e = Engine::new();
18222        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18223        e.execute("INSERT INTO t VALUES (1)").unwrap();
18224        let snapshot = e.clone_snapshot();
18225        e.execute("INSERT INTO t VALUES (2)").unwrap();
18226        let stmt = e.prepare("SELECT id FROM t WHERE id = $1").unwrap();
18227        let QueryResult::Rows { rows, .. } =
18228            Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(2)])
18229                .unwrap()
18230        else {
18231            panic!("expected Rows")
18232        };
18233        assert!(rows.is_empty(), "id=2 was inserted after snapshot");
18234    }
18235
18236    #[test]
18237    fn describe_prepared_on_snapshot_resolves_columns() {
18238        // v7.18 — sqlx's Executor::describe path on the readonly
18239        // fan-out needs to resolve column names + types against
18240        // the snapshot's catalog (not the live engine's catalog,
18241        // which may have moved on).
18242        let mut e = Engine::new();
18243        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)")
18244            .unwrap();
18245        let snapshot = e.clone_snapshot();
18246        let stmt = e.prepare("SELECT id, name FROM t WHERE id = $1").unwrap();
18247        let (_params, cols) = Engine::describe_prepared_on_snapshot(&snapshot, &stmt);
18248        assert_eq!(cols.len(), 2);
18249        assert_eq!(cols[0].name, "id");
18250        assert_eq!(cols[0].ty, DataType::Int);
18251        assert_eq!(cols[1].name, "name");
18252        assert_eq!(cols[1].ty, DataType::Text);
18253    }
18254
18255    #[test]
18256    fn insert_into_half_column_dim_mismatch_errors() {
18257        let mut e = Engine::new();
18258        e.execute("CREATE TABLE t (v VECTOR(4) USING HALF)")
18259            .unwrap();
18260        let err = e.execute("INSERT INTO t VALUES ([1.0, 2.0])").unwrap_err();
18261        assert!(matches!(
18262            &err,
18263            EngineError::Storage(StorageError::TypeMismatch { .. })
18264        ));
18265    }
18266
18267    #[test]
18268    fn insert_into_sq8_column_dim_mismatch_errors() {
18269        // Dim mismatch falls through the `coerce_value` Vector→Sq8
18270        // arm's guard and surfaces as `TypeMismatch` — the same
18271        // error the F32 path produces today, so client error
18272        // handling stays uniform across encodings.
18273        let mut e = Engine::new();
18274        e.execute("CREATE TABLE t (v VECTOR(4) USING SQ8)").unwrap();
18275        let err = e.execute("INSERT INTO t VALUES ([1.0, 2.0])").unwrap_err();
18276        assert!(
18277            matches!(
18278                &err,
18279                EngineError::Storage(StorageError::TypeMismatch { .. })
18280            ),
18281            "got: {err}",
18282        );
18283    }
18284
18285    #[test]
18286    fn create_table_duplicate_errors() {
18287        let mut e = Engine::new();
18288        e.execute("CREATE TABLE foo (a INT)").unwrap();
18289        let err = e.execute("CREATE TABLE foo (a INT)").unwrap_err();
18290        assert!(matches!(
18291            err,
18292            EngineError::Storage(StorageError::DuplicateTable { ref name }) if name == "foo"
18293        ));
18294    }
18295
18296    #[test]
18297    fn insert_into_unknown_table_errors() {
18298        let mut e = Engine::new();
18299        let err = e.execute("INSERT INTO ghost VALUES (1)").unwrap_err();
18300        assert!(matches!(
18301            err,
18302            EngineError::Storage(StorageError::TableNotFound { ref name }) if name == "ghost"
18303        ));
18304    }
18305
18306    #[test]
18307    fn insert_happy_path_reports_one_affected() {
18308        let mut e = Engine::new();
18309        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
18310        let r = e.execute("INSERT INTO foo VALUES (42)").unwrap();
18311        assert_eq!(unwrap_command_ok(&r), 1);
18312        assert_eq!(e.catalog().get("foo").unwrap().row_count(), 1);
18313    }
18314
18315    #[test]
18316    fn insert_arity_mismatch_propagates() {
18317        let mut e = Engine::new();
18318        e.execute("CREATE TABLE foo (a INT, b TEXT)").unwrap();
18319        let err = e.execute("INSERT INTO foo VALUES (1)").unwrap_err();
18320        assert!(matches!(
18321            err,
18322            EngineError::Storage(StorageError::ArityMismatch { .. })
18323        ));
18324    }
18325
18326    #[test]
18327    fn insert_negative_integer_via_unary_minus() {
18328        let mut e = Engine::new();
18329        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
18330        e.execute("INSERT INTO foo VALUES (-7)").unwrap();
18331        let rows = e.catalog().get("foo").unwrap().rows();
18332        assert_eq!(rows[0].values[0], Value::Int(-7));
18333    }
18334
18335    #[test]
18336    fn insert_expression_evaluated_against_empty_context() {
18337        // PG-canonical: INSERT VALUES accepts an arbitrary scalar
18338        // expression. The engine evaluates against an empty row
18339        // context — column references would error, but pure
18340        // arithmetic / function calls are fine.
18341        let mut e = Engine::new();
18342        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
18343        e.execute("INSERT INTO foo VALUES (1 + 2)").unwrap();
18344        let rows = e.catalog().get("foo").unwrap().rows();
18345        assert_eq!(rows[0].values[0], Value::Int(3));
18346    }
18347
18348    #[test]
18349    fn select_star_returns_all_rows_in_insertion_order() {
18350        let mut e = Engine::new();
18351        e.execute("CREATE TABLE foo (a INT NOT NULL, b TEXT NOT NULL)")
18352            .unwrap();
18353        e.execute("INSERT INTO foo VALUES (1, 'one')").unwrap();
18354        e.execute("INSERT INTO foo VALUES (2, 'two')").unwrap();
18355        e.execute("INSERT INTO foo VALUES (3, 'three')").unwrap();
18356
18357        let r = e.execute("SELECT * FROM foo").unwrap();
18358        let QueryResult::Rows { columns, rows } = r else {
18359            panic!("expected Rows")
18360        };
18361        assert_eq!(columns.len(), 2);
18362        assert_eq!(columns[0].name, "a");
18363        assert_eq!(rows.len(), 3);
18364        assert_eq!(
18365            rows[1].values,
18366            vec![Value::Int(2), Value::Text("two".into())]
18367        );
18368    }
18369
18370    #[test]
18371    fn select_star_on_empty_table_returns_zero_rows() {
18372        let mut e = Engine::new();
18373        e.execute("CREATE TABLE foo (a INT)").unwrap();
18374        let r = e.execute("SELECT * FROM foo").unwrap();
18375        match r {
18376            QueryResult::Rows { rows, .. } => assert!(rows.is_empty()),
18377            QueryResult::CommandOk { .. } => panic!("expected Rows"),
18378        }
18379    }
18380
18381    // --- v0.4: WHERE + projection ------------------------------------------
18382
18383    fn make_three_row_users(e: &mut Engine) {
18384        e.execute("CREATE TABLE users (id INT NOT NULL, name TEXT NOT NULL, score INT)")
18385            .unwrap();
18386        e.execute("INSERT INTO users VALUES (1, 'alice', 90)")
18387            .unwrap();
18388        e.execute("INSERT INTO users VALUES (2, 'bob', NULL)")
18389            .unwrap();
18390        e.execute("INSERT INTO users VALUES (3, 'cara', 70)")
18391            .unwrap();
18392    }
18393
18394    fn unwrap_rows(r: QueryResult) -> (Vec<ColumnSchema>, Vec<Row>) {
18395        match r {
18396            QueryResult::Rows { columns, rows } => (columns, rows),
18397            QueryResult::CommandOk { .. } => panic!("expected Rows"),
18398        }
18399    }
18400
18401    #[test]
18402    fn where_filter_passes_only_true_rows() {
18403        let mut e = Engine::new();
18404        make_three_row_users(&mut e);
18405        let r = e.execute("SELECT * FROM users WHERE id > 1").unwrap();
18406        let (_, rows) = unwrap_rows(r);
18407        assert_eq!(rows.len(), 2);
18408        assert_eq!(rows[0].values[0], Value::Int(2));
18409        assert_eq!(rows[1].values[0], Value::Int(3));
18410    }
18411
18412    #[test]
18413    fn where_with_null_result_filters_out_row() {
18414        let mut e = Engine::new();
18415        make_three_row_users(&mut e);
18416        // score is NULL for bob → score > 80 is NULL → row excluded
18417        let r = e.execute("SELECT * FROM users WHERE score > 80").unwrap();
18418        let (_, rows) = unwrap_rows(r);
18419        assert_eq!(rows.len(), 1);
18420        assert_eq!(rows[0].values[1], Value::Text("alice".into()));
18421    }
18422
18423    #[test]
18424    fn projection_named_columns() {
18425        let mut e = Engine::new();
18426        make_three_row_users(&mut e);
18427        let r = e.execute("SELECT name, score FROM users").unwrap();
18428        let (cols, rows) = unwrap_rows(r);
18429        assert_eq!(cols.len(), 2);
18430        assert_eq!(cols[0].name, "name");
18431        assert_eq!(cols[1].name, "score");
18432        assert_eq!(rows.len(), 3);
18433        assert_eq!(
18434            rows[0].values,
18435            vec![Value::Text("alice".into()), Value::Int(90)]
18436        );
18437    }
18438
18439    #[test]
18440    fn projection_with_column_alias() {
18441        let mut e = Engine::new();
18442        make_three_row_users(&mut e);
18443        let r = e
18444            .execute("SELECT name AS who FROM users WHERE id = 1")
18445            .unwrap();
18446        let (cols, rows) = unwrap_rows(r);
18447        assert_eq!(cols[0].name, "who");
18448        assert_eq!(rows.len(), 1);
18449        assert_eq!(rows[0].values[0], Value::Text("alice".into()));
18450    }
18451
18452    #[test]
18453    fn qualified_column_with_table_alias_resolves() {
18454        let mut e = Engine::new();
18455        make_three_row_users(&mut e);
18456        let r = e
18457            .execute("SELECT u.id, u.name FROM users AS u WHERE u.id < 3")
18458            .unwrap();
18459        let (cols, rows) = unwrap_rows(r);
18460        assert_eq!(cols.len(), 2);
18461        assert_eq!(rows.len(), 2);
18462    }
18463
18464    #[test]
18465    fn qualified_column_with_wrong_alias_errors() {
18466        let mut e = Engine::new();
18467        make_three_row_users(&mut e);
18468        let err = e.execute("SELECT x.id FROM users AS u").unwrap_err();
18469        assert!(matches!(
18470            err,
18471            EngineError::Eval(EvalError::UnknownQualifier { ref qualifier }) if qualifier == "x"
18472        ));
18473    }
18474
18475    #[test]
18476    fn select_unknown_column_errors_in_projection() {
18477        let mut e = Engine::new();
18478        make_three_row_users(&mut e);
18479        let err = e.execute("SELECT ghost FROM users").unwrap_err();
18480        assert!(matches!(
18481            err,
18482            EngineError::Eval(EvalError::ColumnNotFound { ref name }) if name == "ghost"
18483        ));
18484    }
18485
18486    #[test]
18487    fn where_unknown_column_errors() {
18488        let mut e = Engine::new();
18489        make_three_row_users(&mut e);
18490        let err = e
18491            .execute("SELECT * FROM users WHERE ghost = 1")
18492            .unwrap_err();
18493        assert!(matches!(
18494            err,
18495            EngineError::Eval(EvalError::ColumnNotFound { .. })
18496        ));
18497    }
18498
18499    #[test]
18500    fn expression_projection_evaluates_and_renders() {
18501        // Compound expressions in the SELECT list are evaluated per row;
18502        // the output column is typed TEXT, name defaults to the expression.
18503        let mut e = Engine::new();
18504        e.execute("CREATE TABLE t (a INT NOT NULL)").unwrap();
18505        e.execute("INSERT INTO t VALUES (3)").unwrap();
18506        let (_, rows) = unwrap_rows(e.execute("SELECT 1 + 2 FROM t").unwrap());
18507        assert_eq!(rows.len(), 1);
18508        // The expression evaluates to integer 3; rendered as the cell value
18509        // (storage::Value::Int(3) since arithmetic kept ints).
18510        assert_eq!(rows[0].values[0], Value::Int(3));
18511    }
18512
18513    #[test]
18514    fn select_unknown_table_errors() {
18515        let mut e = Engine::new();
18516        let err = e.execute("SELECT * FROM ghost").unwrap_err();
18517        assert!(matches!(
18518            err,
18519            EngineError::Storage(StorageError::TableNotFound { .. })
18520        ));
18521    }
18522
18523    #[test]
18524    fn invalid_sql_returns_parse_error() {
18525        // v4.4: UPDATE is now real SQL, so use a true syntactic
18526        // garbage payload for the parse-error path.
18527        let mut e = Engine::new();
18528        let err = e.execute("THIS_IS_NOT_A_KEYWORD foo bar baz").unwrap_err();
18529        assert!(matches!(err, EngineError::Parse(_)));
18530    }
18531
18532    // --- v0.8 CREATE INDEX + index seek ------------------------------------
18533
18534    #[test]
18535    fn create_index_registers_on_table() {
18536        let mut e = Engine::new();
18537        make_three_row_users(&mut e);
18538        e.execute("CREATE INDEX by_name ON users (name)").unwrap();
18539        let t = e.catalog().get("users").unwrap();
18540        assert_eq!(t.indices().len(), 1);
18541        assert_eq!(t.indices()[0].name, "by_name");
18542    }
18543
18544    #[test]
18545    fn create_index_on_unknown_table_errors() {
18546        let mut e = Engine::new();
18547        let err = e.execute("CREATE INDEX i ON ghost (a)").unwrap_err();
18548        assert!(matches!(
18549            err,
18550            EngineError::Storage(StorageError::TableNotFound { .. })
18551        ));
18552    }
18553
18554    #[test]
18555    fn create_index_on_unknown_column_errors() {
18556        let mut e = Engine::new();
18557        make_three_row_users(&mut e);
18558        let err = e.execute("CREATE INDEX i ON users (ghost)").unwrap_err();
18559        assert!(matches!(
18560            err,
18561            EngineError::Storage(StorageError::ColumnNotFound { .. })
18562        ));
18563    }
18564
18565    #[test]
18566    fn select_eq_uses_index_returns_same_rows_as_scan() {
18567        // Build two engines: one with an index, one without. Same query →
18568        // same row set (index is a planner optimisation, not a semantic
18569        // change).
18570        let mut without = Engine::new();
18571        make_three_row_users(&mut without);
18572        let mut with = Engine::new();
18573        make_three_row_users(&mut with);
18574        with.execute("CREATE INDEX by_id ON users (id)").unwrap();
18575
18576        let q = "SELECT * FROM users WHERE id = 2";
18577        let (_, no_idx_rows) = unwrap_rows(without.execute(q).unwrap());
18578        let (_, idx_rows) = unwrap_rows(with.execute(q).unwrap());
18579        assert_eq!(no_idx_rows, idx_rows);
18580        assert_eq!(idx_rows.len(), 1);
18581    }
18582
18583    #[test]
18584    fn select_eq_with_no_matching_index_value_returns_empty() {
18585        let mut e = Engine::new();
18586        make_three_row_users(&mut e);
18587        e.execute("CREATE INDEX by_id ON users (id)").unwrap();
18588        let (_, rows) = unwrap_rows(e.execute("SELECT * FROM users WHERE id = 999").unwrap());
18589        assert_eq!(rows.len(), 0);
18590    }
18591
18592    // --- v0.9 transactions -------------------------------------------------
18593
18594    #[test]
18595    fn begin_sets_in_transaction_flag() {
18596        let mut e = Engine::new();
18597        assert!(!e.in_transaction());
18598        e.execute("BEGIN").unwrap();
18599        assert!(e.in_transaction());
18600    }
18601
18602    #[test]
18603    fn double_begin_errors() {
18604        let mut e = Engine::new();
18605        e.execute("BEGIN").unwrap();
18606        let err = e.execute("BEGIN").unwrap_err();
18607        assert_eq!(err, EngineError::TransactionAlreadyOpen);
18608    }
18609
18610    #[test]
18611    fn commit_without_begin_errors() {
18612        let mut e = Engine::new();
18613        let err = e.execute("COMMIT").unwrap_err();
18614        assert_eq!(err, EngineError::NoActiveTransaction);
18615    }
18616
18617    #[test]
18618    fn rollback_without_begin_errors() {
18619        let mut e = Engine::new();
18620        let err = e.execute("ROLLBACK").unwrap_err();
18621        assert_eq!(err, EngineError::NoActiveTransaction);
18622    }
18623
18624    #[test]
18625    fn commit_applies_shadow_to_committed_catalog() {
18626        let mut e = Engine::new();
18627        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
18628        e.execute("BEGIN").unwrap();
18629        e.execute("INSERT INTO t VALUES (1)").unwrap();
18630        e.execute("INSERT INTO t VALUES (2)").unwrap();
18631        e.execute("COMMIT").unwrap();
18632        assert!(!e.in_transaction());
18633        assert_eq!(e.catalog().get("t").unwrap().row_count(), 2);
18634    }
18635
18636    #[test]
18637    fn rollback_discards_shadow() {
18638        let mut e = Engine::new();
18639        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
18640        e.execute("BEGIN").unwrap();
18641        e.execute("INSERT INTO t VALUES (1)").unwrap();
18642        e.execute("INSERT INTO t VALUES (2)").unwrap();
18643        e.execute("ROLLBACK").unwrap();
18644        assert!(!e.in_transaction());
18645        assert_eq!(e.catalog().get("t").unwrap().row_count(), 0);
18646    }
18647
18648    #[test]
18649    fn select_during_tx_sees_uncommitted_writes_own_session() {
18650        // The shadow catalog is read by SELECTs while a TX is open — the
18651        // session can see its own pending writes.
18652        let mut e = Engine::new();
18653        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
18654        e.execute("BEGIN").unwrap();
18655        e.execute("INSERT INTO t VALUES (42)").unwrap();
18656        let (_, rows) = unwrap_rows(e.execute("SELECT * FROM t").unwrap());
18657        assert_eq!(rows.len(), 1);
18658        assert_eq!(rows[0].values[0], Value::Int(42));
18659    }
18660
18661    #[test]
18662    fn snapshot_with_no_users_is_bare_catalog_format() {
18663        let mut e = Engine::new();
18664        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18665        let bytes = e.snapshot();
18666        assert_eq!(
18667            &bytes[..8],
18668            b"SPGDB001",
18669            "must be the bare v3.x catalog magic"
18670        );
18671        let e2 = Engine::restore_envelope(&bytes).unwrap();
18672        assert!(e2.users().is_empty());
18673        assert_eq!(e2.catalog().table_count(), 1);
18674    }
18675
18676    #[test]
18677    fn snapshot_with_users_round_trips_both_via_envelope() {
18678        let mut e = Engine::new();
18679        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18680        e.create_user("alice", "pw1", Role::Admin, [9; 16]).unwrap();
18681        e.create_user("bob", "pw2", Role::ReadOnly, [5; 16])
18682            .unwrap();
18683        let bytes = e.snapshot();
18684        assert_eq!(&bytes[..8], b"SPGENV01", "must be the v4.1 envelope magic");
18685        let e2 = Engine::restore_envelope(&bytes).unwrap();
18686        assert_eq!(e2.users().len(), 2);
18687        assert_eq!(e2.verify_user("alice", "pw1"), Some(Role::Admin));
18688        assert_eq!(e2.verify_user("bob", "pw2"), Some(Role::ReadOnly));
18689        assert_eq!(e2.verify_user("alice", "wrong"), None);
18690        assert_eq!(e2.catalog().table_count(), 1);
18691    }
18692
18693    #[test]
18694    fn ddl_inside_tx_also_rolled_back() {
18695        let mut e = Engine::new();
18696        e.execute("BEGIN").unwrap();
18697        e.execute("CREATE TABLE t (v INT)").unwrap();
18698        // Visible inside the TX.
18699        e.execute("SELECT * FROM t").unwrap();
18700        e.execute("ROLLBACK").unwrap();
18701        // Gone after rollback.
18702        let err = e.execute("SELECT * FROM t").unwrap_err();
18703        assert!(matches!(
18704            err,
18705            EngineError::Storage(StorageError::TableNotFound { .. })
18706        ));
18707    }
18708
18709    // ── v6.1.2: CREATE / DROP PUBLICATION (engine-side) ──────
18710
18711    #[test]
18712    fn create_publication_lands_in_catalog() {
18713        let mut e = Engine::new();
18714        assert!(e.publications().is_empty());
18715        e.execute("CREATE PUBLICATION pub_a").unwrap();
18716        assert_eq!(e.publications().len(), 1);
18717        assert!(e.publications().contains("pub_a"));
18718    }
18719
18720    #[test]
18721    fn create_publication_duplicate_errors() {
18722        let mut e = Engine::new();
18723        e.execute("CREATE PUBLICATION pub_a").unwrap();
18724        let err = e.execute("CREATE PUBLICATION pub_a").unwrap_err();
18725        assert!(
18726            alloc::format!("{err:?}").contains("DuplicateName"),
18727            "got {err:?}"
18728        );
18729    }
18730
18731    #[test]
18732    fn drop_publication_silent_when_absent() {
18733        let mut e = Engine::new();
18734        // PG-compatible: DROP a publication that doesn't exist
18735        // succeeds (no-op) but reports zero affected.
18736        let r = e.execute("DROP PUBLICATION nope").unwrap();
18737        match r {
18738            QueryResult::CommandOk { affected, .. } => assert_eq!(affected, 0),
18739            other => panic!("expected CommandOk, got {other:?}"),
18740        }
18741    }
18742
18743    #[test]
18744    fn drop_publication_present_reports_one_affected() {
18745        let mut e = Engine::new();
18746        e.execute("CREATE PUBLICATION pub_a").unwrap();
18747        let r = e.execute("DROP PUBLICATION pub_a").unwrap();
18748        match r {
18749            QueryResult::CommandOk {
18750                affected,
18751                modified_catalog,
18752            } => {
18753                assert_eq!(affected, 1);
18754                assert!(modified_catalog);
18755            }
18756            other => panic!("expected CommandOk, got {other:?}"),
18757        }
18758        assert!(e.publications().is_empty());
18759    }
18760
18761    #[test]
18762    fn publications_persist_across_snapshot_restore() {
18763        // The persist-across-restart ship-gate at the engine layer —
18764        // snapshot → restore_envelope round trip must preserve the
18765        // publication catalog. The spg-server e2e covers the
18766        // process-restart variant.
18767        let mut e = Engine::new();
18768        e.execute("CREATE PUBLICATION pub_a").unwrap();
18769        e.execute("CREATE PUBLICATION pub_b FOR ALL TABLES")
18770            .unwrap();
18771        let snap = e.snapshot();
18772        let e2 = Engine::restore_envelope(&snap).unwrap();
18773        assert_eq!(e2.publications().len(), 2);
18774        assert!(e2.publications().contains("pub_a"));
18775        assert!(e2.publications().contains("pub_b"));
18776    }
18777
18778    #[test]
18779    fn create_publication_allowed_inside_transaction() {
18780        // v6.1.4 dropped the v6.1.2 in-TX guard — PG allows
18781        // CREATE PUBLICATION inside a TX and the auto-commit
18782        // wrap path needs the same allowance.
18783        let mut e = Engine::new();
18784        e.execute("BEGIN").unwrap();
18785        e.execute("CREATE PUBLICATION pub_a").unwrap();
18786        e.execute("COMMIT").unwrap();
18787        assert!(e.publications().contains("pub_a"));
18788    }
18789
18790    // ── v6.1.3: SHOW PUBLICATIONS + FOR-list variants ───────
18791
18792    #[test]
18793    fn create_publication_for_table_list_lands_with_scope() {
18794        let mut e = Engine::new();
18795        e.execute("CREATE TABLE t1 (id INT NOT NULL)").unwrap();
18796        e.execute("CREATE TABLE t2 (id INT NOT NULL)").unwrap();
18797        e.execute("CREATE PUBLICATION pub_a FOR TABLE t1, t2")
18798            .unwrap();
18799        let scope = e.publications().get("pub_a").cloned();
18800        let Some(spg_sql::ast::PublicationScope::ForTables(ts)) = scope else {
18801            panic!("expected ForTables scope, got {scope:?}")
18802        };
18803        assert_eq!(ts, alloc::vec!["t1".to_string(), "t2".to_string()]);
18804    }
18805
18806    #[test]
18807    fn create_publication_all_tables_except_lands_with_scope() {
18808        let mut e = Engine::new();
18809        e.execute("CREATE PUBLICATION pub_a FOR ALL TABLES EXCEPT t3")
18810            .unwrap();
18811        let scope = e.publications().get("pub_a").cloned();
18812        let Some(spg_sql::ast::PublicationScope::AllTablesExcept(ts)) = scope else {
18813            panic!("expected AllTablesExcept scope, got {scope:?}")
18814        };
18815        assert_eq!(ts, alloc::vec!["t3".to_string()]);
18816    }
18817
18818    #[test]
18819    fn show_publications_empty_returns_zero_rows() {
18820        let e = Engine::new();
18821        let r = e.execute_readonly("SHOW PUBLICATIONS").unwrap();
18822        let QueryResult::Rows { rows, columns } = r else {
18823            panic!()
18824        };
18825        assert!(rows.is_empty());
18826        assert_eq!(columns.len(), 3);
18827        assert_eq!(columns[0].name, "name");
18828        assert_eq!(columns[1].name, "scope");
18829        assert_eq!(columns[2].name, "table_count");
18830    }
18831
18832    #[test]
18833    fn show_publications_returns_one_row_per_publication_ordered_by_name() {
18834        let mut e = Engine::new();
18835        e.execute("CREATE PUBLICATION z_pub").unwrap();
18836        e.execute("CREATE PUBLICATION a_pub FOR TABLE t1, t2")
18837            .unwrap();
18838        e.execute("CREATE PUBLICATION m_pub FOR ALL TABLES EXCEPT bad")
18839            .unwrap();
18840        let r = e.execute_readonly("SHOW PUBLICATIONS").unwrap();
18841        let QueryResult::Rows { rows, .. } = r else {
18842            panic!()
18843        };
18844        assert_eq!(rows.len(), 3);
18845        // Alphabetical order: a_pub, m_pub, z_pub.
18846        let names: Vec<&str> = rows
18847            .iter()
18848            .map(|r| {
18849                if let Value::Text(s) = &r.values[0] {
18850                    s.as_str()
18851                } else {
18852                    panic!()
18853                }
18854            })
18855            .collect();
18856        assert_eq!(names, alloc::vec!["a_pub", "m_pub", "z_pub"]);
18857        // Row 0 — a_pub scope summary + table_count = 2.
18858        match &rows[0].values[1] {
18859            Value::Text(s) => assert_eq!(s, "FOR TABLE t1, t2"),
18860            other => panic!("expected Text, got {other:?}"),
18861        }
18862        assert_eq!(rows[0].values[2], Value::Int(2));
18863        // Row 1 — m_pub.
18864        match &rows[1].values[1] {
18865            Value::Text(s) => assert_eq!(s, "FOR ALL TABLES EXCEPT bad"),
18866            other => panic!("expected Text, got {other:?}"),
18867        }
18868        assert_eq!(rows[1].values[2], Value::Int(1));
18869        // Row 2 — z_pub (AllTables → NULL count).
18870        match &rows[2].values[1] {
18871            Value::Text(s) => assert_eq!(s, "FOR ALL TABLES"),
18872            other => panic!("expected Text, got {other:?}"),
18873        }
18874        assert_eq!(rows[2].values[2], Value::Null);
18875    }
18876
18877    #[test]
18878    fn for_list_scopes_persist_across_snapshot() {
18879        // The v6.1.2 envelope-v3 round-trip exercised AllTables;
18880        // v6.1.3 needs the scope-1 / scope-2 tags to survive too.
18881        let mut e = Engine::new();
18882        e.execute("CREATE PUBLICATION p1 FOR TABLE t1, t2").unwrap();
18883        e.execute("CREATE PUBLICATION p2 FOR ALL TABLES EXCEPT bad, worse")
18884            .unwrap();
18885        let snap = e.snapshot();
18886        let e2 = Engine::restore_envelope(&snap).unwrap();
18887        assert_eq!(e2.publications().len(), 2);
18888        let p1 = e2.publications().get("p1").cloned();
18889        let Some(spg_sql::ast::PublicationScope::ForTables(ts)) = p1 else {
18890            panic!("p1 scope lost: {p1:?}")
18891        };
18892        assert_eq!(ts, alloc::vec!["t1".to_string(), "t2".to_string()]);
18893        let p2 = e2.publications().get("p2").cloned();
18894        let Some(spg_sql::ast::PublicationScope::AllTablesExcept(ts)) = p2 else {
18895            panic!("p2 scope lost: {p2:?}")
18896        };
18897        assert_eq!(ts, alloc::vec!["bad".to_string(), "worse".to_string()]);
18898    }
18899
18900    // ── v6.1.4: CREATE / DROP SUBSCRIPTION + SHOW + envelope v4 ─
18901
18902    #[test]
18903    fn create_subscription_lands_in_catalog_with_defaults() {
18904        let mut e = Engine::new();
18905        e.execute(
18906            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=127.0.0.1 port=20002' PUBLICATION pub_a",
18907        )
18908        .unwrap();
18909        let s = e.subscriptions().get("sub_a").cloned().expect("present");
18910        assert_eq!(s.conn_str, "host=127.0.0.1 port=20002");
18911        assert_eq!(s.publications, alloc::vec!["pub_a".to_string()]);
18912        assert!(s.enabled);
18913        assert_eq!(s.last_received_pos, 0);
18914    }
18915
18916    #[test]
18917    fn create_subscription_duplicate_name_errors() {
18918        let mut e = Engine::new();
18919        e.execute("CREATE SUBSCRIPTION s CONNECTION 'host=x' PUBLICATION p")
18920            .unwrap();
18921        let err = e
18922            .execute("CREATE SUBSCRIPTION s CONNECTION 'host=y' PUBLICATION p")
18923            .unwrap_err();
18924        assert!(
18925            alloc::format!("{err:?}").contains("DuplicateName"),
18926            "got {err:?}"
18927        );
18928    }
18929
18930    #[test]
18931    fn drop_subscription_silent_when_absent() {
18932        let mut e = Engine::new();
18933        let r = e.execute("DROP SUBSCRIPTION never").unwrap();
18934        match r {
18935            QueryResult::CommandOk { affected, .. } => assert_eq!(affected, 0),
18936            other => panic!("expected CommandOk, got {other:?}"),
18937        }
18938    }
18939
18940    #[test]
18941    fn subscription_advance_updates_last_pos_monotone() {
18942        let mut e = Engine::new();
18943        e.execute("CREATE SUBSCRIPTION s CONNECTION 'h=x' PUBLICATION p")
18944            .unwrap();
18945        assert!(e.subscription_advance("s", 100));
18946        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 100);
18947        assert!(e.subscription_advance("s", 50)); // stale → ignored
18948        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 100);
18949        assert!(e.subscription_advance("s", 200));
18950        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 200);
18951        assert!(!e.subscription_advance("missing", 1));
18952    }
18953
18954    #[test]
18955    fn show_subscriptions_returns_rows_ordered_by_name() {
18956        let mut e = Engine::new();
18957        e.execute("CREATE SUBSCRIPTION z_sub CONNECTION 'h=x' PUBLICATION p1, p2")
18958            .unwrap();
18959        e.execute("CREATE SUBSCRIPTION a_sub CONNECTION 'h=y' PUBLICATION p3")
18960            .unwrap();
18961        let r = e.execute_readonly("SHOW SUBSCRIPTIONS").unwrap();
18962        let QueryResult::Rows { rows, columns } = r else {
18963            panic!()
18964        };
18965        assert_eq!(rows.len(), 2);
18966        assert_eq!(columns.len(), 5);
18967        assert_eq!(columns[0].name, "name");
18968        assert_eq!(columns[4].name, "last_received_pos");
18969        // Alphabetical: a_sub, z_sub.
18970        let names: Vec<&str> = rows
18971            .iter()
18972            .map(|r| {
18973                if let Value::Text(s) = &r.values[0] {
18974                    s.as_str()
18975                } else {
18976                    panic!()
18977                }
18978            })
18979            .collect();
18980        assert_eq!(names, alloc::vec!["a_sub", "z_sub"]);
18981        // Row 0: a_sub
18982        assert_eq!(rows[0].values[1], Value::Text("h=y".to_string()));
18983        assert_eq!(rows[0].values[2], Value::Text("p3".to_string()));
18984        assert_eq!(rows[0].values[3], Value::Bool(true));
18985        assert_eq!(rows[0].values[4], Value::BigInt(0));
18986        // Row 1: z_sub — publications join with ", "
18987        assert_eq!(rows[1].values[2], Value::Text("p1, p2".to_string()));
18988    }
18989
18990    #[test]
18991    fn subscriptions_persist_across_snapshot_envelope_v4() {
18992        let mut e = Engine::new();
18993        e.execute("CREATE SUBSCRIPTION s1 CONNECTION 'h=A' PUBLICATION p1, p2")
18994            .unwrap();
18995        e.execute("CREATE SUBSCRIPTION s2 CONNECTION 'h=B' PUBLICATION p3")
18996            .unwrap();
18997        e.subscription_advance("s2", 42);
18998        let snap = e.snapshot();
18999        let e2 = Engine::restore_envelope(&snap).unwrap();
19000        assert_eq!(e2.subscriptions().len(), 2);
19001        let s1 = e2.subscriptions().get("s1").unwrap();
19002        assert_eq!(s1.conn_str, "h=A");
19003        assert_eq!(
19004            s1.publications,
19005            alloc::vec!["p1".to_string(), "p2".to_string()]
19006        );
19007        assert_eq!(s1.last_received_pos, 0);
19008        let s2 = e2.subscriptions().get("s2").unwrap();
19009        assert_eq!(s2.last_received_pos, 42);
19010    }
19011
19012    #[test]
19013    fn v3_envelope_loads_with_empty_subscriptions() {
19014        // v3 snapshot (publications-only). Forge it by hand so we
19015        // verify v6.1.4 readers don't panic — they must surface
19016        // empty subscriptions and a populated publication table.
19017        let mut e = Engine::new();
19018        e.execute("CREATE PUBLICATION pub_legacy").unwrap();
19019        let catalog = e.catalog.serialize();
19020        let users = crate::users::serialize_users(&e.users);
19021        let pubs = e.publications.serialize();
19022        let mut buf = Vec::new();
19023        buf.extend_from_slice(b"SPGENV01");
19024        buf.push(3u8); // v3
19025        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
19026        buf.extend_from_slice(&catalog);
19027        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
19028        buf.extend_from_slice(&users);
19029        buf.extend_from_slice(&u32::try_from(pubs.len()).unwrap().to_le_bytes());
19030        buf.extend_from_slice(&pubs);
19031        let crc = spg_crypto::crc32::crc32(&buf);
19032        buf.extend_from_slice(&crc.to_le_bytes());
19033
19034        let e2 = Engine::restore_envelope(&buf).expect("v3 envelope restores under v4 reader");
19035        assert!(e2.subscriptions().is_empty());
19036        assert!(e2.publications().contains("pub_legacy"));
19037    }
19038
19039    #[test]
19040    fn create_subscription_allowed_inside_transaction() {
19041        let mut e = Engine::new();
19042        e.execute("BEGIN").unwrap();
19043        e.execute("CREATE SUBSCRIPTION s CONNECTION 'h=x' PUBLICATION p")
19044            .unwrap();
19045        e.execute("COMMIT").unwrap();
19046        assert!(e.subscriptions().contains("s"));
19047    }
19048
19049    // ── v6.2.0: ANALYZE + spg_statistic + envelope v5 ──────────
19050    #[test]
19051    fn analyze_populates_histogram_bounds() {
19052        let mut e = Engine::new();
19053        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT)")
19054            .unwrap();
19055        for i in 0..50 {
19056            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, 'name{i}')"))
19057                .unwrap();
19058        }
19059        e.execute("ANALYZE t").unwrap();
19060        let stats = e.statistics();
19061        let id_stats = stats.get("t", "id").unwrap();
19062        assert!(id_stats.histogram_bounds.len() >= 2);
19063        assert_eq!(id_stats.histogram_bounds.first().unwrap(), "0");
19064        assert_eq!(id_stats.histogram_bounds.last().unwrap(), "49");
19065        assert!((id_stats.null_frac - 0.0).abs() < 1e-6);
19066        assert_eq!(id_stats.n_distinct, 50);
19067    }
19068
19069    #[test]
19070    fn reanalyze_overwrites_prior_stats() {
19071        let mut e = Engine::new();
19072        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19073        for i in 0..10 {
19074            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19075                .unwrap();
19076        }
19077        e.execute("ANALYZE t").unwrap();
19078        let n1 = e.statistics().get("t", "id").unwrap().n_distinct;
19079        assert_eq!(n1, 10);
19080        for i in 10..30 {
19081            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19082                .unwrap();
19083        }
19084        e.execute("ANALYZE t").unwrap();
19085        let n2 = e.statistics().get("t", "id").unwrap().n_distinct;
19086        assert_eq!(n2, 30);
19087    }
19088
19089    #[test]
19090    fn analyze_unknown_table_errors() {
19091        let mut e = Engine::new();
19092        let err = e.execute("ANALYZE nonexistent").unwrap_err();
19093        assert!(matches!(
19094            err,
19095            EngineError::Storage(StorageError::TableNotFound { .. })
19096        ));
19097    }
19098
19099    #[test]
19100    fn bare_analyze_covers_all_user_tables() {
19101        let mut e = Engine::new();
19102        e.execute("CREATE TABLE t1 (id INT NOT NULL)").unwrap();
19103        e.execute("CREATE TABLE t2 (name TEXT NOT NULL)").unwrap();
19104        e.execute("INSERT INTO t1 VALUES (1)").unwrap();
19105        e.execute("INSERT INTO t2 VALUES ('alice')").unwrap();
19106        let r = e.execute("ANALYZE").unwrap();
19107        match r {
19108            QueryResult::CommandOk {
19109                affected,
19110                modified_catalog,
19111            } => {
19112                assert_eq!(affected, 2);
19113                assert!(modified_catalog);
19114            }
19115            other => panic!("expected CommandOk, got {other:?}"),
19116        }
19117        assert!(e.statistics().get("t1", "id").is_some());
19118        assert!(e.statistics().get("t2", "name").is_some());
19119    }
19120
19121    #[test]
19122    fn select_from_spg_statistic_returns_rows_per_column() {
19123        let mut e = Engine::new();
19124        e.execute("CREATE TABLE t (id INT NOT NULL, label TEXT)")
19125            .unwrap();
19126        e.execute("INSERT INTO t VALUES (1, 'a')").unwrap();
19127        e.execute("INSERT INTO t VALUES (2, 'b')").unwrap();
19128        e.execute("ANALYZE t").unwrap();
19129        let r = e.execute_readonly("SELECT * FROM spg_statistic").unwrap();
19130        let QueryResult::Rows { rows, columns } = r else {
19131            panic!()
19132        };
19133        // v6.7.0 — spg_statistic gained a `cold_row_count` column.
19134        assert_eq!(columns.len(), 6);
19135        assert_eq!(columns[0].name, "table_name");
19136        assert_eq!(columns[4].name, "histogram_bounds");
19137        assert_eq!(columns[5].name, "cold_row_count");
19138        assert_eq!(rows.len(), 2, "one row per column of t");
19139        // Sorted by (table_name, column_name).
19140        match (&rows[0].values[0], &rows[0].values[1]) {
19141            (Value::Text(t), Value::Text(c)) => {
19142                assert_eq!(t, "t");
19143                // BTreeMap orders (table, column); columns "id" < "label".
19144                assert_eq!(c, "id");
19145            }
19146            _ => panic!(),
19147        }
19148    }
19149
19150    #[test]
19151    fn analyze_skips_vector_columns() {
19152        // Vector columns have their own stats shape (HNSW graph);
19153        // ANALYZE leaves them out of spg_statistic.
19154        let mut e = Engine::new();
19155        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(3) NOT NULL)")
19156            .unwrap();
19157        e.execute("INSERT INTO t VALUES (1, [1, 2, 3])").unwrap();
19158        e.execute("ANALYZE t").unwrap();
19159        assert!(e.statistics().get("t", "id").is_some());
19160        assert!(e.statistics().get("t", "v").is_none());
19161    }
19162
19163    #[test]
19164    fn statistics_persist_across_envelope_v5_round_trip() {
19165        let mut e = Engine::new();
19166        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19167        for i in 0..20 {
19168            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19169                .unwrap();
19170        }
19171        e.execute("ANALYZE").unwrap();
19172        let snap = e.snapshot();
19173        let e2 = Engine::restore_envelope(&snap).unwrap();
19174        let s = e2.statistics().get("t", "id").unwrap();
19175        assert_eq!(s.n_distinct, 20);
19176    }
19177
19178    // ── v6.2.1 auto-analyze threshold ───────────────────────────
19179
19180    #[test]
19181    fn auto_analyze_threshold_fires_after_10pct_of_min_rows_on_small_table() {
19182        // For a table with 0 rows then 10 inserts → modified=10,
19183        // row_count=10. Threshold = 0.1 × max(10, 100) = 10. So
19184        // after the 10th INSERT the threshold is met.
19185        let mut e = Engine::new();
19186        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19187        for i in 0..9 {
19188            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19189                .unwrap();
19190        }
19191        assert!(e.tables_needing_analyze().is_empty(), "9 < threshold");
19192        e.execute("INSERT INTO t VALUES (9)").unwrap();
19193        let needs = e.tables_needing_analyze();
19194        assert_eq!(needs, alloc::vec!["t".to_string()]);
19195    }
19196
19197    #[test]
19198    fn auto_analyze_threshold_uses_10pct_of_row_count_for_large_tables() {
19199        // After ANALYZE on 1000 rows, threshold = 0.1 × row_count.
19200        // Each new INSERT bumps both modified and row_count, so to
19201        // trigger from N=1000 we need modifications ≥ 0.1 × (1000+M),
19202        // i.e. M ≥ 112. The test inserts 50 (no fire), then 150
19203        // more (200 total mods, row_count=1200, threshold=120 → fire).
19204        let mut e = Engine::new();
19205        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19206        for i in 0..1000 {
19207            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19208                .unwrap();
19209        }
19210        e.execute("ANALYZE t").unwrap();
19211        assert!(e.tables_needing_analyze().is_empty(), "fresh ANALYZE");
19212        for i in 1000..1050 {
19213            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19214                .unwrap();
19215        }
19216        assert!(
19217            e.tables_needing_analyze().is_empty(),
19218            "50 inserts < threshold of ~105"
19219        );
19220        for i in 1050..1200 {
19221            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19222                .unwrap();
19223        }
19224        assert_eq!(
19225            e.tables_needing_analyze(),
19226            alloc::vec!["t".to_string()],
19227            "200 inserts > 0.1 × 1200 threshold"
19228        );
19229    }
19230
19231    #[test]
19232    fn auto_analyze_threshold_resets_after_analyze() {
19233        let mut e = Engine::new();
19234        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19235        for i in 0..200 {
19236            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19237                .unwrap();
19238        }
19239        assert!(!e.tables_needing_analyze().is_empty());
19240        e.execute("ANALYZE").unwrap();
19241        assert!(
19242            e.tables_needing_analyze().is_empty(),
19243            "ANALYZE must reset the counter"
19244        );
19245    }
19246
19247    #[test]
19248    fn auto_analyze_threshold_tracks_updates_and_deletes() {
19249        let mut e = Engine::new();
19250        e.execute("CREATE TABLE t (id INT NOT NULL, label TEXT)")
19251            .unwrap();
19252        for i in 0..50 {
19253            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, 'x')"))
19254                .unwrap();
19255        }
19256        e.execute("ANALYZE t").unwrap();
19257        // UPDATE 20 rows + DELETE 5 → modified=25. Threshold = 0.1
19258        // × max(50, 100) = 10. So 25 >= 10 → trigger.
19259        e.execute("UPDATE t SET label = 'y' WHERE id < 20").unwrap();
19260        e.execute("DELETE FROM t WHERE id >= 45").unwrap();
19261        assert_eq!(e.tables_needing_analyze(), alloc::vec!["t".to_string()]);
19262    }
19263
19264    #[test]
19265    fn v4_envelope_loads_with_empty_statistics() {
19266        // Forge a v4 envelope by hand: catalog + users + pubs +
19267        // subs trailer, no statistics. A v6.2.0 reader must accept
19268        // it and surface an empty Statistics.
19269        let mut e = Engine::new();
19270        e.create_user("alice", "secret", crate::users::Role::ReadOnly, [0u8; 16])
19271            .unwrap();
19272        let catalog = e.catalog.serialize();
19273        let users = crate::users::serialize_users(&e.users);
19274        let pubs = e.publications.serialize();
19275        let subs = e.subscriptions.serialize();
19276        let mut buf = Vec::new();
19277        buf.extend_from_slice(b"SPGENV01");
19278        buf.push(4u8);
19279        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
19280        buf.extend_from_slice(&catalog);
19281        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
19282        buf.extend_from_slice(&users);
19283        buf.extend_from_slice(&u32::try_from(pubs.len()).unwrap().to_le_bytes());
19284        buf.extend_from_slice(&pubs);
19285        buf.extend_from_slice(&u32::try_from(subs.len()).unwrap().to_le_bytes());
19286        buf.extend_from_slice(&subs);
19287        let crc = spg_crypto::crc32::crc32(&buf);
19288        buf.extend_from_slice(&crc.to_le_bytes());
19289        let e2 = Engine::restore_envelope(&buf).expect("v4 envelope restores");
19290        assert!(e2.statistics().is_empty());
19291    }
19292
19293    #[test]
19294    fn v1_v2_envelope_loads_with_empty_publications() {
19295        // A snapshot taken before v6.1.2 (no publication trailer,
19296        // envelope v2) must still deserialise — and the resulting
19297        // engine must report zero publications. Use the engine's own
19298        // round-trip with no publications: that emits v3 but with an
19299        // empty pubs block. Then forge a v2 envelope by hand to lock
19300        // the back-compat path.
19301        let mut e = Engine::new();
19302        // Force users to be non-empty so the snapshot takes the
19303        // envelope path rather than the bare-catalog fallback.
19304        e.create_user("alice", "secret", crate::users::Role::ReadOnly, [0u8; 16])
19305            .unwrap();
19306
19307        // Forge an envelope v2: same shape as v3 but no pubs trailer.
19308        let catalog = e.catalog.serialize();
19309        let users = crate::users::serialize_users(&e.users);
19310        let mut buf = Vec::new();
19311        buf.extend_from_slice(b"SPGENV01");
19312        buf.push(2u8); // v2
19313        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
19314        buf.extend_from_slice(&catalog);
19315        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
19316        buf.extend_from_slice(&users);
19317        let crc = spg_crypto::crc32::crc32(&buf);
19318        buf.extend_from_slice(&crc.to_le_bytes());
19319
19320        let e2 = Engine::restore_envelope(&buf).expect("v2 envelope restores");
19321        assert!(e2.publications().is_empty());
19322    }
19323}