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        // v7.29 (round-23a) - implicit serial sequences materialise
3279        // on first address, ALTER SEQUENCE included.
3280        self.ensure_implicit_sequence(&s.name);
3281        let cat = self.active_catalog_mut();
3282        if !cat.sequences().contains_key(&s.name) {
3283            if s.if_exists {
3284                return Ok(QueryResult::CommandOk {
3285                    affected: 0,
3286                    modified_catalog: false,
3287                });
3288            }
3289            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3290                alloc::format!("sequence {:?} does not exist", s.name),
3291            )));
3292        }
3293        let min_value = match s.options.min_value {
3294            None => None,
3295            Some(SeqBound::NoBound) => None, // NO MINVALUE → keep current
3296            Some(SeqBound::Value(n)) => Some(n),
3297        };
3298        let max_value = match s.options.max_value {
3299            None => None,
3300            Some(SeqBound::NoBound) => None,
3301            Some(SeqBound::Value(n)) => Some(n),
3302        };
3303        let owned_by = s.options.owned_by.map(|ob| match ob {
3304            spg_sql::ast::SequenceOwnedBy::None => None,
3305            spg_sql::ast::SequenceOwnedBy::Column { table, column } => Some((table, column)),
3306        });
3307        cat.alter_sequence(
3308            &s.name,
3309            s.options.increment,
3310            min_value,
3311            max_value,
3312            s.options.start,
3313            s.options.restart,
3314            s.options.cache,
3315            s.options.cycle,
3316            owned_by,
3317        )
3318        .map_err(EngineError::Storage)?;
3319        Ok(QueryResult::CommandOk {
3320            affected: 0,
3321            modified_catalog: !self.in_transaction(),
3322        })
3323    }
3324
3325    /// v7.17.0 Phase 1.1 — walk a Statement tree and pre-resolve
3326    /// any sequence FunctionCall nodes inside its Expr slots.
3327    /// Delegates per-statement-kind: SELECT projection +
3328    /// WHERE, INSERT VALUES, UPDATE SET, DELETE WHERE.
3329    fn pre_resolve_sequence_calls_in_statement(
3330        &mut self,
3331        stmt: &mut Statement,
3332    ) -> Result<(), EngineError> {
3333        match stmt {
3334            Statement::Select(s) => self.pre_resolve_sequence_calls_in_select(s),
3335            Statement::Insert(s) => {
3336                for tuple in &mut s.rows {
3337                    for cell in tuple.iter_mut() {
3338                        self.resolve_sequence_calls_in_expr(cell)?;
3339                    }
3340                }
3341                Ok(())
3342            }
3343            Statement::Update(s) => {
3344                for (_col, expr) in &mut s.assignments {
3345                    self.resolve_sequence_calls_in_expr(expr)?;
3346                }
3347                if let Some(w) = &mut s.where_ {
3348                    self.resolve_sequence_calls_in_expr(w)?;
3349                }
3350                Ok(())
3351            }
3352            Statement::Delete(s) => {
3353                if let Some(w) = &mut s.where_ {
3354                    self.resolve_sequence_calls_in_expr(w)?;
3355                }
3356                Ok(())
3357            }
3358            _ => Ok(()),
3359        }
3360    }
3361
3362    fn pre_resolve_sequence_calls_in_select(
3363        &mut self,
3364        s: &mut spg_sql::ast::SelectStatement,
3365    ) -> Result<(), EngineError> {
3366        for item in &mut s.items {
3367            match item {
3368                spg_sql::ast::SelectItem::Expr { expr, .. } => {
3369                    self.resolve_sequence_calls_in_expr(expr)?;
3370                }
3371                spg_sql::ast::SelectItem::Wildcard => {}
3372            }
3373        }
3374        if let Some(w) = &mut s.where_ {
3375            self.resolve_sequence_calls_in_expr(w)?;
3376        }
3377        Ok(())
3378    }
3379
3380    /// v7.17.0 Phase 1.1 — walk an Expr tree and pre-resolve any
3381    /// `nextval(name)` / `currval(name)` / `setval(name, value[,
3382    /// is_called])` FunctionCall nodes by calling the catalog and
3383    /// replacing the node with the resulting `Expr::Literal`.
3384    /// Used by INSERT VALUES / UPDATE SET / DEFAULT eval so the
3385    /// row-eval path sees pre-computed sequence values instead of
3386    /// needing mutable catalog access mid-eval.
3387    #[allow(clippy::too_many_lines)]
3388    fn resolve_sequence_calls_in_expr(&mut self, expr: &mut Expr) -> Result<(), EngineError> {
3389        match expr {
3390            Expr::Literal(_) | Expr::Column(_) | Expr::Placeholder(_) => Ok(()),
3391            Expr::FunctionCall { name, args } => {
3392                // Descend first so nested calls — e.g.
3393                // setval('seq', currval('other')) — resolve
3394                // innermost-first.
3395                for a in args.iter_mut() {
3396                    self.resolve_sequence_calls_in_expr(a)?;
3397                }
3398                let lc = name.to_ascii_lowercase();
3399                if lc == "nextval" || lc == "currval" || lc == "setval" {
3400                    let v = self.eval_sequence_call(&lc, args)?;
3401                    *expr = Expr::Literal(value_to_literal(v));
3402                } else if lc == "pg_get_serial_sequence" && args.len() == 2 {
3403                    // v7.29 (round-23a) — resolves to the implicit
3404                    // sequence name so the pg_dump idiom
3405                    // `setval(pg_get_serial_sequence('t','c'), n)`
3406                    // works (the setval arm receives a literal).
3407                    let lit = |e: &Expr| -> Option<String> {
3408                        match e {
3409                            Expr::Literal(spg_sql::ast::Literal::String(v)) => {
3410                                let t = v.strip_prefix("public.").unwrap_or(v).trim_matches('"');
3411                                Some(t.to_string())
3412                            }
3413                            _ => None,
3414                        }
3415                    };
3416                    if let (Some(t), Some(c)) = (lit(&args[0]), lit(&args[1])) {
3417                        let is_serial = self.active_catalog().get(&t).is_some_and(|tb| {
3418                            tb.schema()
3419                                .columns
3420                                .iter()
3421                                .any(|col| col.name == c && col.auto_increment)
3422                        });
3423                        *expr = if is_serial {
3424                            Expr::Literal(spg_sql::ast::Literal::String(alloc::format!(
3425                                "public.{t}_{c}_seq"
3426                            )))
3427                        } else {
3428                            Expr::Literal(spg_sql::ast::Literal::Null)
3429                        };
3430                    }
3431                }
3432                Ok(())
3433            }
3434            Expr::Binary { lhs, rhs, .. } => {
3435                self.resolve_sequence_calls_in_expr(lhs)?;
3436                self.resolve_sequence_calls_in_expr(rhs)
3437            }
3438            Expr::Unary { expr, .. } => self.resolve_sequence_calls_in_expr(expr),
3439            Expr::Cast { expr, .. } => self.resolve_sequence_calls_in_expr(expr),
3440            Expr::IsNull { expr, .. } => self.resolve_sequence_calls_in_expr(expr),
3441            Expr::Like { expr, pattern, .. } => {
3442                self.resolve_sequence_calls_in_expr(expr)?;
3443                self.resolve_sequence_calls_in_expr(pattern)
3444            }
3445            Expr::Extract { source, .. } => self.resolve_sequence_calls_in_expr(source),
3446            Expr::Array(items) => {
3447                for it in items.iter_mut() {
3448                    self.resolve_sequence_calls_in_expr(it)?;
3449                }
3450                Ok(())
3451            }
3452            // Window / subquery / etc — sequence calls inside these
3453            // are uncommon and require separate row-eval; leave
3454            // untouched for now and rely on the eval-time error
3455            // (no sequence_resolver attached).
3456            _ => Ok(()),
3457        }
3458    }
3459
3460    /// v7.29 (mailrs round-23a) — SERIAL/BIGSERIAL columns get their
3461    /// PG-style implicit sequence `<table>_<column>_seq` ON FIRST
3462    /// ADDRESS rather than at CREATE TABLE time, so pre-7.29 data
3463    /// directories gain addressability without a storage migration.
3464    /// The sequence is born synced to the column's current MAX so
3465    /// `nextval` immediately after creation continues the series.
3466    fn ensure_implicit_sequence(&mut self, seq_name: &str) {
3467        if self.active_catalog().sequences().contains_key(seq_name) {
3468            return;
3469        }
3470        let Some(rest) = seq_name.strip_suffix("_seq") else {
3471            return;
3472        };
3473        let mut found: Option<(String, String, i64)> = None;
3474        for tname in self.active_catalog().table_names() {
3475            let Some(table) = self.active_catalog().get(&tname) else {
3476                continue;
3477            };
3478            for (i, col) in table.schema().columns.iter().enumerate() {
3479                if col.auto_increment && alloc::format!("{tname}_{}", col.name) == rest {
3480                    let next = table.next_auto_value(i).unwrap_or(1);
3481                    found = Some((tname.clone(), col.name.clone(), next - 1));
3482                    break;
3483                }
3484            }
3485            if found.is_some() {
3486                break;
3487            }
3488        }
3489        let Some((tname, cname, last)) = found else {
3490            return;
3491        };
3492        let def = spg_storage::SequenceDef {
3493            name: seq_name.to_string(),
3494            data_type: spg_storage::SequenceDataType::BigInt,
3495            start: 1,
3496            increment: 1,
3497            min_value: 1,
3498            max_value: i64::MAX,
3499            cache: 1,
3500            cycle: false,
3501            owned_by: Some((tname, cname)),
3502            last_value: last.max(0),
3503            is_called: last > 0,
3504        };
3505        let _ = self.active_catalog_mut().create_sequence(def, true);
3506    }
3507
3508    /// v7.17.0 Phase 1.1 — evaluate a single nextval/currval/
3509    /// setval call. `args` are already pre-resolved Expr nodes
3510    /// (literals) — we extract their constant values.
3511    fn eval_sequence_call(&mut self, op: &str, args: &[Expr]) -> Result<Value, EngineError> {
3512        if args.is_empty() {
3513            return Err(EngineError::Unsupported(alloc::format!(
3514                "{op}() takes at least one argument"
3515            )));
3516        }
3517        let seq_name = match &args[0] {
3518            Expr::Literal(spg_sql::ast::Literal::String(s)) => {
3519                // v7.17 dump-compat — pg_dump emits sequence
3520                // names schema-qualified (`'public.posts_id_seq'`).
3521                // SPG is single-schema; strip a leading
3522                // `public.` / `pg_catalog.` so the catalog lookup
3523                // matches the bare-name CREATE SEQUENCE used.
3524                let trimmed = s
3525                    .strip_prefix("public.")
3526                    .or_else(|| s.strip_prefix("pg_catalog."))
3527                    .unwrap_or(s);
3528                trimmed.to_string()
3529            }
3530            // v7.17 dump-compat — pg_dump also emits
3531            // `nextval('public.posts_id_seq'::regclass)`
3532            // where the cast wraps the literal. Peel the cast
3533            // and continue.
3534            Expr::Cast { expr, .. } => {
3535                if let Expr::Literal(spg_sql::ast::Literal::String(s)) = expr.as_ref() {
3536                    let trimmed = s
3537                        .strip_prefix("public.")
3538                        .or_else(|| s.strip_prefix("pg_catalog."))
3539                        .unwrap_or(s);
3540                    trimmed.to_string()
3541                } else {
3542                    return Err(EngineError::Unsupported(alloc::format!(
3543                        "{op}() first argument must be a literal sequence name"
3544                    )));
3545                }
3546            }
3547            other => {
3548                return Err(EngineError::Unsupported(alloc::format!(
3549                    "{op}() first argument must be a literal sequence name, got {other:?}"
3550                )));
3551            }
3552        };
3553        self.ensure_implicit_sequence(&seq_name);
3554        match op {
3555            "nextval" => {
3556                let v = self
3557                    .active_catalog_mut()
3558                    .sequence_next_value(&seq_name)
3559                    .map_err(EngineError::Storage)?;
3560                Ok(Value::BigInt(v))
3561            }
3562            "currval" => {
3563                let v = self
3564                    .active_catalog()
3565                    .sequence_current_value(&seq_name)
3566                    .map_err(EngineError::Storage)?;
3567                Ok(Value::BigInt(v))
3568            }
3569            "setval" => {
3570                if args.len() < 2 || args.len() > 3 {
3571                    return Err(EngineError::Unsupported(alloc::format!(
3572                        "setval() takes 2 or 3 arguments, got {}",
3573                        args.len()
3574                    )));
3575                }
3576                let value = match &args[1] {
3577                    Expr::Literal(spg_sql::ast::Literal::Integer(n)) => *n,
3578                    other => {
3579                        return Err(EngineError::Unsupported(alloc::format!(
3580                            "setval() value argument must be a literal integer, got {other:?}"
3581                        )));
3582                    }
3583                };
3584                let is_called = if args.len() == 3 {
3585                    match &args[2] {
3586                        Expr::Literal(spg_sql::ast::Literal::Bool(b)) => *b,
3587                        other => {
3588                            return Err(EngineError::Unsupported(alloc::format!(
3589                                "setval() is_called argument must be a literal BOOL, got {other:?}"
3590                            )));
3591                        }
3592                    }
3593                } else {
3594                    true
3595                };
3596                let v = self
3597                    .active_catalog_mut()
3598                    .sequence_set_value(&seq_name, value, is_called)
3599                    .map_err(EngineError::Storage)?;
3600                Ok(Value::BigInt(v))
3601            }
3602            other => Err(EngineError::Unsupported(alloc::format!(
3603                "unknown sequence op {other:?}"
3604            ))),
3605        }
3606    }
3607
3608    /// v7.17.0 Phase 1.2 — find every catalog VIEW referenced in
3609    /// the SELECT's FROM / JOIN graph, re-parse each view's body
3610    /// source, and prepend it as a synthetic CTE on the
3611    /// returned SelectStatement. Returns `None` when no view
3612    /// references are found (caller proceeds with the original
3613    /// statement); returns `Some(rewritten)` otherwise (caller
3614    /// re-runs exec_select_cancel on the rewritten form so the
3615    /// regular CTE materialiser handles it).
3616    fn expand_views_in_select(
3617        &self,
3618        stmt: &SelectStatement,
3619    ) -> Result<Option<SelectStatement>, EngineError> {
3620        let cat = self.active_catalog();
3621        let mut referenced: Vec<String> = Vec::new();
3622        if let Some(from) = &stmt.from {
3623            collect_view_refs(&from.primary, cat, &mut referenced);
3624            for j in &from.joins {
3625                collect_view_refs(&j.table, cat, &mut referenced);
3626            }
3627        }
3628        // Don't expand a view name that's already shadowed by a
3629        // CTE on the same SELECT — the CTE wins per PG.
3630        referenced.retain(|n| !stmt.ctes.iter().any(|c| c.name == *n));
3631        if referenced.is_empty() {
3632            return Ok(None);
3633        }
3634        let mut new_ctes: Vec<spg_sql::ast::Cte> = Vec::with_capacity(referenced.len());
3635        for name in &referenced {
3636            let view = cat.views().get(name).ok_or_else(|| {
3637                EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
3638                    "view {name:?} disappeared mid-expansion"
3639                )))
3640            })?;
3641            let parsed = spg_sql::parser::parse_statement(&view.body).map_err(|e| {
3642                EngineError::Unsupported(alloc::format!("view {name:?} body re-parse failed: {e}"))
3643            })?;
3644            let Statement::Select(body) = parsed else {
3645                return Err(EngineError::Unsupported(alloc::format!(
3646                    "view {name:?} body is not a SELECT (catalog corruption)"
3647                )));
3648            };
3649            new_ctes.push(spg_sql::ast::Cte {
3650                name: name.clone(),
3651                body,
3652                recursive: false,
3653                column_overrides: view.columns.clone(),
3654            });
3655        }
3656        let mut out = stmt.clone();
3657        // Prepend so view CTEs are visible to caller-supplied CTEs.
3658        new_ctes.extend(out.ctes);
3659        out.ctes = new_ctes;
3660        Ok(Some(out))
3661    }
3662
3663    /// v7.17.0 Phase 1.2 — `CREATE VIEW` engine path. Stores the
3664    /// Display-rendered body verbatim in the catalog; SELECT-from-
3665    /// view at exec time re-parses + prepends as a synthetic CTE.
3666    fn exec_create_view(
3667        &mut self,
3668        s: spg_sql::ast::CreateViewStatement,
3669    ) -> Result<QueryResult, EngineError> {
3670        // Render the SELECT body to canonical form so the catalog
3671        // round-trips a deterministic source (no whitespace /
3672        // comment surprises in the on-disk snapshot).
3673        let body_repr = alloc::format!("{}", spg_sql::ast::Statement::Select(s.body));
3674        let def = spg_storage::ViewDef {
3675            name: s.name.clone(),
3676            columns: s.columns,
3677            body: body_repr,
3678        };
3679        self.active_catalog_mut()
3680            .create_view(def, s.or_replace, s.if_not_exists)
3681            .map_err(EngineError::Storage)?;
3682        Ok(QueryResult::CommandOk {
3683            affected: 0,
3684            modified_catalog: !self.in_transaction(),
3685        })
3686    }
3687
3688    /// v7.17.0 Phase 1.4 — `CREATE TYPE name AS ENUM (…)` engine
3689    /// path. Registers the enum in the catalog with order-
3690    /// preserving labels. PG semantics: CREATE TYPE errors if the
3691    /// name is taken (no IF NOT EXISTS).
3692    fn exec_create_type(
3693        &mut self,
3694        s: spg_sql::ast::CreateTypeStatement,
3695    ) -> Result<QueryResult, EngineError> {
3696        // Name-collision check against tables / sequences / views /
3697        // materialized views.
3698        let cat = self.active_catalog();
3699        if cat.get(&s.name).is_some() {
3700            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3701                alloc::format!("type {:?} would shadow an existing table", s.name),
3702            )));
3703        }
3704        if cat.sequences().contains_key(&s.name) {
3705            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3706                alloc::format!("type {:?} would shadow an existing sequence", s.name),
3707            )));
3708        }
3709        if cat.views().contains_key(&s.name) {
3710            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3711                alloc::format!("type {:?} would shadow an existing view", s.name),
3712            )));
3713        }
3714        let def = match s.kind {
3715            spg_sql::ast::TypeKind::Enum { labels } => {
3716                if labels.is_empty() {
3717                    return Err(EngineError::Unsupported(
3718                        "CREATE TYPE … AS ENUM requires at least one label".into(),
3719                    ));
3720                }
3721                // Reject duplicate labels per PG.
3722                for i in 0..labels.len() {
3723                    for j in (i + 1)..labels.len() {
3724                        if labels[i] == labels[j] {
3725                            return Err(EngineError::Unsupported(alloc::format!(
3726                                "CREATE TYPE {:?}: duplicate ENUM label {:?}",
3727                                s.name,
3728                                labels[i]
3729                            )));
3730                        }
3731                    }
3732                }
3733                spg_storage::EnumDef {
3734                    name: s.name.clone(),
3735                    labels,
3736                }
3737            }
3738        };
3739        self.active_catalog_mut()
3740            .create_enum_type(def)
3741            .map_err(EngineError::Storage)?;
3742        Ok(QueryResult::CommandOk {
3743            affected: 0,
3744            modified_catalog: !self.in_transaction(),
3745        })
3746    }
3747
3748    /// v7.17.0 Phase 1.5 — `CREATE DOMAIN name AS base [DEFAULT
3749    /// expr] [NOT NULL] [CHECK (expr)]*` engine path. Stores the
3750    /// base type + Display-rendered CHECK / DEFAULT sources so
3751    /// INSERT/UPDATE on bound columns can re-eval the checks.
3752    fn exec_create_domain(
3753        &mut self,
3754        s: spg_sql::ast::CreateDomainStatement,
3755    ) -> Result<QueryResult, EngineError> {
3756        let cat = self.active_catalog();
3757        if cat.domain_types().contains_key(&s.name) {
3758            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3759                alloc::format!("domain {:?} already exists", s.name),
3760            )));
3761        }
3762        if cat.get(&s.name).is_some()
3763            || cat.sequences().contains_key(&s.name)
3764            || cat.views().contains_key(&s.name)
3765            || cat.enum_types().contains_key(&s.name)
3766        {
3767            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3768                alloc::format!("domain {:?} would shadow an existing object", s.name),
3769            )));
3770        }
3771        let base_type = column_type_to_data_type(s.base_type);
3772        let default = s.default.as_ref().map(|e| alloc::format!("{e}"));
3773        let checks = s
3774            .checks
3775            .iter()
3776            .map(|e| alloc::format!("{e}"))
3777            .collect::<Vec<_>>();
3778        let def = spg_storage::DomainDef {
3779            name: s.name.clone(),
3780            base_type,
3781            nullable: !s.not_null,
3782            default,
3783            checks,
3784        };
3785        self.active_catalog_mut()
3786            .create_domain_type(def)
3787            .map_err(EngineError::Storage)?;
3788        Ok(QueryResult::CommandOk {
3789            affected: 0,
3790            modified_catalog: !self.in_transaction(),
3791        })
3792    }
3793
3794    /// v7.17.0 Phase 1.5 — `DROP DOMAIN [IF EXISTS] names`.
3795    fn exec_drop_domain(
3796        &mut self,
3797        names: &[String],
3798        if_exists: bool,
3799    ) -> Result<QueryResult, EngineError> {
3800        let mut removed = 0usize;
3801        for name in names {
3802            let was_present = self.active_catalog_mut().drop_domain_type(name);
3803            if was_present {
3804                removed += 1;
3805            } else if !if_exists {
3806                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3807                    alloc::format!("domain {name:?} does not exist"),
3808                )));
3809            }
3810        }
3811        Ok(QueryResult::CommandOk {
3812            affected: removed,
3813            modified_catalog: removed > 0 && !self.in_transaction(),
3814        })
3815    }
3816
3817    /// v7.17.0 Phase 1.6 — `CREATE SCHEMA [IF NOT EXISTS] name`.
3818    /// Registers the schema in the catalog. Schema-qualified
3819    /// table references continue to strip the prefix at lookup
3820    /// time (prefix routing, not isolation — see project-next-
3821    /// docket for the v7.18+ real-isolation tracking).
3822    fn exec_create_schema(
3823        &mut self,
3824        name: String,
3825        if_not_exists: bool,
3826    ) -> Result<QueryResult, EngineError> {
3827        self.active_catalog_mut()
3828            .create_schema(name, if_not_exists)
3829            .map_err(EngineError::Storage)?;
3830        Ok(QueryResult::CommandOk {
3831            affected: 0,
3832            modified_catalog: !self.in_transaction(),
3833        })
3834    }
3835
3836    /// v7.17.0 Phase 1.6 — `DROP SCHEMA [IF EXISTS] names`.
3837    /// Built-in schemas always reject the drop with a clear
3838    /// error.
3839    fn exec_drop_schema(
3840        &mut self,
3841        names: &[String],
3842        if_exists: bool,
3843    ) -> Result<QueryResult, EngineError> {
3844        let mut removed = 0usize;
3845        for name in names {
3846            let was_present = self
3847                .active_catalog_mut()
3848                .drop_schema(name)
3849                .map_err(EngineError::Storage)?;
3850            if was_present {
3851                removed += 1;
3852            } else if !if_exists {
3853                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3854                    alloc::format!("schema {name:?} does not exist"),
3855                )));
3856            }
3857        }
3858        Ok(QueryResult::CommandOk {
3859            affected: removed,
3860            modified_catalog: removed > 0 && !self.in_transaction(),
3861        })
3862    }
3863
3864    /// v7.17.0 Phase 1.4 — `DROP TYPE [IF EXISTS] names`. Only
3865    /// ENUM types are catalogued today; other types silently
3866    /// no-op even outside IF EXISTS to mirror the prior
3867    /// "everything's text" lax stance.
3868    fn exec_drop_type(
3869        &mut self,
3870        names: &[String],
3871        if_exists: bool,
3872    ) -> Result<QueryResult, EngineError> {
3873        let mut removed = 0usize;
3874        for name in names {
3875            let was_present = self.active_catalog_mut().drop_enum_type(name);
3876            if was_present {
3877                removed += 1;
3878            } else if !if_exists {
3879                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3880                    alloc::format!("type {name:?} does not exist"),
3881                )));
3882            }
3883        }
3884        Ok(QueryResult::CommandOk {
3885            affected: removed,
3886            modified_catalog: removed > 0 && !self.in_transaction(),
3887        })
3888    }
3889
3890    /// v7.17.0 Phase 1.3 — `CREATE MATERIALIZED VIEW` engine path.
3891    /// Materialises the body at CREATE time (unless WITH NO DATA),
3892    /// stores the result as a regular `Table`, and registers the
3893    /// body source in the catalog so REFRESH can re-run it.
3894    fn exec_create_materialized_view(
3895        &mut self,
3896        s: spg_sql::ast::CreateMaterializedViewStatement,
3897    ) -> Result<QueryResult, EngineError> {
3898        // Name-collision check (table / view / sequence / mat-view).
3899        let cat = self.active_catalog();
3900        if cat.materialized_views().contains_key(&s.name) || cat.get(&s.name).is_some() {
3901            if s.if_not_exists {
3902                return Ok(QueryResult::CommandOk {
3903                    affected: 0,
3904                    modified_catalog: false,
3905                });
3906            }
3907            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3908                alloc::format!("materialized view {:?} already exists", s.name),
3909            )));
3910        }
3911        if cat.views().contains_key(&s.name) {
3912            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3913                alloc::format!(
3914                    "materialized view {:?} would shadow an existing view",
3915                    s.name
3916                ),
3917            )));
3918        }
3919        if cat.sequences().contains_key(&s.name) {
3920            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3921                alloc::format!(
3922                    "materialized view {:?} would shadow an existing sequence",
3923                    s.name
3924                ),
3925            )));
3926        }
3927        // Render the body to canonical form for the registry.
3928        let body_repr = alloc::format!("{}", spg_sql::ast::Statement::Select(s.body.clone()));
3929        // Execute the body to learn the columns. With WITH DATA we
3930        // also materialise the rows; with WITH NO DATA we only need
3931        // the schema, so re-use a LIMIT 0 wrap to keep the column
3932        // inference path uniform without paying for the rows.
3933        let result = self.exec_select_cancel(&s.body, CancelToken::none())?;
3934        let (mut cols, rows) = match result {
3935            QueryResult::Rows { columns, rows } => (columns, rows),
3936            other => {
3937                return Err(EngineError::Unsupported(alloc::format!(
3938                    "CREATE MATERIALIZED VIEW body did not return rows: {other:?}"
3939                )));
3940            }
3941        };
3942        // Apply the column-rename list per PG semantics.
3943        if !s.columns.is_empty() {
3944            if s.columns.len() != cols.len() {
3945                return Err(EngineError::Unsupported(alloc::format!(
3946                    "CREATE MATERIALIZED VIEW {:?}: column list has {} names but body returns {}",
3947                    s.name,
3948                    s.columns.len(),
3949                    cols.len()
3950                )));
3951            }
3952            for (c, name) in cols.iter_mut().zip(s.columns.iter()) {
3953                c.name.clone_from(name);
3954            }
3955        }
3956        // Promote any synthetic-Text projections to their actual
3957        // observed types so the backing table accepts the rows.
3958        cols = infer_column_types(&cols, &rows);
3959        let schema = spg_storage::TableSchema::new(s.name.clone(), cols);
3960        let cat = self.active_catalog_mut();
3961        cat.create_table(schema).map_err(EngineError::Storage)?;
3962        if s.with_data {
3963            let table = cat
3964                .get_mut(&s.name)
3965                .expect("just-created materialized-view backing table must exist");
3966            for row in rows {
3967                table.insert(row).map_err(EngineError::Storage)?;
3968            }
3969        }
3970        cat.register_materialized_view(s.name.clone(), body_repr);
3971        Ok(QueryResult::CommandOk {
3972            affected: 0,
3973            modified_catalog: !self.in_transaction(),
3974        })
3975    }
3976
3977    /// v7.17.0 Phase 1.3 — `REFRESH MATERIALIZED VIEW name [WITH
3978    /// [NO] DATA]`. Looks up the source, re-runs it, replaces the
3979    /// backing table's rows.
3980    fn exec_refresh_materialized_view(
3981        &mut self,
3982        name: &str,
3983        with_data: bool,
3984    ) -> Result<QueryResult, EngineError> {
3985        let source = self
3986            .active_catalog()
3987            .materialized_views()
3988            .get(name)
3989            .cloned()
3990            .ok_or_else(|| {
3991                EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
3992                    "materialized view {name:?} does not exist"
3993                )))
3994            })?;
3995        // Wipe the existing rows first (PG truncates the matview
3996        // and rebuilds; we approximate with an empty INSERT loop).
3997        {
3998            let cat = self.active_catalog_mut();
3999            let table = cat.get_mut(name).ok_or_else(|| {
4000                EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
4001                    "materialized view {name:?} backing table missing"
4002                )))
4003            })?;
4004            table.truncate();
4005        }
4006        if !with_data {
4007            return Ok(QueryResult::CommandOk {
4008                affected: 0,
4009                modified_catalog: !self.in_transaction(),
4010            });
4011        }
4012        let parsed = spg_sql::parser::parse_statement(&source).map_err(|e| {
4013            EngineError::Unsupported(alloc::format!(
4014                "materialized view {name:?} body re-parse failed: {e}"
4015            ))
4016        })?;
4017        let Statement::Select(body) = parsed else {
4018            return Err(EngineError::Unsupported(alloc::format!(
4019                "materialized view {name:?} body is not a SELECT (catalog corruption)"
4020            )));
4021        };
4022        let rows = match self.exec_select_cancel(&body, CancelToken::none())? {
4023            QueryResult::Rows { rows, .. } => rows,
4024            other => {
4025                return Err(EngineError::Unsupported(alloc::format!(
4026                    "REFRESH MATERIALIZED VIEW {name:?} body did not return rows: {other:?}"
4027                )));
4028            }
4029        };
4030        let cat = self.active_catalog_mut();
4031        let table = cat.get_mut(name).expect("backing table verified above");
4032        let affected = rows.len();
4033        for row in rows {
4034            table.insert(row).map_err(EngineError::Storage)?;
4035        }
4036        Ok(QueryResult::CommandOk {
4037            affected,
4038            modified_catalog: !self.in_transaction(),
4039        })
4040    }
4041
4042    /// v7.17.0 Phase 1.3 — `DROP MATERIALIZED VIEW [IF EXISTS]
4043    /// names`. Drops the backing table + unregisters the source.
4044    fn exec_drop_materialized_view(
4045        &mut self,
4046        names: &[String],
4047        if_exists: bool,
4048    ) -> Result<QueryResult, EngineError> {
4049        let mut removed = 0usize;
4050        for name in names {
4051            let was_present = self
4052                .active_catalog_mut()
4053                .drop_materialized_view_source(name);
4054            if was_present {
4055                // Drop the backing table too.
4056                self.active_catalog_mut().drop_table(name);
4057                removed += 1;
4058            } else if !if_exists {
4059                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
4060                    alloc::format!("materialized view {name:?} does not exist"),
4061                )));
4062            }
4063        }
4064        Ok(QueryResult::CommandOk {
4065            affected: removed,
4066            modified_catalog: removed > 0 && !self.in_transaction(),
4067        })
4068    }
4069
4070    /// v7.17.0 Phase 1.2 — `DROP VIEW [IF EXISTS] name [, name…]`.
4071    fn exec_drop_view(
4072        &mut self,
4073        names: &[String],
4074        if_exists: bool,
4075    ) -> Result<QueryResult, EngineError> {
4076        let mut removed = 0usize;
4077        for name in names {
4078            let was_present = self.active_catalog_mut().drop_view(name);
4079            if !was_present && !if_exists {
4080                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
4081                    alloc::format!("view {name:?} does not exist"),
4082                )));
4083            }
4084            if was_present {
4085                removed += 1;
4086            }
4087        }
4088        Ok(QueryResult::CommandOk {
4089            affected: removed,
4090            modified_catalog: removed > 0 && !self.in_transaction(),
4091        })
4092    }
4093
4094    /// v7.17.0 — `DROP SEQUENCE [IF EXISTS] name [, name…]`.
4095    fn exec_drop_sequence(
4096        &mut self,
4097        names: &[String],
4098        if_exists: bool,
4099    ) -> Result<QueryResult, EngineError> {
4100        let mut removed = 0usize;
4101        for name in names {
4102            let was_present = self.active_catalog_mut().drop_sequence(name);
4103            if !was_present && !if_exists {
4104                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
4105                    alloc::format!("sequence {name:?} does not exist"),
4106                )));
4107            }
4108            if was_present {
4109                removed += 1;
4110            }
4111        }
4112        Ok(QueryResult::CommandOk {
4113            affected: removed,
4114            modified_catalog: removed > 0 && !self.in_transaction(),
4115        })
4116    }
4117
4118    /// v4.4 `UPDATE <table> SET col = expr [, ...] [WHERE cond]`.
4119    /// Filter pass uses the same WHERE eval as `exec_select`. Per
4120    /// matched row, evaluate each RHS expression against the *old*
4121    /// row, then call `Table::update_row` which rebuilds indices.
4122    /// Indexed columns are correctly reflected because rebuild
4123    /// happens after the cell rewrite.
4124    fn exec_update_cancel(
4125        &mut self,
4126        stmt: &spg_sql::ast::UpdateStatement,
4127        cancel: CancelToken<'_>,
4128    ) -> Result<QueryResult, EngineError> {
4129        // v7.12.5 — snapshot BEFORE/AFTER UPDATE row triggers + the
4130        // session FTS config before the table mut-borrow opens (the
4131        // INSERT path uses the same pattern). Empty vecs are the
4132        // common "no triggers on this table" fast path.
4133        // v7.13.0 — UPDATE triggers carry an optional `UPDATE OF
4134        // cols` filter. The filter is paired with each function so
4135        // the per-row fire loop can skip when no listed column
4136        // actually differs between OLD and NEW.
4137        let before_update_triggers = self.snapshot_update_row_triggers(&stmt.table, "BEFORE");
4138        let after_update_triggers = self.snapshot_update_row_triggers(&stmt.table, "AFTER");
4139        let trigger_session_cfg: Option<String> = self
4140            .session_params
4141            .get("default_text_search_config")
4142            .cloned();
4143        // v5.2.3: if the WHERE is a PK equality and matches a cold-
4144        // tier row, promote it back to the hot tier *before* the
4145        // hot-row walk. The promote pushes the row to the end of
4146        // `table.rows`, where the upcoming SET-evaluation loop will
4147        // pick it up and apply the assignments. Lookups for the key
4148        // never observe a gap because `promote_cold_row` inserts the
4149        // hot row before retiring the cold locator.
4150        if let Some(w) = &stmt.where_ {
4151            let schema_cols = self
4152                .active_catalog()
4153                .get(&stmt.table)
4154                .ok_or_else(|| {
4155                    EngineError::Storage(StorageError::TableNotFound {
4156                        name: stmt.table.clone(),
4157                    })
4158                })?
4159                .schema()
4160                .columns
4161                .clone();
4162            if let Some((col_pos, key)) = try_pk_predicate(w, &schema_cols, stmt.table.as_str())
4163                && let Some(idx_name) = self
4164                    .active_catalog()
4165                    .get(&stmt.table)
4166                    .and_then(|t| t.index_on(col_pos).map(|i| i.name.clone()))
4167            {
4168                // Promote may be a no-op (key is hot-only or absent);
4169                // we don't care about the return value here — the
4170                // subsequent hot walk will either match or not.
4171                let _ = self
4172                    .active_catalog_mut()
4173                    .promote_cold_row(&stmt.table, &idx_name, &key);
4174            }
4175        }
4176
4177        // v7.12.1 — cache session FTS config before the table
4178        // mut-borrow (same reason as exec_delete).
4179        let ts_cfg: Option<String> = self
4180            .session_param("default_text_search_config")
4181            .map(String::from);
4182        // v7.17.0 Phase 2.1 — snapshot the clock pointer before
4183        // we hold the catalog mutably so ON UPDATE runtime
4184        // overrides see the engine wall clock.
4185        let clock_for_on_update = self.clock;
4186        let table = self
4187            .active_catalog_mut()
4188            .get_mut(&stmt.table)
4189            .ok_or_else(|| {
4190                EngineError::Storage(StorageError::TableNotFound {
4191                    name: stmt.table.clone(),
4192                })
4193            })?;
4194        let schema_cols: Vec<ColumnSchema> = table.schema().columns.clone();
4195        // Resolve each SET target to a column position once, validate
4196        // up front so a typo'd column doesn't leave a partial mutation
4197        // behind.
4198        let mut targets: Vec<(usize, &Expr)> = Vec::with_capacity(stmt.assignments.len());
4199        for (col, expr) in &stmt.assignments {
4200            let pos = schema_cols
4201                .iter()
4202                .position(|c| c.name == *col)
4203                .ok_or_else(|| {
4204                    EngineError::Eval(EvalError::ColumnNotFound { name: col.clone() })
4205                })?;
4206            targets.push((pos, expr));
4207        }
4208        // v7.17.0 Phase 2.1 — for every column with an
4209        // `ON UPDATE CURRENT_TIMESTAMP` binding that the caller
4210        // did NOT explicitly set, schedule an automatic override.
4211        // Reuses `eval_runtime_default_free` so the same
4212        // canonical runtime-expression whitelist (now /
4213        // current_timestamp / current_date / …) governs both
4214        // DEFAULT and ON UPDATE.
4215        let mut on_update_overrides: Vec<(usize, String)> = Vec::new();
4216        for (i, col) in schema_cols.iter().enumerate() {
4217            if targets.iter().any(|(p, _)| *p == i) {
4218                continue;
4219            }
4220            if let Some(src) = &col.on_update_runtime {
4221                on_update_overrides.push((i, src.clone()));
4222            }
4223        }
4224        let ctx = EvalContext::new(&schema_cols, Some(stmt.table.as_str()))
4225            .with_default_text_search_config(ts_cfg.as_deref());
4226        // Walk candidate rows, evaluate WHERE then SET
4227        // expressions. We gather (position, new_values) tuples
4228        // first and apply them afterwards so the WHERE/RHS
4229        // evaluation reads the original row state — matches PG
4230        // semantics (UPDATE doesn't see its own writes).
4231        //
4232        // v7.20 P4 — index seek: a single-column equality WHERE
4233        // on an indexed column narrows the walk from
4234        // O(table.rows()) to O(matches). The full WHERE still
4235        // re-evaluates per candidate (the seek may be an
4236        // over-approximation under AND-composites), so semantics
4237        // are unchanged. profile: the bench's `UPDATE … WHERE
4238        // id = $1` on a 5 000-row table was a ~1.3 ms full scan
4239        // per statement; with the seek it's ~2 µs.
4240        let seek_positions: Option<Vec<usize>> = stmt
4241            .where_
4242            .as_ref()
4243            .and_then(|w| try_index_seek_positions(w, &schema_cols, table, stmt.table.as_str()));
4244        let mut planned: Vec<(usize, Vec<Value>)> = Vec::new();
4245        let candidate_positions: Vec<usize> = match &seek_positions {
4246            Some(list) => list.clone(),
4247            None => (0..table.row_count()).collect(),
4248        };
4249        for (loop_n, &i) in candidate_positions.iter().enumerate() {
4250            // v4.5: cooperative cancel checkpoint every 256 rows so
4251            // a runaway UPDATE without WHERE doesn't drag past the
4252            // server's query-timeout watchdog.
4253            if loop_n.is_multiple_of(256) {
4254                cancel.check()?;
4255            }
4256            let Some(row) = table.rows().get(i) else {
4257                continue;
4258            };
4259            if let Some(w) = &stmt.where_ {
4260                let cond = eval::eval_expr(w, row, &ctx)?;
4261                if !matches!(cond, Value::Bool(true)) {
4262                    continue;
4263                }
4264            }
4265            let mut new_vals = row.values.clone();
4266            for (pos, expr) in &targets {
4267                let v = eval::eval_expr(expr, row, &ctx)?;
4268                let coerced = coerce_value(v, schema_cols[*pos].ty, &schema_cols[*pos].name, *pos)?;
4269                check_unsigned_range(&coerced, &schema_cols[*pos], *pos)?;
4270                new_vals[*pos] = coerced;
4271            }
4272            // v7.17.0 Phase 2.1 — apply ON UPDATE overrides for
4273            // any column the SET clause didn't touch.
4274            for (pos, src) in &on_update_overrides {
4275                let v = eval_runtime_default_free(src, schema_cols[*pos].ty, clock_for_on_update)?;
4276                new_vals[*pos] = v;
4277            }
4278            planned.push((i, new_vals));
4279        }
4280        // planned must stay position-sorted: downstream passes
4281        // (FK pairing, trigger walks, the apply loop) iterate it
4282        // assuming ascending row order, which the full-scan path
4283        // guaranteed implicitly.
4284        planned.sort_by_key(|(i, _)| *i);
4285        // v7.6.6 — capture pre-update row values for the FK
4286        // enforcement passes below. `planned` carries new values
4287        // only; pair them with the old row.
4288        let plan_with_old: Vec<(usize, Vec<Value>, Vec<Value>)> = planned
4289            .iter()
4290            .map(|(pos, new_vals)| (*pos, table.rows()[*pos].values.clone(), new_vals.clone()))
4291            .collect();
4292        let self_fks = table.schema().foreign_keys.clone();
4293        // v7.12.5 — `affected` is computed post-BEFORE-trigger
4294        // below (triggers may RETURN NULL to skip individual
4295        // rows). The pre-trigger len shape is no longer accurate.
4296        // Release mutable borrow on `table` for the FK passes.
4297        let _ = table;
4298        // v7.6.6 — Stage 2a: outbound FK check. For every row whose
4299        // local FK columns changed, the new value must exist in the
4300        // parent.
4301        if !self_fks.is_empty() {
4302            let new_rows: Vec<Vec<Value>> = planned
4303                .iter()
4304                .map(|(_pos, new_vals)| new_vals.clone())
4305                .collect();
4306            enforce_fk_inserts(self.active_catalog(), &stmt.table, &self_fks, &new_rows)?;
4307        }
4308        // v7.13.0 — CHECK constraint enforcement on UPDATE
4309        // (mailrs round-5 G3). Predicates evaluated against the
4310        // candidate post-UPDATE row; false rejects the UPDATE.
4311        {
4312            let new_rows: Vec<Vec<Value>> = planned
4313                .iter()
4314                .map(|(_pos, new_vals)| new_vals.clone())
4315                .collect();
4316            enforce_check_constraints(self.active_catalog(), &stmt.table, &new_rows)?;
4317        }
4318        // v7.6.6 — Stage 2b: inbound FK check. For every row that
4319        // changed value in a column that *some other table* uses as
4320        // a FK parent column, react per `on_update` action.
4321        let child_plan =
4322            plan_fk_parent_updates(self.active_catalog(), &stmt.table, &plan_with_old)?;
4323        // Stage 3a — apply each child-side action.
4324        for step in &child_plan {
4325            apply_fk_child_step(self.active_catalog_mut(), step)?;
4326        }
4327        // Stage 3b — apply the original UPDATE.
4328        let table = self
4329            .active_catalog_mut()
4330            .get_mut(&stmt.table)
4331            .ok_or_else(|| {
4332                EngineError::Storage(StorageError::TableNotFound {
4333                    name: stmt.table.clone(),
4334                })
4335            })?;
4336        // v7.12.5 — fire BEFORE/AFTER UPDATE row-level triggers
4337        // around the apply loop. BEFORE sees NEW=candidate +
4338        // OLD=current; may rewrite NEW or RETURN NULL to skip.
4339        // AFTER sees NEW=post-write + OLD=pre-write (both read-
4340        // only).
4341        //
4342        // Filter `planned` through the BEFORE pass first so the
4343        // RETURNING snapshot reflects what actually got written
4344        // (triggers may rewrite cells, including a cancellation).
4345        let mut applied_after_before: Vec<(usize, Row, Row)> = Vec::with_capacity(planned.len());
4346        // v7.12.7 — embedded SQL queue.
4347        let mut deferred_embedded: Vec<triggers::DeferredEmbeddedStmt> = Vec::new();
4348        for (pos, new_vals) in &planned {
4349            let old_row = table.rows()[*pos].clone();
4350            let mut new_row = Row::new(new_vals.clone());
4351            let mut skip = false;
4352            for (fd, filter) in &before_update_triggers {
4353                // v7.13.0 — `UPDATE OF cols` filter (mailrs round-5
4354                // G7). Skip this trigger when the filter is set and
4355                // no listed column actually differs between OLD and
4356                // NEW for this row.
4357                if !filter.is_empty()
4358                    && !any_column_changed(filter, &schema_cols, &old_row, &new_row)
4359                {
4360                    continue;
4361                }
4362                let (outcome, deferred) = triggers::fire_row_trigger(
4363                    fd,
4364                    Some(new_row.clone()),
4365                    Some(&old_row),
4366                    &stmt.table,
4367                    &schema_cols,
4368                    &[],
4369                    trigger_session_cfg.as_deref(),
4370                    false,
4371                )
4372                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
4373                deferred_embedded.extend(deferred);
4374                match outcome {
4375                    triggers::TriggerOutcome::Row(r) => new_row = r,
4376                    triggers::TriggerOutcome::Skip => {
4377                        skip = true;
4378                        break;
4379                    }
4380                }
4381            }
4382            if !skip {
4383                applied_after_before.push((*pos, new_row, old_row));
4384            }
4385        }
4386        // v7.9.4 — snapshot post-update values for RETURNING (post-
4387        // BEFORE-trigger because triggers can rewrite cells).
4388        let updated_for_returning: Vec<Vec<Value>> = if stmt.returning.is_some() {
4389            applied_after_before
4390                .iter()
4391                .map(|(_pos, new_row, _old)| new_row.values.clone())
4392                .collect()
4393        } else {
4394            Vec::new()
4395        };
4396        let affected = applied_after_before.len();
4397        // Apply, then fire AFTER triggers per row. AFTER runs read-
4398        // only against the freshly-written row; v7.12.4-shape
4399        // assignment errors with a clear message.
4400        for (pos, new_row, old_row) in applied_after_before {
4401            table.update_row(pos, new_row.values.clone())?;
4402            for (fd, filter) in &after_update_triggers {
4403                if !filter.is_empty()
4404                    && !any_column_changed(filter, &schema_cols, &old_row, &new_row)
4405                {
4406                    continue;
4407                }
4408                let (_outcome, deferred) = triggers::fire_row_trigger(
4409                    fd,
4410                    Some(new_row.clone()),
4411                    Some(&old_row),
4412                    &stmt.table,
4413                    &schema_cols,
4414                    &[],
4415                    trigger_session_cfg.as_deref(),
4416                    true,
4417                )
4418                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
4419                deferred_embedded.extend(deferred);
4420            }
4421        }
4422        let _ = table;
4423        // v7.12.7 — drain trigger-emitted embedded SQL for this UPDATE.
4424        self.execute_deferred_trigger_stmts(deferred_embedded, cancel)?;
4425        // v6.2.1 — auto-analyze modified-row tracking for UPDATE.
4426        if !self.in_transaction() && affected > 0 {
4427            self.statistics
4428                .record_modifications(&stmt.table, affected as u64);
4429        }
4430        // v7.9.4 — RETURNING projection.
4431        if let Some(items) = &stmt.returning {
4432            return self.build_returning_rows(&stmt.table, items, updated_for_returning);
4433        }
4434        Ok(QueryResult::CommandOk {
4435            affected,
4436            modified_catalog: !self.in_transaction(),
4437        })
4438    }
4439
4440    /// v4.4 `DELETE FROM <table> [WHERE cond]`. Collects matching
4441    /// positions then delegates to `Table::delete_rows` (single index
4442    /// rebuild for the batch).
4443    /// v7.17.0 Phase 3.P0-42 — SQL:2003 / PG 15+ `MERGE` execution.
4444    ///
4445    /// Semantics:
4446    ///   * Resolve `target` and `source` tables (catalog reads).
4447    ///   * Build a combined `(target_alias.col, source_alias.col)`
4448    ///     schema so the ON / WHEN AND / SET / VALUES expressions
4449    ///     resolve through the standard qualifier-aware resolver.
4450    ///   * Pass 1: walk every source row × every target hot row,
4451    ///     evaluate ON, then pick the first WHEN clause that fits
4452    ///     (`Matched` if any target row matched, `NotMatched`
4453    ///     otherwise; AND-condition must hold). Collect the action
4454    ///     plan as `(deletes, updates, inserts)` so the apply pass
4455    ///     reads the original target row state.
4456    ///   * Pass 2: apply the plan against the target's mutable row
4457    ///     vector. Deletes execute by index in descending order so
4458    ///     earlier indices remain stable; updates next; inserts
4459    ///     last (matching PG's "INSERT branch sees the post-delete
4460    ///     state" behaviour for the common upsert shape).
4461    ///
4462    /// v7.17 simplifications (documented limitations):
4463    ///   * No triggers / WAL plumbing (MVP); MERGE rows don't fire
4464    ///     INSERT / UPDATE / DELETE row triggers in v7.17.
4465    ///   * No cardinality check (PG-canonical: "MERGE command
4466    ///     cannot affect row a second time" — SPG silently applies
4467    ///     the last action for a target row covered twice).
4468    ///   * Source must be a catalog-resolvable table (no subquery
4469    ///     source); RETURNING / BY SOURCE / BY TARGET unsupported.
4470    fn exec_merge_cancel(
4471        &mut self,
4472        stmt: &spg_sql::ast::MergeStatement,
4473        cancel: CancelToken<'_>,
4474    ) -> Result<QueryResult, EngineError> {
4475        let target_alias = stmt
4476            .target_alias
4477            .clone()
4478            .unwrap_or_else(|| stmt.target.clone());
4479        let source_alias = stmt
4480            .source_alias
4481            .clone()
4482            .unwrap_or_else(|| stmt.source.clone());
4483        let (target_cols, target_rows_snapshot) = {
4484            let t = self.active_catalog().get(&stmt.target).ok_or_else(|| {
4485                EngineError::Storage(StorageError::TableNotFound {
4486                    name: stmt.target.clone(),
4487                })
4488            })?;
4489            (
4490                t.schema().columns.clone(),
4491                t.rows().iter().cloned().collect::<Vec<Row>>(),
4492            )
4493        };
4494        let (source_cols, source_rows) = {
4495            let s = self.active_catalog().get(&stmt.source).ok_or_else(|| {
4496                EngineError::Storage(StorageError::TableNotFound {
4497                    name: stmt.source.clone(),
4498                })
4499            })?;
4500            (
4501                s.schema().columns.clone(),
4502                s.rows().iter().cloned().collect::<Vec<Row>>(),
4503            )
4504        };
4505        // Composite schema: target_alias.col ... source_alias.col ...
4506        let mut combined_schema: Vec<ColumnSchema> = Vec::new();
4507        for col in &target_cols {
4508            combined_schema.push(ColumnSchema::new(
4509                alloc::format!("{target_alias}.{}", col.name),
4510                col.ty,
4511                col.nullable,
4512            ));
4513        }
4514        for col in &source_cols {
4515            combined_schema.push(ColumnSchema::new(
4516                alloc::format!("{source_alias}.{}", col.name),
4517                col.ty,
4518                col.nullable,
4519            ));
4520        }
4521        let combined_ctx = EvalContext::new(&combined_schema, None);
4522        // Source-only context for WHEN NOT MATCHED actions (no
4523        // matched target row exists — the source-side qualified
4524        // columns must still resolve).
4525        let mut source_only_schema: Vec<ColumnSchema> = Vec::new();
4526        for col in &target_cols {
4527            source_only_schema.push(ColumnSchema::new(
4528                alloc::format!("{target_alias}.{}", col.name),
4529                col.ty,
4530                col.nullable,
4531            ));
4532        }
4533        for col in &source_cols {
4534            source_only_schema.push(ColumnSchema::new(
4535                alloc::format!("{source_alias}.{}", col.name),
4536                col.ty,
4537                col.nullable,
4538            ));
4539        }
4540        let source_only_ctx = EvalContext::new(&source_only_schema, None);
4541        let target_arity = target_cols.len();
4542        let source_arity = source_cols.len();
4543
4544        // Resolve INSERT column positions once (validate names).
4545        // For each clause that's an INSERT, map column names → target positions.
4546        let mut delete_indices: Vec<usize> = Vec::new();
4547        let mut updates: Vec<(usize, Vec<Value>)> = Vec::new();
4548        let mut inserts: Vec<Vec<Value>> = Vec::new();
4549        let mut affected: usize = 0;
4550
4551        for (src_idx, src_row) in source_rows.iter().enumerate() {
4552            if src_idx.is_multiple_of(256) {
4553                cancel.check()?;
4554            }
4555            // Find every matched target index (per the ON predicate).
4556            let mut matched_targets: Vec<usize> = Vec::new();
4557            for (t_idx, t_row) in target_rows_snapshot.iter().enumerate() {
4558                let mut combined_vals = t_row.values.clone();
4559                combined_vals.extend(src_row.values.iter().cloned());
4560                let combined_row = Row::new(combined_vals);
4561                let cond = eval::eval_expr(&stmt.on, &combined_row, &combined_ctx)?;
4562                if matches!(cond, Value::Bool(true)) {
4563                    matched_targets.push(t_idx);
4564                }
4565            }
4566            let is_matched = !matched_targets.is_empty();
4567            // Pick the first WHEN clause whose kind agrees with
4568            // `is_matched` and whose AND condition (if any) holds.
4569            // AND condition for MATCHED: evaluated against the
4570            // first matched target row × source. For NOT MATCHED:
4571            // evaluated with target side NULL-padded.
4572            let fired_clause = stmt.clauses.iter().find(|c| {
4573                let kind_ok = match c.matched {
4574                    spg_sql::ast::MergeMatched::Matched => is_matched,
4575                    spg_sql::ast::MergeMatched::NotMatched => !is_matched,
4576                };
4577                if !kind_ok {
4578                    return false;
4579                }
4580                let Some(cond_expr) = &c.condition else {
4581                    return true;
4582                };
4583                let row = if is_matched {
4584                    let t = &target_rows_snapshot[matched_targets[0]];
4585                    let mut vals = t.values.clone();
4586                    vals.extend(src_row.values.iter().cloned());
4587                    Row::new(vals)
4588                } else {
4589                    let mut vals: Vec<Value> = (0..target_arity).map(|_| Value::Null).collect();
4590                    vals.extend(src_row.values.iter().cloned());
4591                    Row::new(vals)
4592                };
4593                let ctx_ref = if is_matched {
4594                    &combined_ctx
4595                } else {
4596                    &source_only_ctx
4597                };
4598                matches!(
4599                    eval::eval_expr(cond_expr, &row, ctx_ref),
4600                    Ok(Value::Bool(true))
4601                )
4602            });
4603            let Some(clause) = fired_clause else { continue };
4604            match &clause.action {
4605                spg_sql::ast::MergeAction::DoNothing => {}
4606                spg_sql::ast::MergeAction::Delete => {
4607                    for &t_idx in &matched_targets {
4608                        if !delete_indices.contains(&t_idx) {
4609                            delete_indices.push(t_idx);
4610                            affected += 1;
4611                        }
4612                    }
4613                }
4614                spg_sql::ast::MergeAction::Update { assignments } => {
4615                    // Pre-resolve SET targets to target column positions.
4616                    let mut planned_sets: Vec<(usize, &Expr)> =
4617                        Vec::with_capacity(assignments.len());
4618                    for (col, expr) in assignments {
4619                        let pos =
4620                            target_cols
4621                                .iter()
4622                                .position(|c| c.name == *col)
4623                                .ok_or_else(|| {
4624                                    EngineError::Eval(EvalError::ColumnNotFound {
4625                                        name: col.clone(),
4626                                    })
4627                                })?;
4628                        planned_sets.push((pos, expr));
4629                    }
4630                    for &t_idx in &matched_targets {
4631                        let t_row = &target_rows_snapshot[t_idx];
4632                        let mut new_values = t_row.values.clone();
4633                        let mut combined_vals = t_row.values.clone();
4634                        combined_vals.extend(src_row.values.iter().cloned());
4635                        let combined_row = Row::new(combined_vals);
4636                        for (pos, expr) in &planned_sets {
4637                            let raw = eval::eval_expr(expr, &combined_row, &combined_ctx)?;
4638                            let coerced = coerce_value(
4639                                raw,
4640                                target_cols[*pos].ty,
4641                                &target_cols[*pos].name,
4642                                *pos,
4643                            )?;
4644                            new_values[*pos] = coerced;
4645                        }
4646                        updates.push((t_idx, new_values));
4647                        affected += 1;
4648                    }
4649                }
4650                spg_sql::ast::MergeAction::Insert { columns, values } => {
4651                    // For INSERT NOT MATCHED, target side is NULL-padded.
4652                    let mut vals: Vec<Value> = (0..target_arity).map(|_| Value::Null).collect();
4653                    vals.extend(src_row.values.iter().cloned());
4654                    let synth_row = Row::new(vals);
4655                    let mut new_row_values: Vec<Value> =
4656                        (0..target_arity).map(|_| Value::Null).collect();
4657                    for (col, expr) in columns.iter().zip(values.iter()) {
4658                        let pos =
4659                            target_cols
4660                                .iter()
4661                                .position(|c| c.name == *col)
4662                                .ok_or_else(|| {
4663                                    EngineError::Eval(EvalError::ColumnNotFound {
4664                                        name: col.clone(),
4665                                    })
4666                                })?;
4667                        let raw = eval::eval_expr(expr, &synth_row, &source_only_ctx)?;
4668                        let coerced =
4669                            coerce_value(raw, target_cols[pos].ty, &target_cols[pos].name, pos)?;
4670                        new_row_values[pos] = coerced;
4671                    }
4672                    inserts.push(new_row_values);
4673                    affected += 1;
4674                }
4675            }
4676        }
4677        let _ = source_arity; // captured for symmetry; cancellation cost negligible.
4678
4679        // Apply the plan to the target table.
4680        let table = self
4681            .active_catalog_mut()
4682            .get_mut(&stmt.target)
4683            .ok_or_else(|| {
4684                EngineError::Storage(StorageError::TableNotFound {
4685                    name: stmt.target.clone(),
4686                })
4687            })?;
4688        // Apply updates first (in-place), then deletes (one batch),
4689        // then inserts. The storage API uses `update_row(pos,
4690        // new_values)`, `delete_rows(&[positions])`, and `insert(row)`.
4691        for (idx, new_vals) in &updates {
4692            table
4693                .update_row(*idx, new_vals.clone())
4694                .map_err(EngineError::Storage)?;
4695        }
4696        if !delete_indices.is_empty() {
4697            table.delete_rows(&delete_indices);
4698        }
4699        for vals in inserts {
4700            table.insert(Row::new(vals)).map_err(EngineError::Storage)?;
4701        }
4702        Ok(QueryResult::CommandOk {
4703            affected,
4704            modified_catalog: affected > 0,
4705        })
4706    }
4707
4708    fn exec_delete_cancel(
4709        &mut self,
4710        stmt: &spg_sql::ast::DeleteStatement,
4711        cancel: CancelToken<'_>,
4712    ) -> Result<QueryResult, EngineError> {
4713        // v7.12.5 — snapshot BEFORE/AFTER DELETE row triggers + the
4714        // session FTS config before the mut borrow (same shape as
4715        // INSERT / UPDATE).
4716        let before_delete_triggers = self.snapshot_row_triggers(&stmt.table, "DELETE", "BEFORE");
4717        let after_delete_triggers = self.snapshot_row_triggers(&stmt.table, "DELETE", "AFTER");
4718        let trigger_session_cfg: Option<String> = self
4719            .session_params
4720            .get("default_text_search_config")
4721            .cloned();
4722        // v5.2.3: PK-targeted DELETE → first retire any cold-tier
4723        // locator for the key. The cold row body stays in the
4724        // segment (becoming shadowed garbage that a future
4725        // compaction pass reclaims) but the index no longer
4726        // resolves it. The shadow count contributes to the
4727        // affected total; the subsequent hot walk handles any hot
4728        // rows for the same key.
4729        let mut cold_shadow_count: usize = 0;
4730        if let Some(w) = &stmt.where_ {
4731            let schema_cols = self
4732                .active_catalog()
4733                .get(&stmt.table)
4734                .ok_or_else(|| {
4735                    EngineError::Storage(StorageError::TableNotFound {
4736                        name: stmt.table.clone(),
4737                    })
4738                })?
4739                .schema()
4740                .columns
4741                .clone();
4742            if let Some((col_pos, key)) = try_pk_predicate(w, &schema_cols, stmt.table.as_str())
4743                && let Some(idx_name) = self
4744                    .active_catalog()
4745                    .get(&stmt.table)
4746                    .and_then(|t| t.index_on(col_pos).map(|i| i.name.clone()))
4747            {
4748                cold_shadow_count = self
4749                    .active_catalog_mut()
4750                    .shadow_cold_row(&stmt.table, &idx_name, &key)
4751                    .unwrap_or(0);
4752            }
4753        }
4754
4755        // v7.12.1 — cache the session FTS config as an owned
4756        // String before the mutable table borrow below; the
4757        // ctx-builder then references it via `as_deref` so the
4758        // immutable read of `session_params` doesn't conflict
4759        // with the mut borrow chain.
4760        let ts_cfg: Option<String> = self
4761            .session_param("default_text_search_config")
4762            .map(String::from);
4763        let table = self
4764            .active_catalog_mut()
4765            .get_mut(&stmt.table)
4766            .ok_or_else(|| {
4767                EngineError::Storage(StorageError::TableNotFound {
4768                    name: stmt.table.clone(),
4769                })
4770            })?;
4771        let schema_cols: Vec<ColumnSchema> = table.schema().columns.clone();
4772        let ctx = EvalContext::new(&schema_cols, Some(stmt.table.as_str()))
4773            .with_default_text_search_config(ts_cfg.as_deref());
4774        let mut positions: Vec<usize> = Vec::new();
4775        // v7.6.3 — collect every to-delete row's full Value tuple
4776        // alongside its position, so the FK enforcement pass can
4777        // run after the mut borrow drops.
4778        let mut to_delete_rows: Vec<Vec<Value>> = Vec::new();
4779        // v7.20 P4 — index seek (same shape as exec_update_cancel):
4780        // an equality WHERE on an indexed column narrows the walk
4781        // to the matching hot positions; the full WHERE still
4782        // re-evaluates per candidate. Downstream passes assume
4783        // ascending position order, so the seek result is sorted.
4784        let seek_positions: Option<Vec<usize>> = stmt
4785            .where_
4786            .as_ref()
4787            .and_then(|w| try_index_seek_positions(w, &schema_cols, table, stmt.table.as_str()));
4788        let candidate_positions: Vec<usize> = match seek_positions {
4789            Some(mut list) => {
4790                list.sort_unstable();
4791                list
4792            }
4793            None => (0..table.row_count()).collect(),
4794        };
4795        for (loop_n, &i) in candidate_positions.iter().enumerate() {
4796            if loop_n.is_multiple_of(256) {
4797                cancel.check()?;
4798            }
4799            let Some(row) = table.rows().get(i) else {
4800                continue;
4801            };
4802            let keep = if let Some(w) = &stmt.where_ {
4803                let cond = eval::eval_expr(w, row, &ctx)?;
4804                !matches!(cond, Value::Bool(true))
4805            } else {
4806                false
4807            };
4808            if !keep {
4809                positions.push(i);
4810                to_delete_rows.push(row.values.clone());
4811            }
4812        }
4813        // v7.6.3 / v7.6.4 — Stage 2: FK enforcement on the immutable
4814        // catalog. Release the mut borrow and run reverse-scan
4815        // against every child table whose FK targets this table.
4816        // RESTRICT / NoAction raise an error; CASCADE returns a
4817        // cascade plan that stage 3 applies after the primary delete.
4818        // SET NULL / SET DEFAULT remain Unsupported until v7.6.5.
4819        let _ = table;
4820        // v7.12.5 — BEFORE DELETE row-level triggers. Each fires
4821        // with NEW=None / OLD=pre-delete row; RETURN OLD (or NEW)
4822        // = proceed, RETURN NULL = skip the row entirely. The
4823        // filter must run BEFORE the FK cascade plan so cascaded
4824        // child rows track the trigger's skip-decision on the
4825        // parent.
4826        // v7.12.7 — embedded SQL queue.
4827        let mut deferred_embedded: Vec<triggers::DeferredEmbeddedStmt> = Vec::new();
4828        if !before_delete_triggers.is_empty() {
4829            let mut filtered_positions: Vec<usize> = Vec::with_capacity(positions.len());
4830            let mut filtered_old_rows: Vec<Vec<Value>> = Vec::with_capacity(to_delete_rows.len());
4831            for (pos, old_vals) in positions.iter().zip(to_delete_rows.iter()) {
4832                let old_row = Row::new(old_vals.clone());
4833                let mut cancel_this = false;
4834                for fd in &before_delete_triggers {
4835                    let (outcome, deferred) = triggers::fire_row_trigger(
4836                        fd,
4837                        None,
4838                        Some(&old_row),
4839                        &stmt.table,
4840                        &schema_cols,
4841                        &[],
4842                        trigger_session_cfg.as_deref(),
4843                        false,
4844                    )
4845                    .map_err(|e| {
4846                        EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}")))
4847                    })?;
4848                    deferred_embedded.extend(deferred);
4849                    if matches!(outcome, triggers::TriggerOutcome::Skip) {
4850                        cancel_this = true;
4851                        break;
4852                    }
4853                }
4854                if !cancel_this {
4855                    filtered_positions.push(*pos);
4856                    filtered_old_rows.push(old_vals.clone());
4857                }
4858            }
4859            positions = filtered_positions;
4860            to_delete_rows = filtered_old_rows;
4861        }
4862        let cascade_plan = plan_fk_parent_deletions(
4863            self.active_catalog(),
4864            &stmt.table,
4865            &positions,
4866            &to_delete_rows,
4867        )?;
4868        // Stage 3a — apply each FK child step (SET NULL / SET
4869        // DEFAULT / CASCADE delete) before deleting the parent.
4870        // The plan is already ordered: nulls/defaults first, then
4871        // cascade deletes (so a row mutated and later deleted
4872        // surfaces as deleted — though v7.6.5 doesn't produce
4873        // that overlap today).
4874        for step in &cascade_plan {
4875            apply_fk_child_step(self.active_catalog_mut(), step)?;
4876        }
4877        // Stage 3b — actually delete the original target rows.
4878        let table = self
4879            .active_catalog_mut()
4880            .get_mut(&stmt.table)
4881            .ok_or_else(|| {
4882                EngineError::Storage(StorageError::TableNotFound {
4883                    name: stmt.table.clone(),
4884                })
4885            })?;
4886        let affected = table.delete_rows(&positions) + cold_shadow_count;
4887        let _ = table;
4888        // v7.12.5 — AFTER DELETE row-level triggers fire post-write
4889        // with NEW=None / OLD=pre-delete row (each from the
4890        // already-snapshotted to_delete_rows). Return value is
4891        // ignored (matches PG AFTER semantics).
4892        if !after_delete_triggers.is_empty() {
4893            for old_vals in &to_delete_rows {
4894                let old_row = Row::new(old_vals.clone());
4895                for fd in &after_delete_triggers {
4896                    let (_outcome, deferred) = triggers::fire_row_trigger(
4897                        fd,
4898                        None,
4899                        Some(&old_row),
4900                        &stmt.table,
4901                        &schema_cols,
4902                        &[],
4903                        trigger_session_cfg.as_deref(),
4904                        true,
4905                    )
4906                    .map_err(|e| {
4907                        EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}")))
4908                    })?;
4909                    deferred_embedded.extend(deferred);
4910                }
4911            }
4912        }
4913        // v7.12.7 — drain trigger-emitted embedded SQL for this DELETE.
4914        self.execute_deferred_trigger_stmts(deferred_embedded, cancel)?;
4915        // v6.2.1 — auto-analyze modified-row tracking for DELETE.
4916        if !self.in_transaction() && affected > 0 {
4917            self.statistics
4918                .record_modifications(&stmt.table, affected as u64);
4919        }
4920        // v7.9.4 — RETURNING projection over the soon-to-be-gone
4921        // rows. `to_delete_rows` was snapshotted in stage 1 before
4922        // mutation, so the projection sees the pre-delete state
4923        // (matches PG semantics: DELETE RETURNING returns the row
4924        // as it was just before removal).
4925        if let Some(items) = &stmt.returning {
4926            return self.build_returning_rows(&stmt.table, items, to_delete_rows);
4927        }
4928        Ok(QueryResult::CommandOk {
4929            affected,
4930            modified_catalog: !self.in_transaction(),
4931        })
4932    }
4933
4934    /// `SHOW TABLES` — one row per table in the active catalog.
4935    /// Column name is `name` so result-set consumers can downstream
4936    /// `SELECT name FROM ...` style logic if needed.
4937    /// v4.26: `EXPLAIN [ANALYZE] <select>`. Returns a single-column
4938    /// `QUERY PLAN` text table — first line names the top operator
4939    /// (Scan / Aggregate / Window / etc.), indented children list
4940    /// FROM joins, WHERE filters, ORDER BY / LIMIT, projection
4941    /// shape, and any active index hits. `ANALYZE` execs the inner
4942    /// SELECT and appends actual-row + elapsed-micros annotations.
4943    #[allow(clippy::format_push_string)]
4944    fn exec_explain(
4945        &self,
4946        e: &spg_sql::ast::ExplainStatement,
4947        cancel: CancelToken<'_>,
4948    ) -> Result<QueryResult, EngineError> {
4949        let mut lines = Vec::<String>::new();
4950        explain_select(&e.inner, self, 0, &mut lines);
4951        if e.suggest {
4952            // v6.8.3 — index advisor. Walks the SELECT's FROM
4953            // tables + WHERE column refs; for each (table, column)
4954            // pair that lacks an index, append a SUGGEST line with
4955            // a copy-pastable `CREATE INDEX` statement. This is a
4956            // pure-syntax heuristic — no cardinality estimation —
4957            // matching the v6.8.3 design intent of "tell the
4958            // operator where indexes are missing", not "give the
4959            // mathematically optimal index set".
4960            let suggestions = build_index_suggestions(&e.inner, self);
4961            for s in suggestions {
4962                lines.push(s);
4963            }
4964        } else if e.analyze {
4965            // v6.2.4 — EXPLAIN ANALYZE annotates each operator line
4966            // with `(rows=N)` where the row count is computable
4967            // without re-executing the full query:
4968            //   - Top-level operator (first non-indented line):
4969            //     rows = final result.len()
4970            //   - "From: <table> [full scan]" lines: rows =
4971            //     table.rows().len() (catalog read; no execution)
4972            //   - "From: <table> [index seek]": indeterminate —
4973            //     the index step would need re-execution; v6.2.5
4974            //     adds per-operator wall-clock + hot/cold rows
4975            //     instrumentation that makes this concrete.
4976            //   - Everything else: marked `(—)` so the surface
4977            //     stays well-defined without silently dropping
4978            //     stats. v6.2.5 fills in via inline executor
4979            //     instrumentation.
4980            // Total elapsed lands on a trailing `Total: …` line.
4981            let started = self.clock.map(|f| f());
4982            let exec = self.exec_select_cancel(&e.inner, cancel)?;
4983            let elapsed_micros = match (self.clock, started) {
4984                (Some(f), Some(s)) => Some(f().saturating_sub(s)),
4985                _ => None,
4986            };
4987            let row_count = if let QueryResult::Rows { rows, .. } = &exec {
4988                rows.len()
4989            } else {
4990                0
4991            };
4992            annotate_explain_lines(&mut lines, row_count, self);
4993            let mut total = alloc::format!("Total: rows={row_count}");
4994            if let Some(us) = elapsed_micros {
4995                total.push_str(&alloc::format!(" elapsed={us}us"));
4996            }
4997            lines.push(total);
4998        }
4999        let columns = alloc::vec![ColumnSchema::new("QUERY PLAN", DataType::Text, false)];
5000        let rows: Vec<Row> = lines
5001            .into_iter()
5002            .map(|l| Row::new(alloc::vec![Value::Text(l)]))
5003            .collect();
5004        Ok(QueryResult::Rows { columns, rows })
5005    }
5006
5007    fn exec_show_tables(&self) -> QueryResult {
5008        let columns = alloc::vec![ColumnSchema::new("name", DataType::Text, false)];
5009        let rows: Vec<Row> = self
5010            .active_catalog()
5011            .table_names()
5012            .into_iter()
5013            .map(|n| Row::new(alloc::vec![Value::Text(n)]))
5014            .collect();
5015        QueryResult::Rows { columns, rows }
5016    }
5017
5018    /// v7.17.0 Phase 3.P0-59 — `SHOW CREATE TABLE <t>`. Synthesise
5019    /// a minimal MySQL-flavoured CREATE TABLE DDL from the
5020    /// catalog's TableSchema so mysqldump round-trips load against
5021    /// SPG without splitting init scripts.
5022    fn exec_show_create_table(&self, name: &str) -> Result<QueryResult, EngineError> {
5023        let t = self.active_catalog().get(name).ok_or_else(|| {
5024            EngineError::Storage(StorageError::TableNotFound { name: name.into() })
5025        })?;
5026        let cols: Vec<String> = t
5027            .schema()
5028            .columns
5029            .iter()
5030            .map(|c| {
5031                let ty = render_data_type(c.ty);
5032                let nullable = if c.nullable { "" } else { " NOT NULL" };
5033                alloc::format!("  `{}` {}{}", c.name, ty, nullable)
5034            })
5035            .collect();
5036        let mut body = cols.join(",\n");
5037        // Append UNIQUE / PRIMARY KEY clauses.
5038        for uc in &t.schema().uniqueness_constraints {
5039            let col_names: Vec<String> = uc
5040                .columns
5041                .iter()
5042                .map(|&p| {
5043                    t.schema().columns.get(p).map_or_else(
5044                        || alloc::format!("col{p}"),
5045                        |c| alloc::format!("`{}`", c.name),
5046                    )
5047                })
5048                .collect();
5049            let kw = if uc.is_primary_key {
5050                "PRIMARY KEY"
5051            } else {
5052                "UNIQUE KEY"
5053            };
5054            body.push_str(",\n  ");
5055            body.push_str(&alloc::format!("{kw} ({})", col_names.join(", ")));
5056        }
5057        // Foreign keys.
5058        for fk in &t.schema().foreign_keys {
5059            let local: Vec<String> = fk
5060                .local_columns
5061                .iter()
5062                .map(|&p| {
5063                    t.schema().columns.get(p).map_or_else(
5064                        || alloc::format!("col{p}"),
5065                        |c| alloc::format!("`{}`", c.name),
5066                    )
5067                })
5068                .collect();
5069            let parent_cols: Vec<String> =
5070                if let Some(parent) = self.active_catalog().get(&fk.parent_table) {
5071                    fk.parent_columns
5072                        .iter()
5073                        .map(|&p| {
5074                            parent.schema().columns.get(p).map_or_else(
5075                                || alloc::format!("col{p}"),
5076                                |c| alloc::format!("`{}`", c.name),
5077                            )
5078                        })
5079                        .collect()
5080                } else {
5081                    fk.parent_columns
5082                        .iter()
5083                        .map(|p| alloc::format!("col{p}"))
5084                        .collect()
5085                };
5086            body.push_str(",\n  ");
5087            body.push_str(&alloc::format!(
5088                "FOREIGN KEY ({}) REFERENCES `{}` ({})",
5089                local.join(", "),
5090                fk.parent_table,
5091                parent_cols.join(", ")
5092            ));
5093        }
5094        let ddl = alloc::format!(
5095            "CREATE TABLE `{}` (\n{}\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
5096            name,
5097            body
5098        );
5099        let columns = alloc::vec![
5100            ColumnSchema::new("Table", DataType::Text, false),
5101            ColumnSchema::new("Create Table", DataType::Text, false),
5102        ];
5103        let rows = alloc::vec![Row::new(alloc::vec![
5104            Value::Text(name.into()),
5105            Value::Text(ddl),
5106        ])];
5107        Ok(QueryResult::Rows { columns, rows })
5108    }
5109
5110    /// v7.17.0 Phase 3.P0-60 — `SHOW INDEXES FROM <t>`. MySQL
5111    /// surface returns one row per (index × column) with 14
5112    /// columns; v7.17 ships the columns admin probes actually
5113    /// filter on: Table, Non_unique, Key_name, Seq_in_index,
5114    /// Column_name, Null, Index_type.
5115    fn exec_show_indexes(&self, name: &str) -> Result<QueryResult, EngineError> {
5116        let t = self.active_catalog().get(name).ok_or_else(|| {
5117            EngineError::Storage(StorageError::TableNotFound { name: name.into() })
5118        })?;
5119        let columns = alloc::vec![
5120            ColumnSchema::new("Table", DataType::Text, false),
5121            ColumnSchema::new("Non_unique", DataType::Int, false),
5122            ColumnSchema::new("Key_name", DataType::Text, false),
5123            ColumnSchema::new("Seq_in_index", DataType::Int, false),
5124            ColumnSchema::new("Column_name", DataType::Text, false),
5125            ColumnSchema::new("Null", DataType::Text, false),
5126            ColumnSchema::new("Index_type", DataType::Text, false),
5127        ];
5128        let mut rows: Vec<Row> = Vec::new();
5129        for idx in t.indices() {
5130            let col = t
5131                .schema()
5132                .columns
5133                .get(idx.column_position)
5134                .map_or("?".into(), |c| c.name.clone());
5135            let nullable = t
5136                .schema()
5137                .columns
5138                .get(idx.column_position)
5139                .map_or(true, |c| c.nullable);
5140            rows.push(Row::new(alloc::vec![
5141                Value::Text(name.into()),
5142                Value::Int(i32::from(!idx.is_unique)),
5143                Value::Text(idx.name.clone()),
5144                Value::Int(1),
5145                Value::Text(col),
5146                Value::Text(if nullable {
5147                    "YES".into()
5148                } else {
5149                    String::new()
5150                }),
5151                Value::Text("BTREE".into()),
5152            ]));
5153        }
5154        Ok(QueryResult::Rows { columns, rows })
5155    }
5156
5157    /// v7.17.0 Phase 3.P0-61 — `SHOW STATUS`. Returns canonical
5158    /// MySQL server-status counters (2-column `(Variable_name,
5159    /// Value)`).
5160    fn exec_show_status(&self) -> QueryResult {
5161        let columns = alloc::vec![
5162            ColumnSchema::new("Variable_name", DataType::Text, false),
5163            ColumnSchema::new("Value", DataType::Text, false),
5164        ];
5165        let pairs: &[(&str, &str)] = &[
5166            ("Uptime", "0"),
5167            ("Threads_connected", "1"),
5168            ("Threads_running", "1"),
5169            ("Questions", "0"),
5170            ("Slow_queries", "0"),
5171            ("Opened_tables", "0"),
5172            ("Innodb_buffer_pool_pages_total", "0"),
5173        ];
5174        let rows: Vec<Row> = pairs
5175            .iter()
5176            .map(|(k, v)| {
5177                Row::new(alloc::vec![
5178                    Value::Text((*k).into()),
5179                    Value::Text((*v).into())
5180                ])
5181            })
5182            .collect();
5183        QueryResult::Rows { columns, rows }
5184    }
5185
5186    /// v7.17.0 Phase 3.P0-61 — `SHOW VARIABLES`. Returns server-side
5187    /// variables MySQL/MariaDB clients probe at connect time.
5188    fn exec_show_variables(&self) -> QueryResult {
5189        let columns = alloc::vec![
5190            ColumnSchema::new("Variable_name", DataType::Text, false),
5191            ColumnSchema::new("Value", DataType::Text, false),
5192        ];
5193        let mut rows: Vec<Row> = Vec::new();
5194        let canonical: &[(&str, &str)] = &[
5195            ("version", "8.0.35-spg"),
5196            ("version_comment", "SPG dual-stack engine"),
5197            ("character_set_server", "utf8mb4"),
5198            ("collation_server", "utf8mb4_0900_ai_ci"),
5199            ("max_allowed_packet", "67108864"),
5200            ("autocommit", "ON"),
5201            ("sql_mode", "STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION"),
5202            ("time_zone", "SYSTEM"),
5203            ("transaction_isolation", "REPEATABLE-READ"),
5204        ];
5205        for &(k, v) in canonical {
5206            rows.push(Row::new(alloc::vec![
5207                Value::Text(k.into()),
5208                Value::Text(v.into()),
5209            ]));
5210        }
5211        // Session-set parameters surface here too.
5212        for (k, v) in &self.session_params {
5213            if !canonical.iter().any(|(n, _)| (*n).eq_ignore_ascii_case(k)) {
5214                rows.push(Row::new(alloc::vec![
5215                    Value::Text(k.clone()),
5216                    Value::Text(v.clone()),
5217                ]));
5218            }
5219        }
5220        QueryResult::Rows { columns, rows }
5221    }
5222
5223    /// v7.17.0 Phase 3.P0-62 — `SHOW PROCESSLIST`. SPG is
5224    /// single-process so the surface returns one synthetic row
5225    /// describing the current connection (Id, User, Host, db,
5226    /// Command, Time, State, Info).
5227    fn exec_show_processlist(&self) -> QueryResult {
5228        let columns = alloc::vec![
5229            ColumnSchema::new("Id", DataType::Int, false),
5230            ColumnSchema::new("User", DataType::Text, false),
5231            ColumnSchema::new("Host", DataType::Text, false),
5232            ColumnSchema::new("db", DataType::Text, true),
5233            ColumnSchema::new("Command", DataType::Text, false),
5234            ColumnSchema::new("Time", DataType::Int, false),
5235            ColumnSchema::new("State", DataType::Text, true),
5236            ColumnSchema::new("Info", DataType::Text, true),
5237        ];
5238        let rows = alloc::vec![Row::new(alloc::vec![
5239            Value::Int(1),
5240            Value::Text("postgres".into()),
5241            Value::Text("localhost".into()),
5242            Value::Text("postgres".into()),
5243            Value::Text("Query".into()),
5244            Value::Int(0),
5245            Value::Text("executing".into()),
5246            Value::Text("SHOW PROCESSLIST".into()),
5247        ])];
5248        QueryResult::Rows { columns, rows }
5249    }
5250
5251    /// v7.17.0 Phase 3.P0-58 — `SHOW DATABASES` / `SHOW SCHEMAS`.
5252    /// SPG is single-database so the result is the canonical MySQL
5253    /// set every mysql/MariaDB client expects at connect time:
5254    /// `information_schema`, `mysql`, `performance_schema`, `sys`,
5255    /// plus a `postgres` slot so dual-stack callers find their
5256    /// PG-compatible database too.
5257    fn exec_show_databases(&self) -> QueryResult {
5258        let columns = alloc::vec![ColumnSchema::new("Database", DataType::Text, false)];
5259        let names = [
5260            "information_schema",
5261            "mysql",
5262            "performance_schema",
5263            "sys",
5264            "postgres",
5265        ];
5266        let rows: Vec<Row> = names
5267            .iter()
5268            .map(|n| Row::new(alloc::vec![Value::Text((*n).into())]))
5269            .collect();
5270        QueryResult::Rows { columns, rows }
5271    }
5272
5273    /// `SHOW COLUMNS FROM <table>` — one row per column with the
5274    /// declared name, SQL type rendering, and nullability flag.
5275    fn exec_show_columns(&self, table_name: &str) -> Result<QueryResult, EngineError> {
5276        let table =
5277            self.active_catalog()
5278                .get(table_name)
5279                .ok_or_else(|| StorageError::TableNotFound {
5280                    name: table_name.into(),
5281                })?;
5282        let columns = alloc::vec![
5283            ColumnSchema::new("name", DataType::Text, false),
5284            ColumnSchema::new("type", DataType::Text, false),
5285            ColumnSchema::new("nullable", DataType::Bool, false),
5286        ];
5287        let rows: Vec<Row> = table
5288            .schema()
5289            .columns
5290            .iter()
5291            .map(|c| {
5292                Row::new(alloc::vec![
5293                    Value::Text(c.name.clone()),
5294                    Value::Text(alloc::format!("{}", c.ty)),
5295                    Value::Bool(c.nullable),
5296                ])
5297            })
5298            .collect();
5299        Ok(QueryResult::Rows { columns, rows })
5300    }
5301
5302    fn exec_begin(&mut self) -> Result<QueryResult, EngineError> {
5303        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5304        if self.tx_catalogs.contains_key(&tx_id) {
5305            return Err(EngineError::TransactionAlreadyOpen);
5306        }
5307        self.tx_catalogs.insert(
5308            tx_id,
5309            TxState {
5310                catalog: self.catalog.clone(),
5311                savepoints: Vec::new(),
5312            },
5313        );
5314        Ok(QueryResult::CommandOk {
5315            affected: 0,
5316            modified_catalog: false,
5317        })
5318    }
5319
5320    fn exec_commit(&mut self) -> Result<QueryResult, EngineError> {
5321        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5322        let state = self
5323            .tx_catalogs
5324            .remove(&tx_id)
5325            .ok_or(EngineError::NoActiveTransaction)?;
5326        self.catalog = state.catalog;
5327        // All savepoints become permanent at COMMIT and the stack
5328        // resets for the next TX (`state.savepoints` is discarded with
5329        // `state`).
5330        Ok(QueryResult::CommandOk {
5331            affected: 0,
5332            modified_catalog: true,
5333        })
5334    }
5335
5336    fn exec_rollback(&mut self) -> Result<QueryResult, EngineError> {
5337        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5338        if self.tx_catalogs.remove(&tx_id).is_none() {
5339            return Err(EngineError::NoActiveTransaction);
5340        }
5341        // savepoints discarded with the TxState
5342        Ok(QueryResult::CommandOk {
5343            affected: 0,
5344            modified_catalog: false,
5345        })
5346    }
5347
5348    fn exec_savepoint(&mut self, name: String) -> Result<QueryResult, EngineError> {
5349        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5350        let state = self
5351            .tx_catalogs
5352            .get_mut(&tx_id)
5353            .ok_or(EngineError::NoActiveTransaction)?;
5354        // PG re-uses an existing savepoint name by dropping the older
5355        // entry and pushing a fresh one — match that behaviour so
5356        // application code can `SAVEPOINT sp; ...; SAVEPOINT sp` freely.
5357        state.savepoints.retain(|(n, _)| n != &name);
5358        let snapshot = state.catalog.clone();
5359        state.savepoints.push((name, snapshot));
5360        Ok(QueryResult::CommandOk {
5361            affected: 0,
5362            modified_catalog: false,
5363        })
5364    }
5365
5366    fn exec_rollback_to_savepoint(&mut self, name: &str) -> Result<QueryResult, EngineError> {
5367        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5368        let state = self
5369            .tx_catalogs
5370            .get_mut(&tx_id)
5371            .ok_or(EngineError::NoActiveTransaction)?;
5372        let pos = state
5373            .savepoints
5374            .iter()
5375            .rposition(|(n, _)| n == name)
5376            .ok_or_else(|| {
5377                EngineError::Unsupported(alloc::format!("savepoint not found: {name}"))
5378            })?;
5379        // The savepoint stays on the stack (PG semantics): a later
5380        // `RELEASE` or further `ROLLBACK TO` is still allowed. Everything
5381        // after it is discarded.
5382        let snapshot = state.savepoints[pos].1.clone();
5383        state.savepoints.truncate(pos + 1);
5384        state.catalog = snapshot;
5385        Ok(QueryResult::CommandOk {
5386            affected: 0,
5387            modified_catalog: false,
5388        })
5389    }
5390
5391    fn exec_release_savepoint(&mut self, name: &str) -> Result<QueryResult, EngineError> {
5392        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5393        let state = self
5394            .tx_catalogs
5395            .get_mut(&tx_id)
5396            .ok_or(EngineError::NoActiveTransaction)?;
5397        let pos = state
5398            .savepoints
5399            .iter()
5400            .rposition(|(n, _)| n == name)
5401            .ok_or_else(|| {
5402                EngineError::Unsupported(alloc::format!("savepoint not found: {name}"))
5403            })?;
5404        // RELEASE keeps the work since the savepoint, just discards the
5405        // bookmark plus everything nested under it.
5406        state.savepoints.truncate(pos);
5407        Ok(QueryResult::CommandOk {
5408            affected: 0,
5409            modified_catalog: false,
5410        })
5411    }
5412
5413    /// v6.0.4 — synchronous `ALTER INDEX <name> REBUILD [WITH
5414    /// (encoding = …)]`. Walks every table in the active catalog
5415    /// looking for an index matching `stmt.name`, then delegates the
5416    /// rebuild (including any encoding switch) to
5417    /// `Table::rebuild_nsw_index`. The "live" non-blocking
5418    /// optimisation is v6.0.4.1 / v6.1.x territory.
5419    /// v6.7.2 — `ALTER TABLE t SET hot_tier_bytes = X`. Dispatch
5420    /// arm. Currently the only setting is `hot_tier_bytes`; later
5421    /// v6.7.x can extend `AlterTableTarget` without touching this
5422    /// arm structure.
5423    fn exec_alter_table(
5424        &mut self,
5425        s: spg_sql::ast::AlterTableStatement,
5426    ) -> Result<QueryResult, EngineError> {
5427        // v7.13.2 — mailrs round-6 S1: apply each subaction in order.
5428        // On first error the statement aborts; subactions already
5429        // applied stay (no transactional rollback in v7.13 — wrap in
5430        // BEGIN/COMMIT if atomicity matters).
5431        let table_name = s.name.clone();
5432        for target in s.targets {
5433            self.exec_alter_table_subaction(&table_name, target)?;
5434        }
5435        Ok(QueryResult::CommandOk {
5436            affected: 0,
5437            modified_catalog: !self.in_transaction(),
5438        })
5439    }
5440
5441    fn exec_alter_table_subaction(
5442        &mut self,
5443        table_name_outer: &str,
5444        target: spg_sql::ast::AlterTableTarget,
5445    ) -> Result<(), EngineError> {
5446        // Inner helper retains the s.name closure shape; alias to `s`
5447        // for minimal diff against the v7.13.0 body.
5448        struct S<'a> {
5449            name: &'a str,
5450        }
5451        let s = S {
5452            name: table_name_outer,
5453        };
5454        match target {
5455            spg_sql::ast::AlterTableTarget::SetHotTierBytes(n) => {
5456                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5457                    EngineError::Storage(StorageError::TableNotFound {
5458                        name: s.name.into(),
5459                    })
5460                })?;
5461                table.schema_mut().hot_tier_bytes = Some(n);
5462            }
5463            spg_sql::ast::AlterTableTarget::AddForeignKey(fk) => {
5464                // v7.6.8 — resolve FK against the live catalog first
5465                // (validates parent table, columns, indices). Then
5466                // verify every existing row in the child table
5467                // satisfies the new constraint. Then install it.
5468                let cols_snapshot = self
5469                    .active_catalog()
5470                    .get(s.name)
5471                    .ok_or_else(|| {
5472                        EngineError::Storage(StorageError::TableNotFound {
5473                            name: s.name.into(),
5474                        })
5475                    })?
5476                    .schema()
5477                    .columns
5478                    .clone();
5479                let storage_fk =
5480                    resolve_foreign_key(s.name, &cols_snapshot, fk, self.active_catalog())?;
5481                // Verify existing rows. Treat them as a virtual
5482                // INSERT batch — reusing the v7.6.2 enforce helper.
5483                let existing_rows: Vec<Vec<Value>> = self
5484                    .active_catalog()
5485                    .get(s.name)
5486                    .expect("checked above")
5487                    .rows()
5488                    .iter()
5489                    .map(|r| r.values.clone())
5490                    .collect();
5491                enforce_fk_inserts(
5492                    self.active_catalog(),
5493                    s.name,
5494                    core::slice::from_ref(&storage_fk),
5495                    &existing_rows,
5496                )?;
5497                // Reject duplicate constraint name.
5498                let table = self
5499                    .active_catalog_mut()
5500                    .get_mut(s.name)
5501                    .expect("checked above");
5502                if let Some(name) = &storage_fk.name
5503                    && table
5504                        .schema()
5505                        .foreign_keys
5506                        .iter()
5507                        .any(|f| f.name.as_ref() == Some(name))
5508                {
5509                    return Err(EngineError::Unsupported(alloc::format!(
5510                        "ALTER TABLE ADD CONSTRAINT: a constraint named {name:?} already exists"
5511                    )));
5512                }
5513                table.schema_mut().foreign_keys.push(storage_fk);
5514            }
5515            spg_sql::ast::AlterTableTarget::DropForeignKey { name, if_exists } => {
5516                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5517                    EngineError::Storage(StorageError::TableNotFound {
5518                        name: s.name.into(),
5519                    })
5520                })?;
5521                let fks = &mut table.schema_mut().foreign_keys;
5522                let before = fks.len();
5523                fks.retain(|f| f.name.as_ref() != Some(&name));
5524                if fks.len() == before && !if_exists {
5525                    return Err(EngineError::Unsupported(alloc::format!(
5526                        "ALTER TABLE DROP CONSTRAINT: no FK named {name:?} on {:?}",
5527                        s.name
5528                    )));
5529                }
5530                // v7.13.2 mailrs round-6 S7: IF EXISTS silences the miss.
5531            }
5532            spg_sql::ast::AlterTableTarget::AddColumn {
5533                column,
5534                if_not_exists,
5535            } => {
5536                // v7.13.0 — mailrs round-5 G1. Append-only column add
5537                // with back-fill of the DEFAULT (or NULL) into every
5538                // existing row. Column positions don't shift, so we
5539                // skip index rebuild.
5540                let clock = self.clock;
5541                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5542                    EngineError::Storage(StorageError::TableNotFound {
5543                        name: s.name.into(),
5544                    })
5545                })?;
5546                if table
5547                    .schema()
5548                    .columns
5549                    .iter()
5550                    .any(|c| c.name.eq_ignore_ascii_case(&column.name))
5551                {
5552                    if if_not_exists {
5553                        return Ok(());
5554                    }
5555                    return Err(EngineError::Unsupported(alloc::format!(
5556                        "ALTER TABLE ADD COLUMN: column {:?} already exists on {:?}",
5557                        column.name,
5558                        s.name
5559                    )));
5560                }
5561                let col_name = column.name.clone();
5562                let nullable = column.nullable;
5563                let has_default = column.default.is_some() || column.auto_increment;
5564                let col_schema = column_def_to_schema(column)?;
5565                let row_count = table.row_count();
5566                // Compute the back-fill value. Literal / runtime DEFAULT
5567                // funnels through the same resolver that INSERT uses
5568                // (v7.9.21 `resolve_column_default_free`). NULL when
5569                // the column is nullable and has no DEFAULT. NOT NULL
5570                // without DEFAULT errors when the table has existing
5571                // rows — same as PG.
5572                let fill_value: Value = if has_default || col_schema.runtime_default.is_some() {
5573                    resolve_column_default_free(&col_schema, clock)?
5574                } else if nullable || row_count == 0 {
5575                    Value::Null
5576                } else {
5577                    return Err(EngineError::Unsupported(alloc::format!(
5578                        "ALTER TABLE ADD COLUMN {col_name:?}: NOT NULL column requires DEFAULT \
5579                         when the table has existing rows"
5580                    )));
5581                };
5582                table.add_column(col_schema, fill_value);
5583            }
5584            spg_sql::ast::AlterTableTarget::AlterColumnType {
5585                column,
5586                new_type,
5587                using,
5588            } => {
5589                // v7.13.0 — mailrs round-5 G8. Re-evaluate each
5590                // row's column value (either through the USING
5591                // expression if supplied, or as a direct CAST of
5592                // the existing value) and re-coerce to the new
5593                // type. Indices on the column get rebuilt.
5594                let new_data_type = column_type_to_data_type(new_type);
5595                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5596                    EngineError::Storage(StorageError::TableNotFound {
5597                        name: s.name.into(),
5598                    })
5599                })?;
5600                let col_pos = table
5601                    .schema()
5602                    .columns
5603                    .iter()
5604                    .position(|c| c.name.eq_ignore_ascii_case(&column))
5605                    .ok_or_else(|| {
5606                        EngineError::Unsupported(alloc::format!(
5607                            "ALTER COLUMN TYPE: column {column:?} not found on {:?}",
5608                            s.name
5609                        ))
5610                    })?;
5611                let schema_cols = table.schema().columns.clone();
5612                let ctx = eval::EvalContext::new(&schema_cols, None);
5613                let mut new_values: alloc::vec::Vec<Value> =
5614                    alloc::vec::Vec::with_capacity(table.row_count());
5615                for row in table.rows().iter() {
5616                    let raw = match &using {
5617                        Some(expr) => eval::eval_expr(expr, row, &ctx).map_err(|e| {
5618                            EngineError::Unsupported(alloc::format!(
5619                                "ALTER COLUMN TYPE: USING expression failed: {e:?}"
5620                            ))
5621                        })?,
5622                        None => row.values.get(col_pos).cloned().unwrap_or(Value::Null),
5623                    };
5624                    let coerced = coerce_value(raw, new_data_type, &column, col_pos)?;
5625                    new_values.push(coerced);
5626                }
5627                table.schema_mut().columns[col_pos].ty = new_data_type;
5628                for (i, v) in new_values.into_iter().enumerate() {
5629                    let mut row_values = table
5630                        .rows()
5631                        .get(i)
5632                        .expect("bounds-checked above")
5633                        .values
5634                        .clone();
5635                    row_values[col_pos] = v;
5636                    table.update_row(i, row_values)?;
5637                }
5638            }
5639            spg_sql::ast::AlterTableTarget::AddTableConstraint(tc) => {
5640                // v7.14.0 — pg_dump emits PKs as a separate
5641                // ALTER TABLE ADD CONSTRAINT post-CREATE-TABLE.
5642                // For PRIMARY KEY / UNIQUE, install a UC entry
5643                // and the implicit BTree index on the leading
5644                // column. CHECK: append predicate to schema.
5645                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5646                    EngineError::Storage(StorageError::TableNotFound {
5647                        name: s.name.into(),
5648                    })
5649                })?;
5650                let is_pk = matches!(tc, spg_sql::ast::TableConstraint::PrimaryKey { .. });
5651                // v7.22 (mailrs round-13 gap 6) — carry the parsed
5652                // NULLS NOT DISTINCT flag through the ALTER path;
5653                // it was hardcoded false here while the CREATE
5654                // TABLE path honoured it since v7.13.
5655                let nnd = matches!(
5656                    tc,
5657                    spg_sql::ast::TableConstraint::Unique {
5658                        nulls_not_distinct: true,
5659                        ..
5660                    }
5661                );
5662                match tc {
5663                    spg_sql::ast::TableConstraint::PrimaryKey { columns, .. }
5664                    | spg_sql::ast::TableConstraint::Unique { columns, .. } => {
5665                        let positions: Vec<usize> = columns
5666                            .iter()
5667                            .map(|c| {
5668                                table
5669                                    .schema()
5670                                    .columns
5671                                    .iter()
5672                                    .position(|sc| sc.name.eq_ignore_ascii_case(c))
5673                                    .ok_or_else(|| {
5674                                        EngineError::Unsupported(alloc::format!(
5675                                            "ALTER TABLE ADD CONSTRAINT: column {c:?} not found on {:?}",
5676                                            s.name
5677                                        ))
5678                                    })
5679                            })
5680                            .collect::<Result<Vec<_>, _>>()?;
5681                        // Skip if an equivalent UC is already there
5682                        // (idempotent — pg_dump's PK + a prior inline
5683                        // PK shouldn't double-install).
5684                        let already = table
5685                            .schema()
5686                            .uniqueness_constraints
5687                            .iter()
5688                            .any(|u| u.columns == positions);
5689                        if !already {
5690                            table.schema_mut().uniqueness_constraints.push(
5691                                spg_storage::UniquenessConstraint {
5692                                    is_primary_key: is_pk,
5693                                    columns: positions.clone(),
5694                                    nulls_not_distinct: nnd,
5695                                },
5696                            );
5697                            // PK implies NOT NULL on referenced cols.
5698                            if is_pk {
5699                                for p in &positions {
5700                                    if let Some(c) = table.schema_mut().columns.get_mut(*p) {
5701                                        c.nullable = false;
5702                                    }
5703                                }
5704                            }
5705                            // Add a BTree index on the leading
5706                            // column for INSERT-side enforcement.
5707                            let leading = &columns[0];
5708                            let already_idx = table.indices().iter().any(|idx| {
5709                                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
5710                                    && table.schema().columns[idx.column_position].name == *leading
5711                            });
5712                            if !already_idx {
5713                                let suffix = if is_pk { "pkey" } else { "key" };
5714                                let idx_name = alloc::format!("{}_{leading}_{suffix}", s.name);
5715                                let _ = table.add_index(idx_name, leading);
5716                            }
5717                        }
5718                    }
5719                    spg_sql::ast::TableConstraint::Check { expr, .. } => {
5720                        table.schema_mut().checks.push(alloc::format!("{expr}"));
5721                    }
5722                    spg_sql::ast::TableConstraint::Index { name, columns } => {
5723                        // v7.15.0 — ALTER TABLE ADD KEY (cols).
5724                        // mysqldump occasionally emits this
5725                        // post-CREATE-TABLE shape; build a BTree
5726                        // on the leading column using the
5727                        // user-supplied or synthesised name.
5728                        let leading = &columns[0];
5729                        let already_idx = table.indices().iter().any(|idx| {
5730                            matches!(idx.kind, spg_storage::IndexKind::BTree(_))
5731                                && table.schema().columns[idx.column_position].name == *leading
5732                        });
5733                        if !already_idx {
5734                            let idx_name = name
5735                                .clone()
5736                                .unwrap_or_else(|| alloc::format!("{}_{leading}_idx", s.name));
5737                            let _ = table.add_index(idx_name, leading);
5738                        }
5739                    }
5740                    spg_sql::ast::TableConstraint::FulltextIndex { name, columns } => {
5741                        // v7.17.0 Phase 2.2 — ALTER TABLE ADD
5742                        // FULLTEXT KEY (cols). Builds one
5743                        // fulltext-GIN per named column so MATCH
5744                        // AGAINST gets a real inverted index.
5745                        // Multi-column declarations expand to
5746                        // per-column GINs (the leading column
5747                        // drives MATCH AGAINST planning).
5748                        for (k, col) in columns.iter().enumerate() {
5749                            let already_idx = table.indices().iter().any(|idx| {
5750                                matches!(idx.kind, spg_storage::IndexKind::GinFulltext(_))
5751                                    && table.schema().columns[idx.column_position].name == *col
5752                            });
5753                            if already_idx {
5754                                continue;
5755                            }
5756                            let idx_name = match (&name, columns.len(), k) {
5757                                (Some(n), 1, _) => n.clone(),
5758                                (Some(n), _, k) => alloc::format!("{n}_{k}"),
5759                                (None, _, _) => {
5760                                    alloc::format!("{}_{col}_ftidx", s.name)
5761                                }
5762                            };
5763                            let _ = table.add_gin_fulltext_index(idx_name, col);
5764                        }
5765                    }
5766                }
5767            }
5768            spg_sql::ast::AlterTableTarget::DropColumn {
5769                column,
5770                if_exists,
5771                cascade,
5772            } => {
5773                // v7.13.3 — mailrs round-7 S8. Remove the column +
5774                // every row's value at that position; drop any index
5775                // on the column. RESTRICT (default) rejects when an
5776                // FK on this table or partial-index predicate
5777                // references the column; CASCADE removes those
5778                // dependents first.
5779                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5780                    EngineError::Storage(StorageError::TableNotFound {
5781                        name: s.name.into(),
5782                    })
5783                })?;
5784                let col_pos = match table
5785                    .schema()
5786                    .columns
5787                    .iter()
5788                    .position(|c| c.name.eq_ignore_ascii_case(&column))
5789                {
5790                    Some(p) => p,
5791                    None => {
5792                        if if_exists {
5793                            return Ok(());
5794                        }
5795                        return Err(EngineError::Unsupported(alloc::format!(
5796                            "ALTER TABLE DROP COLUMN: column {column:?} not found on {:?}",
5797                            s.name
5798                        )));
5799                    }
5800                };
5801                // Dependent check: FKs whose local columns include
5802                // col_pos. CASCADE drops them; otherwise reject.
5803                let dependent_fks: Vec<usize> = table
5804                    .schema()
5805                    .foreign_keys
5806                    .iter()
5807                    .enumerate()
5808                    .filter_map(|(i, fk)| {
5809                        if fk.local_columns.contains(&col_pos) {
5810                            Some(i)
5811                        } else {
5812                            None
5813                        }
5814                    })
5815                    .collect();
5816                if !dependent_fks.is_empty() && !cascade {
5817                    return Err(EngineError::Unsupported(alloc::format!(
5818                        "ALTER TABLE DROP COLUMN {column:?}: column has FK dependents; \
5819                         use DROP COLUMN ... CASCADE to remove them"
5820                    )));
5821                }
5822                // CASCADE the FK removals first.
5823                if cascade {
5824                    // Drop in reverse so indices stay valid.
5825                    let mut sorted = dependent_fks.clone();
5826                    sorted.sort();
5827                    sorted.reverse();
5828                    let fks = &mut table.schema_mut().foreign_keys;
5829                    for i in sorted {
5830                        fks.remove(i);
5831                    }
5832                }
5833                // Drop the column. New helper on Table does the
5834                // row + schema + index shift atomically.
5835                table.drop_column(col_pos);
5836            }
5837            spg_sql::ast::AlterTableTarget::SetTriggerEnabled { which, enabled } => {
5838                // v7.16.1 — mailrs round-9 A.2.b. pg_dump
5839                // --disable-triggers wraps each table's data
5840                // block with `ALTER TABLE … DISABLE TRIGGER ALL`
5841                // / `… ENABLE TRIGGER ALL`. Toggle the enabled
5842                // flag on every matching trigger so the row-
5843                // write paths skip them; the catalog snapshot
5844                // persists the new state across restarts.
5845                let table_name = s.name.to_string();
5846                let trigs = self.active_catalog_mut().triggers_mut();
5847                let mut touched = false;
5848                for t in trigs.iter_mut() {
5849                    if !t.table.eq_ignore_ascii_case(&table_name) {
5850                        continue;
5851                    }
5852                    match &which {
5853                        spg_sql::ast::TriggerSelector::All => {
5854                            t.enabled = enabled;
5855                            touched = true;
5856                        }
5857                        spg_sql::ast::TriggerSelector::Named(name) => {
5858                            if t.name.eq_ignore_ascii_case(name) {
5859                                t.enabled = enabled;
5860                                touched = true;
5861                            }
5862                        }
5863                    }
5864                }
5865                // PG semantics: `ALL` on a table with no
5866                // triggers is a no-op (no error). A `Named`
5867                // form pointing at a non-existent trigger
5868                // raises in PG; v7.16.1 also raises so we
5869                // don't silently lose state.
5870                if !touched {
5871                    if let spg_sql::ast::TriggerSelector::Named(name) = &which {
5872                        return Err(EngineError::Unsupported(alloc::format!(
5873                            "ALTER TABLE {table_name:?} {} TRIGGER {name:?}: no such trigger on table",
5874                            if enabled { "ENABLE" } else { "DISABLE" },
5875                        )));
5876                    }
5877                }
5878            }
5879            spg_sql::ast::AlterTableTarget::SetColumnAutoIncrement { column, seq_name } => {
5880                // pg_dump's identity form names an IMPLICIT sequence
5881                // (`… AS IDENTITY ( SEQUENCE NAME s … )`) that never
5882                // gets its own CREATE SEQUENCE statement, while the
5883                // data section still calls `setval(s, …)`. Make the
5884                // sequence exist (idempotent) so those calls land.
5885                if let Some(seq) = seq_name {
5886                    let _ = self.exec_create_sequence(spg_sql::ast::CreateSequenceStatement {
5887                        name: seq,
5888                        if_not_exists: true,
5889                        temporary: false,
5890                        data_type: None,
5891                        options: spg_sql::ast::SequenceOptions::default(),
5892                    })?;
5893                }
5894                // v7.22 (round-13 T2) — pg_dump's serial/identity
5895                // spellings (`SET DEFAULT nextval(…)` / `ADD
5896                // GENERATED … AS IDENTITY`) lower here: flip the
5897                // column's auto-increment flag so post-import
5898                // INSERTs without an explicit value keep numbering
5899                // (max+1 semantics; the dump's setval() calls are
5900                // no-ops by construction).
5901                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5902                    EngineError::Storage(StorageError::TableNotFound {
5903                        name: s.name.into(),
5904                    })
5905                })?;
5906                let pos = table
5907                    .schema()
5908                    .columns
5909                    .iter()
5910                    .position(|c| c.name.eq_ignore_ascii_case(&column))
5911                    .ok_or_else(|| {
5912                        EngineError::Unsupported(alloc::format!(
5913                            "ALTER COLUMN {column:?}: no such column on {:?}",
5914                            s.name
5915                        ))
5916                    })?;
5917                let col = &table.schema().columns[pos];
5918                if !matches!(
5919                    col.ty,
5920                    spg_storage::DataType::SmallInt
5921                        | spg_storage::DataType::Int
5922                        | spg_storage::DataType::BigInt
5923                ) {
5924                    return Err(EngineError::Unsupported(alloc::format!(
5925                        "auto-increment applies to integer columns only ({column:?} is {:?})",
5926                        col.ty
5927                    )));
5928                }
5929                table.schema_mut().columns[pos].auto_increment = true;
5930            }
5931            spg_sql::ast::AlterTableTarget::RenameTable { new } => {
5932                // v7.16.2 — table-level rename (mailrs round-10
5933                // A.5 — used by migrate-042's `ALTER TABLE
5934                // contacts RENAME TO email_contacts`). Storage
5935                // helper updates the schema + by_name index +
5936                // dangling FK / trigger references in one
5937                // atomic step.
5938                let old = s.name.to_string();
5939                self.active_catalog_mut()
5940                    .rename_table(&old, &new)
5941                    .map_err(EngineError::Storage)?;
5942            }
5943            spg_sql::ast::AlterTableTarget::RenameColumn { old, new } => {
5944                // v7.15.0 — `ALTER TABLE t RENAME [COLUMN] old TO
5945                // new`. Rename the column in the schema; rewrite
5946                // every stored source string on this table that
5947                // references it as a (potentially-qualified)
5948                // column identifier: CHECK predicates, partial-
5949                // index predicates, runtime DEFAULT expressions.
5950                // Then walk catalog triggers on this table and
5951                // patch any `UPDATE OF` column list. Function and
5952                // trigger bodies are NOT auto-rewritten — that
5953                // surface is dynamic SQL territory; users update
5954                // those separately (matches PG plpgsql behavior:
5955                // a column rename invalidates name-referencing
5956                // plpgsql at call time, not rename time).
5957                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5958                    EngineError::Storage(StorageError::TableNotFound {
5959                        name: s.name.into(),
5960                    })
5961                })?;
5962                let col_pos = table
5963                    .schema()
5964                    .columns
5965                    .iter()
5966                    .position(|c| c.name.eq_ignore_ascii_case(&old))
5967                    .ok_or_else(|| {
5968                        EngineError::Unsupported(alloc::format!(
5969                            "ALTER TABLE RENAME COLUMN: column {old:?} not found on {:?}",
5970                            s.name
5971                        ))
5972                    })?;
5973                // Reject same-name (case-insensitive) collision.
5974                if table
5975                    .schema()
5976                    .columns
5977                    .iter()
5978                    .enumerate()
5979                    .any(|(i, c)| i != col_pos && c.name.eq_ignore_ascii_case(&new))
5980                {
5981                    return Err(EngineError::Unsupported(alloc::format!(
5982                        "ALTER TABLE RENAME COLUMN: column {new:?} already exists on {:?}",
5983                        s.name
5984                    )));
5985                }
5986                // Schema rename first — even idempotent same-name
5987                // rename (`ALTER TABLE t RENAME a TO a`) needs to
5988                // be a no-op, not an error.
5989                if old.eq_ignore_ascii_case(&new) {
5990                    return Ok(());
5991                }
5992                table.rename_column(col_pos, &new);
5993                // Rewrite per-column runtime_default sources on
5994                // every column of this table — a DEFAULT expression
5995                // on column X may reference column Y by name (rare,
5996                // but legal in PG when the value is supplied via a
5997                // function that takes the row).
5998                let n_cols = table.schema().columns.len();
5999                for i in 0..n_cols {
6000                    let rt = table.schema().columns[i].runtime_default.clone();
6001                    if let Some(src) = rt {
6002                        let rewritten = rewrite_column_in_source(&src, &old, &new)?;
6003                        table.schema_mut().columns[i].runtime_default = Some(rewritten);
6004                    }
6005                }
6006                // Rewrite table-level CHECK predicates.
6007                let checks = table.schema().checks.clone();
6008                let mut new_checks = Vec::with_capacity(checks.len());
6009                for chk in checks {
6010                    new_checks.push(rewrite_column_in_source(&chk, &old, &new)?);
6011                }
6012                table.schema_mut().checks = new_checks;
6013                // Rewrite per-index partial_predicate sources.
6014                let n_idx = table.indices().len();
6015                for i in 0..n_idx {
6016                    let pred = table.indices()[i].partial_predicate.clone();
6017                    if let Some(src) = pred {
6018                        let rewritten = rewrite_column_in_source(&src, &old, &new)?;
6019                        // SAFETY: indices_mut would be cleanest, but
6020                        // partial_predicate is the only mutable field
6021                        // here; reach in via the public mut accessor.
6022                        table.set_partial_predicate(i, Some(rewritten));
6023                    }
6024                }
6025                // Walk catalog triggers; patch `update_columns` on
6026                // triggers attached to this table.
6027                let table_name = s.name.to_string();
6028                for trig in self.active_catalog_mut().triggers_mut() {
6029                    if !trig.table.eq_ignore_ascii_case(&table_name) {
6030                        continue;
6031                    }
6032                    for c in &mut trig.update_columns {
6033                        if c.eq_ignore_ascii_case(&old) {
6034                            *c = new.clone();
6035                        }
6036                    }
6037                }
6038            }
6039        }
6040        Ok(())
6041    }
6042
6043    fn exec_alter_index(
6044        &mut self,
6045        stmt: spg_sql::ast::AlterIndexStatement,
6046    ) -> Result<QueryResult, EngineError> {
6047        // Translate the optional SQL-side encoding choice into the
6048        // storage-side enum; the same SqlVecEncoding -> VecEncoding
6049        // bridge `column_type_to_data_type` uses.
6050        let spg_sql::ast::AlterIndexStatement {
6051            name: idx_name,
6052            target,
6053        } = stmt;
6054        // v7.16.2 — RENAME TO branch (mailrs round-10 migrate-042).
6055        // IF EXISTS makes a missing index a no-op rather than an
6056        // error, mirroring PG semantics.
6057        if let spg_sql::ast::AlterIndexTarget::Rename { new, if_exists } = target {
6058            let renamed = self.active_catalog_mut().rename_index(&idx_name, &new);
6059            return match renamed {
6060                Ok(()) => Ok(QueryResult::CommandOk {
6061                    affected: 0,
6062                    modified_catalog: !self.in_transaction(),
6063                }),
6064                Err(StorageError::IndexNotFound { .. }) if if_exists => {
6065                    Ok(QueryResult::CommandOk {
6066                        affected: 0,
6067                        modified_catalog: false,
6068                    })
6069                }
6070                Err(e) => Err(EngineError::Storage(e)),
6071            };
6072        }
6073        let spg_sql::ast::AlterIndexTarget::Rebuild { encoding } = target else {
6074            unreachable!("Rename branch returned above");
6075        };
6076        let target = encoding.map(|e| match e {
6077            SqlVecEncoding::F32 => VecEncoding::F32,
6078            SqlVecEncoding::Sq8 => VecEncoding::Sq8,
6079            SqlVecEncoding::F16 => VecEncoding::F16,
6080        });
6081        // Linear scan: index names are globally unique within a
6082        // catalog (enforced by add_nsw_index_inner) so the first
6083        // match is the only one. Save the table name to avoid
6084        // borrowing while we then take a mut borrow.
6085        let table_name = {
6086            let cat = self.active_catalog();
6087            let mut found: Option<String> = None;
6088            for tname in cat.table_names() {
6089                if let Some(t) = cat.get(&tname)
6090                    && t.indices().iter().any(|i| i.name == idx_name)
6091                {
6092                    found = Some(tname);
6093                    break;
6094                }
6095            }
6096            found.ok_or_else(|| {
6097                EngineError::Storage(StorageError::IndexNotFound {
6098                    name: idx_name.clone(),
6099                })
6100            })?
6101        };
6102        let table = self
6103            .active_catalog_mut()
6104            .get_mut(&table_name)
6105            .expect("table found above");
6106        table.rebuild_nsw_index(&idx_name, target)?;
6107        // v6.3.1 — ALTER INDEX REBUILD potentially with new encoding
6108        // changes cost characteristics; evict any cached plans.
6109        self.plan_cache.evict_referencing(&table_name);
6110        Ok(QueryResult::CommandOk {
6111            affected: 0,
6112            modified_catalog: !self.in_transaction(),
6113        })
6114    }
6115
6116    fn exec_create_index(
6117        &mut self,
6118        stmt: CreateIndexStatement,
6119    ) -> Result<QueryResult, EngineError> {
6120        let table = self
6121            .active_catalog_mut()
6122            .get_mut(&stmt.table)
6123            .ok_or_else(|| {
6124                EngineError::Storage(StorageError::TableNotFound {
6125                    name: stmt.table.clone(),
6126                })
6127            })?;
6128        // `IF NOT EXISTS` reduces DuplicateIndex to a no-op CommandOk.
6129        if stmt.if_not_exists && table.indices().iter().any(|i| i.name == stmt.name) {
6130            return Ok(QueryResult::CommandOk {
6131                affected: 0,
6132                modified_catalog: false,
6133            });
6134        }
6135        // v7.9.14 — multi-column index parses through; engine
6136        // builds a single-column BTree on the leading column only.
6137        // The extras live on the AST so spg-server's dispatcher
6138        // can emit a PG-wire NoticeResponse / log line. Composite
6139        // BTree keys land in v7.10.
6140        let _ = &stmt.extra_columns; // intentional drop on engine side
6141        let table_name = stmt.table.clone();
6142        // v6.8.0 — resolve INCLUDE column names to positions. Done
6143        // before `add_index` so a typo error surfaces before any
6144        // catalog mutation lands.
6145        let included_positions: Vec<usize> = if stmt.included_columns.is_empty() {
6146            Vec::new()
6147        } else {
6148            let schema = table.schema();
6149            stmt.included_columns
6150                .iter()
6151                .map(|c| {
6152                    schema.column_position(c).ok_or_else(|| {
6153                        EngineError::Storage(StorageError::ColumnNotFound { column: c.clone() })
6154                    })
6155                })
6156                .collect::<Result<Vec<_>, _>>()?
6157        };
6158        match stmt.method {
6159            IndexMethod::BTree => table.add_index(stmt.name.clone(), &stmt.column)?,
6160            IndexMethod::Hnsw => {
6161                if !included_positions.is_empty() {
6162                    return Err(EngineError::Unsupported(
6163                        "INCLUDE columns are not supported on HNSW indexes".into(),
6164                    ));
6165                }
6166                table.add_nsw_index(stmt.name.clone(), &stmt.column, spg_storage::NSW_DEFAULT_M)?;
6167            }
6168            // v6.7.1 — BRIN. Pure metadata; no in-memory data.
6169            IndexMethod::Brin => {
6170                if !included_positions.is_empty() {
6171                    return Err(EngineError::Unsupported(
6172                        "INCLUDE columns are not supported on BRIN indexes".into(),
6173                    ));
6174                }
6175                table.add_brin_index(stmt.name.clone(), &stmt.column)?;
6176            }
6177            // v7.12.3 — GIN inverted index. Real posting-list-backed
6178            // GIN when the indexed column is `tsvector`; falls back
6179            // to a BTree on the leading column for any other column
6180            // type so v7.9.26b's `pg_dump` compatibility (GIN on
6181            // JSONB etc. silently loading as BTree) is preserved.
6182            // Operators see the real GIN only where it matters; old
6183            // schemas keep loading.
6184            IndexMethod::Gin => {
6185                if !included_positions.is_empty() {
6186                    return Err(EngineError::Unsupported(
6187                        "INCLUDE columns are not supported on GIN indexes".into(),
6188                    ));
6189                }
6190                let col_pos = table
6191                    .schema()
6192                    .column_position(&stmt.column)
6193                    .ok_or_else(|| {
6194                        EngineError::Storage(StorageError::ColumnNotFound {
6195                            column: stmt.column.clone(),
6196                        })
6197                    })?;
6198                let col_ty = table.schema().columns[col_pos].ty;
6199                // v7.15.0 — `gin_trgm_ops` on a TEXT/VARCHAR
6200                // column dispatches to the real trigram-shingle
6201                // GIN build (LIKE / similarity acceleration).
6202                // Other GIN opclasses fall through to the regular
6203                // tsvector-vs-BTree split below.
6204                let is_trgm = stmt
6205                    .opclass
6206                    .as_deref()
6207                    .is_some_and(|op| op.eq_ignore_ascii_case("gin_trgm_ops"));
6208                if is_trgm
6209                    && matches!(
6210                        col_ty,
6211                        spg_storage::DataType::Text | spg_storage::DataType::Varchar(_)
6212                    )
6213                {
6214                    table
6215                        .add_gin_trgm_index(stmt.name.clone(), &stmt.column)
6216                        .map_err(EngineError::Storage)?;
6217                } else if col_ty == spg_storage::DataType::TsVector {
6218                    table
6219                        .add_gin_index(stmt.name.clone(), &stmt.column)
6220                        .map_err(EngineError::Storage)?;
6221                } else {
6222                    // v7.9.26b BTree fallback — the catalog still
6223                    // gets an index entry on the leading column so
6224                    // pg_dump scripts that name GIN on JSONB / etc.
6225                    // load clean; query-time gain stays opt-in for
6226                    // tsvector callers.
6227                    table.add_index(stmt.name.clone(), &stmt.column)?;
6228                }
6229            }
6230        }
6231        if !included_positions.is_empty()
6232            && let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name)
6233        {
6234            idx.included_columns = included_positions;
6235        }
6236        // v6.8.1 — persist partial-index predicate. Stored as the
6237        // expression's Display form so the catalog snapshot stays
6238        // pure (storage has no spg-sql dependency). The runtime
6239        // maintenance path treats partial indexes identically to
6240        // full indexes for v6.8.1 (over-maintenance is safe; the
6241        // planner-side "use partial when query WHERE implies the
6242        // predicate" pass is STABILITY carve-out).
6243        if let Some(pred_expr) = &stmt.partial_predicate {
6244            let canonical = pred_expr.to_string();
6245            // v7.13.2 — mailrs round-6 S2. PG's `pg_trgm` uses
6246            // `CREATE INDEX … USING gin(col gin_trgm_ops) WHERE …`
6247            // routinely to slim trigram indexes. SPG now persists
6248            // the predicate for GIN / BRIN / HNSW the same way it
6249            // already does for BTree — same v6.8.1 "over-maintain
6250            // is safe; planner-side partial routing is STABILITY
6251            // carve-out" semantics. HNSW carries an additional
6252            // caveat: the predicate isn't applied at index build
6253            // time (would require per-row eval inside the NSW
6254            // construction loop), so the index oversamples; query
6255            // time the WHERE clause still filters correctly.
6256            if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
6257                idx.partial_predicate = Some(canonical);
6258            }
6259        }
6260        // v6.8.2 — persist expression index key. Same Display-form
6261        // storage; the runtime maintenance pass evaluates each
6262        // row's expression to derive the index key, but for v6.8.2
6263        // the engine falls through to the bare-column-reference
6264        // path and the expression is preserved for format-layer
6265        // round-trip + future planner work. Carved-out in
6266        // STABILITY § "Out of v6.8".
6267        if let Some(key_expr) = &stmt.expression {
6268            if matches!(
6269                stmt.method,
6270                IndexMethod::Hnsw | IndexMethod::Brin | IndexMethod::Gin
6271            ) {
6272                return Err(EngineError::Unsupported(
6273                    "Expression keys are not supported on HNSW or BRIN indexes".into(),
6274                ));
6275            }
6276            let canonical = key_expr.to_string();
6277            if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
6278                idx.expression = Some(canonical);
6279            }
6280        }
6281        // v7.9.29 — persist `is_unique` flag on the storage Index.
6282        // Combined with `partial_predicate`, INSERT enforcement
6283        // checks that no other row whose predicate evaluates true
6284        // shares the same indexed key. Parser already rejected
6285        // `UNIQUE` on HNSW / BRIN, so plain BTree here.
6286        // For multi-column UNIQUE INDEX the extras matter (the
6287        // full tuple is the uniqueness key), so resolve them to
6288        // column positions and persist on the index too.
6289        if stmt.is_unique {
6290            let mut extra_positions: alloc::vec::Vec<usize> = alloc::vec::Vec::new();
6291            for col_name in &stmt.extra_columns {
6292                let pos = table
6293                    .schema()
6294                    .columns
6295                    .iter()
6296                    .position(|c| c.name.eq_ignore_ascii_case(col_name))
6297                    .ok_or_else(|| {
6298                        EngineError::Unsupported(alloc::format!(
6299                            "UNIQUE INDEX {:?}: extra column {col_name:?} not in table {:?}",
6300                            stmt.name,
6301                            stmt.table
6302                        ))
6303                    })?;
6304                extra_positions.push(pos);
6305            }
6306            if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
6307                idx.is_unique = true;
6308                idx.extra_column_positions = extra_positions;
6309            }
6310            // At index-creation time, check the existing rows for
6311            // pre-existing duplicates that would have violated the
6312            // new constraint — otherwise CREATE UNIQUE INDEX would
6313            // silently leave duplicates in place.
6314            let snapshot_indices = table.indices().to_vec();
6315            let snapshot_rows: alloc::vec::Vec<spg_storage::Row> =
6316                table.rows().iter().cloned().collect();
6317            let snapshot_schema = table.schema().clone();
6318            let idx_ref = snapshot_indices
6319                .iter()
6320                .find(|i| i.name == stmt.name)
6321                .expect("just-added index");
6322            check_existing_unique_violation(idx_ref, &snapshot_schema, &snapshot_rows)?;
6323        }
6324        // v6.3.1 — adding an index can change the optimal plan for
6325        // any cached query that references this table.
6326        self.plan_cache.evict_referencing(&table_name);
6327        Ok(QueryResult::CommandOk {
6328            affected: 0,
6329            modified_catalog: !self.in_transaction(),
6330        })
6331    }
6332
6333    /// v7.13.3 — mailrs round-7 S9. SPG-specific reconciliation
6334    /// for `CREATE TABLE IF NOT EXISTS` when the table already
6335    /// exists. Adds missing columns + inline FKs from the new
6336    /// definition; existing columns / constraints stay untouched.
6337    /// New columns with a `NOT NULL` declaration without a
6338    /// `DEFAULT` are reported as a clear error rather than
6339    /// silently dropped — this is the "fail loud on real
6340    /// incompatibility, fail silent on schema-superset" tradeoff.
6341    fn reconcile_table_if_not_exists(
6342        &mut self,
6343        stmt: CreateTableStatement,
6344    ) -> Result<QueryResult, EngineError> {
6345        let table_name = stmt.name.clone();
6346        let clock = self.clock;
6347        let existing_col_names: alloc::collections::BTreeSet<String> = self
6348            .active_catalog()
6349            .get(&table_name)
6350            .expect("checked above")
6351            .schema()
6352            .columns
6353            .iter()
6354            .map(|c| c.name.to_ascii_lowercase())
6355            .collect();
6356        let row_count = self
6357            .active_catalog()
6358            .get(&table_name)
6359            .expect("checked above")
6360            .row_count();
6361        // Collect missing column defs in source order.
6362        let new_columns: alloc::vec::Vec<spg_sql::ast::ColumnDef> = stmt
6363            .columns
6364            .iter()
6365            .filter(|c| !existing_col_names.contains(&c.name.to_ascii_lowercase()))
6366            .cloned()
6367            .collect();
6368        for col_def in new_columns {
6369            let col_name = col_def.name.clone();
6370            let nullable = col_def.nullable;
6371            let has_default = col_def.default.is_some() || col_def.auto_increment;
6372            let col_schema = column_def_to_schema(col_def)?;
6373            let fill_value: Value = if has_default || col_schema.runtime_default.is_some() {
6374                resolve_column_default_free(&col_schema, clock)?
6375            } else if nullable || row_count == 0 {
6376                Value::Null
6377            } else {
6378                return Err(EngineError::Unsupported(alloc::format!(
6379                    "CREATE TABLE IF NOT EXISTS {table_name:?}: reconciling \
6380                     column {col_name:?} requires DEFAULT (existing rows would violate NOT NULL)"
6381                )));
6382            };
6383            let table = self
6384                .active_catalog_mut()
6385                .get_mut(&table_name)
6386                .expect("checked above");
6387            table.add_column(col_schema, fill_value);
6388        }
6389        // Resolve any newly-added inline FKs (column-level
6390        // REFERENCES forms) and install. Skip FKs whose local
6391        // columns we didn't have in the existing table.
6392        let table_cols_now = self
6393            .active_catalog()
6394            .get(&table_name)
6395            .expect("checked above")
6396            .schema()
6397            .columns
6398            .clone();
6399        for fk in stmt.foreign_keys {
6400            // Only install FKs whose every local column resolves
6401            // — older catalogs may have a column the new FK
6402            // references but not the column the new FK declares.
6403            let all_resolved = fk.columns.iter().all(|c| {
6404                table_cols_now
6405                    .iter()
6406                    .any(|sc| sc.name.eq_ignore_ascii_case(c))
6407            });
6408            if !all_resolved {
6409                continue;
6410            }
6411            let already_present = {
6412                let table = self
6413                    .active_catalog()
6414                    .get(&table_name)
6415                    .expect("checked above");
6416                table.schema().foreign_keys.iter().any(|f| {
6417                    f.parent_table.eq_ignore_ascii_case(&fk.parent_table)
6418                        && f.local_columns.len() == fk.columns.len()
6419                })
6420            };
6421            if already_present {
6422                continue;
6423            }
6424            let storage_fk =
6425                resolve_foreign_key(&table_name, &table_cols_now, fk, self.active_catalog())?;
6426            let table = self
6427                .active_catalog_mut()
6428                .get_mut(&table_name)
6429                .expect("checked above");
6430            table.schema_mut().foreign_keys.push(storage_fk);
6431        }
6432        Ok(QueryResult::CommandOk {
6433            affected: 0,
6434            modified_catalog: !self.in_transaction(),
6435        })
6436    }
6437
6438    /// v7.14.0 — DROP TABLE handler (pg_dump / mysqldump preamble).
6439    fn exec_drop_table(
6440        &mut self,
6441        names: Vec<String>,
6442        if_exists: bool,
6443    ) -> Result<QueryResult, EngineError> {
6444        for name in names {
6445            let dropped = self.active_catalog_mut().drop_table(&name);
6446            if !dropped && !if_exists {
6447                return Err(EngineError::Storage(StorageError::TableNotFound { name }));
6448            }
6449        }
6450        Ok(QueryResult::CommandOk {
6451            affected: 0,
6452            modified_catalog: !self.in_transaction(),
6453        })
6454    }
6455
6456    /// v7.14.0 — DROP INDEX handler.
6457    fn exec_drop_index(
6458        &mut self,
6459        name: String,
6460        if_exists: bool,
6461    ) -> Result<QueryResult, EngineError> {
6462        let dropped = self.active_catalog_mut().drop_named_index(&name);
6463        if !dropped && !if_exists {
6464            return Err(EngineError::Storage(StorageError::IndexNotFound { name }));
6465        }
6466        Ok(QueryResult::CommandOk {
6467            affected: 0,
6468            modified_catalog: !self.in_transaction(),
6469        })
6470    }
6471
6472    fn exec_create_table(
6473        &mut self,
6474        stmt: CreateTableStatement,
6475    ) -> Result<QueryResult, EngineError> {
6476        if stmt.if_not_exists && self.active_catalog().get(&stmt.name).is_some() {
6477            // v7.16.2 — PG-strict silent no-op (mailrs round-10
6478            // surfaced this). v7.13.3's "reconcile by adding
6479            // missing columns" was friendly for mailrs round-7
6480            // where init-schema's `contacts` and migrate-023's
6481            // CardDAV `contacts` collided; but it ALSO silently
6482            // added columns to existing tables when later
6483            // migrations had a duplicate `CREATE TABLE IF NOT
6484            // EXISTS <t> (different-shape-cols)` shape. mailrs's
6485            // migrate-030 has exactly that — re-declares
6486            // system_config with `key` even though init-schema
6487            // already created it with `config_key`. PG's silent
6488            // no-op leaves system_config at `config_key`;
6489            // v7.13.3 added a phantom `key` column that then
6490            // tripped migrate-040's idempotent rename guard.
6491            // mailrs v1.7.106 ships the proper PG-style
6492            // contacts rename via DO + IF EXISTS, so SPG can
6493            // revert to PG-strict here without re-breaking the
6494            // round-7 case.
6495            return Ok(QueryResult::CommandOk {
6496                affected: 0,
6497                modified_catalog: false,
6498            });
6499        }
6500        let table_name = stmt.name.clone();
6501        // v7.9.13 — pluck the names of any columns marked
6502        // `PRIMARY KEY` inline so the post-create-table pass can
6503        // build an implicit BTree index. mailrs F1.
6504        let inline_pk_columns: Vec<String> = stmt
6505            .columns
6506            .iter()
6507            .filter(|c| c.is_primary_key)
6508            .map(|c| c.name.clone())
6509            .collect();
6510        // v7.9.19 — table-level constraints: PRIMARY KEY (a, b, ...)
6511        // and UNIQUE (a, b, ...). Each builds a BTree index on the
6512        // leading column (the existing single-column storage tier)
6513        // and registers a UniquenessConstraint on the schema for
6514        // INSERT-time enforcement of the full tuple. mailrs G1/G6.
6515        let cols = stmt
6516            .columns
6517            .into_iter()
6518            .map(column_def_to_schema)
6519            .collect::<Result<Vec<_>, _>>()?;
6520        // v7.17.0 Phase 1.4 + 1.5 — classify every raw
6521        // user_type_ref (parked as user_enum_type by
6522        // column_def_to_schema) into either an enum binding or a
6523        // domain binding. For domains, also rewrite the column's
6524        // base DataType from the placeholder Text to the domain's
6525        // declared base. Unknown idents are still a hard error
6526        // here (same as Phase 1.4) so silent acceptance never
6527        // happens.
6528        let mut cols = cols;
6529        for col in cols.iter_mut() {
6530            let Some(name) = col.user_enum_type.take() else {
6531                continue;
6532            };
6533            let cat = self.active_catalog();
6534            if cat.enum_types().contains_key(&name) {
6535                col.user_enum_type = Some(name);
6536                continue;
6537            }
6538            if let Some(dom) = cat.domain_types().get(&name) {
6539                col.ty = dom.base_type;
6540                col.user_domain_type = Some(name);
6541                if !dom.nullable {
6542                    col.nullable = false;
6543                }
6544                continue;
6545            }
6546            return Err(EngineError::Unsupported(alloc::format!(
6547                "column {:?}: unknown column type {:?} (not a built-in, ENUM, or DOMAIN)",
6548                col.name,
6549                name
6550            )));
6551        }
6552        for tc in &stmt.table_constraints {
6553            if let spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } = tc {
6554                for col_name in columns {
6555                    if let Some(col) = cols.iter_mut().find(|c| c.name == *col_name) {
6556                        col.nullable = false;
6557                    }
6558                }
6559            }
6560        }
6561        // v7.6.1 — resolve every FK in the statement against the
6562        // already-known catalog. Validates: parent table exists,
6563        // parent column names exist, arity matches, parent columns
6564        // have a PK / UNIQUE index. Self-referencing FKs (parent
6565        // table == this table) resolve against the column list we
6566        // just built — they don't need the catalog yet.
6567        let mut fks: Vec<spg_storage::ForeignKeyConstraint> =
6568            Vec::with_capacity(stmt.foreign_keys.len());
6569        for fk in stmt.foreign_keys {
6570            // v7.14.0 — when SET FOREIGN_KEY_CHECKS=0 is in effect
6571            // (mysqldump preamble + bulk imports), defer FK
6572            // resolution if the parent table isn't in the catalog
6573            // yet. The FK is queued and resolved when checks flip
6574            // back on. Self-references stay in-band (the parent is
6575            // the same as the child we're building).
6576            let needs_parent = !fk.parent_table.eq_ignore_ascii_case(&table_name);
6577            if !self.foreign_key_checks
6578                && needs_parent
6579                && self.active_catalog().get(&fk.parent_table).is_none()
6580            {
6581                self.pending_foreign_keys.push((table_name.clone(), fk));
6582                continue;
6583            }
6584            fks.push(resolve_foreign_key(
6585                &table_name,
6586                &cols,
6587                fk,
6588                self.active_catalog(),
6589            )?);
6590        }
6591        let mut schema = TableSchema::new(table_name.clone(), cols);
6592        schema.foreign_keys = fks;
6593        // v7.9.19 — translate AST table_constraints to storage
6594        // UniquenessConstraints (column name → position) so the
6595        // INSERT enforcement helper sees positions directly.
6596        let mut uc_storage: Vec<spg_storage::UniquenessConstraint> = Vec::new();
6597        let mut check_exprs: Vec<String> = Vec::new();
6598        for tc in &stmt.table_constraints {
6599            let (is_pk, names, nnd) = match tc {
6600                spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } => {
6601                    (true, columns.clone(), false)
6602                }
6603                spg_sql::ast::TableConstraint::Unique {
6604                    columns,
6605                    nulls_not_distinct,
6606                    ..
6607                } => (false, columns.clone(), *nulls_not_distinct),
6608                spg_sql::ast::TableConstraint::Check { expr, .. } => {
6609                    // v7.13.0 — collect CHECK predicate sources;
6610                    // they get attached to the schema below.
6611                    check_exprs.push(alloc::format!("{expr}"));
6612                    continue;
6613                }
6614                // v7.15.0 — plain `KEY (cols)` from MySQL inline
6615                // is NOT a uniqueness constraint; skip the UC
6616                // build path entirely. The BTree index lands in
6617                // the post-create loop below alongside the PK/UQ
6618                // implicit indexes.
6619                spg_sql::ast::TableConstraint::Index { .. } => continue,
6620                // v7.17.0 Phase 2.2 — MySQL FULLTEXT KEY is not
6621                // a uniqueness constraint either; its GIN gets
6622                // built in the post-create loop below.
6623                spg_sql::ast::TableConstraint::FulltextIndex { .. } => continue,
6624            };
6625            let mut positions = Vec::with_capacity(names.len());
6626            for n in &names {
6627                let pos = schema
6628                    .columns
6629                    .iter()
6630                    .position(|c| c.name == *n)
6631                    .ok_or_else(|| {
6632                        EngineError::Unsupported(alloc::format!(
6633                            "table constraint references unknown column {n:?}"
6634                        ))
6635                    })?;
6636                positions.push(pos);
6637            }
6638            uc_storage.push(spg_storage::UniquenessConstraint {
6639                is_primary_key: is_pk,
6640                columns: positions,
6641                nulls_not_distinct: nnd,
6642            });
6643        }
6644        // v7.24 (round-16 collateral) — inline `PRIMARY KEY` column
6645        // constraints used to build only the implicit BTree index;
6646        // uniqueness was NEVER registered, so duplicate keys were
6647        // silently accepted (table-level PRIMARY KEY did enforce).
6648        // Register the same UniquenessConstraint the table-level
6649        // form gets, unless one already covers the column set.
6650        if !inline_pk_columns.is_empty() {
6651            let mut positions = Vec::with_capacity(inline_pk_columns.len());
6652            for n in &inline_pk_columns {
6653                if let Some(pos) = schema.columns.iter().position(|c| c.name == *n) {
6654                    positions.push(pos);
6655                }
6656            }
6657            if !uc_storage
6658                .iter()
6659                .any(|uc| uc.is_primary_key || uc.columns == positions)
6660            {
6661                uc_storage.push(spg_storage::UniquenessConstraint {
6662                    is_primary_key: true,
6663                    columns: positions,
6664                    nulls_not_distinct: false,
6665                });
6666            }
6667        }
6668        schema.uniqueness_constraints = uc_storage.clone();
6669        schema.checks = check_exprs;
6670        self.active_catalog_mut().create_table(schema)?;
6671        // v7.9.13 — implicit BTree per inline PK column +
6672        // v7.9.19 — implicit BTree on the leading column of every
6673        // table-level PRIMARY KEY / UNIQUE constraint.
6674        let table = self
6675            .active_catalog_mut()
6676            .get_mut(&table_name)
6677            .expect("just created");
6678        for (i, col_name) in inline_pk_columns.iter().enumerate() {
6679            let idx_name = if inline_pk_columns.len() == 1 {
6680                alloc::format!("{table_name}_pkey")
6681            } else {
6682                alloc::format!("{table_name}_pkey_{i}")
6683            };
6684            if let Err(e) = table.add_index(idx_name, col_name) {
6685                return Err(EngineError::Storage(e));
6686            }
6687        }
6688        for (i, tc) in stmt.table_constraints.iter().enumerate() {
6689            // v7.17.0 Phase 2.2 — FULLTEXT KEY lands a real
6690            // tsvector-GIN per declared column instead of the
6691            // BTree the PK / UQ / KEY paths build. Branch early
6692            // so the BTree loop never sees the FULLTEXT shape.
6693            if let spg_sql::ast::TableConstraint::FulltextIndex { name, columns } = tc {
6694                for (k, col) in columns.iter().enumerate() {
6695                    let already = table.indices().iter().any(|idx| {
6696                        matches!(idx.kind, spg_storage::IndexKind::GinFulltext(_))
6697                            && table.schema().columns[idx.column_position].name == *col
6698                    });
6699                    if already {
6700                        continue;
6701                    }
6702                    let idx_name = match (name.as_ref(), columns.len(), k) {
6703                        (Some(n), 1, _) => n.clone(),
6704                        (Some(n), _, k) => alloc::format!("{n}_{k}"),
6705                        (None, _, _) => {
6706                            alloc::format!("{table_name}_{col}_ftidx")
6707                        }
6708                    };
6709                    if let Err(e) = table.add_gin_fulltext_index(idx_name, col) {
6710                        return Err(EngineError::Storage(e));
6711                    }
6712                }
6713                continue;
6714            }
6715            // v7.15.0 — plain KEY/INDEX rides this same loop so
6716            // the implicit BTree gets built. It carries its own
6717            // user-supplied name; PK/UQ still synthesise.
6718            let (suffix, names, explicit_name): (&str, &Vec<String>, Option<&String>) = match tc {
6719                spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } => {
6720                    ("pkey", columns, None)
6721                }
6722                spg_sql::ast::TableConstraint::Unique { columns, .. } => ("key", columns, None),
6723                spg_sql::ast::TableConstraint::Index { name, columns } => {
6724                    ("idx", columns, name.as_ref())
6725                }
6726                spg_sql::ast::TableConstraint::Check { .. } => continue,
6727                // Handled by the early-branch above.
6728                spg_sql::ast::TableConstraint::FulltextIndex { .. } => continue,
6729            };
6730            let leading = &names[0];
6731            // Skip if a same-column BTree already exists (e.g.
6732            // inline PK on the leading column).
6733            let already = table.indices().iter().any(|idx| {
6734                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
6735                    && table.schema().columns[idx.column_position].name == *leading
6736            });
6737            if already {
6738                continue;
6739            }
6740            let idx_name = if let Some(n) = explicit_name {
6741                n.clone()
6742            } else if names.len() == 1 {
6743                alloc::format!("{table_name}_{leading}_{suffix}")
6744            } else {
6745                alloc::format!("{table_name}_{leading}_{suffix}_{i}")
6746            };
6747            if let Err(e) = table.add_index(idx_name, leading) {
6748                return Err(EngineError::Storage(e));
6749            }
6750        }
6751        Ok(QueryResult::CommandOk {
6752            affected: 0,
6753            modified_catalog: !self.in_transaction(),
6754        })
6755    }
6756
6757    fn exec_insert(&mut self, mut stmt: InsertStatement) -> Result<QueryResult, EngineError> {
6758        // v7.17.0 Phase 1.1 — pre-resolve any nextval / currval /
6759        // setval calls against the catalog before the row loop. We
6760        // walk each tuple expression and replace matching
6761        // FunctionCall nodes with their concrete Literal. This
6762        // keeps `literal_expr_to_value` free of `&mut self` and
6763        // lets multi-row INSERT VALUES (… nextval('seq') …)
6764        // mint a separate sequence value per row.
6765        for tuple in &mut stmt.rows {
6766            for cell in tuple.iter_mut() {
6767                self.resolve_sequence_calls_in_expr(cell)?;
6768            }
6769        }
6770        // v7.13.0 — `INSERT INTO t [(cols)] SELECT …` (mailrs
6771        // round-5 G4). Execute the inner SELECT first, then route
6772        // back through the regular VALUES code path with the
6773        // materialised rows.
6774        if let Some(select) = stmt.select_source.clone() {
6775            let select_result = self.exec_select_cancel(&select, CancelToken::none())?;
6776            let rows = match select_result {
6777                QueryResult::Rows { rows, .. } => rows,
6778                other => {
6779                    return Err(EngineError::Unsupported(alloc::format!(
6780                        "INSERT … SELECT: inner statement produced {other:?} instead of a row set"
6781                    )));
6782                }
6783            };
6784            let mut materialised: Vec<Vec<Expr>> = Vec::with_capacity(rows.len());
6785            for row in rows {
6786                let mut tuple: Vec<Expr> = Vec::with_capacity(row.values.len());
6787                for v in row.values {
6788                    tuple.push(value_to_literal_expr_permissive(v)?);
6789                }
6790                materialised.push(tuple);
6791            }
6792            let recurse = InsertStatement {
6793                table: stmt.table,
6794                columns: stmt.columns,
6795                rows: materialised,
6796                select_source: None,
6797                on_conflict: stmt.on_conflict,
6798                returning: stmt.returning,
6799            };
6800            return self.exec_insert(recurse);
6801        }
6802        // v7.9.21 — snapshot the clock fn pointer before the mut
6803        // borrow on the catalog opens; runtime DEFAULT eval needs
6804        // it inside the row hot loop.
6805        let clock = self.clock;
6806        // v7.12.4 — snapshot row-level triggers + their referenced
6807        // functions before the mut borrow on the catalog opens.
6808        // Cloned out so the row hot loop can fire them without
6809        // re-borrowing the catalog (which would conflict with
6810        // table.insert's mutable borrow).
6811        let before_insert_triggers = self.snapshot_row_triggers(&stmt.table, "INSERT", "BEFORE");
6812        let after_insert_triggers = self.snapshot_row_triggers(&stmt.table, "INSERT", "AFTER");
6813        let trigger_session_cfg: Option<alloc::string::String> = self
6814            .session_params
6815            .get("default_text_search_config")
6816            .cloned();
6817        // v7.17.0 Phase 1.4 — snapshot the enum label lookup BEFORE
6818        // opening the mutable borrow on the table below. We need
6819        // catalog-level read access (enum_types lives at the
6820        // catalog level, not the table) and the upcoming mutable
6821        // borrow shadows it.
6822        let pre_borrow_column_meta: Vec<ColumnSchema> = {
6823            let preview_table = self.active_catalog().get(&stmt.table).ok_or_else(|| {
6824                EngineError::Storage(StorageError::TableNotFound {
6825                    name: stmt.table.clone(),
6826                })
6827            })?;
6828            preview_table.schema().columns.clone()
6829        };
6830        let enum_label_lookup: alloc::collections::BTreeMap<usize, Vec<String>> =
6831            pre_borrow_column_meta
6832                .iter()
6833                .enumerate()
6834                .filter_map(|(i, col)| {
6835                    // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM
6836                    // variant lists take priority over the PG
6837                    // catalog enum_types lookup (they're
6838                    // column-local and authoritative when set).
6839                    if let Some(inline) = &col.inline_enum_variants {
6840                        return Some((i, inline.clone()));
6841                    }
6842                    col.user_enum_type.as_ref().and_then(|ename| {
6843                        self.active_catalog()
6844                            .enum_types()
6845                            .get(ename)
6846                            .map(|e| (i, e.labels.clone()))
6847                    })
6848                })
6849                .collect();
6850        // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant lists.
6851        // Distinct from enum_label_lookup: SET validates that
6852        // every comma-separated token is in the variant list, and
6853        // canonicalises the cell to definition-order de-duped text.
6854        let set_variant_lookup: alloc::collections::BTreeMap<usize, Vec<String>> =
6855            pre_borrow_column_meta
6856                .iter()
6857                .enumerate()
6858                .filter_map(|(i, col)| col.inline_set_variants.as_ref().map(|vs| (i, vs.clone())))
6859                .collect();
6860        // v7.29 (round-23a) - when the column's implicit sequence
6861        // exists (born on first nextval/setval address), a setval
6862        // above the table MAX moves the next auto-assigned id:
6863        // assign from max(table_max + 1, last_value + 1). Tables
6864        // whose sequence was never addressed keep the bare max+1
6865        // path (identical pre-7.29 behaviour, no lookup cost
6866        // beyond one map probe per auto column per statement).
6867        let mut seq_floors: alloc::collections::BTreeMap<usize, i64> =
6868            alloc::collections::BTreeMap::new();
6869        for (i, col) in pre_borrow_column_meta.iter().enumerate() {
6870            if col.auto_increment
6871                && let Some(sd) = self.active_catalog().sequences().get(&alloc::format!(
6872                    "{}_{}_seq",
6873                    stmt.table,
6874                    col.name
6875                ))
6876            {
6877                // is_called=false (fresh RESTART / setval(_, false))
6878                // means the NEXT value is last_value itself.
6879                let floor = if sd.is_called {
6880                    sd.last_value + 1
6881                } else {
6882                    sd.last_value
6883                };
6884                seq_floors.insert(i, floor);
6885            }
6886        }
6887        let table = self
6888            .active_catalog_mut()
6889            .get_mut(&stmt.table)
6890            .ok_or_else(|| {
6891                EngineError::Storage(StorageError::TableNotFound {
6892                    name: stmt.table.clone(),
6893                })
6894            })?;
6895        // v3.1.5: clone the columns vector only (not the whole
6896        // TableSchema — saves one String alloc for the table name).
6897        // We need an owned snapshot because we'll call `table.insert`
6898        // (mutable borrow on `table`) inside the row loop while
6899        // reading schema fields.
6900        let column_meta: Vec<ColumnSchema> = table.schema().columns.clone();
6901        let schema_cols_len = column_meta.len();
6902        // Build a permutation `tuple_pos[c] = Some(j)` meaning schema
6903        // column `c` is filled from the `j`-th tuple slot; `None` means
6904        // "fill with NULL". Validated once and reused for every row.
6905        let tuple_pos: Option<Vec<Option<usize>>> = match &stmt.columns {
6906            None => None, // 1-1 mapping, fast path
6907            Some(cols) => {
6908                let mut map = alloc::vec![None; schema_cols_len];
6909                for (j, name) in cols.iter().enumerate() {
6910                    let idx = column_meta
6911                        .iter()
6912                        .position(|c| c.name == *name)
6913                        .ok_or_else(|| {
6914                            EngineError::Eval(EvalError::ColumnNotFound { name: name.clone() })
6915                        })?;
6916                    if map[idx].is_some() {
6917                        return Err(EngineError::Storage(StorageError::ArityMismatch {
6918                            expected: schema_cols_len,
6919                            actual: cols.len(),
6920                        }));
6921                    }
6922                    map[idx] = Some(j);
6923                }
6924                // Omitted columns must either be nullable, carry a
6925                // DEFAULT, or be AUTO_INCREMENT. Catch NOT NULL
6926                // omissions up front so the WAL stays clean.
6927                for (i, col) in column_meta.iter().enumerate() {
6928                    if map[i].is_none()
6929                        && !col.nullable
6930                        && col.default.is_none()
6931                        && col.runtime_default.is_none()
6932                        && !col.auto_increment
6933                    {
6934                        return Err(EngineError::Storage(StorageError::NullInNotNull {
6935                            column: col.name.clone(),
6936                        }));
6937                    }
6938                }
6939                Some(map)
6940            }
6941        };
6942        let expected_tuple_len = stmt.columns.as_ref().map_or(schema_cols_len, Vec::len);
6943        // v7.6.2 — snapshot this table's FK list before the
6944        // mutable-borrow window so we can run parent lookups
6945        // against the immutable catalog after parsing. Empty vec is
6946        // the no-FK fast path; clone cost is O(fks * arity) which
6947        // is < 100 ns for typical schemas.
6948        let fks = table.schema().foreign_keys.clone();
6949        let mut affected = 0usize;
6950        // Stage 1 — parse + AUTO_INC + coerce all rows under the
6951        // single mutable borrow.
6952        let mut all_values: Vec<Vec<Value>> = Vec::with_capacity(stmt.rows.len());
6953        // v7.24 (round-16 collateral) — statement-scoped serial
6954        // cursors. next_auto_value() is a max+1 scan over COMMITTED
6955        // rows; multi-row `INSERT … VALUES (…),(…)` computed it per
6956        // tuple BEFORE any insertion, so every row drew the SAME id
6957        // (then sailed through, compounding with the inline-PK
6958        // enforcement gap). First use per column seeds from the
6959        // table; subsequent rows increment.
6960        let mut auto_cursors: alloc::collections::BTreeMap<usize, i64> =
6961            alloc::collections::BTreeMap::new();
6962        for tuple in stmt.rows {
6963            if tuple.len() != expected_tuple_len {
6964                return Err(EngineError::Storage(StorageError::ArityMismatch {
6965                    expected: expected_tuple_len,
6966                    actual: tuple.len(),
6967                }));
6968            }
6969            // Fast path: no column-list permutation → tuple slot j
6970            // maps to schema column j. We can zip schema with tuple
6971            // and skip the `raw_tuple` staging allocation entirely.
6972            let values: Vec<Value> = if let Some(map) = &tuple_pos {
6973                // Permuted path: still need raw_tuple to index by `map[i]`.
6974                let raw_tuple: Vec<Value> = tuple
6975                    .into_iter()
6976                    .map(literal_expr_to_value)
6977                    .collect::<Result<_, _>>()?;
6978                let mut out = Vec::with_capacity(schema_cols_len);
6979                for (i, col) in column_meta.iter().enumerate() {
6980                    let mut raw = match map[i] {
6981                        Some(j) => raw_tuple[j].clone(),
6982                        None => resolve_column_default_free(col, clock)?,
6983                    };
6984                    if col.auto_increment && raw.is_null() {
6985                        let next = match auto_cursors.get(&i) {
6986                            Some(n) => *n,
6987                            None => {
6988                                let base = table.next_auto_value(i).ok_or_else(|| {
6989                                    EngineError::Unsupported(alloc::format!(
6990                                        "AUTO_INCREMENT applies to integer columns only (column `{}`)",
6991                                        col.name
6992                                    ))
6993                                })?;
6994                                base.max(seq_floors.get(&i).copied().unwrap_or(i64::MIN))
6995                            }
6996                        };
6997                        auto_cursors.insert(i, next + 1);
6998                        raw = Value::BigInt(next);
6999                    }
7000                    let coerced = coerce_value(raw, col.ty, &col.name, i)?;
7001                    enforce_enum_label(&enum_label_lookup, i, &col.name, &coerced)?;
7002                    let coerced =
7003                        canonicalize_set_value(&set_variant_lookup, i, &col.name, coerced)?;
7004                    check_unsigned_range(&coerced, col, i)?;
7005                    out.push(coerced);
7006                }
7007                out
7008            } else {
7009                // 1-1 mapping fast path: single Vec alloc, no raw_tuple.
7010                let mut out = Vec::with_capacity(schema_cols_len);
7011                for (i, (col, expr)) in column_meta.iter().zip(tuple).enumerate() {
7012                    let mut raw = literal_expr_to_value(expr)?;
7013                    if col.auto_increment && raw.is_null() {
7014                        let next = match auto_cursors.get(&i) {
7015                            Some(n) => *n,
7016                            None => {
7017                                let base = table.next_auto_value(i).ok_or_else(|| {
7018                                    EngineError::Unsupported(alloc::format!(
7019                                        "AUTO_INCREMENT applies to integer columns only (column `{}`)",
7020                                        col.name
7021                                    ))
7022                                })?;
7023                                base.max(seq_floors.get(&i).copied().unwrap_or(i64::MIN))
7024                            }
7025                        };
7026                        auto_cursors.insert(i, next + 1);
7027                        raw = Value::BigInt(next);
7028                    }
7029                    let coerced = coerce_value(raw, col.ty, &col.name, i)?;
7030                    enforce_enum_label(&enum_label_lookup, i, &col.name, &coerced)?;
7031                    let coerced =
7032                        canonicalize_set_value(&set_variant_lookup, i, &col.name, coerced)?;
7033                    check_unsigned_range(&coerced, col, i)?;
7034                    out.push(coerced);
7035                }
7036                out
7037            };
7038            all_values.push(values);
7039        }
7040        // Stage 2 — FK enforcement on the immutable catalog.
7041        // Non-lexical lifetimes release the mutable borrow on
7042        // `table` here since stage 1 was the last use. The
7043        // parent-table lookup runs before any row is committed.
7044        let uniqueness = table.schema().uniqueness_constraints.clone();
7045        let _ = table;
7046        if !fks.is_empty() {
7047            enforce_fk_inserts(self.active_catalog(), &stmt.table, &fks, &all_values)?;
7048        }
7049        // v7.13.0 — CHECK constraint enforcement (mailrs round-5 G3).
7050        enforce_check_constraints(self.active_catalog(), &stmt.table, &all_values)?;
7051        // NOTE (mailrs embed round-12): UNIQUE / PRIMARY KEY and
7052        // UNIQUE INDEX enforcement moved BELOW the ON CONFLICT
7053        // resolution pass. Running them first made every
7054        // `ON CONFLICT … DO UPDATE` upsert fail with a uniqueness
7055        // violation before the conflict handler could route the row
7056        // to an UPDATE — PG resolves the conflict action first and
7057        // only errors on rows no arbiter matched.
7058        // v7.9.8 / v7.9.9 — ON CONFLICT handling.
7059        //   - `DO NOTHING` filters `all_values` to non-conflicting
7060        //     rows + drops within-batch duplicates.
7061        //   - `DO UPDATE SET …` ALSO filters, but for each
7062        //     conflicting row it queues an UPDATE on the existing
7063        //     row using the incoming row's values as `EXCLUDED.*`.
7064        let mut pending_updates: Vec<(usize, Vec<Value>)> = Vec::new();
7065        let mut skipped_count = 0usize;
7066        if let Some(clause) = &stmt.on_conflict {
7067            let (conflict_cols, conflict_nnd) = resolve_on_conflict_columns(
7068                self.active_catalog(),
7069                &stmt.table,
7070                clause.target_columns.as_slice(),
7071            )?;
7072            let mut kept: Vec<Vec<Value>> = Vec::with_capacity(all_values.len());
7073            let mut seen_keys: Vec<Vec<Value>> = Vec::new();
7074            for values in all_values {
7075                let key_tuple: Vec<&Value> = conflict_cols.iter().map(|&c| &values[c]).collect();
7076                // SQL spec: NULL in any conflict column means "no
7077                // conflict possible" (NULL ≠ NULL for uniqueness) —
7078                // UNLESS the constraint says NULLS NOT DISTINCT
7079                // (v7.29; mailrs migrate-013 replays its seed row
7080                // ('super', NULL) under exactly that declaration).
7081                let has_null_key =
7082                    !conflict_nnd && key_tuple.iter().any(|v| matches!(v, Value::Null));
7083                let collides_with_table = !has_null_key
7084                    && on_conflict_keys_exist(
7085                        self.active_catalog(),
7086                        &stmt.table,
7087                        &conflict_cols,
7088                        &key_tuple,
7089                    );
7090                let key_tuple_owned: Vec<Value> = key_tuple.iter().map(|v| (*v).clone()).collect();
7091                let collides_with_batch =
7092                    !has_null_key && seen_keys.iter().any(|k| k == &key_tuple_owned);
7093                let collides = collides_with_table || collides_with_batch;
7094                match (&clause.action, collides) {
7095                    (_, false) => {
7096                        seen_keys.push(key_tuple_owned);
7097                        kept.push(values);
7098                    }
7099                    (spg_sql::ast::OnConflictAction::Nothing, true) => {
7100                        skipped_count += 1;
7101                    }
7102                    (
7103                        spg_sql::ast::OnConflictAction::Update {
7104                            assignments,
7105                            where_,
7106                        },
7107                        true,
7108                    ) => {
7109                        if !collides_with_table {
7110                            skipped_count += 1;
7111                            continue;
7112                        }
7113                        let target_pos = lookup_row_position_by_keys(
7114                            self.active_catalog(),
7115                            &stmt.table,
7116                            &conflict_cols,
7117                            &key_tuple,
7118                        )
7119                        .ok_or_else(|| {
7120                            EngineError::Unsupported(
7121                                "ON CONFLICT DO UPDATE: conflict detected but row \
7122                                 position could not be resolved (cold-tier row?)"
7123                                    .into(),
7124                            )
7125                        })?;
7126                        let updated = apply_on_conflict_assignments(
7127                            self.active_catalog(),
7128                            &stmt.table,
7129                            target_pos,
7130                            &values,
7131                            assignments,
7132                            where_.as_ref(),
7133                        )?;
7134                        if let Some(new_row) = updated {
7135                            pending_updates.push((target_pos, new_row));
7136                        } else {
7137                            skipped_count += 1;
7138                        }
7139                    }
7140                }
7141            }
7142            all_values = kept;
7143        }
7144        // v7.9.19 — composite UNIQUE / PRIMARY KEY enforcement.
7145        // v7.9.29 — CREATE UNIQUE INDEX [WHERE pred] enforcement.
7146        // Both run on the post-ON-CONFLICT row set: conflicting rows
7147        // already left `all_values` (DO NOTHING drop / DO UPDATE
7148        // reroute), so what remains must be genuinely unique.
7149        enforce_uniqueness_inserts(self.active_catalog(), &stmt.table, &uniqueness, &all_values)?;
7150        enforce_unique_index_inserts(self.active_catalog(), &stmt.table, &all_values)?;
7151        // Stage 3 — insert all rows under a fresh mutable borrow.
7152        let table = self
7153            .active_catalog_mut()
7154            .get_mut(&stmt.table)
7155            .ok_or_else(|| {
7156                EngineError::Storage(StorageError::TableNotFound {
7157                    name: stmt.table.clone(),
7158                })
7159            })?;
7160        // v7.9.4 — keep RETURNING projection rows separate per
7161        // INSERT and per UPDATE branch so DO UPDATE pushes the new
7162        // post-update state, not the incoming-only values.
7163        let mut returning_rows: Vec<Vec<Value>> = Vec::new();
7164        // v7.12.7 — collect embedded SQL emitted by any trigger
7165        // fire across the row loop; engine drains the queue after
7166        // the table mut borrow drops.
7167        let mut deferred_embedded: Vec<triggers::DeferredEmbeddedStmt> = Vec::new();
7168        'rowloop: for values in all_values {
7169            let mut row = Row::new(values);
7170            // v7.12.4 — BEFORE INSERT row-level triggers. Each
7171            // trigger may rewrite NEW cells (e.g. populate
7172            // `search_vector := to_tsvector(...)`) and may return
7173            // NULL to skip the row entirely.
7174            for fd in &before_insert_triggers {
7175                let (outcome, deferred) = triggers::fire_row_trigger(
7176                    fd,
7177                    Some(row.clone()),
7178                    None,
7179                    &stmt.table,
7180                    &column_meta,
7181                    &[],
7182                    trigger_session_cfg.as_deref(),
7183                    false,
7184                )
7185                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
7186                deferred_embedded.extend(deferred);
7187                match outcome {
7188                    triggers::TriggerOutcome::Row(r) => row = r,
7189                    triggers::TriggerOutcome::Skip => continue 'rowloop,
7190                }
7191            }
7192            if stmt.returning.is_some() {
7193                returning_rows.push(row.values.clone());
7194            }
7195            // v7.12.4 — clone for the AFTER trigger view; insert
7196            // moves the row into the table.
7197            let inserted = row.clone();
7198            table.insert(row)?;
7199            affected += 1;
7200            // v7.12.4 — AFTER INSERT row-level triggers fire post-
7201            // write. Return value is ignored (PG semantics); we
7202            // surface any error from the body up to the caller.
7203            for fd in &after_insert_triggers {
7204                let (_outcome, deferred) = triggers::fire_row_trigger(
7205                    fd,
7206                    Some(inserted.clone()),
7207                    None,
7208                    &stmt.table,
7209                    &column_meta,
7210                    &[],
7211                    trigger_session_cfg.as_deref(),
7212                    true,
7213                )
7214                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
7215                deferred_embedded.extend(deferred);
7216            }
7217        }
7218        // v7.9.9 — apply ON CONFLICT DO UPDATE rewrites collected
7219        // in the conflict-resolution pass. update_row handles
7220        // index maintenance + body re-encoding.
7221        for (pos, new_row) in pending_updates {
7222            if stmt.returning.is_some() {
7223                returning_rows.push(new_row.clone());
7224            }
7225            table.update_row(pos, new_row)?;
7226            affected += 1;
7227        }
7228        let _ = skipped_count;
7229        // v7.12.7 — drop the table mut borrow and drain any
7230        // trigger-emitted embedded SQL queued during this INSERT.
7231        // The borrow has to release first because each deferred
7232        // stmt may UPDATE / INSERT / DELETE the same (or another)
7233        // table — including, in principle, this one.
7234        let _ = table;
7235        self.execute_deferred_trigger_stmts(deferred_embedded, CancelToken::none())?;
7236        // v7.9.4/v7.9.9 — RETURNING streams the rows that ended
7237        // up in the table after this statement (insert or
7238        // post-update on conflict).
7239        if let Some(items) = &stmt.returning {
7240            return self.build_returning_rows(&stmt.table, items, returning_rows);
7241        }
7242        // v6.2.1 — auto-analyze: track per-table modified-row
7243        // counter so the background sweep can decide when to
7244        // re-ANALYZE. Cheap path on the autocommit-wrap hot loop
7245        // — one BTreeMap entry update per INSERT batch.
7246        if !self.in_transaction() && affected > 0 {
7247            self.statistics
7248                .record_modifications(&stmt.table, affected as u64);
7249        }
7250        Ok(QueryResult::CommandOk {
7251            affected,
7252            modified_catalog: !self.in_transaction(),
7253        })
7254    }
7255
7256    /// v4.5: SELECT with cooperative cancellation. The token is
7257    /// honoured between UNION peers and inside the bare-SELECT row
7258    /// loop; HNSW kNN graph walks and the aggregate executor don't
7259    /// honour it yet (deferred — those paths bound their work
7260    /// internally by `LIMIT k` and `GROUP BY` cardinality).
7261    /// v6.10.2 — cold-tier time-travel scan. Resolves the segment
7262    /// by id, decodes each row body against the table's current
7263    /// schema, applies the SELECT's projection + optional WHERE +
7264    /// optional LIMIT, returns a `Rows` result. JOINs / aggregates
7265    /// / ORDER BY are unsupported on this path (STABILITY carve-
7266    /// out); operators wanting them should restore the segment
7267    /// into a regular table first.
7268    fn exec_select_as_of_segment(
7269        &self,
7270        stmt: &SelectStatement,
7271        from: &spg_sql::ast::FromClause,
7272        segment_id: u32,
7273    ) -> Result<QueryResult, EngineError> {
7274        // v6.10.2 scope: no joins, no aggregates, no ORDER BY,
7275        // no GROUP BY / HAVING / UNION / OFFSET / DISTINCT.
7276        if !from.joins.is_empty()
7277            || stmt.group_by.is_some()
7278            || stmt.having.is_some()
7279            || !stmt.unions.is_empty()
7280            || !stmt.order_by.is_empty()
7281            || stmt.offset.is_some()
7282            || stmt.distinct
7283            || aggregate::uses_aggregate(stmt)
7284        {
7285            return Err(EngineError::Unsupported(
7286                "AS OF SEGMENT supports SELECT projection + WHERE + LIMIT only \
7287                 (joins / aggregates / ORDER BY are STABILITY § \"Out of v6.10\")"
7288                    .into(),
7289            ));
7290        }
7291        let table = self
7292            .active_catalog()
7293            .get(&from.primary.name)
7294            .ok_or_else(|| StorageError::TableNotFound {
7295                name: from.primary.name.clone(),
7296            })?;
7297        let schema = table.schema().clone();
7298        let schema_cols = &schema.columns;
7299        let alias = from
7300            .primary
7301            .alias
7302            .as_deref()
7303            .unwrap_or(from.primary.name.as_str());
7304        let ctx = EvalContext::new(schema_cols, Some(alias));
7305        let seg = self
7306            .active_catalog()
7307            .cold_segment(segment_id)
7308            .ok_or_else(|| {
7309                EngineError::Unsupported(alloc::format!(
7310                    "AS OF SEGMENT: cold segment {segment_id} not registered"
7311                ))
7312            })?;
7313        let mut out_rows: Vec<Row> = Vec::new();
7314        let mut limit_remaining: Option<usize> =
7315            stmt.limit_literal().and_then(|n| usize::try_from(n).ok());
7316        for (_key, body) in seg.scan() {
7317            let (row, _consumed) =
7318                spg_storage::decode_row_body_dense(&body, &schema, seg.codec_version())
7319                    .map_err(EngineError::Storage)?;
7320            if let Some(where_expr) = &stmt.where_ {
7321                let cond = self.eval_expr_simple(where_expr, &row, &ctx)?;
7322                if !matches!(cond, Value::Bool(true)) {
7323                    continue;
7324                }
7325            }
7326            // Projection.
7327            let projected = self.project_row_simple(&row, &stmt.items, schema_cols, alias)?;
7328            out_rows.push(projected);
7329            if let Some(rem) = limit_remaining.as_mut() {
7330                if *rem == 0 {
7331                    out_rows.pop();
7332                    break;
7333                }
7334                *rem -= 1;
7335            }
7336        }
7337        // Output column schema: derive from SELECT items.
7338        let columns = self.derive_output_columns(&stmt.items, schema_cols, alias);
7339        Ok(QueryResult::Rows {
7340            columns,
7341            rows: out_rows,
7342        })
7343    }
7344
7345    /// v6.10.2 — simple-path WHERE eval that doesn't go through
7346    /// the correlated-subquery / Memoize machinery. AS OF SEGMENT
7347    /// scan paths predicate against a snapshot frozen segment, no
7348    /// cross-row state.
7349    fn eval_expr_simple(
7350        &self,
7351        expr: &Expr,
7352        row: &Row,
7353        ctx: &EvalContext,
7354    ) -> Result<Value, EngineError> {
7355        let cancel = CancelToken::none();
7356        self.eval_expr_with_correlated(expr, row, ctx, cancel, None)
7357    }
7358
7359    /// v7.9.4 — INSERT / UPDATE / DELETE RETURNING projector.
7360    /// Given the table name, the user-supplied projection items,
7361    /// and the mutated rows (post-insert / post-update values, or
7362    /// pre-delete snapshot), build a `QueryResult::Rows` whose
7363    /// schema describes the projected columns. Mailrs migration
7364    /// blocker #1.
7365    fn build_returning_rows(
7366        &self,
7367        table_name: &str,
7368        items: &[SelectItem],
7369        mutated_rows: Vec<Vec<Value>>,
7370    ) -> Result<QueryResult, EngineError> {
7371        let table = self.active_catalog().get(table_name).ok_or_else(|| {
7372            EngineError::Storage(StorageError::TableNotFound {
7373                name: table_name.into(),
7374            })
7375        })?;
7376        let schema_cols = table.schema().columns.clone();
7377        let columns = self.derive_output_columns(items, &schema_cols, table_name);
7378        let mut out_rows: Vec<Row> = Vec::with_capacity(mutated_rows.len());
7379        for values in mutated_rows {
7380            let row = Row::new(values);
7381            let projected = self.project_row_simple(&row, items, &schema_cols, table_name)?;
7382            out_rows.push(projected);
7383        }
7384        Ok(QueryResult::Rows {
7385            columns,
7386            rows: out_rows,
7387        })
7388    }
7389
7390    /// v6.10.2 — projection for AS OF SEGMENT. Resolves
7391    /// `SelectItem::Wildcard` to all schema columns and
7392    /// `SelectItem::Expr` via the regular eval path.
7393    fn project_row_simple(
7394        &self,
7395        row: &Row,
7396        items: &[SelectItem],
7397        schema_cols: &[ColumnSchema],
7398        alias: &str,
7399    ) -> Result<Row, EngineError> {
7400        let ctx = EvalContext::new(schema_cols, Some(alias));
7401        let cancel = CancelToken::none();
7402        let mut out_vals = Vec::new();
7403        for item in items {
7404            match item {
7405                SelectItem::Wildcard => {
7406                    out_vals.extend(row.values.iter().cloned());
7407                }
7408                SelectItem::Expr { expr, .. } => {
7409                    let v = self.eval_expr_with_correlated(expr, row, &ctx, cancel, None)?;
7410                    out_vals.push(v);
7411                }
7412            }
7413        }
7414        Ok(Row::new(out_vals))
7415    }
7416
7417    /// v6.10.2 — derive the output `ColumnSchema` list for an
7418    /// AS OF SEGMENT projection. Wildcards take the full schema;
7419    /// expressions take the alias if present or a synthetic
7420    /// `?column?` (PG convention) otherwise.
7421    fn derive_output_columns(
7422        &self,
7423        items: &[SelectItem],
7424        schema_cols: &[ColumnSchema],
7425        _alias: &str,
7426    ) -> Vec<ColumnSchema> {
7427        let mut out = Vec::new();
7428        for item in items {
7429            match item {
7430                SelectItem::Wildcard => {
7431                    out.extend(schema_cols.iter().cloned());
7432                }
7433                SelectItem::Expr { expr, alias } => {
7434                    // Bare column references inherit the schema
7435                    // column's name + type — PG names `RETURNING id`
7436                    // "id" and types it BIGINT, and the sqlx embed
7437                    // path type-checks RowDescription against the
7438                    // Rust target (mailrs embed round-12).
7439                    if let Expr::Column(col) = expr
7440                        && let Some(sc) = schema_cols.iter().find(|c| c.name == col.name)
7441                    {
7442                        let name = alias.clone().unwrap_or_else(|| sc.name.clone());
7443                        out.push(ColumnSchema::new(name, sc.ty, sc.nullable));
7444                        continue;
7445                    }
7446                    let name = alias.clone().unwrap_or_else(|| "?column?".to_string());
7447                    // Default to Text; the caller's row values
7448                    // carry the actual type. v6.10.2 scope.
7449                    out.push(ColumnSchema::new(name, DataType::Text, true));
7450                }
7451            }
7452        }
7453        out
7454    }
7455
7456    fn exec_select_cancel(
7457        &self,
7458        stmt: &SelectStatement,
7459        cancel: CancelToken<'_>,
7460    ) -> Result<QueryResult, EngineError> {
7461        cancel.check()?;
7462        // v7.17.0 Phase 1.2 — user-defined VIEW expansion. If the
7463        // FROM / JOIN graph references any catalogued view name,
7464        // re-parse the view body and prepend it as a synthetic
7465        // CTE. Recurses on views-in-views via the regular CTE
7466        // dispatch below. Fast-path: skip the walker entirely when
7467        // the catalog has no views (the typical OLTP load).
7468        if !self.active_catalog().views().is_empty() {
7469            if let Some(rewritten) = self.expand_views_in_select(stmt)? {
7470                return self.exec_select_cancel(&rewritten, cancel);
7471            }
7472        }
7473        // v7.16.2 — information_schema / pg_catalog virtual
7474        // views (mailrs round-10 A.3). If the SELECT touches a
7475        // synthetic meta-table name (`__spg_info_*` /
7476        // `__spg_pg_*` — produced by the parser for
7477        // `information_schema.X` / `pg_catalog.X`), clone the
7478        // catalog, materialise the requested view as a real
7479        // temporary table, and re-execute against an enriched
7480        // engine. Same pattern as `exec_with_ctes` for CTEs.
7481        if !self.meta_views_materialised && select_references_meta_view(stmt) {
7482            return self.exec_select_with_meta_views(stmt, cancel);
7483        }
7484        // v6.10.2 — cold-tier time-travel short-circuit. When the
7485        // primary TableRef carries `AS OF SEGMENT '<id>'`, run a
7486        // dedicated cold-segment scan instead of the regular
7487        // hot+index path. The scope is intentionally narrow for
7488        // v6.10.2 — bare `SELECT * FROM <t> AS OF SEGMENT 'id'`,
7489        // optionally with a single-column-equality WHERE. JOINs /
7490        // aggregates / ORDER BY / subqueries on top of a time-
7491        // travelled scan are STABILITY § "Out of v6.10".
7492        if let Some(from) = &stmt.from
7493            && let Some(seg_id) = from.primary.as_of_segment
7494        {
7495            return self.exec_select_as_of_segment(stmt, from, seg_id);
7496        }
7497        // v6.2.0 / v6.5.0 — virtual-table short-circuits. Detected
7498        // pre-CTE because they don't read from the catalog and
7499        // shouldn't participate in regular FROM resolution.
7500        if let Some(from) = &stmt.from
7501            && from.joins.is_empty()
7502            && stmt.where_.is_none()
7503            && stmt.group_by.is_none()
7504            && stmt.having.is_none()
7505            && stmt.unions.is_empty()
7506            && stmt.order_by.is_empty()
7507            && stmt.limit.is_none()
7508            && stmt.offset.is_none()
7509            && !stmt.distinct
7510            && stmt.items.iter().all(|i| matches!(i, SelectItem::Wildcard))
7511        {
7512            let lower = from.primary.name.to_ascii_lowercase();
7513            match lower.as_str() {
7514                "spg_statistic" => return Ok(self.exec_spg_statistic()),
7515                // v6.5.0 — observability v2 virtual tables.
7516                "spg_stat_replication" => return Ok(self.exec_spg_stat_replication()),
7517                "spg_stat_segment" => return Ok(self.exec_spg_stat_segment()),
7518                "spg_stat_query" => return Ok(self.exec_spg_stat_query()),
7519                "spg_stat_activity" => return Ok(self.exec_spg_stat_activity()),
7520                "spg_audit_chain" => return Ok(self.exec_spg_audit_chain()),
7521                "spg_audit_verify" => return Ok(self.exec_spg_audit_verify()),
7522                "spg_table_ddl" => return Ok(self.exec_spg_table_ddl()),
7523                "spg_role_ddl" => return Ok(self.exec_spg_role_ddl()),
7524                "spg_database_ddl" => return Ok(self.exec_spg_database_ddl()),
7525                _ => {}
7526            }
7527        }
7528        // v4.11: CTEs materialise into a temporary enriched catalog
7529        // *before* anything else — the body SELECT can then refer
7530        // to CTE names via the regular FROM-clause resolution.
7531        // Uncorrelated only: each CTE body runs once against the
7532        // current catalog, not against later CTEs' results (left-
7533        // to-right materialisation would relax this, but we keep
7534        // it simple for v4.11 MVP).
7535        if !stmt.ctes.is_empty() {
7536            return self.exec_with_ctes(stmt, cancel);
7537        }
7538        // v4.10: subqueries (uncorrelated) are resolved here, before
7539        // the executor sees the row loop. We clone the statement so
7540        // we can mutate without disturbing the caller's AST — most
7541        // queries pass through with no subquery nodes and the clone
7542        // is cheap; with subqueries the materialisation cost
7543        // dominates anyway.
7544        let mut stmt_owned;
7545        let stmt_ref: &SelectStatement = if expr_tree_has_subquery(stmt) {
7546            stmt_owned = stmt.clone();
7547            self.resolve_select_subqueries(&mut stmt_owned, cancel)?;
7548            &stmt_owned
7549        } else {
7550            stmt
7551        };
7552        if stmt_ref.unions.is_empty() {
7553            return self.exec_bare_select_cancel(stmt_ref, cancel);
7554        }
7555        // UNION path: clone-strip the head into a bare block (its own
7556        // DISTINCT and any inner ORDER BY are dropped by parser rule —
7557        // the wrapper SelectStatement carries them), execute, then chain
7558        // peers with left-associative dedup semantics.
7559        let mut head = stmt_ref.clone();
7560        head.unions = Vec::new();
7561        head.order_by = Vec::new();
7562        head.limit = None;
7563        let QueryResult::Rows { columns, mut rows } =
7564            self.exec_bare_select_cancel(&head, cancel)?
7565        else {
7566            unreachable!("bare SELECT cannot return CommandOk")
7567        };
7568        for (kind, peer) in &stmt_ref.unions {
7569            let QueryResult::Rows {
7570                columns: peer_cols,
7571                rows: peer_rows,
7572            } = self.exec_bare_select_cancel(peer, cancel)?
7573            else {
7574                unreachable!("bare SELECT cannot return CommandOk")
7575            };
7576            if peer_cols.len() != columns.len() {
7577                return Err(EngineError::Unsupported(alloc::format!(
7578                    "UNION arity mismatch: head has {} columns, peer has {}",
7579                    columns.len(),
7580                    peer_cols.len()
7581                )));
7582            }
7583            rows.extend(peer_rows);
7584            if matches!(kind, UnionKind::Distinct) {
7585                rows = dedup_rows(rows);
7586            }
7587        }
7588        // ORDER BY at the top of a UNION applies to the combined result.
7589        // Eval against the projected schema (NOT the source table).
7590        if !stmt.order_by.is_empty() {
7591            let synth_ctx = EvalContext::new(&columns, None);
7592            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
7593            let mut tagged: Vec<(Vec<f64>, Row)> = Vec::with_capacity(rows.len());
7594            for r in rows {
7595                let keys = build_order_keys(&stmt.order_by, &r, &synth_ctx)?;
7596                tagged.push((keys, r));
7597            }
7598            sort_by_keys(&mut tagged, &descs);
7599            rows = tagged.into_iter().map(|(_, r)| r).collect();
7600        }
7601        apply_offset_and_limit(&mut rows, stmt.offset_literal(), stmt.limit_literal());
7602        Ok(QueryResult::Rows { columns, rows })
7603    }
7604
7605    #[allow(clippy::too_many_lines)]
7606    #[allow(clippy::too_many_lines)] // huge match — splitting fragments the planner
7607    /// v7.11.7 — execute `SELECT … FROM unnest(expr) [AS] alias …`.
7608    /// Synthesises a single-column virtual table whose column type
7609    /// is TEXT and whose rows are the array elements. Routes
7610    /// through the regular projection / WHERE / ORDER BY / LIMIT
7611    /// machinery so set-returning UNNEST composes naturally with
7612    /// the rest of the SELECT surface.
7613    fn exec_select_unnest(
7614        &self,
7615        stmt: &SelectStatement,
7616        primary: &TableRef,
7617        cancel: CancelToken<'_>,
7618    ) -> Result<QueryResult, EngineError> {
7619        let expr = primary
7620            .unnest_expr
7621            .as_deref()
7622            .expect("caller guards unnest_expr.is_some()");
7623        // Evaluate the array expression once. Empty schema / empty
7624        // row — uncorrelated UNNEST cannot reference outer columns.
7625        let empty_schema: alloc::vec::Vec<ColumnSchema> = alloc::vec::Vec::new();
7626        let ctx = EvalContext::new(&empty_schema, None);
7627        let dummy_row = Row::new(alloc::vec::Vec::new());
7628        // v7.11.13 — unnest dispatches per array element type so
7629        // INT[] / BIGINT[] surface their PG types in projection.
7630        let (elem_dtype, rows): (DataType, alloc::vec::Vec<Row>) =
7631            match eval::eval_expr(expr, &dummy_row, &ctx).map_err(EngineError::Eval)? {
7632                Value::Null => (DataType::Text, alloc::vec::Vec::new()),
7633                Value::TextArray(items) => {
7634                    let rows = items
7635                        .into_iter()
7636                        .map(|item| {
7637                            Row::new(alloc::vec![match item {
7638                                Some(s) => Value::Text(s),
7639                                None => Value::Null,
7640                            }])
7641                        })
7642                        .collect();
7643                    (DataType::Text, rows)
7644                }
7645                Value::IntArray(items) => {
7646                    let rows = items
7647                        .into_iter()
7648                        .map(|item| {
7649                            Row::new(alloc::vec![match item {
7650                                Some(n) => Value::Int(n),
7651                                None => Value::Null,
7652                            }])
7653                        })
7654                        .collect();
7655                    (DataType::Int, rows)
7656                }
7657                Value::BigIntArray(items) => {
7658                    let rows = items
7659                        .into_iter()
7660                        .map(|item| {
7661                            Row::new(alloc::vec![match item {
7662                                Some(n) => Value::BigInt(n),
7663                                None => Value::Null,
7664                            }])
7665                        })
7666                        .collect();
7667                    (DataType::BigInt, rows)
7668                }
7669                other => {
7670                    return Err(EngineError::Unsupported(alloc::format!(
7671                        "unnest() expects an array argument, got {:?}",
7672                        other.data_type()
7673                    )));
7674                }
7675            };
7676        let alias = primary
7677            .alias
7678            .clone()
7679            .unwrap_or_else(|| "unnest".to_string());
7680        // v7.13.2 — mailrs round-6 S5. Honour PG-standard
7681        // `UNNEST(arr) AS p(col_name)` column-list aliasing: the
7682        // first entry overrides the projected column's name.
7683        // Without the column list, fall back to the table alias
7684        // (pre-v7.13.2 behaviour).
7685        let col_name = primary
7686            .unnest_column_aliases
7687            .first()
7688            .cloned()
7689            .unwrap_or_else(|| alias.clone());
7690        let col_schema = ColumnSchema::new(col_name, elem_dtype, true);
7691        let schema_cols = alloc::vec![col_schema.clone()];
7692        let scan_ctx = EvalContext::new(&schema_cols, Some(&alias));
7693        // Apply WHERE.
7694        let filtered: alloc::vec::Vec<Row> = if let Some(w) = &stmt.where_ {
7695            let mut out = alloc::vec::Vec::with_capacity(rows.len());
7696            for row in rows {
7697                cancel.check()?;
7698                let v = eval::eval_expr(w, &row, &scan_ctx).map_err(EngineError::Eval)?;
7699                if matches!(v, Value::Bool(true)) {
7700                    out.push(row);
7701                }
7702            }
7703            out
7704        } else {
7705            rows
7706        };
7707        // v7.17.0 Phase 3.P0-48 — aggregate dispatch over the
7708        // unnest source. Same routing the relational scan path
7709        // already takes — without it `SELECT COUNT(*) FROM
7710        // unnest(ARRAY[…])` either errored at projection time or
7711        // returned the wrong shape.
7712        if aggregate::uses_aggregate(stmt) {
7713            // v7.29 — a per-query memo so correlated scalar
7714            // subqueries batch-evaluate once (group map) instead of
7715            // executing per group.
7716            let agg_memo = core::cell::RefCell::new(memoize::MemoizeCache::default());
7717            let agg_correlated = |e: &Expr, r: &Row, c: &EvalContext<'_>| {
7718                self.eval_expr_with_correlated(e, r, c, cancel, Some(&mut agg_memo.borrow_mut()))
7719                    .map_err(|err| match err {
7720                        EngineError::Eval(ev) => ev,
7721                        other => eval::EvalError::TypeMismatch {
7722                            detail: alloc::format!("{other}"),
7723                        },
7724                    })
7725            };
7726            let filtered_refs: alloc::vec::Vec<&Row> = filtered.iter().collect();
7727            let mut agg = aggregate::run(
7728                stmt,
7729                &filtered_refs,
7730                &schema_cols,
7731                Some(&alias),
7732                Some(&agg_correlated),
7733            )?;
7734            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
7735            return Ok(QueryResult::Rows {
7736                columns: agg.columns,
7737                rows: agg.rows,
7738            });
7739        }
7740        // Projection.
7741        let projection = build_projection(&stmt.items, &schema_cols, &alias)?;
7742        let mut projected_rows: alloc::vec::Vec<Row> =
7743            alloc::vec::Vec::with_capacity(filtered.len());
7744        // v7.19 P5 — Set-Returning-Function in projection
7745        // position (PG `SELECT unnest(arr) FROM t` shape). When a
7746        // SELECT item evaluates to a top-level unnest(arr) call,
7747        // expand it: for each input row, evaluate the array, emit
7748        // one output row per element, broadcasting non-SRF
7749        // projections from the same input row. Multi-SRF + LCM
7750        // padding stays a documented carve-out; mailrs uses
7751        // single-SRF for redirect_uris.
7752        let srf_position = projection.iter().position(|p| is_top_level_unnest(&p.expr));
7753        if let Some(srf_idx) = srf_position {
7754            let srf_arg = top_level_unnest_arg(&projection[srf_idx].expr)
7755                .expect("checked by is_top_level_unnest above");
7756            for row in &filtered {
7757                let arr_val =
7758                    eval::eval_expr(srf_arg, row, &scan_ctx).map_err(EngineError::Eval)?;
7759                let elements = array_value_to_elements(&arr_val)?;
7760                // Empty array → zero rows for this input row (PG
7761                // semantics: `SELECT unnest('{}'::int[])` returns
7762                // 0 rows, not a single NULL row).
7763                for elem in elements {
7764                    let mut vals = alloc::vec::Vec::with_capacity(projection.len());
7765                    for (i, p) in projection.iter().enumerate() {
7766                        if i == srf_idx {
7767                            vals.push(elem.clone());
7768                        } else {
7769                            vals.push(
7770                                eval::eval_expr(&p.expr, row, &scan_ctx)
7771                                    .map_err(EngineError::Eval)?,
7772                            );
7773                        }
7774                    }
7775                    projected_rows.push(Row::new(vals));
7776                }
7777            }
7778        } else {
7779            // v7.24 (round-16 B) — select-list subqueries resolve
7780            // per row (correlated-aware; plain exprs take the fast
7781            // path inside).
7782            let mut proj_memo = memoize::MemoizeCache::default();
7783            for row in &filtered {
7784                let mut vals = alloc::vec::Vec::with_capacity(projection.len());
7785                for p in &projection {
7786                    vals.push(self.eval_expr_with_correlated(
7787                        &p.expr,
7788                        row,
7789                        &scan_ctx,
7790                        cancel,
7791                        Some(&mut proj_memo),
7792                    )?);
7793                }
7794                projected_rows.push(Row::new(vals));
7795            }
7796        }
7797        // ORDER BY / LIMIT — apply on the projected rows (cheap;
7798        // unnest result sets are small by design).
7799        let columns: alloc::vec::Vec<ColumnSchema> = projection
7800            .iter()
7801            .map(|p| ColumnSchema::new(p.output_name.clone(), p.ty, p.nullable))
7802            .collect();
7803        // Re-evaluate ORDER BY against the source schema (pre-projection
7804        // so col refs by name still resolve through `scan_ctx`).
7805        if !stmt.order_by.is_empty() {
7806            let mut indexed: alloc::vec::Vec<(usize, Vec<Value>)> = filtered
7807                .iter()
7808                .enumerate()
7809                .map(|(i, r)| -> Result<_, EngineError> {
7810                    let keys: Result<Vec<Value>, EngineError> = stmt
7811                        .order_by
7812                        .iter()
7813                        .map(|ob| {
7814                            eval::eval_expr(&ob.expr, r, &scan_ctx).map_err(EngineError::Eval)
7815                        })
7816                        .collect();
7817                    Ok((i, keys?))
7818                })
7819                .collect::<Result<_, _>>()?;
7820            indexed.sort_by(|a, b| {
7821                for (idx, (ka, kb)) in a.1.iter().zip(b.1.iter()).enumerate() {
7822                    let o = &stmt.order_by[idx];
7823                    let cmp = order_by_value_cmp(o.desc, o.nulls_first, ka, kb);
7824                    if cmp != core::cmp::Ordering::Equal {
7825                        return cmp;
7826                    }
7827                }
7828                core::cmp::Ordering::Equal
7829            });
7830            projected_rows = indexed
7831                .into_iter()
7832                .map(|(i, _)| projected_rows[i].clone())
7833                .collect();
7834        }
7835        // LIMIT / OFFSET — apply at the tail.
7836        if let Some(offset) = stmt.offset_literal() {
7837            let off = (offset as usize).min(projected_rows.len());
7838            projected_rows.drain(..off);
7839        }
7840        if let Some(limit) = stmt.limit_literal() {
7841            projected_rows.truncate(limit as usize);
7842        }
7843        Ok(QueryResult::Rows {
7844            columns,
7845            rows: projected_rows,
7846        })
7847    }
7848
7849    /// v7.17.0 Phase 3.10 — `FROM generate_series(start, stop [,
7850    /// step])` set-returning source. Mirrors `exec_select_unnest`'s
7851    /// shape: evaluate the arg list once against an empty row,
7852    /// materialise the row stream by stepping start → stop, then
7853    /// route through the standard WHERE / projection / ORDER BY /
7854    /// LIMIT pipeline. Two arg-type combos in v7.17:
7855    ///   * integer / integer [/ integer] — SmallInt, Int, BigInt
7856    ///     (widened to BigInt internally; step defaults to 1)
7857    ///   * timestamp / timestamp / interval — date-range
7858    ///     iteration (mailrs's daily-report pattern)
7859    fn exec_select_generate_series(
7860        &self,
7861        stmt: &SelectStatement,
7862        primary: &TableRef,
7863        cancel: CancelToken<'_>,
7864    ) -> Result<QueryResult, EngineError> {
7865        let args = primary
7866            .generate_series_args
7867            .as_ref()
7868            .expect("caller guards generate_series_args.is_some()");
7869        let empty_schema: alloc::vec::Vec<ColumnSchema> = alloc::vec::Vec::new();
7870        let ctx = EvalContext::new(&empty_schema, None);
7871        let dummy_row = Row::new(alloc::vec::Vec::new());
7872        let mut arg_values: alloc::vec::Vec<Value> = alloc::vec::Vec::with_capacity(args.len());
7873        for a in args {
7874            arg_values.push(eval::eval_expr(a, &dummy_row, &ctx).map_err(EngineError::Eval)?);
7875        }
7876        // Dispatch on the start value's shape. Reject mixed-shape
7877        // calls early (e.g. start = timestamp, stop = integer) so
7878        // the caller gets a clean error rather than a panic.
7879        let (elem_dtype, rows) = match arg_values.as_slice() {
7880            [Value::Timestamp(start), Value::Timestamp(stop), step] => {
7881                let interval_step = match step {
7882                    Value::Interval { .. } => step.clone(),
7883                    other => {
7884                        return Err(EngineError::Unsupported(alloc::format!(
7885                            "generate_series(timestamp, timestamp, …): \
7886                             step must be INTERVAL, got {:?}",
7887                            other.data_type()
7888                        )));
7889                    }
7890                };
7891                let rows = generate_series_timestamps(*start, *stop, interval_step, &cancel)?;
7892                (DataType::Timestamp, rows)
7893            }
7894            [start, stop, step]
7895                if value_is_integer(start) && value_is_integer(stop) && value_is_integer(step) =>
7896            {
7897                let s = value_to_i64(start);
7898                let e = value_to_i64(stop);
7899                let st = value_to_i64(step);
7900                let rows = generate_series_integers(s, e, st, &cancel)?;
7901                (DataType::BigInt, rows)
7902            }
7903            [start, stop] if value_is_integer(start) && value_is_integer(stop) => {
7904                let s = value_to_i64(start);
7905                let e = value_to_i64(stop);
7906                let rows = generate_series_integers(s, e, 1, &cancel)?;
7907                (DataType::BigInt, rows)
7908            }
7909            _ => {
7910                return Err(EngineError::Unsupported(alloc::format!(
7911                    "generate_series(): v7.17 supports integer or (timestamp, timestamp, interval) \
7912                     argument shapes; got {:?}",
7913                    arg_values
7914                        .iter()
7915                        .map(|v| v.data_type())
7916                        .collect::<alloc::vec::Vec<_>>()
7917                )));
7918            }
7919        };
7920        let alias = primary
7921            .alias
7922            .clone()
7923            .unwrap_or_else(|| "generate_series".to_string());
7924        let col_name = alias.clone();
7925        let col_schema = ColumnSchema::new(col_name, elem_dtype, true);
7926        let schema_cols = alloc::vec![col_schema.clone()];
7927        let scan_ctx = EvalContext::new(&schema_cols, Some(&alias));
7928        // WHERE.
7929        let filtered: alloc::vec::Vec<Row> = if let Some(w) = &stmt.where_ {
7930            let mut out = alloc::vec::Vec::with_capacity(rows.len());
7931            for row in rows {
7932                cancel.check()?;
7933                let v = eval::eval_expr(w, &row, &scan_ctx).map_err(EngineError::Eval)?;
7934                if matches!(v, Value::Bool(true)) {
7935                    out.push(row);
7936                }
7937            }
7938            out
7939        } else {
7940            rows
7941        };
7942        // v7.17.0 Phase 3.P0-48 — aggregate dispatch for set-
7943        // returning sources. When the SELECT projection contains
7944        // aggregate functions (COUNT/SUM/MIN/MAX/AVG/string_agg/
7945        // …) we route the filtered row stream through the same
7946        // aggregate executor the relational scan path uses, so
7947        // `SELECT COUNT(*) FROM generate_series(1, 100)` returns
7948        // a single 100 row instead of erroring at projection
7949        // time. GROUP BY / HAVING / ORDER BY over the aggregate
7950        // output all ride through `aggregate::run`.
7951        if aggregate::uses_aggregate(stmt) {
7952            // v7.29 — a per-query memo so correlated scalar
7953            // subqueries batch-evaluate once (group map) instead of
7954            // executing per group.
7955            let agg_memo = core::cell::RefCell::new(memoize::MemoizeCache::default());
7956            let agg_correlated = |e: &Expr, r: &Row, c: &EvalContext<'_>| {
7957                self.eval_expr_with_correlated(e, r, c, cancel, Some(&mut agg_memo.borrow_mut()))
7958                    .map_err(|err| match err {
7959                        EngineError::Eval(ev) => ev,
7960                        other => eval::EvalError::TypeMismatch {
7961                            detail: alloc::format!("{other}"),
7962                        },
7963                    })
7964            };
7965            let filtered_refs: alloc::vec::Vec<&Row> = filtered.iter().collect();
7966            let mut agg = aggregate::run(
7967                stmt,
7968                &filtered_refs,
7969                &schema_cols,
7970                Some(&alias),
7971                Some(&agg_correlated),
7972            )?;
7973            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
7974            return Ok(QueryResult::Rows {
7975                columns: agg.columns,
7976                rows: agg.rows,
7977            });
7978        }
7979        // Projection.
7980        let projection = build_projection(&stmt.items, &schema_cols, &alias)?;
7981        let mut projected_rows: alloc::vec::Vec<Row> =
7982            alloc::vec::Vec::with_capacity(filtered.len());
7983        let mut proj_memo = memoize::MemoizeCache::default();
7984        for row in &filtered {
7985            let mut vals = alloc::vec::Vec::with_capacity(projection.len());
7986            for p in &projection {
7987                // v7.24 (round-16 B) — correlated-aware.
7988                vals.push(self.eval_expr_with_correlated(
7989                    &p.expr,
7990                    row,
7991                    &scan_ctx,
7992                    cancel,
7993                    Some(&mut proj_memo),
7994                )?);
7995            }
7996            projected_rows.push(Row::new(vals));
7997        }
7998        let columns: alloc::vec::Vec<ColumnSchema> = projection
7999            .iter()
8000            .map(|p| ColumnSchema::new(p.output_name.clone(), p.ty, p.nullable))
8001            .collect();
8002        // ORDER BY against the source schema.
8003        if !stmt.order_by.is_empty() {
8004            let mut indexed: alloc::vec::Vec<(usize, Vec<Value>)> = filtered
8005                .iter()
8006                .enumerate()
8007                .map(|(i, r)| -> Result<_, EngineError> {
8008                    let keys: Result<Vec<Value>, EngineError> = stmt
8009                        .order_by
8010                        .iter()
8011                        .map(|ob| {
8012                            eval::eval_expr(&ob.expr, r, &scan_ctx).map_err(EngineError::Eval)
8013                        })
8014                        .collect();
8015                    Ok((i, keys?))
8016                })
8017                .collect::<Result<_, _>>()?;
8018            indexed.sort_by(|a, b| {
8019                for (idx, (ka, kb)) in a.1.iter().zip(b.1.iter()).enumerate() {
8020                    let o = &stmt.order_by[idx];
8021                    let cmp = order_by_value_cmp(o.desc, o.nulls_first, ka, kb);
8022                    if cmp != core::cmp::Ordering::Equal {
8023                        return cmp;
8024                    }
8025                }
8026                core::cmp::Ordering::Equal
8027            });
8028            projected_rows = indexed
8029                .into_iter()
8030                .map(|(i, _)| projected_rows[i].clone())
8031                .collect();
8032        }
8033        if let Some(offset) = stmt.offset_literal() {
8034            let off = (offset as usize).min(projected_rows.len());
8035            projected_rows.drain(..off);
8036        }
8037        if let Some(limit) = stmt.limit_literal() {
8038            projected_rows.truncate(limit as usize);
8039        }
8040        Ok(QueryResult::Rows {
8041            columns,
8042            rows: projected_rows,
8043        })
8044    }
8045
8046    fn exec_bare_select_cancel(
8047        &self,
8048        stmt: &SelectStatement,
8049        cancel: CancelToken<'_>,
8050    ) -> Result<QueryResult, EngineError> {
8051        // v7.17.0 Phase 3.P0-49 — `FETCH FIRST N ROWS WITH TIES`
8052        // is meaningless without an ORDER BY; PG raises a hard
8053        // error and SPG mirrors the surface so the same DDL/app
8054        // path behaves identically on cutover.
8055        check_with_ties_requires_order_by(stmt)?;
8056        // v7.16.2 — same meta-view dispatch as
8057        // `exec_select_cancel`, applied here too because
8058        // `subquery_replacement` enters this function directly
8059        // for Exists / ScalarSubquery / InSubquery resolution
8060        // (bypassing the top-level entry to avoid double
8061        // subquery walking). Without this dispatch the subquery
8062        // hits `__spg_info_columns` and reports TableNotFound.
8063        if !self.meta_views_materialised && select_references_meta_view(stmt) {
8064            return self.exec_select_with_meta_views(stmt, cancel);
8065        }
8066        // v4.12: window-function path. When the projection contains
8067        // any `name(args) OVER (...)` we route to the dedicated
8068        // executor — partition + sort + per-row window value before
8069        // the regular projection.
8070        if select_has_window(stmt) {
8071            return self.exec_select_with_window(stmt, cancel);
8072        }
8073        // Constant SELECT (no FROM) — evaluate each item once against an
8074        // empty dummy row. Useful for `SELECT 1`, `SELECT coalesce(...)`,
8075        // `SELECT '7'::INT`. Column references will surface as
8076        // ColumnNotFound on eval since the schema is empty.
8077        let Some(from) = &stmt.from else {
8078            let empty_schema: Vec<ColumnSchema> = Vec::new();
8079            let ctx = self.ev_ctx(&empty_schema, None);
8080            let projection = build_projection(&stmt.items, &empty_schema, "")?;
8081            let dummy_row = Row::new(Vec::new());
8082            let mut values = Vec::with_capacity(projection.len());
8083            for p in &projection {
8084                values.push(eval::eval_expr(&p.expr, &dummy_row, &ctx)?);
8085            }
8086            let columns: Vec<ColumnSchema> = projection
8087                .into_iter()
8088                .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
8089                .collect();
8090            return Ok(QueryResult::Rows {
8091                columns,
8092                rows: alloc::vec![Row::new(values)],
8093            });
8094        };
8095        // Multi-table FROM (one or more joined peers) goes through the
8096        // nested-loop join executor. Single-table FROM stays on the
8097        // existing scan + index-seek path.
8098        if !from.joins.is_empty() {
8099            return self.exec_joined_select(stmt, from, cancel);
8100        }
8101        // v7.11.7 — `FROM unnest(<expr>) [AS] <alias>`. Synthesise a
8102        // single-column table at SELECT entry by evaluating the
8103        // expression once against the empty row (UNNEST is
8104        // uncorrelated in v7.11; correlated / LATERAL unnest is a
8105        // v7.12 carve-out). Build a virtual `Table` in a heap-only
8106        // catalog, then route to the regular scan path.
8107        if from.primary.unnest_expr.is_some() {
8108            return self.exec_select_unnest(stmt, &from.primary, cancel);
8109        }
8110        // v7.17.0 Phase 3.10 — `FROM generate_series(start, stop
8111        // [, step])` set-returning source. Dispatch mirrors UNNEST:
8112        // materialise the row stream from a single eval pass, then
8113        // run the regular projection / WHERE / ORDER BY / LIMIT
8114        // pipeline over the synthetic single-column table.
8115        if from.primary.generate_series_args.is_some() {
8116            return self.exec_select_generate_series(stmt, &from.primary, cancel);
8117        }
8118        let primary = &from.primary;
8119        let table = self.active_catalog().get(&primary.name).ok_or_else(|| {
8120            StorageError::TableNotFound {
8121                name: primary.name.clone(),
8122            }
8123        })?;
8124        let schema_cols = &table.schema().columns;
8125        // The qualifier accepted on column refs is the alias (if any) else the
8126        // bare table name.
8127        let alias = primary.alias.as_deref().unwrap_or(primary.name.as_str());
8128        let ctx = self.ev_ctx(schema_cols, Some(alias));
8129
8130        // NSW kNN planner: `ORDER BY col <-> literal LIMIT k` with no
8131        // WHERE and an NSW index on `col` skips the full scan. The
8132        // walk returns rows already in ascending-distance order, so
8133        // ORDER BY / LIMIT are honoured implicitly.
8134        if let Some(nsw_rows) = try_nsw_knn(stmt, table, schema_cols, alias) {
8135            return materialise_in_order(stmt, table, schema_cols, alias, &nsw_rows);
8136        }
8137
8138        // Index seek: if WHERE is `col = literal` (or commuted) and the
8139        // referenced column has an index, dispatch each locator through
8140        // the catalog (hot tier → borrow, cold tier → page-read +
8141        // decode) and iterate just those rows. Otherwise fall back to a
8142        // full scan over the hot tier (cold-tier rows are only reached
8143        // via index seek in v5.1 — full table scans against cold-tier
8144        // data ship in v5.2 with the freezer's per-segment scan API).
8145        let indexed_rows: Option<Vec<Cow<'_, Row>>> = stmt.where_.as_ref().and_then(|w| {
8146            // BTree / col=literal seek first — covers the v7.11.3 multi-
8147            // column AND case and the leading-column equality lookup.
8148            try_index_seek(w, schema_cols, self.active_catalog(), table, alias)
8149                .or_else(|| {
8150                    // v7.12.3 — GIN-accelerated `WHERE col @@
8151                    // tsquery` when the column has a `USING gin`
8152                    // index. Returns an over-approximate candidate
8153                    // set; the WHERE re-eval loop below verifies
8154                    // the full `@@` predicate per row.
8155                    try_gin_seek(w, schema_cols, self.active_catalog(), table, alias, &ctx)
8156                })
8157                .or_else(|| {
8158                    // v7.15.0 — trigram-GIN-accelerated
8159                    // `WHERE col LIKE / ILIKE '<pat>'` when the
8160                    // column has a `gin_trgm_ops` GIN index.
8161                    // Over-approximate candidate set; the WHERE
8162                    // re-eval verifies the LIKE per row.
8163                    try_trgm_seek(w, schema_cols, table, alias)
8164                })
8165        });
8166
8167        // Aggregate path: filter rows first, then hand off to the
8168        // aggregate executor which does its own projection + ORDER BY.
8169        if aggregate::uses_aggregate(stmt) {
8170            let mut filtered: Vec<&Row> = Vec::new();
8171            // v6.2.6 — Memoize: per-query LRU cache for correlated
8172            // scalar subqueries. Fresh per row-loop entry so each
8173            // SELECT execution gets an isolated cache.
8174            let mut memo = memoize::MemoizeCache::new();
8175            if let Some(rows) = &indexed_rows {
8176                for cow in rows {
8177                    let row = cow.as_ref();
8178                    if let Some(where_expr) = &stmt.where_ {
8179                        let cond = self.eval_expr_with_correlated(
8180                            where_expr,
8181                            row,
8182                            &ctx,
8183                            cancel,
8184                            Some(&mut memo),
8185                        )?;
8186                        if !matches!(cond, Value::Bool(true)) {
8187                            continue;
8188                        }
8189                    }
8190                    filtered.push(row);
8191                }
8192            } else {
8193                for i in 0..table.row_count() {
8194                    let row = &table.rows()[i];
8195                    if let Some(where_expr) = &stmt.where_ {
8196                        let cond = self.eval_expr_with_correlated(
8197                            where_expr,
8198                            row,
8199                            &ctx,
8200                            cancel,
8201                            Some(&mut memo),
8202                        )?;
8203                        if !matches!(cond, Value::Bool(true)) {
8204                            continue;
8205                        }
8206                    }
8207                    filtered.push(row);
8208                }
8209            }
8210            // v7.29 — a per-query memo so correlated scalar
8211            // subqueries batch-evaluate once (group map) instead of
8212            // executing per group.
8213            let agg_memo = core::cell::RefCell::new(memoize::MemoizeCache::default());
8214            let agg_correlated = |e: &Expr, r: &Row, c: &EvalContext<'_>| {
8215                self.eval_expr_with_correlated(e, r, c, cancel, Some(&mut agg_memo.borrow_mut()))
8216                    .map_err(|err| match err {
8217                        EngineError::Eval(ev) => ev,
8218                        other => eval::EvalError::TypeMismatch {
8219                            detail: alloc::format!("{other}"),
8220                        },
8221                    })
8222            };
8223            let mut agg = aggregate::run(
8224                stmt,
8225                &filtered,
8226                schema_cols,
8227                Some(alias),
8228                Some(&agg_correlated),
8229            )?;
8230            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
8231            return Ok(QueryResult::Rows {
8232                columns: agg.columns,
8233                rows: agg.rows,
8234            });
8235        }
8236
8237        let projection = build_projection(&stmt.items, schema_cols, alias)?;
8238        // v7.19 P5 — single-table SELECT path for SRF
8239        // `SELECT unnest(arr) FROM t` shape. Detect a top-level
8240        // unnest in the projection list. When present, the
8241        // per-row processor emits one output row per array
8242        // element (broadcasting non-SRF projections from the
8243        // same input row). Empty / NULL arrays emit zero rows
8244        // for that input — PG semantics.
8245        let srf_position = projection.iter().position(|p| is_top_level_unnest(&p.expr));
8246
8247        // Materialise the filter pass into `(order_key, projected_row)`
8248        // tuples. The order key is `None` when there's no ORDER BY clause.
8249        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::new();
8250        // v6.2.6 — Memoize per-row WHERE eval shares one cache.
8251        let mut memo = memoize::MemoizeCache::new();
8252        // Inline the per-row work in a closure so the indexed and full-
8253        // scan branches share the body.
8254        let mut process_row = |row: &Row, loop_idx: usize| -> Result<(), EngineError> {
8255            if loop_idx.is_multiple_of(256) {
8256                cancel.check()?;
8257            }
8258            if let Some(where_expr) = &stmt.where_ {
8259                let cond =
8260                    self.eval_expr_with_correlated(where_expr, row, &ctx, cancel, Some(&mut memo))?;
8261                if !matches!(cond, Value::Bool(true)) {
8262                    return Ok(());
8263                }
8264            }
8265            let order_keys = if stmt.order_by.is_empty() {
8266                Vec::new()
8267            } else {
8268                build_order_keys(&stmt.order_by, row, &ctx)?
8269            };
8270            if let Some(srf_idx) = srf_position {
8271                let srf_arg = top_level_unnest_arg(&projection[srf_idx].expr)
8272                    .expect("checked by is_top_level_unnest above");
8273                let arr_val = eval::eval_expr(srf_arg, row, &ctx)?;
8274                let elements = array_value_to_elements(&arr_val)?;
8275                for elem in elements {
8276                    let mut values = Vec::with_capacity(projection.len());
8277                    for (i, p) in projection.iter().enumerate() {
8278                        if i == srf_idx {
8279                            values.push(elem.clone());
8280                        } else {
8281                            values.push(eval::eval_expr(&p.expr, row, &ctx)?);
8282                        }
8283                    }
8284                    tagged.push((order_keys.clone(), Row::new(values)));
8285                }
8286            } else {
8287                let mut values = Vec::with_capacity(projection.len());
8288                for p in &projection {
8289                    // v7.24 (round-16 B) — correlated-aware.
8290                    values.push(self.eval_expr_with_correlated(&p.expr, row, &ctx, cancel, None)?);
8291                }
8292                tagged.push((order_keys, Row::new(values)));
8293            }
8294            Ok(())
8295        };
8296        if let Some(rows) = &indexed_rows {
8297            for (loop_idx, cow) in rows.iter().enumerate() {
8298                process_row(cow.as_ref(), loop_idx)?;
8299            }
8300        } else {
8301            for i in 0..table.row_count() {
8302                process_row(&table.rows()[i], i)?;
8303            }
8304        }
8305
8306        if !stmt.order_by.is_empty() {
8307            // Partial-sort fast path: when LIMIT is small relative to
8308            // the row count, select_nth_unstable + sort just the
8309            // prefix is O(n + k log k) instead of O(n log n). DISTINCT
8310            // requires the full sort because de-dup happens after.
8311            // WITH TIES likewise needs the full sort so the tie
8312            // extension can scan past `limit` to find rows that
8313            // share the last-kept row's key.
8314            let keep = if stmt.distinct || stmt.limit_with_ties {
8315                None
8316            } else {
8317                stmt.limit_literal()
8318                    .map(|l| l as usize + stmt.offset_literal().map_or(0, |o| o as usize))
8319            };
8320            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
8321            partial_sort_tagged(&mut tagged, keep, &descs);
8322        }
8323
8324        // v7.17.0 Phase 3.P0-49 — `FETCH FIRST … WITH TIES` extends
8325        // past the truncated tail through every row that shares the
8326        // last-kept row's ORDER BY key. The tie check uses the
8327        // already-computed `(order_keys, row)` pairs so it matches
8328        // the sort comparator exactly. DISTINCT + WITH TIES falls
8329        // through to the no-ties path (PG also disallows their
8330        // combination; SPG silently drops the tie extension here so
8331        // the customer doesn't see a hard error mid-query — the
8332        // user-visible result is still correct, just narrower).
8333        let output_rows: Vec<Row> = if stmt.limit_with_ties && !stmt.distinct {
8334            apply_offset_and_limit_tagged(
8335                &mut tagged,
8336                stmt.offset_literal(),
8337                stmt.limit_literal(),
8338                true,
8339            );
8340            tagged.into_iter().map(|(_, r)| r).collect()
8341        } else {
8342            let mut output_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
8343            if stmt.distinct {
8344                output_rows = dedup_rows(output_rows);
8345            }
8346            apply_offset_and_limit(
8347                &mut output_rows,
8348                stmt.offset_literal(),
8349                stmt.limit_literal(),
8350            );
8351            output_rows
8352        };
8353
8354        let columns: Vec<ColumnSchema> = projection
8355            .into_iter()
8356            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
8357            .collect();
8358
8359        Ok(QueryResult::Rows {
8360            columns,
8361            rows: output_rows,
8362        })
8363    }
8364
8365    /// Multi-table SELECT executor (one or more JOIN peers).
8366    ///
8367    /// v1.10 builds the joined row set up-front via nested-loop joins,
8368    /// then runs WHERE + projection + ORDER BY against the combined
8369    /// rows. No index seek. Aggregates and DISTINCT still work because
8370    /// the executor delegates projection through the same shared paths.
8371    #[allow(clippy::too_many_lines)]
8372    /// v7.13.2 — mailrs round-6 S5. Resolve a TableRef into an
8373    /// owned (rows, schema) pair. Catalog tables clone their hot
8374    /// rows + schema; UNNEST table refs evaluate their array
8375    /// expression once and synthesise a single-column row set
8376    /// using the same dispatch as `exec_select_unnest`. Used by
8377    /// the joined-select path so UNNEST can appear in any FROM
8378    /// position, not just as the primary.
8379    fn materialise_table_ref(
8380        &self,
8381        tref: &TableRef,
8382    ) -> Result<(Vec<Row>, Vec<ColumnSchema>), EngineError> {
8383        if let Some(expr) = tref.unnest_expr.as_deref() {
8384            let empty_schema: Vec<ColumnSchema> = Vec::new();
8385            let ctx = EvalContext::new(&empty_schema, None);
8386            let dummy_row = Row::new(Vec::new());
8387            let (elem_dtype, rows) =
8388                match eval::eval_expr(expr, &dummy_row, &ctx).map_err(EngineError::Eval)? {
8389                    Value::Null => (DataType::Text, Vec::new()),
8390                    Value::TextArray(items) => (
8391                        DataType::Text,
8392                        items
8393                            .into_iter()
8394                            .map(|item| {
8395                                Row::new(alloc::vec![match item {
8396                                    Some(s) => Value::Text(s),
8397                                    None => Value::Null,
8398                                }])
8399                            })
8400                            .collect(),
8401                    ),
8402                    Value::IntArray(items) => (
8403                        DataType::Int,
8404                        items
8405                            .into_iter()
8406                            .map(|item| {
8407                                Row::new(alloc::vec![match item {
8408                                    Some(n) => Value::Int(n),
8409                                    None => Value::Null,
8410                                }])
8411                            })
8412                            .collect(),
8413                    ),
8414                    Value::BigIntArray(items) => (
8415                        DataType::BigInt,
8416                        items
8417                            .into_iter()
8418                            .map(|item| {
8419                                Row::new(alloc::vec![match item {
8420                                    Some(n) => Value::BigInt(n),
8421                                    None => Value::Null,
8422                                }])
8423                            })
8424                            .collect(),
8425                    ),
8426                    other => {
8427                        return Err(EngineError::Unsupported(alloc::format!(
8428                            "unnest() expects an array argument, got {:?}",
8429                            other.data_type()
8430                        )));
8431                    }
8432                };
8433            let alias = tref.alias.clone().unwrap_or_else(|| "unnest".to_string());
8434            let col_name = tref.unnest_column_aliases.first().cloned().unwrap_or(alias);
8435            return Ok((
8436                rows,
8437                alloc::vec![ColumnSchema::new(col_name, elem_dtype, true)],
8438            ));
8439        }
8440        let table =
8441            self.active_catalog()
8442                .get(&tref.name)
8443                .ok_or_else(|| StorageError::TableNotFound {
8444                    name: tref.name.clone(),
8445                })?;
8446        let rows: Vec<Row> = table.rows().iter().cloned().collect();
8447        let cols = table.schema().columns.clone();
8448        Ok((rows, cols))
8449    }
8450
8451    /// v7.28 (round-22) — materialise a plain table ref with
8452    /// single-table predicates pushed BELOW the clone: an indexed
8453    /// `col = literal` narrows to the matching row ids before any
8454    /// row is cloned, the rest filter linearly. A correlated
8455    /// subquery body like `… JOIN messages m2 ON …
8456    /// WHERE m2.thread_id = '<outer>'` runs per GROUP — without
8457    /// this it cloned + scanned the full 24k-row table 23.5k times.
8458    /// Falls back to the plain path for non-table refs.
8459    fn materialise_table_ref_filtered(
8460        &self,
8461        tref: &TableRef,
8462        preds: &[&Expr],
8463    ) -> Result<(Vec<Row>, Vec<ColumnSchema>), EngineError> {
8464        if preds.is_empty()
8465            || tref.unnest_expr.is_some()
8466            || tref.lateral_subquery.is_some()
8467            || tref.as_of_segment.is_some()
8468        {
8469            return self.materialise_table_ref(tref);
8470        }
8471        let Some(table) = self.active_catalog().get(&tref.name) else {
8472            return self.materialise_table_ref(tref);
8473        };
8474        let cols = table.schema().columns.clone();
8475        let alias = tref.alias.as_deref().unwrap_or(tref.name.as_str());
8476        // Index seek on the first `col = literal` predicate with a
8477        // BTree on that column.
8478        let mut seeded: Option<Vec<usize>> = None;
8479        for p in preds {
8480            if let Expr::Binary {
8481                lhs,
8482                op: spg_sql::ast::BinOp::Eq,
8483                rhs,
8484            } = p
8485            {
8486                let pair = match (lhs.as_ref(), rhs.as_ref()) {
8487                    (Expr::Column(c), Expr::Literal(l)) | (Expr::Literal(l), Expr::Column(c)) => {
8488                        Some((c, l))
8489                    }
8490                    _ => None,
8491                };
8492                if let Some((c, l)) = pair
8493                    && c.qualifier
8494                        .as_deref()
8495                        .is_none_or(|q| q.eq_ignore_ascii_case(alias))
8496                    && let Some(pos) = cols.iter().position(|s| s.name == c.name)
8497                    && let Some(idx) = table.index_on(pos)
8498                    && let Some(key) = spg_storage::IndexKey::from_value(&eval::literal_to_value(l))
8499                {
8500                    let mut ids = Vec::new();
8501                    let mut all_hot = true;
8502                    for loc in idx.lookup_eq(&key) {
8503                        match *loc {
8504                            spg_storage::RowLocator::Hot(i) => ids.push(i),
8505                            spg_storage::RowLocator::Cold { .. } => {
8506                                all_hot = false;
8507                                break;
8508                            }
8509                        }
8510                    }
8511                    if all_hot {
8512                        seeded = Some(ids);
8513                        break;
8514                    }
8515                }
8516            }
8517        }
8518        let ctx = EvalContext::new(&cols, Some(alias));
8519        let mut out: Vec<Row> = Vec::new();
8520        let push_if = |row: &Row, out: &mut Vec<Row>| -> Result<(), EngineError> {
8521            for p in preds {
8522                let v = eval::eval_expr(p, row, &ctx).map_err(EngineError::Eval)?;
8523                if !matches!(v, Value::Bool(true)) {
8524                    return Ok(());
8525                }
8526            }
8527            out.push(row.clone());
8528            Ok(())
8529        };
8530        match seeded {
8531            Some(ids) => {
8532                for i in ids {
8533                    if let Some(row) = table.rows().get(i) {
8534                        push_if(row, &mut out)?;
8535                    }
8536                }
8537            }
8538            None => {
8539                for row in table.rows().iter() {
8540                    push_if(row, &mut out)?;
8541                }
8542            }
8543        }
8544        Ok((out, cols))
8545    }
8546
8547    /// v7.17.0 Phase 3.P0-43 — materialise a `FROM` with one or more
8548    /// JOINs into `(combined_schema, filtered_rows)`. The combined
8549    /// schema uses composite `alias.col` column names so the
8550    /// qualifier-aware column resolver finds every join peer by
8551    /// exact match; the filtered rows are the join cross-product
8552    /// after the optional WHERE clause is applied.
8553    ///
8554    /// Shared by `exec_joined_select` and the JOIN branch of
8555    /// `exec_select_with_window`; both paths used to inline the
8556    /// same nested-loop logic and the window path rejected JOIN
8557    /// outright.
8558    /// v7.28 (round-22) — resolve a Column reference against a
8559    /// composite ("alias.col") schema slice. Bare names match a
8560    /// unique ".col" suffix.
8561    fn composite_col_pos(schema: &[ColumnSchema], c: &spg_sql::ast::ColumnName) -> Option<usize> {
8562        if let Some(q) = &c.qualifier {
8563            let composite = alloc::format!("{q}.{}", c.name);
8564            return schema.iter().position(|s| s.name == composite);
8565        }
8566        let suffix = alloc::format!(".{}", c.name);
8567        let mut hits = schema
8568            .iter()
8569            .enumerate()
8570            .filter(|(_, s)| s.name.ends_with(&suffix) || s.name == c.name);
8571        let first = hits.next();
8572        if hits.next().is_some() {
8573            return None; // ambiguous — leave to the residual evaluator
8574        }
8575        first.map(|(i, _)| i)
8576    }
8577
8578    /// v7.28 (round-22) — resolve a Column against ONE peer's own
8579    /// columns (right side of a join): `alias.col` or a bare name.
8580    fn peer_col_pos(
8581        peer_alias: &str,
8582        peer_cols: &[ColumnSchema],
8583        c: &spg_sql::ast::ColumnName,
8584    ) -> Option<usize> {
8585        if let Some(q) = &c.qualifier
8586            && !q.eq_ignore_ascii_case(peer_alias)
8587        {
8588            return None;
8589        }
8590        peer_cols.iter().position(|s| s.name == c.name)
8591    }
8592
8593    /// v7.28 (round-22) — drop the VALUES of columns the statement
8594    /// never references (schema and positions stay; the value
8595    /// becomes NULL, so a 30 KB body column costs nothing through
8596    /// the join pipeline instead of being cloned per row).
8597    fn null_out_unreferenced(
8598        rows: &mut [Row],
8599        cols: &[ColumnSchema],
8600        alias: &str,
8601        needed: &alloc::collections::BTreeSet<(String, String)>,
8602    ) {
8603        let keep: Vec<bool> = cols
8604            .iter()
8605            .map(|c| needed.contains(&(alias.to_string(), c.name.clone())))
8606            .collect();
8607        if keep.iter().all(|k| *k) {
8608            return;
8609        }
8610        for row in rows.iter_mut() {
8611            for (i, k) in keep.iter().enumerate() {
8612                if !*k && i < row.values.len() {
8613                    row.values[i] = Value::Null;
8614                }
8615            }
8616        }
8617    }
8618
8619    fn build_joined_filtered_rows(
8620        &self,
8621        from: &FromClause,
8622        where_: Option<&Expr>,
8623        cancel: CancelToken<'_>,
8624        needed: Option<&alloc::collections::BTreeSet<(String, String)>>,
8625    ) -> Result<(Vec<ColumnSchema>, Vec<Row>), EngineError> {
8626        let primary_alias = from
8627            .primary
8628            .alias
8629            .as_deref()
8630            .unwrap_or(from.primary.name.as_str())
8631            .to_string();
8632        // v7.28 (round-22) — single-table predicate pushdown. WHERE
8633        // conjuncts whose every column is QUALIFIED with one table's
8634        // alias filter that table BEFORE the join (with an index
8635        // seek when one matches `col = literal`). Only the primary
8636        // and INNER peers are eligible — pre-filtering a LEFT peer
8637        // would change which rows NULL-extend. Pushed conjuncts stay
8638        // in WHERE too (idempotent), so correctness never depends on
8639        // the pushdown.
8640        let mut primary_preds: Vec<&Expr> = Vec::new();
8641        let mut peer_preds: Vec<Vec<&Expr>> = alloc::vec![Vec::new(); from.joins.len()];
8642        if let Some(w) = where_ {
8643            for sub in reorder::split_and_conjunctions(w) {
8644                if expr_has_subquery(sub) || aggregate::contains_aggregate(sub) {
8645                    continue;
8646                }
8647                let mut quals: Vec<&str> = Vec::new();
8648                let mut all_qualified = true;
8649                collect_column_qualifiers(sub, &mut quals, &mut all_qualified);
8650                if !all_qualified || quals.is_empty() {
8651                    continue;
8652                }
8653                let q0 = quals[0];
8654                if !quals.iter().all(|q| q.eq_ignore_ascii_case(q0)) {
8655                    continue;
8656                }
8657                if q0.eq_ignore_ascii_case(&primary_alias) {
8658                    primary_preds.push(sub);
8659                    continue;
8660                }
8661                for (i, j) in from.joins.iter().enumerate() {
8662                    if matches!(j.kind, JoinKind::Inner)
8663                        && j.table.lateral_subquery.is_none()
8664                        && q0.eq_ignore_ascii_case(
8665                            j.table.alias.as_deref().unwrap_or(j.table.name.as_str()),
8666                        )
8667                    {
8668                        peer_preds[i].push(sub);
8669                        break;
8670                    }
8671                }
8672            }
8673        }
8674        // v7.28 (round-22) — table-order swap: when the primary has
8675        // no pushed predicate but an INNER peer does, start from the
8676        // filtered peer instead. Equi-joins commute; output columns
8677        // resolve by composite name, so downstream projection is
8678        // order-independent. (A correlated subquery body like
8679        // `FROM email_analysis e2 JOIN messages m2 … WHERE
8680        // m2.thread_id = '<outer>'` otherwise clones the whole
8681        // unfiltered primary once per outer group.)
8682        let mut from_owned;
8683        let mut from = from;
8684        // Safety: swapping reorders which table joins FIRST, so it is
8685        // only legal when the FIRST join's ON references no table
8686        // beyond {primary, first peer} (a later peer's ON may name
8687        // the original primary, which must already be in the
8688        // combined row when that peer joins). Restrict to i == 0 AND
8689        // an ON whose qualifiers all live in those two tables.
8690        if primary_preds.is_empty()
8691            && let Some(j0) = from.joins.first()
8692            && matches!(j0.kind, JoinKind::Inner)
8693            && j0.table.lateral_subquery.is_none()
8694            && !peer_preds[0].is_empty()
8695        {
8696            let peer_alias = j0.table.alias.as_deref().unwrap_or(j0.table.name.as_str());
8697            let on_safe = j0.on.as_ref().is_some_and(|on| {
8698                let mut quals: Vec<&str> = Vec::new();
8699                let mut all_q = true;
8700                collect_column_qualifiers(on, &mut quals, &mut all_q);
8701                all_q
8702                    && quals.iter().all(|q| {
8703                        q.eq_ignore_ascii_case(&primary_alias) || q.eq_ignore_ascii_case(peer_alias)
8704                    })
8705            });
8706            if on_safe {
8707                from_owned = from.clone();
8708                core::mem::swap(&mut from_owned.primary, &mut from_owned.joins[0].table);
8709                primary_preds = peer_preds[0].drain(..).collect();
8710                from = &from_owned;
8711            }
8712        }
8713        let primary_alias = from
8714            .primary
8715            .alias
8716            .as_deref()
8717            .unwrap_or(from.primary.name.as_str())
8718            .to_string();
8719        let (mut primary_rows, primary_cols) =
8720            self.materialise_table_ref_filtered(&from.primary, &primary_preds)?;
8721        if let Some(needed) = needed {
8722            Self::null_out_unreferenced(&mut primary_rows, &primary_cols, &primary_alias, needed);
8723        }
8724        // v7.17.0 Phase 3.P0-41 — LATERAL peers can't be
8725        // pre-materialised because their rows depend on outer
8726        // columns. For each peer, build either an eager
8727        // (rows, schema) pair or a "lateral" sentinel carrying
8728        // just the schema and the inner SELECT to re-run per
8729        // outer row.
8730        #[allow(clippy::type_complexity)]
8731        let mut joined: Vec<JoinedPeer<'_>> = Vec::new();
8732        for j in &from.joins {
8733            let a = j
8734                .table
8735                .alias
8736                .as_deref()
8737                .unwrap_or(j.table.name.as_str())
8738                .to_string();
8739            if let Some(inner_box) = &j.table.lateral_subquery {
8740                // Probe schema by running the inner SELECT against a
8741                // NULL-padded outer context. The probe gives us the
8742                // projection's column shape; rows materialise per
8743                // left-row below.
8744                let schema = self.lateral_probe_schema(inner_box)?;
8745                joined.push(JoinedPeer {
8746                    eager_rows: None,
8747                    cols: schema,
8748                    alias: a,
8749                    kind: j.kind,
8750                    on: j.on.as_ref(),
8751                    lateral: Some(inner_box.as_ref()),
8752                    join_table: None,
8753                });
8754            } else {
8755                let pidx = from
8756                    .joins
8757                    .iter()
8758                    .position(|jj| core::ptr::eq(jj, j))
8759                    .unwrap_or(0);
8760                // v7.28 - defer materialisation for plain tables with
8761                // no pushed predicate: the index-nested-loop path may
8762                // avoid cloning the table entirely.
8763                let plain = j.table.unnest_expr.is_none() && j.table.as_of_segment.is_none();
8764                if plain
8765                    && peer_preds[pidx].is_empty()
8766                    && let Some(t) = self.active_catalog().get(&j.table.name)
8767                {
8768                    joined.push(JoinedPeer {
8769                        eager_rows: None,
8770                        cols: t.schema().columns.clone(),
8771                        alias: a,
8772                        kind: j.kind,
8773                        on: j.on.as_ref(),
8774                        lateral: None,
8775                        join_table: Some(j.table.name.clone()),
8776                    });
8777                    continue;
8778                }
8779                let (mut rows, cols) =
8780                    self.materialise_table_ref_filtered(&j.table, &peer_preds[pidx])?;
8781                if let Some(needed) = needed {
8782                    Self::null_out_unreferenced(&mut rows, &cols, &a, needed);
8783                }
8784                joined.push(JoinedPeer {
8785                    eager_rows: Some(rows),
8786                    cols,
8787                    alias: a,
8788                    kind: j.kind,
8789                    on: j.on.as_ref(),
8790                    lateral: None,
8791                    join_table: Some(j.table.name.clone()),
8792                });
8793            }
8794        }
8795        let mut combined_schema: Vec<ColumnSchema> = Vec::new();
8796        for col in &primary_cols {
8797            combined_schema.push(ColumnSchema::new(
8798                alloc::format!("{primary_alias}.{}", col.name),
8799                col.ty,
8800                col.nullable,
8801            ));
8802        }
8803        for peer in &joined {
8804            for col in &peer.cols {
8805                combined_schema.push(ColumnSchema::new(
8806                    alloc::format!("{}.{}", peer.alias, col.name),
8807                    col.ty,
8808                    col.nullable,
8809                ));
8810            }
8811        }
8812        let ctx = EvalContext::new(&combined_schema, None);
8813        // v7.28 (round-22) - intermediate-row ceiling: a join whose
8814        // working set explodes errors instead of eating the host
8815        // (mailrs watched RSS climb to 7 GiB of 15 before a manual
8816        // restart). The ceiling is per join STAGE, not per query.
8817        const MAX_JOIN_INTERMEDIATE_ROWS: usize = 4_000_000;
8818        let mut working: Vec<Row> = primary_rows;
8819        // Track the per-row width consumed by the outer left side so
8820        // each lateral evaluation sees the correct schema slice.
8821        let mut consumed_cols = primary_cols.len();
8822        for peer in &joined {
8823            if working.len() > MAX_JOIN_INTERMEDIATE_ROWS {
8824                return Err(EngineError::Unsupported(alloc::format!(
8825                    "join intermediate result exceeds {MAX_JOIN_INTERMEDIATE_ROWS} rows ({} so far) - add join predicates",
8826                    working.len()
8827                )));
8828            }
8829            let right_arity = peer.cols.len();
8830            let mut next: Vec<Row> = Vec::new();
8831            // v7.28 (round-22) — hash equi-join. The old path CLONED
8832            // the full combined row for EVERY (left, right) pair and
8833            // then evaluated ON — O(L×R) row materialisations (a
8834            // 24k × 6k LEFT JOIN = 1.5e8 multi-KB clones; the inbox
8835            // query never returned). Extract `left_col = right_col`
8836            // conjuncts from ON, build a hash on the (smaller,
8837            // already-materialised) right side, and only materialise
8838            // matching pairs. Residual ON conjuncts evaluate on the
8839            // candidates. NULL keys never match (SQL equality).
8840            let mut eq_pairs: Vec<(usize, usize)> = Vec::new(); // (left combined pos, right peer pos)
8841            let mut residual: Vec<&Expr> = Vec::new();
8842            if let (Some(on_expr), None) = (peer.on, peer.lateral) {
8843                for sub in reorder::split_and_conjunctions(on_expr) {
8844                    let mut matched = None;
8845                    if let Expr::Binary {
8846                        lhs,
8847                        op: spg_sql::ast::BinOp::Eq,
8848                        rhs,
8849                    } = sub
8850                        && let (Expr::Column(a), Expr::Column(b)) = (lhs.as_ref(), rhs.as_ref())
8851                    {
8852                        let left_slice = &combined_schema[..consumed_cols];
8853                        if let (Some(l), Some(r)) = (
8854                            Self::composite_col_pos(left_slice, a),
8855                            Self::peer_col_pos(&peer.alias, &peer.cols, b),
8856                        ) {
8857                            matched = Some((l, r));
8858                        } else if let (Some(l), Some(r)) = (
8859                            Self::composite_col_pos(left_slice, b),
8860                            Self::peer_col_pos(&peer.alias, &peer.cols, a),
8861                        ) {
8862                            matched = Some((l, r));
8863                        }
8864                    }
8865                    match matched {
8866                        Some(pair) => eq_pairs.push(pair),
8867                        None => residual.push(sub),
8868                    }
8869                }
8870            }
8871            // v7.28 (round-22) - index-nested-loop: when the working
8872            // set is small and the peer's join column has a BTree,
8873            // seek per left row instead of materialising the whole
8874            // peer table (a correlated subquery body otherwise
8875            // clones the full table once per outer group).
8876            const INL_MAX_LEFT: usize = 1024;
8877            if let Some(tname) = &peer.join_table
8878                && peer.eager_rows.is_none()
8879                && !eq_pairs.is_empty()
8880                && working.len() <= INL_MAX_LEFT
8881                && let Some(table) = self.active_catalog().get(tname)
8882                && let Some(idx) = peer
8883                    .cols
8884                    .iter()
8885                    .position(|c| c.name == peer.cols[eq_pairs[0].1].name)
8886                    .and_then(|pos| table.index_on(pos))
8887            {
8888                let (lpos0, _) = eq_pairs[0];
8889                for left in &working {
8890                    cancel.check()?;
8891                    let mut left_matched = false;
8892                    let key_v = left.values.get(lpos0).cloned().unwrap_or(Value::Null);
8893                    if !matches!(key_v, Value::Null)
8894                        && let Some(key) = spg_storage::IndexKey::from_value(&key_v)
8895                    {
8896                        for loc in idx.lookup_eq(&key) {
8897                            let right = match *loc {
8898                                spg_storage::RowLocator::Hot(i) => match table.rows().get(i) {
8899                                    Some(r) => r,
8900                                    None => continue,
8901                                },
8902                                spg_storage::RowLocator::Cold { .. } => continue,
8903                            };
8904                            // Remaining eq pairs + residual ON check on
8905                            // the candidate only.
8906                            let mut ok = true;
8907                            for (lp, rp) in eq_pairs.iter().skip(1) {
8908                                let lv = left.values.get(*lp);
8909                                let rv = right.values.get(*rp);
8910                                let eq = match (lv, rv) {
8911                                    (Some(a), Some(b)) => {
8912                                        !matches!(a, Value::Null)
8913                                            && !matches!(b, Value::Null)
8914                                            && value_cmp(a, b) == core::cmp::Ordering::Equal
8915                                    }
8916                                    _ => false,
8917                                };
8918                                if !eq {
8919                                    ok = false;
8920                                    break;
8921                                }
8922                            }
8923                            if !ok {
8924                                continue;
8925                            }
8926                            let mut combined_vals = left.values.clone();
8927                            combined_vals.extend(right.values.iter().cloned());
8928                            let combined = Row::new(combined_vals);
8929                            let keep = if residual.is_empty() {
8930                                true
8931                            } else {
8932                                let mut k = true;
8933                                for r in &residual {
8934                                    let cond = self.eval_expr_with_correlated(
8935                                        r, &combined, &ctx, cancel, None,
8936                                    )?;
8937                                    if !matches!(cond, Value::Bool(true)) {
8938                                        k = false;
8939                                        break;
8940                                    }
8941                                }
8942                                k
8943                            };
8944                            if keep {
8945                                next.push(combined);
8946                                left_matched = true;
8947                            }
8948                        }
8949                    }
8950                    if !left_matched && matches!(peer.kind, JoinKind::Left) {
8951                        let mut combined_vals = left.values.clone();
8952                        for _ in 0..right_arity {
8953                            combined_vals.push(Value::Null);
8954                        }
8955                        next.push(Row::new(combined_vals));
8956                    }
8957                }
8958                working = next;
8959                consumed_cols += right_arity;
8960                continue;
8961            }
8962            // Deferred peer that didn't take the INL path: materialise
8963            // now (no pushed predicate, full table).
8964            let lazy_rows: Option<Vec<Row>> = if peer.eager_rows.is_none() && peer.lateral.is_none()
8965            {
8966                let tname = peer.join_table.as_deref().unwrap_or("");
8967                let mut rows: Vec<Row> = self
8968                    .active_catalog()
8969                    .get(tname)
8970                    .map(|t| t.rows().iter().cloned().collect())
8971                    .unwrap_or_default();
8972                if let Some(needed) = needed {
8973                    Self::null_out_unreferenced(&mut rows, &peer.cols, &peer.alias, needed);
8974                }
8975                Some(rows)
8976            } else {
8977                None
8978            };
8979            let eager_view: Option<&Vec<Row>> = peer.eager_rows.as_ref().or(lazy_rows.as_ref());
8980            if !eq_pairs.is_empty() && peer.lateral.is_none() {
8981                let rights = eager_view.expect("non-lateral peer eager");
8982                // v7.29 - hashbrown over BTreeMap: the ordered map
8983                // paid O(log n) string comparisons per insert/probe
8984                // (24k-row build sides spent ~100 ms in it).
8985                let mut table: hashbrown::HashMap<String, Vec<usize>> =
8986                    hashbrown::HashMap::with_capacity(rights.len());
8987                let mut keybuf: Vec<Value> = Vec::with_capacity(eq_pairs.len());
8988                'build: for (ri, right) in rights.iter().enumerate() {
8989                    keybuf.clear();
8990                    for (_, rpos) in &eq_pairs {
8991                        let v = right.values.get(*rpos).cloned().unwrap_or(Value::Null);
8992                        if matches!(v, Value::Null) {
8993                            continue 'build;
8994                        }
8995                        keybuf.push(v);
8996                    }
8997                    table
8998                        .entry(aggregate::encode_key(&keybuf))
8999                        .or_default()
9000                        .push(ri);
9001                }
9002                for left in &working {
9003                    cancel.check()?;
9004                    let mut left_matched = false;
9005                    keybuf.clear();
9006                    let mut left_has_null = false;
9007                    for (lpos, _) in &eq_pairs {
9008                        let v = left.values.get(*lpos).cloned().unwrap_or(Value::Null);
9009                        if matches!(v, Value::Null) {
9010                            left_has_null = true;
9011                            break;
9012                        }
9013                        keybuf.push(v);
9014                    }
9015                    if !left_has_null
9016                        && let Some(cands) = table.get(&aggregate::encode_key(&keybuf))
9017                    {
9018                        for &ri in cands {
9019                            let right = &rights[ri];
9020                            let mut combined_vals = left.values.clone();
9021                            combined_vals.extend(right.values.iter().cloned());
9022                            let combined = Row::new(combined_vals);
9023                            let keep = if residual.is_empty() {
9024                                true
9025                            } else {
9026                                let mut ok = true;
9027                                for r in &residual {
9028                                    let cond = self.eval_expr_with_correlated(
9029                                        r, &combined, &ctx, cancel, None,
9030                                    )?;
9031                                    if !matches!(cond, Value::Bool(true)) {
9032                                        ok = false;
9033                                        break;
9034                                    }
9035                                }
9036                                ok
9037                            };
9038                            if keep {
9039                                next.push(combined);
9040                                left_matched = true;
9041                            }
9042                        }
9043                    }
9044                    if !left_matched && matches!(peer.kind, JoinKind::Left) {
9045                        let mut combined_vals = left.values.clone();
9046                        for _ in 0..right_arity {
9047                            combined_vals.push(Value::Null);
9048                        }
9049                        next.push(Row::new(combined_vals));
9050                    }
9051                }
9052                working = next;
9053                consumed_cols += right_arity;
9054                debug_assert!(consumed_cols <= combined_schema.len());
9055                continue;
9056            }
9057            // Fallback: nested loop (lateral peers, non-equi ON).
9058            for left in &working {
9059                cancel.check()?;
9060                let mut left_matched = false;
9061                let per_left_rrows: alloc::borrow::Cow<'_, [Row]> = match peer.lateral {
9062                    Some(inner) => {
9063                        // Substitute outer columns and run the inner
9064                        // SELECT against the current left row's slice
9065                        // of the combined schema.
9066                        let outer_schema = &combined_schema[..consumed_cols];
9067                        let rows = self.materialise_lateral_for_outer(inner, outer_schema, left)?;
9068                        alloc::borrow::Cow::Owned(rows)
9069                    }
9070                    None => {
9071                        let r = eager_view.expect("non-lateral peer eager");
9072                        alloc::borrow::Cow::Borrowed(r.as_slice())
9073                    }
9074                };
9075                for right in per_left_rrows.as_ref() {
9076                    let mut combined_vals = left.values.clone();
9077                    combined_vals.extend(right.values.iter().cloned());
9078                    let combined = Row::new(combined_vals);
9079                    let keep = if let Some(on_expr) = peer.on {
9080                        // v7.24.1 — correlated-aware (subqueries in
9081                        // ON referencing earlier join columns).
9082                        let cond =
9083                            self.eval_expr_with_correlated(on_expr, &combined, &ctx, cancel, None)?;
9084                        matches!(cond, Value::Bool(true))
9085                    } else {
9086                        true
9087                    };
9088                    if keep {
9089                        next.push(combined);
9090                        left_matched = true;
9091                    }
9092                }
9093                if !left_matched && matches!(peer.kind, JoinKind::Left) {
9094                    let mut combined_vals = left.values.clone();
9095                    for _ in 0..right_arity {
9096                        combined_vals.push(Value::Null);
9097                    }
9098                    next.push(Row::new(combined_vals));
9099                }
9100            }
9101            working = next;
9102            if working.len() > MAX_JOIN_INTERMEDIATE_ROWS {
9103                return Err(EngineError::Unsupported(alloc::format!(
9104                    "join intermediate result exceeds {MAX_JOIN_INTERMEDIATE_ROWS} rows ({} so far) - add join predicates",
9105                    working.len()
9106                )));
9107            }
9108            consumed_cols += right_arity;
9109            debug_assert!(consumed_cols <= combined_schema.len());
9110        }
9111        let mut filtered: Vec<Row> = Vec::new();
9112        // v7.24 (round-16 B) — the joined WHERE filter ran the plain
9113        // row evaluator, so a correlated EXISTS/IN/scalar subquery
9114        // under a JOIN hit "subquery reached row eval". Route through
9115        // the correlated-aware evaluator (memoized, same as the
9116        // single-table path).
9117        let mut memo = memoize::MemoizeCache::default();
9118        for row in working {
9119            if let Some(where_expr) = where_ {
9120                let cond = self.eval_expr_with_correlated(
9121                    where_expr,
9122                    &row,
9123                    &ctx,
9124                    cancel,
9125                    Some(&mut memo),
9126                )?;
9127                if !matches!(cond, Value::Bool(true)) {
9128                    continue;
9129                }
9130            }
9131            filtered.push(row);
9132        }
9133        Ok((combined_schema, filtered))
9134    }
9135
9136    /// v7.17.0 Phase 3.P0-41 — probe a LATERAL subquery's projection
9137    /// schema by running it once with a NULL-padded outer context.
9138    /// The probe never materialises real outer rows; it just executes
9139    /// the inner SELECT with `outer_alias.col` references substituted
9140    /// to NULL so the projection's type inference is exercised.
9141    fn lateral_probe_schema(
9142        &self,
9143        inner: &SelectStatement,
9144    ) -> Result<Vec<ColumnSchema>, EngineError> {
9145        // Substitute every qualified column reference whose qualifier
9146        // does NOT match an in-subquery FROM alias with NULL. The
9147        // safest probe is to walk the inner SELECT and replace any
9148        // `<qual>.<col>` whose qual isn't bound inside the subquery
9149        // with a Null literal. For the v7.17 probe we just run the
9150        // unmodified subquery and surface the columns; if it fails
9151        // (e.g. references an outer column the probe can't resolve),
9152        // we synthesise a best-effort schema from the SELECT items
9153        // by inferring a single Text-typed column per projection.
9154        match self.execute_readonly_select_for_lateral_probe(inner) {
9155            Ok(QueryResult::Rows { columns, .. }) => Ok(columns),
9156            // Best-effort fallback: each SELECT item becomes a TEXT
9157            // column. Real schemas only differ when the inner SELECT
9158            // references outer columns at projection-time; those
9159            // queries surface via the substitution path during
9160            // per-row execution and still return the right values.
9161            _ => {
9162                let mut out: Vec<ColumnSchema> = Vec::new();
9163                for (i, item) in inner.items.iter().enumerate() {
9164                    let name = match item {
9165                        SelectItem::Expr { alias: Some(a), .. } => a.clone(),
9166                        SelectItem::Expr { expr, .. } => synth_lateral_col_name(expr, i),
9167                        SelectItem::Wildcard => alloc::format!("col{i}"),
9168                    };
9169                    out.push(ColumnSchema::new(name, DataType::Text, true));
9170                }
9171                Ok(out)
9172            }
9173        }
9174    }
9175
9176    /// v7.17.0 Phase 3.P0-41 — try the inner LATERAL subquery against
9177    /// the engine in read-only mode for schema-probe purposes. Failure
9178    /// is expected when the subquery references an outer column the
9179    /// probe can't resolve; the caller falls back to a best-effort
9180    /// schema based on the SELECT items.
9181    fn execute_readonly_select_for_lateral_probe(
9182        &self,
9183        inner: &SelectStatement,
9184    ) -> Result<QueryResult, EngineError> {
9185        self.exec_bare_select_cancel(inner, CancelToken::none())
9186    }
9187
9188    /// v7.17.0 Phase 3.P0-41 — materialise a LATERAL subquery's rows
9189    /// for one outer-row context. Walks the inner SELECT, replaces
9190    /// every `<outer_alias>.<col>` reference whose alias appears in
9191    /// the outer schema with the literal value from the outer row,
9192    /// then runs the rewritten SELECT against the engine.
9193    fn materialise_lateral_for_outer(
9194        &self,
9195        inner: &SelectStatement,
9196        outer_schema: &[ColumnSchema],
9197        outer_row: &Row,
9198    ) -> Result<Vec<Row>, EngineError> {
9199        let mut substituted = inner.clone();
9200        substitute_outer_columns_multi(&mut substituted, outer_row, outer_schema);
9201        let result = self.exec_bare_select_cancel(&substituted, CancelToken::none())?;
9202        match result {
9203            QueryResult::Rows { rows, .. } => Ok(rows),
9204            _ => Err(EngineError::Unsupported(
9205                "LATERAL subquery must be a SELECT (cannot be a write statement)".into(),
9206            )),
9207        }
9208    }
9209
9210    fn exec_joined_select(
9211        &self,
9212        stmt: &SelectStatement,
9213        from: &FromClause,
9214        cancel: CancelToken<'_>,
9215    ) -> Result<QueryResult, EngineError> {
9216        // v7.17.0 Phase 3.P0-43 + P0-41 — delegate the join +
9217        // WHERE materialisation to the shared helper so the LATERAL
9218        // / UNNEST / regular-catalog paths route through one place.
9219        // (`build_joined_filtered_rows` carries LATERAL support as
9220        // of Phase 3.P0-41.) Downstream we still handle aggregate /
9221        // projection / ORDER BY / DISTINCT / LIMIT inline because
9222        // those depend on the SelectStatement's items list.
9223        let (combined_schema, filtered) = {
9224            let mut needed = alloc::collections::BTreeSet::new();
9225            let prunable = collect_qualified_refs(stmt, &mut needed).is_some();
9226            self.build_joined_filtered_rows(
9227                from,
9228                stmt.where_.as_ref(),
9229                cancel,
9230                if prunable { Some(&needed) } else { None },
9231            )?
9232        };
9233        let ctx = EvalContext::new(&combined_schema, None);
9234        // Aggregate path: handle GROUP BY / aggregate calls over the
9235        // joined+filtered rows.
9236        if aggregate::uses_aggregate(stmt) {
9237            let refs: Vec<&Row> = filtered.iter().collect();
9238            // v7.29 — a per-query memo so correlated scalar
9239            // subqueries batch-evaluate once (group map) instead of
9240            // executing per group.
9241            let agg_memo = core::cell::RefCell::new(memoize::MemoizeCache::default());
9242            let agg_correlated = |e: &Expr, r: &Row, c: &EvalContext<'_>| {
9243                self.eval_expr_with_correlated(e, r, c, cancel, Some(&mut agg_memo.borrow_mut()))
9244                    .map_err(|err| match err {
9245                        EngineError::Eval(ev) => ev,
9246                        other => eval::EvalError::TypeMismatch {
9247                            detail: alloc::format!("{other}"),
9248                        },
9249                    })
9250            };
9251            let mut agg =
9252                aggregate::run(stmt, &refs, &combined_schema, None, Some(&agg_correlated))?;
9253            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
9254            return Ok(QueryResult::Rows {
9255                columns: agg.columns,
9256                rows: agg.rows,
9257            });
9258        }
9259
9260        let projection = build_projection(&stmt.items, &combined_schema, "")?;
9261        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::new();
9262        let mut proj_memo = memoize::MemoizeCache::default();
9263        for row in &filtered {
9264            let mut values = Vec::with_capacity(projection.len());
9265            for p in &projection {
9266                // v7.24 (round-16 B) — select-list subqueries under a
9267                // JOIN go through the correlated-aware evaluator too.
9268                values.push(self.eval_expr_with_correlated(
9269                    &p.expr,
9270                    row,
9271                    &ctx,
9272                    cancel,
9273                    Some(&mut proj_memo),
9274                )?);
9275            }
9276            let order_keys = if stmt.order_by.is_empty() {
9277                Vec::new()
9278            } else {
9279                build_order_keys(&stmt.order_by, row, &ctx)?
9280            };
9281            tagged.push((order_keys, Row::new(values)));
9282        }
9283        if !stmt.order_by.is_empty() {
9284            let keep = if stmt.distinct {
9285                None
9286            } else {
9287                stmt.limit_literal()
9288                    .map(|l| l as usize + stmt.offset_literal().map_or(0, |o| o as usize))
9289            };
9290            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
9291            partial_sort_tagged(&mut tagged, keep, &descs);
9292        }
9293        let mut output_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
9294        if stmt.distinct {
9295            output_rows = dedup_rows(output_rows);
9296        }
9297        apply_offset_and_limit(
9298            &mut output_rows,
9299            stmt.offset_literal(),
9300            stmt.limit_literal(),
9301        );
9302        let columns: Vec<ColumnSchema> = projection
9303            .into_iter()
9304            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
9305            .collect();
9306        Ok(QueryResult::Rows {
9307            columns,
9308            rows: output_rows,
9309        })
9310    }
9311}
9312
9313/// One row-producing projection: an expression to evaluate, the resulting
9314/// column's user-visible name, its inferred type, and nullability.
9315#[derive(Debug, Clone)]
9316struct ProjectedItem {
9317    expr: Expr,
9318    output_name: String,
9319    ty: DataType,
9320    nullable: bool,
9321}
9322
9323/// Dedupe a row set, preserving first-seen order. `Row`'s `PartialEq` is
9324/// structural (`Vec<Value>` ⇒ pairwise `Value` equality), which gives SQL
9325/// `NULL = NULL → TRUE` and `NaN = NaN → FALSE`. The first agrees with
9326/// the spec's "two NULLs are not distinct"; the second is a tolerated
9327/// quirk for v1 (no NaN literals are reachable from the SQL surface).
9328fn dedup_rows(rows: Vec<Row>) -> Vec<Row> {
9329    let mut out: Vec<Row> = Vec::with_capacity(rows.len());
9330    for r in rows {
9331        if !out.iter().any(|seen| seen == &r) {
9332            out.push(r);
9333        }
9334    }
9335    out
9336}
9337
9338/// Coerce a `Value` to an `f64` sort key for ORDER BY. Numbers map directly;
9339/// NULL sorts last (treated as `+∞`); booleans are 0.0 / 1.0; text uses lex
9340/// order via the byte values; vectors are not sortable.
9341fn value_to_order_key(v: &Value) -> Result<f64, EngineError> {
9342    match v {
9343        Value::Null => Ok(f64::INFINITY),
9344        Value::SmallInt(n) => Ok(f64::from(*n)),
9345        Value::Int(n) => Ok(f64::from(*n)),
9346        Value::Date(d) => Ok(f64::from(*d)),
9347        #[allow(clippy::cast_precision_loss)]
9348        Value::Timestamp(t) => Ok(*t as f64),
9349        // v7.17.0 Phase 3.P0-32 — PG TIME ordered by underlying
9350        // i64 microseconds (matches wall-clock ordering).
9351        #[allow(clippy::cast_precision_loss)]
9352        Value::Time(us) => Ok(*us as f64),
9353        // v7.17.0 Phase 3.P0-33 — MySQL YEAR ordered by underlying
9354        // u16 (matches calendar ordering; zero-year sentinel
9355        // sorts before 1901).
9356        Value::Year(y) => Ok(f64::from(*y)),
9357        // v7.17.0 Phase 3.P0-34 — PG TIMETZ ordered by the
9358        // UTC-equivalent microseconds (local wall - offset). Two
9359        // values for the same physical instant in different zones
9360        // sort equal — matches PG TIMETZ index behaviour.
9361        #[allow(clippy::cast_precision_loss)]
9362        Value::TimeTz { us, offset_secs } => Ok((us - i64::from(*offset_secs) * 1_000_000) as f64),
9363        // v7.17.0 Phase 3.P0-35 — PG MONEY ordered by i64 cents.
9364        #[allow(clippy::cast_precision_loss)]
9365        Value::Money(c) => Ok(*c as f64),
9366        // v7.17.0 Phase 3.P0-38 — range ordering is not supported
9367        // in v7.17.0 (needs lex-then-inclusivity tiebreak).
9368        Value::Range { .. } => Err(EngineError::Unsupported(
9369            "ORDER BY of a range value is not supported in v7.17.0".into(),
9370        )),
9371        // v7.17.0 Phase 3.P0-39 — hstore is not orderable.
9372        Value::Hstore(_) => Err(EngineError::Unsupported(
9373            "ORDER BY of a hstore value is not supported".into(),
9374        )),
9375        // v7.17.0 Phase 3.P0-40 — 2D arrays not orderable.
9376        Value::IntArray2D(_) | Value::BigIntArray2D(_) | Value::TextArray2D(_) => Err(
9377            EngineError::Unsupported("ORDER BY of a 2D array is not supported in v7.17.0".into()),
9378        ),
9379        #[allow(clippy::cast_precision_loss)]
9380        Value::Numeric { scaled, scale } => {
9381            // Scaled integer / 10^scale, computed via f64 for sort
9382            // ordering only. Precision losses here only matter for
9383            // ORDER BY tie-breaks well past 15 significant digits.
9384            // `f64::powi` lives in std; we hand-roll the loop so the
9385            // no_std engine crate doesn't need it.
9386            let mut divisor = 1.0_f64;
9387            for _ in 0..*scale {
9388                divisor *= 10.0;
9389            }
9390            Ok((*scaled as f64) / divisor)
9391        }
9392        #[allow(clippy::cast_precision_loss)]
9393        Value::BigInt(n) => Ok(*n as f64),
9394        Value::Float(x) => Ok(*x),
9395        Value::Bool(b) => Ok(if *b { 1.0 } else { 0.0 }),
9396        Value::Text(s) => {
9397            // Lex order by codepoints — good enough for ORDER BY name.
9398            // Map first 8 bytes packed into u64 as a coarse key; ties fall to
9399            // partial_cmp Equal. v1.x can swap in a real string comparator.
9400            let mut key: u64 = 0;
9401            for &b in s.as_bytes().iter().take(8) {
9402                key = (key << 8) | u64::from(b);
9403            }
9404            #[allow(clippy::cast_precision_loss)]
9405            Ok(key as f64)
9406        }
9407        Value::Vector(_) | Value::Sq8Vector(_) | Value::HalfVector(_) => {
9408            Err(EngineError::Unsupported(
9409                "ORDER BY of a raw vector column is not meaningful — use `<->`".into(),
9410            ))
9411        }
9412        Value::Interval { .. } => Err(EngineError::Unsupported(
9413            "ORDER BY of an INTERVAL is not supported in v2.11 \
9414             (months vs micros has no single canonical ordering)"
9415                .into(),
9416        )),
9417        Value::Json(_) => Err(EngineError::Unsupported(
9418            "ORDER BY of a JSON value is not supported — cast the document to text first".into(),
9419        )),
9420        // v7.5.0 — Value is #[non_exhaustive]; future variants need
9421        // an explicit ORDER BY mapping. Surface as Unsupported until
9422        // engine support is added.
9423        _ => Err(EngineError::Unsupported(
9424            "ORDER BY of this value type is not supported".into(),
9425        )),
9426    }
9427}
9428
9429/// Try to plan a WHERE clause as an equality lookup against an existing
9430/// index. Returns the candidate row indices on success; `None` means the
9431/// caller should fall back to a full scan.
9432///
9433/// v0.8 recognises a single top-level `col = literal` (in either operand
9434/// order). AND chains and range scans land in later milestones.
9435/// Look for `ORDER BY col <dist-op> literal LIMIT k` against an
9436/// NSW-indexed vector column. Recognised distance ops: `<->` (L2),
9437/// `<#>` (inner product), `<=>` (cosine). When a WHERE clause is
9438/// present, the planner does an "over-fetch and filter" pass — it
9439/// asks the graph for `k * over_fetch` candidates, evaluates WHERE
9440/// against each, and trims back to `k`. Returns the row indices in
9441/// ascending-distance order when the plan applies.
9442fn try_nsw_knn(
9443    stmt: &SelectStatement,
9444    table: &Table,
9445    schema_cols: &[ColumnSchema],
9446    table_alias: &str,
9447) -> Option<Vec<usize>> {
9448    if stmt.distinct {
9449        return None;
9450    }
9451    let limit = usize::try_from(stmt.limit_literal()?).ok()?;
9452    if limit == 0 {
9453        return None;
9454    }
9455    // v6.4.0 — NSW kNN dispatch needs a single ORDER BY key on the
9456    // distance metric. Multi-key ORDER BY falls through to the
9457    // generic sort path.
9458    if stmt.order_by.len() != 1 {
9459        return None;
9460    }
9461    let order = &stmt.order_by[0];
9462    // NSW kNN returns rows ascending by distance — DESC inverts the
9463    // natural order, so the planner can't handle it without a sort
9464    // pass. Fall back to the generic ORDER BY path.
9465    if order.desc {
9466        return None;
9467    }
9468    let Expr::Binary { lhs, op, rhs } = &order.expr else {
9469        return None;
9470    };
9471    let metric = match op {
9472        BinOp::L2Distance => spg_storage::NswMetric::L2,
9473        BinOp::InnerProduct => spg_storage::NswMetric::InnerProduct,
9474        BinOp::CosineDistance => spg_storage::NswMetric::Cosine,
9475        _ => return None,
9476    };
9477    // Accept both `col <op> literal` and `literal <op> col`.
9478    let ((Expr::Column(col), literal) | (literal, Expr::Column(col))) =
9479        (lhs.as_ref(), rhs.as_ref())
9480    else {
9481        return None;
9482    };
9483    if let Some(q) = &col.qualifier
9484        && q != table_alias
9485    {
9486        return None;
9487    }
9488    let col_pos = schema_cols.iter().position(|s| s.name == col.name)?;
9489    let query = literal_to_vector(literal)?;
9490    let idx = spg_storage::nsw_index_on(table, col_pos)?;
9491    if let Some(where_expr) = &stmt.where_ {
9492        // Over-fetch and filter. The factor (10×) is a heuristic that
9493        // covers typical selectivity for the corpus tests; v2.x will
9494        // make it configurable.
9495        let over_fetch = limit.saturating_mul(10).max(NSW_OVER_FETCH_FLOOR);
9496        let candidates = spg_storage::nsw_query(table, &idx.name, &query, over_fetch, metric);
9497        let ctx = EvalContext::new(schema_cols, Some(table_alias));
9498        let mut kept: Vec<usize> = Vec::with_capacity(limit);
9499        for i in candidates {
9500            let row = &table.rows()[i];
9501            let cond = eval::eval_expr(where_expr, row, &ctx).ok()?;
9502            if matches!(cond, Value::Bool(true)) {
9503                kept.push(i);
9504                if kept.len() >= limit {
9505                    break;
9506                }
9507            }
9508        }
9509        Some(kept)
9510    } else {
9511        Some(spg_storage::nsw_query(
9512            table, &idx.name, &query, limit, metric,
9513        ))
9514    }
9515}
9516
9517/// Lower bound on the over-fetch pool when WHERE is present — even
9518/// for tiny `LIMIT 1` queries we keep enough candidates to absorb a
9519/// few WHERE rejections.
9520const NSW_OVER_FETCH_FLOOR: usize = 32;
9521
9522/// Pull a `Vec<f32>` out of a literal-or-cast expression. Returns
9523/// `None` for anything we can't fold at plan time.
9524fn literal_to_vector(e: &Expr) -> Option<Vec<f32>> {
9525    match e {
9526        Expr::Literal(Literal::Vector(v)) => Some(v.clone()),
9527        Expr::Cast { expr, .. } => literal_to_vector(expr),
9528        _ => None,
9529    }
9530}
9531
9532/// Materialise rows in a planner-supplied order (used by the NSW path)
9533/// without re-running ORDER BY. The projection + LIMIT slot mirror the
9534/// equivalent block in `exec_bare_select`.
9535fn materialise_in_order(
9536    stmt: &SelectStatement,
9537    table: &Table,
9538    schema_cols: &[ColumnSchema],
9539    table_alias: &str,
9540    ordered_rows: &[usize],
9541) -> Result<QueryResult, EngineError> {
9542    let ctx = EvalContext::new(schema_cols, Some(table_alias));
9543    let projection = build_projection(&stmt.items, schema_cols, table_alias)?;
9544    let mut output_rows: Vec<Row> = Vec::with_capacity(ordered_rows.len());
9545    for &i in ordered_rows {
9546        let row = &table.rows()[i];
9547        let mut values = Vec::with_capacity(projection.len());
9548        for p in &projection {
9549            values.push(eval::eval_expr(&p.expr, row, &ctx)?);
9550        }
9551        output_rows.push(Row::new(values));
9552    }
9553    apply_offset_and_limit(
9554        &mut output_rows,
9555        stmt.offset_literal(),
9556        stmt.limit_literal(),
9557    );
9558    let columns: Vec<ColumnSchema> = projection
9559        .into_iter()
9560        .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
9561        .collect();
9562    Ok(QueryResult::Rows {
9563        columns,
9564        rows: output_rows,
9565    })
9566}
9567
9568/// v7.20 P4 — hot-row POSITION seek for the mutation paths
9569/// (UPDATE / DELETE index their planned writes by position in
9570/// `table.rows()`, so the Cow-row shape `try_index_seek`
9571/// returns doesn't fit). Same top-level-AND recursion and
9572/// col=literal resolution; the caller re-applies the full WHERE
9573/// to every returned row so the index only narrows candidates.
9574///
9575/// Returns `None` (→ caller full-scans) when no equality leaf
9576/// hits an index OR any matching locator lives in the cold tier
9577/// — the mutation paths operate on hot rows, and the PK
9578/// promote-then-walk upstream already handles the
9579/// cold-single-row case.
9580fn try_index_seek_positions(
9581    where_expr: &Expr,
9582    schema_cols: &[ColumnSchema],
9583    table: &Table,
9584    table_alias: &str,
9585) -> Option<Vec<usize>> {
9586    if let Expr::Binary {
9587        lhs,
9588        op: BinOp::And,
9589        rhs,
9590    } = where_expr
9591    {
9592        if let Some(p) = try_index_seek_positions(lhs, schema_cols, table, table_alias) {
9593            return Some(p);
9594        }
9595        return try_index_seek_positions(rhs, schema_cols, table, table_alias);
9596    }
9597    let Expr::Binary {
9598        lhs,
9599        op: BinOp::Eq,
9600        rhs,
9601    } = where_expr
9602    else {
9603        return None;
9604    };
9605    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
9606        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
9607    let idx = table.index_on(col_pos)?;
9608    let key = IndexKey::from_value(&value)?;
9609    let locators = idx.lookup_eq(&key);
9610    let mut out = Vec::with_capacity(locators.len());
9611    for loc in locators {
9612        match *loc {
9613            spg_storage::RowLocator::Hot(i) => out.push(i),
9614            spg_storage::RowLocator::Cold { .. } => return None,
9615        }
9616    }
9617    Some(out)
9618}
9619
9620fn try_index_seek<'a>(
9621    where_expr: &Expr,
9622    schema_cols: &[ColumnSchema],
9623    catalog: &'a Catalog,
9624    table: &'a Table,
9625    table_alias: &str,
9626) -> Option<Vec<Cow<'a, Row>>> {
9627    // v7.11.3 — recurse through top-level `AND` so a PG-style
9628    // composite predicate like `WHERE id = 1 AND created_at > $1`
9629    // still hits the index on `id`. The caller re-applies the
9630    // full WHERE expression to each returned row, so dropping the
9631    // residual conjuncts here is correct — the index just narrows
9632    // the candidate set.
9633    if let Expr::Binary {
9634        lhs,
9635        op: BinOp::And,
9636        rhs,
9637    } = where_expr
9638    {
9639        // Try LHS first (typical convention: leading equality on
9640        // the indexed column comes first in user-written SQL).
9641        if let Some(rows) = try_index_seek(lhs, schema_cols, catalog, table, table_alias) {
9642            return Some(rows);
9643        }
9644        return try_index_seek(rhs, schema_cols, catalog, table, table_alias);
9645    }
9646    let Expr::Binary {
9647        lhs,
9648        op: BinOp::Eq,
9649        rhs,
9650    } = where_expr
9651    else {
9652        return None;
9653    };
9654    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
9655        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
9656    let idx = table.index_on(col_pos)?;
9657    let key = IndexKey::from_value(&value)?;
9658    let locators = idx.lookup_eq(&key);
9659    let table_name = table.schema().name.as_str();
9660    // v5.1: each locator dispatches to either the hot tier (zero-
9661    // copy borrow of `table.rows()[i]`) or a cold-tier segment
9662    // (one page read + dense row decode, ~µs scale). Cold rows are
9663    // returned as `Cow::Owned` so the caller's `&Row` iteration
9664    // doesn't see a tier distinction; pre-freezer (no cold
9665    // segments loaded) every locator is `Hot` and every entry is
9666    // `Cow::Borrowed` — identical cost to the pre-v5.1 path.
9667    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(locators.len());
9668    for loc in locators {
9669        match *loc {
9670            spg_storage::RowLocator::Hot(i) => {
9671                if let Some(row) = table.rows().get(i) {
9672                    out.push(Cow::Borrowed(row));
9673                }
9674            }
9675            spg_storage::RowLocator::Cold { segment_id, .. } => {
9676                if let Some(row) = catalog.resolve_cold_locator(table_name, segment_id, &key) {
9677                    out.push(Cow::Owned(row));
9678                }
9679            }
9680        }
9681    }
9682    Some(out)
9683}
9684
9685/// v7.12.3 — GIN-accelerated candidate seek for `WHERE col @@ <ts_query>`.
9686///
9687/// Recurses through top-level `AND` like [`try_index_seek`] so a
9688/// composite predicate `WHERE search_vector @@ q AND id > $1` still
9689/// hits the GIN index on `search_vector` — the caller re-applies the
9690/// full WHERE expression to each returned candidate, so dropping the
9691/// `id > $1` residual here stays semantically correct.
9692///
9693/// Returns `None` when:
9694///   - no leaf is a `col @@ <rhs>` shape on a GIN-indexed column;
9695///   - the RHS can't be const-evaluated to a `Value::TsQuery`
9696///     (typically because it references row columns);
9697///   - the resolved `TsQuery` uses query shapes the MVP doesn't
9698///     accelerate (`Not`, `Phrase` — those fall through to full scan).
9699///
9700/// On `Some(rows)` the caller iterates only `rows` and re-evaluates
9701/// the full `@@` predicate per row, so an over-approximate candidate
9702/// set is safe.
9703fn try_gin_seek<'a>(
9704    where_expr: &Expr,
9705    schema_cols: &[ColumnSchema],
9706    catalog: &'a Catalog,
9707    table: &'a Table,
9708    table_alias: &str,
9709    ctx: &eval::EvalContext<'_>,
9710) -> Option<Vec<Cow<'a, Row>>> {
9711    if let Expr::Binary {
9712        lhs,
9713        op: BinOp::And,
9714        rhs,
9715    } = where_expr
9716    {
9717        if let Some(rows) = try_gin_seek(lhs, schema_cols, catalog, table, table_alias, ctx) {
9718            return Some(rows);
9719        }
9720        return try_gin_seek(rhs, schema_cols, catalog, table, table_alias, ctx);
9721    }
9722    // v7.17.0 Phase 3.P0-44 — MySQL `MATCH(col1, col2) AGAINST (...)`
9723    // desugars into `(to_tsvector(col1) @@ q) OR (to_tsvector(col2) @@ q)`
9724    // in the parser. To accelerate the multi-column case, walk OR the same
9725    // way we walk AND: only emit a candidate set if BOTH sides can seek
9726    // (otherwise the OR result is unbounded and we must fall through to
9727    // the full scan). Candidates are union'd; the caller's WHERE re-eval
9728    // verifies the full predicate per row, so duplicates / supersets stay
9729    // semantically safe.
9730    if let Expr::Binary {
9731        lhs,
9732        op: BinOp::Or,
9733        rhs,
9734    } = where_expr
9735    {
9736        let left = try_gin_seek(lhs, schema_cols, catalog, table, table_alias, ctx)?;
9737        let right = try_gin_seek(rhs, schema_cols, catalog, table, table_alias, ctx)?;
9738        let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(left.len() + right.len());
9739        out.extend(left);
9740        out.extend(right);
9741        return Some(out);
9742    }
9743    let Expr::Binary {
9744        lhs,
9745        op: BinOp::TsMatch,
9746        rhs,
9747    } = where_expr
9748    else {
9749        return None;
9750    };
9751    // Either side can be the column; pgvector idiom (`vec @@ q`)
9752    // hits the first arm, FROM-clause-derived (`plainto_tsquery($1)
9753    // q ... WHERE search_vector @@ q`) the same. CROSS JOIN derived
9754    // tables resolve `q` to a Column too.
9755    let (col_pos, query) = resolve_gin_col_query(lhs, rhs, schema_cols, table_alias, ctx)
9756        .or_else(|| resolve_gin_col_query(rhs, lhs, schema_cols, table_alias, ctx))?;
9757    // v7.17.0 Phase 3.P0-44 — MySQL `FULLTEXT KEY` builds a
9758    // `IndexKind::GinFulltext` posting list (Phase 2.2). It shares
9759    // the same `gin_lookup_word` shape as the tsvector-typed GIN,
9760    // so the MATCH-AGAINST `@@` predicate (desugared by the parser
9761    // into `to_tsvector(col) @@ plainto_tsquery('term')`) routes
9762    // through the same candidate-set seek.
9763    let idx = table
9764        .indices()
9765        .iter()
9766        .find(|i| i.column_position == col_pos && (i.is_gin() || i.is_gin_fulltext()))?;
9767    let candidates = gin_query_candidates(idx, &query)?;
9768    let _ = catalog; // cold-tier row resolution unused in MVP; see below.
9769    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(candidates.len());
9770    for loc in candidates {
9771        match loc {
9772            spg_storage::RowLocator::Hot(i) => {
9773                if let Some(row) = table.rows().get(i) {
9774                    out.push(Cow::Borrowed(row));
9775                }
9776            }
9777            // GIN cold-tier rows in the MVP: skipped, matching the
9778            // full-scan `@@` path which itself only iterates
9779            // `table.rows()` (hot tier). When v7.13+ adds cold-tier
9780            // scan-time materialisation for `@@`, the parallel
9781            // resolution lands here; until then both paths see the
9782            // same hot-only candidate set so correctness is preserved.
9783            spg_storage::RowLocator::Cold { .. } => {}
9784        }
9785    }
9786    Some(out)
9787}
9788
9789/// v7.15.0 — trigram-GIN-accelerated candidate seek for
9790/// `WHERE col LIKE '<pat>'` and `WHERE col ILIKE '<pat>'` when
9791/// the column has a `gin_trgm_ops` GIN index.
9792///
9793/// Walks top-level `AND` so multi-predicate WHEREs (`col LIKE
9794/// 'foo%' AND id > 1`) still hit the trigram index; the caller
9795/// re-evaluates the full WHERE per candidate row, so dropping
9796/// non-LIKE conjuncts here stays semantically correct.
9797///
9798/// Returns `None` when:
9799///   - no leaf is `col LIKE/ILIKE <literal>` on a trigram-GIN-
9800///     indexed column;
9801///   - the pattern's literal runs are too short to constrain
9802///     (pattern decomposes into `< 3`-char runs, e.g. `%ab%`);
9803///   - the pattern doesn't const-evaluate to a TEXT.
9804fn try_trgm_seek<'a>(
9805    where_expr: &Expr,
9806    schema_cols: &[ColumnSchema],
9807    table: &'a Table,
9808    table_alias: &str,
9809) -> Option<Vec<Cow<'a, Row>>> {
9810    if let Expr::Binary {
9811        lhs,
9812        op: BinOp::And,
9813        rhs,
9814    } = where_expr
9815    {
9816        if let Some(rows) = try_trgm_seek(lhs, schema_cols, table, table_alias) {
9817            return Some(rows);
9818        }
9819        return try_trgm_seek(rhs, schema_cols, table, table_alias);
9820    }
9821    // LIKE node is what carries the column reference + pattern.
9822    // ILIKE is the same AST node — PG's LIKE/ILIKE both lower
9823    // through `Expr::Like { expr, pattern, negated }`. The trigram
9824    // index posting-list keys are already lower-cased and
9825    // case-folded, so we only need the pattern's literal runs.
9826    let Expr::Like { expr, pattern, .. } = where_expr else {
9827        return None;
9828    };
9829    // Column side.
9830    let Expr::Column(c) = expr.as_ref() else {
9831        return None;
9832    };
9833    if let Some(q) = &c.qualifier
9834        && q != table_alias
9835    {
9836        return None;
9837    }
9838    let col_pos = schema_cols
9839        .iter()
9840        .position(|s| s.name.eq_ignore_ascii_case(&c.name))?;
9841    // Index must exist on that column AND be a trigram-GIN.
9842    let idx = table
9843        .indices()
9844        .iter()
9845        .find(|i| i.column_position == col_pos && i.is_gin_trgm())?;
9846    // Pattern side must be a literal TEXT — anything else (column
9847    // ref, function call, parameter that hasn't been bound yet)
9848    // falls through to full scan.
9849    let Expr::Literal(spg_sql::ast::Literal::String(pat)) = pattern.as_ref() else {
9850        return None;
9851    };
9852    let trigrams = spg_storage::trgm::trigrams_from_like_pattern(pat)?;
9853    // Intersect every trigram's posting list. Empty intersection
9854    // → empty candidate set (caller short-circuits its row loop).
9855    let mut iter = trigrams.iter();
9856    let first = iter.next()?;
9857    let mut acc: Vec<spg_storage::RowLocator> = {
9858        let mut v = idx.gin_trgm_lookup(first).to_vec();
9859        v.sort_by_key(locator_sort_key);
9860        v.dedup_by_key(|l| locator_sort_key(l));
9861        v
9862    };
9863    for tri in iter {
9864        let mut next: Vec<spg_storage::RowLocator> = idx.gin_trgm_lookup(tri).to_vec();
9865        next.sort_by_key(locator_sort_key);
9866        next.dedup_by_key(|l| locator_sort_key(l));
9867        // Sorted-merge intersection.
9868        let mut merged: Vec<spg_storage::RowLocator> =
9869            Vec::with_capacity(acc.len().min(next.len()));
9870        let (mut i, mut j) = (0usize, 0usize);
9871        while i < acc.len() && j < next.len() {
9872            let lk = locator_sort_key(&acc[i]);
9873            let rk = locator_sort_key(&next[j]);
9874            match lk.cmp(&rk) {
9875                core::cmp::Ordering::Less => i += 1,
9876                core::cmp::Ordering::Greater => j += 1,
9877                core::cmp::Ordering::Equal => {
9878                    merged.push(acc[i]);
9879                    i += 1;
9880                    j += 1;
9881                }
9882            }
9883        }
9884        acc = merged;
9885        if acc.is_empty() {
9886            break;
9887        }
9888    }
9889    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(acc.len());
9890    for loc in acc {
9891        if let spg_storage::RowLocator::Hot(i) = loc
9892            && let Some(row) = table.rows().get(i)
9893        {
9894            out.push(Cow::Borrowed(row));
9895        }
9896        // Cold-tier rows: skipped in MVP (same as try_gin_seek).
9897    }
9898    Some(out)
9899}
9900
9901/// v7.12.3 — extract `(column_position, TsQueryAst)` when one side of
9902/// the binary is a column reference to a GIN-indexed tsvector column
9903/// and the other side const-evaluates to a `Value::TsQuery`. Returns
9904/// `None` if the column reference is for the wrong table alias, or if
9905/// the RHS expression depends on row data.
9906fn resolve_gin_col_query(
9907    col_side: &Expr,
9908    query_side: &Expr,
9909    schema_cols: &[ColumnSchema],
9910    table_alias: &str,
9911    ctx: &eval::EvalContext<'_>,
9912) -> Option<(usize, spg_storage::TsQueryAst)> {
9913    // v7.17.0 Phase 3.P0-44 — the MATCH AGAINST desugar wraps the
9914    // column in `to_tsvector('simple', col)`, so we peel that wrapper
9915    // before the column lookup. Direct `col @@ tsquery` paths (the
9916    // tsvector-typed v7.12 surface) skip the wrapper entirely.
9917    let column = match col_side {
9918        Expr::Column(c) => c,
9919        Expr::FunctionCall { name, args }
9920            if name.eq_ignore_ascii_case("to_tsvector") && !args.is_empty() =>
9921        {
9922            // PG `to_tsvector` accepts either `to_tsvector(col)` or
9923            // `to_tsvector(config, col)`. In both shapes the column
9924            // we care about is the final argument.
9925            if let Expr::Column(c) = args.last().unwrap() {
9926                c
9927            } else {
9928                return None;
9929            }
9930        }
9931        _ => return None,
9932    };
9933    let c = column;
9934    if let Some(q) = &c.qualifier
9935        && q != table_alias
9936    {
9937        return None;
9938    }
9939    let pos = schema_cols.iter().position(|s| s.name == c.name)?;
9940    // Const-evaluate the query side with an empty row — fails fast
9941    // (with a `ColumnNotFound` / similar) if the expression actually
9942    // depends on row data, which is exactly the bail signal we want.
9943    let empty_row = Row::new(Vec::new());
9944    let v = eval::eval_expr(query_side, &empty_row, ctx).ok()?;
9945    let Value::TsQuery(q) = v else { return None };
9946    Some((pos, q))
9947}
9948
9949/// v7.12.3 — walk a `TsQueryAst` against an [`IndexKind::Gin`] index
9950/// to produce a candidate row-locator set. Returns `None` for query
9951/// shapes the MVP doesn't accelerate (`Not` / `Phrase` — both bail to
9952/// full scan since their semantics need either complementation across
9953/// the whole row set or positional verification beyond what the
9954/// posting list carries).
9955///
9956/// Candidate sets are over-approximate — the caller re-applies the
9957/// full `@@` predicate per row, so reporting "row was in some
9958/// posting list" without verifying positions / weights stays correct.
9959fn gin_query_candidates(
9960    idx: &spg_storage::Index,
9961    query: &spg_storage::TsQueryAst,
9962) -> Option<Vec<spg_storage::RowLocator>> {
9963    use spg_storage::TsQueryAst;
9964    match query {
9965        TsQueryAst::Term { word, .. } => {
9966            let mut v: Vec<spg_storage::RowLocator> = idx.gin_lookup_word(word).to_vec();
9967            v.sort_by_key(locator_sort_key);
9968            v.dedup_by_key(|l| locator_sort_key(l));
9969            Some(v)
9970        }
9971        TsQueryAst::And(l, r) => {
9972            let mut left = gin_query_candidates(idx, l)?;
9973            let mut right = gin_query_candidates(idx, r)?;
9974            left.sort_by_key(locator_sort_key);
9975            right.sort_by_key(locator_sort_key);
9976            // Sorted-merge intersection.
9977            let mut out: Vec<spg_storage::RowLocator> = Vec::new();
9978            let (mut i, mut j) = (0usize, 0usize);
9979            while i < left.len() && j < right.len() {
9980                let lk = locator_sort_key(&left[i]);
9981                let rk = locator_sort_key(&right[j]);
9982                match lk.cmp(&rk) {
9983                    core::cmp::Ordering::Less => i += 1,
9984                    core::cmp::Ordering::Greater => j += 1,
9985                    core::cmp::Ordering::Equal => {
9986                        out.push(left[i]);
9987                        i += 1;
9988                        j += 1;
9989                    }
9990                }
9991            }
9992            Some(out)
9993        }
9994        TsQueryAst::Or(l, r) => {
9995            let mut out = gin_query_candidates(idx, l)?;
9996            out.extend(gin_query_candidates(idx, r)?);
9997            out.sort_by_key(locator_sort_key);
9998            out.dedup_by_key(|l| locator_sort_key(l));
9999            Some(out)
10000        }
10001        // Not / Phrase bail to full scan in the MVP. Not needs
10002        // complementation against the whole row set (not represented
10003        // in the posting-list view); Phrase needs positional
10004        // verification beyond what `word → rows` carries.
10005        TsQueryAst::Not(_) | TsQueryAst::Phrase { .. } => None,
10006    }
10007}
10008
10009/// v7.12.3 — total ordering on `RowLocator` for sort/dedup purposes
10010/// inside the GIN intersection / union loops. Hot rows order by their
10011/// row index; Cold rows order after all Hot rows, then by
10012/// `(segment_id, the cold sub-key)`.
10013fn locator_sort_key(l: &spg_storage::RowLocator) -> (u8, u64, u64) {
10014    match *l {
10015        spg_storage::RowLocator::Hot(i) => (0, i as u64, 0),
10016        spg_storage::RowLocator::Cold {
10017            segment_id,
10018            page_offset,
10019        } => (1, u64::from(segment_id), u64::from(page_offset)),
10020    }
10021}
10022
10023/// v5.2.3: extract `(column_position, IndexKey)` when `where_expr`
10024/// is a simple `col = literal` predicate suitable for a `BTree` index
10025/// seek. Used by `exec_update_cancel` / `exec_delete_cancel` to
10026/// decide whether a write touches a cold-tier row (which requires
10027/// promote-on-write / shadow-on-delete) before falling through to
10028/// the hot-tier row walk.
10029///
10030/// Returns `None` for any predicate shape the planner can't push
10031/// down to an index seek — complex WHERE clauses always take the
10032/// hot-only path (cold rows are immutable to non-indexed writes
10033/// until a future scan-fanout sub-version).
10034fn try_pk_predicate(
10035    where_expr: &Expr,
10036    schema_cols: &[ColumnSchema],
10037    table_alias: &str,
10038) -> Option<(usize, IndexKey)> {
10039    let Expr::Binary {
10040        lhs,
10041        op: BinOp::Eq,
10042        rhs,
10043    } = where_expr
10044    else {
10045        return None;
10046    };
10047    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
10048        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
10049    let key = IndexKey::from_value(&value)?;
10050    Some((col_pos, key))
10051}
10052
10053fn resolve_col_literal_pair(
10054    col_side: &Expr,
10055    lit_side: &Expr,
10056    schema_cols: &[ColumnSchema],
10057    table_alias: &str,
10058) -> Option<(usize, Value)> {
10059    let Expr::Column(c) = col_side else {
10060        return None;
10061    };
10062    if let Some(q) = &c.qualifier
10063        && q != table_alias
10064    {
10065        return None;
10066    }
10067    let pos = schema_cols.iter().position(|s| s.name == c.name)?;
10068    let Expr::Literal(l) = lit_side else {
10069        return None;
10070    };
10071    let v = match l {
10072        Literal::Integer(n) => {
10073            if let Ok(small) = i32::try_from(*n) {
10074                Value::Int(small)
10075            } else {
10076                Value::BigInt(*n)
10077            }
10078        }
10079        Literal::Float(x) => Value::Float(*x),
10080        Literal::String(s) => Value::Text(s.clone()),
10081        Literal::Bool(b) => Value::Bool(*b),
10082        Literal::Null => Value::Null,
10083        // Vector, array and Interval literals can't be used as B-tree
10084        // index keys. Tell the planner to fall back to full-scan.
10085        Literal::Vector(_)
10086        | Literal::Interval { .. }
10087        | Literal::TextArray(_)
10088        | Literal::IntArray(_)
10089        | Literal::BigIntArray(_) => return None,
10090    };
10091    Some((pos, v))
10092}
10093
10094/// Find the schema entry that a SELECT-list `Expr::Column` refers to.
10095/// Mirrors `resolve_column` in `eval.rs`, but returns a proper
10096/// `EngineError` so the projection-build path keeps `UnknownQualifier`
10097/// vs `ColumnNotFound` distinct.
10098fn resolve_projection_column<'a>(
10099    c: &ColumnName,
10100    schema_cols: &'a [ColumnSchema],
10101    table_alias: &str,
10102) -> Result<&'a ColumnSchema, EngineError> {
10103    if let Some(q) = &c.qualifier {
10104        let composite = alloc::format!("{q}.{name}", name = c.name);
10105        if let Some(s) = schema_cols.iter().find(|s| s.name == composite) {
10106            return Ok(s);
10107        }
10108        // Single-table case: the qualifier may equal the active alias —
10109        // then look for the bare column name.
10110        if q == table_alias
10111            && let Some(s) = schema_cols.iter().find(|s| s.name == c.name)
10112        {
10113            return Ok(s);
10114        }
10115        // For multi-table schemas the qualifier is unknown only if no
10116        // column bears the "<q>." prefix. For single-table, the alias
10117        // mismatch alone is enough.
10118        let prefix = alloc::format!("{q}.");
10119        let qualifier_known =
10120            q == table_alias || schema_cols.iter().any(|s| s.name.starts_with(&prefix));
10121        if !qualifier_known {
10122            return Err(EngineError::Eval(EvalError::UnknownQualifier {
10123                qualifier: q.clone(),
10124            }));
10125        }
10126        return Err(EngineError::Eval(EvalError::ColumnNotFound {
10127            name: c.name.clone(),
10128        }));
10129    }
10130    if let Some(s) = schema_cols.iter().find(|s| s.name == c.name) {
10131        return Ok(s);
10132    }
10133    let suffix = alloc::format!(".{name}", name = c.name);
10134    let mut matches = schema_cols.iter().filter(|s| s.name.ends_with(&suffix));
10135    let first = matches.next();
10136    let extra = matches.next();
10137    match (first, extra) {
10138        (Some(s), None) => Ok(s),
10139        (Some(_), Some(_)) => Err(EngineError::Eval(EvalError::TypeMismatch {
10140            detail: alloc::format!("ambiguous column reference: {}", c.name),
10141        })),
10142        _ => Err(EngineError::Eval(EvalError::ColumnNotFound {
10143            name: c.name.clone(),
10144        })),
10145    }
10146}
10147
10148fn build_projection(
10149    items: &[SelectItem],
10150    schema_cols: &[ColumnSchema],
10151    table_alias: &str,
10152) -> Result<Vec<ProjectedItem>, EngineError> {
10153    let mut out = Vec::new();
10154    for item in items {
10155        match item {
10156            SelectItem::Wildcard => {
10157                for col in schema_cols {
10158                    out.push(ProjectedItem {
10159                        expr: Expr::Column(ColumnName {
10160                            qualifier: None,
10161                            name: col.name.clone(),
10162                        }),
10163                        output_name: col.name.clone(),
10164                        ty: col.ty,
10165                        nullable: col.nullable,
10166                    });
10167                }
10168            }
10169            SelectItem::Expr { expr, alias } => {
10170                // Plain column ref keeps full schema info (real type +
10171                // nullability). For compound expressions try the
10172                // describe-side function-return-type table first
10173                // (e.g. `SELECT now()` → Timestamptz, `SELECT
10174                // concat(…)` → Text). Falls back to nullable Text
10175                // for shapes the describe path can't resolve.
10176                if let Expr::Column(c) = expr {
10177                    let sch = resolve_projection_column(c, schema_cols, table_alias)?;
10178                    let output_name = alias.clone().unwrap_or_else(|| c.name.clone());
10179                    out.push(ProjectedItem {
10180                        expr: expr.clone(),
10181                        output_name,
10182                        ty: sch.ty,
10183                        nullable: sch.nullable,
10184                    });
10185                } else if let Some(shape) = describe::describe_expr(expr, schema_cols) {
10186                    let output_name = alias.clone().unwrap_or_else(|| expr.to_string());
10187                    out.push(ProjectedItem {
10188                        expr: expr.clone(),
10189                        output_name,
10190                        ty: shape.ty,
10191                        nullable: shape.nullable,
10192                    });
10193                } else {
10194                    let output_name = alias.clone().unwrap_or_else(|| expr.to_string());
10195                    out.push(ProjectedItem {
10196                        expr: expr.clone(),
10197                        output_name,
10198                        ty: DataType::Text,
10199                        nullable: true,
10200                    });
10201                }
10202            }
10203        }
10204    }
10205    Ok(out)
10206}
10207
10208/// Promote an integer to a NUMERIC value at the requested scale.
10209/// Rejects values that, after scaling, would overflow the column's
10210/// precision budget.
10211fn numeric_from_integer(
10212    n: i128,
10213    precision: u8,
10214    scale: u8,
10215    col_name: &str,
10216) -> Result<Value, EngineError> {
10217    let factor = pow10_i128(scale);
10218    let scaled = n.checked_mul(factor).ok_or_else(|| {
10219        EngineError::Unsupported(alloc::format!(
10220            "integer overflow scaling value for column `{col_name}` to scale {scale}"
10221        ))
10222    })?;
10223    check_precision(scaled, precision, col_name)?;
10224    Ok(Value::Numeric { scaled, scale })
10225}
10226
10227/// Float → NUMERIC. Uses round-half-away-from-zero on `x * 10^scale`,
10228/// then verifies the result fits the column's precision.
10229#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
10230fn numeric_from_float(
10231    x: f64,
10232    precision: u8,
10233    scale: u8,
10234    col_name: &str,
10235) -> Result<Value, EngineError> {
10236    if !x.is_finite() {
10237        return Err(EngineError::Unsupported(alloc::format!(
10238            "cannot store non-finite float in NUMERIC column `{col_name}`"
10239        )));
10240    }
10241    let mut factor = 1.0_f64;
10242    for _ in 0..scale {
10243        factor *= 10.0;
10244    }
10245    // Round half-away-from-zero by biasing then casting (`as i128`
10246    // truncates toward zero, so the bias + truncation gives the
10247    // desired rounding). `f64::floor` / `ceil` live in std; we don't
10248    // need them — the cast handles the truncation step.
10249    let shifted = x * factor;
10250    let biased = if shifted >= 0.0 {
10251        shifted + 0.5
10252    } else {
10253        shifted - 0.5
10254    };
10255    // Range-check before casting back to i128 — the cast itself is
10256    // saturating in Rust, which would silently truncate huge inputs.
10257    if !(-1e38..=1e38).contains(&biased) {
10258        return Err(EngineError::Unsupported(alloc::format!(
10259            "value {x} overflows NUMERIC range for column `{col_name}`"
10260        )));
10261    }
10262    let scaled = biased as i128;
10263    check_precision(scaled, precision, col_name)?;
10264    Ok(Value::Numeric { scaled, scale })
10265}
10266
10267/// v7.17.0 Phase 3.P0-67 — parse PG-canonical decimal text into
10268/// `(mantissa: i128, source_scale: u8)`. Accepts optional sign,
10269/// optional integer part, optional fractional part. Rejects
10270/// scientific notation, embedded spaces, locale-specific
10271/// thousand separators. Returns None on bad input — coerce_value
10272/// turns that into a TypeMismatch error.
10273fn parse_numeric_text(s: &str) -> Option<(i128, u8)> {
10274    let s = s.trim();
10275    if s.is_empty() {
10276        return None;
10277    }
10278    let (negative, rest) = match s.as_bytes()[0] {
10279        b'-' => (true, &s[1..]),
10280        b'+' => (false, &s[1..]),
10281        _ => (false, s),
10282    };
10283    if rest.is_empty() {
10284        return None;
10285    }
10286    // Reject scientific notation — bigdecimal collapses it before
10287    // hitting the wire, and we want a clear error if a stray `e`
10288    // sneaks in.
10289    if rest.bytes().any(|b| b == b'e' || b == b'E') {
10290        return None;
10291    }
10292    let (int_part, frac_part) = match rest.find('.') {
10293        Some(idx) => (&rest[..idx], &rest[idx + 1..]),
10294        None => (rest, ""),
10295    };
10296    if int_part.is_empty() && frac_part.is_empty() {
10297        return None;
10298    }
10299    if int_part.bytes().any(|b| !b.is_ascii_digit()) {
10300        return None;
10301    }
10302    if frac_part.bytes().any(|b| !b.is_ascii_digit()) {
10303        return None;
10304    }
10305    let scale_u32 = u32::try_from(frac_part.len()).ok()?;
10306    if scale_u32 > u32::from(u8::MAX) {
10307        return None;
10308    }
10309    let scale = scale_u32 as u8;
10310    let mut digits = alloc::string::String::with_capacity(int_part.len() + frac_part.len() + 1);
10311    if negative {
10312        digits.push('-');
10313    }
10314    digits.push_str(int_part);
10315    digits.push_str(frac_part);
10316    // Strip a leading "+0..0" so parse doesn't choke on "00" etc.
10317    let digits = if digits == "-" {
10318        return None;
10319    } else if digits.is_empty() {
10320        "0"
10321    } else {
10322        digits.as_str()
10323    };
10324    let mantissa: i128 = digits.parse().ok()?;
10325    Some((mantissa, scale))
10326}
10327
10328/// Move a Numeric value from `src_scale` to `dst_scale`. Going up
10329/// multiplies by 10; going down rounds half-away-from-zero.
10330fn numeric_rescale(
10331    scaled: i128,
10332    src_scale: u8,
10333    precision: u8,
10334    dst_scale: u8,
10335    col_name: &str,
10336) -> Result<Value, EngineError> {
10337    let new_scaled = if dst_scale >= src_scale {
10338        let bump = pow10_i128(dst_scale - src_scale);
10339        scaled.checked_mul(bump).ok_or_else(|| {
10340            EngineError::Unsupported(alloc::format!(
10341                "overflow rescaling NUMERIC for column `{col_name}`"
10342            ))
10343        })?
10344    } else {
10345        let drop = pow10_i128(src_scale - dst_scale);
10346        let half = drop / 2;
10347        if scaled >= 0 {
10348            (scaled + half) / drop
10349        } else {
10350            (scaled - half) / drop
10351        }
10352    };
10353    check_precision(new_scaled, precision, col_name)?;
10354    Ok(Value::Numeric {
10355        scaled: new_scaled,
10356        scale: dst_scale,
10357    })
10358}
10359
10360/// Drop the fractional part of a scaled integer, returning the integer
10361/// portion (toward zero). Used for NUMERIC → INT casts.
10362const fn numeric_truncate_to_integer(scaled: i128, scale: u8) -> i128 {
10363    if scale == 0 {
10364        return scaled;
10365    }
10366    let factor = pow10_i128_const(scale);
10367    scaled / factor
10368}
10369
10370/// Verify a scaled NUMERIC value fits the column's declared precision.
10371/// `precision == 0` is the "unconstrained" form (bare `NUMERIC`); we
10372/// skip the check there.
10373fn check_precision(scaled: i128, precision: u8, col_name: &str) -> Result<(), EngineError> {
10374    if precision == 0 {
10375        return Ok(());
10376    }
10377    let limit = pow10_i128(precision);
10378    if scaled.unsigned_abs() >= limit.unsigned_abs() {
10379        return Err(EngineError::Unsupported(alloc::format!(
10380            "NUMERIC value exceeds precision {precision} for column `{col_name}`"
10381        )));
10382    }
10383    Ok(())
10384}
10385
10386const fn pow10_i128_const(p: u8) -> i128 {
10387    let mut acc: i128 = 1;
10388    let mut i = 0;
10389    while i < p {
10390        acc *= 10;
10391        i += 1;
10392    }
10393    acc
10394}
10395
10396fn pow10_i128(p: u8) -> i128 {
10397    pow10_i128_const(p)
10398}
10399
10400/// Walk a parsed `Statement`, swapping any `NOW()` /
10401/// `CURRENT_TIMESTAMP()` / `CURRENT_DATE()` function calls for a
10402/// literal cast that wraps the engine's per-statement clock reading.
10403/// When `now_micros` is `None`, calls stay as-is and surface as
10404/// `unknown function` at eval time — keeps the error path explicit.
10405/// v4.10: pre-walk the WHERE / projection / etc. of a SELECT and
10406/// replace every subquery node with a materialised literal. SPG
10407/// only supports uncorrelated subqueries — the inner SELECT does
10408/// not see outer-row columns, so the result is the same for every
10409/// outer row and can be evaluated once.
10410///
10411/// Returns the rewritten statement; the caller passes this to the
10412/// regular row-loop executor which no longer sees Subquery nodes
10413/// in its tree.
10414impl Engine {
10415    /// v4.12 window executor. Implements `ROW_NUMBER` / `RANK` /
10416    /// `DENSE_RANK` and the partition-aware aggregates `SUM` /
10417    /// `AVG` / `COUNT` / `MIN` / `MAX`. The plan is:
10418    /// 1. Apply the WHERE filter.
10419    /// 2. For each unique `WindowFunction` node in the projection,
10420    ///    partition + sort, compute the per-row value.
10421    /// 3. Append the window values as synthetic columns (`__win_N`)
10422    ///    to the row schema.
10423    /// 4. Rewrite the projection to read those columns.
10424    /// 5. Hand off to the regular project / ORDER BY / LIMIT pipe.
10425    #[allow(
10426        clippy::too_many_lines,
10427        clippy::type_complexity,
10428        clippy::needless_range_loop
10429    )] // window-eval is one cohesive pipe; splitting fragments
10430    fn exec_select_with_window(
10431        &self,
10432        stmt: &SelectStatement,
10433        cancel: CancelToken<'_>,
10434    ) -> Result<QueryResult, EngineError> {
10435        let from = stmt.from.as_ref().ok_or_else(|| {
10436            EngineError::Unsupported("window functions require a FROM clause".into())
10437        })?;
10438        // v7.17.0 Phase 3.P0-43 — JOIN + window functions. Phase
10439        // 3.6 rejected this combination outright ("queued for
10440        // v5.x"); P0-43 materialises the join + WHERE through the
10441        // existing nested-loop helper and runs the window pipeline
10442        // on the joined row set with the combined `alias.col`
10443        // schema. The window expressions resolve through the
10444        // qualifier-aware column resolver same as the aggregate /
10445        // projection paths on JOIN.
10446        let (schema_cols_owned, alias_opt): (Vec<ColumnSchema>, Option<&str>);
10447        let filtered: Vec<Row>;
10448        if from.joins.is_empty() {
10449            let primary = &from.primary;
10450            let table = self.active_catalog().get(&primary.name).ok_or_else(|| {
10451                StorageError::TableNotFound {
10452                    name: primary.name.clone(),
10453                }
10454            })?;
10455            let alias = primary.alias.as_deref().unwrap_or(primary.name.as_str());
10456            schema_cols_owned = table.schema().columns.clone();
10457            alias_opt = Some(alias);
10458            // Materialise WHERE-filtered rows owned so the JOIN
10459            // and single-table paths share a single downstream
10460            // shape. The clone is cheap relative to the window
10461            // computation that follows.
10462            let ctx = self.ev_ctx(&schema_cols_owned, alias_opt);
10463            let mut owned: Vec<Row> = Vec::new();
10464            for (i, row) in table.rows().iter().enumerate() {
10465                if i.is_multiple_of(256) {
10466                    cancel.check()?;
10467                }
10468                if let Some(w) = &stmt.where_ {
10469                    let cond = eval::eval_expr(w, row, &ctx)?;
10470                    if !matches!(cond, Value::Bool(true)) {
10471                        continue;
10472                    }
10473                }
10474                owned.push(row.clone());
10475            }
10476            filtered = owned;
10477        } else {
10478            let (combined_schema, rows) =
10479                self.build_joined_filtered_rows(from, stmt.where_.as_ref(), cancel, None)?;
10480            schema_cols_owned = combined_schema;
10481            alias_opt = None;
10482            filtered = rows;
10483        }
10484        let schema_cols = &schema_cols_owned;
10485        let ctx = self.ev_ctx(schema_cols, alias_opt);
10486        let alias = alias_opt.unwrap_or("");
10487        let n_rows = filtered.len();
10488        // Borrow refs into the owned row vec once so the downstream
10489        // `compute_window_partition` call (which takes `&[&Row]`) and
10490        // the per-row eval loops share a single backing buffer.
10491        let filtered_refs: Vec<&Row> = filtered.iter().collect();
10492
10493        // 2) Collect unique window function nodes from projection.
10494        let mut window_nodes: Vec<Expr> = Vec::new();
10495        for item in &stmt.items {
10496            if let SelectItem::Expr { expr, .. } = item {
10497                collect_window_nodes(expr, &mut window_nodes);
10498            }
10499        }
10500
10501        // 3) For each window, compute per-row value.
10502        // Index: same order as window_nodes; for row i, win_vals[w][i].
10503        let mut win_vals: Vec<Vec<Value>> = Vec::with_capacity(window_nodes.len());
10504        for wnode in &window_nodes {
10505            let Expr::WindowFunction {
10506                name,
10507                args,
10508                partition_by,
10509                order_by,
10510                frame,
10511                null_treatment,
10512            } = wnode
10513            else {
10514                unreachable!("collect_window_nodes pushes only WindowFunction");
10515            };
10516            // Compute (partition_key, order_key, original_index) for each row.
10517            let mut indexed: Vec<(Vec<Value>, Vec<(Value, bool, Option<bool>)>, usize)> =
10518                Vec::with_capacity(n_rows);
10519            for (i, row) in filtered.iter().enumerate() {
10520                let pkey: Vec<Value> = partition_by
10521                    .iter()
10522                    .map(|p| eval::eval_expr(p, row, &ctx))
10523                    .collect::<Result<_, _>>()?;
10524                let okey: Vec<(Value, bool, Option<bool>)> = order_by
10525                    .iter()
10526                    .map(|(e, desc, nf)| eval::eval_expr(e, row, &ctx).map(|v| (v, *desc, *nf)))
10527                    .collect::<Result<_, _>>()?;
10528                indexed.push((pkey, okey, i));
10529            }
10530            // Sort by (partition_key, order_key). Partition key uses
10531            // a stable encoded form; order key respects ASC/DESC.
10532            indexed.sort_by(|a, b| {
10533                let p_cmp = partition_key_cmp(&a.0, &b.0);
10534                if p_cmp != core::cmp::Ordering::Equal {
10535                    return p_cmp;
10536                }
10537                order_key_cmp(&a.1, &b.1)
10538            });
10539            // Per-partition compute.
10540            let mut out_vals: Vec<Value> = alloc::vec![Value::Null; n_rows];
10541            let mut p_start = 0;
10542            while p_start < indexed.len() {
10543                let mut p_end = p_start + 1;
10544                while p_end < indexed.len()
10545                    && partition_key_cmp(&indexed[p_start].0, &indexed[p_end].0)
10546                        == core::cmp::Ordering::Equal
10547                {
10548                    p_end += 1;
10549                }
10550                // Compute the function within this partition slice.
10551                compute_window_partition(
10552                    name,
10553                    args,
10554                    !order_by.is_empty(),
10555                    frame.as_ref(),
10556                    *null_treatment,
10557                    &indexed[p_start..p_end],
10558                    &filtered_refs,
10559                    &ctx,
10560                    &mut out_vals,
10561                )?;
10562                p_start = p_end;
10563            }
10564            win_vals.push(out_vals);
10565        }
10566
10567        // 4) Build extended schema: original columns + synthetic.
10568        let mut ext_cols = schema_cols.clone();
10569        for i in 0..window_nodes.len() {
10570            ext_cols.push(ColumnSchema::new(
10571                alloc::format!("__win_{i}"),
10572                DataType::Text, // type doesn't matter for projection eval
10573                true,
10574            ));
10575        }
10576        // 5) Build extended rows: each row gets its window values appended.
10577        let mut ext_rows: Vec<Row> = Vec::with_capacity(n_rows);
10578        for i in 0..n_rows {
10579            let mut values = filtered[i].values.clone();
10580            for w in 0..window_nodes.len() {
10581                values.push(win_vals[w][i].clone());
10582            }
10583            ext_rows.push(Row::new(values));
10584        }
10585        // 6) Rewrite the projection: WindowFunction nodes → Column(__win_N).
10586        let mut rewritten_items: Vec<SelectItem> = Vec::with_capacity(stmt.items.len());
10587        for item in &stmt.items {
10588            let new_item = match item {
10589                SelectItem::Wildcard => SelectItem::Wildcard,
10590                SelectItem::Expr { expr, alias } => {
10591                    let mut e = expr.clone();
10592                    rewrite_window_to_columns(&mut e, &window_nodes);
10593                    SelectItem::Expr {
10594                        expr: e,
10595                        alias: alias.clone(),
10596                    }
10597                }
10598            };
10599            rewritten_items.push(new_item);
10600        }
10601
10602        // 7) Project into final rows. JOIN case uses None so the
10603        // qualifier check in `resolve_column` falls through to the
10604        // composite `alias.col` schema lookup; single-table case
10605        // keeps the bare alias so `bare_col` resolution still
10606        // works for the projection's per-row column references.
10607        let ext_ctx = EvalContext::new(&ext_cols, alias_opt);
10608        let projection = build_projection(&rewritten_items, &ext_cols, alias)?;
10609        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::with_capacity(n_rows);
10610        for (i, row) in ext_rows.iter().enumerate() {
10611            if i.is_multiple_of(256) {
10612                cancel.check()?;
10613            }
10614            let mut values = Vec::with_capacity(projection.len());
10615            for p in &projection {
10616                values.push(eval::eval_expr(&p.expr, row, &ext_ctx)?);
10617            }
10618            let order_keys = if stmt.order_by.is_empty() {
10619                Vec::new()
10620            } else {
10621                let mut keys = Vec::with_capacity(stmt.order_by.len());
10622                for o in &stmt.order_by {
10623                    let mut e = o.expr.clone();
10624                    rewrite_window_to_columns(&mut e, &window_nodes);
10625                    let key = eval::eval_expr(&e, row, &ext_ctx)?;
10626                    keys.push(value_to_order_key(&key)?);
10627                }
10628                keys
10629            };
10630            tagged.push((order_keys, Row::new(values)));
10631        }
10632        // ORDER BY + LIMIT/OFFSET on the projected rows.
10633        if !stmt.order_by.is_empty() {
10634            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
10635            sort_by_keys(&mut tagged, &descs);
10636        }
10637        let mut out_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
10638        apply_offset_and_limit(&mut out_rows, stmt.offset_literal(), stmt.limit_literal());
10639        let final_cols: Vec<ColumnSchema> = projection
10640            .into_iter()
10641            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
10642            .collect();
10643        Ok(QueryResult::Rows {
10644            columns: final_cols,
10645            rows: out_rows,
10646        })
10647    }
10648
10649    /// v4.11: materialise each CTE into a temp table inside a
10650    /// cloned catalog, then run the body SELECT against a fresh
10651    /// engine instance that owns the enriched catalog. The clone
10652    /// is moderately expensive — only paid by CTE-bearing queries.
10653    /// Subqueries inside CTE bodies / the main body resolve as
10654    /// usual; `clock_fn` is propagated so `NOW()` lines up.
10655    /// v7.16.2 — mailrs round-10 A.3. Materialise the
10656    /// `information_schema.*` / `pg_catalog.*` virtual views
10657    /// the SELECT references, then re-execute the SELECT
10658    /// against an enriched catalog where those views are real
10659    /// tables. Same pattern as `exec_with_ctes`. The temp
10660    /// engine carries `meta_views_materialised = true` so its
10661    /// own meta-dispatch short-circuits — without that we'd
10662    /// infinite-recurse since the temp catalog's view name
10663    /// still starts with `__spg_info_` and re-triggers the
10664    /// check.
10665    fn exec_select_with_meta_views(
10666        &self,
10667        stmt: &SelectStatement,
10668        cancel: CancelToken<'_>,
10669    ) -> Result<QueryResult, EngineError> {
10670        let mut needed: alloc::collections::BTreeSet<String> = alloc::collections::BTreeSet::new();
10671        collect_meta_view_names(stmt, &mut needed);
10672        let mut catalog = self.active_catalog().clone();
10673        for view in &needed {
10674            if catalog.get(view).is_some() {
10675                continue;
10676            }
10677            match view.as_str() {
10678                "__spg_info_columns" => {
10679                    let (schema, rows) = synth_information_schema_columns(self.active_catalog());
10680                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10681                }
10682                "__spg_info_tables" => {
10683                    let (schema, rows) = synth_information_schema_tables(self.active_catalog());
10684                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10685                }
10686                "__spg_pg_class" => {
10687                    let (schema, rows) = synth_pg_class(self.active_catalog());
10688                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10689                }
10690                "__spg_pg_attribute" => {
10691                    let (schema, rows) = synth_pg_attribute(self.active_catalog());
10692                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10693                }
10694                // v7.17.0 Phase 3.P0-50 — pg_catalog.pg_type for
10695                // sqlx / SQLAlchemy / Diesel / pgAdmin lookups.
10696                "__spg_pg_type" => {
10697                    let (schema, rows) = synth_pg_type(self.active_catalog());
10698                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10699                }
10700                // v7.17.0 Phase 3.P0-51 — pg_catalog.pg_proc for
10701                // function-name introspection (ORM / pgAdmin).
10702                "__spg_pg_proc" => {
10703                    let (schema, rows) = synth_pg_proc(self.active_catalog());
10704                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10705                }
10706                // v7.24 (round-16 D) — pg_catalog.pg_trigger. The
10707                // round-16 "why doesn't prod fire the trigger"
10708                // question was unanswerable because triggers had NO
10709                // introspection surface; tgname/tgenabled plus the
10710                // pragmatic relname/timing/events/function columns
10711                // make "is it registered and enabled" a one-liner.
10712                "__spg_pg_trigger" => {
10713                    let (schema, rows) = synth_pg_trigger(self.active_catalog());
10714                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10715                }
10716                // v7.17.0 Phase 3.P0-52 — pg_catalog.pg_namespace
10717                // (schema list for admin tools' tree views).
10718                "__spg_pg_namespace" => {
10719                    let (schema, rows) = synth_pg_namespace(self.active_catalog());
10720                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10721                }
10722                // v7.17.0 Phase 3.P0-53 — pg_catalog.pg_indexes view
10723                // for pgAdmin / DataGrip "indexes per table" listings.
10724                "__spg_pg_indexes" => {
10725                    let (schema, rows) = synth_pg_indexes(self.active_catalog());
10726                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10727                }
10728                // v7.17.0 Phase 3.P0-53 — pg_catalog.pg_index (raw)
10729                // for index introspection by ORM compilers.
10730                "__spg_pg_index" => {
10731                    let (schema, rows) = synth_pg_index_raw(self.active_catalog());
10732                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10733                }
10734                // v7.17.0 Phase 3.P0-54 — pg_catalog.pg_constraint
10735                // for FK / UNIQUE / PK / CHECK introspection.
10736                "__spg_pg_constraint" => {
10737                    let (schema, rows) = synth_pg_constraint(self.active_catalog());
10738                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10739                }
10740                // v7.17.0 Phase 3.P0-55 — pg_catalog.pg_database /
10741                // pg_roles / pg_user. SPG is single-database so
10742                // pg_database surfaces just `postgres`; pg_roles
10743                // / pg_user walk the engine's UserStore.
10744                "__spg_pg_database" => {
10745                    let (schema, rows) = synth_pg_database(self.active_catalog());
10746                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10747                }
10748                "__spg_pg_roles" | "__spg_pg_user" => {
10749                    let (schema, rows) = synth_pg_roles(self);
10750                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10751                }
10752                // v7.17.0 Phase 3.P0-56 — pg_catalog.pg_views. PG's
10753                // pg_views surfaces every CREATE VIEW result; SPG
10754                // ships one row per declared view from the catalog.
10755                "__spg_pg_views" => {
10756                    let (schema, rows) = synth_pg_views(self.active_catalog());
10757                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10758                }
10759                // v7.17.0 Phase 3.P0-56 — pg_catalog.pg_matviews.
10760                // SPG has no materialised view surface yet so the
10761                // table shares pg_views's schema but stays empty.
10762                "__spg_pg_matviews" => {
10763                    let (schema, _) = synth_pg_views(self.active_catalog());
10764                    materialise_meta_view(&mut catalog, view, schema, Vec::new())?;
10765                }
10766                // pg_catalog.pg_extension — native capability list
10767                // (mailrs embed round-12).
10768                "__spg_pg_extension" => {
10769                    let (schema, rows) = synth_pg_extension();
10770                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10771                }
10772                // v7.17.0 Phase 3.P0-57 — pg_catalog.pg_settings.
10773                "__spg_pg_settings" => {
10774                    let (schema, rows) = synth_pg_settings(self);
10775                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10776                }
10777                // v7.17.0 Phase 3.P0-63 — information_schema.KEY_COLUMN_USAGE.
10778                "__spg_info_key_column_usage" => {
10779                    let (schema, rows) = synth_info_key_column_usage(self.active_catalog());
10780                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10781                }
10782                // v7.17.0 Phase 3.P0-64 — information_schema.REFERENTIAL_CONSTRAINTS.
10783                "__spg_info_referential_constraints" => {
10784                    let (schema, rows) = synth_info_referential_constraints(self.active_catalog());
10785                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10786                }
10787                // v7.17.0 Phase 3.P0-64 — information_schema.STATISTICS.
10788                "__spg_info_statistics" => {
10789                    let (schema, rows) = synth_info_statistics(self.active_catalog());
10790                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10791                }
10792                // v7.17.0 Phase 3.P0-64 — information_schema.ROUTINES.
10793                "__spg_info_routines" => {
10794                    let (schema, rows) = synth_info_routines();
10795                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10796                }
10797                // v7.17.0 Phase 3.P0-65 — mysql.user / mysql.db.
10798                "__spg_mysql_user" => {
10799                    let (schema, rows) = synth_mysql_user(self);
10800                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10801                }
10802                "__spg_mysql_db" => {
10803                    let (schema, rows) = synth_mysql_db();
10804                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10805                }
10806                _ => {
10807                    return Err(EngineError::Unsupported(alloc::format!(
10808                        "meta view {view:?} is not yet materialisable; \
10809                         v7.16.2 covers information_schema.columns / .tables \
10810                         and pg_catalog.pg_class / pg_attribute; \
10811                         v7.17.0 P0-50..P0-57 add pg_type / pg_proc / pg_namespace / \
10812                         pg_indexes / pg_index / pg_constraint / pg_database / pg_roles / \
10813                         pg_user / pg_views / pg_matviews / pg_settings"
10814                    )));
10815                }
10816            }
10817        }
10818        let mut temp = Engine::restore(catalog);
10819        if let Some(c) = self.clock {
10820            temp = temp.with_clock(c);
10821        }
10822        if let Some(f) = self.salt_fn {
10823            temp = temp.with_salt_fn(f);
10824        }
10825        temp.meta_views_materialised = true;
10826        temp.exec_select_cancel(stmt, cancel)
10827    }
10828
10829    fn exec_with_ctes(
10830        &self,
10831        stmt: &SelectStatement,
10832        cancel: CancelToken<'_>,
10833    ) -> Result<QueryResult, EngineError> {
10834        cancel.check()?;
10835        let mut catalog = self.active_catalog().clone();
10836        for cte in &stmt.ctes {
10837            if catalog.get(&cte.name).is_some() {
10838                return Err(EngineError::Unsupported(alloc::format!(
10839                    "CTE name {:?} shadows an existing table; rename the CTE",
10840                    cte.name
10841                )));
10842            }
10843            let (columns, rows) = if cte.recursive {
10844                self.materialise_recursive_cte(cte, &catalog, cancel)?
10845            } else {
10846                // v7.25 (round-17) — run the body against the
10847                // ACCUMULATED catalog so a CTE can reference every
10848                // CTE declared before it (`WITH a AS (…), b AS
10849                // (SELECT … FROM a)`). Executing on `self` lost the
10850                // already-materialised CTE tables.
10851                let mut cte_engine = Engine::restore(catalog.clone());
10852                if let Some(c) = self.clock {
10853                    cte_engine = cte_engine.with_clock(c);
10854                }
10855                if let Some(f) = self.salt_fn {
10856                    cte_engine = cte_engine.with_salt_fn(f);
10857                }
10858                let body_result = cte_engine.exec_select_cancel(&cte.body, cancel)?;
10859                let QueryResult::Rows { columns, rows } = body_result else {
10860                    return Err(EngineError::Unsupported(alloc::format!(
10861                        "CTE {:?} body did not return rows",
10862                        cte.name
10863                    )));
10864                };
10865                (columns, rows)
10866            };
10867            // v4.22: the projection builder labels any non-column
10868            // expression as Text — including literal SELECT 1.
10869            // Promote each column's type to whatever the rows
10870            // actually carry so the CTE storage table accepts them.
10871            let inferred = infer_column_types(&columns, &rows);
10872            let mut columns = inferred;
10873            // v4.22: apply optional `WITH name(a, b, c)` overrides.
10874            if !cte.column_overrides.is_empty() {
10875                if cte.column_overrides.len() != columns.len() {
10876                    return Err(EngineError::Unsupported(alloc::format!(
10877                        "CTE {:?} column list has {} names but body returns {} columns",
10878                        cte.name,
10879                        cte.column_overrides.len(),
10880                        columns.len()
10881                    )));
10882                }
10883                for (col, name) in columns.iter_mut().zip(cte.column_overrides.iter()) {
10884                    col.name.clone_from(name);
10885                }
10886            }
10887            let schema = TableSchema::new(cte.name.clone(), columns);
10888            catalog.create_table(schema).map_err(EngineError::Storage)?;
10889            let table = catalog
10890                .get_mut(&cte.name)
10891                .expect("just-created CTE table must exist");
10892            for row in rows {
10893                table.insert(row).map_err(EngineError::Storage)?;
10894            }
10895        }
10896        // Strip CTEs from the body before running on the temp engine
10897        // so we don't recurse forever.
10898        let mut body = stmt.clone();
10899        body.ctes = Vec::new();
10900        let mut temp = Engine::restore(catalog);
10901        if let Some(c) = self.clock {
10902            temp = temp.with_clock(c);
10903        }
10904        if let Some(f) = self.salt_fn {
10905            temp = temp.with_salt_fn(f);
10906        }
10907        temp.exec_select_cancel(&body, cancel)
10908    }
10909
10910    /// v4.22: materialise a WITH RECURSIVE CTE. The body must be a
10911    /// UNION (or UNION ALL) of an anchor that does not reference
10912    /// the CTE name, and one or more recursive terms that do. The
10913    /// anchor runs first; each subsequent iteration runs the
10914    /// recursive term against a temp catalog where the CTE name is
10915    /// bound to the *previous* iteration's output. Iteration stops
10916    /// when the recursive term yields no rows; UNION (DISTINCT)
10917    /// deduplicates against the accumulated result, UNION ALL does
10918    /// not. A hard cap on total rows prevents runaway queries.
10919    #[allow(clippy::too_many_lines)]
10920    fn materialise_recursive_cte(
10921        &self,
10922        cte: &spg_sql::ast::Cte,
10923        base_catalog: &Catalog,
10924        cancel: CancelToken<'_>,
10925    ) -> Result<(Vec<ColumnSchema>, Vec<Row>), EngineError> {
10926        const MAX_TOTAL_ROWS: usize = 1_000_000;
10927        const MAX_ITERATIONS: usize = 100_000;
10928        cancel.check()?;
10929        if cte.body.unions.is_empty() {
10930            return Err(EngineError::Unsupported(alloc::format!(
10931                "WITH RECURSIVE {:?} body must be a UNION of an anchor and a recursive term",
10932                cte.name
10933            )));
10934        }
10935        // Anchor: the body's leading SELECT, with unions stripped.
10936        let mut anchor = cte.body.clone();
10937        let union_terms = core::mem::take(&mut anchor.unions);
10938        anchor.ctes = Vec::new();
10939        // Anchor must not reference the CTE name.
10940        if select_refers_to(&anchor, &cte.name) {
10941            return Err(EngineError::Unsupported(alloc::format!(
10942                "WITH RECURSIVE {:?}: the anchor must not reference the CTE itself",
10943                cte.name
10944            )));
10945        }
10946        let anchor_result = self.exec_select_cancel(&anchor, cancel)?;
10947        let QueryResult::Rows {
10948            columns: anchor_cols,
10949            rows: anchor_rows,
10950        } = anchor_result
10951        else {
10952            return Err(EngineError::Unsupported(alloc::format!(
10953                "WITH RECURSIVE {:?}: anchor did not return rows",
10954                cte.name
10955            )));
10956        };
10957        // The projection builder labels non-column expressions Text;
10958        // refine column types from the anchor's actual values so the
10959        // intermediate iter-catalog tables accept them.
10960        let mut columns = infer_column_types(&anchor_cols, &anchor_rows);
10961        if !cte.column_overrides.is_empty() {
10962            if cte.column_overrides.len() != columns.len() {
10963                return Err(EngineError::Unsupported(alloc::format!(
10964                    "CTE {:?} column list has {} names but anchor returns {} columns",
10965                    cte.name,
10966                    cte.column_overrides.len(),
10967                    columns.len()
10968                )));
10969            }
10970            for (col, name) in columns.iter_mut().zip(cte.column_overrides.iter()) {
10971                col.name.clone_from(name);
10972            }
10973        }
10974        let mut all_rows: Vec<Row> = anchor_rows.clone();
10975        let mut working_set: Vec<Row> = anchor_rows;
10976        let mut seen: alloc::collections::BTreeSet<Vec<u8>> = alloc::collections::BTreeSet::new();
10977        // Track at least one "all UNION ALL" flag — if every union
10978        // kind is ALL we skip the dedup step (faster + matches PG).
10979        let all_union_all = union_terms.iter().all(|(k, _)| matches!(k, UnionKind::All));
10980        if !all_union_all {
10981            for r in &all_rows {
10982                seen.insert(encode_row_key(r));
10983            }
10984        }
10985        for iter in 0..MAX_ITERATIONS {
10986            cancel.check()?;
10987            if working_set.is_empty() {
10988                break;
10989            }
10990            // Build a fresh catalog: base + CTE bound to working_set.
10991            let mut iter_catalog = base_catalog.clone();
10992            let schema = TableSchema::new(cte.name.clone(), columns.clone());
10993            iter_catalog
10994                .create_table(schema)
10995                .map_err(EngineError::Storage)?;
10996            {
10997                let table = iter_catalog.get_mut(&cte.name).expect("just-created");
10998                for row in &working_set {
10999                    table.insert(row.clone()).map_err(EngineError::Storage)?;
11000                }
11001            }
11002            let mut iter_engine = Engine::restore(iter_catalog);
11003            if let Some(c) = self.clock {
11004                iter_engine = iter_engine.with_clock(c);
11005            }
11006            if let Some(f) = self.salt_fn {
11007                iter_engine = iter_engine.with_salt_fn(f);
11008            }
11009            // Run each recursive term in sequence and collect new rows.
11010            let mut next_set: Vec<Row> = Vec::new();
11011            for (_, term) in &union_terms {
11012                let mut term = term.clone();
11013                term.ctes = Vec::new();
11014                let r = iter_engine.exec_select_cancel(&term, cancel)?;
11015                let QueryResult::Rows {
11016                    columns: rc,
11017                    rows: rs,
11018                } = r
11019                else {
11020                    return Err(EngineError::Unsupported(alloc::format!(
11021                        "WITH RECURSIVE {:?}: recursive term did not return rows",
11022                        cte.name
11023                    )));
11024                };
11025                if rc.len() != columns.len() {
11026                    return Err(EngineError::Unsupported(alloc::format!(
11027                        "WITH RECURSIVE {:?}: column count of recursive term ({}) does not match anchor ({})",
11028                        cte.name,
11029                        rc.len(),
11030                        columns.len()
11031                    )));
11032                }
11033                for row in rs {
11034                    if !all_union_all {
11035                        let key = encode_row_key(&row);
11036                        if !seen.insert(key) {
11037                            continue;
11038                        }
11039                    }
11040                    next_set.push(row);
11041                }
11042            }
11043            if next_set.is_empty() {
11044                break;
11045            }
11046            all_rows.extend(next_set.iter().cloned());
11047            working_set = next_set;
11048            if all_rows.len() > MAX_TOTAL_ROWS {
11049                return Err(EngineError::Unsupported(alloc::format!(
11050                    "WITH RECURSIVE {:?}: produced more than {MAX_TOTAL_ROWS} rows — likely runaway recursion",
11051                    cte.name
11052                )));
11053            }
11054            if iter + 1 == MAX_ITERATIONS {
11055                return Err(EngineError::Unsupported(alloc::format!(
11056                    "WITH RECURSIVE {:?}: exceeded {MAX_ITERATIONS} iterations",
11057                    cte.name
11058                )));
11059            }
11060        }
11061        Ok((columns, all_rows))
11062    }
11063
11064    fn resolve_select_subqueries(
11065        &self,
11066        stmt: &mut SelectStatement,
11067        cancel: CancelToken<'_>,
11068    ) -> Result<(), EngineError> {
11069        for item in &mut stmt.items {
11070            if let SelectItem::Expr { expr, .. } = item {
11071                self.resolve_expr_subqueries(expr, cancel)?;
11072            }
11073        }
11074        if let Some(w) = &mut stmt.where_ {
11075            self.resolve_expr_subqueries(w, cancel)?;
11076        }
11077        // v7.24.1 — JOIN ON conditions can carry subqueries too;
11078        // they were never walked, so even an UNCORRELATED subquery
11079        // in ON hit "subquery reached row eval".
11080        if let Some(from) = &mut stmt.from {
11081            for j in &mut from.joins {
11082                if let Some(on) = &mut j.on {
11083                    self.resolve_expr_subqueries(on, cancel)?;
11084                }
11085            }
11086        }
11087        if let Some(gs) = &mut stmt.group_by {
11088            for g in gs {
11089                self.resolve_expr_subqueries(g, cancel)?;
11090            }
11091        }
11092        if let Some(h) = &mut stmt.having {
11093            self.resolve_expr_subqueries(h, cancel)?;
11094        }
11095        for o in &mut stmt.order_by {
11096            self.resolve_expr_subqueries(&mut o.expr, cancel)?;
11097        }
11098        for (_, peer) in &mut stmt.unions {
11099            self.resolve_select_subqueries(peer, cancel)?;
11100        }
11101        Ok(())
11102    }
11103
11104    #[allow(clippy::only_used_in_recursion)] // engine handle reads aren't really pure
11105    fn resolve_expr_subqueries(
11106        &self,
11107        e: &mut Expr,
11108        cancel: CancelToken<'_>,
11109    ) -> Result<(), EngineError> {
11110        // Replace-on-this-node cases first.
11111        if let Some(replacement) = self.subquery_replacement(e, cancel)? {
11112            *e = replacement;
11113            return Ok(());
11114        }
11115        match e {
11116            Expr::AggregateOrdered { call, order_by, .. } => {
11117                self.resolve_expr_subqueries(call, cancel)?;
11118                for o in order_by.iter_mut() {
11119                    self.resolve_expr_subqueries(&mut o.expr, cancel)?;
11120                }
11121            }
11122            Expr::Binary { lhs, rhs, .. } => {
11123                self.resolve_expr_subqueries(lhs, cancel)?;
11124                self.resolve_expr_subqueries(rhs, cancel)?;
11125            }
11126            Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
11127                self.resolve_expr_subqueries(expr, cancel)?;
11128            }
11129            Expr::FunctionCall { args, .. } => {
11130                for a in args {
11131                    self.resolve_expr_subqueries(a, cancel)?;
11132                }
11133            }
11134            Expr::Like { expr, pattern, .. } => {
11135                self.resolve_expr_subqueries(expr, cancel)?;
11136                self.resolve_expr_subqueries(pattern, cancel)?;
11137            }
11138            Expr::Extract { source, .. } => self.resolve_expr_subqueries(source, cancel)?,
11139            // v4.12 window functions — recurse into args + ORDER BY
11140            // + PARTITION BY in case they carry inner subqueries.
11141            Expr::WindowFunction {
11142                args,
11143                partition_by,
11144                order_by,
11145                ..
11146            } => {
11147                for a in args {
11148                    self.resolve_expr_subqueries(a, cancel)?;
11149                }
11150                for p in partition_by {
11151                    self.resolve_expr_subqueries(p, cancel)?;
11152                }
11153                for (e, _, _) in order_by {
11154                    self.resolve_expr_subqueries(e, cancel)?;
11155                }
11156            }
11157            // Subquery nodes are handled in subquery_replacement
11158            // (which returned None — defensive no-op); Literal /
11159            // Column are leaves.
11160            Expr::ScalarSubquery(_)
11161            | Expr::Exists { .. }
11162            | Expr::InSubquery { .. }
11163            | Expr::Literal(_)
11164            | Expr::Placeholder(_)
11165            | Expr::Column(_) => {}
11166            // v7.30.2 — list elements can carry scalar subqueries
11167            // (`x IN (1, (SELECT …))`).
11168            Expr::InList { expr, list, .. } => {
11169                self.resolve_expr_subqueries(expr, cancel)?;
11170                for item in list {
11171                    self.resolve_expr_subqueries(item, cancel)?;
11172                }
11173            }
11174            // v7.10.10 — recurse children.
11175            Expr::Array(items) => {
11176                for elem in items {
11177                    self.resolve_expr_subqueries(elem, cancel)?;
11178                }
11179            }
11180            Expr::ArraySubscript { target, index } => {
11181                self.resolve_expr_subqueries(target, cancel)?;
11182                self.resolve_expr_subqueries(index, cancel)?;
11183            }
11184            Expr::AnyAll { expr, array, .. } => {
11185                self.resolve_expr_subqueries(expr, cancel)?;
11186                self.resolve_expr_subqueries(array, cancel)?;
11187            }
11188            Expr::Case {
11189                operand,
11190                branches,
11191                else_branch,
11192            } => {
11193                if let Some(o) = operand {
11194                    self.resolve_expr_subqueries(o, cancel)?;
11195                }
11196                for (w, t) in branches {
11197                    self.resolve_expr_subqueries(w, cancel)?;
11198                    self.resolve_expr_subqueries(t, cancel)?;
11199                }
11200                if let Some(e) = else_branch {
11201                    self.resolve_expr_subqueries(e, cancel)?;
11202                }
11203            }
11204        }
11205        Ok(())
11206    }
11207
11208    /// v4.23: per-row eval that handles correlated subqueries.
11209    /// Equivalent to `eval::eval_expr` when the expression has no
11210    /// subqueries; otherwise clones the expression, substitutes
11211    /// outer-row columns into each surviving subquery node, runs
11212    /// the inner SELECT, and replaces the node with the literal
11213    /// result. Only the WHERE-filter call sites use this path so
11214    /// the uncorrelated fast path is preserved everywhere else.
11215    fn eval_expr_with_correlated(
11216        &self,
11217        expr: &Expr,
11218        row: &Row,
11219        ctx: &EvalContext<'_>,
11220        cancel: CancelToken<'_>,
11221        mut memo: Option<&mut memoize::MemoizeCache>,
11222    ) -> Result<Value, EngineError> {
11223        // v7.30.2 (mailrs round-25) — the has-subquery walk is
11224        // O(tree) and a materialised `IN (…)` list makes the tree
11225        // huge; cache the answer per expression address so the
11226        // per-row dispatch stops re-walking 24k list elements.
11227        let has_subq = if let Some(m) = memo.as_deref_mut() {
11228            let key = core::ptr::from_ref::<Expr>(expr) as usize;
11229            match m.has_subquery.get(&key) {
11230                Some(b) => *b,
11231                None => {
11232                    let b = expr_has_subquery(expr);
11233                    m.has_subquery.insert(key, b);
11234                    b
11235                }
11236            }
11237        } else {
11238            expr_has_subquery(expr)
11239        };
11240        if !has_subq {
11241            // A large materialised `IN (…)` list inside the WHERE
11242            // makes the plain eval O(rows × list); route through the
11243            // per-query membership set (built once, keyed by node
11244            // address) when one is reachable on the AND spine.
11245            if let Some(m) = memo.as_deref_mut()
11246                && expr_may_use_in_set(expr)
11247            {
11248                return eval_with_in_sets(expr, row, ctx, m);
11249            }
11250            return eval::eval_expr(expr, row, ctx).map_err(EngineError::Eval);
11251        }
11252        // v7.29 (3c) - per-expression plan: the batch maps for this
11253        // host expression's scalar subqueries are looked up by the
11254        // expression's ADDRESS (stable across the row loop), so the
11255        // hot path does zero AST formatting. Building the plan (and
11256        // its Display-keyed group maps) happens once per expression.
11257        if let Some(m) = memo.as_deref_mut() {
11258            let key = core::ptr::from_ref::<Expr>(expr) as usize;
11259            // Plan hit: skip the collection walk entirely (it ran
11260            // once per group otherwise - 70k walks per inbox query).
11261            // The memo is per-query and host expressions outlive it,
11262            // so an address that hit once stays valid.
11263            let plan_hit = m.expr_plans.contains_key(&key);
11264            let mut subs: Vec<&SelectStatement> = Vec::new();
11265            if !plan_hit {
11266                collect_scalar_subqueries(expr, &mut subs);
11267            }
11268            if !plan_hit && !subs.is_empty() {
11269                let mut plan: Vec<Option<alloc::rc::Rc<memoize::GroupMap>>> =
11270                    Vec::with_capacity(subs.len());
11271                for sub in &subs {
11272                    let repr = alloc::format!("{sub}");
11273                    if !m.group_maps.contains_key(&repr) {
11274                        let built = self
11275                            .try_batch_correlated_scalar(sub, cancel)?
11276                            .map(alloc::rc::Rc::new);
11277                        m.group_maps.insert(repr.clone(), built);
11278                    }
11279                    plan.push(m.group_maps.get(&repr).cloned().flatten());
11280                }
11281                let mut template = expr.clone();
11282                hollow_scalar_subqueries(&mut template);
11283                m.expr_plans.insert(key, (subs.len(), plan, template));
11284            }
11285            if let Some((_, plan, template)) = m.expr_plans.get(&key)
11286                && !plan.is_empty()
11287                && plan.iter().all(|p| p.is_some())
11288            {
11289                // Fast path: every scalar subquery resolves via its
11290                // map; clone the HOLLOW template (subquery bodies
11291                // emptied at plan time - cloning full subquery ASTs
11292                // per row was the dominant malloc load), splice map
11293                // values, eval. Exists/IN subqueries (if any) still
11294                // drop to the resolver.
11295                let plan = plan.clone();
11296                let mut e = template.clone();
11297                let mut idx = 0usize;
11298                let ok = splice_planned_subqueries(&mut e, &plan, &mut idx, row, ctx)?;
11299                if ok {
11300                    if expr_has_subquery(&e) {
11301                        self.resolve_correlated_in_expr(&mut e, row, ctx, cancel, memo)?;
11302                    }
11303                    return eval::eval_expr(&e, row, ctx).map_err(EngineError::Eval);
11304                }
11305            }
11306        }
11307        let mut e = expr.clone();
11308        self.resolve_correlated_in_expr(&mut e, row, ctx, cancel, memo)?;
11309        eval::eval_expr(&e, row, ctx).map_err(EngineError::Eval)
11310    }
11311
11312    fn resolve_correlated_in_expr(
11313        &self,
11314        e: &mut Expr,
11315        row: &Row,
11316        ctx: &EvalContext<'_>,
11317        cancel: CancelToken<'_>,
11318        mut memo: Option<&mut memoize::MemoizeCache>,
11319    ) -> Result<(), EngineError> {
11320        match e {
11321            Expr::AggregateOrdered { call, order_by, .. } => {
11322                self.resolve_correlated_in_expr(call, row, ctx, cancel, memo.as_deref_mut())?;
11323                for o in order_by.iter_mut() {
11324                    self.resolve_correlated_in_expr(
11325                        &mut o.expr,
11326                        row,
11327                        ctx,
11328                        cancel,
11329                        memo.as_deref_mut(),
11330                    )?;
11331                }
11332            }
11333            Expr::ScalarSubquery(inner) => {
11334                // v7.29 (round-22 phase 3) — batch path first: a
11335                // correlated scalar of the `inner_col = outer_col
11336                // [ORDER BY … LIMIT 1]` shape evaluates ONCE as a
11337                // grouped scan; per-row resolution becomes a map
11338                // lookup. 23.5k per-group executions (~900 ms) became
11339                // one scan + lookups.
11340                if memo.is_some() {
11341                    let repr = alloc::format!("{}", **inner);
11342                    let entry_known = memo
11343                        .as_ref()
11344                        .is_some_and(|m| m.group_maps.contains_key(&repr));
11345                    if !entry_known {
11346                        let built = self
11347                            .try_batch_correlated_scalar(inner, cancel)?
11348                            .map(alloc::rc::Rc::new);
11349                        if let Some(m) = memo.as_deref_mut() {
11350                            m.group_maps.insert(repr.clone(), built);
11351                        }
11352                    }
11353                    if let Some(m) = memo.as_deref_mut()
11354                        && let Some(Some(gm)) = m.group_maps.get(&repr)
11355                    {
11356                        let (outer_col, map) = gm.as_ref();
11357                        let key_v = eval::eval_expr(&Expr::Column(outer_col.clone()), row, ctx)
11358                            .map_err(EngineError::Eval)?;
11359                        let v = if matches!(key_v, Value::Null) {
11360                            Value::Null
11361                        } else {
11362                            map.get(&aggregate::encode_key(core::slice::from_ref(&key_v)))
11363                                .cloned()
11364                                .unwrap_or(Value::Null)
11365                        };
11366                        *e = value_to_literal_expr(v)?;
11367                        return Ok(());
11368                    }
11369                }
11370                // v6.2.6 — Memoize: build the cache key from the
11371                // pre-substitution subquery repr + the outer row's
11372                // values. Two outer rows with identical correlated
11373                // values hit the same entry.
11374                let cache_key = memo.as_ref().map(|_| memoize::CacheKey {
11375                    subquery_repr: alloc::format!("{}", **inner),
11376                    outer_values: row.values.clone(),
11377                });
11378                if let (Some(cache), Some(k)) = (memo.as_deref_mut(), cache_key.as_ref())
11379                    && let Some(cached) = cache.get(k)
11380                {
11381                    *e = value_to_literal_expr(cached)?;
11382                    return Ok(());
11383                }
11384                let mut s = (**inner).clone();
11385                substitute_outer_columns(&mut s, row, ctx);
11386                let r = self.exec_select_cancel(&s, cancel)?;
11387                let QueryResult::Rows { rows, .. } = r else {
11388                    return Err(EngineError::Unsupported(
11389                        "scalar subquery: inner did not return rows".into(),
11390                    ));
11391                };
11392                let value = match rows.as_slice() {
11393                    [] => Value::Null,
11394                    [r0] => r0.values.first().cloned().unwrap_or(Value::Null),
11395                    _ => {
11396                        return Err(EngineError::Unsupported(alloc::format!(
11397                            "scalar subquery returned {} rows; expected 0 or 1",
11398                            rows.len()
11399                        )));
11400                    }
11401                };
11402                if let (Some(cache), Some(k)) = (memo.as_deref_mut(), cache_key) {
11403                    cache.insert(k, value.clone());
11404                }
11405                *e = value_to_literal_expr(value)?;
11406            }
11407            Expr::Exists { subquery, negated } => {
11408                let mut s = (**subquery).clone();
11409                substitute_outer_columns(&mut s, row, ctx);
11410                let r = self.exec_select_cancel(&s, cancel)?;
11411                let exists = matches!(r, QueryResult::Rows { rows, .. } if !rows.is_empty());
11412                let bit = if *negated { !exists } else { exists };
11413                *e = Expr::Literal(Literal::Bool(bit));
11414            }
11415            Expr::InSubquery {
11416                expr: lhs,
11417                subquery,
11418                negated,
11419            } => {
11420                self.resolve_correlated_in_expr(lhs, row, ctx, cancel, memo.as_deref_mut())?;
11421                let lhs_val = eval::eval_expr(lhs, row, ctx).map_err(EngineError::Eval)?;
11422                let mut s = (**subquery).clone();
11423                substitute_outer_columns(&mut s, row, ctx);
11424                let r = self.exec_select_cancel(&s, cancel)?;
11425                let QueryResult::Rows { columns, rows, .. } = r else {
11426                    return Err(EngineError::Unsupported(
11427                        "IN-subquery: inner did not return rows".into(),
11428                    ));
11429                };
11430                if columns.len() != 1 {
11431                    return Err(EngineError::Unsupported(alloc::format!(
11432                        "IN-subquery must project exactly one column; got {}",
11433                        columns.len()
11434                    )));
11435                }
11436                let mut found = false;
11437                let mut any_null = false;
11438                for r0 in rows {
11439                    let v = r0.values.into_iter().next().unwrap_or(Value::Null);
11440                    if v.is_null() {
11441                        any_null = true;
11442                        continue;
11443                    }
11444                    if value_cmp(&v, &lhs_val) == core::cmp::Ordering::Equal {
11445                        found = true;
11446                        break;
11447                    }
11448                }
11449                let bit = if found {
11450                    !*negated
11451                } else if any_null {
11452                    return Err(EngineError::Unsupported(
11453                        "IN-subquery with NULL in result and no match: NULL semantics not yet implemented".into(),
11454                    ));
11455                } else {
11456                    *negated
11457                };
11458                *e = Expr::Literal(Literal::Bool(bit));
11459            }
11460            Expr::Binary { lhs, rhs, .. } => {
11461                self.resolve_correlated_in_expr(lhs, row, ctx, cancel, memo.as_deref_mut())?;
11462                self.resolve_correlated_in_expr(rhs, row, ctx, cancel, memo.as_deref_mut())?;
11463            }
11464            Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
11465                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
11466            }
11467            Expr::Like { expr, pattern, .. } => {
11468                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
11469                self.resolve_correlated_in_expr(pattern, row, ctx, cancel, memo.as_deref_mut())?;
11470            }
11471            Expr::FunctionCall { args, .. } => {
11472                for a in args {
11473                    self.resolve_correlated_in_expr(a, row, ctx, cancel, memo.as_deref_mut())?;
11474                }
11475            }
11476            Expr::Extract { source, .. } => {
11477                self.resolve_correlated_in_expr(source, row, ctx, cancel, memo.as_deref_mut())?;
11478            }
11479            Expr::WindowFunction { .. }
11480            | Expr::Literal(_)
11481            | Expr::Placeholder(_)
11482            | Expr::Column(_) => {}
11483            // v7.10.10 — recurse children.
11484            Expr::Array(items) => {
11485                for elem in items {
11486                    self.resolve_correlated_in_expr(elem, row, ctx, cancel, memo.as_deref_mut())?;
11487                }
11488            }
11489            Expr::ArraySubscript { target, index } => {
11490                self.resolve_correlated_in_expr(target, row, ctx, cancel, memo.as_deref_mut())?;
11491                self.resolve_correlated_in_expr(index, row, ctx, cancel, memo.as_deref_mut())?;
11492            }
11493            Expr::AnyAll { expr, array, .. } => {
11494                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
11495                self.resolve_correlated_in_expr(array, row, ctx, cancel, memo.as_deref_mut())?;
11496            }
11497            Expr::InList { expr, list, .. } => {
11498                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
11499                for item in list {
11500                    self.resolve_correlated_in_expr(item, row, ctx, cancel, memo.as_deref_mut())?;
11501                }
11502            }
11503            Expr::Case {
11504                operand,
11505                branches,
11506                else_branch,
11507            } => {
11508                if let Some(o) = operand {
11509                    self.resolve_correlated_in_expr(o, row, ctx, cancel, memo.as_deref_mut())?;
11510                }
11511                for (w, t) in branches {
11512                    self.resolve_correlated_in_expr(w, row, ctx, cancel, memo.as_deref_mut())?;
11513                    self.resolve_correlated_in_expr(t, row, ctx, cancel, memo.as_deref_mut())?;
11514                }
11515                if let Some(e) = else_branch {
11516                    self.resolve_correlated_in_expr(e, row, ctx, cancel, memo.as_deref_mut())?;
11517                }
11518            }
11519        }
11520        Ok(())
11521    }
11522
11523    fn subquery_replacement(
11524        &self,
11525        e: &Expr,
11526        cancel: CancelToken<'_>,
11527    ) -> Result<Option<Expr>, EngineError> {
11528        match e {
11529            Expr::ScalarSubquery(inner) => {
11530                let mut s = (**inner).clone();
11531                // Recurse into the inner SELECT first so nested
11532                // subqueries materialise bottom-up.
11533                self.resolve_select_subqueries(&mut s, cancel)?;
11534                let r = match self.exec_bare_select_cancel(&s, cancel) {
11535                    Ok(r) => r,
11536                    Err(e) if is_correlation_error(&e) => return Ok(None),
11537                    Err(e) => return Err(e),
11538                };
11539                let QueryResult::Rows { rows, .. } = r else {
11540                    return Err(EngineError::Unsupported(
11541                        "scalar subquery: inner statement did not return rows".into(),
11542                    ));
11543                };
11544                let value = match rows.as_slice() {
11545                    [] => Value::Null,
11546                    [row] => row.values.first().cloned().unwrap_or(Value::Null),
11547                    _ => {
11548                        return Err(EngineError::Unsupported(alloc::format!(
11549                            "scalar subquery returned {} rows; expected 0 or 1",
11550                            rows.len()
11551                        )));
11552                    }
11553                };
11554                Ok(Some(value_to_literal_expr(value)?))
11555            }
11556            Expr::Exists { subquery, negated } => {
11557                let mut s = (**subquery).clone();
11558                self.resolve_select_subqueries(&mut s, cancel)?;
11559                let r = match self.exec_bare_select_cancel(&s, cancel) {
11560                    Ok(r) => r,
11561                    Err(e) if is_correlation_error(&e) => return Ok(None),
11562                    Err(e) => return Err(e),
11563                };
11564                let exists = match r {
11565                    QueryResult::Rows { rows, .. } => !rows.is_empty(),
11566                    QueryResult::CommandOk { .. } => false,
11567                };
11568                let bit = if *negated { !exists } else { exists };
11569                Ok(Some(Expr::Literal(Literal::Bool(bit))))
11570            }
11571            Expr::InSubquery {
11572                expr,
11573                subquery,
11574                negated,
11575            } => {
11576                let mut s = (**subquery).clone();
11577                self.resolve_select_subqueries(&mut s, cancel)?;
11578                let r = match self.exec_bare_select_cancel(&s, cancel) {
11579                    Ok(r) => r,
11580                    Err(e) if is_correlation_error(&e) => return Ok(None),
11581                    Err(e) => return Err(e),
11582                };
11583                let QueryResult::Rows { columns, rows, .. } = r else {
11584                    return Err(EngineError::Unsupported(
11585                        "IN-subquery: inner statement did not return rows".into(),
11586                    ));
11587                };
11588                if columns.len() != 1 {
11589                    return Err(EngineError::Unsupported(alloc::format!(
11590                        "IN-subquery must project exactly one column; got {}",
11591                        columns.len()
11592                    )));
11593                }
11594                // v7.30.2 (mailrs round-25) — flat InList, NOT an OR-Eq
11595                // chain: chain depth scaled with the inner result's ROW
11596                // COUNT, so one 24k-match search overflowed the worker
11597                // stack (recursive eval + recursive Box drop) and
11598                // aborted the embedding host process.
11599                let mut list: Vec<Expr> = Vec::with_capacity(rows.len());
11600                for row in rows {
11601                    let v = row.values.into_iter().next().unwrap_or(Value::Null);
11602                    list.push(value_to_literal_expr(v)?);
11603                }
11604                Ok(Some(Expr::InList {
11605                    expr: expr.clone(),
11606                    list,
11607                    negated: *negated,
11608                }))
11609            }
11610            _ => Ok(None),
11611        }
11612    }
11613}
11614
11615// ---- v4.12 window-function helpers ----
11616// The (partition-key, order-key, original-index) tuple shape used
11617// across these helpers is intrinsic to the planner. Factoring it
11618// into a typedef adds indirection without making the code clearer,
11619// so several lints are allowed inline on the affected functions
11620// rather than module-wide.
11621
11622/// v4.22: cheap structural scan for `FROM <name>` (qualified or
11623/// not) inside a SELECT — used to verify the anchor of a WITH
11624/// RECURSIVE CTE doesn't recurse into itself. Conservative: walks
11625/// FROM joins, subqueries, and unions.
11626fn select_refers_to(stmt: &SelectStatement, target: &str) -> bool {
11627    if let Some(from) = &stmt.from
11628        && from_refers_to(from, target)
11629    {
11630        return true;
11631    }
11632    for (_, peer) in &stmt.unions {
11633        if select_refers_to(peer, target) {
11634            return true;
11635        }
11636    }
11637    for item in &stmt.items {
11638        if let SelectItem::Expr { expr, .. } = item
11639            && expr_refers_to(expr, target)
11640        {
11641            return true;
11642        }
11643    }
11644    if let Some(w) = &stmt.where_
11645        && expr_refers_to(w, target)
11646    {
11647        return true;
11648    }
11649    false
11650}
11651
11652fn from_refers_to(from: &FromClause, target: &str) -> bool {
11653    if from.primary.name.eq_ignore_ascii_case(target) {
11654        return true;
11655    }
11656    from.joins
11657        .iter()
11658        .any(|j| j.table.name.eq_ignore_ascii_case(target))
11659}
11660
11661/// v7.28 (round-22) — collect every QUALIFIED column referenced
11662/// anywhere in a SELECT (subquery bodies included). Returns None
11663/// when a wildcard or a bare column name makes static attribution
11664/// unsafe — callers then keep every column.
11665fn collect_qualified_refs(
11666    stmt: &SelectStatement,
11667    out: &mut alloc::collections::BTreeSet<(String, String)>,
11668) -> Option<()> {
11669    for item in &stmt.items {
11670        match item {
11671            SelectItem::Wildcard => return None,
11672            SelectItem::Expr { expr, .. } => collect_qualified_refs_expr(expr, out)?,
11673        }
11674    }
11675    if let Some(w) = &stmt.where_ {
11676        collect_qualified_refs_expr(w, out)?;
11677    }
11678    if let Some(from) = &stmt.from {
11679        for j in &from.joins {
11680            if let Some(on) = &j.on {
11681                collect_qualified_refs_expr(on, out)?;
11682            }
11683            if j.table.lateral_subquery.is_some() {
11684                return None;
11685            }
11686        }
11687    }
11688    if let Some(gs) = &stmt.group_by {
11689        for g in gs {
11690            collect_qualified_refs_expr(g, out)?;
11691        }
11692    }
11693    if let Some(h) = &stmt.having {
11694        collect_qualified_refs_expr(h, out)?;
11695    }
11696    for o in &stmt.order_by {
11697        collect_qualified_refs_expr(&o.expr, out)?;
11698    }
11699    for (_, peer) in &stmt.unions {
11700        collect_qualified_refs(peer, out)?;
11701    }
11702    for cte in &stmt.ctes {
11703        collect_qualified_refs(&cte.body, out)?;
11704    }
11705    Some(())
11706}
11707
11708fn collect_qualified_refs_expr(
11709    e: &Expr,
11710    out: &mut alloc::collections::BTreeSet<(String, String)>,
11711) -> Option<()> {
11712    // Two passes so the column and subquery visitors don't both
11713    // capture `out` mutably.
11714    let mut cols: Vec<spg_sql::ast::ColumnName> = Vec::new();
11715    let mut subs: Vec<&SelectStatement> = Vec::new();
11716    visit_expr_columns_and_subqueries(
11717        e,
11718        &mut |c: &spg_sql::ast::ColumnName| cols.push(c.clone()),
11719        &mut |sub| subs.push(sub),
11720    );
11721    for c in cols {
11722        match c.qualifier {
11723            Some(q) => {
11724                out.insert((q, c.name));
11725            }
11726            None => return None,
11727        }
11728    }
11729    for sub in subs {
11730        collect_qualified_refs(sub, out)?;
11731    }
11732    Some(())
11733}
11734
11735/// Immutable walk over an Expr visiting every Column and every
11736/// nested SelectStatement (v7.28).
11737fn visit_expr_columns_and_subqueries<'a>(
11738    e: &'a Expr,
11739    on_col: &mut impl FnMut(&'a spg_sql::ast::ColumnName),
11740    on_sub: &mut impl FnMut(&'a SelectStatement),
11741) {
11742    match e {
11743        Expr::Column(c) => on_col(c),
11744        Expr::ScalarSubquery(s) => on_sub(s),
11745        Expr::Exists { subquery, .. } => on_sub(subquery),
11746        Expr::InSubquery { expr, subquery, .. } => {
11747            visit_expr_columns_and_subqueries(expr, on_col, on_sub);
11748            on_sub(subquery);
11749        }
11750        Expr::Binary { lhs, rhs, .. } => {
11751            visit_expr_columns_and_subqueries(lhs, on_col, on_sub);
11752            visit_expr_columns_and_subqueries(rhs, on_col, on_sub);
11753        }
11754        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
11755            visit_expr_columns_and_subqueries(expr, on_col, on_sub);
11756        }
11757        Expr::Like { expr, pattern, .. } => {
11758            visit_expr_columns_and_subqueries(expr, on_col, on_sub);
11759            visit_expr_columns_and_subqueries(pattern, on_col, on_sub);
11760        }
11761        Expr::FunctionCall { args, .. } => {
11762            for a in args {
11763                visit_expr_columns_and_subqueries(a, on_col, on_sub);
11764            }
11765        }
11766        Expr::AggregateOrdered { call, order_by, .. } => {
11767            visit_expr_columns_and_subqueries(call, on_col, on_sub);
11768            for o in order_by {
11769                visit_expr_columns_and_subqueries(&o.expr, on_col, on_sub);
11770            }
11771        }
11772        Expr::Case {
11773            operand,
11774            branches,
11775            else_branch,
11776        } => {
11777            if let Some(op) = operand {
11778                visit_expr_columns_and_subqueries(op, on_col, on_sub);
11779            }
11780            for (w, t) in branches {
11781                visit_expr_columns_and_subqueries(w, on_col, on_sub);
11782                visit_expr_columns_and_subqueries(t, on_col, on_sub);
11783            }
11784            if let Some(eb) = else_branch {
11785                visit_expr_columns_and_subqueries(eb, on_col, on_sub);
11786            }
11787        }
11788        Expr::ArraySubscript { target, index } => {
11789            visit_expr_columns_and_subqueries(target, on_col, on_sub);
11790            visit_expr_columns_and_subqueries(index, on_col, on_sub);
11791        }
11792        Expr::Literal(_) | Expr::Placeholder(_) => {}
11793        // Exotic nodes (window etc.) — visit nothing extra; their
11794        // columns are caught when the caller bails on bare names
11795        // elsewhere, and window queries skip pruning entirely at
11796        // the call sites.
11797        _ => {
11798            // Exotic node (window function etc.): report an
11799            // unattributable marker so callers disable pruning.
11800            static BAIL: spg_sql::ast::ColumnName = spg_sql::ast::ColumnName {
11801                qualifier: None,
11802                name: String::new(),
11803            };
11804            on_col(&BAIL);
11805        }
11806    }
11807}
11808
11809/// v7.28 (round-22) — collect every Column qualifier in an expr;
11810/// `all_qualified` flips false on any bare column (those can't be
11811/// attributed to one table safely, so the pushdown skips them).
11812fn collect_column_qualifiers<'e>(e: &'e Expr, out: &mut Vec<&'e str>, all_qualified: &mut bool) {
11813    if let Expr::Column(c) = e {
11814        match &c.qualifier {
11815            Some(q) => out.push(q.as_str()),
11816            None => *all_qualified = false,
11817        }
11818        return;
11819    }
11820    // Reuse the canonical immutable walk via describe's walker shape:
11821    // recurse the common containers.
11822    match e {
11823        Expr::Binary { lhs, rhs, .. } => {
11824            collect_column_qualifiers(lhs, out, all_qualified);
11825            collect_column_qualifiers(rhs, out, all_qualified);
11826        }
11827        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
11828            collect_column_qualifiers(expr, out, all_qualified);
11829        }
11830        Expr::Like { expr, pattern, .. } => {
11831            collect_column_qualifiers(expr, out, all_qualified);
11832            collect_column_qualifiers(pattern, out, all_qualified);
11833        }
11834        Expr::FunctionCall { args, .. } => {
11835            for a in args {
11836                collect_column_qualifiers(a, out, all_qualified);
11837            }
11838        }
11839        Expr::Literal(_) | Expr::Placeholder(_) => {}
11840        // Anything exotic (CASE, subquery, window, arrays…):
11841        // conservatively mark unattributable.
11842        _ => *all_qualified = false,
11843    }
11844}
11845
11846fn expr_refers_to(e: &Expr, target: &str) -> bool {
11847    match e {
11848        Expr::AggregateOrdered { call, order_by, .. } => {
11849            expr_refers_to(call, target) || order_by.iter().any(|o| expr_refers_to(&o.expr, target))
11850        }
11851        Expr::ScalarSubquery(s) => select_refers_to(s, target),
11852        Expr::Exists { subquery, .. } | Expr::InSubquery { subquery, .. } => {
11853            select_refers_to(subquery, target)
11854        }
11855        Expr::Binary { lhs, rhs, .. } => expr_refers_to(lhs, target) || expr_refers_to(rhs, target),
11856        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
11857            expr_refers_to(expr, target)
11858        }
11859        Expr::Like { expr, pattern, .. } => {
11860            expr_refers_to(expr, target) || expr_refers_to(pattern, target)
11861        }
11862        Expr::FunctionCall { args, .. } => args.iter().any(|a| expr_refers_to(a, target)),
11863        Expr::Extract { source, .. } => expr_refers_to(source, target),
11864        Expr::WindowFunction {
11865            args,
11866            partition_by,
11867            order_by,
11868            ..
11869        } => {
11870            args.iter().any(|a| expr_refers_to(a, target))
11871                || partition_by.iter().any(|p| expr_refers_to(p, target))
11872                || order_by.iter().any(|(o, _, _)| expr_refers_to(o, target))
11873        }
11874        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => false,
11875        Expr::Array(items) => items.iter().any(|e| expr_refers_to(e, target)),
11876        Expr::InList { expr, list, .. } => {
11877            expr_refers_to(expr, target) || list.iter().any(|e| expr_refers_to(e, target))
11878        }
11879        Expr::ArraySubscript { target: t, index } => {
11880            expr_refers_to(t, target) || expr_refers_to(index, target)
11881        }
11882        Expr::AnyAll { expr, array, .. } => {
11883            expr_refers_to(expr, target) || expr_refers_to(array, target)
11884        }
11885        Expr::Case {
11886            operand,
11887            branches,
11888            else_branch,
11889        } => {
11890            operand
11891                .as_deref()
11892                .is_some_and(|o| expr_refers_to(o, target))
11893                || branches
11894                    .iter()
11895                    .any(|(w, t)| expr_refers_to(w, target) || expr_refers_to(t, target))
11896                || else_branch
11897                    .as_deref()
11898                    .is_some_and(|e| expr_refers_to(e, target))
11899        }
11900    }
11901}
11902
11903/// v4.22: pick more specific column types from observed rows when
11904/// the projection builder defaulted to Text (the v1.x behavior for
11905/// non-column expressions). Lets `WITH t(n) AS (SELECT 1 ...)`
11906/// land an Int column in the CTE storage table rather than failing
11907/// the insert with "expected TEXT, got INT".
11908/// v7.16.2 — map an SPG [`DataType`] to the PG-canonical
11909/// `information_schema.columns.data_type` text. Covers the
11910/// values mailrs's migrations probe (`'ARRAY'`, `'integer'`,
11911/// `'text'`, …). Unknown variants fall back to the SPG name
11912/// downcased — better than panicking on a future DataType.
11913fn pg_data_type_text(ty: DataType) -> alloc::string::String {
11914    let s = match ty {
11915        DataType::Int => "integer",
11916        DataType::BigInt => "bigint",
11917        DataType::SmallInt => "smallint",
11918        DataType::Float => "double precision",
11919        DataType::Bool => "boolean",
11920        DataType::Text => "text",
11921        DataType::Varchar(_) => "character varying",
11922        DataType::Date => "date",
11923        DataType::Timestamp => "timestamp without time zone",
11924        DataType::Timestamptz => "timestamp with time zone",
11925        DataType::Json => "jsonb",
11926        DataType::Bytes => "bytea",
11927        DataType::TextArray | DataType::IntArray | DataType::BigIntArray => "ARRAY",
11928        DataType::TsVector => "tsvector",
11929        DataType::TsQuery => "tsquery",
11930        DataType::Vector { .. } => "USER-DEFINED",
11931        // Non-exhaustive — fall back to "USER-DEFINED" the way
11932        // PG labels any pg_type it doesn't recognise.
11933        _ => "USER-DEFINED",
11934    };
11935    alloc::string::String::from(s)
11936}
11937
11938/// v7.16.2 — synthesise `information_schema.columns`. mailrs
11939/// queries are of shape `SELECT 1 FROM information_schema.columns
11940/// WHERE table_name = … AND column_name = … AND data_type = …` —
11941/// the v7.16.2 view returns the columns mailrs probes; broader
11942/// PG-spec parity (ordinal_position, is_nullable, character_
11943/// maximum_length, udt_name, …) lands as needed.
11944fn synth_information_schema_columns(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11945    let schema = alloc::vec![
11946        ColumnSchema::new("table_catalog", DataType::Text, false),
11947        ColumnSchema::new("table_schema", DataType::Text, false),
11948        ColumnSchema::new("table_name", DataType::Text, false),
11949        ColumnSchema::new("column_name", DataType::Text, false),
11950        ColumnSchema::new("ordinal_position", DataType::Int, false),
11951        ColumnSchema::new("is_nullable", DataType::Text, false),
11952        ColumnSchema::new("data_type", DataType::Text, false),
11953    ];
11954    let mut rows: Vec<Row> = Vec::new();
11955    for tname in cat.table_names() {
11956        let Some(t) = cat.get(&tname) else { continue };
11957        for (i, col) in t.schema().columns.iter().enumerate() {
11958            #[allow(clippy::cast_possible_wrap)]
11959            let ordinal = (i + 1) as i32;
11960            rows.push(Row::new(alloc::vec![
11961                Value::Text("spg".into()),
11962                Value::Text("public".into()),
11963                Value::Text(tname.clone()),
11964                Value::Text(col.name.clone()),
11965                Value::Int(ordinal),
11966                Value::Text(if col.nullable {
11967                    "YES".into()
11968                } else {
11969                    "NO".into()
11970                }),
11971                Value::Text(pg_data_type_text(col.ty)),
11972            ]));
11973        }
11974    }
11975    (schema, rows)
11976}
11977
11978/// v7.16.2 — synthesise `information_schema.tables`.
11979fn synth_information_schema_tables(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11980    let schema = alloc::vec![
11981        ColumnSchema::new("table_catalog", DataType::Text, false),
11982        ColumnSchema::new("table_schema", DataType::Text, false),
11983        ColumnSchema::new("table_name", DataType::Text, false),
11984        ColumnSchema::new("table_type", DataType::Text, false),
11985    ];
11986    let mut rows: Vec<Row> = Vec::new();
11987    for tname in cat.table_names() {
11988        rows.push(Row::new(alloc::vec![
11989            Value::Text("spg".into()),
11990            Value::Text("public".into()),
11991            Value::Text(tname.clone()),
11992            Value::Text("BASE TABLE".into()),
11993        ]));
11994    }
11995    (schema, rows)
11996}
11997
11998/// v7.16.2 — synthesise `pg_catalog.pg_class`. Minimum shape
11999/// for psql `\d` / ORM probes: `relname` + `relkind`. Each
12000/// user table emits one row.
12001fn synth_pg_class(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12002    let schema = alloc::vec![
12003        ColumnSchema::new("relname", DataType::Text, false),
12004        ColumnSchema::new("relkind", DataType::Text, false),
12005        ColumnSchema::new("relnamespace", DataType::BigInt, false),
12006    ];
12007    let mut rows: Vec<Row> = Vec::new();
12008    for tname in cat.table_names() {
12009        rows.push(Row::new(alloc::vec![
12010            Value::Text(tname.clone()),
12011            Value::Text("r".into()),
12012            Value::BigInt(2200), // PG's `public` namespace OID
12013        ]));
12014    }
12015    (schema, rows)
12016}
12017
12018/// v7.16.2 — synthesise `pg_catalog.pg_attribute`. Minimum
12019/// shape: `attrelid` (text — SPG has no OID), `attname`,
12020/// `attnum`, `atttypid` (text), `attnotnull`.
12021fn synth_pg_attribute(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12022    let schema = alloc::vec![
12023        ColumnSchema::new("attrelid", DataType::Text, false),
12024        ColumnSchema::new("attname", DataType::Text, false),
12025        ColumnSchema::new("attnum", DataType::Int, false),
12026        ColumnSchema::new("atttypid", DataType::Text, false),
12027        ColumnSchema::new("attnotnull", DataType::Bool, false),
12028    ];
12029    let mut rows: Vec<Row> = Vec::new();
12030    for tname in cat.table_names() {
12031        let Some(t) = cat.get(&tname) else { continue };
12032        for (i, col) in t.schema().columns.iter().enumerate() {
12033            #[allow(clippy::cast_possible_wrap)]
12034            let ordinal = (i + 1) as i32;
12035            rows.push(Row::new(alloc::vec![
12036                Value::Text(tname.clone()),
12037                Value::Text(col.name.clone()),
12038                Value::Int(ordinal),
12039                Value::Text(pg_data_type_text(col.ty)),
12040                Value::Bool(!col.nullable),
12041            ]));
12042        }
12043    }
12044    (schema, rows)
12045}
12046
12047/// v7.17.0 Phase 3.P0-50 — synthesise `pg_catalog.pg_type`. The
12048/// returned rows cover every built-in scalar / array type sqlx,
12049/// SQLAlchemy, Diesel and pgAdmin look up at compile / connect
12050/// time. PG-canonical schema columns we expose:
12051///   * oid           — type OID (the lookup key sqlx uses)
12052///   * typname       — canonical type name (`int4`, `text`, …)
12053///   * typlen        — width in bytes (-1 for var-length)
12054///   * typtype       — `b`ase / `c`omposite / `e`num / etc.
12055///   * typcategory   — PG type category single-char
12056///   * typelem       — element OID for arrays (0 otherwise)
12057///   * typarray      — array-type OID (0 if no array type)
12058///   * typnamespace  — schema OID (always `public` = 2200)
12059///
12060/// Other pg_type columns (typowner, typinput/typoutput, etc.)
12061/// land in follow-up work — sqlx encoders don't query them at
12062/// connect time.
12063fn synth_pg_type(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12064    let schema = alloc::vec![
12065        ColumnSchema::new("oid", DataType::BigInt, false),
12066        ColumnSchema::new("typname", DataType::Text, false),
12067        ColumnSchema::new("typlen", DataType::SmallInt, false),
12068        ColumnSchema::new("typtype", DataType::Text, false),
12069        ColumnSchema::new("typcategory", DataType::Text, false),
12070        ColumnSchema::new("typelem", DataType::BigInt, false),
12071        ColumnSchema::new("typarray", DataType::BigInt, false),
12072        ColumnSchema::new("typnamespace", DataType::BigInt, false),
12073    ];
12074    // (oid, name, len, type, cat, elem, array_oid). PG OID
12075    // numbers come straight from `pg_type.dat`.
12076    let scalars: &[(i64, &str, i16, &str, &str, i64, i64)] = &[
12077        // bool
12078        (16, "bool", 1, "b", "B", 0, 1000),
12079        (17, "bytea", -1, "b", "U", 0, 1001),
12080        (18, "char", 1, "b", "S", 0, 1002),
12081        (19, "name", 64, "b", "S", 0, 1003),
12082        (20, "int8", 8, "b", "N", 0, 1016),
12083        (21, "int2", 2, "b", "N", 0, 1005),
12084        (23, "int4", 4, "b", "N", 0, 1007),
12085        (24, "regproc", 4, "b", "N", 0, 1008),
12086        (25, "text", -1, "b", "S", 0, 1009),
12087        (26, "oid", 4, "b", "N", 0, 1028),
12088        (114, "json", -1, "b", "U", 0, 199),
12089        (142, "xml", -1, "b", "U", 0, 143),
12090        (700, "float4", 4, "b", "N", 0, 1021),
12091        (701, "float8", 8, "b", "N", 0, 1022),
12092        (650, "cidr", -1, "b", "I", 0, 651),
12093        (869, "inet", -1, "b", "I", 0, 1041),
12094        (829, "macaddr", 6, "b", "U", 0, 1040),
12095        (1042, "bpchar", -1, "b", "S", 0, 1014),
12096        (1043, "varchar", -1, "b", "S", 0, 1015),
12097        (1082, "date", 4, "b", "D", 0, 1182),
12098        (1083, "time", 8, "b", "D", 0, 1183),
12099        (1114, "timestamp", 8, "b", "D", 0, 1115),
12100        (1184, "timestamptz", 8, "b", "D", 0, 1185),
12101        (1186, "interval", 16, "b", "T", 0, 1187),
12102        (1266, "timetz", 12, "b", "D", 0, 1270),
12103        (1700, "numeric", -1, "b", "N", 0, 1231),
12104        (790, "money", 8, "b", "N", 0, 791),
12105        (2950, "uuid", 16, "b", "U", 0, 2951),
12106        (3802, "jsonb", -1, "b", "U", 0, 3807),
12107        (3614, "tsvector", -1, "b", "U", 0, 3643),
12108        (3615, "tsquery", -1, "b", "U", 0, 3645),
12109        // hstore + range types — typcategory 'U' (user) / 'R' (range).
12110        (3908, "tstzrange", -1, "r", "R", 0, 3909),
12111        (3910, "tsrange", -1, "r", "R", 0, 3911),
12112        (3904, "int4range", -1, "r", "R", 0, 3905),
12113        (3926, "int8range", -1, "r", "R", 0, 3927),
12114        (3906, "numrange", -1, "r", "R", 0, 3907),
12115        (3912, "daterange", -1, "r", "R", 0, 3913),
12116    ];
12117    // Array companion types share the typelem / typcategory='A'.
12118    // We emit just the array OIDs the scalars reference.
12119    let arrays: &[(i64, &str, i64)] = &[
12120        (1000, "_bool", 16),
12121        (1001, "_bytea", 17),
12122        (1002, "_char", 18),
12123        (1003, "_name", 19),
12124        (1016, "_int8", 20),
12125        (1005, "_int2", 21),
12126        (1007, "_int4", 23),
12127        (1008, "_regproc", 24),
12128        (1009, "_text", 25),
12129        (1028, "_oid", 26),
12130        (199, "_json", 114),
12131        (143, "_xml", 142),
12132        (1021, "_float4", 700),
12133        (1022, "_float8", 701),
12134        (651, "_cidr", 650),
12135        (1041, "_inet", 869),
12136        (1040, "_macaddr", 829),
12137        (1014, "_bpchar", 1042),
12138        (1015, "_varchar", 1043),
12139        (1182, "_date", 1082),
12140        (1183, "_time", 1083),
12141        (1115, "_timestamp", 1114),
12142        (1185, "_timestamptz", 1184),
12143        (1187, "_interval", 1186),
12144        (1270, "_timetz", 1266),
12145        (1231, "_numeric", 1700),
12146        (791, "_money", 790),
12147        (2951, "_uuid", 2950),
12148        (3807, "_jsonb", 3802),
12149        (3643, "_tsvector", 3614),
12150        (3645, "_tsquery", 3615),
12151    ];
12152    let mut rows: Vec<Row> = Vec::with_capacity(scalars.len() + arrays.len());
12153    for &(oid, name, len, ty, cat, elem, arr) in scalars {
12154        rows.push(Row::new(alloc::vec![
12155            Value::BigInt(oid),
12156            Value::Text(name.into()),
12157            Value::SmallInt(len),
12158            Value::Text(ty.into()),
12159            Value::Text(cat.into()),
12160            Value::BigInt(elem),
12161            Value::BigInt(arr),
12162            Value::BigInt(2200),
12163        ]));
12164    }
12165    for &(oid, name, elem) in arrays {
12166        rows.push(Row::new(alloc::vec![
12167            Value::BigInt(oid),
12168            Value::Text(name.into()),
12169            Value::SmallInt(-1),
12170            Value::Text("b".into()),
12171            Value::Text("A".into()),
12172            Value::BigInt(elem),
12173            Value::BigInt(0),
12174            Value::BigInt(2200),
12175        ]));
12176    }
12177    (schema, rows)
12178}
12179
12180/// v7.17.0 Phase 3.P0-51 — synthesise `pg_catalog.pg_proc`. ORM /
12181/// pgAdmin probes look up functions by name; SPG synthesises rows
12182/// for the built-in scalar functions / aggregates / window funcs
12183/// the engine actually dispatches. SPG has no user-defined
12184/// functions yet so the table is a stable static list.
12185///
12186/// Schema columns exposed:
12187///   * oid (BigInt) — function OID from PG's pg_proc.dat
12188///   * proname (Text) — function name (lowercase)
12189///   * pronamespace (BigInt) — 11 (`pg_catalog`)
12190///   * prokind (Text) — 'f' function, 'a' aggregate, 'w' window
12191///   * pronargs (SmallInt) — declared arg count (-1 for variadic)
12192///   * prorettype (BigInt) — return type OID (matches synth_pg_type)
12193/// v7.24 (round-16 D) — synthesise `pg_catalog.pg_trigger` from the
12194/// live catalog. PG-shaped core columns (tgname, tgenabled with
12195/// 'O'/'D') plus pragmatic text columns PG keeps relational
12196/// (relname, timing, events, function) so health checks don't need
12197/// oid joins.
12198fn synth_pg_trigger(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12199    let schema = alloc::vec![
12200        ColumnSchema::new("tgname", DataType::Text, false),
12201        ColumnSchema::new("relname", DataType::Text, false),
12202        ColumnSchema::new("tgenabled", DataType::Text, false),
12203        ColumnSchema::new("timing", DataType::Text, false),
12204        ColumnSchema::new("events", DataType::Text, false),
12205        ColumnSchema::new("function", DataType::Text, false),
12206    ];
12207    let rows: Vec<Row> = cat
12208        .triggers()
12209        .iter()
12210        .map(|t| {
12211            Row::new(alloc::vec![
12212                Value::Text(t.name.clone()),
12213                Value::Text(t.table.clone()),
12214                Value::Text(if t.enabled { "O".into() } else { "D".into() }),
12215                Value::Text(t.timing.clone()),
12216                Value::Text(t.events.join(" OR ")),
12217                Value::Text(t.function.clone()),
12218            ])
12219        })
12220        .collect();
12221    (schema, rows)
12222}
12223
12224fn synth_pg_proc(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12225    let schema = alloc::vec![
12226        ColumnSchema::new("oid", DataType::BigInt, false),
12227        ColumnSchema::new("proname", DataType::Text, false),
12228        ColumnSchema::new("pronamespace", DataType::BigInt, false),
12229        ColumnSchema::new("prokind", DataType::Text, false),
12230        ColumnSchema::new("pronargs", DataType::Int, false),
12231        ColumnSchema::new("prorettype", DataType::BigInt, false),
12232    ];
12233    // (oid, name, kind, nargs, rettype). OIDs taken from PG's
12234    // pg_proc.dat for the common subset.
12235    let funcs: &[(i64, &str, &str, i32, i64)] = &[
12236        // Scalar functions.
12237        (1318, "length", "f", 1, 23),
12238        (871, "upper", "f", 1, 25),
12239        (870, "lower", "f", 1, 25),
12240        (936, "substring", "f", 3, 25),
12241        (937, "substring", "f", 2, 25),
12242        (3055, "btrim", "f", 1, 25),
12243        (885, "btrim", "f", 2, 25),
12244        (3056, "ltrim", "f", 1, 25),
12245        (875, "ltrim", "f", 2, 25),
12246        (3057, "rtrim", "f", 1, 25),
12247        (876, "rtrim", "f", 2, 25),
12248        (1397, "abs", "f", 1, 23),
12249        (1396, "abs", "f", 1, 20),
12250        (1606, "round", "f", 1, 1700),
12251        (1707, "round", "f", 2, 1700),
12252        (2308, "ceil", "f", 1, 701),
12253        (2309, "ceiling", "f", 1, 701),
12254        (2310, "floor", "f", 1, 701),
12255        (1376, "sqrt", "f", 1, 701),
12256        (1369, "ln", "f", 1, 701),
12257        (1373, "exp", "f", 1, 701),
12258        (1368, "power", "f", 2, 701),
12259        (2228, "random", "f", 0, 701),
12260        // Date / time.
12261        (1299, "now", "f", 0, 1184),
12262        (1274, "current_timestamp", "f", 0, 1184),
12263        (1140, "current_date", "f", 0, 1082),
12264        (2050, "current_time", "f", 0, 1083),
12265        (1158, "date_trunc", "f", 2, 1184),
12266        (1171, "date_part", "f", 2, 701),
12267        (1172, "age", "f", 1, 1186),
12268        (936, "to_char", "f", 2, 25),
12269        // Session / introspection.
12270        (861, "current_database", "f", 0, 19),
12271        (745, "current_user", "f", 0, 19),
12272        (745, "session_user", "f", 0, 19),
12273        (1402, "current_schema", "f", 0, 19),
12274        // String concat / format.
12275        (3058, "concat", "f", -1, 25),
12276        (3059, "concat_ws", "f", -1, 25),
12277        (3539, "format", "f", -1, 25),
12278        // Type introspection.
12279        (2877, "pg_typeof", "f", 1, 2206),
12280        // JSON.
12281        (3198, "json_build_object", "f", -1, 114),
12282        (3199, "jsonb_build_object", "f", -1, 3802),
12283        (3271, "json_build_array", "f", -1, 114),
12284        (3272, "jsonb_build_array", "f", -1, 3802),
12285        // UUID.
12286        (3253, "gen_random_uuid", "f", 0, 2950),
12287        (3252, "uuid_generate_v4", "f", 0, 2950),
12288        // Aggregates.
12289        (2147, "count", "a", 0, 20),
12290        (2803, "count", "a", -1, 20),
12291        (2116, "max", "a", 1, 23),
12292        (2132, "min", "a", 1, 23),
12293        (2108, "sum", "a", 1, 20),
12294        (2100, "avg", "a", 1, 1700),
12295        (2517, "string_agg", "a", 2, 25),
12296        (2747, "array_agg", "a", 1, 1009),
12297        (2517, "bool_and", "a", 1, 16),
12298        (2518, "bool_or", "a", 1, 16),
12299        (2519, "every", "a", 1, 16),
12300        // Window functions.
12301        (3100, "row_number", "w", 0, 20),
12302        (3101, "rank", "w", 0, 20),
12303        (3102, "dense_rank", "w", 0, 20),
12304        (3103, "percent_rank", "w", 0, 701),
12305        (3104, "cume_dist", "w", 0, 701),
12306        (3105, "lag", "w", -1, 2283),
12307        (3106, "lead", "w", -1, 2283),
12308        (3107, "first_value", "w", 1, 2283),
12309        (3108, "last_value", "w", 1, 2283),
12310        (3109, "nth_value", "w", 2, 2283),
12311    ];
12312    let mut rows: Vec<Row> = Vec::with_capacity(funcs.len());
12313    for &(oid, name, kind, nargs, rettype) in funcs {
12314        rows.push(Row::new(alloc::vec![
12315            Value::BigInt(oid),
12316            Value::Text(name.into()),
12317            Value::BigInt(11),
12318            Value::Text(kind.into()),
12319            Value::Int(nargs),
12320            Value::BigInt(rettype),
12321        ]));
12322    }
12323    (schema, rows)
12324}
12325
12326/// v7.17.0 Phase 3.P0-65 — synthesise `mysql.user`. MySQL admin
12327/// queries (`SELECT user, host FROM mysql.user`) probe this at
12328/// connect time to list accounts. SPG ships one row per
12329/// UserStore entry plus a synthetic `root` superuser row for
12330/// MySQL bootstrap compat.
12331fn synth_mysql_user(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
12332    let schema = alloc::vec![
12333        ColumnSchema::new("user", DataType::Text, false),
12334        ColumnSchema::new("host", DataType::Text, false),
12335        ColumnSchema::new("select_priv", DataType::Text, false),
12336    ];
12337    let mut rows: Vec<Row> = Vec::new();
12338    rows.push(Row::new(alloc::vec![
12339        Value::Text("root".into()),
12340        Value::Text("localhost".into()),
12341        Value::Text("Y".into()),
12342    ]));
12343    for (name, _) in engine.users.iter() {
12344        if name != "root" {
12345            rows.push(Row::new(alloc::vec![
12346                Value::Text(name.to_string()),
12347                Value::Text("%".into()),
12348                Value::Text("Y".into()),
12349            ]));
12350        }
12351    }
12352    (schema, rows)
12353}
12354
12355/// v7.17.0 Phase 3.P0-65 — synthesise `mysql.db`. The
12356/// per-database privileges table. SPG is single-database so the
12357/// table surfaces one row per declared user with full privileges
12358/// on the canonical `postgres` database.
12359fn synth_mysql_db() -> (Vec<ColumnSchema>, Vec<Row>) {
12360    let schema = alloc::vec![
12361        ColumnSchema::new("host", DataType::Text, false),
12362        ColumnSchema::new("db", DataType::Text, false),
12363        ColumnSchema::new("user", DataType::Text, false),
12364        ColumnSchema::new("select_priv", DataType::Text, false),
12365    ];
12366    let rows = alloc::vec![Row::new(alloc::vec![
12367        Value::Text("localhost".into()),
12368        Value::Text("postgres".into()),
12369        Value::Text("root".into()),
12370        Value::Text("Y".into()),
12371    ])];
12372    (schema, rows)
12373}
12374
12375/// v7.17.0 Phase 3.P0-63 — synthesise
12376/// `information_schema.KEY_COLUMN_USAGE`. ORM migration tools
12377/// (Alembic, Sequelize, TypeORM) walk this view to discover FK
12378/// relationships in MySQL-flavoured introspection queries.
12379///
12380/// Schema columns exposed:
12381///   * CONSTRAINT_NAME (Text)
12382///   * TABLE_NAME (Text)
12383///   * COLUMN_NAME (Text)
12384///   * ORDINAL_POSITION (Int)
12385///   * REFERENCED_TABLE_NAME (Text) — empty for non-FK rows
12386///   * REFERENCED_COLUMN_NAME (Text) — empty for non-FK rows
12387fn synth_info_key_column_usage(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12388    let schema = alloc::vec![
12389        ColumnSchema::new("constraint_name", DataType::Text, false),
12390        ColumnSchema::new("table_name", DataType::Text, false),
12391        ColumnSchema::new("column_name", DataType::Text, false),
12392        ColumnSchema::new("ordinal_position", DataType::Int, false),
12393        ColumnSchema::new("referenced_table_name", DataType::Text, false),
12394        ColumnSchema::new("referenced_column_name", DataType::Text, false),
12395    ];
12396    let mut rows: Vec<Row> = Vec::new();
12397    for tname in cat.table_names() {
12398        let Some(t) = cat.get(&tname) else { continue };
12399        let cols = &t.schema().columns;
12400        let col_name_at = |pos: usize| -> String {
12401            cols.get(pos)
12402                .map_or_else(|| alloc::format!("col{pos}"), |c| c.name.clone())
12403        };
12404        // FKs.
12405        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
12406            let conname = fk
12407                .name
12408                .clone()
12409                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
12410            for (i, (&local, &parent)) in fk
12411                .local_columns
12412                .iter()
12413                .zip(fk.parent_columns.iter())
12414                .enumerate()
12415            {
12416                let parent_name = cat
12417                    .get(&fk.parent_table)
12418                    .and_then(|pt| pt.schema().columns.get(parent).map(|c| c.name.clone()))
12419                    .unwrap_or_else(|| alloc::format!("col{parent}"));
12420                #[allow(clippy::cast_possible_wrap)]
12421                let ordinal = (i + 1) as i32;
12422                rows.push(Row::new(alloc::vec![
12423                    Value::Text(conname.clone()),
12424                    Value::Text(tname.clone()),
12425                    Value::Text(col_name_at(local)),
12426                    Value::Int(ordinal),
12427                    Value::Text(fk.parent_table.clone()),
12428                    Value::Text(parent_name),
12429                ]));
12430            }
12431        }
12432        // PK / composite UC entries.
12433        for (ci, uc) in t.schema().uniqueness_constraints.iter().enumerate() {
12434            let conname = if uc.is_primary_key {
12435                alloc::format!("{}_pkey", tname)
12436            } else {
12437                alloc::format!("{}_uniq{ci}", tname)
12438            };
12439            for (i, &local) in uc.columns.iter().enumerate() {
12440                #[allow(clippy::cast_possible_wrap)]
12441                let ordinal = (i + 1) as i32;
12442                rows.push(Row::new(alloc::vec![
12443                    Value::Text(conname.clone()),
12444                    Value::Text(tname.clone()),
12445                    Value::Text(col_name_at(local)),
12446                    Value::Int(ordinal),
12447                    Value::Text(String::new()),
12448                    Value::Text(String::new()),
12449                ]));
12450            }
12451        }
12452    }
12453    (schema, rows)
12454}
12455
12456/// v7.17.0 Phase 3.P0-64 — synthesise
12457/// `information_schema.REFERENTIAL_CONSTRAINTS`. One row per FK.
12458fn synth_info_referential_constraints(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12459    let schema = alloc::vec![
12460        ColumnSchema::new("constraint_name", DataType::Text, false),
12461        ColumnSchema::new("table_name", DataType::Text, false),
12462        ColumnSchema::new("referenced_table_name", DataType::Text, false),
12463        ColumnSchema::new("update_rule", DataType::Text, false),
12464        ColumnSchema::new("delete_rule", DataType::Text, false),
12465    ];
12466    fn rule_name(a: spg_storage::FkAction) -> &'static str {
12467        match a {
12468            spg_storage::FkAction::Cascade => "CASCADE",
12469            spg_storage::FkAction::SetNull => "SET NULL",
12470            spg_storage::FkAction::SetDefault => "SET DEFAULT",
12471            spg_storage::FkAction::Restrict => "RESTRICT",
12472            spg_storage::FkAction::NoAction => "NO ACTION",
12473        }
12474    }
12475    let mut rows: Vec<Row> = Vec::new();
12476    for tname in cat.table_names() {
12477        let Some(t) = cat.get(&tname) else { continue };
12478        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
12479            let conname = fk
12480                .name
12481                .clone()
12482                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
12483            rows.push(Row::new(alloc::vec![
12484                Value::Text(conname),
12485                Value::Text(tname.clone()),
12486                Value::Text(fk.parent_table.clone()),
12487                Value::Text(rule_name(fk.on_update).into()),
12488                Value::Text(rule_name(fk.on_delete).into()),
12489            ]));
12490        }
12491    }
12492    (schema, rows)
12493}
12494
12495/// v7.17.0 Phase 3.P0-64 — synthesise `information_schema.STATISTICS`.
12496/// One row per (index × column) — admin tools walk this to
12497/// surface index-cardinality estimates.
12498fn synth_info_statistics(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12499    let schema = alloc::vec![
12500        ColumnSchema::new("table_name", DataType::Text, false),
12501        ColumnSchema::new("index_name", DataType::Text, false),
12502        ColumnSchema::new("column_name", DataType::Text, false),
12503        ColumnSchema::new("seq_in_index", DataType::Int, false),
12504        ColumnSchema::new("non_unique", DataType::Int, false),
12505        ColumnSchema::new("index_type", DataType::Text, false),
12506    ];
12507    let mut rows: Vec<Row> = Vec::new();
12508    for tname in cat.table_names() {
12509        let Some(t) = cat.get(&tname) else { continue };
12510        for idx in t.indices() {
12511            let col = t
12512                .schema()
12513                .columns
12514                .get(idx.column_position)
12515                .map_or("?".into(), |c| c.name.clone());
12516            rows.push(Row::new(alloc::vec![
12517                Value::Text(tname.clone()),
12518                Value::Text(idx.name.clone()),
12519                Value::Text(col),
12520                Value::Int(1),
12521                Value::Int(i32::from(!idx.is_unique)),
12522                Value::Text("BTREE".into()),
12523            ]));
12524        }
12525    }
12526    (schema, rows)
12527}
12528
12529/// v7.17.0 Phase 3.P0-64 — synthesise `information_schema.ROUTINES`.
12530/// SPG has no user-defined functions in v7.17 so the surface is
12531/// always empty; admin tools just need the table to exist.
12532fn synth_info_routines() -> (Vec<ColumnSchema>, Vec<Row>) {
12533    let schema = alloc::vec![
12534        ColumnSchema::new("routine_name", DataType::Text, false),
12535        ColumnSchema::new("routine_type", DataType::Text, false),
12536        ColumnSchema::new("data_type", DataType::Text, false),
12537    ];
12538    (schema, Vec::new())
12539}
12540
12541/// v7.17.0 Phase 3.P0-54 — synthesise `pg_catalog.pg_constraint`.
12542/// ORM compilers (Diesel, sea-orm) and admin tools probe this for
12543/// FK / UNIQUE / PK / CHECK definitions to surface relationship
12544/// graphs and validation rules. SPG ships one row per
12545/// uniqueness constraint + foreign key declared in the catalog.
12546///
12547/// Schema columns exposed:
12548///   * conname (Text) — constraint name (synthetic when anonymous)
12549///   * contype (Text) — `p` PK, `u` UNIQUE, `f` FK, `c` CHECK
12550///   * conrelid (Text) — owner table name
12551///   * confrelid (Text) — referenced parent table (FK only;
12552///     empty string otherwise)
12553///   * conkey (Text) — comma-separated column names
12554///   * confkey (Text) — comma-separated parent column names (FK only)
12555fn synth_pg_constraint(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12556    let schema = alloc::vec![
12557        ColumnSchema::new("conname", DataType::Text, false),
12558        ColumnSchema::new("contype", DataType::Text, false),
12559        ColumnSchema::new("conrelid", DataType::Text, false),
12560        ColumnSchema::new("confrelid", DataType::Text, false),
12561        ColumnSchema::new("conkey", DataType::Text, false),
12562        ColumnSchema::new("confkey", DataType::Text, false),
12563    ];
12564    let mut rows: Vec<Row> = Vec::new();
12565    for tname in cat.table_names() {
12566        let Some(t) = cat.get(&tname) else { continue };
12567        let cols = &t.schema().columns;
12568        let col_name_at = |pos: usize| -> String {
12569            cols.get(pos)
12570                .map_or_else(|| alloc::format!("col{pos}"), |c| c.name.clone())
12571        };
12572        // Uniqueness constraints (composite UNIQUE / PRIMARY KEY).
12573        for (ci, uc) in t.schema().uniqueness_constraints.iter().enumerate() {
12574            let kind = if uc.is_primary_key { "p" } else { "u" };
12575            let conname = if uc.is_primary_key {
12576                alloc::format!("{}_pkey", tname)
12577            } else {
12578                alloc::format!("{}_uniq{ci}", tname)
12579            };
12580            let conkey: Vec<String> = uc.columns.iter().map(|&p| col_name_at(p)).collect();
12581            rows.push(Row::new(alloc::vec![
12582                Value::Text(conname),
12583                Value::Text(kind.into()),
12584                Value::Text(tname.clone()),
12585                Value::Text(String::new()),
12586                Value::Text(conkey.join(",")),
12587                Value::Text(String::new()),
12588            ]));
12589        }
12590        // Single-column PK / UNIQUE indexes that have no
12591        // matching entry in `uniqueness_constraints` (the engine
12592        // creates only the BTree index for the bare-column case;
12593        // composite forms ride the UC path above).
12594        for idx in t.indices() {
12595            if !idx.is_unique {
12596                continue;
12597            }
12598            let is_primary = idx.name.ends_with("_pkey");
12599            let conname = idx.name.clone();
12600            let kind = if is_primary { "p" } else { "u" };
12601            let col_name = col_name_at(idx.column_position);
12602            // Skip if already emitted via the UC loop above (same
12603            // tuple shape — single-column).
12604            let already = t
12605                .schema()
12606                .uniqueness_constraints
12607                .iter()
12608                .any(|uc| uc.columns.len() == 1 && uc.columns[0] == idx.column_position);
12609            if already {
12610                continue;
12611            }
12612            rows.push(Row::new(alloc::vec![
12613                Value::Text(conname),
12614                Value::Text(kind.into()),
12615                Value::Text(tname.clone()),
12616                Value::Text(String::new()),
12617                Value::Text(col_name),
12618                Value::Text(String::new()),
12619            ]));
12620        }
12621        // Foreign keys.
12622        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
12623            let conname = fk
12624                .name
12625                .clone()
12626                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
12627            let conkey: Vec<String> = fk.local_columns.iter().map(|&p| col_name_at(p)).collect();
12628            // Parent column names: look up the parent table's
12629            // schema if it exists; otherwise emit positions.
12630            let confkey: Vec<String> = if let Some(parent) = cat.get(&fk.parent_table) {
12631                fk.parent_columns
12632                    .iter()
12633                    .map(|&p| {
12634                        parent
12635                            .schema()
12636                            .columns
12637                            .get(p)
12638                            .map_or_else(|| alloc::format!("col{p}"), |c| c.name.clone())
12639                    })
12640                    .collect()
12641            } else {
12642                fk.parent_columns
12643                    .iter()
12644                    .map(|p| alloc::format!("col{p}"))
12645                    .collect()
12646            };
12647            rows.push(Row::new(alloc::vec![
12648                Value::Text(conname),
12649                Value::Text("f".into()),
12650                Value::Text(tname.clone()),
12651                Value::Text(fk.parent_table.clone()),
12652                Value::Text(conkey.join(",")),
12653                Value::Text(confkey.join(",")),
12654            ]));
12655        }
12656    }
12657    (schema, rows)
12658}
12659
12660/// v7.17.0 Phase 3.P0-55 — synthesise `pg_catalog.pg_database`.
12661/// SPG is single-database so we surface a single row keyed on the
12662/// canonical `postgres` database name (matching what every PG
12663/// admin tool's startup screen expects to find).
12664fn synth_pg_database(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12665    let schema = alloc::vec![
12666        ColumnSchema::new("oid", DataType::BigInt, false),
12667        ColumnSchema::new("datname", DataType::Text, false),
12668        ColumnSchema::new("datdba", DataType::BigInt, false),
12669        ColumnSchema::new("encoding", DataType::Int, false),
12670        ColumnSchema::new("datcollate", DataType::Text, false),
12671    ];
12672    let rows = alloc::vec![Row::new(alloc::vec![
12673        Value::BigInt(16384),
12674        Value::Text("postgres".into()),
12675        Value::BigInt(10),
12676        Value::Int(6), // UTF8
12677        Value::Text("en_US.UTF-8".into()),
12678    ])];
12679    (schema, rows)
12680}
12681
12682/// v7.17.0 Phase 3.P0-55 — synthesise `pg_catalog.pg_roles`. PG's
12683/// pg_roles is a view over pg_authid showing all roles. SPG ships
12684/// one row per declared user from the engine's UserStore so admin
12685/// tool startup screens can populate.
12686fn synth_pg_roles(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
12687    let schema = alloc::vec![
12688        ColumnSchema::new("oid", DataType::BigInt, false),
12689        ColumnSchema::new("rolname", DataType::Text, false),
12690        ColumnSchema::new("rolsuper", DataType::Bool, false),
12691        ColumnSchema::new("rolinherit", DataType::Bool, false),
12692        ColumnSchema::new("rolcanlogin", DataType::Bool, false),
12693    ];
12694    let mut rows: Vec<Row> = Vec::new();
12695    let oid: i64 = 10;
12696    for (i, (name, _)) in engine.users.iter().enumerate() {
12697        rows.push(Row::new(alloc::vec![
12698            Value::BigInt(oid + (i as i64) + 1),
12699            Value::Text(name.to_string()),
12700            Value::Bool(false),
12701            Value::Bool(true),
12702            Value::Bool(true),
12703        ]));
12704    }
12705    // Always include `postgres` as the bootstrap superuser if not
12706    // already present — admin tools probe for it.
12707    if !rows
12708        .iter()
12709        .any(|r| matches!(&r.values[1], Value::Text(s) if s == "postgres"))
12710    {
12711        rows.insert(
12712            0,
12713            Row::new(alloc::vec![
12714                Value::BigInt(10),
12715                Value::Text("postgres".into()),
12716                Value::Bool(true),
12717                Value::Bool(true),
12718                Value::Bool(true),
12719            ]),
12720        );
12721    }
12722    (schema, rows)
12723}
12724
12725/// v7.17.0 Phase 3.P0-56 — synthesise `pg_catalog.pg_views`. PG's
12726/// pg_views is a view listing every catalog view; SPG ships one
12727/// row per declared view + its definition text.
12728/// Synthesise `pg_catalog.pg_extension`. SPG ships its "extension"
12729/// surfaces natively (vector, pg_trgm, plpgsql-shaped DO blocks), so
12730/// the table lists those as installed — `SELECT … FROM pg_extension
12731/// WHERE extname = 'vector'` probes from PG clients (mailrs embed
12732/// round-12) answer truthfully about capability presence.
12733fn synth_pg_extension() -> (Vec<ColumnSchema>, Vec<Row>) {
12734    let schema = alloc::vec![
12735        ColumnSchema::new("oid", DataType::BigInt, false),
12736        ColumnSchema::new("extname", DataType::Text, false),
12737        ColumnSchema::new("extversion", DataType::Text, false),
12738        ColumnSchema::new("extnamespace", DataType::Text, false),
12739    ];
12740    let exts: &[(&str, &str)] = &[("plpgsql", "1.0"), ("vector", "0.8.0"), ("pg_trgm", "1.6")];
12741    let rows = exts
12742        .iter()
12743        .enumerate()
12744        .map(|(i, (name, ver))| {
12745            Row::new(alloc::vec![
12746                Value::BigInt(16384 + i as i64),
12747                Value::Text((*name).into()),
12748                Value::Text((*ver).into()),
12749                Value::Text("pg_catalog".into()),
12750            ])
12751        })
12752        .collect();
12753    (schema, rows)
12754}
12755
12756fn synth_pg_views(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12757    let schema = alloc::vec![
12758        ColumnSchema::new("schemaname", DataType::Text, false),
12759        ColumnSchema::new("viewname", DataType::Text, false),
12760        ColumnSchema::new("definition", DataType::Text, false),
12761    ];
12762    let mut rows: Vec<Row> = Vec::new();
12763    for (name, def) in cat.views() {
12764        rows.push(Row::new(alloc::vec![
12765            Value::Text("public".into()),
12766            Value::Text(name.clone()),
12767            Value::Text(def.body.clone()),
12768        ]));
12769    }
12770    (schema, rows)
12771}
12772
12773/// v7.17.0 Phase 3.P0-57 — synthesise `pg_catalog.pg_settings`. ORM
12774/// connection-checkers (sqlx pre-flight, Diesel migrator) and admin
12775/// tools read `pg_settings` to discover server-side configuration.
12776/// SPG surfaces every session_param + a small set of canonical PG
12777/// defaults so the pre-flight queries match.
12778fn synth_pg_settings(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
12779    let schema = alloc::vec![
12780        ColumnSchema::new("name", DataType::Text, false),
12781        ColumnSchema::new("setting", DataType::Text, false),
12782        ColumnSchema::new("category", DataType::Text, false),
12783    ];
12784    let mut rows: Vec<Row> = Vec::new();
12785    // Canonical defaults every admin tool expects to find.
12786    let defaults: &[(&str, &str, &str)] = &[
12787        ("server_version", "16.0 (spg)", "Preset Options"),
12788        ("server_encoding", "UTF8", "Client Connection Defaults"),
12789        ("client_encoding", "UTF8", "Client Connection Defaults"),
12790        ("DateStyle", "ISO, MDY", "Client Connection Defaults"),
12791        ("TimeZone", "UTC", "Client Connection Defaults"),
12792        ("standard_conforming_strings", "on", "Compatibility"),
12793        ("integer_datetimes", "on", "Compatibility"),
12794        ("max_connections", "100", "Connections and Authentication"),
12795    ];
12796    for &(name, val, cat) in defaults {
12797        rows.push(Row::new(alloc::vec![
12798            Value::Text(name.into()),
12799            Value::Text(val.into()),
12800            Value::Text(cat.into()),
12801        ]));
12802    }
12803    // Session-set params override the static defaults.
12804    for (k, v) in &engine.session_params {
12805        if !defaults
12806            .iter()
12807            .any(|(n, _, _)| (*n).eq_ignore_ascii_case(k))
12808        {
12809            rows.push(Row::new(alloc::vec![
12810                Value::Text(k.clone()),
12811                Value::Text(v.clone()),
12812                Value::Text("Session".into()),
12813            ]));
12814        }
12815    }
12816    (schema, rows)
12817}
12818
12819/// v7.17.0 Phase 3.P0-53 — synthesise `pg_catalog.pg_indexes`.
12820/// PG's pg_indexes is a real view on pg_index + pg_class + pg_attribute.
12821/// SPG ships it as a synthesised flat table so admin tools (pgAdmin,
12822/// DataGrip) can list indexes by tablename without joining four catalogs.
12823///
12824/// Schema columns exposed:
12825///   * schemaname (Text) — always `public`
12826///   * tablename (Text)
12827///   * indexname (Text)
12828///   * indexdef (Text) — best-effort CREATE INDEX DDL
12829fn synth_pg_indexes(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12830    let schema = alloc::vec![
12831        ColumnSchema::new("schemaname", DataType::Text, false),
12832        ColumnSchema::new("tablename", DataType::Text, false),
12833        ColumnSchema::new("indexname", DataType::Text, false),
12834        ColumnSchema::new("indexdef", DataType::Text, false),
12835    ];
12836    let mut rows: Vec<Row> = Vec::new();
12837    for tname in cat.table_names() {
12838        let Some(t) = cat.get(&tname) else { continue };
12839        for idx in t.indices() {
12840            let col_name = t
12841                .schema()
12842                .columns
12843                .get(idx.column_position)
12844                .map_or("?".into(), |c| c.name.clone());
12845            let unique_kw = if idx.is_unique { "UNIQUE " } else { "" };
12846            let indexdef = alloc::format!(
12847                "CREATE {unique_kw}INDEX {} ON public.{} ({})",
12848                idx.name,
12849                tname,
12850                col_name
12851            );
12852            rows.push(Row::new(alloc::vec![
12853                Value::Text("public".into()),
12854                Value::Text(tname.clone()),
12855                Value::Text(idx.name.clone()),
12856                Value::Text(indexdef),
12857            ]));
12858        }
12859    }
12860    (schema, rows)
12861}
12862
12863/// v7.17.0 Phase 3.P0-53 — synthesise `pg_catalog.pg_index`. The
12864/// "raw" pg_index catalog used by PG-internal tooling for index
12865/// flags and ordinal information. SPG ships the columns ORM probes
12866/// actually filter on.
12867///
12868/// Schema columns exposed:
12869///   * indexrelid (BigInt) — index OID (synthetic = position+1)
12870///   * indrelid (BigInt) — table OID (synthetic = position+1)
12871///   * indnatts (Int) — number of indexed columns
12872///   * indisunique (Bool)
12873///   * indisprimary (Bool)
12874fn synth_pg_index_raw(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12875    let schema = alloc::vec![
12876        ColumnSchema::new("indexrelid", DataType::BigInt, false),
12877        ColumnSchema::new("indrelid", DataType::BigInt, false),
12878        ColumnSchema::new("indnatts", DataType::Int, false),
12879        ColumnSchema::new("indisunique", DataType::Bool, false),
12880        ColumnSchema::new("indisprimary", DataType::Bool, false),
12881    ];
12882    let mut rows: Vec<Row> = Vec::new();
12883    let mut idx_oid: i64 = 100_000;
12884    for (table_idx, tname) in cat.table_names().iter().enumerate() {
12885        let Some(t) = cat.get(tname) else { continue };
12886        for idx in t.indices() {
12887            idx_oid += 1;
12888            #[allow(clippy::cast_possible_wrap)]
12889            let nattrs = (1 + idx.extra_column_positions.len()) as i32;
12890            // is_primary: SPG / PG flag the primary via the
12891            // index name convention `<table>_pkey`.
12892            let is_primary = idx.name.ends_with("_pkey");
12893            rows.push(Row::new(alloc::vec![
12894                Value::BigInt(idx_oid),
12895                Value::BigInt((table_idx + 1) as i64),
12896                Value::Int(nattrs),
12897                Value::Bool(idx.is_unique),
12898                Value::Bool(is_primary),
12899            ]));
12900        }
12901    }
12902    (schema, rows)
12903}
12904
12905/// v7.17.0 Phase 3.P0-52 — synthesise `pg_catalog.pg_namespace`.
12906/// SPG is single-schema so we expose the canonical PG schemas:
12907/// `public` (user-facing), `pg_catalog` (built-in), and
12908/// `information_schema` (PG meta).
12909fn synth_pg_namespace(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12910    let schema = alloc::vec![
12911        ColumnSchema::new("oid", DataType::BigInt, false),
12912        ColumnSchema::new("nspname", DataType::Text, false),
12913        ColumnSchema::new("nspowner", DataType::BigInt, false),
12914    ];
12915    let rows = alloc::vec![
12916        Row::new(alloc::vec![
12917            Value::BigInt(11),
12918            Value::Text("pg_catalog".into()),
12919            Value::BigInt(10),
12920        ]),
12921        Row::new(alloc::vec![
12922            Value::BigInt(2200),
12923            Value::Text("public".into()),
12924            Value::BigInt(10),
12925        ]),
12926        Row::new(alloc::vec![
12927            Value::BigInt(13000),
12928            Value::Text("information_schema".into()),
12929            Value::BigInt(10),
12930        ]),
12931    ];
12932    (schema, rows)
12933}
12934
12935/// v7.16.2 — drop the synthesised meta view into the enriched
12936/// catalog so the regular FROM-resolution path can see it.
12937fn materialise_meta_view(
12938    catalog: &mut Catalog,
12939    name: &str,
12940    columns: Vec<ColumnSchema>,
12941    rows: Vec<Row>,
12942) -> Result<(), EngineError> {
12943    let schema = TableSchema::new(name.to_string(), columns);
12944    catalog.create_table(schema).map_err(EngineError::Storage)?;
12945    let table = catalog
12946        .get_mut(name)
12947        .expect("just-created meta view must exist");
12948    for row in rows {
12949        table.insert(row).map_err(EngineError::Storage)?;
12950    }
12951    Ok(())
12952}
12953
12954/// v7.16.2 — true when the SELECT statement references any
12955/// `__spg_info_*` or `__spg_pg_*` synthetic table name (the
12956/// parser produces these for `information_schema.X` /
12957/// `pg_catalog.X`). Used by `exec_select_cancel` to short-
12958/// circuit into the meta-view materialisation path.
12959/// v7.17.0 Phase 1.2 — append the names of any catalog-known
12960/// views referenced by `tref` to `into`. Helper for
12961/// `Engine::expand_views_in_select`. A view that's been already
12962/// materialised as a table (e.g. via the synthetic CTE pass for
12963/// SELECT FROM v) is skipped — the table form wins so the
12964/// recursive exec_select_cancel call inside exec_with_ctes
12965/// doesn't re-expand and trigger the CTE-shadow guard.
12966fn collect_view_refs(
12967    tref: &spg_sql::ast::TableRef,
12968    cat: &spg_storage::Catalog,
12969    into: &mut Vec<String>,
12970) {
12971    if cat.views().contains_key(&tref.name)
12972        && cat.get(&tref.name).is_none()
12973        && !into.iter().any(|n| n == &tref.name)
12974    {
12975        into.push(tref.name.clone());
12976    }
12977}
12978
12979fn select_references_meta_view(stmt: &SelectStatement) -> bool {
12980    fn is_meta(name: &str) -> bool {
12981        name.starts_with("__spg_info_")
12982            || name.starts_with("__spg_pg_")
12983            || name.starts_with("__spg_mysql_")
12984    }
12985    if let Some(from) = &stmt.from {
12986        if is_meta(&from.primary.name) {
12987            return true;
12988        }
12989        for j in &from.joins {
12990            if is_meta(&j.table.name) {
12991                return true;
12992            }
12993        }
12994    }
12995    for cte in &stmt.ctes {
12996        if select_references_meta_view(&cte.body) {
12997            return true;
12998        }
12999    }
13000    false
13001}
13002
13003/// v7.16.2 — collect every meta-view name a SELECT touches.
13004/// Returns a deduplicated, sorted list. Caller materialises
13005/// each one into the enriched catalog before re-running the
13006/// SELECT. Walks JOINs, CTEs, and the primary FROM.
13007fn collect_meta_view_names(
13008    stmt: &SelectStatement,
13009    into: &mut alloc::collections::BTreeSet<String>,
13010) {
13011    fn is_meta(name: &str) -> bool {
13012        name.starts_with("__spg_info_")
13013            || name.starts_with("__spg_pg_")
13014            || name.starts_with("__spg_mysql_")
13015    }
13016    if let Some(from) = &stmt.from {
13017        if is_meta(&from.primary.name) {
13018            into.insert(from.primary.name.clone());
13019        }
13020        for j in &from.joins {
13021            if is_meta(&j.table.name) {
13022                into.insert(j.table.name.clone());
13023            }
13024        }
13025    }
13026    for cte in &stmt.ctes {
13027        collect_meta_view_names(&cte.body, into);
13028    }
13029}
13030
13031fn infer_column_types(columns: &[ColumnSchema], rows: &[Row]) -> Vec<ColumnSchema> {
13032    let mut out = columns.to_vec();
13033    for (col_idx, col) in out.iter_mut().enumerate() {
13034        if col.ty != DataType::Text {
13035            continue;
13036        }
13037        let mut inferred: Option<DataType> = None;
13038        let mut all_null = true;
13039        for row in rows {
13040            let Some(v) = row.values.get(col_idx) else {
13041                continue;
13042            };
13043            let ty = match v {
13044                Value::Null => continue,
13045                Value::SmallInt(_) => DataType::SmallInt,
13046                Value::Int(_) => DataType::Int,
13047                Value::BigInt(_) => DataType::BigInt,
13048                Value::Float(_) => DataType::Float,
13049                Value::Bool(_) => DataType::Bool,
13050                Value::Vector(_) => DataType::Vector {
13051                    dim: 0,
13052                    encoding: VecEncoding::F32,
13053                },
13054                _ => DataType::Text,
13055            };
13056            all_null = false;
13057            inferred = Some(match inferred {
13058                None => ty,
13059                Some(prev) if prev == ty => prev,
13060                Some(_) => DataType::Text,
13061            });
13062        }
13063        if let Some(t) = inferred {
13064            col.ty = t;
13065            col.nullable = true;
13066        } else if all_null {
13067            col.nullable = true;
13068        }
13069    }
13070    out
13071}
13072
13073/// v4.26: render a human-readable plan tree for `EXPLAIN <select>`.
13074/// Lines are pushed into `out`; `depth` controls indentation. We
13075/// describe the rewritten SELECT — what the executor *would* do —
13076/// using the engine handle to spot indexed lookups and table shapes.
13077#[allow(clippy::too_many_lines, clippy::format_push_string)]
13078/// v6.2.4 — Walk every line of the rendered plan tree and append
13079/// per-operator stats. Lines that name a known operator get
13080/// `(rows=N)` (`actual_rows` of the top-level operator equals the
13081/// final result row count; scans report their catalog row count
13082/// as the rows-considered metric). Other lines — Filter / Join /
13083/// GroupBy / OrderBy etc. — are marked `(—)` so the surface is
13084/// complete-by-construction; v6.2.5 fills these in via inline
13085/// executor counters.
13086/// v6.8.3 — surface "CREATE INDEX …" suggestions for every
13087/// `(table, column)` pair the query touches via WHERE / JOIN
13088/// that doesn't already have an index on the owning table.
13089/// Walks the SELECT's FROM clauses + WHERE expression tree;
13090/// returns one line per missing index. Deterministic order:
13091/// FROM-clause iteration order, then column-reference walk
13092/// order inside each WHERE. Each suggestion is a copy-pastable
13093/// DDL string.
13094fn build_index_suggestions(stmt: &SelectStatement, engine: &Engine) -> Vec<String> {
13095    use alloc::collections::BTreeSet;
13096    let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
13097    let mut out: Vec<String> = Vec::new();
13098    let cat = engine.active_catalog();
13099    // Build a (table, qualifier-or-alias) list from the FROM clause
13100    // so unqualified column refs in WHERE resolve to the correct
13101    // table.
13102    let Some(from) = &stmt.from else {
13103        return out;
13104    };
13105    let mut tables: Vec<String> = Vec::new();
13106    tables.push(from.primary.name.clone());
13107    for j in &from.joins {
13108        tables.push(j.table.name.clone());
13109    }
13110    // Collect column refs from the WHERE expression. JOIN ON
13111    // predicates also feed in.
13112    let mut col_refs: Vec<spg_sql::ast::ColumnName> = Vec::new();
13113    if let Some(w) = &stmt.where_ {
13114        collect_column_refs(w, &mut col_refs);
13115    }
13116    for j in &from.joins {
13117        if let Some(on) = &j.on {
13118            collect_column_refs(on, &mut col_refs);
13119        }
13120    }
13121    for cn in &col_refs {
13122        // Resolve owner table: explicit qualifier first, else
13123        // first table in FROM that has a column of this name.
13124        let owner: Option<String> = if let Some(q) = &cn.qualifier {
13125            tables.iter().find(|t| t == &q).cloned()
13126        } else {
13127            tables.iter().find_map(|t| {
13128                cat.get(t).and_then(|tbl| {
13129                    if tbl.schema().column_position(&cn.name).is_some() {
13130                        Some(t.clone())
13131                    } else {
13132                        None
13133                    }
13134                })
13135            })
13136        };
13137        let Some(owner) = owner else {
13138            continue;
13139        };
13140        let Some(tbl) = cat.get(&owner) else {
13141            continue;
13142        };
13143        let Some(col_pos) = tbl.schema().column_position(&cn.name) else {
13144            continue;
13145        };
13146        // Skip if any BTree index already covers this column as
13147        // its key.
13148        let already_indexed = tbl.indices().iter().any(|i| {
13149            matches!(i.kind, spg_storage::IndexKind::BTree(_))
13150                && i.column_position == col_pos
13151                && i.expression.is_none()
13152                && i.partial_predicate.is_none()
13153        });
13154        if already_indexed {
13155            continue;
13156        }
13157        if seen.insert((owner.clone(), cn.name.clone())) {
13158            out.push(alloc::format!(
13159                "SUGGEST: CREATE INDEX ix_{}_{} ON {} ({})",
13160                owner,
13161                cn.name,
13162                owner,
13163                cn.name
13164            ));
13165        }
13166    }
13167    out
13168}
13169
13170/// Walks an `Expr` and pushes every `ColumnName` it references.
13171/// Order is depth-first, left-to-right.
13172fn collect_column_refs(expr: &Expr, out: &mut Vec<spg_sql::ast::ColumnName>) {
13173    match expr {
13174        Expr::Column(cn) => out.push(cn.clone()),
13175        Expr::FunctionCall { args, .. } => {
13176            for a in args {
13177                collect_column_refs(a, out);
13178            }
13179        }
13180        Expr::Binary { lhs, rhs, .. } => {
13181            collect_column_refs(lhs, out);
13182            collect_column_refs(rhs, out);
13183        }
13184        Expr::Unary { expr: e, .. } => collect_column_refs(e, out),
13185        _ => {}
13186    }
13187}
13188
13189fn annotate_explain_lines(lines: &mut [String], total_rows: usize, engine: &Engine) {
13190    let catalog = engine.active_catalog();
13191    let cold_ids = catalog.cold_segment_ids_global();
13192    let any_cold = !cold_ids.is_empty();
13193    let cold_ids_repr = if any_cold {
13194        let mut s = alloc::string::String::from("[");
13195        for (i, id) in cold_ids.iter().enumerate() {
13196            if i > 0 {
13197                s.push(',');
13198            }
13199            s.push_str(&alloc::format!("{id}"));
13200        }
13201        s.push(']');
13202        s
13203    } else {
13204        alloc::string::String::new()
13205    };
13206    for (idx, line) in lines.iter_mut().enumerate() {
13207        let trimmed = line.trim_start();
13208        let is_top_level = idx == 0;
13209        if is_top_level {
13210            line.push_str(&alloc::format!(" (rows={total_rows})"));
13211            continue;
13212        }
13213        if let Some(rest) = trimmed.strip_prefix("From: ") {
13214            let (name, scan_kind) = match rest.split_once(" [") {
13215                Some((n, k)) => (n.trim(), k.trim_end_matches(']')),
13216                None => (rest.trim(), ""),
13217            };
13218            let bare = name.split_whitespace().next().unwrap_or(name);
13219            let hot = catalog.get(bare).map(|t| t.rows().len());
13220            // v6.2.7 — `cold_segments=[id0,id1,…]` enumerates every
13221            // cold-tier segment the scan COULD have walked. v6.2.x
13222            // can tighten to per-table by walking the table's
13223            // BTree-index cold locators.
13224            let annot = match (hot, scan_kind) {
13225                (Some(h), "full scan") => {
13226                    let mut s = alloc::format!(" (hot_rows={h}");
13227                    if any_cold {
13228                        s.push_str(&alloc::format!(
13229                            ", cold_tier=present, cold_segments={cold_ids_repr}"
13230                        ));
13231                    }
13232                    s.push(')');
13233                    s
13234                }
13235                (Some(h), "index seek") => {
13236                    let mut s = alloc::format!(" (hot_rows≤{h}");
13237                    if any_cold {
13238                        s.push_str(&alloc::format!(
13239                            ", cold_tier=present, cold_segments={cold_ids_repr}"
13240                        ));
13241                    }
13242                    s.push(')');
13243                    s
13244                }
13245                _ => " (rows=—)".to_string(),
13246            };
13247            line.push_str(&annot);
13248            continue;
13249        }
13250        // Filter / GroupBy / Having / OrderBy / Limit / Join etc.
13251        line.push_str(" (rows=—)");
13252    }
13253}
13254
13255fn explain_select(stmt: &SelectStatement, engine: &Engine, depth: usize, out: &mut Vec<String>) {
13256    let pad = "  ".repeat(depth);
13257    // 1) Top-level operator label.
13258    let top = if !stmt.ctes.is_empty() {
13259        if stmt.ctes.iter().any(|c| c.recursive) {
13260            "CTEScan (WITH RECURSIVE)"
13261        } else {
13262            "CTEScan (WITH)"
13263        }
13264    } else if !stmt.unions.is_empty() {
13265        "UnionScan"
13266    } else if select_has_window(stmt) {
13267        "WindowAgg"
13268    } else if aggregate::uses_aggregate(stmt) {
13269        "Aggregate"
13270    } else if stmt.distinct {
13271        "Distinct"
13272    } else if stmt.from.is_some() {
13273        "TableScan"
13274    } else {
13275        "Result"
13276    };
13277    out.push(alloc::format!("{pad}{top}"));
13278    let child = "  ".repeat(depth + 1);
13279    // 2) CTE bodies.
13280    for cte in &stmt.ctes {
13281        let head = if cte.recursive {
13282            alloc::format!("{child}CTE (recursive): {}", cte.name)
13283        } else {
13284            alloc::format!("{child}CTE: {}", cte.name)
13285        };
13286        out.push(head);
13287        explain_select(&cte.body, engine, depth + 2, out);
13288    }
13289    // 3) FROM details — primary table + joins, index hits.
13290    if let Some(from) = &stmt.from {
13291        let mut tag = alloc::format!("{child}From: {}", from.primary.name);
13292        if let Some(alias) = &from.primary.alias {
13293            tag.push_str(&alloc::format!(" AS {alias}"));
13294        }
13295        // Try to detect an index-seek opportunity on WHERE against
13296        // the primary table — same heuristic the executor uses.
13297        if let Some(w) = &stmt.where_
13298            && let Some(table) = engine.active_catalog().get(&from.primary.name)
13299        {
13300            let alias = from.primary.alias.as_deref().unwrap_or(&from.primary.name);
13301            let cols = &table.schema().columns;
13302            if try_index_seek(w, cols, engine.active_catalog(), table, alias).is_some() {
13303                tag.push_str(" [index seek]");
13304            } else {
13305                tag.push_str(" [full scan]");
13306            }
13307        } else {
13308            tag.push_str(" [full scan]");
13309        }
13310        out.push(tag);
13311        for j in &from.joins {
13312            let kind = match j.kind {
13313                spg_sql::ast::JoinKind::Inner => "INNER JOIN",
13314                spg_sql::ast::JoinKind::Left => "LEFT JOIN",
13315                spg_sql::ast::JoinKind::Cross => "CROSS JOIN",
13316            };
13317            let mut s = alloc::format!("{child}{kind}: {}", j.table.name);
13318            if let Some(alias) = &j.table.alias {
13319                s.push_str(&alloc::format!(" AS {alias}"));
13320            }
13321            if j.on.is_some() {
13322                s.push_str(" (ON …)");
13323            }
13324            out.push(s);
13325        }
13326    }
13327    // 4) WHERE / GROUP BY / HAVING / ORDER BY / LIMIT / OFFSET.
13328    if let Some(w) = &stmt.where_ {
13329        let mut s = alloc::format!("{child}Filter: {w}");
13330        if expr_has_subquery(w) {
13331            s.push_str(" [subquery]");
13332        }
13333        out.push(s);
13334    }
13335    if let Some(gs) = &stmt.group_by {
13336        let mut parts = Vec::new();
13337        for g in gs {
13338            parts.push(alloc::format!("{g}"));
13339        }
13340        out.push(alloc::format!("{child}GroupBy: {}", parts.join(", ")));
13341    }
13342    if let Some(h) = &stmt.having {
13343        out.push(alloc::format!("{child}Having: {h}"));
13344    }
13345    for o in &stmt.order_by {
13346        let dir = if o.desc { "DESC" } else { "ASC" };
13347        out.push(alloc::format!("{child}OrderBy: {} {dir}", o.expr));
13348    }
13349    if let Some(lim) = stmt.limit {
13350        out.push(alloc::format!("{child}Limit: {lim}"));
13351    }
13352    if let Some(off) = stmt.offset {
13353        out.push(alloc::format!("{child}Offset: {off}"));
13354    }
13355    // 5) Projection — collapse Wildcard or render N items.
13356    if stmt
13357        .items
13358        .iter()
13359        .any(|it| matches!(it, SelectItem::Wildcard))
13360    {
13361        out.push(alloc::format!("{child}Project: *"));
13362    } else {
13363        out.push(alloc::format!(
13364            "{child}Project: {} item(s)",
13365            stmt.items.len()
13366        ));
13367    }
13368    // 6) Recurse into UNION peers.
13369    for (kind, peer) in &stmt.unions {
13370        let label = match kind {
13371            UnionKind::All => "UNION ALL",
13372            UnionKind::Distinct => "UNION",
13373        };
13374        out.push(alloc::format!("{child}{label}"));
13375        explain_select(peer, engine, depth + 2, out);
13376    }
13377}
13378
13379/// v4.23: recognise the engine errors that indicate the inner
13380/// SELECT couldn't be evaluated in isolation because it references
13381/// an outer column — used by `subquery_replacement` to skip
13382/// materialisation and let row-eval handle it instead.
13383fn is_correlation_error(e: &EngineError) -> bool {
13384    matches!(
13385        e,
13386        EngineError::Eval(
13387            eval::EvalError::ColumnNotFound { .. } | eval::EvalError::UnknownQualifier { .. }
13388        )
13389    )
13390}
13391
13392/// v4.23: walk every Expr in `stmt` and replace each Column ref
13393/// that targets the outer scope (qualifier matches the outer
13394/// table alias) with a Literal carrying the outer row's value.
13395/// Conservative: only qualified refs are substituted, so the user
13396/// must write `outer_alias.col` to reference an outer column. This
13397/// matches PG's lexical scoping for correlated subqueries and
13398/// avoids accidentally rebinding inner columns of the same name.
13399/// v7.17.0 Phase 3.P0-41 — LATERAL peer descriptor. Either eagerly
13400/// materialised (every regular table / unnest / generate_series) or
13401/// lateral (subquery re-evaluated per outer row).
13402struct JoinedPeer<'a> {
13403    eager_rows: Option<Vec<Row>>,
13404    cols: Vec<ColumnSchema>,
13405    alias: String,
13406    kind: JoinKind,
13407    on: Option<&'a Expr>,
13408    lateral: Option<&'a SelectStatement>,
13409    /// v7.28 (round-22) — plain-table name for the index-nested-loop
13410    /// path. None for unnest/lateral.
13411    join_table: Option<String>,
13412}
13413
13414/// v7.17.0 Phase 3.P0-41 — synthesise a column name for a LATERAL
13415/// projection item that has no explicit alias. PG names anonymous
13416/// projection items by the function call's name or by `column<i>`.
13417/// SPG mirrors the latter (lower-overhead than walking arbitrary
13418/// Expr shapes) so the probe-schema fallback path produces stable
13419/// names for the lateral peer's columns.
13420fn synth_lateral_col_name(expr: &Expr, idx: usize) -> String {
13421    match expr {
13422        // Bare column reference — use the column's own name.
13423        Expr::Column(c) => c.name.clone(),
13424        // Function call — use the function name (PG canonical:
13425        // `count` / `max` / `lower` …).
13426        Expr::FunctionCall { name, .. } => name.clone(),
13427        // Cast — drill into the inner expression.
13428        Expr::Cast { expr: inner, .. } => synth_lateral_col_name(inner, idx),
13429        // Everything else falls back to PG's `column<N>` placeholder.
13430        _ => alloc::format!("column{}", idx + 1),
13431    }
13432}
13433
13434/// v7.17.0 Phase 3.P0-41 — substitute every `<alias>.<col>` Expr
13435/// reference whose `<alias>.<col>` exists in the outer composite
13436/// schema with the matching value from the outer row. Walks the
13437/// entire SELECT body (items, WHERE, GROUP BY, HAVING, ORDER BY,
13438/// UNION peers) so any depth of outer reference inside the
13439/// LATERAL subquery resolves before execution.
13440fn substitute_outer_columns_multi(
13441    stmt: &mut SelectStatement,
13442    outer_row: &Row,
13443    outer_schema: &[ColumnSchema],
13444) {
13445    substitute_outer_in_select(stmt, outer_row, outer_schema);
13446}
13447
13448fn substitute_outer_in_select(
13449    stmt: &mut SelectStatement,
13450    outer_row: &Row,
13451    outer_schema: &[ColumnSchema],
13452) {
13453    for item in &mut stmt.items {
13454        if let SelectItem::Expr { expr, .. } = item {
13455            substitute_outer_in_expr(expr, outer_row, outer_schema);
13456        }
13457    }
13458    if let Some(w) = &mut stmt.where_ {
13459        substitute_outer_in_expr(w, outer_row, outer_schema);
13460    }
13461    if let Some(gs) = &mut stmt.group_by {
13462        for g in gs {
13463            substitute_outer_in_expr(g, outer_row, outer_schema);
13464        }
13465    }
13466    if let Some(h) = &mut stmt.having {
13467        substitute_outer_in_expr(h, outer_row, outer_schema);
13468    }
13469    for o in &mut stmt.order_by {
13470        substitute_outer_in_expr(&mut o.expr, outer_row, outer_schema);
13471    }
13472    for (_, peer) in &mut stmt.unions {
13473        substitute_outer_in_select(peer, outer_row, outer_schema);
13474    }
13475}
13476
13477fn substitute_outer_in_expr(e: &mut Expr, outer_row: &Row, outer_schema: &[ColumnSchema]) {
13478    if let Expr::Column(c) = e
13479        && let Some(qual) = &c.qualifier
13480    {
13481        let composite = alloc::format!("{qual}.{}", c.name);
13482        if let Some(idx) = outer_schema
13483            .iter()
13484            .position(|sc| sc.name.eq_ignore_ascii_case(&composite))
13485        {
13486            let v = outer_row.values.get(idx).cloned().unwrap_or(Value::Null);
13487            if let Ok(lit) = value_to_literal_expr(v) {
13488                *e = lit;
13489                return;
13490            }
13491        }
13492    }
13493    match e {
13494        Expr::Binary { lhs, rhs, .. } => {
13495            substitute_outer_in_expr(lhs, outer_row, outer_schema);
13496            substitute_outer_in_expr(rhs, outer_row, outer_schema);
13497        }
13498        Expr::Unary { expr: inner, .. } => {
13499            substitute_outer_in_expr(inner, outer_row, outer_schema);
13500        }
13501        Expr::FunctionCall { args, .. } => {
13502            for a in args {
13503                substitute_outer_in_expr(a, outer_row, outer_schema);
13504            }
13505        }
13506        Expr::Cast { expr: inner, .. } => {
13507            substitute_outer_in_expr(inner, outer_row, outer_schema);
13508        }
13509        Expr::Case {
13510            operand,
13511            branches,
13512            else_branch,
13513        } => {
13514            if let Some(op) = operand {
13515                substitute_outer_in_expr(op, outer_row, outer_schema);
13516            }
13517            for (cond, val) in branches {
13518                substitute_outer_in_expr(cond, outer_row, outer_schema);
13519                substitute_outer_in_expr(val, outer_row, outer_schema);
13520            }
13521            if let Some(e) = else_branch {
13522                substitute_outer_in_expr(e, outer_row, outer_schema);
13523            }
13524        }
13525        _ => {}
13526    }
13527}
13528
13529impl Engine {
13530    /// v7.29 (round-22 phase 3) — try to batch-evaluate a correlated
13531    /// scalar subquery of the shape
13532    ///   (SELECT expr FROM … WHERE inner_preds AND inner_col = outer_col
13533    ///    [ORDER BY o [DESC]] [LIMIT 1])
13534    /// by running the subquery ONCE without the correlation and
13535    /// folding rows into a key→value map (group top-1 when ordered).
13536    /// Returns None when the shape doesn't qualify; correctness then
13537    /// falls back to per-row execution.
13538    fn try_batch_correlated_scalar(
13539        &self,
13540        inner: &SelectStatement,
13541        cancel: CancelToken<'_>,
13542    ) -> Result<Option<memoize::GroupMap>, EngineError> {
13543        use spg_sql::ast::{BinOp, SelectItem as SI};
13544        if !inner.ctes.is_empty()
13545            || !inner.unions.is_empty()
13546            || inner.group_by.is_some()
13547            || inner.having.is_some()
13548            || inner.distinct
13549            || inner.items.len() != 1
13550            || inner.order_by.len() > 1
13551            || inner.offset.is_some()
13552        {
13553            return Ok(None);
13554        }
13555        // LIMIT must be absent or literally 1 (top-1 semantics).
13556        if let Some(le) = inner.limit
13557            && le.as_literal() != Some(1)
13558        {
13559            return Ok(None);
13560        }
13561        let Some(from) = &inner.from else {
13562            return Ok(None);
13563        };
13564        if from.primary.lateral_subquery.is_some() || from.primary.unnest_expr.is_some() {
13565            return Ok(None);
13566        }
13567        // Inner alias set.
13568        let mut inner_aliases: Vec<String> = Vec::new();
13569        inner_aliases.push(
13570            from.primary
13571                .alias
13572                .clone()
13573                .unwrap_or_else(|| from.primary.name.clone()),
13574        );
13575        for j in &from.joins {
13576            if j.table.lateral_subquery.is_some() || j.table.unnest_expr.is_some() {
13577                return Ok(None);
13578            }
13579            inner_aliases.push(
13580                j.table
13581                    .alias
13582                    .clone()
13583                    .unwrap_or_else(|| j.table.name.clone()),
13584            );
13585        }
13586        let is_inner = |c: &spg_sql::ast::ColumnName| -> bool {
13587            match &c.qualifier {
13588                Some(q) => inner_aliases.iter().any(|a| a.eq_ignore_ascii_case(q)),
13589                None => false,
13590            }
13591        };
13592        let is_outer = |c: &spg_sql::ast::ColumnName| -> bool {
13593            match &c.qualifier {
13594                Some(q) => !inner_aliases.iter().any(|a| a.eq_ignore_ascii_case(q)),
13595                // Synthetic group columns arrive bare after the
13596                // aggregate rewrite.
13597                None => c.name.starts_with("__grp_") || c.name.starts_with("__agg_"),
13598            }
13599        };
13600        // Every expression OTHER than the correlation conjunct must be
13601        // fully inner (qualified to inner aliases).
13602        let all_inner = |e: &Expr| -> bool {
13603            let mut cols: Vec<spg_sql::ast::ColumnName> = Vec::new();
13604            let mut subs: Vec<&SelectStatement> = Vec::new();
13605            visit_expr_columns_and_subqueries(e, &mut |c| cols.push(c.clone()), &mut |sub| {
13606                subs.push(sub)
13607            });
13608            subs.is_empty() && cols.iter().all(|c| is_inner(c) && !c.name.is_empty())
13609        };
13610        let Some(w) = &inner.where_ else {
13611            return Ok(None);
13612        };
13613        let conjuncts = reorder::split_and_conjunctions(w);
13614        let mut corr: Option<(spg_sql::ast::ColumnName, spg_sql::ast::ColumnName)> = None; // (inner, outer)
13615        let mut rest: Vec<&Expr> = Vec::new();
13616        for c in conjuncts {
13617            if let Expr::Binary {
13618                lhs,
13619                op: BinOp::Eq,
13620                rhs,
13621            } = c
13622                && let (Expr::Column(a), Expr::Column(b)) = (lhs.as_ref(), rhs.as_ref())
13623            {
13624                let pair = if is_inner(a) && is_outer(b) {
13625                    Some((a.clone(), b.clone()))
13626                } else if is_inner(b) && is_outer(a) {
13627                    Some((b.clone(), a.clone()))
13628                } else {
13629                    None
13630                };
13631                if let Some(p) = pair {
13632                    if corr.is_some() {
13633                        return Ok(None); // more than one correlation
13634                    }
13635                    corr = Some(p);
13636                    continue;
13637                }
13638            }
13639            if !all_inner(c) {
13640                return Ok(None);
13641            }
13642            rest.push(c);
13643        }
13644        let Some((inner_col, outer_col)) = corr else {
13645            return Ok(None);
13646        };
13647        let SI::Expr { expr: out_expr, .. } = &inner.items[0] else {
13648            return Ok(None);
13649        };
13650        if !all_inner(out_expr) {
13651            return Ok(None);
13652        }
13653        let order = inner.order_by.first();
13654        if let Some(o) = order
13655            && !all_inner(&o.expr)
13656        {
13657            return Ok(None);
13658        }
13659        // Build the batch statement: SELECT inner_col, [order], expr
13660        // FROM … WHERE rest — no correlation, no order, no limit.
13661        let mut batch = inner.clone();
13662        batch.limit = None;
13663        batch.offset = None;
13664        batch.order_by = Vec::new();
13665        batch.where_ = rest
13666            .iter()
13667            .map(|e| (*e).clone())
13668            .reduce(|a, b| Expr::Binary {
13669                lhs: alloc::boxed::Box::new(a),
13670                op: BinOp::And,
13671                rhs: alloc::boxed::Box::new(b),
13672            });
13673        let mut items: Vec<SI> = alloc::vec![SI::Expr {
13674            expr: Expr::Column(inner_col),
13675            alias: None,
13676        }];
13677        if let Some(o) = order {
13678            items.push(SI::Expr {
13679                expr: o.expr.clone(),
13680                alias: None,
13681            });
13682        }
13683        items.push(SI::Expr {
13684            expr: out_expr.clone(),
13685            alias: None,
13686        });
13687        batch.items = items;
13688        let r = self.exec_select_cancel(&batch, cancel)?;
13689        let QueryResult::Rows { rows, .. } = r else {
13690            return Ok(None);
13691        };
13692        let has_order = order.is_some();
13693        let (desc, nf) = order
13694            .map(|o| (o.desc, o.nulls_first))
13695            .unwrap_or((false, None));
13696        let mut best: alloc::collections::BTreeMap<String, (Option<Value>, Value)> =
13697            alloc::collections::BTreeMap::new();
13698        for row in rows {
13699            let key_v = row.values.first().cloned().unwrap_or(Value::Null);
13700            if matches!(key_v, Value::Null) {
13701                continue;
13702            }
13703            let key = aggregate::encode_key(core::slice::from_ref(&key_v));
13704            let (ord_v, out_v) = if has_order {
13705                (
13706                    Some(row.values.get(1).cloned().unwrap_or(Value::Null)),
13707                    row.values.get(2).cloned().unwrap_or(Value::Null),
13708                )
13709            } else {
13710                (None, row.values.get(1).cloned().unwrap_or(Value::Null))
13711            };
13712            match best.get(&key) {
13713                None => {
13714                    best.insert(key, (ord_v, out_v));
13715                }
13716                Some((cur_ord, _)) if has_order => {
13717                    // The sorted-first row wins: candidate beats the
13718                    // incumbent when it compares LESS under the key's
13719                    // ordering.
13720                    let cand = ord_v.clone().unwrap_or(Value::Null);
13721                    let cur = cur_ord.clone().unwrap_or(Value::Null);
13722                    if order_by_value_cmp(desc, nf, &cand, &cur) == core::cmp::Ordering::Less {
13723                        best.insert(key, (ord_v, out_v));
13724                    }
13725                }
13726                Some(_) => {} // unordered: first row stands (any row is valid)
13727            }
13728        }
13729        let map = best.into_iter().map(|(k, (_, v))| (k, v)).collect();
13730        Ok(Some((outer_col, map)))
13731    }
13732}
13733
13734/// v7.29 (3c) — pre-order collection of SCALAR subquery nodes in a
13735/// host expression (no descent into subquery bodies). The splice
13736/// walk below uses the same order; the pair must stay in lockstep.
13737fn collect_scalar_subqueries<'a>(e: &'a Expr, out: &mut Vec<&'a SelectStatement>) {
13738    match e {
13739        Expr::ScalarSubquery(s) => out.push(s),
13740        Expr::Exists { .. } | Expr::InSubquery { .. } => {}
13741        Expr::Binary { lhs, rhs, .. } => {
13742            collect_scalar_subqueries(lhs, out);
13743            collect_scalar_subqueries(rhs, out);
13744        }
13745        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13746            collect_scalar_subqueries(expr, out);
13747        }
13748        Expr::Like { expr, pattern, .. } => {
13749            collect_scalar_subqueries(expr, out);
13750            collect_scalar_subqueries(pattern, out);
13751        }
13752        Expr::FunctionCall { args, .. } => {
13753            for a in args {
13754                collect_scalar_subqueries(a, out);
13755            }
13756        }
13757        Expr::AggregateOrdered { call, order_by, .. } => {
13758            collect_scalar_subqueries(call, out);
13759            for o in order_by {
13760                collect_scalar_subqueries(&o.expr, out);
13761            }
13762        }
13763        Expr::Case {
13764            operand,
13765            branches,
13766            else_branch,
13767        } => {
13768            if let Some(op) = operand {
13769                collect_scalar_subqueries(op, out);
13770            }
13771            for (w, t) in branches {
13772                collect_scalar_subqueries(w, out);
13773                collect_scalar_subqueries(t, out);
13774            }
13775            if let Some(eb) = else_branch {
13776                collect_scalar_subqueries(eb, out);
13777            }
13778        }
13779        Expr::ArraySubscript { target, index } => {
13780            collect_scalar_subqueries(target, out);
13781            collect_scalar_subqueries(index, out);
13782        }
13783        Expr::InList { expr, list, .. } => {
13784            collect_scalar_subqueries(expr, out);
13785            for item in list {
13786                collect_scalar_subqueries(item, out);
13787            }
13788        }
13789        _ => {}
13790    }
13791}
13792
13793/// v7.29 (3d) — empty every scalar-subquery BODY in a host
13794/// expression (node kept so the splice pre-order still matches).
13795fn hollow_scalar_subqueries(e: &mut Expr) {
13796    match e {
13797        Expr::ScalarSubquery(s) => {
13798            let hollow = SelectStatement {
13799                items: Vec::new(),
13800                ..SelectStatement::default()
13801            };
13802            **s = hollow;
13803        }
13804        Expr::Exists { .. } | Expr::InSubquery { .. } => {}
13805        Expr::Binary { lhs, rhs, .. } => {
13806            hollow_scalar_subqueries(lhs);
13807            hollow_scalar_subqueries(rhs);
13808        }
13809        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13810            hollow_scalar_subqueries(expr);
13811        }
13812        Expr::Like { expr, pattern, .. } => {
13813            hollow_scalar_subqueries(expr);
13814            hollow_scalar_subqueries(pattern);
13815        }
13816        Expr::FunctionCall { args, .. } => {
13817            for a in args.iter_mut() {
13818                hollow_scalar_subqueries(a);
13819            }
13820        }
13821        Expr::AggregateOrdered { call, order_by, .. } => {
13822            hollow_scalar_subqueries(call);
13823            for o in order_by.iter_mut() {
13824                hollow_scalar_subqueries(&mut o.expr);
13825            }
13826        }
13827        Expr::Case {
13828            operand,
13829            branches,
13830            else_branch,
13831        } => {
13832            if let Some(op) = operand {
13833                hollow_scalar_subqueries(op);
13834            }
13835            for (w, t) in branches.iter_mut() {
13836                hollow_scalar_subqueries(w);
13837                hollow_scalar_subqueries(t);
13838            }
13839            if let Some(eb) = else_branch {
13840                hollow_scalar_subqueries(eb);
13841            }
13842        }
13843        Expr::ArraySubscript { target, index } => {
13844            hollow_scalar_subqueries(target);
13845            hollow_scalar_subqueries(index);
13846        }
13847        Expr::InList { expr, list, .. } => {
13848            hollow_scalar_subqueries(expr);
13849            for item in list.iter_mut() {
13850                hollow_scalar_subqueries(item);
13851            }
13852        }
13853        _ => {}
13854    }
13855}
13856
13857/// v7.29 (3c) — splice the i-th scalar subquery's batched value into
13858/// the cloned tree (same pre-order as collect_scalar_subqueries).
13859/// Returns Ok(false) if a literal conversion fails (caller falls
13860/// back to the resolver path).
13861fn splice_planned_subqueries(
13862    e: &mut Expr,
13863    plan: &[Option<alloc::rc::Rc<memoize::GroupMap>>],
13864    idx: &mut usize,
13865    row: &Row,
13866    ctx: &EvalContext<'_>,
13867) -> Result<bool, EngineError> {
13868    match e {
13869        Expr::ScalarSubquery(_) => {
13870            let Some(Some(gm)) = plan.get(*idx) else {
13871                return Ok(false);
13872            };
13873            *idx += 1;
13874            let (outer_col, map) = gm.as_ref();
13875            let key_v = eval::eval_expr(&Expr::Column(outer_col.clone()), row, ctx)
13876                .map_err(EngineError::Eval)?;
13877            let v = if matches!(key_v, Value::Null) {
13878                Value::Null
13879            } else {
13880                map.get(&aggregate::encode_key(core::slice::from_ref(&key_v)))
13881                    .cloned()
13882                    .unwrap_or(Value::Null)
13883            };
13884            *e = value_to_literal_expr(v)?;
13885            Ok(true)
13886        }
13887        Expr::Exists { .. } | Expr::InSubquery { .. } => Ok(true),
13888        Expr::Binary { lhs, rhs, .. } => Ok(splice_planned_subqueries(lhs, plan, idx, row, ctx)?
13889            && splice_planned_subqueries(rhs, plan, idx, row, ctx)?),
13890        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13891            splice_planned_subqueries(expr, plan, idx, row, ctx)
13892        }
13893        Expr::Like { expr, pattern, .. } => {
13894            Ok(splice_planned_subqueries(expr, plan, idx, row, ctx)?
13895                && splice_planned_subqueries(pattern, plan, idx, row, ctx)?)
13896        }
13897        Expr::FunctionCall { args, .. } => {
13898            for a in args.iter_mut() {
13899                if !splice_planned_subqueries(a, plan, idx, row, ctx)? {
13900                    return Ok(false);
13901                }
13902            }
13903            Ok(true)
13904        }
13905        Expr::AggregateOrdered { call, order_by, .. } => {
13906            if !splice_planned_subqueries(call, plan, idx, row, ctx)? {
13907                return Ok(false);
13908            }
13909            for o in order_by.iter_mut() {
13910                if !splice_planned_subqueries(&mut o.expr, plan, idx, row, ctx)? {
13911                    return Ok(false);
13912                }
13913            }
13914            Ok(true)
13915        }
13916        Expr::Case {
13917            operand,
13918            branches,
13919            else_branch,
13920        } => {
13921            if let Some(op) = operand {
13922                if !splice_planned_subqueries(op, plan, idx, row, ctx)? {
13923                    return Ok(false);
13924                }
13925            }
13926            for (w, t) in branches.iter_mut() {
13927                if !splice_planned_subqueries(w, plan, idx, row, ctx)?
13928                    || !splice_planned_subqueries(t, plan, idx, row, ctx)?
13929                {
13930                    return Ok(false);
13931                }
13932            }
13933            if let Some(eb) = else_branch {
13934                if !splice_planned_subqueries(eb, plan, idx, row, ctx)? {
13935                    return Ok(false);
13936                }
13937            }
13938            Ok(true)
13939        }
13940        Expr::ArraySubscript { target, index } => {
13941            Ok(splice_planned_subqueries(target, plan, idx, row, ctx)?
13942                && splice_planned_subqueries(index, plan, idx, row, ctx)?)
13943        }
13944        Expr::InList { expr, list, .. } => {
13945            if !splice_planned_subqueries(expr, plan, idx, row, ctx)? {
13946                return Ok(false);
13947            }
13948            for item in list.iter_mut() {
13949                if !splice_planned_subqueries(item, plan, idx, row, ctx)? {
13950                    return Ok(false);
13951                }
13952            }
13953            Ok(true)
13954        }
13955        _ => Ok(true),
13956    }
13957}
13958
13959/// v7.30.2 (mailrs round-25) — minimum element count before an
13960/// all-literal `IN` list gets a per-query membership set. Below
13961/// this the linear scan wins on build cost.
13962const INLIST_SET_THRESHOLD: usize = 64;
13963
13964/// Cheap pre-check: is a set-eligible `IN` list reachable on the
13965/// AND spine of this expression? Anything else keeps the plain
13966/// `eval_expr` path untouched.
13967fn expr_may_use_in_set(e: &Expr) -> bool {
13968    match e {
13969        Expr::InList { list, .. } => list.len() >= INLIST_SET_THRESHOLD,
13970        Expr::Binary {
13971            lhs,
13972            op: BinOp::And,
13973            rhs,
13974        } => expr_may_use_in_set(lhs) || expr_may_use_in_set(rhs),
13975        _ => false,
13976    }
13977}
13978
13979/// Analyse an `IN` list for set eligibility: every element a literal,
13980/// all of one family (integer or string, NULLs tracked separately).
13981fn build_in_list_set(list: &[Expr]) -> Option<memoize::InListSetEntry> {
13982    let mut has_null = false;
13983    let mut ints: alloc::collections::BTreeSet<i64> = alloc::collections::BTreeSet::new();
13984    let mut texts: alloc::collections::BTreeSet<String> = alloc::collections::BTreeSet::new();
13985    for item in list {
13986        let Expr::Literal(lit) = item else {
13987            return None;
13988        };
13989        match lit {
13990            Literal::Null => has_null = true,
13991            Literal::Integer(i) => {
13992                ints.insert(*i);
13993            }
13994            Literal::String(s) => {
13995                texts.insert(s.clone());
13996            }
13997            _ => return None,
13998        }
13999        if !ints.is_empty() && !texts.is_empty() {
14000            return None;
14001        }
14002    }
14003    let set = if !ints.is_empty() {
14004        memoize::InListSet::Int(ints)
14005    } else if !texts.is_empty() {
14006        memoize::InListSet::Text(texts)
14007    } else {
14008        return None;
14009    };
14010    Some(memoize::InListSetEntry { set, has_null })
14011}
14012
14013/// Subquery-free eval that serves large all-literal `IN` lists from
14014/// a per-query membership set (cached in the memo by node address).
14015/// Walks only the AND spine; every other node — and every needle
14016/// whose runtime family doesn't match the set — falls through to
14017/// `eval_expr`, so coercion and error semantics stay identical.
14018fn eval_with_in_sets(
14019    e: &Expr,
14020    row: &Row,
14021    ctx: &EvalContext<'_>,
14022    m: &mut memoize::MemoizeCache,
14023) -> Result<Value, EngineError> {
14024    match e {
14025        Expr::Binary {
14026            lhs,
14027            op: BinOp::And,
14028            rhs,
14029        } => {
14030            // Mirror eval_expr: both sides evaluate (no short
14031            // circuit), then SQL three-valued AND.
14032            let l = eval_with_in_sets(lhs, row, ctx, m)?;
14033            let r = eval_with_in_sets(rhs, row, ctx, m)?;
14034            eval::and_3vl(l, r).map_err(EngineError::Eval)
14035        }
14036        Expr::InList {
14037            expr: lhs,
14038            list,
14039            negated,
14040        } if list.len() >= INLIST_SET_THRESHOLD => {
14041            let key = core::ptr::from_ref::<Expr>(e) as usize;
14042            let Some(entry) = m
14043                .in_sets
14044                .entry(key)
14045                .or_insert_with(|| build_in_list_set(list))
14046            else {
14047                return eval::eval_expr(e, row, ctx).map_err(EngineError::Eval);
14048            };
14049            let needle = eval::eval_expr(lhs, row, ctx).map_err(EngineError::Eval)?;
14050            let contained = match (&needle, &entry.set) {
14051                // Non-empty list + NULL needle → NULL (negation of
14052                // NULL is still NULL).
14053                (Value::Null, _) => return Ok(Value::Null),
14054                (Value::SmallInt(n), memoize::InListSet::Int(s)) => s.contains(&i64::from(*n)),
14055                (Value::Int(n), memoize::InListSet::Int(s)) => s.contains(&i64::from(*n)),
14056                (Value::BigInt(n), memoize::InListSet::Int(s)) => s.contains(n),
14057                (Value::Text(t), memoize::InListSet::Text(s)) => s.contains(t.as_str()),
14058                // Cross-family needle (e.g. Float vs integer list):
14059                // keep apply_binary's coercion / error behaviour.
14060                _ => return eval::eval_expr(e, row, ctx).map_err(EngineError::Eval),
14061            };
14062            let inner = if contained {
14063                Value::Bool(true)
14064            } else if entry.has_null {
14065                Value::Null
14066            } else {
14067                Value::Bool(false)
14068            };
14069            Ok(match (negated, inner) {
14070                (true, Value::Bool(b)) => Value::Bool(!b),
14071                (_, v) => v,
14072            })
14073        }
14074        _ => eval::eval_expr(e, row, ctx).map_err(EngineError::Eval),
14075    }
14076}
14077
14078fn substitute_outer_columns(stmt: &mut SelectStatement, row: &Row, ctx: &EvalContext<'_>) {
14079    // v7.24 (round-16 B) — joined outer contexts carry no single
14080    // table alias; their schemas use composite "alias.column" names
14081    // instead. Pass an unmatchable alias and let the composite
14082    // lookup in substitute_in_expr do the work (a correlated EXISTS
14083    // under a JOIN previously skipped substitution entirely and
14084    // died with "unknown table qualifier").
14085    let outer_alias = ctx.table_alias.unwrap_or("");
14086    substitute_in_select(stmt, row, ctx, outer_alias);
14087}
14088
14089fn substitute_in_select(
14090    stmt: &mut SelectStatement,
14091    row: &Row,
14092    ctx: &EvalContext<'_>,
14093    outer_alias: &str,
14094) {
14095    for item in &mut stmt.items {
14096        if let SelectItem::Expr { expr, .. } = item {
14097            substitute_in_expr(expr, row, ctx, outer_alias);
14098        }
14099    }
14100    if let Some(w) = &mut stmt.where_ {
14101        substitute_in_expr(w, row, ctx, outer_alias);
14102    }
14103    if let Some(gs) = &mut stmt.group_by {
14104        for g in gs {
14105            substitute_in_expr(g, row, ctx, outer_alias);
14106        }
14107    }
14108    if let Some(h) = &mut stmt.having {
14109        substitute_in_expr(h, row, ctx, outer_alias);
14110    }
14111    for o in &mut stmt.order_by {
14112        substitute_in_expr(&mut o.expr, row, ctx, outer_alias);
14113    }
14114    for (_, peer) in &mut stmt.unions {
14115        substitute_in_select(peer, row, ctx, outer_alias);
14116    }
14117}
14118
14119fn substitute_in_expr(e: &mut Expr, row: &Row, ctx: &EvalContext<'_>, outer_alias: &str) {
14120    // v7.25.2 (round-19 A) — bare synthetic columns. The aggregate
14121    // rewriter replaces group-key references INSIDE subquery bodies
14122    // with `__grp_N` so a correlated subquery in a GROUP BY select
14123    // list can resolve against the synthesised group row. The names
14124    // are engine-generated, so they can't shadow user columns.
14125    if let Expr::Column(c) = e
14126        && c.qualifier.is_none()
14127        && (c.name.starts_with("__grp_") || c.name.starts_with("__agg_"))
14128        && let Some(idx) = ctx.columns.iter().position(|sc| sc.name == c.name)
14129    {
14130        let v = row.values.get(idx).cloned().unwrap_or(Value::Null);
14131        if let Ok(lit) = value_to_literal_expr(v) {
14132            *e = lit;
14133            return;
14134        }
14135    }
14136    if let Expr::Column(c) = e
14137        && let Some(qual) = &c.qualifier
14138    {
14139        // Look up the column's index in the outer schema: plain name
14140        // when the qualifier is the outer table's alias, composite
14141        // "alias.column" for joined outer schemas (v7.24).
14142        let idx = if !outer_alias.is_empty() && qual.eq_ignore_ascii_case(outer_alias) {
14143            ctx.columns
14144                .iter()
14145                .position(|sc| sc.name.eq_ignore_ascii_case(&c.name))
14146        } else {
14147            None
14148        }
14149        .or_else(|| {
14150            let composite = alloc::format!("{qual}.{name}", name = c.name);
14151            ctx.columns
14152                .iter()
14153                .position(|sc| sc.name.eq_ignore_ascii_case(&composite))
14154        });
14155        if let Some(idx) = idx {
14156            let v = row.values.get(idx).cloned().unwrap_or(Value::Null);
14157            if let Ok(lit) = value_to_literal_expr(v) {
14158                *e = lit;
14159                return;
14160            }
14161        }
14162    }
14163    match e {
14164        Expr::AggregateOrdered { call, order_by, .. } => {
14165            substitute_in_expr(call, row, ctx, outer_alias);
14166            for o in order_by.iter_mut() {
14167                substitute_in_expr(&mut o.expr, row, ctx, outer_alias);
14168            }
14169        }
14170        Expr::Binary { lhs, rhs, .. } => {
14171            substitute_in_expr(lhs, row, ctx, outer_alias);
14172            substitute_in_expr(rhs, row, ctx, outer_alias);
14173        }
14174        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
14175            substitute_in_expr(expr, row, ctx, outer_alias);
14176        }
14177        Expr::Like { expr, pattern, .. } => {
14178            substitute_in_expr(expr, row, ctx, outer_alias);
14179            substitute_in_expr(pattern, row, ctx, outer_alias);
14180        }
14181        Expr::FunctionCall { args, .. } => {
14182            for a in args {
14183                substitute_in_expr(a, row, ctx, outer_alias);
14184            }
14185        }
14186        Expr::Extract { source, .. } => substitute_in_expr(source, row, ctx, outer_alias),
14187        Expr::WindowFunction {
14188            args,
14189            partition_by,
14190            order_by,
14191            ..
14192        } => {
14193            for a in args {
14194                substitute_in_expr(a, row, ctx, outer_alias);
14195            }
14196            for p in partition_by {
14197                substitute_in_expr(p, row, ctx, outer_alias);
14198            }
14199            for (o, _, _) in order_by {
14200                substitute_in_expr(o, row, ctx, outer_alias);
14201            }
14202        }
14203        Expr::ScalarSubquery(s) => substitute_in_select(s, row, ctx, outer_alias),
14204        Expr::Exists { subquery, .. } | Expr::InSubquery { subquery, .. } => {
14205            substitute_in_select(subquery, row, ctx, outer_alias);
14206        }
14207        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => {}
14208        Expr::Array(items) => {
14209            for elem in items {
14210                substitute_in_expr(elem, row, ctx, outer_alias);
14211            }
14212        }
14213        Expr::ArraySubscript { target, index } => {
14214            substitute_in_expr(target, row, ctx, outer_alias);
14215            substitute_in_expr(index, row, ctx, outer_alias);
14216        }
14217        Expr::AnyAll { expr, array, .. } => {
14218            substitute_in_expr(expr, row, ctx, outer_alias);
14219            substitute_in_expr(array, row, ctx, outer_alias);
14220        }
14221        Expr::InList { expr, list, .. } => {
14222            substitute_in_expr(expr, row, ctx, outer_alias);
14223            for item in list {
14224                substitute_in_expr(item, row, ctx, outer_alias);
14225            }
14226        }
14227        Expr::Case {
14228            operand,
14229            branches,
14230            else_branch,
14231        } => {
14232            if let Some(o) = operand {
14233                substitute_in_expr(o, row, ctx, outer_alias);
14234            }
14235            for (w, t) in branches {
14236                substitute_in_expr(w, row, ctx, outer_alias);
14237                substitute_in_expr(t, row, ctx, outer_alias);
14238            }
14239            if let Some(e) = else_branch {
14240                substitute_in_expr(e, row, ctx, outer_alias);
14241            }
14242        }
14243    }
14244}
14245
14246/// v4.22: encode a Row to a comparable byte key for UNION-DISTINCT
14247/// dedup inside the recursive iteration. Crude but deterministic
14248/// — Debug prints embed type discriminants so NULL ≠ "" ≠ 0.
14249fn encode_row_key(row: &Row) -> Vec<u8> {
14250    let mut out = Vec::new();
14251    for v in &row.values {
14252        let s = alloc::format!("{v:?}|");
14253        out.extend_from_slice(s.as_bytes());
14254    }
14255    out
14256}
14257
14258fn select_has_window(stmt: &SelectStatement) -> bool {
14259    for item in &stmt.items {
14260        if let SelectItem::Expr { expr, .. } = item
14261            && expr_has_window(expr)
14262        {
14263            return true;
14264        }
14265    }
14266    false
14267}
14268
14269fn expr_has_window(e: &Expr) -> bool {
14270    match e {
14271        Expr::WindowFunction { .. } => true,
14272        Expr::AggregateOrdered { call, order_by, .. } => {
14273            expr_has_window(call) || order_by.iter().any(|o| expr_has_window(&o.expr))
14274        }
14275        Expr::Binary { lhs, rhs, .. } => expr_has_window(lhs) || expr_has_window(rhs),
14276        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
14277            expr_has_window(expr)
14278        }
14279        Expr::FunctionCall { args, .. } => args.iter().any(expr_has_window),
14280        Expr::Like { expr, pattern, .. } => expr_has_window(expr) || expr_has_window(pattern),
14281        Expr::Extract { source, .. } => expr_has_window(source),
14282        Expr::ScalarSubquery(_)
14283        | Expr::Exists { .. }
14284        | Expr::InSubquery { .. }
14285        | Expr::Literal(_)
14286        | Expr::Placeholder(_)
14287        | Expr::Column(_) => false,
14288        Expr::Array(items) => items.iter().any(expr_has_window),
14289        Expr::ArraySubscript { target, index } => expr_has_window(target) || expr_has_window(index),
14290        Expr::AnyAll { expr, array, .. } => expr_has_window(expr) || expr_has_window(array),
14291        Expr::InList { expr, list, .. } => {
14292            expr_has_window(expr) || list.iter().any(expr_has_window)
14293        }
14294        Expr::Case {
14295            operand,
14296            branches,
14297            else_branch,
14298        } => {
14299            operand.as_deref().is_some_and(expr_has_window)
14300                || branches
14301                    .iter()
14302                    .any(|(w, t)| expr_has_window(w) || expr_has_window(t))
14303                || else_branch.as_deref().is_some_and(expr_has_window)
14304        }
14305    }
14306}
14307
14308fn collect_window_nodes(e: &Expr, out: &mut Vec<Expr>) {
14309    if let Expr::WindowFunction { .. } = e {
14310        // Deduplicate by structural equality on the expression
14311        // (cheap because window args + partition + order are
14312        // small). Without dedup we'd recompute identical windows
14313        // once per occurrence in the projection.
14314        if !out.iter().any(|x| x == e) {
14315            out.push(e.clone());
14316        }
14317        return;
14318    }
14319    match e {
14320        // Already handled by the early-return at the top.
14321        Expr::WindowFunction { .. } => unreachable!(),
14322        Expr::Binary { lhs, rhs, .. } => {
14323            collect_window_nodes(lhs, out);
14324            collect_window_nodes(rhs, out);
14325        }
14326        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
14327            collect_window_nodes(expr, out);
14328        }
14329        Expr::FunctionCall { args, .. } => {
14330            for a in args {
14331                collect_window_nodes(a, out);
14332            }
14333        }
14334        Expr::Like { expr, pattern, .. } => {
14335            collect_window_nodes(expr, out);
14336            collect_window_nodes(pattern, out);
14337        }
14338        Expr::Extract { source, .. } => collect_window_nodes(source, out),
14339        _ => {}
14340    }
14341}
14342
14343fn rewrite_window_to_columns(e: &mut Expr, window_nodes: &[Expr]) {
14344    if let Expr::WindowFunction { .. } = e
14345        && let Some(idx) = window_nodes.iter().position(|w| w == e)
14346    {
14347        *e = Expr::Column(spg_sql::ast::ColumnName {
14348            qualifier: None,
14349            name: alloc::format!("__win_{idx}"),
14350        });
14351        return;
14352    }
14353    match e {
14354        Expr::Binary { lhs, rhs, .. } => {
14355            rewrite_window_to_columns(lhs, window_nodes);
14356            rewrite_window_to_columns(rhs, window_nodes);
14357        }
14358        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
14359            rewrite_window_to_columns(expr, window_nodes);
14360        }
14361        Expr::FunctionCall { args, .. } => {
14362            for a in args {
14363                rewrite_window_to_columns(a, window_nodes);
14364            }
14365        }
14366        Expr::Like { expr, pattern, .. } => {
14367            rewrite_window_to_columns(expr, window_nodes);
14368            rewrite_window_to_columns(pattern, window_nodes);
14369        }
14370        Expr::Extract { source, .. } => rewrite_window_to_columns(source, window_nodes),
14371        _ => {}
14372    }
14373}
14374
14375/// Total order over partition-key tuples. NULL sorts as the
14376/// lowest value (matches the `<` partial order's NULL-last
14377/// behaviour with `INFINITY` flipped).
14378fn partition_key_cmp(a: &[Value], b: &[Value]) -> core::cmp::Ordering {
14379    for (x, y) in a.iter().zip(b.iter()) {
14380        let c = value_cmp(x, y);
14381        if c != core::cmp::Ordering::Equal {
14382            return c;
14383        }
14384    }
14385    a.len().cmp(&b.len())
14386}
14387
14388fn order_key_cmp(
14389    a: &[(Value, bool, Option<bool>)],
14390    b: &[(Value, bool, Option<bool>)],
14391) -> core::cmp::Ordering {
14392    // v7.24.1 — per-key DESC + effective NULLS placement (shared
14393    // contract with order_by_value_cmp).
14394    for ((va, desc, nf), (vb, _, _)) in a.iter().zip(b.iter()) {
14395        let c = order_by_value_cmp(*desc, *nf, va, vb);
14396        if c != core::cmp::Ordering::Equal {
14397            return c;
14398        }
14399    }
14400    a.len().cmp(&b.len())
14401}
14402
14403/// v7.17.0 Phase 3.10 — true when the Value is one of the
14404/// integer-shaped variants `generate_series` accepts as a start
14405/// / stop / step component. Float / NUMERIC are rejected — PG's
14406/// `generate_series(numeric, numeric)` overload is out of v7.17
14407/// scope.
14408const fn value_is_integer(v: &Value) -> bool {
14409    matches!(v, Value::SmallInt(_) | Value::Int(_) | Value::BigInt(_))
14410}
14411
14412/// v7.17.0 Phase 3.10 — widen any integer-shaped Value to i64 for
14413/// the generate_series iteration loop. Non-integer inputs panic;
14414/// caller guards via `value_is_integer`.
14415const fn value_to_i64(v: &Value) -> i64 {
14416    match v {
14417        Value::SmallInt(n) => *n as i64,
14418        Value::Int(n) => *n as i64,
14419        Value::BigInt(n) => *n,
14420        _ => panic!("value_to_i64 called on non-integer Value"),
14421    }
14422}
14423
14424/// v7.17.0 Phase 3.10 — integer-mode generate_series materialiser.
14425/// Step direction follows the sign: positive step iterates upward
14426/// (stops when current > stop); negative iterates downward; zero
14427/// errors. Caller-facing row stream is `BigInt`-typed so a single
14428/// projection schema covers SmallInt / Int / BigInt callers.
14429fn generate_series_integers(
14430    start: i64,
14431    stop: i64,
14432    step: i64,
14433    cancel: &CancelToken<'_>,
14434) -> Result<alloc::vec::Vec<Row>, EngineError> {
14435    if step == 0 {
14436        return Err(EngineError::Unsupported(
14437            "generate_series(): step argument cannot be zero".into(),
14438        ));
14439    }
14440    let mut out = alloc::vec::Vec::new();
14441    let mut cur = start;
14442    // Hard cap to keep a runaway call from eating all memory. PG
14443    // has no such cap but does honour query timeout; SPG's cancel
14444    // token will fire too — this is a defense-in-depth backstop.
14445    const MAX_ROWS: usize = 10_000_000;
14446    loop {
14447        cancel.check()?;
14448        if step > 0 && cur > stop {
14449            break;
14450        }
14451        if step < 0 && cur < stop {
14452            break;
14453        }
14454        out.push(Row::new(alloc::vec![Value::BigInt(cur)]));
14455        if out.len() > MAX_ROWS {
14456            return Err(EngineError::Unsupported(alloc::format!(
14457                "generate_series(): exceeded {MAX_ROWS} rows; \
14458                 narrow start/stop or use a larger step"
14459            )));
14460        }
14461        cur = match cur.checked_add(step) {
14462            Some(n) => n,
14463            None => break,
14464        };
14465    }
14466    Ok(out)
14467}
14468
14469/// v7.17.0 Phase 3.10 — timestamp-mode generate_series. step is a
14470/// `Value::Interval { months, micros }` per the caller's guard;
14471/// each iteration adds the interval via `apply_binary_interval`
14472/// so month-shifting handles short-month rollover (PG semantics).
14473fn generate_series_timestamps(
14474    start: i64,
14475    stop: i64,
14476    step: Value,
14477    cancel: &CancelToken<'_>,
14478) -> Result<alloc::vec::Vec<Row>, EngineError> {
14479    let (months, micros) = match &step {
14480        Value::Interval { months, micros } => (*months, *micros),
14481        _ => unreachable!("caller guards step.is_interval"),
14482    };
14483    if months == 0 && micros == 0 {
14484        return Err(EngineError::Unsupported(
14485            "generate_series(): INTERVAL step cannot be zero".into(),
14486        ));
14487    }
14488    let ascending = months > 0 || micros > 0;
14489    let mut out = alloc::vec::Vec::new();
14490    let mut cur = Value::Timestamp(start);
14491    const MAX_ROWS: usize = 10_000_000;
14492    loop {
14493        cancel.check()?;
14494        let cur_t = match cur {
14495            Value::Timestamp(t) => t,
14496            _ => unreachable!("loop invariant: cur is Timestamp"),
14497        };
14498        if ascending && cur_t > stop {
14499            break;
14500        }
14501        if !ascending && cur_t < stop {
14502            break;
14503        }
14504        out.push(Row::new(alloc::vec![Value::Timestamp(cur_t)]));
14505        if out.len() > MAX_ROWS {
14506            return Err(EngineError::Unsupported(alloc::format!(
14507                "generate_series(): exceeded {MAX_ROWS} rows; \
14508                 narrow start/stop or use a larger step"
14509            )));
14510        }
14511        let next = eval::apply_binary_interval(
14512            spg_sql::ast::BinOp::Add,
14513            &cur,
14514            &Value::Interval { months, micros },
14515        )
14516        .map_err(EngineError::Eval)?;
14517        cur = match next {
14518            Some(v) => v,
14519            None => break,
14520        };
14521    }
14522    Ok(out)
14523}
14524
14525#[allow(clippy::match_same_arms)] // explicit arms per type document the supported pairs
14526/// v7.24 (round-16 A) — per-key ORDER BY comparator honouring DESC
14527/// and the effective NULLS placement (explicit NULLS FIRST/LAST,
14528/// else the PG default: NULLS LAST for ASC, NULLS FIRST for DESC).
14529/// NULL placement is absolute — it does not flip with DESC.
14530pub(crate) fn order_by_value_cmp(
14531    desc: bool,
14532    nulls_first: Option<bool>,
14533    a: &Value,
14534    b: &Value,
14535) -> core::cmp::Ordering {
14536    use core::cmp::Ordering;
14537    let nf = nulls_first.unwrap_or(desc);
14538    match (matches!(a, Value::Null), matches!(b, Value::Null)) {
14539        (true, true) => Ordering::Equal,
14540        (true, false) => {
14541            if nf {
14542                Ordering::Less
14543            } else {
14544                Ordering::Greater
14545            }
14546        }
14547        (false, true) => {
14548            if nf {
14549                Ordering::Greater
14550            } else {
14551                Ordering::Less
14552            }
14553        }
14554        (false, false) => {
14555            let c = value_cmp(a, b);
14556            if desc { c.reverse() } else { c }
14557        }
14558    }
14559}
14560
14561fn value_cmp(a: &Value, b: &Value) -> core::cmp::Ordering {
14562    use core::cmp::Ordering;
14563    match (a, b) {
14564        (Value::Null, Value::Null) => Ordering::Equal,
14565        (Value::Null, _) => Ordering::Less,
14566        (_, Value::Null) => Ordering::Greater,
14567        (Value::Int(x), Value::Int(y)) => x.cmp(y),
14568        (Value::BigInt(x), Value::BigInt(y)) => x.cmp(y),
14569        (Value::SmallInt(x), Value::SmallInt(y)) => x.cmp(y),
14570        (Value::Text(x), Value::Text(y)) => x.cmp(y),
14571        (Value::Bool(x), Value::Bool(y)) => x.cmp(y),
14572        (Value::Float(x), Value::Float(y)) => x.partial_cmp(y).unwrap_or(Ordering::Equal),
14573        (Value::Date(x), Value::Date(y)) => x.cmp(y),
14574        (Value::Timestamp(x), Value::Timestamp(y)) => x.cmp(y),
14575        // Cross-type compare: fall back to the debug rendering —
14576        // same-partition is the goal, exact order is irrelevant.
14577        _ => alloc::format!("{a:?}").cmp(&alloc::format!("{b:?}")),
14578    }
14579}
14580
14581/// Compute the window function's per-row output for one partition.
14582/// `slice` has (partition key, order key, original-row-index)
14583/// tuples already sorted by order key. `filtered_rows` is the
14584/// full row list indexed by original-row-index. `out_vals` is
14585/// the destination, also indexed by original-row-index.
14586#[allow(
14587    clippy::too_many_arguments,
14588    clippy::cast_possible_truncation,
14589    clippy::cast_possible_wrap,
14590    clippy::cast_precision_loss,
14591    clippy::cast_sign_loss,
14592    clippy::doc_markdown,
14593    clippy::too_many_lines,
14594    clippy::type_complexity,
14595    clippy::match_same_arms
14596)]
14597fn compute_window_partition(
14598    name: &str,
14599    args: &[Expr],
14600    ordered: bool,
14601    frame: Option<&WindowFrame>,
14602    null_treatment: spg_sql::ast::NullTreatment,
14603    slice: &[(Vec<Value>, Vec<(Value, bool, Option<bool>)>, usize)],
14604    filtered_rows: &[&Row],
14605    ctx: &EvalContext<'_>,
14606    out_vals: &mut [Value],
14607) -> Result<(), EngineError> {
14608    let ignore_nulls = matches!(null_treatment, spg_sql::ast::NullTreatment::Ignore);
14609    let lower = name.to_ascii_lowercase();
14610    match lower.as_str() {
14611        "row_number" => {
14612            for (rank, (_, _, idx)) in slice.iter().enumerate() {
14613                out_vals[*idx] = Value::BigInt((rank + 1) as i64);
14614            }
14615            Ok(())
14616        }
14617        "rank" => {
14618            let mut prev_key: Option<&[(Value, bool, Option<bool>)]> = None;
14619            let mut current_rank: i64 = 1;
14620            for (i, (_, okey, idx)) in slice.iter().enumerate() {
14621                if let Some(p) = prev_key
14622                    && order_key_cmp(p, okey) != core::cmp::Ordering::Equal
14623                {
14624                    current_rank = (i + 1) as i64;
14625                }
14626                if prev_key.is_none() {
14627                    current_rank = 1;
14628                }
14629                out_vals[*idx] = Value::BigInt(current_rank);
14630                prev_key = Some(okey.as_slice());
14631            }
14632            Ok(())
14633        }
14634        "dense_rank" => {
14635            let mut prev_key: Option<&[(Value, bool, Option<bool>)]> = None;
14636            let mut current_rank: i64 = 0;
14637            for (_, okey, idx) in slice {
14638                if prev_key.is_none_or(|p| order_key_cmp(p, okey) != core::cmp::Ordering::Equal) {
14639                    current_rank += 1;
14640                }
14641                out_vals[*idx] = Value::BigInt(current_rank);
14642                prev_key = Some(okey.as_slice());
14643            }
14644            Ok(())
14645        }
14646        "sum" | "avg" | "min" | "max" | "count" | "count_star" => {
14647            // Pre-evaluate the function arg per row in the slice
14648            // (count_star has no arg).
14649            let arg_values: Vec<Value> = if lower == "count_star" || args.is_empty() {
14650                slice.iter().map(|_| Value::Null).collect()
14651            } else {
14652                slice
14653                    .iter()
14654                    .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
14655                    .collect::<Result<_, _>>()
14656                    .map_err(EngineError::Eval)?
14657            };
14658            // v4.20: pick the effective frame. Explicit frame
14659            // overrides the implicit default (running for ordered,
14660            // whole-partition for unordered).
14661            let eff = effective_frame(frame, ordered)?;
14662            #[allow(clippy::needless_range_loop)]
14663            for i in 0..slice.len() {
14664                let (lo, hi) = frame_bounds_for_row(&eff, i, slice);
14665                let mut sum: f64 = 0.0;
14666                let mut count: i64 = 0;
14667                let mut min_v: Option<f64> = None;
14668                let mut max_v: Option<f64> = None;
14669                let mut row_count: i64 = 0;
14670                if lo <= hi {
14671                    for j in lo..=hi {
14672                        let v = &arg_values[j];
14673                        match lower.as_str() {
14674                            "count_star" => row_count += 1,
14675                            "count" => {
14676                                if !v.is_null() {
14677                                    count += 1;
14678                                }
14679                            }
14680                            _ => {
14681                                if let Some(x) = value_to_f64(v) {
14682                                    sum += x;
14683                                    count += 1;
14684                                    min_v = Some(min_v.map_or(x, |m| m.min(x)));
14685                                    max_v = Some(max_v.map_or(x, |m| m.max(x)));
14686                                }
14687                            }
14688                        }
14689                    }
14690                }
14691                let value = match lower.as_str() {
14692                    "count_star" => Value::BigInt(row_count),
14693                    "count" => Value::BigInt(count),
14694                    "sum" => Value::Float(sum),
14695                    "avg" => {
14696                        if count == 0 {
14697                            Value::Null
14698                        } else {
14699                            Value::Float(sum / count as f64)
14700                        }
14701                    }
14702                    "min" => min_v.map_or(Value::Null, Value::Float),
14703                    "max" => max_v.map_or(Value::Null, Value::Float),
14704                    _ => unreachable!(),
14705                };
14706                let (_, _, idx) = &slice[i];
14707                out_vals[*idx] = value;
14708            }
14709            Ok(())
14710        }
14711        "lag" | "lead" => {
14712            // lag(expr [, offset [, default]])
14713            // lead(expr [, offset [, default]])
14714            if args.is_empty() {
14715                return Err(EngineError::Unsupported(alloc::format!(
14716                    "{lower}() requires at least one argument"
14717                )));
14718            }
14719            let offset: i64 = if args.len() >= 2 {
14720                let v = eval::eval_expr(&args[1], filtered_rows[slice[0].2], ctx)
14721                    .map_err(EngineError::Eval)?;
14722                match v {
14723                    Value::SmallInt(n) => i64::from(n),
14724                    Value::Int(n) => i64::from(n),
14725                    Value::BigInt(n) => n,
14726                    _ => {
14727                        return Err(EngineError::Unsupported(alloc::format!(
14728                            "{lower}() offset must be integer"
14729                        )));
14730                    }
14731                }
14732            } else {
14733                1
14734            };
14735            let default: Value = if args.len() >= 3 {
14736                eval::eval_expr(&args[2], filtered_rows[slice[0].2], ctx)
14737                    .map_err(EngineError::Eval)?
14738            } else {
14739                Value::Null
14740            };
14741            let values: Vec<Value> = slice
14742                .iter()
14743                .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
14744                .collect::<Result<_, _>>()
14745                .map_err(EngineError::Eval)?;
14746            let n = slice.len();
14747            for (i, (_, _, idx)) in slice.iter().enumerate() {
14748                let signed_offset = if lower == "lag" { -offset } else { offset };
14749                let v = if ignore_nulls {
14750                    // v6.4.2 — IGNORE NULLS: walk in the offset direction
14751                    // skipping NULL values; the `offset`-th non-NULL
14752                    // encountered is the result.
14753                    let step: i64 = if signed_offset >= 0 { 1 } else { -1 };
14754                    let needed: i64 = signed_offset.abs();
14755                    if needed == 0 {
14756                        values[i].clone()
14757                    } else {
14758                        let mut j: i64 = i as i64;
14759                        let mut hits: i64 = 0;
14760                        let mut found: Option<Value> = None;
14761                        loop {
14762                            j += step;
14763                            if j < 0 || j >= n as i64 {
14764                                break;
14765                            }
14766                            #[allow(clippy::cast_sign_loss)]
14767                            let v = &values[j as usize];
14768                            if !v.is_null() {
14769                                hits += 1;
14770                                if hits == needed {
14771                                    found = Some(v.clone());
14772                                    break;
14773                                }
14774                            }
14775                        }
14776                        found.unwrap_or_else(|| default.clone())
14777                    }
14778                } else {
14779                    let target_signed = i64::try_from(i).unwrap_or(i64::MAX) + signed_offset;
14780                    if target_signed < 0 || target_signed >= i64::try_from(n).unwrap_or(i64::MAX) {
14781                        default.clone()
14782                    } else {
14783                        #[allow(clippy::cast_sign_loss)]
14784                        {
14785                            values[target_signed as usize].clone()
14786                        }
14787                    }
14788                };
14789                out_vals[*idx] = v;
14790            }
14791            Ok(())
14792        }
14793        "first_value" | "last_value" | "nth_value" => {
14794            if args.is_empty() {
14795                return Err(EngineError::Unsupported(alloc::format!(
14796                    "{lower}() requires at least one argument"
14797                )));
14798            }
14799            let values: Vec<Value> = slice
14800                .iter()
14801                .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
14802                .collect::<Result<_, _>>()
14803                .map_err(EngineError::Eval)?;
14804            let nth: usize = if lower == "nth_value" {
14805                if args.len() < 2 {
14806                    return Err(EngineError::Unsupported(
14807                        "nth_value() requires (expr, n)".into(),
14808                    ));
14809                }
14810                let v = eval::eval_expr(&args[1], filtered_rows[slice[0].2], ctx)
14811                    .map_err(EngineError::Eval)?;
14812                let raw = match v {
14813                    Value::SmallInt(n) => i64::from(n),
14814                    Value::Int(n) => i64::from(n),
14815                    Value::BigInt(n) => n,
14816                    _ => {
14817                        return Err(EngineError::Unsupported(
14818                            "nth_value() n must be integer".into(),
14819                        ));
14820                    }
14821                };
14822                if raw < 1 {
14823                    return Err(EngineError::Unsupported(
14824                        "nth_value() n must be >= 1".into(),
14825                    ));
14826                }
14827                #[allow(clippy::cast_sign_loss)]
14828                {
14829                    raw as usize
14830                }
14831            } else {
14832                0
14833            };
14834            let eff = effective_frame(frame, ordered)?;
14835            for i in 0..slice.len() {
14836                let (lo, hi) = frame_bounds_for_row(&eff, i, slice);
14837                let (_, _, idx) = &slice[i];
14838                let v = if lo > hi {
14839                    Value::Null
14840                } else if ignore_nulls && matches!(lower.as_str(), "first_value" | "last_value") {
14841                    // v6.4.2 — IGNORE NULLS: skip NULL cells when
14842                    // selecting the boundary value within the frame.
14843                    if lower == "first_value" {
14844                        (lo..=hi)
14845                            .find_map(|j| {
14846                                let v = &values[j];
14847                                (!v.is_null()).then(|| v.clone())
14848                            })
14849                            .unwrap_or(Value::Null)
14850                    } else {
14851                        (lo..=hi)
14852                            .rev()
14853                            .find_map(|j| {
14854                                let v = &values[j];
14855                                (!v.is_null()).then(|| v.clone())
14856                            })
14857                            .unwrap_or(Value::Null)
14858                    }
14859                } else {
14860                    match lower.as_str() {
14861                        "first_value" => values[lo].clone(),
14862                        "last_value" => values[hi].clone(),
14863                        "nth_value" => {
14864                            let pos = lo + nth - 1;
14865                            if pos > hi {
14866                                Value::Null
14867                            } else {
14868                                values[pos].clone()
14869                            }
14870                        }
14871                        _ => unreachable!(),
14872                    }
14873                };
14874                out_vals[*idx] = v;
14875            }
14876            Ok(())
14877        }
14878        "ntile" => {
14879            if args.is_empty() {
14880                return Err(EngineError::Unsupported(
14881                    "ntile(n) requires an integer argument".into(),
14882                ));
14883            }
14884            let v = eval::eval_expr(&args[0], filtered_rows[slice[0].2], ctx)
14885                .map_err(EngineError::Eval)?;
14886            let bucket_count: i64 = match v {
14887                Value::SmallInt(n) => i64::from(n),
14888                Value::Int(n) => i64::from(n),
14889                Value::BigInt(n) => n,
14890                _ => {
14891                    return Err(EngineError::Unsupported(
14892                        "ntile() argument must be integer".into(),
14893                    ));
14894                }
14895            };
14896            if bucket_count < 1 {
14897                return Err(EngineError::Unsupported(
14898                    "ntile() argument must be >= 1".into(),
14899                ));
14900            }
14901            #[allow(clippy::cast_sign_loss)]
14902            let buckets = bucket_count as usize;
14903            let n = slice.len();
14904            // Each bucket gets `base` rows; the first `extras` buckets
14905            // get one extra. PG semantics.
14906            let base = n / buckets;
14907            let extras = n % buckets;
14908            let mut bucket: usize = 1;
14909            let mut remaining_in_bucket = if extras > 0 { base + 1 } else { base };
14910            let mut buckets_with_extra_remaining = extras;
14911            for (_, _, idx) in slice {
14912                if remaining_in_bucket == 0 {
14913                    bucket += 1;
14914                    buckets_with_extra_remaining = buckets_with_extra_remaining.saturating_sub(1);
14915                    remaining_in_bucket = if buckets_with_extra_remaining > 0 {
14916                        base + 1
14917                    } else {
14918                        base
14919                    };
14920                    // Edge: if base==0 and extras==0, all rows fit;
14921                    // shouldn't reach here, but guard anyway.
14922                    if remaining_in_bucket == 0 {
14923                        remaining_in_bucket = 1;
14924                    }
14925                }
14926                out_vals[*idx] = Value::BigInt(i64::try_from(bucket).unwrap_or(i64::MAX));
14927                remaining_in_bucket -= 1;
14928            }
14929            Ok(())
14930        }
14931        "percent_rank" => {
14932            // (rank - 1) / (n - 1) where rank is the standard RANK().
14933            // Single-row partitions get 0.
14934            let n = slice.len();
14935            let mut prev_key: Option<&[(Value, bool, Option<bool>)]> = None;
14936            let mut current_rank: i64 = 1;
14937            for (i, (_, okey, idx)) in slice.iter().enumerate() {
14938                if let Some(p) = prev_key
14939                    && order_key_cmp(p, okey) != core::cmp::Ordering::Equal
14940                {
14941                    current_rank = i64::try_from(i + 1).unwrap_or(i64::MAX);
14942                }
14943                if prev_key.is_none() {
14944                    current_rank = 1;
14945                }
14946                #[allow(clippy::cast_precision_loss)]
14947                let pr = if n <= 1 {
14948                    0.0
14949                } else {
14950                    (current_rank - 1) as f64 / (n - 1) as f64
14951                };
14952                out_vals[*idx] = Value::Float(pr);
14953                prev_key = Some(okey.as_slice());
14954            }
14955            Ok(())
14956        }
14957        "cume_dist" => {
14958            // # rows up to and including this row's peer group / n.
14959            let n = slice.len();
14960            // First pass: find peer-group-end rank for each row.
14961            for i in 0..slice.len() {
14962                let peer_end = peer_group_end(slice, i);
14963                #[allow(clippy::cast_precision_loss)]
14964                let cd = (peer_end + 1) as f64 / n as f64;
14965                let (_, _, idx) = &slice[i];
14966                out_vals[*idx] = Value::Float(cd);
14967            }
14968            Ok(())
14969        }
14970        other => Err(EngineError::Unsupported(alloc::format!(
14971            "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)"
14972        ))),
14973    }
14974}
14975
14976/// v4.20: resolve the user-provided frame down to a normalised
14977/// `(kind, start, end)`. `None` means default — derive from
14978/// `ordered`: ordered ⇒ RANGE UNBOUNDED PRECEDING AND CURRENT ROW,
14979/// unordered ⇒ ROWS UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
14980/// Single-bound shorthand (e.g. `ROWS 5 PRECEDING`) normalises
14981/// end → CURRENT ROW per the PG spec.
14982fn effective_frame(
14983    frame: Option<&WindowFrame>,
14984    ordered: bool,
14985) -> Result<(FrameKind, FrameBound, FrameBound), EngineError> {
14986    match frame {
14987        None => {
14988            if ordered {
14989                Ok((
14990                    FrameKind::Range,
14991                    FrameBound::UnboundedPreceding,
14992                    FrameBound::CurrentRow,
14993                ))
14994            } else {
14995                Ok((
14996                    FrameKind::Rows,
14997                    FrameBound::UnboundedPreceding,
14998                    FrameBound::UnboundedFollowing,
14999                ))
15000            }
15001        }
15002        Some(fr) => {
15003            let end = fr.end.clone().unwrap_or(FrameBound::CurrentRow);
15004            // Reject start > end (a few impossible combinations).
15005            if matches!(fr.start, FrameBound::UnboundedFollowing)
15006                || matches!(end, FrameBound::UnboundedPreceding)
15007            {
15008                return Err(EngineError::Unsupported(alloc::format!(
15009                    "invalid frame: start={:?} end={:?}",
15010                    fr.start,
15011                    end
15012                )));
15013            }
15014            // RANGE OFFSET PRECEDING / FOLLOWING needs value-typed
15015            // arithmetic on the ORDER BY key (e.g. `RANGE BETWEEN
15016            // INTERVAL '1 day' PRECEDING AND CURRENT ROW`). Not
15017            // implemented in v4.20.
15018            if fr.kind == FrameKind::Range
15019                && (matches!(
15020                    fr.start,
15021                    FrameBound::OffsetPreceding(_) | FrameBound::OffsetFollowing(_)
15022                ) || matches!(
15023                    end,
15024                    FrameBound::OffsetPreceding(_) | FrameBound::OffsetFollowing(_)
15025                ))
15026            {
15027                return Err(EngineError::Unsupported(
15028                    "RANGE with explicit offset bounds is not supported (v4.20: only UNBOUNDED / CURRENT ROW for RANGE)".into(),
15029                ));
15030            }
15031            Ok((fr.kind, fr.start.clone(), end))
15032        }
15033    }
15034}
15035
15036/// Compute `(lo, hi)` row-index bounds inside the partition slice
15037/// for the row at position `i`. Inclusive, clamped to
15038/// `[0, slice.len()-1]`. Empty result if `lo > hi`.
15039#[allow(clippy::type_complexity)]
15040fn frame_bounds_for_row(
15041    eff: &(FrameKind, FrameBound, FrameBound),
15042    i: usize,
15043    slice: &[(Vec<Value>, Vec<(Value, bool, Option<bool>)>, usize)],
15044) -> (usize, usize) {
15045    let (kind, start, end) = eff;
15046    let n = slice.len();
15047    let last = n.saturating_sub(1);
15048    let (mut lo, mut hi) = match kind {
15049        FrameKind::Rows => {
15050            let lo = match start {
15051                FrameBound::UnboundedPreceding => 0,
15052                FrameBound::OffsetPreceding(k) => {
15053                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
15054                    i.saturating_sub(k)
15055                }
15056                FrameBound::CurrentRow => i,
15057                FrameBound::OffsetFollowing(k) => {
15058                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
15059                    i.saturating_add(k).min(last)
15060                }
15061                FrameBound::UnboundedFollowing => last,
15062            };
15063            let hi = match end {
15064                FrameBound::UnboundedPreceding => 0,
15065                FrameBound::OffsetPreceding(k) => {
15066                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
15067                    i.saturating_sub(k)
15068                }
15069                FrameBound::CurrentRow => i,
15070                FrameBound::OffsetFollowing(k) => {
15071                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
15072                    i.saturating_add(k).min(last)
15073                }
15074                FrameBound::UnboundedFollowing => last,
15075            };
15076            (lo, hi)
15077        }
15078        FrameKind::Range => {
15079            // RANGE bounds are peer-aware. With only UNBOUNDED and
15080            // CURRENT ROW supported (rejected at effective_frame for
15081            // explicit offsets), the start/end map to the
15082            // partition's full extent at the same-order-key peer
15083            // group boundary.
15084            let lo = match start {
15085                FrameBound::UnboundedPreceding => 0,
15086                FrameBound::CurrentRow => peer_group_start(slice, i),
15087                FrameBound::UnboundedFollowing => last,
15088                _ => unreachable!("offset bounds rejected for RANGE"),
15089            };
15090            let hi = match end {
15091                FrameBound::UnboundedPreceding => 0,
15092                FrameBound::CurrentRow => peer_group_end(slice, i),
15093                FrameBound::UnboundedFollowing => last,
15094                _ => unreachable!("offset bounds rejected for RANGE"),
15095            };
15096            (lo, hi)
15097        }
15098    };
15099    if hi >= n {
15100        hi = last;
15101    }
15102    if lo >= n {
15103        lo = last;
15104    }
15105    (lo, hi)
15106}
15107
15108/// Find the inclusive index of the first row with the same ORDER
15109/// BY key as `slice[i]`. Slice is already sorted by partition then
15110/// order, so peers are contiguous.
15111#[allow(clippy::type_complexity)]
15112fn peer_group_start(
15113    slice: &[(Vec<Value>, Vec<(Value, bool, Option<bool>)>, usize)],
15114    i: usize,
15115) -> usize {
15116    let key = &slice[i].1;
15117    let mut j = i;
15118    while j > 0 && order_key_cmp(&slice[j - 1].1, key) == core::cmp::Ordering::Equal {
15119        j -= 1;
15120    }
15121    j
15122}
15123
15124/// Find the inclusive index of the last row with the same ORDER
15125/// BY key as `slice[i]`.
15126#[allow(clippy::type_complexity)]
15127fn peer_group_end(
15128    slice: &[(Vec<Value>, Vec<(Value, bool, Option<bool>)>, usize)],
15129    i: usize,
15130) -> usize {
15131    let key = &slice[i].1;
15132    let mut j = i;
15133    while j + 1 < slice.len() && order_key_cmp(&slice[j + 1].1, key) == core::cmp::Ordering::Equal {
15134        j += 1;
15135    }
15136    j
15137}
15138
15139fn value_to_f64(v: &Value) -> Option<f64> {
15140    match v {
15141        Value::SmallInt(n) => Some(f64::from(*n)),
15142        Value::Int(n) => Some(f64::from(*n)),
15143        #[allow(clippy::cast_precision_loss)]
15144        Value::BigInt(n) => Some(*n as f64),
15145        Value::Float(x) => Some(*x),
15146        _ => None,
15147    }
15148}
15149
15150/// Quick scan for any subquery-bearing node in a SELECT's WHERE /
15151/// projection / `order_by` — saves cloning the AST when there are
15152/// none (the common case).
15153fn expr_tree_has_subquery(stmt: &SelectStatement) -> bool {
15154    let mut any = false;
15155    for item in &stmt.items {
15156        if let SelectItem::Expr { expr, .. } = item {
15157            any = any || expr_has_subquery(expr);
15158        }
15159    }
15160    if let Some(w) = &stmt.where_ {
15161        any = any || expr_has_subquery(w);
15162    }
15163    if let Some(h) = &stmt.having {
15164        any = any || expr_has_subquery(h);
15165    }
15166    for o in &stmt.order_by {
15167        any = any || expr_has_subquery(&o.expr);
15168    }
15169    for (_, peer) in &stmt.unions {
15170        any = any || expr_tree_has_subquery(peer);
15171    }
15172    any
15173}
15174
15175pub(crate) fn expr_has_subquery(e: &Expr) -> bool {
15176    match e {
15177        Expr::ScalarSubquery(_) | Expr::Exists { .. } | Expr::InSubquery { .. } => true,
15178        Expr::AggregateOrdered { call, order_by, .. } => {
15179            expr_has_subquery(call) || order_by.iter().any(|o| expr_has_subquery(&o.expr))
15180        }
15181        Expr::Binary { lhs, rhs, .. } => expr_has_subquery(lhs) || expr_has_subquery(rhs),
15182        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
15183            expr_has_subquery(expr)
15184        }
15185        Expr::FunctionCall { args, .. } => args.iter().any(expr_has_subquery),
15186        Expr::Like { expr, pattern, .. } => expr_has_subquery(expr) || expr_has_subquery(pattern),
15187        Expr::Extract { source, .. } => expr_has_subquery(source),
15188        Expr::WindowFunction {
15189            args,
15190            partition_by,
15191            order_by,
15192            ..
15193        } => {
15194            args.iter().any(expr_has_subquery)
15195                || partition_by.iter().any(expr_has_subquery)
15196                || order_by.iter().any(|(e, _, _)| expr_has_subquery(e))
15197        }
15198        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => false,
15199        Expr::Array(items) => items.iter().any(expr_has_subquery),
15200        Expr::ArraySubscript { target, index } => {
15201            expr_has_subquery(target) || expr_has_subquery(index)
15202        }
15203        Expr::AnyAll { expr, array, .. } => expr_has_subquery(expr) || expr_has_subquery(array),
15204        Expr::InList { expr, list, .. } => {
15205            expr_has_subquery(expr) || list.iter().any(expr_has_subquery)
15206        }
15207        Expr::Case {
15208            operand,
15209            branches,
15210            else_branch,
15211        } => {
15212            operand.as_deref().is_some_and(expr_has_subquery)
15213                || branches
15214                    .iter()
15215                    .any(|(w, t)| expr_has_subquery(w) || expr_has_subquery(t))
15216                || else_branch.as_deref().is_some_and(expr_has_subquery)
15217        }
15218    }
15219}
15220
15221/// v4.10 helper: materialise a runtime `Value` back into an AST
15222/// `Expr::Literal` for the subquery-rewrite path. Supports the
15223/// types `Literal` can represent (Integer / Float / Text / Bool /
15224/// Null). Date / Timestamp / Numeric / Vector / Interval / JSON
15225/// would lose precision through Literal and aren't supported in
15226/// uncorrelated-subquery results; they error with a clear hint.
15227fn value_to_literal_expr(v: Value) -> Result<Expr, EngineError> {
15228    let lit = match v {
15229        Value::Null => Literal::Null,
15230        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
15231        Value::Int(n) => Literal::Integer(i64::from(n)),
15232        Value::BigInt(n) => Literal::Integer(n),
15233        Value::Float(x) => Literal::Float(x),
15234        Value::Text(s) | Value::Json(s) => Literal::String(s),
15235        Value::Bool(b) => Literal::Bool(b),
15236        other => {
15237            return Err(EngineError::Unsupported(alloc::format!(
15238                "subquery result type {:?} not yet materialisable; cast to text or integer in the inner SELECT",
15239                other.data_type()
15240            )));
15241        }
15242    };
15243    Ok(Expr::Literal(lit))
15244}
15245
15246/// v7.13.0 — wider helper used by `INSERT … SELECT` (mailrs
15247/// round-5 G4). Covers the most common `Value` variants. Types
15248/// that need lossy textual round-trip (BYTEA, arrays, ts*)
15249/// surface as an Unsupported error so the caller can add a cast
15250/// in the inner SELECT.
15251fn value_to_literal_expr_permissive(v: Value) -> Result<Expr, EngineError> {
15252    let lit = match v {
15253        Value::Null => Literal::Null,
15254        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
15255        Value::Int(n) => Literal::Integer(i64::from(n)),
15256        Value::BigInt(n) => Literal::Integer(n),
15257        Value::Float(x) => Literal::Float(x),
15258        Value::Text(s) | Value::Json(s) => Literal::String(s),
15259        Value::Bool(b) => Literal::Bool(b),
15260        Value::Vector(xs) => Literal::Vector(xs),
15261        // Date / Timestamp / Timestamptz / Numeric round-trip
15262        // through a TEXT literal that `coerce_value` re-parses
15263        // against the target column type.
15264        Value::Date(days) => {
15265            let micros = (i64::from(days)) * 86_400_000_000;
15266            Literal::String(format_timestamp_micros_as_date(micros))
15267        }
15268        Value::Timestamp(us) => Literal::String(format_timestamp_micros(us)),
15269        Value::Numeric { scaled, scale } => Literal::String(format_numeric(scaled, scale)),
15270        other => {
15271            return Err(EngineError::Unsupported(alloc::format!(
15272                "INSERT … SELECT cannot materialise value of type {:?}; \
15273                 add an explicit CAST in the inner SELECT",
15274                other.data_type()
15275            )));
15276        }
15277    };
15278    Ok(Expr::Literal(lit))
15279}
15280
15281fn format_timestamp_micros(us: i64) -> String {
15282    // Same Y/M/D split used by the wire layer; epoch-relative.
15283    let days = us.div_euclid(86_400_000_000);
15284    let intra_day = us.rem_euclid(86_400_000_000);
15285    let date = format_timestamp_micros_as_date(days * 86_400_000_000);
15286    let secs = intra_day / 1_000_000;
15287    let us_rem = intra_day % 1_000_000;
15288    let h = (secs / 3600) % 24;
15289    let m = (secs / 60) % 60;
15290    let s = secs % 60;
15291    if us_rem == 0 {
15292        alloc::format!("{date} {h:02}:{m:02}:{s:02}")
15293    } else {
15294        alloc::format!("{date} {h:02}:{m:02}:{s:02}.{us_rem:06}")
15295    }
15296}
15297
15298fn format_timestamp_micros_as_date(us: i64) -> String {
15299    // Days since 1970-01-01 → calendar Y-M-D via the proleptic
15300    // Gregorian conversion used by spg-engine's date helpers.
15301    let days = us.div_euclid(86_400_000_000);
15302    // 1970-01-01 = JDN 2440588.
15303    let jdn = days + 2_440_588;
15304    let (y, mo, d) = jdn_to_ymd(jdn);
15305    alloc::format!("{y:04}-{mo:02}-{d:02}")
15306}
15307
15308fn jdn_to_ymd(jdn: i64) -> (i64, u32, u32) {
15309    // Fliegel & Van Flandern (1968) — works for all positive JDNs.
15310    let l = jdn + 68569;
15311    let n = (4 * l) / 146_097;
15312    let l = l - (146_097 * n + 3) / 4;
15313    let i = (4000 * (l + 1)) / 1_461_001;
15314    let l = l - (1461 * i) / 4 + 31;
15315    let j = (80 * l) / 2447;
15316    let day = (l - (2447 * j) / 80) as u32;
15317    let l = j / 11;
15318    let month = (j + 2 - 12 * l) as u32;
15319    let year = 100 * (n - 49) + i + l;
15320    (year, month, day)
15321}
15322
15323fn format_numeric(scaled: i128, scale: u8) -> String {
15324    if scale == 0 {
15325        return alloc::format!("{scaled}");
15326    }
15327    let abs = scaled.unsigned_abs();
15328    let divisor = 10u128.pow(u32::from(scale));
15329    let whole = abs / divisor;
15330    let frac = abs % divisor;
15331    let sign = if scaled < 0 { "-" } else { "" };
15332    alloc::format!("{sign}{whole}.{frac:0width$}", width = usize::from(scale))
15333}
15334
15335/// v6.1.1 — walk the prepared `Statement` AST and replace every
15336/// `Expr::Placeholder(n)` with `Expr::Literal(value_to_literal(
15337/// params[n-1]))`. The dispatch downstream sees a `Statement`
15338/// indistinguishable from a simple-query parse, so the exec path
15339/// stays unchanged.
15340///
15341/// Errors fall into one shape: a `$N` references past the bound
15342/// `params.len()`. Out-of-range happens when the Bind didn't
15343/// supply enough values; pgwire surfaces this as a protocol error
15344/// to the client.
15345/// v7.15.0 — rewrite every (potentially-qualified) column
15346/// identifier matching `old` to `new` in a stored SQL source
15347/// string. Used by `ALTER TABLE … RENAME COLUMN` to patch
15348/// CHECK predicate sources, partial-index predicate sources,
15349/// and runtime DEFAULT expression sources before they get
15350/// re-parsed on the next INSERT/UPDATE.
15351///
15352/// Round-trips through the parser, so the rewritten output is
15353/// the canonical Display form (matches what the engine stores
15354/// for fresh predicates). If the source doesn't parse, surfaces
15355/// the parse error — the invariant that stored predicates are
15356/// in canonical Display form means a parse failure here is a
15357/// real bug, not a user mistake to swallow.
15358fn rewrite_column_in_source(
15359    src: &str,
15360    old: &str,
15361    new: &str,
15362) -> Result<alloc::string::String, EngineError> {
15363    let mut expr = spg_sql::parser::parse_expression(src).map_err(|e| {
15364        EngineError::Unsupported(alloc::format!(
15365            "ALTER TABLE RENAME COLUMN: stored predicate source {src:?} \
15366             failed to parse for rewrite ({e})"
15367        ))
15368    })?;
15369    rewrite_column_in_expr(&mut expr, old, new);
15370    Ok(alloc::format!("{expr}"))
15371}
15372
15373/// v7.15.0 — Expr walker that swaps `Expr::Column { name: old, .. }`
15374/// for `Expr::Column { name: new, .. }`. Qualifier is preserved
15375/// (e.g. `t.old` → `t.new`); a foreign-table qualifier still
15376/// gets rewritten because the AST has no way to tell us this
15377/// predicate is on table T versus table T2 — predicate sources
15378/// in SPG are always scoped to the owning table, so any
15379/// qualifier present is either redundant or wrong.
15380fn rewrite_column_in_expr(e: &mut Expr, old: &str, new: &str) {
15381    match e {
15382        Expr::AggregateOrdered { call, order_by, .. } => {
15383            rewrite_column_in_expr(call, old, new);
15384            for o in order_by.iter_mut() {
15385                rewrite_column_in_expr(&mut o.expr, old, new);
15386            }
15387        }
15388        Expr::Column(c) => {
15389            if c.name.eq_ignore_ascii_case(old) {
15390                c.name = new.to_string();
15391            }
15392        }
15393        Expr::Binary { lhs, rhs, .. } => {
15394            rewrite_column_in_expr(lhs, old, new);
15395            rewrite_column_in_expr(rhs, old, new);
15396        }
15397        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
15398            rewrite_column_in_expr(expr, old, new);
15399        }
15400        Expr::FunctionCall { args, .. } => {
15401            for a in args {
15402                rewrite_column_in_expr(a, old, new);
15403            }
15404        }
15405        Expr::Like { expr, pattern, .. } => {
15406            rewrite_column_in_expr(expr, old, new);
15407            rewrite_column_in_expr(pattern, old, new);
15408        }
15409        Expr::Extract { source, .. } => rewrite_column_in_expr(source, old, new),
15410        Expr::WindowFunction {
15411            args,
15412            partition_by,
15413            order_by,
15414            ..
15415        } => {
15416            for a in args {
15417                rewrite_column_in_expr(a, old, new);
15418            }
15419            for p in partition_by {
15420                rewrite_column_in_expr(p, old, new);
15421            }
15422            for (o, _, _) in order_by {
15423                rewrite_column_in_expr(o, old, new);
15424            }
15425        }
15426        Expr::Array(items) => {
15427            for elem in items {
15428                rewrite_column_in_expr(elem, old, new);
15429            }
15430        }
15431        Expr::ArraySubscript { target, index } => {
15432            rewrite_column_in_expr(target, old, new);
15433            rewrite_column_in_expr(index, old, new);
15434        }
15435        Expr::AnyAll { expr, array, .. } => {
15436            rewrite_column_in_expr(expr, old, new);
15437            rewrite_column_in_expr(array, old, new);
15438        }
15439        Expr::InList { expr, list, .. } => {
15440            rewrite_column_in_expr(expr, old, new);
15441            for item in list {
15442                rewrite_column_in_expr(item, old, new);
15443            }
15444        }
15445        Expr::Case {
15446            operand,
15447            branches,
15448            else_branch,
15449        } => {
15450            if let Some(o) = operand {
15451                rewrite_column_in_expr(o, old, new);
15452            }
15453            for (w, t) in branches {
15454                rewrite_column_in_expr(w, old, new);
15455                rewrite_column_in_expr(t, old, new);
15456            }
15457            if let Some(e) = else_branch {
15458                rewrite_column_in_expr(e, old, new);
15459            }
15460        }
15461        // Stored predicate sources never contain subqueries —
15462        // CHECK / partial-index / runtime_default are all scalar.
15463        // If a future feature changes that, recurse here.
15464        Expr::ScalarSubquery(_) | Expr::Exists { .. } | Expr::InSubquery { .. } => {}
15465        Expr::Literal(_) | Expr::Placeholder(_) => {}
15466    }
15467}
15468
15469/// v7.16.0 — walks a parsed statement and replaces every
15470/// `Expr::Placeholder(N)` with the corresponding `params[N-1]`
15471/// re-encoded as an `Expr::Literal`. Used internally by
15472/// `Engine::execute_prepared` AND surfaced for the spg-embedded
15473/// WAL path (which needs the bind-final AST so replay sees a
15474/// simple-query-shaped statement, not a `$1`-shaped one). Errors
15475/// when a placeholder references an index past the params slice.
15476pub fn substitute_placeholders(stmt: &mut Statement, params: &[Value]) -> Result<(), EngineError> {
15477    match stmt {
15478        Statement::Select(s) => substitute_select(s, params)?,
15479        Statement::Insert(ins) => {
15480            for row in &mut ins.rows {
15481                for e in row {
15482                    substitute_expr(e, params)?;
15483                }
15484            }
15485            // ON CONFLICT DO UPDATE assignments / WHERE can carry
15486            // placeholders too (`… DO UPDATE SET reason = $2` —
15487            // mailrs embed round-12).
15488            if let Some(clause) = &mut ins.on_conflict
15489                && let spg_sql::ast::OnConflictAction::Update {
15490                    assignments,
15491                    where_,
15492                } = &mut clause.action
15493            {
15494                for (_, e) in assignments.iter_mut() {
15495                    substitute_expr(e, params)?;
15496                }
15497                if let Some(w) = where_ {
15498                    substitute_expr(w, params)?;
15499                }
15500            }
15501        }
15502        Statement::Update(u) => {
15503            for (_, e) in &mut u.assignments {
15504                substitute_expr(e, params)?;
15505            }
15506            if let Some(w) = &mut u.where_ {
15507                substitute_expr(w, params)?;
15508            }
15509        }
15510        Statement::Delete(d) => {
15511            if let Some(w) = &mut d.where_ {
15512                substitute_expr(w, params)?;
15513            }
15514        }
15515        Statement::Explain(e) => substitute_select(&mut e.inner, params)?,
15516        // Other statements (CREATE / BEGIN / SHOW / …) have no
15517        // expression slots; no walk needed.
15518        _ => {}
15519    }
15520    Ok(())
15521}
15522
15523/// v7.25.1 (mailrs round-18) — THE canonical mutable traversal of
15524/// every expression slot in a SelectStatement, including every
15525/// nested SelectStatement (CTE bodies, UNION peers, LATERAL derived
15526/// tables) and the JOIN ON conditions. Round-12 #7b and round-18
15527/// were both "a hand-rolled Select walker forgot one subtree";
15528/// every whole-statement rewrite pass (placeholders, clock) must go
15529/// through here so a new AST slot only needs adding once.
15530/// Expression-INTERNAL recursion (into subquery nodes inside an
15531/// Expr) stays the visitor's own responsibility.
15532pub(crate) fn walk_select_exprs_mut(
15533    s: &mut SelectStatement,
15534    f: &mut impl FnMut(&mut Expr) -> Result<(), EngineError>,
15535) -> Result<(), EngineError> {
15536    for cte in &mut s.ctes {
15537        walk_select_exprs_mut(&mut cte.body, f)?;
15538    }
15539    for item in &mut s.items {
15540        if let SelectItem::Expr { expr, .. } = item {
15541            f(expr)?;
15542        }
15543    }
15544    if let Some(from) = &mut s.from {
15545        if let Some(sub) = &mut from.primary.lateral_subquery {
15546            walk_select_exprs_mut(sub, f)?;
15547        }
15548        for j in &mut from.joins {
15549            if let Some(sub) = &mut j.table.lateral_subquery {
15550                walk_select_exprs_mut(sub, f)?;
15551            }
15552            if let Some(on) = &mut j.on {
15553                f(on)?;
15554            }
15555        }
15556    }
15557    if let Some(w) = &mut s.where_ {
15558        f(w)?;
15559    }
15560    if let Some(gs) = &mut s.group_by {
15561        for g in gs {
15562            f(g)?;
15563        }
15564    }
15565    if let Some(h) = &mut s.having {
15566        f(h)?;
15567    }
15568    for o in &mut s.order_by {
15569        f(&mut o.expr)?;
15570    }
15571    for (_, peer) in &mut s.unions {
15572        walk_select_exprs_mut(peer, f)?;
15573    }
15574    Ok(())
15575}
15576
15577fn substitute_select(s: &mut SelectStatement, params: &[Value]) -> Result<(), EngineError> {
15578    walk_select_exprs_mut(s, &mut |e| substitute_expr(e, params))?;
15579    // v7.25.1 — LIMIT/OFFSET placeholders inside CTE bodies and
15580    // UNION peers resolve through their own recursion (the walker
15581    // above only visits Expr slots), so handle them per nested
15582    // statement here.
15583    for cte in &mut s.ctes {
15584        resolve_limit_offset_placeholders(&mut cte.body, params)?;
15585    }
15586    for (_, peer) in &mut s.unions {
15587        resolve_limit_offset_placeholders(peer, params)?;
15588    }
15589    // v7.9.24 — LIMIT $N / OFFSET $N placeholder resolution.
15590    // mailrs H2. After this pass each LIMIT/OFFSET that was a
15591    // Placeholder is rewritten to Literal so the existing
15592    // `LimitExpr::as_literal` path consumes a concrete u32.
15593    if let Some(le) = s.limit {
15594        s.limit = Some(resolve_limit_placeholder(le, params)?);
15595    }
15596    if let Some(le) = s.offset {
15597        s.offset = Some(resolve_limit_placeholder(le, params)?);
15598    }
15599    Ok(())
15600}
15601
15602/// v7.25.1 — recursive LIMIT/OFFSET placeholder resolution for
15603/// nested statements (CTE bodies / UNION peers).
15604fn resolve_limit_offset_placeholders(
15605    s: &mut SelectStatement,
15606    params: &[Value],
15607) -> Result<(), EngineError> {
15608    if let Some(le) = s.limit {
15609        s.limit = Some(resolve_limit_placeholder(le, params)?);
15610    }
15611    if let Some(le) = s.offset {
15612        s.offset = Some(resolve_limit_placeholder(le, params)?);
15613    }
15614    for cte in &mut s.ctes {
15615        resolve_limit_offset_placeholders(&mut cte.body, params)?;
15616    }
15617    for (_, peer) in &mut s.unions {
15618        resolve_limit_offset_placeholders(peer, params)?;
15619    }
15620    Ok(())
15621}
15622
15623fn resolve_limit_placeholder(
15624    le: spg_sql::ast::LimitExpr,
15625    params: &[Value],
15626) -> Result<spg_sql::ast::LimitExpr, EngineError> {
15627    use spg_sql::ast::LimitExpr;
15628    match le {
15629        LimitExpr::Literal(_) => Ok(le),
15630        LimitExpr::Placeholder(n) => {
15631            let idx = usize::from(n).saturating_sub(1);
15632            let v = params.get(idx).ok_or_else(|| {
15633                EngineError::Eval(EvalError::PlaceholderOutOfRange {
15634                    n,
15635                    bound: u16::try_from(params.len()).unwrap_or(u16::MAX),
15636                })
15637            })?;
15638            let int = match v {
15639                Value::SmallInt(x) => Some(i64::from(*x)),
15640                Value::Int(x) => Some(i64::from(*x)),
15641                Value::BigInt(x) => Some(*x),
15642                _ => None,
15643            }
15644            .ok_or_else(|| {
15645                EngineError::Unsupported(alloc::format!(
15646                    "LIMIT/OFFSET ${n} bound to non-integer {v:?}"
15647                ))
15648            })?;
15649            if int < 0 {
15650                return Err(EngineError::Unsupported(alloc::format!(
15651                    "LIMIT/OFFSET ${n} bound to negative value {int}"
15652                )));
15653            }
15654            let bounded = u32::try_from(int).map_err(|_| {
15655                EngineError::Unsupported(alloc::format!(
15656                    "LIMIT/OFFSET ${n} value {int} exceeds u32 range"
15657                ))
15658            })?;
15659            Ok(LimitExpr::Literal(bounded))
15660        }
15661    }
15662}
15663
15664fn substitute_expr(e: &mut Expr, params: &[Value]) -> Result<(), EngineError> {
15665    if let Expr::Placeholder(n) = e {
15666        let idx = usize::from(*n).saturating_sub(1);
15667        let v = params.get(idx).ok_or_else(|| {
15668            EngineError::Eval(EvalError::PlaceholderOutOfRange {
15669                n: *n,
15670                bound: u16::try_from(params.len()).unwrap_or(u16::MAX),
15671            })
15672        })?;
15673        *e = Expr::Literal(value_to_literal(v.clone()));
15674        return Ok(());
15675    }
15676    match e {
15677        Expr::AggregateOrdered { call, order_by, .. } => {
15678            substitute_expr(call, params)?;
15679            for o in order_by.iter_mut() {
15680                substitute_expr(&mut o.expr, params)?;
15681            }
15682        }
15683        Expr::Binary { lhs, rhs, .. } => {
15684            substitute_expr(lhs, params)?;
15685            substitute_expr(rhs, params)?;
15686        }
15687        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
15688            substitute_expr(expr, params)?;
15689        }
15690        Expr::FunctionCall { args, .. } => {
15691            for a in args {
15692                substitute_expr(a, params)?;
15693            }
15694        }
15695        Expr::Like { expr, pattern, .. } => {
15696            substitute_expr(expr, params)?;
15697            substitute_expr(pattern, params)?;
15698        }
15699        Expr::Extract { source, .. } => substitute_expr(source, params)?,
15700        Expr::ScalarSubquery(s) => substitute_select(s, params)?,
15701        Expr::Exists { subquery, .. } => substitute_select(subquery, params)?,
15702        Expr::InSubquery { expr, subquery, .. } => {
15703            substitute_expr(expr, params)?;
15704            substitute_select(subquery, params)?;
15705        }
15706        Expr::WindowFunction {
15707            args,
15708            partition_by,
15709            order_by,
15710            ..
15711        } => {
15712            for a in args {
15713                substitute_expr(a, params)?;
15714            }
15715            for p in partition_by {
15716                substitute_expr(p, params)?;
15717            }
15718            for (e, _, _) in order_by {
15719                substitute_expr(e, params)?;
15720            }
15721        }
15722        Expr::Literal(_) | Expr::Column(_) => {}
15723        // Already handled above.
15724        Expr::Placeholder(_) => unreachable!("Placeholder handled at top of fn"),
15725        Expr::Array(items) => {
15726            for elem in items {
15727                substitute_expr(elem, params)?;
15728            }
15729        }
15730        Expr::ArraySubscript { target, index } => {
15731            substitute_expr(target, params)?;
15732            substitute_expr(index, params)?;
15733        }
15734        Expr::AnyAll { expr, array, .. } => {
15735            substitute_expr(expr, params)?;
15736            substitute_expr(array, params)?;
15737        }
15738        Expr::InList { expr, list, .. } => {
15739            substitute_expr(expr, params)?;
15740            for item in list {
15741                substitute_expr(item, params)?;
15742            }
15743        }
15744        Expr::Case {
15745            operand,
15746            branches,
15747            else_branch,
15748        } => {
15749            if let Some(o) = operand {
15750                substitute_expr(o, params)?;
15751            }
15752            for (w, t) in branches {
15753                substitute_expr(w, params)?;
15754                substitute_expr(t, params)?;
15755            }
15756            if let Some(e) = else_branch {
15757                substitute_expr(e, params)?;
15758            }
15759        }
15760    }
15761    Ok(())
15762}
15763
15764/// v6.1.1 — convert a runtime `Value` into the closest matching
15765/// `Literal` for the substitute walker. Lossless for the simple
15766/// scalars (Int / Float / Text / Bool); Numeric / Date / Timestamp
15767/// / Json / Interval render as their canonical text form so the
15768/// downstream coerce_value can re-parse against the target column
15769/// type. SQ8 / HalfVector cells are NOT expected as bind params;
15770/// pgwire's Bind decodes vector params to the f32 representation
15771/// before they reach this helper.
15772/// v6.2.0 — total ordering on `Value`s used by ANALYZE to sort a
15773/// column's non-NULL sample before histogram building. Cross-type
15774/// pairs (Int vs Float, Date vs Timestamp, …) compare via the
15775/// same widening the eval-side `compare` operator uses; everything
15776/// else (the genuinely-incompatible pairs) falls back to ordering
15777/// by canonical string form so the sort is still total + stable.
15778/// Vector / SQ8 / Half / Json / Numeric / Interval values reach
15779/// here only via the string-fallback path because vector columns
15780/// are filtered out upstream.
15781fn sort_values_for_histogram(a: &Value, b: &Value) -> core::cmp::Ordering {
15782    use core::cmp::Ordering;
15783    match (a, b) {
15784        (Value::SmallInt(a), Value::SmallInt(b)) => a.cmp(b),
15785        (Value::Int(a), Value::Int(b)) => a.cmp(b),
15786        (Value::BigInt(a), Value::BigInt(b)) => a.cmp(b),
15787        (Value::SmallInt(a), Value::Int(b)) => i32::from(*a).cmp(b),
15788        (Value::Int(a), Value::SmallInt(b)) => a.cmp(&i32::from(*b)),
15789        (Value::Int(a), Value::BigInt(b)) => i64::from(*a).cmp(b),
15790        (Value::BigInt(a), Value::Int(b)) => a.cmp(&i64::from(*b)),
15791        (Value::SmallInt(a), Value::BigInt(b)) => i64::from(*a).cmp(b),
15792        (Value::BigInt(a), Value::SmallInt(b)) => a.cmp(&i64::from(*b)),
15793        (Value::Float(a), Value::Float(b)) => a.partial_cmp(b).unwrap_or(Ordering::Equal),
15794        (Value::Text(a), Value::Text(b)) | (Value::Json(a), Value::Json(b)) => a.cmp(b),
15795        (Value::Bool(a), Value::Bool(b)) => a.cmp(b),
15796        (Value::Date(a), Value::Date(b)) => a.cmp(b),
15797        (Value::Timestamp(a), Value::Timestamp(b)) => a.cmp(b),
15798        // Mixed numeric/float — widen to f64 and compare.
15799        (Value::SmallInt(n), Value::Float(x)) => {
15800            (f64::from(*n)).partial_cmp(x).unwrap_or(Ordering::Equal)
15801        }
15802        (Value::Float(x), Value::SmallInt(n)) => {
15803            x.partial_cmp(&f64::from(*n)).unwrap_or(Ordering::Equal)
15804        }
15805        (Value::Int(n), Value::Float(x)) => {
15806            (f64::from(*n)).partial_cmp(x).unwrap_or(Ordering::Equal)
15807        }
15808        (Value::Float(x), Value::Int(n)) => {
15809            x.partial_cmp(&f64::from(*n)).unwrap_or(Ordering::Equal)
15810        }
15811        (Value::BigInt(n), Value::Float(x)) => {
15812            #[allow(clippy::cast_precision_loss)]
15813            let nf = *n as f64;
15814            nf.partial_cmp(x).unwrap_or(Ordering::Equal)
15815        }
15816        (Value::Float(x), Value::BigInt(n)) => {
15817            #[allow(clippy::cast_precision_loss)]
15818            let nf = *n as f64;
15819            x.partial_cmp(&nf).unwrap_or(Ordering::Equal)
15820        }
15821        // Cross-type fallback: lexicographic on canonical form.
15822        // Total + stable so the sort is well-defined.
15823        _ => canonical_value_repr(a).cmp(&canonical_value_repr(b)),
15824    }
15825}
15826
15827/// v6.2.0 — render the histogram bounds list as a `[v0, v1, ...]`
15828/// string for the `spg_statistic.histogram_bounds` column. Values
15829/// containing `,` or `[` / `]` are JSON-style escaped so the
15830/// rendering round-trips through a future parser; v6.2.0 only
15831/// uses the rendered form for human consumption, so the escaping
15832/// is conservative.
15833fn render_histogram_bounds(bounds: &[alloc::string::String]) -> alloc::string::String {
15834    let mut out = alloc::string::String::with_capacity(bounds.len() * 8 + 2);
15835    out.push('[');
15836    for (i, b) in bounds.iter().enumerate() {
15837        if i > 0 {
15838            out.push_str(", ");
15839        }
15840        let needs_quote = b.contains([',', '[', ']', '"']) || b.is_empty();
15841        if needs_quote {
15842            out.push('"');
15843            for ch in b.chars() {
15844                if ch == '"' || ch == '\\' {
15845                    out.push('\\');
15846                }
15847                out.push(ch);
15848            }
15849            out.push('"');
15850        } else {
15851            out.push_str(b);
15852        }
15853    }
15854    out.push(']');
15855    out
15856}
15857
15858/// v6.2.0 — canonical textual form of a `Value` for histogram
15859/// bound storage. Strings used by ANALYZE for sort + bound output.
15860/// INT / BIGINT → decimal; FLOAT → shortest-round-trip via
15861/// `{:?}`; TEXT pass-through; BOOL → `t` / `f`; DATE / TIMESTAMP →
15862/// the same form `format_date` / `format_timestamp` produce for
15863/// SQL Display. Vector / SQ8 / Half / Json / Numeric / Interval
15864/// reach this only via a non-Vector column (vector columns are
15865/// skipped upstream); they fall back to a Debug-derived form so
15866/// stats still serialise without crashing.
15867pub(crate) fn canonical_value_repr(v: &Value) -> alloc::string::String {
15868    match v {
15869        Value::Null => "NULL".to_string(),
15870        Value::SmallInt(n) => alloc::format!("{n}"),
15871        Value::Int(n) => alloc::format!("{n}"),
15872        Value::BigInt(n) => alloc::format!("{n}"),
15873        Value::Float(x) => alloc::format!("{x:?}"),
15874        Value::Text(s) | Value::Json(s) => s.clone(),
15875        Value::Bool(b) => if *b { "t" } else { "f" }.to_string(),
15876        Value::Date(d) => eval::format_date(*d),
15877        Value::Timestamp(t) => eval::format_timestamp(*t),
15878        // v7.17.0 Phase 3.P0-32 — PG TIME canonical text form.
15879        Value::Time(us) => eval::format_time(*us),
15880        // v7.17.0 Phase 3.P0-33 — MySQL YEAR 4-digit zero-padded.
15881        Value::Year(y) => alloc::format!("{y:04}"),
15882        // v7.17.0 Phase 3.P0-34 — PG TIMETZ canonical text form.
15883        Value::TimeTz { us, offset_secs } => eval::format_timetz(*us, *offset_secs),
15884        // v7.17.0 Phase 3.P0-35 — PG MONEY canonical en_US text form.
15885        Value::Money(c) => eval::format_money(*c),
15886        // v7.17.0 Phase 3.P0-38 — PG range canonical text form.
15887        v @ Value::Range { .. } => format_range_str(v),
15888        // v7.17.0 Phase 3.P0-39 — PG hstore canonical text form.
15889        Value::Hstore(pairs) => format_hstore_str(pairs),
15890        // v7.17.0 Phase 3.P0-40 — 2D array canonical text form.
15891        Value::IntArray2D(rows) => format_int_2d_text(rows),
15892        Value::BigIntArray2D(rows) => format_bigint_2d_text(rows),
15893        Value::TextArray2D(rows) => format_text_2d_text(rows),
15894        Value::Interval { months, micros } => eval::format_interval(*months, *micros),
15895        Value::Numeric { scaled, scale } => eval::format_numeric(*scaled, *scale),
15896        Value::Vector(_) | Value::Sq8Vector(_) | Value::HalfVector(_) => {
15897            // Unreachable in practice (vector columns are filtered
15898            // out before this). Defensive fallback so a future
15899            // vector-stats path doesn't crash.
15900            alloc::format!("{v:?}")
15901        }
15902        // v7.5.0 — Value is #[non_exhaustive] for downstream
15903        // forward-compat. Future variants fall through to Debug
15904        // form here (same shape as the vector fallback above).
15905        _ => alloc::format!("{v:?}"),
15906    }
15907}
15908
15909/// v6.2.0 — true for engine-managed catalog tables that the bare
15910/// `ANALYZE` (no target) should skip. v6.2.0 has no internal
15911/// tables yet (publications / subscriptions / users / statistics
15912/// all live as engine fields, not catalog tables), so this is a
15913/// reserved future-proofing hook — every existing user table is
15914/// analysed.
15915const fn is_internal_table_name(_name: &str) -> bool {
15916    false
15917}
15918
15919fn value_to_literal(v: Value) -> Literal {
15920    match v {
15921        Value::Null => Literal::Null,
15922        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
15923        Value::Int(n) => Literal::Integer(i64::from(n)),
15924        Value::BigInt(n) => Literal::Integer(n),
15925        Value::Float(x) => Literal::Float(x),
15926        Value::Text(s) | Value::Json(s) => Literal::String(s),
15927        Value::Bool(b) => Literal::Bool(b),
15928        Value::Vector(v) => Literal::Vector(v),
15929        Value::Numeric { scaled, scale } => Literal::String(eval::format_numeric(scaled, scale)),
15930        Value::Date(d) => Literal::String(eval::format_date(d)),
15931        Value::Timestamp(t) => Literal::String(eval::format_timestamp(t)),
15932        // v7.17.0 Phase 3.P0-69 — UUID round-trips via canonical
15933        // hyphenated text. Without this arm the fallback below
15934        // renders `Debug` form ("Uuid([85, …])") which the
15935        // engine's Text → Uuid coerce can't parse, breaking
15936        // prepared-bind round-trip from the spg-sqlx adapter.
15937        Value::Uuid(b) => Literal::String(spg_storage::format_uuid(&b)),
15938        // v7.16.0 — BYTEA round-trip for the spg-sqlx Bind path.
15939        // PG-canonical text rep is `\x` + lowercase hex; the
15940        // engine's coerce_value already accepts that on the
15941        // text → bytea direction.
15942        Value::Bytes(b) => Literal::String(eval::format_bytea_hex(&b)),
15943        // Arrays ride the AST natively (mailrs embed round-12) —
15944        // the prior `{a,b,c}` text form only worked where a column
15945        // type drove the re-parse; `= ANY($1)` has no column
15946        // context and saw a bare Text value.
15947        Value::TextArray(items) => Literal::TextArray(items),
15948        Value::IntArray(items) => Literal::IntArray(items),
15949        Value::BigIntArray(items) => Literal::BigIntArray(items),
15950        Value::Interval { months, micros } => Literal::Interval {
15951            months,
15952            micros,
15953            text: eval::format_interval(months, micros),
15954        },
15955        // SQ8 / halfvec cells dequantise to f32 before reaching the
15956        // substitute walker; pgwire's Bind path handles that.
15957        Value::Sq8Vector(q) => Literal::Vector(spg_storage::quantize::dequantize(&q)),
15958        Value::HalfVector(h) => Literal::Vector(h.to_f32_vec()),
15959        // v7.5.0 — Value is #[non_exhaustive]; future variants
15960        // render as Debug-form String literal until explicit
15961        // mapping is added.
15962        v => Literal::String(alloc::format!("{v:?}")),
15963    }
15964}
15965
15966fn rewrite_clock_calls(stmt: &mut Statement, now_micros: Option<i64>) {
15967    let Some(now) = now_micros else {
15968        return;
15969    };
15970    match stmt {
15971        Statement::Select(s) => rewrite_select_clock(s, now),
15972        Statement::Insert(ins) => {
15973            for row in &mut ins.rows {
15974                for e in row {
15975                    rewrite_expr_clock(e, now);
15976                }
15977            }
15978            // `ON CONFLICT … DO UPDATE SET created_at = NOW()` —
15979            // the upsert assignments carry clock calls too (mailrs
15980            // embed round-12).
15981            if let Some(clause) = &mut ins.on_conflict
15982                && let spg_sql::ast::OnConflictAction::Update {
15983                    assignments,
15984                    where_,
15985                } = &mut clause.action
15986            {
15987                for (_, e) in assignments.iter_mut() {
15988                    rewrite_expr_clock(e, now);
15989                }
15990                if let Some(w) = where_ {
15991                    rewrite_expr_clock(w, now);
15992                }
15993            }
15994        }
15995        // `UPDATE … SET seen_at = NOW() WHERE …` / `DELETE … WHERE
15996        // ts < NOW()` (mailrs embed round-12 — previously only
15997        // SELECT / INSERT-rows were walked).
15998        Statement::Update(u) => {
15999            for (_, e) in &mut u.assignments {
16000                rewrite_expr_clock(e, now);
16001            }
16002            if let Some(w) = &mut u.where_ {
16003                rewrite_expr_clock(w, now);
16004            }
16005        }
16006        Statement::Delete(d) => {
16007            if let Some(w) = &mut d.where_ {
16008                rewrite_expr_clock(w, now);
16009            }
16010        }
16011        _ => {}
16012    }
16013}
16014
16015fn rewrite_select_clock(s: &mut SelectStatement, now: i64) {
16016    // v7.25.1 (round-18) — shared traversal: CTE bodies, LATERAL
16017    // subqueries, JOIN ON, and UNION peers all get the clock
16018    // rewrite (NOW() inside a CTE previously survived to eval as
16019    // "unknown function `now`").
16020    let _ = walk_select_exprs_mut(s, &mut |e| {
16021        rewrite_expr_clock(e, now);
16022        Ok(())
16023    });
16024}
16025
16026/// v3.0.3 hot path: every recursion lands in exactly one `match` arm.
16027/// Literal / Column-with-qualifier (the dominant cases on a typical
16028/// AST) take a single pattern dispatch and exit. The clock-rewrite
16029/// targets (zero-arg `NOW` / `CURRENT_TIMESTAMP` / `CURRENT_DATE`
16030/// functions, and bare `CURRENT_TIMESTAMP` / `CURRENT_DATE` column
16031/// refs) sit on their own arms with match guards so the fall-through
16032/// to the recursive arms is unambiguous.
16033fn rewrite_expr_clock(e: &mut Expr, now: i64) {
16034    // Fast-path test on the no-recursion shapes first. We can't fold
16035    // them into the big match below because they need to *replace* `e`
16036    // outright; the recursive arms below match on its sub-fields.
16037    if let Some(replacement) = clock_replacement_for(e, now) {
16038        *e = replacement;
16039        return;
16040    }
16041    match e {
16042        Expr::AggregateOrdered { call, order_by, .. } => {
16043            rewrite_expr_clock(call, now);
16044            for o in order_by.iter_mut() {
16045                rewrite_expr_clock(&mut o.expr, now);
16046            }
16047        }
16048        Expr::Binary { lhs, rhs, .. } => {
16049            rewrite_expr_clock(lhs, now);
16050            rewrite_expr_clock(rhs, now);
16051        }
16052        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
16053            rewrite_expr_clock(expr, now);
16054        }
16055        Expr::FunctionCall { args, .. } => {
16056            for a in args {
16057                rewrite_expr_clock(a, now);
16058            }
16059        }
16060        Expr::Like { expr, pattern, .. } => {
16061            rewrite_expr_clock(expr, now);
16062            rewrite_expr_clock(pattern, now);
16063        }
16064        Expr::Extract { source, .. } => rewrite_expr_clock(source, now),
16065        // v4.10 subquery nodes — recurse into the inner SELECT's
16066        // expression slots so e.g. SELECT NOW() in a scalar
16067        // subquery picks up the same instant as the outer query.
16068        Expr::ScalarSubquery(s) => rewrite_select_clock(s, now),
16069        Expr::Exists { subquery, .. } => rewrite_select_clock(subquery, now),
16070        Expr::InSubquery { expr, subquery, .. } => {
16071            rewrite_expr_clock(expr, now);
16072            rewrite_select_clock(subquery, now);
16073        }
16074        // v4.12 window functions — args + PARTITION BY + ORDER BY
16075        // may all reference clock literals.
16076        Expr::WindowFunction {
16077            args,
16078            partition_by,
16079            order_by,
16080            ..
16081        } => {
16082            for a in args {
16083                rewrite_expr_clock(a, now);
16084            }
16085            for p in partition_by {
16086                rewrite_expr_clock(p, now);
16087            }
16088            for (e, _, _) in order_by {
16089                rewrite_expr_clock(e, now);
16090            }
16091        }
16092        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => {}
16093        Expr::Array(items) => {
16094            for elem in items {
16095                rewrite_expr_clock(elem, now);
16096            }
16097        }
16098        Expr::ArraySubscript { target, index } => {
16099            rewrite_expr_clock(target, now);
16100            rewrite_expr_clock(index, now);
16101        }
16102        Expr::AnyAll { expr, array, .. } => {
16103            rewrite_expr_clock(expr, now);
16104            rewrite_expr_clock(array, now);
16105        }
16106        Expr::InList { expr, list, .. } => {
16107            rewrite_expr_clock(expr, now);
16108            for item in list {
16109                rewrite_expr_clock(item, now);
16110            }
16111        }
16112        Expr::Case {
16113            operand,
16114            branches,
16115            else_branch,
16116        } => {
16117            if let Some(o) = operand {
16118                rewrite_expr_clock(o, now);
16119            }
16120            for (w, t) in branches {
16121                rewrite_expr_clock(w, now);
16122                rewrite_expr_clock(t, now);
16123            }
16124            if let Some(e) = else_branch {
16125                rewrite_expr_clock(e, now);
16126            }
16127        }
16128    }
16129}
16130
16131/// Returns `Some(Expr)` when `e` is one of the clock-call shapes that
16132/// must be rewritten; otherwise `None` so the caller falls through to
16133/// the recursive walk. Identifies both function-call forms (`NOW()` /
16134/// `CURRENT_TIMESTAMP()` / `CURRENT_DATE()`) and bare-identifier forms
16135/// (`CURRENT_TIMESTAMP` / `CURRENT_DATE` as unqualified column refs,
16136/// which is how PG accepts them without parens).
16137fn clock_replacement_for(e: &Expr, now: i64) -> Option<Expr> {
16138    let (kind, name) = match e {
16139        Expr::FunctionCall { name, args } if args.is_empty() => (ClockSite::Fn, name.as_str()),
16140        Expr::Column(c) if c.qualifier.is_none() => (ClockSite::BareIdent, c.name.as_str()),
16141        _ => return None,
16142    };
16143    // ASCII case-insensitive name match. Each entry decides what
16144    // synthetic literal the call expands to.
16145    //
16146    // v7.17.0 Phase 3.P0-29 — `unix_timestamp` (no args) joins this
16147    // table as MySQL's epoch-seconds equivalent of `now()`. Folded
16148    // to a BigInt literal here so apply_function never needs a
16149    // clock dependency.
16150    enum ClockShape {
16151        Timestamp,
16152        Date,
16153        UnixSeconds,
16154    }
16155    let shape = match name.len() {
16156        3 if kind == ClockSite::Fn && name.eq_ignore_ascii_case("now") => {
16157            Some(ClockShape::Timestamp)
16158        }
16159        12 if name.eq_ignore_ascii_case("current_date") => Some(ClockShape::Date),
16160        14 if kind == ClockSite::Fn && name.eq_ignore_ascii_case("unix_timestamp") => {
16161            Some(ClockShape::UnixSeconds)
16162        }
16163        17 if name.eq_ignore_ascii_case("current_timestamp") => Some(ClockShape::Timestamp),
16164        _ => None,
16165    };
16166    let shape = shape?;
16167    let payload = match shape {
16168        ClockShape::Timestamp => now,
16169        ClockShape::Date => now.div_euclid(86_400_000_000),
16170        ClockShape::UnixSeconds => now.div_euclid(1_000_000),
16171    };
16172    let target = match shape {
16173        ClockShape::Timestamp => spg_sql::ast::CastTarget::Timestamp,
16174        ClockShape::Date => spg_sql::ast::CastTarget::Date,
16175        ClockShape::UnixSeconds => spg_sql::ast::CastTarget::BigInt,
16176    };
16177    Some(Expr::Cast {
16178        expr: alloc::boxed::Box::new(Expr::Literal(spg_sql::ast::Literal::Integer(payload))),
16179        target,
16180    })
16181}
16182
16183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16184enum ClockSite {
16185    Fn,
16186    BareIdent,
16187}
16188
16189/// `ORDER BY <integer>` references the N-th SELECT item (1-based).
16190/// Swap the integer literal for the matching item's expression so the
16191/// executor doesn't need a special-case branch. Recurses into UNION
16192/// peers because each peer keeps its own SELECT list.
16193/// v6.4.1 — expand `GROUP BY ALL` to every non-aggregate SELECT-list
16194/// item. Mirrors DuckDB / PG 19 semantics. Wildcards (`SELECT * …`)
16195/// are NOT expanded by GROUP BY ALL (PG 19 leaves the wildcard intact
16196/// and groups by whatever explicit non-aggregates remain — none in
16197/// the wildcard-only case, which still works for non-aggregate
16198/// queries).
16199fn expand_group_by_all(s: &mut SelectStatement) {
16200    if !s.group_by_all {
16201        for (_, peer) in &mut s.unions {
16202            expand_group_by_all(peer);
16203        }
16204        return;
16205    }
16206    let mut groups: Vec<Expr> = Vec::new();
16207    for item in &s.items {
16208        if let SelectItem::Expr { expr, .. } = item
16209            && !aggregate::contains_aggregate(expr)
16210        {
16211            groups.push(expr.clone());
16212        }
16213    }
16214    s.group_by = Some(groups);
16215    s.group_by_all = false;
16216    for (_, peer) in &mut s.unions {
16217        expand_group_by_all(peer);
16218    }
16219}
16220
16221fn resolve_order_by_position(s: &mut SelectStatement) {
16222    // v6.4.0 — iterate every ORDER BY key. Position references
16223    // (`ORDER BY 2`) bind to the 1-based projection index;
16224    // identifier references that match a SELECT-list alias bind to
16225    // the projected expression (Step 4 of L3a).
16226    for order in &mut s.order_by {
16227        match &order.expr {
16228            Expr::Literal(Literal::Integer(n)) if *n >= 1 => {
16229                if let Ok(idx_one_based) = usize::try_from(*n) {
16230                    let idx = idx_one_based - 1;
16231                    if idx < s.items.len()
16232                        && let SelectItem::Expr { expr, .. } = &s.items[idx]
16233                    {
16234                        order.expr = expr.clone();
16235                    }
16236                }
16237            }
16238            Expr::Column(c) if c.qualifier.is_none() => {
16239                // Alias-in-ORDER-BY lookup.
16240                for item in &s.items {
16241                    if let SelectItem::Expr {
16242                        expr,
16243                        alias: Some(a),
16244                    } = item
16245                        && a == &c.name
16246                    {
16247                        order.expr = expr.clone();
16248                        break;
16249                    }
16250                }
16251            }
16252            _ => {}
16253        }
16254    }
16255    for (_, peer) in &mut s.unions {
16256        resolve_order_by_position(peer);
16257    }
16258}
16259
16260/// Sort `tagged` by `f64` key, reversing the comparator under DESC.
16261/// Used by the UNION ORDER BY path; per-block paths inline the same
16262/// comparator because they already hold `&OrderBy` directly.
16263/// v3.1.1: partial-sort helper. When `keep` (= offset + limit) is
16264/// strictly less than `tagged.len()`, run `select_nth_unstable_by` to
16265/// partition the prefix in O(n), then sort just that prefix in O(k
16266/// log k). Total O(n + k log k), vs O(n log n) for a full sort. The
16267/// caller decides what `keep` is; passing `None` (no LIMIT) keeps the
16268/// full-sort behaviour.
16269///
16270/// `tagged` holds `(Option<f64>, Row)` (the SELECT path) — `None` keys
16271/// sort last in ascending order, mirroring NULL-sorts-last in SQL.
16272fn partial_sort_tagged(tagged: &mut Vec<(Vec<f64>, Row)>, keep: Option<usize>, descs: &[bool]) {
16273    let cmp = |a: &(Vec<f64>, Row), b: &(Vec<f64>, Row)| cmp_multi_key(&a.0, &b.0, descs);
16274    match keep {
16275        Some(k) if k < tagged.len() && k > 0 => {
16276            let pivot = k - 1;
16277            tagged.select_nth_unstable_by(pivot, cmp);
16278            tagged[..k].sort_by(cmp);
16279            tagged.truncate(k);
16280        }
16281        _ => {
16282            tagged.sort_by(cmp);
16283        }
16284    }
16285}
16286
16287fn sort_by_keys(tagged: &mut [(Vec<f64>, Row)], descs: &[bool]) {
16288    tagged.sort_by(|a, b| cmp_multi_key(&a.0, &b.0, descs));
16289}
16290
16291/// v6.4.0 — multi-key ORDER BY comparator. Each key's per-key DESC
16292/// flag is honored independently. NULL is encoded as `f64::INFINITY`
16293/// so it sorts last in ASC and first in DESC (matches PG default).
16294fn cmp_multi_key(a: &[f64], b: &[f64], descs: &[bool]) -> core::cmp::Ordering {
16295    use core::cmp::Ordering;
16296    for (i, (ka, kb)) in a.iter().zip(b.iter()).enumerate() {
16297        let ord = ka.partial_cmp(kb).unwrap_or(Ordering::Equal);
16298        let ord = if descs.get(i).copied().unwrap_or(false) {
16299            ord.reverse()
16300        } else {
16301            ord
16302        };
16303        if ord != Ordering::Equal {
16304            return ord;
16305        }
16306    }
16307    Ordering::Equal
16308}
16309
16310/// v6.4.0 — eval every ORDER BY expression for a row and pack the
16311/// resulting keys into a `Vec<f64>`. NULL → `f64::INFINITY`.
16312fn build_order_keys(
16313    order_by: &[OrderBy],
16314    row: &Row,
16315    ctx: &EvalContext,
16316) -> Result<Vec<f64>, EngineError> {
16317    let mut keys = Vec::with_capacity(order_by.len());
16318    for o in order_by {
16319        let v = eval::eval_expr(&o.expr, row, ctx)?;
16320        // v7.24 (round-16 A) — explicit NULLS FIRST/LAST. The f64
16321        // packing sorts ascending THEN applies the per-key DESC
16322        // reverse, so a NULL must land at +INF exactly when the
16323        // effective placement agrees with the reverse direction:
16324        // nf == desc → +INF (ASC default last / DESC default
16325        // first), nf != desc → -INF (the explicit flips).
16326        if matches!(v, Value::Null) {
16327            let nf = o.nulls_first.unwrap_or(o.desc);
16328            keys.push(if nf == o.desc {
16329                f64::INFINITY
16330            } else {
16331                f64::NEG_INFINITY
16332            });
16333        } else {
16334            keys.push(value_to_order_key(&v)?);
16335        }
16336    }
16337    Ok(keys)
16338}
16339
16340/// Drop the first `offset` rows then truncate to `limit`. PG / `MySQL`
16341/// agree: OFFSET applies *after* ORDER BY but *before* LIMIT (so
16342/// `LIMIT 10 OFFSET 5` keeps rows 6..=15).
16343fn apply_offset_and_limit(rows: &mut Vec<Row>, offset: Option<u32>, limit: Option<u32>) {
16344    if let Some(off) = offset {
16345        let off = off as usize;
16346        if off >= rows.len() {
16347            rows.clear();
16348        } else {
16349            rows.drain(..off);
16350        }
16351    }
16352    if let Some(n) = limit {
16353        rows.truncate(n as usize);
16354    }
16355}
16356
16357/// v7.17.0 Phase 3.P0-49 — offset + limit applied to a tagged
16358/// `(order_keys, row)` sequence, with optional SQL:2008 `WITH
16359/// TIES` extension. When `with_ties` is set, the truncated tail
16360/// is extended through every subsequent row whose order keys
16361/// equal the last-kept row's keys (so a "top 3 by score" with
16362/// WITH TIES emits row 4 too when row 4 ties row 3 on `score`).
16363///
16364/// The order-key vector is the per-row sort key the caller already
16365/// computed via `build_order_keys`; equal-key detection therefore
16366/// matches the sort comparator exactly.
16367fn apply_offset_and_limit_tagged(
16368    tagged: &mut Vec<(Vec<f64>, Row)>,
16369    offset: Option<u32>,
16370    limit: Option<u32>,
16371    with_ties: bool,
16372) {
16373    if let Some(off) = offset {
16374        let off = off as usize;
16375        if off >= tagged.len() {
16376            tagged.clear();
16377        } else {
16378            tagged.drain(..off);
16379        }
16380    }
16381    if let Some(n) = limit {
16382        let n = n as usize;
16383        if with_ties && n > 0 && n < tagged.len() {
16384            let cutoff_key = tagged[n - 1].0.clone();
16385            let mut end = n;
16386            while end < tagged.len() && tagged[end].0 == cutoff_key {
16387                end += 1;
16388            }
16389            tagged.truncate(end);
16390        } else {
16391            tagged.truncate(n);
16392        }
16393    }
16394}
16395
16396/// v7.17.0 Phase 3.P0-49 — PG-canonical: `FETCH FIRST <n> ROWS
16397/// WITH TIES` requires an `ORDER BY`. Without one, there's no
16398/// way to identify "ties" deterministically, so PG errors at
16399/// plan time. SPG mirrors that surface so the same DDL / app
16400/// behaviour holds on cutover.
16401fn check_with_ties_requires_order_by(stmt: &SelectStatement) -> Result<(), EngineError> {
16402    if stmt.limit_with_ties && stmt.order_by.is_empty() {
16403        return Err(EngineError::Unsupported(alloc::string::String::from(
16404            "FETCH FIRST … ROWS WITH TIES requires an ORDER BY clause",
16405        )));
16406    }
16407    Ok(())
16408}
16409
16410/// v7.6.1 — resolve a parser-level `ForeignKeyConstraint` (column
16411/// names + parent table name) into the storage-layer shape (column
16412/// indices + same parent table). Validates everything the engine
16413/// needs to know about the FK at CREATE TABLE time:
16414///
16415///   - parent table exists (catalog lookup, unless self-referencing)
16416///   - parent columns exist on the parent table
16417///   - parent column list matches the local arity (defaults to the
16418///     parent's primary index column when omitted)
16419///   - parent columns are covered by a `BTree` UNIQUE-class index
16420///     (SPG's stand-in for `PRIMARY KEY`/`UNIQUE`) — required so
16421///     the v7.6.2 INSERT path can do an O(log n) parent lookup
16422///   - local columns exist on the table being created
16423fn resolve_foreign_key(
16424    local_table_name: &str,
16425    local_cols: &[ColumnSchema],
16426    fk: spg_sql::ast::ForeignKeyConstraint,
16427    catalog: &Catalog,
16428) -> Result<spg_storage::ForeignKeyConstraint, EngineError> {
16429    // Resolve local columns.
16430    let mut local_columns = Vec::with_capacity(fk.columns.len());
16431    for name in &fk.columns {
16432        let pos = local_cols
16433            .iter()
16434            .position(|c| c.name == *name)
16435            .ok_or_else(|| {
16436                EngineError::Unsupported(alloc::format!(
16437                    "FOREIGN KEY references unknown local column {name:?}"
16438                ))
16439            })?;
16440        local_columns.push(pos);
16441    }
16442    // Self-referencing FK: parent table is the one we're creating.
16443    // The parent column resolution uses the local column list since
16444    // the catalog doesn't have this table yet.
16445    let is_self_ref = fk.parent_table == local_table_name;
16446    let (parent_cols_for_lookup, parent_table_str): (&[ColumnSchema], &str) = if is_self_ref {
16447        (local_cols, local_table_name)
16448    } else {
16449        let parent_table = catalog.get(&fk.parent_table).ok_or_else(|| {
16450            EngineError::Storage(StorageError::TableNotFound {
16451                name: fk.parent_table.clone(),
16452            })
16453        })?;
16454        (
16455            parent_table.schema().columns.as_slice(),
16456            fk.parent_table.as_str(),
16457        )
16458    };
16459    // Resolve parent column names → positions. If the FK omitted the
16460    // parent column list, fall back to the parent's primary index
16461    // column (single-column only — composite default is rejected
16462    // because there's no unambiguous "PK" in SPG's index list).
16463    let parent_columns: Vec<usize> = if fk.parent_columns.is_empty() {
16464        if fk.columns.len() != 1 {
16465            return Err(EngineError::Unsupported(
16466                "composite FOREIGN KEY without explicit parent column list is not supported \
16467                 — list the parent columns explicitly"
16468                    .into(),
16469            ));
16470        }
16471        // Find a single BTree index on the parent and use its column.
16472        let pos = pick_pk_index_column(catalog, parent_table_str, is_self_ref, local_cols)
16473            .ok_or_else(|| {
16474                EngineError::Unsupported(alloc::format!(
16475                    "parent table {parent_table_str:?} has no PRIMARY-key / UNIQUE BTree index \
16476                     to default the FOREIGN KEY against"
16477                ))
16478            })?;
16479        alloc::vec![pos]
16480    } else {
16481        let mut out = Vec::with_capacity(fk.parent_columns.len());
16482        for name in &fk.parent_columns {
16483            let pos = parent_cols_for_lookup
16484                .iter()
16485                .position(|c| c.name == *name)
16486                .ok_or_else(|| {
16487                    EngineError::Unsupported(alloc::format!(
16488                        "FOREIGN KEY references unknown parent column \
16489                         {name:?} on table {parent_table_str:?}"
16490                    ))
16491                })?;
16492            out.push(pos);
16493        }
16494        out
16495    };
16496    if parent_columns.len() != local_columns.len() {
16497        return Err(EngineError::Unsupported(alloc::format!(
16498            "FOREIGN KEY arity mismatch: {} local columns vs {} parent columns",
16499            local_columns.len(),
16500            parent_columns.len()
16501        )));
16502    }
16503    // For non-self-referencing FKs, verify the parent column set is
16504    // covered by a BTree index. SPG doesn't have a `PRIMARY KEY`
16505    // declaration; the convention is "the parent column for FK
16506    // purposes must have a BTree index" — which the user creates via
16507    // `CREATE INDEX ... USING btree (col)` (the default). We accept
16508    // any single-column BTree index that covers a parent column;
16509    // composite parent column lists require an index whose `column_position`
16510    // matches the first parent column (multi-column BTree indices
16511    // are not in the v7.x roadmap).
16512    if !is_self_ref {
16513        let parent_table = catalog.get(&fk.parent_table).expect("checked above");
16514        let primary_parent_col = parent_columns[0];
16515        let has_btree = parent_table
16516            .schema()
16517            .columns
16518            .get(primary_parent_col)
16519            .is_some()
16520            && parent_table.indices().iter().any(|idx| {
16521                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
16522                    && idx.column_position == primary_parent_col
16523                    && idx.partial_predicate.is_none()
16524            });
16525        if !has_btree {
16526            return Err(EngineError::Unsupported(alloc::format!(
16527                "FOREIGN KEY parent column on {:?} is not covered by an unconditional BTree \
16528                 index — create one with `CREATE INDEX ... ON {} ({})` first",
16529                parent_table_str,
16530                parent_table_str,
16531                parent_table.schema().columns[primary_parent_col].name,
16532            )));
16533        }
16534    }
16535    let on_delete = fk_action_sql_to_storage(fk.on_delete);
16536    let on_update = fk_action_sql_to_storage(fk.on_update);
16537    Ok(spg_storage::ForeignKeyConstraint {
16538        name: fk.name,
16539        local_columns,
16540        parent_table: fk.parent_table,
16541        parent_columns,
16542        on_delete,
16543        on_update,
16544    })
16545}
16546
16547/// v7.6.1 — pick a sentinel "primary key" column from the parent
16548/// table when the FK didn't name parent columns. Picks the first
16549/// single-column unconditional BTree index — that's the closest
16550/// thing SPG has to a PRIMARY KEY today. Self-referencing FKs use
16551/// `local_cols` as the column source.
16552fn pick_pk_index_column(
16553    catalog: &Catalog,
16554    parent_name: &str,
16555    is_self_ref: bool,
16556    local_cols: &[ColumnSchema],
16557) -> Option<usize> {
16558    if is_self_ref {
16559        // Self-ref FK omitted parent columns: pick column 0 by
16560        // convention (no catalog entry yet). Engine will widen this
16561        // when v7.6.7 lands; v7.6.1 only handles the explicit form.
16562        let _ = local_cols;
16563        return Some(0);
16564    }
16565    let parent = catalog.get(parent_name)?;
16566    parent.indices().iter().find_map(|idx| {
16567        if matches!(idx.kind, spg_storage::IndexKind::BTree(_))
16568            && idx.partial_predicate.is_none()
16569            && idx.included_columns.is_empty()
16570            && idx.expression.is_none()
16571        {
16572            Some(idx.column_position)
16573        } else {
16574            None
16575        }
16576    })
16577}
16578
16579/// v7.9.8 / v7.9.10 — resolve the column positions that
16580/// identify a conflict for ON CONFLICT. Returns a Vec of
16581/// column positions (1 element for single-column form, N for
16582/// composite). When the user wrote bare `ON CONFLICT DO …`,
16583/// falls back to the table's first unconditional BTree index
16584/// (always single-column today).
16585/// Returns the conflict-key column positions plus whether the
16586/// matched constraint declares NULLS NOT DISTINCT (v7.29 — a NULL
16587/// in the key only rules out a conflict under the default
16588/// NULLS DISTINCT semantics).
16589fn resolve_on_conflict_columns(
16590    catalog: &Catalog,
16591    table_name: &str,
16592    target: &[String],
16593) -> Result<(Vec<usize>, bool), EngineError> {
16594    let table = catalog.get(table_name).ok_or_else(|| {
16595        EngineError::Storage(StorageError::TableNotFound {
16596            name: table_name.into(),
16597        })
16598    })?;
16599    if target.is_empty() {
16600        // v7.13.2 — mailrs round-6 S5 follow-up. Composite UNIQUE
16601        // constraints carry a multi-column tuple; the prior code
16602        // path picked only the leading column of the first BTree
16603        // index, which caused `ON CONFLICT DO NOTHING` to dedup
16604        // by leading column alone (3 rows with same group_id but
16605        // different permission collapsed to 1). PG semantics use
16606        // the full tuple. Prefer a UniquenessConstraint's full
16607        // column list when one exists; fall back to the leading
16608        // BTree column for legacy single-column UNIQUE.
16609        if let Some(uc) = table.schema().uniqueness_constraints.first() {
16610            return Ok((uc.columns.clone(), uc.nulls_not_distinct));
16611        }
16612        let pos = table
16613            .indices()
16614            .iter()
16615            .find_map(|idx| {
16616                if matches!(idx.kind, spg_storage::IndexKind::BTree(_))
16617                    && idx.partial_predicate.is_none()
16618                    && idx.included_columns.is_empty()
16619                    && idx.expression.is_none()
16620                {
16621                    Some(idx.column_position)
16622                } else {
16623                    None
16624                }
16625            })
16626            .ok_or_else(|| {
16627                EngineError::Unsupported(alloc::format!(
16628                    "ON CONFLICT without target requires a UNIQUE BTree index on {table_name:?}"
16629                ))
16630            })?;
16631        return Ok((alloc::vec![pos], false));
16632    }
16633    let mut out = Vec::with_capacity(target.len());
16634    for name in target {
16635        let pos = table
16636            .schema()
16637            .columns
16638            .iter()
16639            .position(|c| c.name == *name)
16640            .ok_or_else(|| {
16641                EngineError::Unsupported(alloc::format!(
16642                    "ON CONFLICT target column {name:?} not found on {table_name:?}"
16643                ))
16644            })?;
16645        out.push(pos);
16646    }
16647    // An explicit target matching a UNIQUE constraint inherits its
16648    // NULLS [NOT] DISTINCT declaration.
16649    let mut sorted = out.clone();
16650    sorted.sort_unstable();
16651    let nnd = table.schema().uniqueness_constraints.iter().any(|uc| {
16652        let mut u = uc.columns.clone();
16653        u.sort_unstable();
16654        u == sorted && uc.nulls_not_distinct
16655    });
16656    Ok((out, nnd))
16657}
16658
16659/// v7.9.8 — check whether the BTree index on `column_pos` of
16660/// `table_name` already has a row with this key.
16661fn on_conflict_key_exists(
16662    catalog: &Catalog,
16663    table_name: &str,
16664    column_pos: usize,
16665    key: &Value,
16666) -> bool {
16667    let Some(table) = catalog.get(table_name) else {
16668        return false;
16669    };
16670    let Some(idx_key) = spg_storage::IndexKey::from_value(key) else {
16671        return false;
16672    };
16673    table.indices().iter().any(|idx| {
16674        matches!(idx.kind, spg_storage::IndexKind::BTree(_))
16675            && idx.column_position == column_pos
16676            && idx.partial_predicate.is_none()
16677            && !idx.lookup_eq(&idx_key).is_empty()
16678    })
16679}
16680
16681/// v7.9.9 / v7.9.10 — look up an existing row's position by
16682/// matching all `column_positions` against the incoming `key`
16683/// tuple. Single-column shape (one column) reduces to the
16684/// canonical PK lookup; composite shapes scan linearly until
16685/// every position matches.
16686fn lookup_row_position_by_keys(
16687    catalog: &Catalog,
16688    table_name: &str,
16689    column_positions: &[usize],
16690    key: &[&Value],
16691) -> Option<usize> {
16692    let table = catalog.get(table_name)?;
16693    table.rows().iter().position(|r| {
16694        column_positions
16695            .iter()
16696            .enumerate()
16697            .all(|(i, &pos)| r.values.get(pos) == Some(key[i]))
16698    })
16699}
16700
16701/// v7.9.10 — does the table already contain a row whose
16702/// `column_positions` tuple equals `key`? Single-column shape
16703/// uses the existing BTree fast path; composite shapes fall
16704/// back to a row scan.
16705fn on_conflict_keys_exist(
16706    catalog: &Catalog,
16707    table_name: &str,
16708    column_positions: &[usize],
16709    key: &[&Value],
16710) -> bool {
16711    if column_positions.len() == 1 {
16712        return on_conflict_key_exists(catalog, table_name, column_positions[0], key[0]);
16713    }
16714    let Some(table) = catalog.get(table_name) else {
16715        return false;
16716    };
16717    table.rows().iter().any(|r| {
16718        column_positions
16719            .iter()
16720            .enumerate()
16721            .all(|(i, &pos)| r.values.get(pos) == Some(key[i]))
16722    })
16723}
16724
16725/// v7.9.9 — apply ON CONFLICT DO UPDATE SET assignments to an
16726/// existing row.
16727///
16728/// `incoming` is the rejected INSERT row (used to resolve
16729/// `EXCLUDED.col` references in the assignment exprs);
16730/// `target_pos` is the position of the existing row in the table.
16731/// Each assignment substitutes `EXCLUDED.col` with the matching
16732/// incoming value, evaluates the resulting expression against
16733/// the existing row, and writes the new value into the
16734/// corresponding column of the returned `Vec<Value>`. If
16735/// `where_` evaluates falsy, returns Ok(None) — PG behaviour:
16736/// the conflicting row is silently kept unchanged.
16737fn apply_on_conflict_assignments(
16738    catalog: &Catalog,
16739    table_name: &str,
16740    target_pos: usize,
16741    incoming: &[Value],
16742    assignments: &[(String, Expr)],
16743    where_: Option<&Expr>,
16744) -> Result<Option<Vec<Value>>, EngineError> {
16745    let table = catalog.get(table_name).ok_or_else(|| {
16746        EngineError::Storage(StorageError::TableNotFound {
16747            name: table_name.into(),
16748        })
16749    })?;
16750    let schema_cols = table.schema().columns.clone();
16751    let existing = table
16752        .rows()
16753        .get(target_pos)
16754        .ok_or_else(|| {
16755            EngineError::Unsupported(alloc::format!(
16756                "ON CONFLICT DO UPDATE: row position {target_pos} out of bounds on {table_name:?}"
16757            ))
16758        })?
16759        .clone();
16760    let ctx = eval::EvalContext::new(&schema_cols, Some(table_name));
16761    // Optional WHERE filter on the conflict row.
16762    if let Some(w) = where_ {
16763        let pred = w.clone();
16764        let pred = substitute_excluded_refs(pred, &schema_cols, incoming);
16765        let v = eval::eval_expr(&pred, &existing, &ctx)?;
16766        if !matches!(v, Value::Bool(true)) {
16767            return Ok(None);
16768        }
16769    }
16770    let mut new_values = existing.values.clone();
16771    for (col_name, expr) in assignments {
16772        let target_idx = schema_cols
16773            .iter()
16774            .position(|c| c.name == *col_name)
16775            .ok_or_else(|| {
16776                EngineError::Eval(EvalError::ColumnNotFound {
16777                    name: col_name.clone(),
16778                })
16779            })?;
16780        let sub = substitute_excluded_refs(expr.clone(), &schema_cols, incoming);
16781        let v = eval::eval_expr(&sub, &existing, &ctx)?;
16782        let coerced = coerce_value(v, schema_cols[target_idx].ty, col_name, target_idx)?;
16783        check_unsigned_range(&coerced, &schema_cols[target_idx], target_idx)?;
16784        new_values[target_idx] = coerced;
16785    }
16786    Ok(Some(new_values))
16787}
16788
16789/// v7.9.9 — walk an `Expr` tree replacing any `Column { qualifier:
16790/// "EXCLUDED", name }` reference with a `Literal` of the matching
16791/// value from the incoming-row vec. Resolution against the
16792/// child-table column list (by name).
16793fn substitute_excluded_refs(expr: Expr, schema_cols: &[ColumnSchema], incoming: &[Value]) -> Expr {
16794    use spg_sql::ast::ColumnName;
16795    match expr {
16796        Expr::Column(ColumnName { qualifier, name })
16797            if qualifier
16798                .as_deref()
16799                .is_some_and(|q| q.eq_ignore_ascii_case("excluded")) =>
16800        {
16801            let pos = schema_cols.iter().position(|c| c.name == name);
16802            match pos {
16803                Some(p) => {
16804                    let v = incoming.get(p).cloned().unwrap_or(Value::Null);
16805                    value_to_literal_expr(v)
16806                        .unwrap_or_else(|_| Expr::Literal(spg_sql::ast::Literal::Null))
16807                }
16808                None => Expr::Column(ColumnName { qualifier, name }),
16809            }
16810        }
16811        Expr::Binary { op, lhs, rhs } => Expr::Binary {
16812            op,
16813            lhs: Box::new(substitute_excluded_refs(*lhs, schema_cols, incoming)),
16814            rhs: Box::new(substitute_excluded_refs(*rhs, schema_cols, incoming)),
16815        },
16816        Expr::Unary { op, expr } => Expr::Unary {
16817            op,
16818            expr: Box::new(substitute_excluded_refs(*expr, schema_cols, incoming)),
16819        },
16820        Expr::FunctionCall { name, args } => Expr::FunctionCall {
16821            name,
16822            args: args
16823                .into_iter()
16824                .map(|a| substitute_excluded_refs(a, schema_cols, incoming))
16825                .collect(),
16826        },
16827        other => other,
16828    }
16829}
16830
16831/// v7.6.2 / v7.6.7 — INSERT-side FK enforcement. For every row
16832/// about to be inserted into `child_table`, every FK declared on
16833/// that table is checked: the row's FK columns must either be
16834/// NULL (SQL spec skip) or match an existing parent row via the
16835/// parent's BTree PK / UNIQUE index.
16836///
16837/// Returns `EngineError::Unsupported` with a `FOREIGN KEY violation`
16838/// payload on first failure.
16839///
16840/// **Self-referencing FKs (v7.6.7 widening):** when `fk.parent_table
16841/// == child_table`, the parent rows visible to this check are
16842///  (a) rows already committed to the table, plus
16843///  (b) earlier rows from the *same* `rows` batch.
16844/// This makes `INSERT INTO tree VALUES (1, NULL), (2, 1), (3, 2)`
16845/// work in a single statement — common pattern for bulk-loading
16846/// hierarchies.
16847/// v7.9.19 — enforce table-level UNIQUE / PRIMARY KEY tuple
16848/// constraints at INSERT time. For each constraint declared on
16849/// the target table, check that no existing row + no earlier row
16850/// in the same batch has the same full-column tuple. NULL in
16851/// any column lifts the row out of the check (SQL spec: NULL
16852/// ≠ NULL for uniqueness). mailrs G1 + G6.
16853fn enforce_uniqueness_inserts(
16854    catalog: &Catalog,
16855    child_table: &str,
16856    constraints: &[spg_storage::UniquenessConstraint],
16857    rows: &[Vec<Value>],
16858) -> Result<(), EngineError> {
16859    if constraints.is_empty() {
16860        return Ok(());
16861    }
16862    let table = catalog.get(child_table).ok_or_else(|| {
16863        EngineError::Storage(StorageError::TableNotFound {
16864            name: child_table.into(),
16865        })
16866    })?;
16867    let schema = table.schema();
16868    // v7.29 (mailrs round-23b) — set-based: ONE O(table) pass folds
16869    // existing keys into a hash set, then each batch row is a probe
16870    // + insert. The previous shape scanned the WHOLE table per
16871    // inserted row (and earlier batch rows per row), which made
16872    // bulk import O(n²) — a 104 MB dump extrapolated to ~1 hour
16873    // (PG: 2 min). Collation folding (Phase 3.P0-45) and
16874    // NULLS [NOT] DISTINCT semantics are unchanged: keys fold via
16875    // collated_key_cell before encoding, NULL-bearing keys skip the
16876    // set unless nulls_not_distinct.
16877    for uc in constraints {
16878        let fold_key = |values: &[Value]| -> Vec<Value> {
16879            uc.columns
16880                .iter()
16881                .map(|&i| {
16882                    let v = values.get(i).cloned().unwrap_or(Value::Null);
16883                    collated_key_cell(&v, i, schema)
16884                })
16885                .collect()
16886        };
16887        let mut seen: hashbrown::HashSet<String> =
16888            hashbrown::HashSet::with_capacity(table.rows().len() + rows.len());
16889        for prow in table.rows() {
16890            let key = fold_key(&prow.values);
16891            if key.iter().any(|v| matches!(v, Value::Null)) && !uc.nulls_not_distinct {
16892                continue;
16893            }
16894            seen.insert(aggregate::encode_key(&key));
16895        }
16896        for (batch_idx, row_values) in rows.iter().enumerate() {
16897            let key = fold_key(row_values);
16898            if key.iter().any(|v| matches!(v, Value::Null)) && !uc.nulls_not_distinct {
16899                continue;
16900            }
16901            if !seen.insert(aggregate::encode_key(&key)) {
16902                let kind = if uc.is_primary_key {
16903                    "PRIMARY KEY"
16904                } else {
16905                    "UNIQUE"
16906                };
16907                let col_names: Vec<String> = uc
16908                    .columns
16909                    .iter()
16910                    .map(|&i| table.schema().columns[i].name.clone())
16911                    .collect();
16912                return Err(EngineError::Unsupported(alloc::format!(
16913                    "{kind} violation on {child_table:?} columns {col_names:?}: \
16914                     row #{batch_idx} duplicates an existing key"
16915                )));
16916            }
16917        }
16918    }
16919    Ok(())
16920}
16921
16922/// v7.17.0 Phase 3.P0-45 — return a key cell folded by its column's
16923/// declared `Collation`. For `CaseInsensitive`, fold Text payloads to
16924/// ASCII lowercase (matches Phase 2.5's `*_ci` semantics: ASCII case-
16925/// fold only, non-ASCII bytes stay byte-wise). For `Binary` or non-Text
16926/// values, the cell passes through unchanged. The caller compares the
16927/// folded values with `==`.
16928fn collated_key_cell(
16929    v: &spg_storage::Value,
16930    column_position: usize,
16931    schema: &spg_storage::TableSchema,
16932) -> spg_storage::Value {
16933    match (v, schema.columns.get(column_position).map(|c| c.collation)) {
16934        (spg_storage::Value::Text(s), Some(spg_storage::Collation::CaseInsensitive)) => {
16935            spg_storage::Value::Text(s.to_ascii_lowercase())
16936        }
16937        _ => v.clone(),
16938    }
16939}
16940
16941/// v7.9.29 — `true` iff `v` counts as a truthy SQL value for a
16942/// WHERE-style predicate. NULL → false (three-valued logic
16943/// collapses to "skip this row" for index inclusion). Numeric
16944/// non-zero, BIGINT non-zero, TINYINT non-zero, BOOLEAN true → true.
16945/// Everything else (strings, vectors, JSON, …) is not a valid
16946/// predicate result and surfaces as `false` so a malformed
16947/// predicate degrades to "row not in index" rather than panicking.
16948fn predicate_truthy(v: &spg_storage::Value) -> bool {
16949    use spg_storage::Value as V;
16950    match v {
16951        V::Bool(b) => *b,
16952        V::Int(n) => *n != 0,
16953        V::BigInt(n) => *n != 0,
16954        V::SmallInt(n) => *n != 0,
16955        _ => false,
16956    }
16957}
16958
16959/// v7.9.29 — at CREATE UNIQUE INDEX time, scan the table's
16960/// committed rows for pre-existing duplicates. If any pair of rows
16961/// matches the predicate AND has the same index key, refuse to
16962/// create the index so the user fixes the data before retrying.
16963fn check_existing_unique_violation(
16964    idx: &spg_storage::Index,
16965    schema: &spg_storage::TableSchema,
16966    rows: &[spg_storage::Row],
16967) -> Result<(), EngineError> {
16968    let predicate_expr = match idx.partial_predicate.as_deref() {
16969        Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
16970            EngineError::Unsupported(alloc::format!(
16971                "stored partial predicate {s:?} failed to re-parse: {e:?}"
16972            ))
16973        })?),
16974        None => None,
16975    };
16976    let ctx = eval::EvalContext::new(&schema.columns, None);
16977    let key_positions = unique_key_positions(idx);
16978    let mut seen: alloc::vec::Vec<alloc::vec::Vec<spg_storage::Value>> = alloc::vec::Vec::new();
16979    for row in rows {
16980        if let Some(expr) = &predicate_expr {
16981            let v = eval::eval_expr(expr, row, &ctx).map_err(|e| {
16982                EngineError::Unsupported(alloc::format!(
16983                    "evaluating UNIQUE INDEX predicate against existing row: {e:?}"
16984                ))
16985            })?;
16986            if !predicate_truthy(&v) {
16987                continue;
16988            }
16989        }
16990        let key: alloc::vec::Vec<spg_storage::Value> = key_positions
16991            .iter()
16992            .map(|&p| {
16993                let v = row
16994                    .values
16995                    .get(p)
16996                    .cloned()
16997                    .unwrap_or(spg_storage::Value::Null);
16998                collated_key_cell(&v, p, schema)
16999            })
17000            .collect();
17001        if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
17002            continue;
17003        }
17004        if seen.iter().any(|other| *other == key) {
17005            return Err(EngineError::Unsupported(alloc::format!(
17006                "CREATE UNIQUE INDEX {:?}: existing rows already violate the constraint",
17007                idx.name
17008            )));
17009        }
17010        seen.push(key);
17011    }
17012    Ok(())
17013}
17014
17015/// v7.9.29 — full key tuple for a UNIQUE INDEX (leading +
17016/// extra positions). For single-column indexes this is just
17017/// `[column_position]`.
17018fn unique_key_positions(idx: &spg_storage::Index) -> alloc::vec::Vec<usize> {
17019    let mut out = alloc::vec::Vec::with_capacity(1 + idx.extra_column_positions.len());
17020    out.push(idx.column_position);
17021    out.extend_from_slice(&idx.extra_column_positions);
17022    out
17023}
17024
17025/// v7.9.29 — at INSERT time, walk every `is_unique` index on the
17026/// target table. For each, eval the index's optional predicate
17027/// against (a) the candidate row and (b) every committed row plus
17028/// earlier batch rows; only rows where the predicate is truthy
17029/// participate. A duplicate key among predicate-matching rows is a
17030/// uniqueness violation. NULL keys lift the row out of the check
17031/// (matching PG's "UNIQUE allows multiple NULLs" semantics).
17032fn enforce_unique_index_inserts(
17033    catalog: &Catalog,
17034    table_name: &str,
17035    rows: &[alloc::vec::Vec<spg_storage::Value>],
17036) -> Result<(), EngineError> {
17037    let table = catalog.get(table_name).ok_or_else(|| {
17038        EngineError::Storage(StorageError::TableNotFound {
17039            name: table_name.into(),
17040        })
17041    })?;
17042    let schema = table.schema();
17043    let ctx = eval::EvalContext::new(&schema.columns, None);
17044    for idx in table.indices() {
17045        if !idx.is_unique {
17046            continue;
17047        }
17048        // Re-parse the predicate once per index per batch.
17049        let predicate_expr = match idx.partial_predicate.as_deref() {
17050            Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
17051                EngineError::Unsupported(alloc::format!(
17052                    "UNIQUE INDEX {:?} predicate {s:?} failed to re-parse: {e:?}",
17053                    idx.name
17054                ))
17055            })?),
17056            None => None,
17057        };
17058        let key_positions = unique_key_positions(idx);
17059        let key_of = |values: &[spg_storage::Value]| -> alloc::vec::Vec<spg_storage::Value> {
17060            key_positions
17061                .iter()
17062                .map(|&p| {
17063                    let v = values.get(p).cloned().unwrap_or(spg_storage::Value::Null);
17064                    collated_key_cell(&v, p, schema)
17065                })
17066                .collect()
17067        };
17068        let participates = |values: &[spg_storage::Value]| -> Result<bool, EngineError> {
17069            let Some(expr) = &predicate_expr else {
17070                return Ok(true);
17071            };
17072            let tmp_row = spg_storage::Row {
17073                values: values.to_vec(),
17074            };
17075            let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
17076                EngineError::Unsupported(alloc::format!(
17077                    "UNIQUE INDEX {:?} predicate eval: {e:?}",
17078                    idx.name
17079                ))
17080            })?;
17081            Ok(predicate_truthy(&v))
17082        };
17083        // v7.29 (mailrs round-23b) — set-based: one O(table) pass
17084        // (predicate evaluated once per existing row instead of once
17085        // per row PAIR), then probe per batch row. The previous
17086        // nested scans made bulk import O(n²).
17087        let mut seen: hashbrown::HashSet<String> =
17088            hashbrown::HashSet::with_capacity(table.rows().len() + rows.len());
17089        for prow in table.rows() {
17090            if !participates(&prow.values)? {
17091                continue;
17092            }
17093            let key = key_of(&prow.values);
17094            if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
17095                continue;
17096            }
17097            seen.insert(aggregate::encode_key(&key));
17098        }
17099        for (batch_idx, row_values) in rows.iter().enumerate() {
17100            if !participates(row_values)? {
17101                continue;
17102            }
17103            let key = key_of(row_values);
17104            if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
17105                continue;
17106            }
17107            if !seen.insert(aggregate::encode_key(&key)) {
17108                return Err(EngineError::Unsupported(alloc::format!(
17109                    "UNIQUE INDEX {:?} violation on {table_name:?}: \
17110                     row #{batch_idx} duplicates an existing key",
17111                    idx.name
17112                )));
17113            }
17114        }
17115    }
17116    Ok(())
17117}
17118
17119/// v7.13.0 — `UPDATE OF cols` filter helper (mailrs round-5 G7).
17120/// Returns `true` when at least one of `filter_cols` has a
17121/// different value in `new_row` vs `old_row`. Column lookup is
17122/// case-insensitive against `schema_cols`; unknown filter columns
17123/// are treated as "not changed" (the trigger therefore won't
17124/// fire on them — surfacing a parse-time error would be too
17125/// strict for catalog reloads where the schema may have drifted).
17126fn any_column_changed(
17127    filter_cols: &[String],
17128    schema_cols: &[ColumnSchema],
17129    old_row: &Row,
17130    new_row: &Row,
17131) -> bool {
17132    for col_name in filter_cols {
17133        let Some(pos) = schema_cols
17134            .iter()
17135            .position(|c| c.name.eq_ignore_ascii_case(col_name))
17136        else {
17137            continue;
17138        };
17139        let old_v = old_row.values.get(pos);
17140        let new_v = new_row.values.get(pos);
17141        if old_v != new_v {
17142            return true;
17143        }
17144    }
17145    false
17146}
17147
17148/// v7.13.0 — evaluate every CHECK predicate on the schema against
17149/// each candidate row. Mirrors PG semantics: a `false` result
17150/// rejects the mutation; a NULL result *passes* (CHECK rejects
17151/// only on definite-false, not on unknown). mailrs round-5 G3.
17152fn enforce_check_constraints(
17153    catalog: &Catalog,
17154    table_name: &str,
17155    rows: &[alloc::vec::Vec<spg_storage::Value>],
17156) -> Result<(), EngineError> {
17157    let table = catalog.get(table_name).ok_or_else(|| {
17158        EngineError::Storage(StorageError::TableNotFound {
17159            name: table_name.into(),
17160        })
17161    })?;
17162    let schema = table.schema();
17163    // v7.17.0 Phase 1.5 — domain-level CHECKs are enforced in
17164    // parallel with table-level CHECKs. Collect both lists up
17165    // front; if neither exists we early-out.
17166    let mut domain_checks_per_col: alloc::vec::Vec<(usize, alloc::vec::Vec<Expr>)> =
17167        alloc::vec::Vec::new();
17168    for (idx, col) in schema.columns.iter().enumerate() {
17169        let Some(dname) = &col.user_domain_type else {
17170            continue;
17171        };
17172        let Some(dom) = catalog.domain_types().get(dname) else {
17173            continue;
17174        };
17175        let mut parsed_for_col: alloc::vec::Vec<Expr> =
17176            alloc::vec::Vec::with_capacity(dom.checks.len());
17177        for src in &dom.checks {
17178            let expr = spg_sql::parser::parse_expression(src).map_err(|e| {
17179                EngineError::Unsupported(alloc::format!(
17180                    "DOMAIN {dname:?} CHECK ({src:?}) on column {:?}: re-parse failed: {e:?}",
17181                    col.name
17182                ))
17183            })?;
17184            parsed_for_col.push(expr);
17185        }
17186        if !parsed_for_col.is_empty() {
17187            domain_checks_per_col.push((idx, parsed_for_col));
17188        }
17189    }
17190    if schema.checks.is_empty() && domain_checks_per_col.is_empty() {
17191        return Ok(());
17192    }
17193    let ctx = eval::EvalContext::new(&schema.columns, None);
17194    let mut parsed: alloc::vec::Vec<(usize, Expr)> = alloc::vec::Vec::new();
17195    for (i, src) in schema.checks.iter().enumerate() {
17196        let expr = spg_sql::parser::parse_expression(src).map_err(|e| {
17197            EngineError::Unsupported(alloc::format!(
17198                "CHECK constraint #{i} on {table_name:?} ({src:?}) failed to re-parse: {e:?}"
17199            ))
17200        })?;
17201        parsed.push((i, expr));
17202    }
17203    for (batch_idx, row_values) in rows.iter().enumerate() {
17204        let tmp_row = spg_storage::Row {
17205            values: row_values.clone(),
17206        };
17207        for (i, expr) in &parsed {
17208            let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
17209                EngineError::Unsupported(alloc::format!(
17210                    "CHECK constraint #{i} on {table_name:?} eval at row #{batch_idx}: {e:?}"
17211                ))
17212            })?;
17213            // PG: NULL passes (CHECK rejects on definite-false only).
17214            if matches!(v, spg_storage::Value::Bool(false)) {
17215                return Err(EngineError::Unsupported(alloc::format!(
17216                    "CHECK constraint violation on {table_name:?} (row #{batch_idx}): {:?}",
17217                    schema.checks[*i]
17218                )));
17219            }
17220        }
17221        // v7.17.0 Phase 1.5 — domain-level CHECKs. Each CHECK
17222        // expression references VALUE as a column-name; we
17223        // substitute the per-row cell into the eval context by
17224        // synthesising a single-column row of just that value
17225        // under a temporary `value` column schema.
17226        for (col_idx, checks) in &domain_checks_per_col {
17227            let cell = row_values
17228                .get(*col_idx)
17229                .cloned()
17230                .unwrap_or(spg_storage::Value::Null);
17231            let synth_cols = alloc::vec![spg_storage::ColumnSchema::new(
17232                "value",
17233                schema.columns[*col_idx].ty,
17234                schema.columns[*col_idx].nullable,
17235            )];
17236            let synth_ctx = eval::EvalContext::new(&synth_cols, None);
17237            let synth_row = spg_storage::Row {
17238                values: alloc::vec![cell],
17239            };
17240            for (ci, expr) in checks.iter().enumerate() {
17241                let v = eval::eval_expr(expr, &synth_row, &synth_ctx).map_err(|e| {
17242                    EngineError::Unsupported(alloc::format!(
17243                        "DOMAIN CHECK #{ci} on column {:?} eval at row #{batch_idx}: {e:?}",
17244                        schema.columns[*col_idx].name
17245                    ))
17246                })?;
17247                if matches!(v, spg_storage::Value::Bool(false)) {
17248                    return Err(EngineError::Unsupported(alloc::format!(
17249                        "DOMAIN CHECK violation on column {:?} (row #{batch_idx})",
17250                        schema.columns[*col_idx].name
17251                    )));
17252                }
17253            }
17254        }
17255    }
17256    Ok(())
17257}
17258
17259fn enforce_fk_inserts(
17260    catalog: &Catalog,
17261    child_table: &str,
17262    fks: &[spg_storage::ForeignKeyConstraint],
17263    rows: &[Vec<Value>],
17264) -> Result<(), EngineError> {
17265    for fk in fks {
17266        let parent_is_self = fk.parent_table == child_table;
17267        let parent = if parent_is_self {
17268            // Self-ref: read the current state of the same table.
17269            // The mut borrow on child has been dropped by the caller.
17270            catalog.get(child_table).ok_or_else(|| {
17271                EngineError::Storage(StorageError::TableNotFound {
17272                    name: child_table.into(),
17273                })
17274            })?
17275        } else {
17276            catalog.get(&fk.parent_table).ok_or_else(|| {
17277                EngineError::Storage(StorageError::TableNotFound {
17278                    name: fk.parent_table.clone(),
17279                })
17280            })?
17281        };
17282        for (batch_idx, row_values) in rows.iter().enumerate() {
17283            // Single-column FK fast path: try the parent's BTree
17284            // index for an O(log n) lookup. Composite FKs fall back
17285            // to a parent-row scan.
17286            if fk.local_columns.len() == 1 {
17287                let v = &row_values[fk.local_columns[0]];
17288                if matches!(v, Value::Null) {
17289                    continue;
17290                }
17291                let parent_col = fk.parent_columns[0];
17292                let key = spg_storage::IndexKey::from_value(v).ok_or_else(|| {
17293                    EngineError::Unsupported(alloc::format!(
17294                        "FOREIGN KEY column value of type {:?} is not index-eligible",
17295                        v.data_type()
17296                    ))
17297                })?;
17298                let present_committed = parent.indices().iter().any(|idx| {
17299                    matches!(idx.kind, spg_storage::IndexKind::BTree(_))
17300                        && idx.column_position == parent_col
17301                        && idx.partial_predicate.is_none()
17302                        && !idx.lookup_eq(&key).is_empty()
17303                });
17304                // v7.6.7 self-ref widening: also accept a match
17305                // against earlier rows in this same batch when the
17306                // FK points at the table being inserted into.
17307                let present_in_batch = parent_is_self
17308                    && rows[..batch_idx]
17309                        .iter()
17310                        .any(|earlier| earlier.get(parent_col) == Some(v));
17311                if !(present_committed || present_in_batch) {
17312                    return Err(EngineError::Unsupported(alloc::format!(
17313                        "FOREIGN KEY violation: no parent row in {:?} where {} = {:?}",
17314                        fk.parent_table,
17315                        parent
17316                            .schema()
17317                            .columns
17318                            .get(parent_col)
17319                            .map_or("?", |c| c.name.as_str()),
17320                        v,
17321                    )));
17322                }
17323            } else {
17324                // Composite FK: scan parent rows. v7.6.7 also
17325                // accepts a match against earlier rows in the same
17326                // batch (self-ref bulk-loading of hierarchies).
17327                if fk
17328                    .local_columns
17329                    .iter()
17330                    .all(|&i| matches!(row_values.get(i), Some(Value::Null)))
17331                {
17332                    continue;
17333                }
17334                let local: Vec<&Value> = fk.local_columns.iter().map(|&i| &row_values[i]).collect();
17335                let parent_match_committed = parent.rows().iter().any(|prow| {
17336                    fk.parent_columns
17337                        .iter()
17338                        .enumerate()
17339                        .all(|(i, &pi)| prow.values.get(pi) == Some(local[i]))
17340                });
17341                let parent_match_in_batch = parent_is_self
17342                    && rows[..batch_idx].iter().any(|earlier| {
17343                        fk.parent_columns
17344                            .iter()
17345                            .enumerate()
17346                            .all(|(i, &pi)| earlier.get(pi) == Some(local[i]))
17347                    });
17348                if !(parent_match_committed || parent_match_in_batch) {
17349                    return Err(EngineError::Unsupported(alloc::format!(
17350                        "FOREIGN KEY violation: no parent row in {:?} matching composite key",
17351                        fk.parent_table,
17352                    )));
17353                }
17354            }
17355        }
17356    }
17357    Ok(())
17358}
17359
17360/// v7.6.4 / v7.6.5 — one step of the FK action plan computed for a
17361/// DELETE on a parent. The plan is a list of these steps, stacked
17362/// across the FK graph by `plan_fk_parent_deletions`.
17363#[derive(Debug, Clone)]
17364struct FkChildStep {
17365    child_table: String,
17366    action: FkChildAction,
17367}
17368
17369#[derive(Debug, Clone)]
17370enum FkChildAction {
17371    /// CASCADE — remove these rows. Sorted, deduplicated positions.
17372    Delete { positions: Vec<usize> },
17373    /// SET NULL — for each (row, column) in the flat list, write
17374    /// NULL into that child cell. Multiple FKs on the same row may
17375    /// produce overlapping entries (deduped at plan time).
17376    SetNull {
17377        positions: Vec<usize>,
17378        columns: Vec<usize>,
17379    },
17380    /// SET DEFAULT — same shape as SetNull but writes the column's
17381    /// declared DEFAULT value (resolved at plan time). Columns
17382    /// without a DEFAULT raise an error during planning.
17383    SetDefault {
17384        positions: Vec<usize>,
17385        columns: Vec<usize>,
17386        defaults: Vec<Value>,
17387    },
17388}
17389
17390/// v7.6.3 → v7.6.5 — plan FK fallout for a DELETE on a parent table.
17391///
17392/// Walks every table in the catalog looking for FKs whose
17393/// `parent_table` is `parent_table_name`. For each such FK + each
17394/// to-be-deleted parent row:
17395///
17396///   - RESTRICT / NoAction → error, no plan returned
17397///   - CASCADE → child rows get scheduled for deletion; recursive
17398///   - SetNull → child FK column(s) scheduled to be NULL-ed.
17399///     Verified NULL-able at plan time.
17400///   - SetDefault → child FK column(s) scheduled to be reset to
17401///     their declared DEFAULT. Columns without a DEFAULT raise.
17402///
17403/// SET NULL / SET DEFAULT do NOT cascade further — the child row
17404/// stays; only one of its columns mutates.
17405fn plan_fk_parent_deletions(
17406    catalog: &Catalog,
17407    parent_table_name: &str,
17408    to_delete_positions: &[usize],
17409    to_delete_rows: &[Vec<Value>],
17410) -> Result<Vec<FkChildStep>, EngineError> {
17411    use alloc::collections::{BTreeMap, BTreeSet};
17412    if to_delete_rows.is_empty() {
17413        return Ok(Vec::new());
17414    }
17415    let mut delete_plan: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
17416    // setnull / setdefault keyed by child_table → (row_idx, col_idx) → optional default
17417    let mut setnull_plan: BTreeMap<String, BTreeSet<(usize, usize)>> = BTreeMap::new();
17418    let mut setdefault_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
17419    let mut visited: BTreeSet<(String, usize)> = BTreeSet::new();
17420    for &p in to_delete_positions {
17421        visited.insert((parent_table_name.to_string(), p));
17422    }
17423    let mut work: Vec<(String, Vec<Value>)> = to_delete_rows
17424        .iter()
17425        .map(|r| (parent_table_name.to_string(), r.clone()))
17426        .collect();
17427    while let Some((cur_parent, parent_row)) = work.pop() {
17428        for child_name in catalog.table_names() {
17429            let child = catalog
17430                .get(&child_name)
17431                .expect("table_names → catalog.get round-trip is total");
17432            for fk in &child.schema().foreign_keys {
17433                if fk.parent_table != cur_parent {
17434                    continue;
17435                }
17436                let parent_key: Vec<&Value> = fk
17437                    .parent_columns
17438                    .iter()
17439                    .map(|&pi| &parent_row[pi])
17440                    .collect();
17441                if parent_key.iter().any(|v| matches!(v, Value::Null)) {
17442                    continue;
17443                }
17444                for (child_row_idx, child_row) in child.rows().iter().enumerate() {
17445                    if child_name == cur_parent
17446                        && visited.contains(&(child_name.clone(), child_row_idx))
17447                    {
17448                        continue;
17449                    }
17450                    let matches_key = fk
17451                        .local_columns
17452                        .iter()
17453                        .enumerate()
17454                        .all(|(i, &li)| child_row.values.get(li) == Some(parent_key[i]));
17455                    if !matches_key {
17456                        continue;
17457                    }
17458                    match fk.on_delete {
17459                        spg_storage::FkAction::Restrict | spg_storage::FkAction::NoAction => {
17460                            return Err(EngineError::Unsupported(alloc::format!(
17461                                "FOREIGN KEY violation: DELETE on {cur_parent:?} is \
17462                                 restricted by FK from {child_name:?}.{:?}",
17463                                fk.local_columns,
17464                            )));
17465                        }
17466                        spg_storage::FkAction::Cascade => {
17467                            if visited.insert((child_name.clone(), child_row_idx)) {
17468                                delete_plan
17469                                    .entry(child_name.clone())
17470                                    .or_default()
17471                                    .insert(child_row_idx);
17472                                work.push((child_name.clone(), child_row.values.clone()));
17473                            }
17474                        }
17475                        spg_storage::FkAction::SetNull => {
17476                            // Verify every local FK column is NULL-able.
17477                            for &li in &fk.local_columns {
17478                                let col = child.schema().columns.get(li).ok_or_else(|| {
17479                                    EngineError::Unsupported(alloc::format!(
17480                                        "FK local column {li} missing in {child_name:?}"
17481                                    ))
17482                                })?;
17483                                if !col.nullable {
17484                                    return Err(EngineError::Unsupported(alloc::format!(
17485                                        "FOREIGN KEY ON DELETE SET NULL: column \
17486                                         {child_name:?}.{:?} is NOT NULL — cannot SET NULL",
17487                                        col.name,
17488                                    )));
17489                                }
17490                            }
17491                            let entry = setnull_plan.entry(child_name.clone()).or_default();
17492                            for &li in &fk.local_columns {
17493                                entry.insert((child_row_idx, li));
17494                            }
17495                        }
17496                        spg_storage::FkAction::SetDefault => {
17497                            // Resolve the DEFAULT for every local FK col.
17498                            let entry = setdefault_plan.entry(child_name.clone()).or_default();
17499                            for &li in &fk.local_columns {
17500                                let col = child.schema().columns.get(li).ok_or_else(|| {
17501                                    EngineError::Unsupported(alloc::format!(
17502                                        "FK local column {li} missing in {child_name:?}"
17503                                    ))
17504                                })?;
17505                                let default = col.default.clone().ok_or_else(|| {
17506                                    EngineError::Unsupported(alloc::format!(
17507                                        "FOREIGN KEY ON DELETE SET DEFAULT: column \
17508                                         {child_name:?}.{:?} has no DEFAULT declared",
17509                                        col.name,
17510                                    ))
17511                                })?;
17512                                entry.insert((child_row_idx, li), default);
17513                            }
17514                        }
17515                    }
17516                }
17517            }
17518        }
17519    }
17520    // Flatten the three plans into the ordered `FkChildStep` list.
17521    // Deletes are applied last per child (after any null/default
17522    // re-writes on the same child) so a child row that's both
17523    // re-written and then cascade-deleted only ends up deleted —
17524    // but in v7.6.5 SetNull/Cascade never overlap on the same row
17525    // (a single FK chooses exactly one action), so the order is
17526    // mostly a precaution.
17527    let mut steps: Vec<FkChildStep> = Vec::new();
17528    for (child_table, entries) in setnull_plan {
17529        let (positions, columns): (Vec<usize>, Vec<usize>) = entries.into_iter().unzip();
17530        steps.push(FkChildStep {
17531            child_table,
17532            action: FkChildAction::SetNull { positions, columns },
17533        });
17534    }
17535    for (child_table, entries) in setdefault_plan {
17536        let mut positions = Vec::with_capacity(entries.len());
17537        let mut columns = Vec::with_capacity(entries.len());
17538        let mut defaults = Vec::with_capacity(entries.len());
17539        for ((p, c), v) in entries {
17540            positions.push(p);
17541            columns.push(c);
17542            defaults.push(v);
17543        }
17544        steps.push(FkChildStep {
17545            child_table,
17546            action: FkChildAction::SetDefault {
17547                positions,
17548                columns,
17549                defaults,
17550            },
17551        });
17552    }
17553    for (child_table, positions) in delete_plan {
17554        steps.push(FkChildStep {
17555            child_table,
17556            action: FkChildAction::Delete {
17557                positions: positions.into_iter().collect(),
17558            },
17559        });
17560    }
17561    Ok(steps)
17562}
17563
17564/// v7.6.6 — plan FK fallout for an UPDATE that mutates parent-side
17565/// PK/UNIQUE columns. Walks every other table whose FK references
17566/// `parent_table_name`; for each FK whose parent_columns overlap a
17567/// mutated column, decides the action by `fk.on_update`.
17568///
17569///   - RESTRICT / NoAction → error if any child references the OLD
17570///     value
17571///   - CASCADE → child FK columns get rewritten to the NEW parent
17572///     value (a SetNull-style update step with the new value)
17573///   - SetNull → child FK columns set to NULL
17574///   - SetDefault → child FK columns set to declared default
17575///
17576/// `plan_with_old` is `(row_position, old_values, new_values)` so
17577/// the planner can detect "did this row's parent key actually
17578/// change?" — only rows where at least one referenced parent
17579/// column moved trigger inbound work.
17580fn plan_fk_parent_updates(
17581    catalog: &Catalog,
17582    parent_table_name: &str,
17583    plan_with_old: &[(usize, Vec<Value>, Vec<Value>)],
17584) -> Result<Vec<FkChildStep>, EngineError> {
17585    use alloc::collections::BTreeMap;
17586    if plan_with_old.is_empty() {
17587        return Ok(Vec::new());
17588    }
17589    // For each child table we may touch, build per-child step
17590    // lists. UPDATE never deletes children — `delete_plan` stays
17591    // empty here but is kept structurally aligned with
17592    // `plan_fk_parent_deletions` for future use.
17593    let delete_plan: BTreeMap<String, alloc::collections::BTreeSet<usize>> = BTreeMap::new();
17594    let mut setnull_plan: BTreeMap<String, alloc::collections::BTreeSet<(usize, usize)>> =
17595        BTreeMap::new();
17596    let mut setdefault_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
17597    // Cascade-update plan: child_table → row_idx → col_idx → new_value
17598    let mut cascade_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
17599
17600    for child_name in catalog.table_names() {
17601        let child = catalog
17602            .get(&child_name)
17603            .expect("table_names → catalog.get total");
17604        for fk in &child.schema().foreign_keys {
17605            if fk.parent_table != parent_table_name {
17606                continue;
17607            }
17608            for (_pos, old_row, new_row) in plan_with_old {
17609                // Did any parent FK column change?
17610                let key_changed = fk
17611                    .parent_columns
17612                    .iter()
17613                    .any(|&pi| old_row.get(pi) != new_row.get(pi));
17614                if !key_changed {
17615                    continue;
17616                }
17617                // The OLD parent key — used to find referring children.
17618                let old_key: Vec<&Value> =
17619                    fk.parent_columns.iter().map(|&pi| &old_row[pi]).collect();
17620                if old_key.iter().any(|v| matches!(v, Value::Null)) {
17621                    // NULL parent has no children — skip.
17622                    continue;
17623                }
17624                let new_key: Vec<&Value> =
17625                    fk.parent_columns.iter().map(|&pi| &new_row[pi]).collect();
17626                for (child_row_idx, child_row) in child.rows().iter().enumerate() {
17627                    // Self-ref same-row updates: a row updating its
17628                    // own PK doesn't restrict itself.
17629                    if child_name == parent_table_name
17630                        && plan_with_old.iter().any(|(p, _, _)| *p == child_row_idx)
17631                    {
17632                        continue;
17633                    }
17634                    let matches_key = fk
17635                        .local_columns
17636                        .iter()
17637                        .enumerate()
17638                        .all(|(i, &li)| child_row.values.get(li) == Some(old_key[i]));
17639                    if !matches_key {
17640                        continue;
17641                    }
17642                    match fk.on_update {
17643                        spg_storage::FkAction::Restrict | spg_storage::FkAction::NoAction => {
17644                            return Err(EngineError::Unsupported(alloc::format!(
17645                                "FOREIGN KEY violation: UPDATE on {parent_table_name:?} PK is \
17646                                 restricted by FK from {child_name:?}.{:?}",
17647                                fk.local_columns,
17648                            )));
17649                        }
17650                        spg_storage::FkAction::Cascade => {
17651                            // Rewrite child FK columns to new key.
17652                            let entry = cascade_plan.entry(child_name.clone()).or_default();
17653                            for (i, &li) in fk.local_columns.iter().enumerate() {
17654                                entry.insert((child_row_idx, li), new_key[i].clone());
17655                            }
17656                        }
17657                        spg_storage::FkAction::SetNull => {
17658                            for &li in &fk.local_columns {
17659                                let col = child.schema().columns.get(li).ok_or_else(|| {
17660                                    EngineError::Unsupported(alloc::format!(
17661                                        "FK local column {li} missing in {child_name:?}"
17662                                    ))
17663                                })?;
17664                                if !col.nullable {
17665                                    return Err(EngineError::Unsupported(alloc::format!(
17666                                        "FOREIGN KEY ON UPDATE SET NULL: column \
17667                                         {child_name:?}.{:?} is NOT NULL",
17668                                        col.name,
17669                                    )));
17670                                }
17671                            }
17672                            let entry = setnull_plan.entry(child_name.clone()).or_default();
17673                            for &li in &fk.local_columns {
17674                                entry.insert((child_row_idx, li));
17675                            }
17676                        }
17677                        spg_storage::FkAction::SetDefault => {
17678                            let entry = setdefault_plan.entry(child_name.clone()).or_default();
17679                            for &li in &fk.local_columns {
17680                                let col = child.schema().columns.get(li).ok_or_else(|| {
17681                                    EngineError::Unsupported(alloc::format!(
17682                                        "FK local column {li} missing in {child_name:?}"
17683                                    ))
17684                                })?;
17685                                let default = col.default.clone().ok_or_else(|| {
17686                                    EngineError::Unsupported(alloc::format!(
17687                                        "FOREIGN KEY ON UPDATE SET DEFAULT: column \
17688                                         {child_name:?}.{:?} has no DEFAULT",
17689                                        col.name,
17690                                    ))
17691                                })?;
17692                                entry.insert((child_row_idx, li), default);
17693                            }
17694                        }
17695                    }
17696                }
17697            }
17698        }
17699    }
17700    // Flatten into FkChildStep list. UPDATE doesn't produce
17701    // DeleteSteps (CASCADE on UPDATE just rewrites FK values).
17702    let mut steps: Vec<FkChildStep> = Vec::new();
17703    for (child_table, entries) in cascade_plan {
17704        let mut positions = Vec::with_capacity(entries.len());
17705        let mut columns = Vec::with_capacity(entries.len());
17706        let mut defaults = Vec::with_capacity(entries.len());
17707        for ((p, c), v) in entries {
17708            positions.push(p);
17709            columns.push(c);
17710            defaults.push(v);
17711        }
17712        // We reuse `FkChildAction::SetDefault` for cascade-update:
17713        // both shapes are "write a known value into specific cells"
17714        // — `apply_per_cell_writes` doesn't care whether the value
17715        // came from a DEFAULT declaration or a new parent key.
17716        steps.push(FkChildStep {
17717            child_table,
17718            action: FkChildAction::SetDefault {
17719                positions,
17720                columns,
17721                defaults,
17722            },
17723        });
17724    }
17725    for (child_table, entries) in setnull_plan {
17726        let (positions, columns): (Vec<usize>, Vec<usize>) = entries.into_iter().unzip();
17727        steps.push(FkChildStep {
17728            child_table,
17729            action: FkChildAction::SetNull { positions, columns },
17730        });
17731    }
17732    for (child_table, entries) in setdefault_plan {
17733        let mut positions = Vec::with_capacity(entries.len());
17734        let mut columns = Vec::with_capacity(entries.len());
17735        let mut defaults = Vec::with_capacity(entries.len());
17736        for ((p, c), v) in entries {
17737            positions.push(p);
17738            columns.push(c);
17739            defaults.push(v);
17740        }
17741        steps.push(FkChildStep {
17742            child_table,
17743            action: FkChildAction::SetDefault {
17744                positions,
17745                columns,
17746                defaults,
17747            },
17748        });
17749    }
17750    let _ = delete_plan; // UPDATE never deletes children.
17751    Ok(steps)
17752}
17753
17754/// v7.6.5 — apply one FK child step to the catalog. Encapsulates
17755/// the three action variants so the DELETE executor stays a
17756/// simple loop over the planned steps.
17757fn apply_fk_child_step(catalog: &mut Catalog, step: &FkChildStep) -> Result<(), EngineError> {
17758    let child = catalog.get_mut(&step.child_table).ok_or_else(|| {
17759        EngineError::Storage(StorageError::TableNotFound {
17760            name: step.child_table.clone(),
17761        })
17762    })?;
17763    match &step.action {
17764        FkChildAction::Delete { positions } => {
17765            let _ = child.delete_rows(positions);
17766        }
17767        FkChildAction::SetNull { positions, columns } => {
17768            apply_per_cell_writes(child, positions, columns, |_| Value::Null)?;
17769        }
17770        FkChildAction::SetDefault {
17771            positions,
17772            columns,
17773            defaults,
17774        } => {
17775            apply_per_cell_writes(child, positions, columns, |i| defaults[i].clone())?;
17776        }
17777    }
17778    Ok(())
17779}
17780
17781/// v7.6.5 — write new values into selected child cells via
17782/// `Table::update_row` (the catalog's existing UPDATE entry).
17783/// Groups writes by row position so multi-column updates on the
17784/// same row only call `update_row` once. `value_for(i)` produces
17785/// the new value for the i-th (position, column) entry.
17786fn apply_per_cell_writes(
17787    child: &mut spg_storage::Table,
17788    positions: &[usize],
17789    columns: &[usize],
17790    mut value_for: impl FnMut(usize) -> Value,
17791) -> Result<(), EngineError> {
17792    use alloc::collections::BTreeMap;
17793    let mut by_row: BTreeMap<usize, Vec<(usize, Value)>> = BTreeMap::new();
17794    for i in 0..positions.len() {
17795        by_row
17796            .entry(positions[i])
17797            .or_default()
17798            .push((columns[i], value_for(i)));
17799    }
17800    for (pos, mutations) in by_row {
17801        let mut new_values = child.rows()[pos].values.clone();
17802        for (col, v) in mutations {
17803            if let Some(slot) = new_values.get_mut(col) {
17804                *slot = v;
17805            }
17806        }
17807        child
17808            .update_row(pos, new_values)
17809            .map_err(EngineError::Storage)?;
17810    }
17811    Ok(())
17812}
17813
17814fn fk_action_sql_to_storage(a: spg_sql::ast::FkAction) -> spg_storage::FkAction {
17815    match a {
17816        spg_sql::ast::FkAction::Restrict => spg_storage::FkAction::Restrict,
17817        spg_sql::ast::FkAction::Cascade => spg_storage::FkAction::Cascade,
17818        spg_sql::ast::FkAction::SetNull => spg_storage::FkAction::SetNull,
17819        spg_sql::ast::FkAction::SetDefault => spg_storage::FkAction::SetDefault,
17820        spg_sql::ast::FkAction::NoAction => spg_storage::FkAction::NoAction,
17821    }
17822}
17823
17824/// v7.9.21 — resolve a column's DEFAULT for INSERT-time
17825/// default-fill. Free fn (rather than `&self`) so callers
17826/// with an active `&mut Table` borrow can still use it.
17827/// Literal defaults take the cached path (`col.default`);
17828/// runtime defaults hit `clock_fn` at each call. mailrs G4.
17829fn resolve_column_default_free(
17830    col: &ColumnSchema,
17831    clock_fn: Option<ClockFn>,
17832) -> Result<Value, EngineError> {
17833    if let Some(rt) = &col.runtime_default {
17834        return eval_runtime_default_free(rt, col.ty, clock_fn);
17835    }
17836    Ok(col.default.clone().unwrap_or(Value::Null))
17837}
17838
17839fn eval_runtime_default_free(
17840    rt: &str,
17841    ty: DataType,
17842    clock_fn: Option<ClockFn>,
17843) -> Result<Value, EngineError> {
17844    let s = rt.trim().to_ascii_lowercase();
17845    // v7.17.0 Phase 2.1 — also strip `(N)` precision suffix
17846    // so MySQL `CURRENT_TIMESTAMP(6)` resolves the same as
17847    // bare `CURRENT_TIMESTAMP`. SPG stores TIMESTAMP at fixed
17848    // microsecond resolution; the precision modifier is
17849    // parser-only.
17850    let with_no_parens = s.trim_end_matches("()");
17851    let canonical: &str = if let Some(open_idx) = with_no_parens.find('(') {
17852        if with_no_parens.ends_with(')') {
17853            &with_no_parens[..open_idx]
17854        } else {
17855            with_no_parens
17856        }
17857    } else {
17858        with_no_parens
17859    };
17860    let now_us = match clock_fn {
17861        Some(f) => f(),
17862        None => 0,
17863    };
17864    let v = match canonical {
17865        "now" | "current_timestamp" | "localtimestamp" => Value::Timestamp(now_us),
17866        "current_date" => Value::Date((now_us / 86_400_000_000) as i32),
17867        "current_time" | "localtime" => Value::Timestamp(now_us),
17868        // v7.17.0 — UUID generators in DEFAULT clauses. Required
17869        // for the canonical Django / Rails / Hibernate `id UUID
17870        // PRIMARY KEY DEFAULT gen_random_uuid()` pattern. Each
17871        // INSERT evaluates the function fresh; the per-row UUID
17872        // is the storage value, not a cached literal.
17873        "gen_random_uuid" | "uuid_generate_v4" => Value::Uuid(eval::gen_random_uuid_bytes()),
17874        other => {
17875            return Err(EngineError::Unsupported(alloc::format!(
17876                "runtime DEFAULT expression {other:?} not supported \
17877                 (v7.17.0 whitelist: now() / current_timestamp / \
17878                 current_date / current_time / localtimestamp / \
17879                 localtime / gen_random_uuid() / \
17880                 uuid_generate_v4())"
17881            )));
17882        }
17883    };
17884    coerce_value(v, ty, "DEFAULT", 0)
17885}
17886
17887/// v7.9.21 — true when a DEFAULT expression needs INSERT-time
17888/// evaluation rather than being cacheable as a literal Value.
17889/// FunctionCall is the immediate case (`now()`,
17890/// `current_timestamp`). Literal expressions and simple sign-
17891/// flipped numerics still take the static-cache path.
17892fn is_runtime_default_expr(expr: &Expr) -> bool {
17893    match expr {
17894        Expr::FunctionCall { .. } => true,
17895        Expr::Unary { expr, .. } => is_runtime_default_expr(expr),
17896        _ => false,
17897    }
17898}
17899
17900/// v7.17.0 Phase 1.4 — INSERT/UPDATE-time enum label check. When
17901/// `col_idx` has a registered label list, the cell value must be
17902/// NULL or one of the labels (case-sensitive per PG).
17903/// v7.17.0 Phase 3.P0-37 — validate + canonicalise a MySQL inline
17904/// SET cell. For non-SET columns this is a no-op pass-through.
17905///
17906/// Semantics:
17907///   * NULL preserved.
17908///   * Empty string → `''` (zero flags).
17909///   * Otherwise split on ',', trim each token, validate every
17910///     token against the column's variant list (error on miss),
17911///     de-dup, then re-emit in DEFINITION order joined by ','.
17912fn canonicalize_set_value(
17913    lookup: &alloc::collections::BTreeMap<usize, Vec<String>>,
17914    col_idx: usize,
17915    col_name: &str,
17916    value: Value,
17917) -> Result<Value, EngineError> {
17918    let Some(variants) = lookup.get(&col_idx) else {
17919        return Ok(value);
17920    };
17921    match value {
17922        Value::Null => Ok(Value::Null),
17923        Value::Text(s) => {
17924            if s.is_empty() {
17925                return Ok(Value::Text(alloc::string::String::new()));
17926            }
17927            // Collect a presence-set of variant indices to keep
17928            // definition order + handle de-dup in one pass.
17929            let mut present = alloc::vec![false; variants.len()];
17930            for raw in s.split(',') {
17931                let tok = raw.trim();
17932                if tok.is_empty() {
17933                    continue;
17934                }
17935                let idx = variants.iter().position(|v| v == tok).ok_or_else(|| {
17936                    EngineError::Unsupported(alloc::format!(
17937                        "column {col_name:?}: invalid SET token {tok:?}; \
17938                         allowed: {variants:?}"
17939                    ))
17940                })?;
17941                present[idx] = true;
17942            }
17943            // Re-emit in definition order.
17944            let mut out = alloc::string::String::new();
17945            let mut first = true;
17946            for (i, keep) in present.iter().enumerate() {
17947                if !keep {
17948                    continue;
17949                }
17950                if !first {
17951                    out.push(',');
17952                }
17953                first = false;
17954                out.push_str(&variants[i]);
17955            }
17956            Ok(Value::Text(out))
17957        }
17958        other => Err(EngineError::Unsupported(alloc::format!(
17959            "column {col_name:?}: SET-typed column expects TEXT, got {:?}",
17960            other.data_type()
17961        ))),
17962    }
17963}
17964
17965fn enforce_enum_label(
17966    lookup: &alloc::collections::BTreeMap<usize, Vec<String>>,
17967    col_idx: usize,
17968    col_name: &str,
17969    value: &Value,
17970) -> Result<(), EngineError> {
17971    if let Some(labels) = lookup.get(&col_idx) {
17972        match value {
17973            Value::Null => Ok(()),
17974            Value::Text(s) => {
17975                if labels.iter().any(|l| l == s) {
17976                    Ok(())
17977                } else {
17978                    Err(EngineError::Unsupported(alloc::format!(
17979                        "column {col_name:?}: invalid enum label {s:?}; allowed: {labels:?}"
17980                    )))
17981                }
17982            }
17983            other => Err(EngineError::Unsupported(alloc::format!(
17984                "column {col_name:?}: enum-typed column expects TEXT, got {:?}",
17985                other.data_type()
17986            ))),
17987        }
17988    } else {
17989        Ok(())
17990    }
17991}
17992
17993fn column_def_to_schema(c: ColumnDef) -> Result<ColumnSchema, EngineError> {
17994    let ty = column_type_to_data_type(c.ty);
17995    let mut schema = ColumnSchema::new(c.name.clone(), ty, c.nullable);
17996    // user_type_ref is the raw ident the parser couldn't resolve
17997    // to a built-in; classification into enum vs domain happens
17998    // at exec_create_table where we have catalog access. We
17999    // park it temporarily as user_enum_type and the engine
18000    // promotes domain bindings to user_domain_type before the
18001    // table is stored.
18002    if let Some(name) = c.user_type_ref {
18003        schema.user_enum_type = Some(name);
18004    }
18005    // v7.17.0 Phase 2.1 — render the ON UPDATE expression to
18006    // canonical text (the engine re-parses at UPDATE time).
18007    if let Some(expr) = c.on_update_runtime {
18008        schema.on_update_runtime = Some(alloc::format!("{expr}"));
18009    }
18010    // v7.17.0 Phase 2.5 — bridge the AST `Collation` enum to the
18011    // storage one. Same variants, different crates (spg-storage
18012    // owns no dep on spg-sql).
18013    schema.collation = match c.collation {
18014        spg_sql::ast::Collation::Binary => spg_storage::Collation::Binary,
18015        spg_sql::ast::Collation::CaseInsensitive => spg_storage::Collation::CaseInsensitive,
18016    };
18017    // v7.17.0 Phase 4.4 — MySQL `UNSIGNED` flag propagates to
18018    // storage so engine INSERT / UPDATE can range-check.
18019    schema.is_unsigned = c.is_unsigned;
18020    // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM variant list.
18021    // INSERT validation lives in coerce_value (Text → Text path
18022    // with the column's variant list as the accept-set).
18023    schema.inline_enum_variants = c.inline_enum_variants;
18024    // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant list.
18025    // INSERT canonicalisation (de-dup + sort by definition order)
18026    // lives in the exec_insert path next to the ENUM check.
18027    schema.inline_set_variants = c.inline_set_variants;
18028    if let Some(default_expr) = c.default {
18029        // v7.9.21 — distinguish literal defaults (evaluated once
18030        // at CREATE TABLE) from expression defaults (deferred to
18031        // INSERT). Function calls (`now()`, `current_timestamp`
18032        // — see v7.9.20 keyword promotion) take the runtime path.
18033        // Literals continue to cache. mailrs G4.
18034        if is_runtime_default_expr(&default_expr) {
18035            let display = alloc::format!("{default_expr}");
18036            schema = schema.with_runtime_default(display);
18037        } else {
18038            let raw = literal_expr_to_value(default_expr)?;
18039            let coerced = coerce_value(raw, ty, &c.name, 0)?;
18040            schema = schema.with_default(coerced);
18041        }
18042    }
18043    if c.auto_increment {
18044        // AUTO_INCREMENT only makes sense on integer-shaped columns.
18045        if !matches!(ty, DataType::SmallInt | DataType::Int | DataType::BigInt) {
18046            return Err(EngineError::Unsupported(alloc::format!(
18047                "AUTO_INCREMENT requires an integer column type, got {ty:?}"
18048            )));
18049        }
18050        schema = schema.with_auto_increment();
18051    }
18052    Ok(schema)
18053}
18054
18055/// v7.10.4 — decode a BYTEA literal. Accepts:
18056///   * `\xDEADBEEF` (case-insensitive hex; whitespace stripped)
18057///   * `Hello\000world` (backslash escape form; `\\` for literal backslash)
18058///   * Anything else → raw UTF-8 bytes of the input (PG accepts this too).
18059fn decode_bytea_literal(s: &str) -> Result<alloc::vec::Vec<u8>, &'static str> {
18060    let s = s.trim();
18061    if let Some(hex) = s.strip_prefix("\\x").or_else(|| s.strip_prefix("\\X")) {
18062        // Hex form. Each pair of hex digits → one byte.
18063        let cleaned: alloc::string::String = hex.chars().filter(|c| !c.is_whitespace()).collect();
18064        if cleaned.len() % 2 != 0 {
18065            return Err("odd-length hex literal");
18066        }
18067        let mut out = alloc::vec::Vec::with_capacity(cleaned.len() / 2);
18068        let cleaned_bytes = cleaned.as_bytes();
18069        for i in (0..cleaned_bytes.len()).step_by(2) {
18070            let hi = hex_nibble(cleaned_bytes[i])?;
18071            let lo = hex_nibble(cleaned_bytes[i + 1])?;
18072            out.push((hi << 4) | lo);
18073        }
18074        return Ok(out);
18075    }
18076    // Escape form or raw. Walk char-by-char; `\\` and `\NNN` octal
18077    // sequences decode; anything else is a literal byte.
18078    let bytes = s.as_bytes();
18079    let mut out = alloc::vec::Vec::with_capacity(bytes.len());
18080    let mut i = 0;
18081    while i < bytes.len() {
18082        let b = bytes[i];
18083        if b == b'\\' && i + 1 < bytes.len() {
18084            let n = bytes[i + 1];
18085            if n == b'\\' {
18086                out.push(b'\\');
18087                i += 2;
18088                continue;
18089            }
18090            if n.is_ascii_digit()
18091                && i + 3 < bytes.len()
18092                && bytes[i + 2].is_ascii_digit()
18093                && bytes[i + 3].is_ascii_digit()
18094            {
18095                let oct = |x: u8| (x - b'0') as u32;
18096                let v = oct(n) * 64 + oct(bytes[i + 2]) * 8 + oct(bytes[i + 3]);
18097                if v <= 0xFF {
18098                    out.push(v as u8);
18099                    i += 4;
18100                    continue;
18101                }
18102            }
18103        }
18104        out.push(b);
18105        i += 1;
18106    }
18107    Ok(out)
18108}
18109
18110fn hex_nibble(b: u8) -> Result<u8, &'static str> {
18111    match b {
18112        b'0'..=b'9' => Ok(b - b'0'),
18113        b'a'..=b'f' => Ok(b - b'a' + 10),
18114        b'A'..=b'F' => Ok(b - b'A' + 10),
18115        _ => Err("invalid hex digit"),
18116    }
18117}
18118
18119/// v7.10.11 — decode a PG TEXT[] external array form
18120/// (`{a,b,NULL}` with optional double-quoted elements). The
18121/// engine takes a leading/trailing `{`/`}` and splits at commas.
18122/// Quoted elements (`"hello, world"`) preserve embedded commas;
18123/// `\\` and `\"` decode to literal backslash / quote. Plain
18124/// unquoted `NULL` (case-insensitive) maps to `None`.
18125/// v7.11.13 — pick the array type for `ARRAY[lit, …]` from the
18126/// element values. Single-element-type rules:
18127///   - all NULL / all Text → TextArray
18128///   - all Int (or Int+NULL) → IntArray
18129///   - any BigInt without Text → BigIntArray (widening)
18130///   - any Text → TextArray (fallback; non-string elements
18131///     render as text)
18132fn array_literal_widen(items: alloc::vec::Vec<Value>) -> Value {
18133    let mut has_text = false;
18134    let mut has_bigint = false;
18135    let mut has_int = false;
18136    for v in &items {
18137        match v {
18138            Value::Null => {}
18139            Value::Text(_) | Value::Json(_) => has_text = true,
18140            Value::BigInt(_) => has_bigint = true,
18141            Value::Int(_) | Value::SmallInt(_) => has_int = true,
18142            _ => has_text = true,
18143        }
18144    }
18145    if has_text || (!has_bigint && !has_int) {
18146        let out: alloc::vec::Vec<Option<alloc::string::String>> = items
18147            .into_iter()
18148            .map(|v| match v {
18149                Value::Null => None,
18150                Value::Text(s) | Value::Json(s) => Some(s),
18151                other => Some(alloc::format!("{other:?}")),
18152            })
18153            .collect();
18154        return Value::TextArray(out);
18155    }
18156    if has_bigint {
18157        let out: alloc::vec::Vec<Option<i64>> = items
18158            .into_iter()
18159            .map(|v| match v {
18160                Value::Null => None,
18161                Value::Int(n) => Some(i64::from(n)),
18162                Value::SmallInt(n) => Some(i64::from(n)),
18163                Value::BigInt(n) => Some(n),
18164                _ => unreachable!("widen: unexpected non-integer in BigInt path"),
18165            })
18166            .collect();
18167        return Value::BigIntArray(out);
18168    }
18169    let out: alloc::vec::Vec<Option<i32>> = items
18170        .into_iter()
18171        .map(|v| match v {
18172            Value::Null => None,
18173            Value::Int(n) => Some(n),
18174            Value::SmallInt(n) => Some(i32::from(n)),
18175            _ => unreachable!("widen: unexpected non-i32-compatible in Int path"),
18176        })
18177        .collect();
18178    Value::IntArray(out)
18179}
18180
18181fn decode_text_array_literal(
18182    s: &str,
18183) -> Result<alloc::vec::Vec<Option<alloc::string::String>>, &'static str> {
18184    let trimmed = s.trim();
18185    let inner = trimmed
18186        .strip_prefix('{')
18187        .and_then(|x| x.strip_suffix('}'))
18188        .ok_or("TEXT[] literal must be enclosed in '{...}'")?;
18189    let mut out: alloc::vec::Vec<Option<alloc::string::String>> = alloc::vec::Vec::new();
18190    if inner.trim().is_empty() {
18191        return Ok(out);
18192    }
18193    let bytes = inner.as_bytes();
18194    let mut i = 0;
18195    while i <= bytes.len() {
18196        // Skip leading whitespace.
18197        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
18198            i += 1;
18199        }
18200        // Quoted element.
18201        if i < bytes.len() && bytes[i] == b'"' {
18202            i += 1; // open quote
18203            let mut buf = alloc::string::String::new();
18204            while i < bytes.len() && bytes[i] != b'"' {
18205                if bytes[i] == b'\\' && i + 1 < bytes.len() {
18206                    buf.push(bytes[i + 1] as char);
18207                    i += 2;
18208                } else {
18209                    buf.push(bytes[i] as char);
18210                    i += 1;
18211                }
18212            }
18213            if i >= bytes.len() {
18214                return Err("unterminated quoted element");
18215            }
18216            i += 1; // close quote
18217            out.push(Some(buf));
18218        } else {
18219            // Unquoted element — read until next comma or end.
18220            let start = i;
18221            while i < bytes.len() && bytes[i] != b',' {
18222                i += 1;
18223            }
18224            let raw = inner[start..i].trim();
18225            if raw.eq_ignore_ascii_case("NULL") {
18226                out.push(None);
18227            } else {
18228                out.push(Some(alloc::string::ToString::to_string(raw)));
18229            }
18230        }
18231        // Skip whitespace, expect comma or end.
18232        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
18233            i += 1;
18234        }
18235        if i >= bytes.len() {
18236            break;
18237        }
18238        if bytes[i] != b',' {
18239            return Err("expected ',' between TEXT[] elements");
18240        }
18241        i += 1;
18242    }
18243    Ok(out)
18244}
18245
18246/// v7.10.11 — encode a TEXT[] back into the PG external array
18247/// form. NULL elements become the literal `NULL`; elements
18248/// containing commas, quotes, backslashes, or braces are
18249/// double-quoted with `\\` / `\"` escapes.
18250fn encode_text_array(items: &[Option<alloc::string::String>]) -> alloc::string::String {
18251    let mut out = alloc::string::String::with_capacity(2 + items.len() * 8);
18252    out.push('{');
18253    for (i, item) in items.iter().enumerate() {
18254        if i > 0 {
18255            out.push(',');
18256        }
18257        match item {
18258            None => out.push_str("NULL"),
18259            Some(s) => {
18260                let needs_quote = s.is_empty()
18261                    || s.eq_ignore_ascii_case("NULL")
18262                    || s.chars()
18263                        .any(|c| matches!(c, ',' | '{' | '}' | '"' | '\\' | ' ' | '\t'));
18264                if needs_quote {
18265                    out.push('"');
18266                    for c in s.chars() {
18267                        if c == '"' || c == '\\' {
18268                            out.push('\\');
18269                        }
18270                        out.push(c);
18271                    }
18272                    out.push('"');
18273                } else {
18274                    out.push_str(s);
18275                }
18276            }
18277        }
18278    }
18279    out.push('}');
18280    out
18281}
18282
18283/// v7.10.4 — encode BYTEA bytes in PG hex output format
18284/// (`\x` prefix, lowercase hex pairs). Used by Text-side
18285/// round-trip + the wire layer's text-mode encoder.
18286fn encode_bytea_hex(b: &[u8]) -> alloc::string::String {
18287    let mut out = alloc::string::String::with_capacity(2 + 2 * b.len());
18288    out.push_str("\\x");
18289    for byte in b {
18290        let hi = byte >> 4;
18291        let lo = byte & 0x0F;
18292        out.push(hex_digit(hi));
18293        out.push(hex_digit(lo));
18294    }
18295    out
18296}
18297
18298const fn hex_digit(n: u8) -> char {
18299    match n {
18300        0..=9 => (b'0' + n) as char,
18301        10..=15 => (b'a' + n - 10) as char,
18302        _ => '?',
18303    }
18304}
18305
18306/// v7.17.0 Phase 3.P0-39 — parse a PG `hstore` text literal into
18307/// a flat key→value map. Empty string → empty map. Duplicate
18308/// keys take last-write-wins (matches PG `hstore_in`).
18309///
18310/// Accepted shapes (minimal subset):
18311///   * `'a=>1, b=>2'`            — bareword keys/values
18312///   * `'"a"=>"1", "b"=>"2"'`    — quoted keys/values
18313///   * `'a=>NULL'`               — case-insensitive NULL token
18314///     surfaces as `None` (no quotes around NULL)
18315///
18316/// Returns None on parse failure → caller surfaces as hard error.
18317fn parse_hstore_str(
18318    s: &str,
18319) -> Option<Vec<(alloc::string::String, Option<alloc::string::String>)>> {
18320    let bytes = s.as_bytes();
18321    let mut i = 0;
18322    let mut out: Vec<(alloc::string::String, Option<alloc::string::String>)> = Vec::new();
18323    let skip_ws = |bytes: &[u8], i: &mut usize| {
18324        while *i < bytes.len() && matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r') {
18325            *i += 1;
18326        }
18327    };
18328    let parse_token = |bytes: &[u8], i: &mut usize| -> Option<alloc::string::String> {
18329        if *i >= bytes.len() {
18330            return None;
18331        }
18332        if bytes[*i] == b'"' {
18333            *i += 1;
18334            let mut out = alloc::string::String::new();
18335            while *i < bytes.len() {
18336                match bytes[*i] {
18337                    b'"' => {
18338                        *i += 1;
18339                        return Some(out);
18340                    }
18341                    b'\\' if *i + 1 < bytes.len() => {
18342                        out.push(bytes[*i + 1] as char);
18343                        *i += 2;
18344                    }
18345                    c => {
18346                        out.push(c as char);
18347                        *i += 1;
18348                    }
18349                }
18350            }
18351            None
18352        } else {
18353            let start = *i;
18354            while *i < bytes.len()
18355                && !matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r' | b',' | b'=')
18356            {
18357                *i += 1;
18358            }
18359            if *i == start {
18360                return None;
18361            }
18362            Some(alloc::str::from_utf8(&bytes[start..*i]).ok()?.to_string())
18363        }
18364    };
18365    skip_ws(bytes, &mut i);
18366    while i < bytes.len() {
18367        let key = parse_token(bytes, &mut i)?;
18368        skip_ws(bytes, &mut i);
18369        if i + 1 >= bytes.len() || bytes[i] != b'=' || bytes[i + 1] != b'>' {
18370            return None;
18371        }
18372        i += 2;
18373        skip_ws(bytes, &mut i);
18374        // Check for unquoted NULL token (case-insensitive).
18375        let val_token = if i + 4 <= bytes.len()
18376            && bytes[i..i + 4].eq_ignore_ascii_case(b"NULL")
18377            && (i + 4 == bytes.len() || matches!(bytes[i + 4], b' ' | b'\t' | b',' | b'\n' | b'\r'))
18378        {
18379            i += 4;
18380            None
18381        } else {
18382            Some(parse_token(bytes, &mut i)?)
18383        };
18384        // Replace any existing entry with the same key (last-wins).
18385        if let Some(pos) = out.iter().position(|(k, _)| k == &key) {
18386            out[pos] = (key, val_token);
18387        } else {
18388            out.push((key, val_token));
18389        }
18390        skip_ws(bytes, &mut i);
18391        if i >= bytes.len() {
18392            break;
18393        }
18394        if bytes[i] == b',' {
18395            i += 1;
18396            skip_ws(bytes, &mut i);
18397            continue;
18398        }
18399        return None;
18400    }
18401    Some(out)
18402}
18403
18404/// v7.17.0 Phase 3.P0-39 — render a hstore as canonical PG text
18405/// form `"k"=>"v"` (keys and non-NULL values always quoted;
18406/// NULL token is bare).
18407fn format_hstore_str(
18408    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
18409) -> alloc::string::String {
18410    let mut out = alloc::string::String::new();
18411    for (i, (k, v)) in pairs.iter().enumerate() {
18412        if i > 0 {
18413            out.push_str(", ");
18414        }
18415        out.push('"');
18416        out.push_str(k);
18417        out.push_str("\"=>");
18418        match v {
18419            None => out.push_str("NULL"),
18420            Some(val) => {
18421                out.push('"');
18422                out.push_str(val);
18423                out.push('"');
18424            }
18425        }
18426    }
18427    out
18428}
18429
18430/// v7.17.0 Phase 3.P0-39 — pub re-export so pgwire + sqllogictest
18431/// share the single hstore renderer.
18432pub fn format_hstore_text(
18433    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
18434) -> alloc::string::String {
18435    format_hstore_str(pairs)
18436}
18437
18438// ─── v7.17.0 Phase 3.P0-40 — 2D array parse + display ─────────
18439
18440/// Split a PG external 2D-array literal `'{{a,b},{c,d}}'` into
18441/// per-row token lists. Returns Err on shape mismatch.
18442fn split_2d_literal(s: &str) -> Result<Vec<Vec<alloc::string::String>>, &'static str> {
18443    let s = s.trim();
18444    let outer = s
18445        .strip_prefix('{')
18446        .and_then(|x| x.strip_suffix('}'))
18447        .ok_or("missing outer '{...}' braces")?;
18448    let trimmed = outer.trim();
18449    if trimmed.is_empty() {
18450        return Ok(Vec::new());
18451    }
18452    let mut rows: Vec<Vec<alloc::string::String>> = Vec::new();
18453    let mut i = 0;
18454    let bytes = trimmed.as_bytes();
18455    while i < bytes.len() {
18456        while i < bytes.len() && matches!(bytes[i], b' ' | b'\t' | b'\n' | b'\r' | b',') {
18457            i += 1;
18458        }
18459        if i >= bytes.len() {
18460            break;
18461        }
18462        if bytes[i] != b'{' {
18463            return Err("expected '{' opening a row");
18464        }
18465        i += 1;
18466        let row_start = i;
18467        let mut depth = 1;
18468        while i < bytes.len() && depth > 0 {
18469            match bytes[i] {
18470                b'{' => depth += 1,
18471                b'}' => depth -= 1,
18472                _ => {}
18473            }
18474            if depth > 0 {
18475                i += 1;
18476            }
18477        }
18478        if depth != 0 {
18479            return Err("unbalanced '{...}' in row");
18480        }
18481        let row_text = &trimmed[row_start..i];
18482        i += 1;
18483        let cells: Vec<alloc::string::String> = if row_text.trim().is_empty() {
18484            Vec::new()
18485        } else {
18486            row_text.split(',').map(|t| t.trim().to_string()).collect()
18487        };
18488        rows.push(cells);
18489    }
18490    if let Some(first) = rows.first() {
18491        let cols = first.len();
18492        for r in &rows {
18493            if r.len() != cols {
18494                return Err("ragged 2D array (rows have different column counts)");
18495            }
18496        }
18497    }
18498    Ok(rows)
18499}
18500
18501fn parse_int_2d_literal(s: &str) -> Result<Vec<Vec<Option<i32>>>, &'static str> {
18502    let raw = split_2d_literal(s)?;
18503    raw.into_iter()
18504        .map(|row| {
18505            row.into_iter()
18506                .map(|cell| {
18507                    if cell.eq_ignore_ascii_case("NULL") {
18508                        Ok(None)
18509                    } else {
18510                        cell.parse::<i32>()
18511                            .map(Some)
18512                            .map_err(|_| "invalid int element")
18513                    }
18514                })
18515                .collect()
18516        })
18517        .collect()
18518}
18519
18520fn parse_bigint_2d_literal(s: &str) -> Result<Vec<Vec<Option<i64>>>, &'static str> {
18521    let raw = split_2d_literal(s)?;
18522    raw.into_iter()
18523        .map(|row| {
18524            row.into_iter()
18525                .map(|cell| {
18526                    if cell.eq_ignore_ascii_case("NULL") {
18527                        Ok(None)
18528                    } else {
18529                        cell.parse::<i64>()
18530                            .map(Some)
18531                            .map_err(|_| "invalid bigint element")
18532                    }
18533                })
18534                .collect()
18535        })
18536        .collect()
18537}
18538
18539fn parse_text_2d_literal(s: &str) -> Result<Vec<Vec<Option<alloc::string::String>>>, &'static str> {
18540    let raw = split_2d_literal(s)?;
18541    Ok(raw
18542        .into_iter()
18543        .map(|row| {
18544            row.into_iter()
18545                .map(|cell| {
18546                    if cell.eq_ignore_ascii_case("NULL") {
18547                        None
18548                    } else {
18549                        Some(cell.trim_matches('"').to_string())
18550                    }
18551                })
18552                .collect()
18553        })
18554        .collect())
18555}
18556
18557fn format_int_2d_text(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
18558    let mut out = alloc::string::String::from("{");
18559    for (i, row) in rows.iter().enumerate() {
18560        if i > 0 {
18561            out.push(',');
18562        }
18563        out.push('{');
18564        for (j, cell) in row.iter().enumerate() {
18565            if j > 0 {
18566                out.push(',');
18567            }
18568            match cell {
18569                None => out.push_str("NULL"),
18570                Some(n) => out.push_str(&alloc::format!("{n}")),
18571            }
18572        }
18573        out.push('}');
18574    }
18575    out.push('}');
18576    out
18577}
18578
18579fn format_bigint_2d_text(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
18580    let mut out = alloc::string::String::from("{");
18581    for (i, row) in rows.iter().enumerate() {
18582        if i > 0 {
18583            out.push(',');
18584        }
18585        out.push('{');
18586        for (j, cell) in row.iter().enumerate() {
18587            if j > 0 {
18588                out.push(',');
18589            }
18590            match cell {
18591                None => out.push_str("NULL"),
18592                Some(n) => out.push_str(&alloc::format!("{n}")),
18593            }
18594        }
18595        out.push('}');
18596    }
18597    out.push('}');
18598    out
18599}
18600
18601fn format_text_2d_text(rows: &[Vec<Option<alloc::string::String>>]) -> alloc::string::String {
18602    let mut out = alloc::string::String::from("{");
18603    for (i, row) in rows.iter().enumerate() {
18604        if i > 0 {
18605            out.push(',');
18606        }
18607        out.push('{');
18608        for (j, cell) in row.iter().enumerate() {
18609            if j > 0 {
18610                out.push(',');
18611            }
18612            match cell {
18613                None => out.push_str("NULL"),
18614                Some(s) => out.push_str(s),
18615            }
18616        }
18617        out.push('}');
18618    }
18619    out.push('}');
18620    out
18621}
18622
18623/// v7.17.0 Phase 3.P0-40 — pub re-exports so pgwire + sqllogictest
18624/// share the single 2D-array renderer.
18625pub fn format_int_2d_text_pub(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
18626    format_int_2d_text(rows)
18627}
18628pub fn format_bigint_2d_text_pub(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
18629    format_bigint_2d_text(rows)
18630}
18631pub fn format_text_2d_text_pub(
18632    rows: &[Vec<Option<alloc::string::String>>],
18633) -> alloc::string::String {
18634    format_text_2d_text(rows)
18635}
18636
18637/// v7.17.0 Phase 3.P0-38 — parse a PG range literal of the form
18638/// `'[lo,up)'` / `'(lo,up]'` / `'[lo,up]'` / `'(lo,up)'` /
18639/// `'empty'`. Lower / upper may be empty (unbounded). Returns
18640/// `None` on any parse failure; caller surfaces as hard error.
18641fn parse_range_str(s: &str, kind: spg_storage::RangeKind) -> Option<Value> {
18642    let s = s.trim();
18643    if s.eq_ignore_ascii_case("empty") {
18644        return Some(Value::Range {
18645            kind,
18646            lower: None,
18647            upper: None,
18648            lower_inc: false,
18649            upper_inc: false,
18650            empty: true,
18651        });
18652    }
18653    let bytes = s.as_bytes();
18654    if bytes.len() < 3 {
18655        return None;
18656    }
18657    let lower_inc = match bytes[0] {
18658        b'[' => true,
18659        b'(' => false,
18660        _ => return None,
18661    };
18662    let upper_inc = match bytes[bytes.len() - 1] {
18663        b']' => true,
18664        b')' => false,
18665        _ => return None,
18666    };
18667    let inner = &s[1..s.len() - 1];
18668    let (lo_text, up_text) = inner.split_once(',')?;
18669    let lower = if lo_text.is_empty() {
18670        None
18671    } else {
18672        Some(alloc::boxed::Box::new(parse_range_element(lo_text, kind)?))
18673    };
18674    let upper = if up_text.is_empty() {
18675        None
18676    } else {
18677        Some(alloc::boxed::Box::new(parse_range_element(up_text, kind)?))
18678    };
18679    Some(Value::Range {
18680        kind,
18681        lower,
18682        upper,
18683        lower_inc,
18684        upper_inc,
18685        empty: false,
18686    })
18687}
18688
18689/// v7.17.0 Phase 3.P0-38 — parse a single range bound text into
18690/// the matching element Value for the RangeKind.
18691fn parse_range_element(text: &str, kind: spg_storage::RangeKind) -> Option<Value> {
18692    let text = text.trim().trim_matches('"');
18693    use spg_storage::RangeKind as K;
18694    match kind {
18695        K::Int4 => text.parse::<i32>().ok().map(Value::Int),
18696        K::Int8 => text.parse::<i64>().ok().map(Value::BigInt),
18697        K::Num => {
18698            // Reuse the Numeric parse via the engine's text-coercion
18699            // path; bail to None on failure.
18700            let dot = text.find('.');
18701            let scale: u8 = dot.map_or(0, |p| (text.len() - p - 1) as u8);
18702            let digits: alloc::string::String = text
18703                .chars()
18704                .filter(|c| *c == '-' || c.is_ascii_digit())
18705                .collect();
18706            let scaled: i128 = digits.parse().ok()?;
18707            Some(Value::Numeric { scaled, scale })
18708        }
18709        K::Ts | K::TsTz => {
18710            // Reuse the existing timestamp parse path. v7.17.0
18711            // expects `'YYYY-MM-DD HH:MM:SS[.ffffff]'` in range
18712            // bounds (TZ offset on TsTz is OOS for the initial
18713            // P0-38; ship plain Timestamp shape).
18714            crate::eval::parse_timestamp_literal(text).map(Value::Timestamp)
18715        }
18716        K::Date => crate::eval::parse_date_literal(text).map(Value::Date),
18717    }
18718}
18719
18720/// v7.17.0 Phase 3.P0-38 — render a Range value as its canonical
18721/// PG text form. Re-exported via [`format_range_text`] for use
18722/// from spg-server's pgwire layer.
18723pub fn format_range_text(v: &Value) -> alloc::string::String {
18724    format_range_str(v)
18725}
18726
18727fn format_range_str(v: &Value) -> alloc::string::String {
18728    let Value::Range {
18729        lower,
18730        upper,
18731        lower_inc,
18732        upper_inc,
18733        empty,
18734        ..
18735    } = v
18736    else {
18737        return alloc::string::String::new();
18738    };
18739    if *empty {
18740        return "empty".into();
18741    }
18742    let mut out = alloc::string::String::new();
18743    out.push(if *lower_inc { '[' } else { '(' });
18744    if let Some(l) = lower {
18745        out.push_str(&format_range_element(l));
18746    }
18747    out.push(',');
18748    if let Some(u) = upper {
18749        out.push_str(&format_range_element(u));
18750    }
18751    out.push(if *upper_inc { ']' } else { ')' });
18752    out
18753}
18754
18755fn format_range_element(v: &Value) -> alloc::string::String {
18756    match v {
18757        Value::Int(n) => alloc::format!("{n}"),
18758        Value::BigInt(n) => alloc::format!("{n}"),
18759        Value::Date(d) => crate::eval::format_date(*d),
18760        Value::Timestamp(t) => crate::eval::format_timestamp(*t),
18761        Value::Numeric { scaled, scale } => crate::eval::format_numeric(*scaled, *scale),
18762        other => alloc::format!("{other:?}"),
18763    }
18764}
18765
18766/// v7.17.0 Phase 3.P0-35 — parse a PG `money` literal into i64
18767/// cents. Accepts:
18768///   * Optional leading `-` (negative)
18769///   * Optional `$` prefix
18770///   * Integer portion with optional `,` thousands separators
18771///   * Optional `.` followed by 1-2 digits (cents); 1 digit
18772///     auto-pads to 2 (`.5` → 50 cents).
18773///
18774/// Returns None on any parse failure — caller surfaces as hard
18775/// SQL error.
18776fn parse_money_str(s: &str) -> Option<i64> {
18777    let s = s.trim();
18778    let (neg, rest) = match s.strip_prefix('-') {
18779        Some(r) => (true, r.trim_start()),
18780        None => (false, s),
18781    };
18782    let rest = rest.strip_prefix('$').unwrap_or(rest).trim_start();
18783    let (int_part, frac_part) = match rest.split_once('.') {
18784        Some((i, f)) => (i, Some(f)),
18785        None => (rest, None),
18786    };
18787    if int_part.is_empty() {
18788        return None;
18789    }
18790    // Validate + strip commas from the integer portion.
18791    let mut int_digits = alloc::string::String::with_capacity(int_part.len());
18792    for b in int_part.bytes() {
18793        match b {
18794            b',' => {}
18795            b'0'..=b'9' => int_digits.push(b as char),
18796            _ => return None,
18797        }
18798    }
18799    if int_digits.is_empty() {
18800        return None;
18801    }
18802    let dollars: i64 = int_digits.parse().ok()?;
18803    let cents: i64 = match frac_part {
18804        None => 0,
18805        Some(f) => {
18806            if f.is_empty() || f.len() > 2 || !f.bytes().all(|b| b.is_ascii_digit()) {
18807                return None;
18808            }
18809            let padded = if f.len() == 1 {
18810                alloc::format!("{f}0")
18811            } else {
18812                f.to_string()
18813            };
18814            padded.parse().ok()?
18815        }
18816    };
18817    let total = dollars.checked_mul(100)?.checked_add(cents)?;
18818    Some(if neg { -total } else { total })
18819}
18820
18821/// v7.17.0 Phase 3.P0-34 — parse a PG `timetz` literal
18822/// `HH:MM:SS[.fraction]±HH[:MM]` into (us, offset_secs).
18823///
18824/// The offset suffix is MANDATORY: SPG doesn't have a session TZ
18825/// wired into eval, so a bare `HH:MM:SS` literal would be
18826/// ambiguous. Returns None for any parse failure or out-of-range
18827/// component — caller surfaces as a hard SQL error.
18828///
18829/// Offset range: ±14 hours (±50400 seconds), matching PG's
18830/// internal limit.
18831fn parse_timetz_str(s: &str) -> Option<(i64, i32)> {
18832    let s = s.trim();
18833    // Find the offset sign — scan from right since the time part
18834    // never contains '+' / '-' (after the optional fractional dot
18835    // it's all digits and ':').
18836    let bytes = s.as_bytes();
18837    let sign_pos = bytes
18838        .iter()
18839        .enumerate()
18840        .rev()
18841        .find(|&(_, &b)| b == b'+' || b == b'-')
18842        .map(|(i, _)| i)?;
18843    if sign_pos == 0 {
18844        return None; // bare sign — no time component
18845    }
18846    let time_part = &s[..sign_pos];
18847    let offset_part = &s[sign_pos..];
18848    let us = parse_time_str(time_part)?;
18849    let sign: i32 = if offset_part.starts_with('+') { 1 } else { -1 };
18850    let offset_body = &offset_part[1..];
18851    let (hh_str, mm_str) = match offset_body.split_once(':') {
18852        Some((h, m)) => (h, m),
18853        None => (offset_body, "0"),
18854    };
18855    let hh: i32 = hh_str.parse().ok()?;
18856    let mm: i32 = mm_str.parse().ok()?;
18857    if !(0..=14).contains(&hh) || !(0..=59).contains(&mm) {
18858        return None;
18859    }
18860    let total = sign * (hh * 3600 + mm * 60);
18861    if total.abs() > 50_400 {
18862        return None;
18863    }
18864    Some((us, total))
18865}
18866
18867/// v7.17.0 Phase 3.P0-33 — funnel an integer literal through MySQL
18868/// YEAR range validation: 0 sentinel or 1901..=2155. Out-of-range
18869/// surfaces as a hard SQL error (no silent truncation, mirrors PG
18870/// `time_in` / `uuid_in` discipline).
18871fn coerce_int_to_year(n: i64, col_name: &str) -> Result<Value, EngineError> {
18872    if n == 0 || (1901..=2155).contains(&n) {
18873        // u16::try_from cannot fail in this range; the cast also
18874        // covers the 0 sentinel.
18875        return Ok(Value::Year(n as u16));
18876    }
18877    Err(EngineError::Eval(EvalError::TypeMismatch {
18878        detail: alloc::format!(
18879            "year value out of range: {n} (column `{col_name}`; \
18880             MySQL accepts 0 or 1901..=2155)"
18881        ),
18882    }))
18883}
18884
18885/// v7.17.0 Phase 3.P0-32 — parse a PG `time` literal
18886/// `HH:MM:SS[.fraction]` into microseconds since 00:00:00.
18887///
18888/// Accepts:
18889///   * `HH:MM:SS`            — exact-second precision
18890///   * `HH:MM:SS.f` .. `.ffffff` — 1-6 fractional digits, right-padded
18891///     with zeros to microseconds
18892///
18893/// Range: hour 0..=23, minute 0..=59, second 0..=59. Anything else
18894/// returns None — caller surfaces as a hard SQL error (no silent
18895/// truncation, matches PG's `time_in` behaviour).
18896fn parse_time_str(s: &str) -> Option<i64> {
18897    let s = s.trim();
18898    let (hms, frac) = match s.split_once('.') {
18899        Some((h, f)) => (h, Some(f)),
18900        None => (s, None),
18901    };
18902    let mut parts = hms.split(':');
18903    let hh: u32 = parts.next()?.parse().ok()?;
18904    let mm: u32 = parts.next()?.parse().ok()?;
18905    let ss: u32 = parts.next()?.parse().ok()?;
18906    if parts.next().is_some() {
18907        return None;
18908    }
18909    if hh > 23 || mm > 59 || ss > 59 {
18910        return None;
18911    }
18912    let frac_us: i64 = match frac {
18913        None => 0,
18914        Some(f) => {
18915            if f.is_empty() || f.len() > 6 || !f.bytes().all(|b| b.is_ascii_digit()) {
18916                return None;
18917            }
18918            // Right-pad with zeros so '.5' = 500000 µsec.
18919            let mut padded = alloc::string::String::with_capacity(6);
18920            padded.push_str(f);
18921            while padded.len() < 6 {
18922                padded.push('0');
18923            }
18924            padded.parse().ok()?
18925        }
18926    };
18927    Some(
18928        i64::from(hh) * 3_600_000_000
18929            + i64::from(mm) * 60_000_000
18930            + i64::from(ss) * 1_000_000
18931            + frac_us,
18932    )
18933}
18934
18935const fn column_type_to_data_type(t: ColumnTypeName) -> DataType {
18936    match t {
18937        ColumnTypeName::SmallInt => DataType::SmallInt,
18938        ColumnTypeName::Int => DataType::Int,
18939        ColumnTypeName::BigInt => DataType::BigInt,
18940        ColumnTypeName::Float => DataType::Float,
18941        ColumnTypeName::Text => DataType::Text,
18942        ColumnTypeName::Varchar(n) => DataType::Varchar(n),
18943        ColumnTypeName::Char(n) => DataType::Char(n),
18944        ColumnTypeName::Bool => DataType::Bool,
18945        ColumnTypeName::Vector { dim, encoding } => DataType::Vector {
18946            dim,
18947            encoding: match encoding {
18948                SqlVecEncoding::F32 => VecEncoding::F32,
18949                SqlVecEncoding::Sq8 => VecEncoding::Sq8,
18950                SqlVecEncoding::F16 => VecEncoding::F16,
18951            },
18952        },
18953        ColumnTypeName::Numeric(precision, scale) => DataType::Numeric { precision, scale },
18954        ColumnTypeName::Date => DataType::Date,
18955        ColumnTypeName::Timestamp => DataType::Timestamp,
18956        ColumnTypeName::Timestamptz => DataType::Timestamptz,
18957        ColumnTypeName::Json => DataType::Json,
18958        ColumnTypeName::Jsonb => DataType::Jsonb,
18959        ColumnTypeName::Bytes => DataType::Bytes,
18960        ColumnTypeName::TextArray => DataType::TextArray,
18961        ColumnTypeName::IntArray => DataType::IntArray,
18962        ColumnTypeName::BigIntArray => DataType::BigIntArray,
18963        ColumnTypeName::TsVector => DataType::TsVector,
18964        ColumnTypeName::TsQuery => DataType::TsQuery,
18965        ColumnTypeName::Uuid => DataType::Uuid,
18966        ColumnTypeName::Time => DataType::Time,
18967        ColumnTypeName::Year => DataType::Year,
18968        ColumnTypeName::TimeTz => DataType::TimeTz,
18969        ColumnTypeName::Money => DataType::Money,
18970        ColumnTypeName::Range(k) => DataType::Range(match k {
18971            spg_sql::ast::RangeKindAst::Int4 => spg_storage::RangeKind::Int4,
18972            spg_sql::ast::RangeKindAst::Int8 => spg_storage::RangeKind::Int8,
18973            spg_sql::ast::RangeKindAst::Num => spg_storage::RangeKind::Num,
18974            spg_sql::ast::RangeKindAst::Ts => spg_storage::RangeKind::Ts,
18975            spg_sql::ast::RangeKindAst::TsTz => spg_storage::RangeKind::TsTz,
18976            spg_sql::ast::RangeKindAst::Date => spg_storage::RangeKind::Date,
18977        }),
18978        ColumnTypeName::Hstore => DataType::Hstore,
18979        ColumnTypeName::IntArray2D => DataType::IntArray2D,
18980        ColumnTypeName::BigIntArray2D => DataType::BigIntArray2D,
18981        ColumnTypeName::TextArray2D => DataType::TextArray2D,
18982    }
18983}
18984
18985/// Convert an INSERT VALUES expression to a storage Value. Supports literal
18986/// expressions, unary-minus over numeric literals, and pgvector-style
18987/// `'[..]'::vector` cast (v1.2). Anything more complex returns `Unsupported`.
18988fn literal_expr_to_value(expr: Expr) -> Result<Value, EngineError> {
18989    match expr {
18990        Expr::Literal(l) => Ok(literal_to_value(l)),
18991        Expr::Cast { expr, target } => {
18992            let inner_value = literal_expr_to_value(*expr)?;
18993            crate::eval::cast_value(inner_value, target).map_err(EngineError::Eval)
18994        }
18995        Expr::Unary {
18996            op: UnOp::Neg,
18997            expr,
18998        } => match *expr {
18999            Expr::Literal(Literal::Integer(n)) => {
19000                // Fold to i32 if it fits, else BigInt. Parser emits Integer(i64)
19001                // — overflow on negate of i64::MIN is the one edge case.
19002                let neg = n.checked_neg().ok_or_else(|| {
19003                    EngineError::Unsupported("integer literal overflow on negation".into())
19004                })?;
19005                Ok(int_value_for(neg))
19006            }
19007            Expr::Literal(Literal::Float(x)) => Ok(Value::Float(-x)),
19008            other => Err(EngineError::Unsupported(alloc::format!(
19009                "unary minus over non-literal expression: {other:?}"
19010            ))),
19011        },
19012        // v7.10.10 — `ARRAY[lit, lit, …]` constructor accepted at
19013        // INSERT-time. Each element must reduce to a Value through
19014        // `literal_expr_to_value`; NULL elements become `None`.
19015        // v7.11.13 — deduce shape from element values: all Int →
19016        // IntArray; any BigInt → BigIntArray (widening); any Text
19017        // → TextArray. Cast targets (`ARRAY[]::INT[]`) flow through
19018        // the outer Cast arm before reaching here and re-coerce.
19019        Expr::Array(items) => {
19020            let mut materialised: alloc::vec::Vec<Value> =
19021                alloc::vec::Vec::with_capacity(items.len());
19022            for elem in items {
19023                materialised.push(literal_expr_to_value(elem)?);
19024            }
19025            Ok(array_literal_widen(materialised))
19026        }
19027        // Any other Expr shape — fall back to a general evaluation
19028        // against an empty row + empty schema. This unblocks the
19029        // app-common patterns where INSERT VALUES carries a
19030        // non-correlated function call:
19031        //   INSERT INTO t VALUES (concat('U-', 42))
19032        //   INSERT INTO t VALUES (now())
19033        //   INSERT INTO t VALUES (format('%s-%s', 'a', 'b'))
19034        // Any expression that references a column or `$N`
19035        // placeholder fails cleanly inside `eval_expr` with a
19036        // descriptive error; literals + casts + ARRAY[…] continue
19037        // to take the fast paths above so the hot INSERT path is
19038        // unchanged on the common case.
19039        other => {
19040            let empty_schema: alloc::vec::Vec<spg_storage::ColumnSchema> = alloc::vec::Vec::new();
19041            let ctx = EvalContext::new(&empty_schema, None);
19042            let empty_row = spg_storage::Row::new(alloc::vec::Vec::new());
19043            crate::eval::eval_expr(&other, &empty_row, &ctx).map_err(EngineError::Eval)
19044        }
19045    }
19046}
19047
19048fn literal_to_value(l: Literal) -> Value {
19049    match l {
19050        Literal::Integer(n) => int_value_for(n),
19051        Literal::Float(x) => Value::Float(x),
19052        Literal::String(s) => Value::Text(s),
19053        Literal::Bool(b) => Value::Bool(b),
19054        Literal::Null => Value::Null,
19055        Literal::Vector(v) => Value::Vector(v),
19056        Literal::TextArray(items) => Value::TextArray(items),
19057        Literal::IntArray(items) => Value::IntArray(items),
19058        Literal::BigIntArray(items) => Value::BigIntArray(items),
19059        Literal::Interval { months, micros, .. } => Value::Interval { months, micros },
19060    }
19061}
19062
19063/// Pick `Int` (`i32`) when the literal fits, else `BigInt`. `INT` vs `BIGINT`
19064/// columns will still enforce the right tag downstream — this is just the
19065/// default we synthesise from an unannotated integer literal.
19066fn int_value_for(n: i64) -> Value {
19067    if let Ok(small) = i32::try_from(n) {
19068        Value::Int(small)
19069    } else {
19070        Value::BigInt(n)
19071    }
19072}
19073
19074/// Widen / narrow `v` to fit `expected`. Numerics permit safe widening
19075/// (`Int → BigInt`, `Int/BigInt → Float`) and best-effort narrowing
19076/// (`BigInt → Int` succeeds only when the value fits in `i32`). Everything
19077/// else returns `TypeMismatch` carrying the column name for caller diagnostics.
19078/// `NULL` is always permitted; the nullability check happens later in storage.
19079#[allow(clippy::too_many_lines)]
19080/// v7.17.0 Phase 4.4 — reject negative integer values on UNSIGNED
19081/// columns. Called after `coerce_value` at each INSERT / UPDATE
19082/// site that has ColumnSchema context. NULL passes through (a
19083/// nullable UNSIGNED column can legitimately hold NULL).
19084fn check_unsigned_range(
19085    v: &Value,
19086    schema: &ColumnSchema,
19087    position: usize,
19088) -> Result<(), EngineError> {
19089    if !schema.is_unsigned {
19090        return Ok(());
19091    }
19092    let n = match v {
19093        Value::SmallInt(x) => i64::from(*x),
19094        Value::Int(x) => i64::from(*x),
19095        Value::BigInt(x) => *x,
19096        _ => return Ok(()), // non-integer cells (NULL, default) skip
19097    };
19098    if n < 0 {
19099        return Err(EngineError::Unsupported(alloc::format!(
19100            "column {:?} is UNSIGNED but got negative value {n} at position {position}",
19101            schema.name
19102        )));
19103    }
19104    Ok(())
19105}
19106
19107fn coerce_value(
19108    v: Value,
19109    expected: DataType,
19110    col_name: &str,
19111    position: usize,
19112) -> Result<Value, EngineError> {
19113    if v.is_null() {
19114        return Ok(Value::Null);
19115    }
19116    let actual = v.data_type().expect("non-null");
19117    if actual == expected {
19118        return Ok(v);
19119    }
19120    let coerced = match (v, expected) {
19121        (Value::Int(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
19122        (Value::Int(n), DataType::Float) => Some(Value::Float(f64::from(n))),
19123        (Value::Int(n), DataType::SmallInt) => i16::try_from(n).ok().map(Value::SmallInt),
19124        (Value::Int(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
19125            i128::from(n),
19126            precision,
19127            scale,
19128            col_name,
19129        )?),
19130        (Value::SmallInt(n), DataType::Int) => Some(Value::Int(i32::from(n))),
19131        (Value::SmallInt(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
19132        (Value::SmallInt(n), DataType::Float) => Some(Value::Float(f64::from(n))),
19133        (Value::SmallInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
19134            i128::from(n),
19135            precision,
19136            scale,
19137            col_name,
19138        )?),
19139        (Value::BigInt(n), DataType::Int) => i32::try_from(n).ok().map(Value::Int),
19140        (Value::BigInt(n), DataType::SmallInt) => i16::try_from(n).ok().map(Value::SmallInt),
19141        #[allow(clippy::cast_precision_loss)]
19142        (Value::BigInt(n), DataType::Float) => Some(Value::Float(n as f64)),
19143        (Value::BigInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
19144            i128::from(n),
19145            precision,
19146            scale,
19147            col_name,
19148        )?),
19149        (Value::Float(x), DataType::Numeric { precision, scale }) => {
19150            Some(numeric_from_float(x, precision, scale, col_name)?)
19151        }
19152        // v7.17.0 Phase 3.P0-67 — Text → NUMERIC. Parse a
19153        // canonical decimal text (`"-1234.56"` / `"42"` /
19154        // `"0.0001"`) into `(mantissa, source_scale)` and rescale
19155        // to the column's declared scale. Required for prepared
19156        // binds: `value_to_literal` flattens a Value::Numeric
19157        // into a TEXT literal because Literal carries no native
19158        // Numeric variant, so the placeholder substitution path
19159        // reaches coerce_value as Text → Numeric. Without this
19160        // arm the round-trip surfaces a TypeMismatch even though
19161        // the cell already left the engine as a valid Numeric.
19162        (Value::Text(s), DataType::Numeric { precision, scale }) => {
19163            let Some((mantissa, src_scale)) = parse_numeric_text(&s) else {
19164                return Err(EngineError::Eval(EvalError::TypeMismatch {
19165                    detail: alloc::format!("cannot parse {s:?} as NUMERIC for column `{col_name}`"),
19166                }));
19167            };
19168            Some(numeric_rescale(
19169                mantissa, src_scale, precision, scale, col_name,
19170            )?)
19171        }
19172        // Text → DATE / TIMESTAMP: parse canonical text forms.
19173        (Value::Text(s), DataType::Date) => {
19174            let d = eval::parse_date_literal(&s).ok_or_else(|| {
19175                EngineError::Eval(EvalError::TypeMismatch {
19176                    detail: alloc::format!("cannot parse {s:?} as DATE for column `{col_name}`"),
19177                })
19178            })?;
19179            Some(Value::Date(d))
19180        }
19181        // v7.14.0 — MySQL DEFAULT clauses quote integer / float
19182        // / boolean literals (`DEFAULT '0'`, `DEFAULT '1'`,
19183        // `DEFAULT '3.14'`, `DEFAULT 'true'`). Coerce the text
19184        // form to the column's numeric / bool type at DEFAULT-
19185        // installation time so the storage check sees a typed
19186        // value. Parse failures fall through to TypeMismatch.
19187        (Value::Text(s), DataType::SmallInt) => s.parse::<i16>().ok().map(Value::SmallInt),
19188        (Value::Text(s), DataType::Int) => s.parse::<i32>().ok().map(Value::Int),
19189        (Value::Text(s), DataType::BigInt) => s.parse::<i64>().ok().map(Value::BigInt),
19190        (Value::Text(s), DataType::Float) => s.parse::<f64>().ok().map(Value::Float),
19191        (Value::Text(s), DataType::Bool) => match s.to_ascii_lowercase().as_str() {
19192            "0" | "false" | "f" | "no" | "off" => Some(Value::Bool(false)),
19193            "1" | "true" | "t" | "yes" | "on" => Some(Value::Bool(true)),
19194            _ => None,
19195        },
19196        // v7.17.0 Phase 3.P0-46 — MySQL TINYINT(1) (which Phase 4.3
19197        // classifies as DataType::Bool) is the storage shape every
19198        // mysqldump-restored boolean column lands in. mysqldump emits
19199        // the values as integer `0` / `1` literals, so int → bool
19200        // coerce on INSERT is required for a 0-change cutover. MySQL's
19201        // rule is "any non-zero is truthy"; we follow that for all
19202        // signed int widths so the same coerce path serves an
19203        // explicit `BOOLEAN` column too.
19204        (Value::Int(n), DataType::Bool) => Some(Value::Bool(n != 0)),
19205        (Value::SmallInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
19206        (Value::BigInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
19207        // v4.9: Text ↔ JSON coercion. No structural validation —
19208        // any text literal is accepted; the responsibility for
19209        // valid JSON lies with the producer.
19210        (Value::Text(s), DataType::Json | DataType::Jsonb) => Some(Value::Json(s)),
19211        (Value::Json(s), DataType::Text) => Some(Value::Text(s)),
19212        // v7.13.3 — mailrs round-7 S10. SPG's storage represents
19213        // both JSON and JSONB on-disk as `Value::Json(String)` —
19214        // they share the underlying text payload. The cast
19215        // `'<text>'::jsonb` produces a Value::Json that needs to
19216        // satisfy a DataType::Jsonb column. Identity coerce in
19217        // both directions so JSON ↔ JSONB assignments work at all
19218        // INSERT / ALTER COLUMN TYPE / DEFAULT contexts.
19219        (Value::Json(s), DataType::Jsonb | DataType::Json) => Some(Value::Json(s)),
19220        // v7.10.4 — Text → BYTEA. Decode PG-style literal forms:
19221        //   - Hex:    `\x48656c6c6f`  (case-insensitive hex pairs)
19222        //   - Escape: `Hello\\000world`  (backslash + octal triples)
19223        //   - Plain:  any string → raw UTF-8 bytes (PG also accepts)
19224        // Errors surface as TypeMismatch so the operator gets a
19225        // clear "this literal isn't a bytea literal" hint.
19226        (Value::Text(s), DataType::Bytes) => {
19227            let bytes = decode_bytea_literal(&s).map_err(|e| {
19228                EngineError::Eval(EvalError::TypeMismatch {
19229                    detail: alloc::format!(
19230                        "cannot parse {s:?} as BYTEA for column `{col_name}`: {e}"
19231                    ),
19232                })
19233            })?;
19234            Some(Value::Bytes(bytes))
19235        }
19236        // v7.10.4 — BYTEA → Text round-trip uses the PG hex
19237        // output (lowercase, `\x` prefix). Important when a
19238        // SELECT pulls a bytea cell through a Text column path.
19239        (Value::Bytes(b), DataType::Text) => Some(Value::Text(encode_bytea_hex(&b))),
19240        // v7.17.0 — Text → UUID. PG accepts canonical hyphenated,
19241        // unhyphenated, uppercase, and `{...}`-braced forms; we
19242        // funnel all four through `spg_storage::parse_uuid_str`.
19243        // A malformed literal surfaces as a SQL TypeMismatch
19244        // rather than silently inserting garbage — `0-change
19245        // cutover` requires that an app inserting bad UUID text
19246        // sees the same hard error PG would raise.
19247        (Value::Text(s), DataType::Uuid) => match spg_storage::parse_uuid_str(&s) {
19248            Some(b) => Some(Value::Uuid(b)),
19249            None => {
19250                return Err(EngineError::Eval(EvalError::TypeMismatch {
19251                    detail: alloc::format!(
19252                        "invalid input syntax for type uuid: {s:?} (column `{col_name}`)"
19253                    ),
19254                }));
19255            }
19256        },
19257        // v7.17.0 — UUID → Text canonical 8-4-4-4-12 lowercase.
19258        // Surfaces when a SELECT plucks a uuid cell through a
19259        // Text column path (e.g. INSERT INTO log SELECT id::text
19260        // FROM other_table).
19261        (Value::Uuid(b), DataType::Text) => Some(Value::Text(spg_storage::format_uuid(&b))),
19262        // v7.17.0 Phase 3.P0-32 — Text → TIME. Accepts
19263        // `HH:MM:SS` and `HH:MM:SS.ffffff` (1-6 fractional digits).
19264        // Out-of-range hour/min/sec is a hard SQL error (no
19265        // silent truncation — same 0-change-cutover discipline
19266        // we apply to UUID).
19267        (Value::Text(s), DataType::Time) => match parse_time_str(&s) {
19268            Some(us) => Some(Value::Time(us)),
19269            None => {
19270                return Err(EngineError::Eval(EvalError::TypeMismatch {
19271                    detail: alloc::format!(
19272                        "invalid input syntax for type time: {s:?} (column `{col_name}`)"
19273                    ),
19274                }));
19275            }
19276        },
19277        // v7.17.0 Phase 3.P0-32 — TIME → Text canonical `HH:MM:SS[.ffffff]`.
19278        (Value::Time(us), DataType::Text) => Some(Value::Text(eval::format_time(us))),
19279        // v7.17.0 Phase 3.P0-33 — int / bigint → YEAR. Range
19280        // check enforces the MySQL canonical 1901..=2155 + 0
19281        // sentinel; out-of-range is a hard SQL error (no silent
19282        // truncation, mirrors P0-32 / P0-25 discipline).
19283        (Value::SmallInt(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
19284        (Value::Int(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
19285        (Value::BigInt(n), DataType::Year) => Some(coerce_int_to_year(n, col_name)?),
19286        // Text → YEAR. Accepts the 4-digit decimal form only;
19287        // two-digit YEAR (`'99'` → 1999) was deprecated in MySQL
19288        // 5.7 and is out of scope for v7.17.0.
19289        (Value::Text(s), DataType::Year) => match s.trim().parse::<i64>() {
19290            Ok(n) => Some(coerce_int_to_year(n, col_name)?),
19291            Err(_) => {
19292                return Err(EngineError::Eval(EvalError::TypeMismatch {
19293                    detail: alloc::format!(
19294                        "invalid input syntax for type year: {s:?} (column `{col_name}`)"
19295                    ),
19296                }));
19297            }
19298        },
19299        // YEAR → Text 4-digit zero-padded.
19300        (Value::Year(y), DataType::Text) => Some(Value::Text(alloc::format!("{y:04}"))),
19301        // v7.17.0 Phase 3.P0-34 — Text → TIMETZ. Mandatory
19302        // signed offset suffix; missing offset is a hard error
19303        // (SPG has no session TZ wired into eval, unlike PG).
19304        (Value::Text(s), DataType::TimeTz) => match parse_timetz_str(&s) {
19305            Some((us, offset_secs)) => Some(Value::TimeTz { us, offset_secs }),
19306            None => {
19307                return Err(EngineError::Eval(EvalError::TypeMismatch {
19308                    detail: alloc::format!(
19309                        "invalid input syntax for type time with time zone: \
19310                         {s:?} (column `{col_name}`)"
19311                    ),
19312                }));
19313            }
19314        },
19315        // TIMETZ → Text canonical `HH:MM:SS[.ffffff]±HH[:MM]`.
19316        (Value::TimeTz { us, offset_secs }, DataType::Text) => {
19317            Some(Value::Text(eval::format_timetz(us, offset_secs)))
19318        }
19319        // v7.17.0 Phase 3.P0-35 — Text → MONEY. Accepts `$N.NN`,
19320        // `$N,NNN.NN`, optional leading `-`. Bare numeric literals
19321        // arrive via the Int/BigInt/Float/Numeric arms below.
19322        (Value::Text(s), DataType::Money) => match parse_money_str(&s) {
19323            Some(c) => Some(Value::Money(c)),
19324            None => {
19325                return Err(EngineError::Eval(EvalError::TypeMismatch {
19326                    detail: alloc::format!(
19327                        "invalid input syntax for type money: {s:?} (column `{col_name}`)"
19328                    ),
19329                }));
19330            }
19331        },
19332        // Int / BigInt / SmallInt / Float / Numeric → MONEY.
19333        // Bare numeric literal is interpreted as a major-unit
19334        // amount (matches PG: `100`::money → $100.00 = 10000 cents).
19335        (Value::SmallInt(n), DataType::Money) => {
19336            Some(Value::Money(i64::from(n).saturating_mul(100)))
19337        }
19338        (Value::Int(n), DataType::Money) => Some(Value::Money(i64::from(n).saturating_mul(100))),
19339        (Value::BigInt(n), DataType::Money) => Some(Value::Money(n.saturating_mul(100))),
19340        (Value::Float(x), DataType::Money) => {
19341            // Round half-away-from-zero to cents (no_std — no
19342            // `f64::round`, so hand-roll via biased truncation).
19343            let scaled = x * 100.0;
19344            let cents = if scaled >= 0.0 {
19345                (scaled + 0.5) as i64
19346            } else {
19347                (scaled - 0.5) as i64
19348            };
19349            Some(Value::Money(cents))
19350        }
19351        (Value::Numeric { scaled, scale }, DataType::Money) => {
19352            // Convert exact decimal to cents (scale 2). If scale > 2,
19353            // round half-away-from-zero. If scale < 2, multiply up.
19354            let cents = if scale == 2 {
19355                scaled
19356            } else if scale < 2 {
19357                let mult = 10_i128.pow(u32::from(2 - scale));
19358                scaled.saturating_mul(mult)
19359            } else {
19360                let div = 10_i128.pow(u32::from(scale - 2));
19361                let half = div / 2;
19362                let bias = if scaled >= 0 { half } else { -half };
19363                (scaled + bias) / div
19364            };
19365            Some(Value::Money(i64::try_from(cents).unwrap_or(i64::MAX)))
19366        }
19367        // MONEY → Text canonical `$N,NNN.CC`.
19368        (Value::Money(c), DataType::Text) => Some(Value::Text(eval::format_money(c))),
19369        // v7.17.0 Phase 3.P0-38 — Text → Range. Accepts canonical
19370        // PG forms: `'empty'`, `'[a,b)'`, `'(a,b]'`, `'[a,b]'`,
19371        // `'(a,b)'`, with empty lower or upper for unbounded.
19372        (Value::Text(s), DataType::Range(kind)) => match parse_range_str(&s, kind) {
19373            Some(v) => Some(v),
19374            None => {
19375                return Err(EngineError::Eval(EvalError::TypeMismatch {
19376                    detail: alloc::format!(
19377                        "invalid input syntax for range type: {s:?} (column `{col_name}`)"
19378                    ),
19379                }));
19380            }
19381        },
19382        // Range → Text canonical form (`[a,b)`, `'empty'`, etc).
19383        (v @ Value::Range { .. }, DataType::Text) => Some(Value::Text(format_range_str(&v))),
19384        // v7.17.0 Phase 3.P0-39 — Text → Hstore.
19385        (Value::Text(s), DataType::Hstore) => match parse_hstore_str(&s) {
19386            Some(pairs) => Some(Value::Hstore(pairs)),
19387            None => {
19388                return Err(EngineError::Eval(EvalError::TypeMismatch {
19389                    detail: alloc::format!(
19390                        "invalid input syntax for type hstore: {s:?} (column `{col_name}`)"
19391                    ),
19392                }));
19393            }
19394        },
19395        // Hstore → Text canonical `"k"=>"v"` form.
19396        (Value::Hstore(pairs), DataType::Text) => Some(Value::Text(format_hstore_str(&pairs))),
19397        // v7.17.0 Phase 3.P0-40 — Text → 2D arrays via PG
19398        // external `'{{a,b},{c,d}}'` literal.
19399        (Value::Text(s), DataType::IntArray2D) => match parse_int_2d_literal(&s) {
19400            Ok(m) => Some(Value::IntArray2D(m)),
19401            Err(e) => {
19402                return Err(EngineError::Eval(EvalError::TypeMismatch {
19403                    detail: alloc::format!(
19404                        "invalid input syntax for INT[][]: {s:?} (column `{col_name}`): {e}"
19405                    ),
19406                }));
19407            }
19408        },
19409        (Value::Text(s), DataType::BigIntArray2D) => match parse_bigint_2d_literal(&s) {
19410            Ok(m) => Some(Value::BigIntArray2D(m)),
19411            Err(e) => {
19412                return Err(EngineError::Eval(EvalError::TypeMismatch {
19413                    detail: alloc::format!(
19414                        "invalid input syntax for BIGINT[][]: {s:?} (column `{col_name}`): {e}"
19415                    ),
19416                }));
19417            }
19418        },
19419        (Value::Text(s), DataType::TextArray2D) => match parse_text_2d_literal(&s) {
19420            Ok(m) => Some(Value::TextArray2D(m)),
19421            Err(e) => {
19422                return Err(EngineError::Eval(EvalError::TypeMismatch {
19423                    detail: alloc::format!(
19424                        "invalid input syntax for TEXT[][]: {s:?} (column `{col_name}`): {e}"
19425                    ),
19426                }));
19427            }
19428        },
19429        // 2D arrays → Text canonical nested form.
19430        (Value::IntArray2D(rows), DataType::Text) => Some(Value::Text(format_int_2d_text(&rows))),
19431        (Value::BigIntArray2D(rows), DataType::Text) => {
19432            Some(Value::Text(format_bigint_2d_text(&rows)))
19433        }
19434        (Value::TextArray2D(rows), DataType::Text) => Some(Value::Text(format_text_2d_text(&rows))),
19435        // v7.10.11 — Text → TEXT[]. Decode PG's external array
19436        // form `'{a,b,NULL}'`. NULL element token (case-insensitive)
19437        // is the literal `NULL`; everything else is a quoted or
19438        // unquoted text element. mailrs `'{label1,label2}'::TEXT[]`.
19439        (Value::Text(s), DataType::TextArray) => {
19440            let arr = decode_text_array_literal(&s).map_err(|e| {
19441                EngineError::Eval(EvalError::TypeMismatch {
19442                    detail: alloc::format!(
19443                        "cannot parse {s:?} as TEXT[] for column `{col_name}`: {e}"
19444                    ),
19445                })
19446            })?;
19447            Some(Value::TextArray(arr))
19448        }
19449        // v7.16.0 — Text → IntArray / BigIntArray for the
19450        // spg-sqlx Bind path. Decode the PG external form
19451        // `{1,2,3}` as a TEXT array first, then parse each
19452        // element as int. Same shape as the TextArray decode
19453        // above with an element-wise narrow.
19454        (Value::Text(s), DataType::IntArray) => {
19455            let arr = decode_text_array_literal(&s).map_err(|e| {
19456                EngineError::Eval(EvalError::TypeMismatch {
19457                    detail: alloc::format!(
19458                        "cannot parse {s:?} as INT[] for column `{col_name}`: {e}"
19459                    ),
19460                })
19461            })?;
19462            let mut out: Vec<Option<i32>> = Vec::with_capacity(arr.len());
19463            for elem in arr {
19464                match elem {
19465                    None => out.push(None),
19466                    Some(t) => {
19467                        let n: i32 = t.parse().map_err(|_| {
19468                            EngineError::Eval(EvalError::TypeMismatch {
19469                                detail: alloc::format!(
19470                                    "cannot parse {t:?} as INT element for `{col_name}`"
19471                                ),
19472                            })
19473                        })?;
19474                        out.push(Some(n));
19475                    }
19476                }
19477            }
19478            Some(Value::IntArray(out))
19479        }
19480        (Value::Text(s), DataType::BigIntArray) => {
19481            let arr = decode_text_array_literal(&s).map_err(|e| {
19482                EngineError::Eval(EvalError::TypeMismatch {
19483                    detail: alloc::format!(
19484                        "cannot parse {s:?} as BIGINT[] for column `{col_name}`: {e}"
19485                    ),
19486                })
19487            })?;
19488            let mut out: Vec<Option<i64>> = Vec::with_capacity(arr.len());
19489            for elem in arr {
19490                match elem {
19491                    None => out.push(None),
19492                    Some(t) => {
19493                        let n: i64 = t.parse().map_err(|_| {
19494                            EngineError::Eval(EvalError::TypeMismatch {
19495                                detail: alloc::format!(
19496                                    "cannot parse {t:?} as BIGINT element for `{col_name}`"
19497                                ),
19498                            })
19499                        })?;
19500                        out.push(Some(n));
19501                    }
19502                }
19503            }
19504            Some(Value::BigIntArray(out))
19505        }
19506        // v7.10.11 — TEXT[] → Text round-trip uses PG's
19507        // external array form (`{a,b,NULL}`). Lets a SELECT
19508        // pull an array column through any Text-side codepath.
19509        (Value::TextArray(items), DataType::Text) => Some(Value::Text(encode_text_array(&items))),
19510        // v7.17.0 Phase 3.P0-68 — Text → VECTOR auto-coerce.
19511        // Matches the existing Text → TsVector arm and the
19512        // `::vector` cast: PG-canonical pgvector external form
19513        // (`'[1, 2, -3]'`) becomes a typed Vector value at the
19514        // column boundary. Dim mismatch surfaces as TypeMismatch.
19515        // For SQ8 / HALF encodings we chain through the standard
19516        // quantise helpers so the storage shape matches the
19517        // declared encoding without a second coerce pass.
19518        (Value::Text(s), DataType::Vector { dim, encoding }) => {
19519            let parsed = eval::parse_vector_text(&s).ok_or_else(|| {
19520                EngineError::Eval(EvalError::TypeMismatch {
19521                    detail: alloc::format!("cannot parse {s:?} as VECTOR for column `{col_name}`"),
19522                })
19523            })?;
19524            if parsed.len() != dim as usize {
19525                return Err(EngineError::Eval(EvalError::TypeMismatch {
19526                    detail: alloc::format!(
19527                        "VECTOR({dim}) column `{col_name}` rejects literal of length {}",
19528                        parsed.len()
19529                    ),
19530                }));
19531            }
19532            Some(match encoding {
19533                VecEncoding::F32 => Value::Vector(parsed),
19534                VecEncoding::Sq8 => Value::Sq8Vector(spg_storage::quantize::quantize(&parsed)),
19535                VecEncoding::F16 => {
19536                    Value::HalfVector(spg_storage::halfvec::HalfVector::from_f32_slice(&parsed))
19537                }
19538            })
19539        }
19540        // v7.16.1 — Text → TSVECTOR auto-coerce for the
19541        // INSERT-side wire path (mailrs round-9 A.2.a). PG
19542        // implicitly promotes the TEXT literal at INSERT into a
19543        // TSVECTOR column; SPG previously rejected with a hard
19544        // type mismatch, blocking 23,276 pg_dump rows into
19545        // `messages.search_vector`. We route through the same
19546        // `decode_tsvector_external` the `::tsvector` cast
19547        // already uses, so PG-canonical forms (`'word'`,
19548        // `'word:1A,2B'`, multi-lexeme, empty `''`) all parse.
19549        (Value::Text(s), DataType::TsVector) => {
19550            let lexs = eval::decode_tsvector_external(&s).map_err(|e| {
19551                EngineError::Eval(EvalError::TypeMismatch {
19552                    detail: alloc::format!(
19553                        "cannot parse {s:?} as TSVECTOR for column `{col_name}`: {e}"
19554                    ),
19555                })
19556            })?;
19557            Some(Value::TsVector(lexs))
19558        }
19559        (Value::Text(s), DataType::Timestamp | DataType::Timestamptz) => {
19560            let t = eval::parse_timestamp_literal(&s).ok_or_else(|| {
19561                EngineError::Eval(EvalError::TypeMismatch {
19562                    detail: alloc::format!(
19563                        "cannot parse {s:?} as TIMESTAMP for column `{col_name}`"
19564                    ),
19565                })
19566            })?;
19567            Some(Value::Timestamp(t))
19568        }
19569        // DATE ↔ TIMESTAMP convertibility (DATE → midnight,
19570        // TIMESTAMP → day truncation).
19571        (Value::Date(d), DataType::Timestamp | DataType::Timestamptz) => {
19572            Some(Value::Timestamp(i64::from(d) * 86_400_000_000))
19573        }
19574        // v7.9.21 — Value::Timestamp lands in either Timestamp
19575        // or Timestamptz columns; the on-disk layout is the
19576        // same i64 microseconds UTC.
19577        (Value::Timestamp(t), DataType::Timestamptz) => Some(Value::Timestamp(t)),
19578        (Value::Timestamp(t), DataType::Date) => {
19579            let days = t.div_euclid(86_400_000_000);
19580            i32::try_from(days).ok().map(Value::Date)
19581        }
19582        (
19583            Value::Numeric {
19584                scaled,
19585                scale: src_scale,
19586            },
19587            DataType::Numeric { precision, scale },
19588        ) => Some(numeric_rescale(
19589            scaled, src_scale, precision, scale, col_name,
19590        )?),
19591        #[allow(clippy::cast_precision_loss)]
19592        (Value::Numeric { scaled, scale }, DataType::Float) => {
19593            let mut div = 1.0_f64;
19594            for _ in 0..scale {
19595                div *= 10.0;
19596            }
19597            Some(Value::Float((scaled as f64) / div))
19598        }
19599        (Value::Numeric { scaled, scale }, DataType::Int) => {
19600            let truncated = numeric_truncate_to_integer(scaled, scale);
19601            i32::try_from(truncated).ok().map(Value::Int)
19602        }
19603        (Value::Numeric { scaled, scale }, DataType::BigInt) => {
19604            let truncated = numeric_truncate_to_integer(scaled, scale);
19605            i64::try_from(truncated).ok().map(Value::BigInt)
19606        }
19607        (Value::Numeric { scaled, scale }, DataType::SmallInt) => {
19608            let truncated = numeric_truncate_to_integer(scaled, scale);
19609            i16::try_from(truncated).ok().map(Value::SmallInt)
19610        }
19611        // VARCHAR(n) enforces an upper bound on character count.
19612        (Value::Text(s), DataType::Varchar(max)) => {
19613            if u32::try_from(s.chars().count()).unwrap_or(u32::MAX) <= max {
19614                Some(Value::Text(s))
19615            } else {
19616                return Err(EngineError::Unsupported(alloc::format!(
19617                    "value for VARCHAR({max}) column `{col_name}` exceeds length: \
19618                     {} chars",
19619                    s.chars().count()
19620                )));
19621            }
19622        }
19623        // v6.0.1: f32 → SQ8 INSERT-time quantisation. Triggered
19624        // when the column declares `VECTOR(N) USING SQ8` and
19625        // the INSERT VALUES expression yields a raw f32 vector
19626        // (the normal pgvector-shape literal). Dim mismatch
19627        // falls through the `_ => None` arm and surfaces as
19628        // `TypeMismatch` with the expected SQ8 column type —
19629        // matching the F32 path's existing error.
19630        (
19631            Value::Vector(v),
19632            DataType::Vector {
19633                dim,
19634                encoding: VecEncoding::Sq8,
19635            },
19636        ) if v.len() == dim as usize => Some(Value::Sq8Vector(spg_storage::quantize::quantize(&v))),
19637        // v6.0.3: f32 → f16 INSERT-time conversion for HALF
19638        // columns. Bit-exact at the storage layer (modulo
19639        // half-precision rounding); no rerank pass needed at
19640        // search time.
19641        (
19642            Value::Vector(v),
19643            DataType::Vector {
19644                dim,
19645                encoding: VecEncoding::F16,
19646            },
19647        ) if v.len() == dim as usize => Some(Value::HalfVector(
19648            spg_storage::halfvec::HalfVector::from_f32_slice(&v),
19649        )),
19650        // CHAR(n) right-pads with U+0020 to exactly n chars; if the input
19651        // is already longer we reject (PG truncates trailing-space-only;
19652        // staying strict for v1).
19653        (Value::Text(s), DataType::Char(size)) => {
19654            let len = u32::try_from(s.chars().count()).unwrap_or(u32::MAX);
19655            if len > size {
19656                return Err(EngineError::Unsupported(alloc::format!(
19657                    "value for CHAR({size}) column `{col_name}` exceeds length: \
19658                     {len} chars"
19659                )));
19660            }
19661            let need = (size - len) as usize;
19662            let mut padded = s;
19663            padded.reserve(need);
19664            for _ in 0..need {
19665                padded.push(' ');
19666            }
19667            Some(Value::Text(padded))
19668        }
19669        _ => None,
19670    };
19671    coerced.ok_or(EngineError::Storage(StorageError::TypeMismatch {
19672        column: col_name.into(),
19673        expected,
19674        actual,
19675        position,
19676    }))
19677}
19678
19679/// v7.12.4 — render a function arg list into the
19680/// canonical form the storage layer caches as
19681/// [`spg_storage::FunctionDef::args_repr`]. The catalogue uses
19682/// this string for both display + as a coarse signature key
19683/// for the (deferred) overload resolution v7.12.5+ adds.
19684fn render_function_args(args: &[spg_sql::ast::FunctionArg]) -> alloc::string::String {
19685    use core::fmt::Write;
19686    let mut out = alloc::string::String::from("(");
19687    for (i, a) in args.iter().enumerate() {
19688        if i > 0 {
19689            out.push_str(", ");
19690        }
19691        match a.mode {
19692            spg_sql::ast::FunctionArgMode::In => {}
19693            spg_sql::ast::FunctionArgMode::Out => out.push_str("OUT "),
19694            spg_sql::ast::FunctionArgMode::InOut => out.push_str("INOUT "),
19695        }
19696        if let Some(n) = &a.name {
19697            out.push_str(n);
19698            out.push(' ');
19699        }
19700        match &a.ty {
19701            spg_sql::ast::FunctionArgType::Typed(t) => {
19702                let _ = write!(out, "{t}");
19703            }
19704            spg_sql::ast::FunctionArgType::Raw(s) => out.push_str(s),
19705        }
19706    }
19707    out.push(')');
19708    out
19709}
19710
19711/// v7.19 P5 — true iff `expr` is `unnest(arg)` at the top level
19712/// (case-insensitive). Used by `exec_select_cancel`'s
19713/// projection loop to detect Set-Returning-Function rows that
19714/// need per-row expansion. Only the top-level call counts —
19715/// `coalesce(unnest(arr), 'x')` is NOT a SRF row from the
19716/// projection's perspective; it would surface as an "unknown
19717/// function" mismatch downstream, which is what we want
19718/// (multi-SRF / nested SRF is documented carve-out for v7.19).
19719fn is_top_level_unnest(expr: &spg_sql::ast::Expr) -> bool {
19720    match expr {
19721        spg_sql::ast::Expr::FunctionCall { name, args } => {
19722            name.eq_ignore_ascii_case("unnest") && args.len() == 1
19723        }
19724        _ => false,
19725    }
19726}
19727
19728/// v7.19 P5 — extract the array argument out of a top-level
19729/// `unnest(arg)` call. `None` if `expr` isn't a `unnest` call
19730/// of arity 1 (mirrors `is_top_level_unnest`).
19731fn top_level_unnest_arg(expr: &spg_sql::ast::Expr) -> Option<&spg_sql::ast::Expr> {
19732    match expr {
19733        spg_sql::ast::Expr::FunctionCall { name, args }
19734            if name.eq_ignore_ascii_case("unnest") && args.len() == 1 =>
19735        {
19736            Some(&args[0])
19737        }
19738        _ => None,
19739    }
19740}
19741
19742/// v7.19 P5 — turn an array-typed `Value` into the element list
19743/// `unnest()` projection emits. NULL → empty list (PG: `unnest(NULL)
19744/// = (no rows)`). Non-array values fall through to a type-mismatch
19745/// error.
19746fn array_value_to_elements(v: &Value) -> Result<Vec<Value>, EngineError> {
19747    match v {
19748        Value::Null => Ok(Vec::new()),
19749        Value::TextArray(items) => Ok(items
19750            .iter()
19751            .map(|opt| {
19752                opt.as_ref()
19753                    .map(|s| Value::Text(s.clone()))
19754                    .unwrap_or(Value::Null)
19755            })
19756            .collect()),
19757        Value::IntArray(items) => Ok(items
19758            .iter()
19759            .map(|opt| opt.map(Value::Int).unwrap_or(Value::Null))
19760            .collect()),
19761        Value::BigIntArray(items) => Ok(items
19762            .iter()
19763            .map(|opt| opt.map(Value::BigInt).unwrap_or(Value::Null))
19764            .collect()),
19765        other => Err(EngineError::Eval(EvalError::TypeMismatch {
19766            detail: alloc::format!(
19767                "unnest() expects an array argument, got {:?}",
19768                other.data_type()
19769            ),
19770        })),
19771    }
19772}
19773
19774#[cfg(test)]
19775mod tests {
19776    use super::*;
19777    use alloc::vec;
19778
19779    fn unwrap_command_ok(r: &QueryResult) -> usize {
19780        match r {
19781            QueryResult::CommandOk { affected, .. } => *affected,
19782            QueryResult::Rows { .. } => panic!("expected CommandOk, got Rows"),
19783        }
19784    }
19785
19786    #[test]
19787    fn update_seek_positions_engages_on_indexed_eq() {
19788        let mut e = Engine::new();
19789        e.execute("CREATE TABLE b (id INT NOT NULL, v INT NOT NULL)")
19790            .unwrap();
19791        e.execute("CREATE INDEX b_id ON b (id)").unwrap();
19792        for i in 0..100 {
19793            e.execute(&alloc::format!("INSERT INTO b VALUES ({i}, {i})"))
19794                .unwrap();
19795        }
19796        let stmt = spg_sql::parser::parse_statement("UPDATE b SET v = v + 1 WHERE id = 42")
19797            .expect("parse");
19798        let Statement::Update(u) = stmt else {
19799            panic!("expected Update, got {stmt:?}");
19800        };
19801        let w = u.where_.as_ref().expect("where");
19802        let table = e.catalog().get("b").unwrap();
19803        let schema_cols = table.schema().columns.clone();
19804        // step-by-step: each sub-resolution must succeed.
19805        let Expr::Binary { lhs, op, rhs } = w else {
19806            panic!("WHERE not Binary: {w:?}");
19807        };
19808        assert_eq!(*op, BinOp::Eq, "op not Eq");
19809        let pair = resolve_col_literal_pair(lhs, rhs, &schema_cols, "b");
19810        assert!(
19811            pair.is_some(),
19812            "resolve_col_literal_pair None: lhs={lhs:?} rhs={rhs:?}"
19813        );
19814        let (col_pos, value) = pair.unwrap();
19815        assert!(
19816            table.index_on(col_pos).is_some(),
19817            "no index on col {col_pos}"
19818        );
19819        assert!(
19820            IndexKey::from_value(&value).is_some(),
19821            "IndexKey::from_value None for {value:?}"
19822        );
19823        let positions = try_index_seek_positions(w, &schema_cols, table, "b");
19824        assert_eq!(positions, Some(vec![42]), "seek did not engage");
19825    }
19826
19827    #[test]
19828    fn create_table_registers_schema() {
19829        let mut e = Engine::new();
19830        e.execute("CREATE TABLE foo (a INT NOT NULL, b TEXT)")
19831            .unwrap();
19832        assert_eq!(e.catalog().table_count(), 1);
19833        let t = e.catalog().get("foo").unwrap();
19834        assert_eq!(t.schema().columns.len(), 2);
19835        assert_eq!(t.schema().columns[0].ty, DataType::Int);
19836        assert!(!t.schema().columns[0].nullable);
19837        assert_eq!(t.schema().columns[1].ty, DataType::Text);
19838    }
19839
19840    #[test]
19841    fn create_table_vector_default_is_f32_encoded() {
19842        let mut e = Engine::new();
19843        e.execute("CREATE TABLE t (v VECTOR(8))").unwrap();
19844        let t = e.catalog().get("t").unwrap();
19845        assert_eq!(
19846            t.schema().columns[0].ty,
19847            DataType::Vector {
19848                dim: 8,
19849                encoding: VecEncoding::F32,
19850            },
19851        );
19852    }
19853
19854    #[test]
19855    fn create_table_vector_using_sq8_succeeds() {
19856        // v6.0.1 step 3: the step-1 fence in `column_def_to_schema`
19857        // is lifted. CREATE TABLE persists an SQ8 column type in
19858        // the catalog; INSERT (next test) quantises raw f32 input.
19859        let mut e = Engine::new();
19860        e.execute("CREATE TABLE t (v VECTOR(8) USING SQ8)").unwrap();
19861        let t = e.catalog().get("t").unwrap();
19862        assert_eq!(
19863            t.schema().columns[0].ty,
19864            DataType::Vector {
19865                dim: 8,
19866                encoding: VecEncoding::Sq8,
19867            },
19868        );
19869    }
19870
19871    #[test]
19872    fn insert_into_sq8_column_quantises_f32_payload() {
19873        // v6.0.1 step 3: INSERT-time `coerce_value` rewrites a raw
19874        // `Value::Vector(Vec<f32>)` literal into the column's
19875        // quantised representation. The row that lands in the
19876        // catalog must therefore hold a `Value::Sq8Vector`, not the
19877        // original f32 buffer — that's the bit that delivers the
19878        // 4× compression target.
19879        let mut e = Engine::new();
19880        e.execute("CREATE TABLE t (v VECTOR(4) USING SQ8)").unwrap();
19881        e.execute("INSERT INTO t VALUES ([0.0, 0.25, 0.5, 1.0])")
19882            .unwrap();
19883        let t = e.catalog().get("t").unwrap();
19884        assert_eq!(t.rows().len(), 1);
19885        match &t.rows()[0].values[0] {
19886            Value::Sq8Vector(q) => {
19887                assert_eq!(q.bytes.len(), 4);
19888                // min/max are derived from the payload: min=0.0, max=1.0.
19889                assert!((q.min - 0.0).abs() < 1e-6);
19890                assert!((q.max - 1.0).abs() < 1e-6);
19891            }
19892            other => panic!("expected Sq8Vector cell, got {other:?}"),
19893        }
19894    }
19895
19896    #[test]
19897    fn create_table_vector_using_half_succeeds_and_insert_converts_to_f16() {
19898        // v6.0.3: CREATE TABLE accepts USING HALF; INSERT path
19899        // converts the incoming `Value::Vector(Vec<f32>)` cell
19900        // into `Value::HalfVector(HalfVector)` via the new
19901        // `coerce_value` arm. The dequantised round-trip is
19902        // bit-exact for f16-representable values, so 0.0 / 0.25
19903        // / 0.5 / 1.0 hit their grid points exactly.
19904        let mut e = Engine::new();
19905        e.execute("CREATE TABLE t (v VECTOR(4) USING HALF)")
19906            .unwrap();
19907        e.execute("INSERT INTO t VALUES ([0.0, 0.25, 0.5, 1.0])")
19908            .unwrap();
19909        let t = e.catalog().get("t").unwrap();
19910        assert_eq!(t.rows().len(), 1);
19911        match &t.rows()[0].values[0] {
19912            Value::HalfVector(h) => {
19913                assert_eq!(h.dim(), 4);
19914                let back = h.to_f32_vec();
19915                let expected = alloc::vec![0.0_f32, 0.25, 0.5, 1.0];
19916                for (g, e) in back.iter().zip(expected.iter()) {
19917                    assert!(
19918                        (g - e).abs() < 1e-6,
19919                        "{g} vs {e} should be exact on f16 grid"
19920                    );
19921                }
19922            }
19923            other => panic!("expected HalfVector cell, got {other:?}"),
19924        }
19925    }
19926
19927    #[test]
19928    fn alter_index_rebuild_in_place_succeeds() {
19929        // v6.0.4: bare REBUILD (no encoding switch) walks every
19930        // row again to rebuild the NSW graph. Verifies the engine
19931        // dispatch + storage helper plumbing without changing any
19932        // cell encoding.
19933        let mut e = Engine::new();
19934        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(3) NOT NULL)")
19935            .unwrap();
19936        for i in 0..8_i32 {
19937            #[allow(clippy::cast_precision_loss)]
19938            let base = (i as f32) * 0.1;
19939            e.execute(&alloc::format!(
19940                "INSERT INTO t VALUES ({i}, [{base}, {b1}, {b2}])",
19941                b1 = base + 0.01,
19942                b2 = base + 0.02,
19943            ))
19944            .unwrap();
19945        }
19946        e.execute("CREATE INDEX t_idx ON t USING hnsw (v)").unwrap();
19947        e.execute("ALTER INDEX t_idx REBUILD").unwrap();
19948        // Schema encoding stays F32 (no encoding clause).
19949        assert_eq!(
19950            e.catalog().get("t").unwrap().schema().columns[1].ty,
19951            DataType::Vector {
19952                dim: 3,
19953                encoding: VecEncoding::F32,
19954            },
19955        );
19956    }
19957
19958    #[test]
19959    fn alter_index_rebuild_with_encoding_switches_cell_type() {
19960        // v6.0.4: REBUILD WITH (encoding = SQ8) recodes every
19961        // stored cell from F32 → SQ8 + rebuilds the graph atop the
19962        // new encoding. Post-rebuild, cells must be Sq8Vector and
19963        // the schema must report encoding = Sq8.
19964        let mut e = Engine::new();
19965        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(4) NOT NULL)")
19966            .unwrap();
19967        e.execute("INSERT INTO t VALUES (1, [0.0, 0.25, 0.5, 1.0])")
19968            .unwrap();
19969        e.execute("CREATE INDEX t_idx ON t USING hnsw (v)").unwrap();
19970        e.execute("ALTER INDEX t_idx REBUILD WITH (encoding = SQ8)")
19971            .unwrap();
19972        let t = e.catalog().get("t").unwrap();
19973        assert_eq!(
19974            t.schema().columns[1].ty,
19975            DataType::Vector {
19976                dim: 4,
19977                encoding: VecEncoding::Sq8,
19978            },
19979        );
19980        assert!(matches!(t.rows()[0].values[1], Value::Sq8Vector(_)));
19981    }
19982
19983    #[test]
19984    fn alter_index_rebuild_unknown_index_errors() {
19985        let mut e = Engine::new();
19986        let err = e.execute("ALTER INDEX nope REBUILD").unwrap_err();
19987        assert!(
19988            matches!(
19989                &err,
19990                EngineError::Storage(StorageError::IndexNotFound { name }) if name == "nope"
19991            ),
19992            "got: {err}"
19993        );
19994    }
19995
19996    #[test]
19997    fn alter_index_rebuild_on_btree_index_errors() {
19998        // REBUILD on a B-tree index has no semantic meaning in
19999        // v6.0.4 — rejected at the storage layer with `Unsupported`.
20000        let mut e = Engine::new();
20001        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
20002        e.execute("INSERT INTO t VALUES (1)").unwrap();
20003        e.execute("CREATE INDEX t_idx ON t (id)").unwrap();
20004        let err = e.execute("ALTER INDEX t_idx REBUILD").unwrap_err();
20005        assert!(
20006            matches!(&err, EngineError::Storage(StorageError::Unsupported(_))),
20007            "got: {err}"
20008        );
20009    }
20010
20011    #[test]
20012    fn prepared_insert_substitutes_placeholders() {
20013        // v6.1.1: prepare() parses once; execute_prepared() walks the
20014        // AST and replaces $1/$2 with the param Values BEFORE the
20015        // dispatch sees them. Same logical result as a simple-query
20016        // INSERT, but parse happens once per *statement*, not per
20017        // execution.
20018        let mut e = Engine::new();
20019        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)")
20020            .unwrap();
20021        let stmt = e.prepare("INSERT INTO t VALUES ($1, $2)").unwrap();
20022        for (id, name) in [(1, "alice"), (2, "bob"), (3, "carol")] {
20023            e.execute_prepared(stmt.clone(), &[Value::Int(id), Value::Text(name.into())])
20024                .unwrap();
20025        }
20026        // Read back via simple-query SELECT.
20027        let rows_result = e.execute("SELECT id, name FROM t").unwrap();
20028        let QueryResult::Rows { rows, .. } = rows_result else {
20029            panic!("expected Rows")
20030        };
20031        assert_eq!(rows.len(), 3);
20032    }
20033
20034    #[test]
20035    fn prepared_select_with_placeholder_filters_rows() {
20036        let mut e = Engine::new();
20037        e.execute("CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)")
20038            .unwrap();
20039        for i in 0..10_i32 {
20040            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, {})", i * 7))
20041                .unwrap();
20042        }
20043        let stmt = e.prepare("SELECT id FROM t WHERE v = $1").unwrap();
20044        let QueryResult::Rows { rows, .. } = e.execute_prepared(stmt, &[Value::Int(35)]).unwrap()
20045        else {
20046            panic!("expected Rows")
20047        };
20048        // v = 35 means i*7 = 35 → i = 5.
20049        assert_eq!(rows.len(), 1);
20050        assert_eq!(rows[0].values[0], Value::Int(5));
20051    }
20052
20053    #[test]
20054    fn prepared_too_few_params_errors() {
20055        let mut e = Engine::new();
20056        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
20057        let stmt = e.prepare("INSERT INTO t VALUES ($1)").unwrap();
20058        let err = e.execute_prepared(stmt, &[]).unwrap_err();
20059        assert!(
20060            matches!(
20061                &err,
20062                EngineError::Eval(EvalError::PlaceholderOutOfRange { n: 1, bound: 0 })
20063            ),
20064            "got: {err}"
20065        );
20066    }
20067
20068    #[test]
20069    fn bytea_cast_round_trips_text_input() {
20070        // v7.18 — `'hello'::bytea` produces the raw bytes. Closes
20071        // the mailrs D-pre #3 reverse-acceptance gap.
20072        let e = Engine::new();
20073        let r = e.execute_readonly("SELECT 'hello'::bytea").unwrap();
20074        let QueryResult::Rows { rows, .. } = r else {
20075            panic!("expected Rows")
20076        };
20077        assert_eq!(rows.len(), 1);
20078        assert_eq!(rows[0].values[0], Value::Bytes(b"hello".to_vec()));
20079    }
20080
20081    #[test]
20082    fn bytea_cast_pg_escape_hex_form() {
20083        // E'\\xdeadbeef'::bytea — E-string decodes to `\xdeadbeef`
20084        // (literal 10 chars), then ::bytea reads it as PG hex
20085        // form bytea literal → 4 bytes.
20086        let e = Engine::new();
20087        let r = e.execute_readonly(r"SELECT E'\\xdeadbeef'::bytea").unwrap();
20088        let QueryResult::Rows { rows, .. } = r else {
20089            panic!("expected Rows")
20090        };
20091        assert_eq!(
20092            rows[0].values[0],
20093            Value::Bytes(vec![0xde, 0xad, 0xbe, 0xef])
20094        );
20095    }
20096
20097    #[test]
20098    fn bytea_cast_chains_through_octet_length() {
20099        // octet_length('hello'::bytea) → 5. Confirms the cast
20100        // composes inside larger expressions, not just at top
20101        // level.
20102        let e = Engine::new();
20103        let r = e
20104            .execute_readonly("SELECT octet_length('hello'::bytea)")
20105            .unwrap();
20106        let QueryResult::Rows { rows, .. } = r else {
20107            panic!("expected Rows")
20108        };
20109        match &rows[0].values[0] {
20110            Value::Int(n) => assert_eq!(*n, 5),
20111            Value::BigInt(n) => assert_eq!(*n, 5),
20112            other => panic!("expected integer length, got {other:?}"),
20113        }
20114    }
20115
20116    #[test]
20117    fn readonly_prepared_on_snapshot_select_with_placeholder() {
20118        // v7.18 — sqlx Pool fan-out relies on running prepared
20119        // SELECTs against a frozen snapshot without re-entering
20120        // the writer engine. Mirrors the simple-query SELECT path
20121        // in `execute_readonly_on_snapshot` but takes a Statement
20122        // + bound params (the shape sqlx's Execute path produces).
20123        let mut e = Engine::new();
20124        e.execute("CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)")
20125            .unwrap();
20126        for i in 0..10_i32 {
20127            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, {})", i * 7))
20128                .unwrap();
20129        }
20130        let snapshot = e.clone_snapshot();
20131        let stmt = e.prepare("SELECT id FROM t WHERE v = $1").unwrap();
20132        let QueryResult::Rows { rows, .. } =
20133            Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(35)])
20134                .unwrap()
20135        else {
20136            panic!("expected Rows")
20137        };
20138        assert_eq!(rows.len(), 1);
20139        assert_eq!(rows[0].values[0], Value::Int(5));
20140    }
20141
20142    #[test]
20143    fn readonly_prepared_on_snapshot_rejects_writes() {
20144        // DDL / DML prepared statements on the readonly path must
20145        // surface `WriteRequired` so the spg-sqlx connection layer
20146        // routes them to the writer mutex instead of the snapshot.
20147        let mut e = Engine::new();
20148        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
20149        let snapshot = e.clone_snapshot();
20150        let stmt = e.prepare("INSERT INTO t VALUES ($1)").unwrap();
20151        let err = Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(1)])
20152            .unwrap_err();
20153        assert!(matches!(&err, EngineError::WriteRequired), "got: {err}");
20154    }
20155
20156    #[test]
20157    fn readonly_prepared_on_snapshot_frozen_view() {
20158        // The snapshot reflects engine state at clone_snapshot()
20159        // time. Writes after the snapshot are NOT visible — caller
20160        // takes a fresh snapshot (or `AsyncReadHandle::refresh()`)
20161        // to see them. This is the contract the per-statement
20162        // refresh in spg-sqlx relies on.
20163        let mut e = Engine::new();
20164        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
20165        e.execute("INSERT INTO t VALUES (1)").unwrap();
20166        let snapshot = e.clone_snapshot();
20167        e.execute("INSERT INTO t VALUES (2)").unwrap();
20168        let stmt = e.prepare("SELECT id FROM t WHERE id = $1").unwrap();
20169        let QueryResult::Rows { rows, .. } =
20170            Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(2)])
20171                .unwrap()
20172        else {
20173            panic!("expected Rows")
20174        };
20175        assert!(rows.is_empty(), "id=2 was inserted after snapshot");
20176    }
20177
20178    #[test]
20179    fn describe_prepared_on_snapshot_resolves_columns() {
20180        // v7.18 — sqlx's Executor::describe path on the readonly
20181        // fan-out needs to resolve column names + types against
20182        // the snapshot's catalog (not the live engine's catalog,
20183        // which may have moved on).
20184        let mut e = Engine::new();
20185        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)")
20186            .unwrap();
20187        let snapshot = e.clone_snapshot();
20188        let stmt = e.prepare("SELECT id, name FROM t WHERE id = $1").unwrap();
20189        let (_params, cols) = Engine::describe_prepared_on_snapshot(&snapshot, &stmt);
20190        assert_eq!(cols.len(), 2);
20191        assert_eq!(cols[0].name, "id");
20192        assert_eq!(cols[0].ty, DataType::Int);
20193        assert_eq!(cols[1].name, "name");
20194        assert_eq!(cols[1].ty, DataType::Text);
20195    }
20196
20197    #[test]
20198    fn insert_into_half_column_dim_mismatch_errors() {
20199        let mut e = Engine::new();
20200        e.execute("CREATE TABLE t (v VECTOR(4) USING HALF)")
20201            .unwrap();
20202        let err = e.execute("INSERT INTO t VALUES ([1.0, 2.0])").unwrap_err();
20203        assert!(matches!(
20204            &err,
20205            EngineError::Storage(StorageError::TypeMismatch { .. })
20206        ));
20207    }
20208
20209    #[test]
20210    fn insert_into_sq8_column_dim_mismatch_errors() {
20211        // Dim mismatch falls through the `coerce_value` Vector→Sq8
20212        // arm's guard and surfaces as `TypeMismatch` — the same
20213        // error the F32 path produces today, so client error
20214        // handling stays uniform across encodings.
20215        let mut e = Engine::new();
20216        e.execute("CREATE TABLE t (v VECTOR(4) USING SQ8)").unwrap();
20217        let err = e.execute("INSERT INTO t VALUES ([1.0, 2.0])").unwrap_err();
20218        assert!(
20219            matches!(
20220                &err,
20221                EngineError::Storage(StorageError::TypeMismatch { .. })
20222            ),
20223            "got: {err}",
20224        );
20225    }
20226
20227    #[test]
20228    fn create_table_duplicate_errors() {
20229        let mut e = Engine::new();
20230        e.execute("CREATE TABLE foo (a INT)").unwrap();
20231        let err = e.execute("CREATE TABLE foo (a INT)").unwrap_err();
20232        assert!(matches!(
20233            err,
20234            EngineError::Storage(StorageError::DuplicateTable { ref name }) if name == "foo"
20235        ));
20236    }
20237
20238    #[test]
20239    fn insert_into_unknown_table_errors() {
20240        let mut e = Engine::new();
20241        let err = e.execute("INSERT INTO ghost VALUES (1)").unwrap_err();
20242        assert!(matches!(
20243            err,
20244            EngineError::Storage(StorageError::TableNotFound { ref name }) if name == "ghost"
20245        ));
20246    }
20247
20248    #[test]
20249    fn insert_happy_path_reports_one_affected() {
20250        let mut e = Engine::new();
20251        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
20252        let r = e.execute("INSERT INTO foo VALUES (42)").unwrap();
20253        assert_eq!(unwrap_command_ok(&r), 1);
20254        assert_eq!(e.catalog().get("foo").unwrap().row_count(), 1);
20255    }
20256
20257    #[test]
20258    fn insert_arity_mismatch_propagates() {
20259        let mut e = Engine::new();
20260        e.execute("CREATE TABLE foo (a INT, b TEXT)").unwrap();
20261        let err = e.execute("INSERT INTO foo VALUES (1)").unwrap_err();
20262        assert!(matches!(
20263            err,
20264            EngineError::Storage(StorageError::ArityMismatch { .. })
20265        ));
20266    }
20267
20268    #[test]
20269    fn insert_negative_integer_via_unary_minus() {
20270        let mut e = Engine::new();
20271        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
20272        e.execute("INSERT INTO foo VALUES (-7)").unwrap();
20273        let rows = e.catalog().get("foo").unwrap().rows();
20274        assert_eq!(rows[0].values[0], Value::Int(-7));
20275    }
20276
20277    #[test]
20278    fn insert_expression_evaluated_against_empty_context() {
20279        // PG-canonical: INSERT VALUES accepts an arbitrary scalar
20280        // expression. The engine evaluates against an empty row
20281        // context — column references would error, but pure
20282        // arithmetic / function calls are fine.
20283        let mut e = Engine::new();
20284        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
20285        e.execute("INSERT INTO foo VALUES (1 + 2)").unwrap();
20286        let rows = e.catalog().get("foo").unwrap().rows();
20287        assert_eq!(rows[0].values[0], Value::Int(3));
20288    }
20289
20290    #[test]
20291    fn select_star_returns_all_rows_in_insertion_order() {
20292        let mut e = Engine::new();
20293        e.execute("CREATE TABLE foo (a INT NOT NULL, b TEXT NOT NULL)")
20294            .unwrap();
20295        e.execute("INSERT INTO foo VALUES (1, 'one')").unwrap();
20296        e.execute("INSERT INTO foo VALUES (2, 'two')").unwrap();
20297        e.execute("INSERT INTO foo VALUES (3, 'three')").unwrap();
20298
20299        let r = e.execute("SELECT * FROM foo").unwrap();
20300        let QueryResult::Rows { columns, rows } = r else {
20301            panic!("expected Rows")
20302        };
20303        assert_eq!(columns.len(), 2);
20304        assert_eq!(columns[0].name, "a");
20305        assert_eq!(rows.len(), 3);
20306        assert_eq!(
20307            rows[1].values,
20308            vec![Value::Int(2), Value::Text("two".into())]
20309        );
20310    }
20311
20312    #[test]
20313    fn select_star_on_empty_table_returns_zero_rows() {
20314        let mut e = Engine::new();
20315        e.execute("CREATE TABLE foo (a INT)").unwrap();
20316        let r = e.execute("SELECT * FROM foo").unwrap();
20317        match r {
20318            QueryResult::Rows { rows, .. } => assert!(rows.is_empty()),
20319            QueryResult::CommandOk { .. } => panic!("expected Rows"),
20320        }
20321    }
20322
20323    // --- v0.4: WHERE + projection ------------------------------------------
20324
20325    fn make_three_row_users(e: &mut Engine) {
20326        e.execute("CREATE TABLE users (id INT NOT NULL, name TEXT NOT NULL, score INT)")
20327            .unwrap();
20328        e.execute("INSERT INTO users VALUES (1, 'alice', 90)")
20329            .unwrap();
20330        e.execute("INSERT INTO users VALUES (2, 'bob', NULL)")
20331            .unwrap();
20332        e.execute("INSERT INTO users VALUES (3, 'cara', 70)")
20333            .unwrap();
20334    }
20335
20336    fn unwrap_rows(r: QueryResult) -> (Vec<ColumnSchema>, Vec<Row>) {
20337        match r {
20338            QueryResult::Rows { columns, rows } => (columns, rows),
20339            QueryResult::CommandOk { .. } => panic!("expected Rows"),
20340        }
20341    }
20342
20343    #[test]
20344    fn where_filter_passes_only_true_rows() {
20345        let mut e = Engine::new();
20346        make_three_row_users(&mut e);
20347        let r = e.execute("SELECT * FROM users WHERE id > 1").unwrap();
20348        let (_, rows) = unwrap_rows(r);
20349        assert_eq!(rows.len(), 2);
20350        assert_eq!(rows[0].values[0], Value::Int(2));
20351        assert_eq!(rows[1].values[0], Value::Int(3));
20352    }
20353
20354    #[test]
20355    fn where_with_null_result_filters_out_row() {
20356        let mut e = Engine::new();
20357        make_three_row_users(&mut e);
20358        // score is NULL for bob → score > 80 is NULL → row excluded
20359        let r = e.execute("SELECT * FROM users WHERE score > 80").unwrap();
20360        let (_, rows) = unwrap_rows(r);
20361        assert_eq!(rows.len(), 1);
20362        assert_eq!(rows[0].values[1], Value::Text("alice".into()));
20363    }
20364
20365    #[test]
20366    fn projection_named_columns() {
20367        let mut e = Engine::new();
20368        make_three_row_users(&mut e);
20369        let r = e.execute("SELECT name, score FROM users").unwrap();
20370        let (cols, rows) = unwrap_rows(r);
20371        assert_eq!(cols.len(), 2);
20372        assert_eq!(cols[0].name, "name");
20373        assert_eq!(cols[1].name, "score");
20374        assert_eq!(rows.len(), 3);
20375        assert_eq!(
20376            rows[0].values,
20377            vec![Value::Text("alice".into()), Value::Int(90)]
20378        );
20379    }
20380
20381    #[test]
20382    fn projection_with_column_alias() {
20383        let mut e = Engine::new();
20384        make_three_row_users(&mut e);
20385        let r = e
20386            .execute("SELECT name AS who FROM users WHERE id = 1")
20387            .unwrap();
20388        let (cols, rows) = unwrap_rows(r);
20389        assert_eq!(cols[0].name, "who");
20390        assert_eq!(rows.len(), 1);
20391        assert_eq!(rows[0].values[0], Value::Text("alice".into()));
20392    }
20393
20394    #[test]
20395    fn qualified_column_with_table_alias_resolves() {
20396        let mut e = Engine::new();
20397        make_three_row_users(&mut e);
20398        let r = e
20399            .execute("SELECT u.id, u.name FROM users AS u WHERE u.id < 3")
20400            .unwrap();
20401        let (cols, rows) = unwrap_rows(r);
20402        assert_eq!(cols.len(), 2);
20403        assert_eq!(rows.len(), 2);
20404    }
20405
20406    #[test]
20407    fn qualified_column_with_wrong_alias_errors() {
20408        let mut e = Engine::new();
20409        make_three_row_users(&mut e);
20410        let err = e.execute("SELECT x.id FROM users AS u").unwrap_err();
20411        assert!(matches!(
20412            err,
20413            EngineError::Eval(EvalError::UnknownQualifier { ref qualifier }) if qualifier == "x"
20414        ));
20415    }
20416
20417    #[test]
20418    fn select_unknown_column_errors_in_projection() {
20419        let mut e = Engine::new();
20420        make_three_row_users(&mut e);
20421        let err = e.execute("SELECT ghost FROM users").unwrap_err();
20422        assert!(matches!(
20423            err,
20424            EngineError::Eval(EvalError::ColumnNotFound { ref name }) if name == "ghost"
20425        ));
20426    }
20427
20428    #[test]
20429    fn where_unknown_column_errors() {
20430        let mut e = Engine::new();
20431        make_three_row_users(&mut e);
20432        let err = e
20433            .execute("SELECT * FROM users WHERE ghost = 1")
20434            .unwrap_err();
20435        assert!(matches!(
20436            err,
20437            EngineError::Eval(EvalError::ColumnNotFound { .. })
20438        ));
20439    }
20440
20441    #[test]
20442    fn expression_projection_evaluates_and_renders() {
20443        // Compound expressions in the SELECT list are evaluated per row;
20444        // the output column is typed TEXT, name defaults to the expression.
20445        let mut e = Engine::new();
20446        e.execute("CREATE TABLE t (a INT NOT NULL)").unwrap();
20447        e.execute("INSERT INTO t VALUES (3)").unwrap();
20448        let (_, rows) = unwrap_rows(e.execute("SELECT 1 + 2 FROM t").unwrap());
20449        assert_eq!(rows.len(), 1);
20450        // The expression evaluates to integer 3; rendered as the cell value
20451        // (storage::Value::Int(3) since arithmetic kept ints).
20452        assert_eq!(rows[0].values[0], Value::Int(3));
20453    }
20454
20455    #[test]
20456    fn select_unknown_table_errors() {
20457        let mut e = Engine::new();
20458        let err = e.execute("SELECT * FROM ghost").unwrap_err();
20459        assert!(matches!(
20460            err,
20461            EngineError::Storage(StorageError::TableNotFound { .. })
20462        ));
20463    }
20464
20465    #[test]
20466    fn invalid_sql_returns_parse_error() {
20467        // v4.4: UPDATE is now real SQL, so use a true syntactic
20468        // garbage payload for the parse-error path.
20469        let mut e = Engine::new();
20470        let err = e.execute("THIS_IS_NOT_A_KEYWORD foo bar baz").unwrap_err();
20471        assert!(matches!(err, EngineError::Parse(_)));
20472    }
20473
20474    // --- v0.8 CREATE INDEX + index seek ------------------------------------
20475
20476    #[test]
20477    fn create_index_registers_on_table() {
20478        let mut e = Engine::new();
20479        make_three_row_users(&mut e);
20480        e.execute("CREATE INDEX by_name ON users (name)").unwrap();
20481        let t = e.catalog().get("users").unwrap();
20482        assert_eq!(t.indices().len(), 1);
20483        assert_eq!(t.indices()[0].name, "by_name");
20484    }
20485
20486    #[test]
20487    fn create_index_on_unknown_table_errors() {
20488        let mut e = Engine::new();
20489        let err = e.execute("CREATE INDEX i ON ghost (a)").unwrap_err();
20490        assert!(matches!(
20491            err,
20492            EngineError::Storage(StorageError::TableNotFound { .. })
20493        ));
20494    }
20495
20496    #[test]
20497    fn create_index_on_unknown_column_errors() {
20498        let mut e = Engine::new();
20499        make_three_row_users(&mut e);
20500        let err = e.execute("CREATE INDEX i ON users (ghost)").unwrap_err();
20501        assert!(matches!(
20502            err,
20503            EngineError::Storage(StorageError::ColumnNotFound { .. })
20504        ));
20505    }
20506
20507    #[test]
20508    fn select_eq_uses_index_returns_same_rows_as_scan() {
20509        // Build two engines: one with an index, one without. Same query →
20510        // same row set (index is a planner optimisation, not a semantic
20511        // change).
20512        let mut without = Engine::new();
20513        make_three_row_users(&mut without);
20514        let mut with = Engine::new();
20515        make_three_row_users(&mut with);
20516        with.execute("CREATE INDEX by_id ON users (id)").unwrap();
20517
20518        let q = "SELECT * FROM users WHERE id = 2";
20519        let (_, no_idx_rows) = unwrap_rows(without.execute(q).unwrap());
20520        let (_, idx_rows) = unwrap_rows(with.execute(q).unwrap());
20521        assert_eq!(no_idx_rows, idx_rows);
20522        assert_eq!(idx_rows.len(), 1);
20523    }
20524
20525    #[test]
20526    fn select_eq_with_no_matching_index_value_returns_empty() {
20527        let mut e = Engine::new();
20528        make_three_row_users(&mut e);
20529        e.execute("CREATE INDEX by_id ON users (id)").unwrap();
20530        let (_, rows) = unwrap_rows(e.execute("SELECT * FROM users WHERE id = 999").unwrap());
20531        assert_eq!(rows.len(), 0);
20532    }
20533
20534    // --- v0.9 transactions -------------------------------------------------
20535
20536    #[test]
20537    fn begin_sets_in_transaction_flag() {
20538        let mut e = Engine::new();
20539        assert!(!e.in_transaction());
20540        e.execute("BEGIN").unwrap();
20541        assert!(e.in_transaction());
20542    }
20543
20544    #[test]
20545    fn double_begin_errors() {
20546        let mut e = Engine::new();
20547        e.execute("BEGIN").unwrap();
20548        let err = e.execute("BEGIN").unwrap_err();
20549        assert_eq!(err, EngineError::TransactionAlreadyOpen);
20550    }
20551
20552    #[test]
20553    fn commit_without_begin_errors() {
20554        let mut e = Engine::new();
20555        let err = e.execute("COMMIT").unwrap_err();
20556        assert_eq!(err, EngineError::NoActiveTransaction);
20557    }
20558
20559    #[test]
20560    fn rollback_without_begin_errors() {
20561        let mut e = Engine::new();
20562        let err = e.execute("ROLLBACK").unwrap_err();
20563        assert_eq!(err, EngineError::NoActiveTransaction);
20564    }
20565
20566    #[test]
20567    fn commit_applies_shadow_to_committed_catalog() {
20568        let mut e = Engine::new();
20569        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
20570        e.execute("BEGIN").unwrap();
20571        e.execute("INSERT INTO t VALUES (1)").unwrap();
20572        e.execute("INSERT INTO t VALUES (2)").unwrap();
20573        e.execute("COMMIT").unwrap();
20574        assert!(!e.in_transaction());
20575        assert_eq!(e.catalog().get("t").unwrap().row_count(), 2);
20576    }
20577
20578    #[test]
20579    fn rollback_discards_shadow() {
20580        let mut e = Engine::new();
20581        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
20582        e.execute("BEGIN").unwrap();
20583        e.execute("INSERT INTO t VALUES (1)").unwrap();
20584        e.execute("INSERT INTO t VALUES (2)").unwrap();
20585        e.execute("ROLLBACK").unwrap();
20586        assert!(!e.in_transaction());
20587        assert_eq!(e.catalog().get("t").unwrap().row_count(), 0);
20588    }
20589
20590    #[test]
20591    fn select_during_tx_sees_uncommitted_writes_own_session() {
20592        // The shadow catalog is read by SELECTs while a TX is open — the
20593        // session can see its own pending writes.
20594        let mut e = Engine::new();
20595        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
20596        e.execute("BEGIN").unwrap();
20597        e.execute("INSERT INTO t VALUES (42)").unwrap();
20598        let (_, rows) = unwrap_rows(e.execute("SELECT * FROM t").unwrap());
20599        assert_eq!(rows.len(), 1);
20600        assert_eq!(rows[0].values[0], Value::Int(42));
20601    }
20602
20603    #[test]
20604    fn snapshot_with_no_users_is_bare_catalog_format() {
20605        let mut e = Engine::new();
20606        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
20607        let bytes = e.snapshot();
20608        assert_eq!(
20609            &bytes[..8],
20610            b"SPGDB001",
20611            "must be the bare v3.x catalog magic"
20612        );
20613        let e2 = Engine::restore_envelope(&bytes).unwrap();
20614        assert!(e2.users().is_empty());
20615        assert_eq!(e2.catalog().table_count(), 1);
20616    }
20617
20618    #[test]
20619    fn snapshot_with_users_round_trips_both_via_envelope() {
20620        let mut e = Engine::new();
20621        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
20622        e.create_user("alice", "pw1", Role::Admin, [9; 16]).unwrap();
20623        e.create_user("bob", "pw2", Role::ReadOnly, [5; 16])
20624            .unwrap();
20625        let bytes = e.snapshot();
20626        assert_eq!(&bytes[..8], b"SPGENV01", "must be the v4.1 envelope magic");
20627        let e2 = Engine::restore_envelope(&bytes).unwrap();
20628        assert_eq!(e2.users().len(), 2);
20629        assert_eq!(e2.verify_user("alice", "pw1"), Some(Role::Admin));
20630        assert_eq!(e2.verify_user("bob", "pw2"), Some(Role::ReadOnly));
20631        assert_eq!(e2.verify_user("alice", "wrong"), None);
20632        assert_eq!(e2.catalog().table_count(), 1);
20633    }
20634
20635    #[test]
20636    fn ddl_inside_tx_also_rolled_back() {
20637        let mut e = Engine::new();
20638        e.execute("BEGIN").unwrap();
20639        e.execute("CREATE TABLE t (v INT)").unwrap();
20640        // Visible inside the TX.
20641        e.execute("SELECT * FROM t").unwrap();
20642        e.execute("ROLLBACK").unwrap();
20643        // Gone after rollback.
20644        let err = e.execute("SELECT * FROM t").unwrap_err();
20645        assert!(matches!(
20646            err,
20647            EngineError::Storage(StorageError::TableNotFound { .. })
20648        ));
20649    }
20650
20651    // ── v6.1.2: CREATE / DROP PUBLICATION (engine-side) ──────
20652
20653    #[test]
20654    fn create_publication_lands_in_catalog() {
20655        let mut e = Engine::new();
20656        assert!(e.publications().is_empty());
20657        e.execute("CREATE PUBLICATION pub_a").unwrap();
20658        assert_eq!(e.publications().len(), 1);
20659        assert!(e.publications().contains("pub_a"));
20660    }
20661
20662    #[test]
20663    fn create_publication_duplicate_errors() {
20664        let mut e = Engine::new();
20665        e.execute("CREATE PUBLICATION pub_a").unwrap();
20666        let err = e.execute("CREATE PUBLICATION pub_a").unwrap_err();
20667        assert!(
20668            alloc::format!("{err:?}").contains("DuplicateName"),
20669            "got {err:?}"
20670        );
20671    }
20672
20673    #[test]
20674    fn drop_publication_silent_when_absent() {
20675        let mut e = Engine::new();
20676        // PG-compatible: DROP a publication that doesn't exist
20677        // succeeds (no-op) but reports zero affected.
20678        let r = e.execute("DROP PUBLICATION nope").unwrap();
20679        match r {
20680            QueryResult::CommandOk { affected, .. } => assert_eq!(affected, 0),
20681            other => panic!("expected CommandOk, got {other:?}"),
20682        }
20683    }
20684
20685    #[test]
20686    fn drop_publication_present_reports_one_affected() {
20687        let mut e = Engine::new();
20688        e.execute("CREATE PUBLICATION pub_a").unwrap();
20689        let r = e.execute("DROP PUBLICATION pub_a").unwrap();
20690        match r {
20691            QueryResult::CommandOk {
20692                affected,
20693                modified_catalog,
20694            } => {
20695                assert_eq!(affected, 1);
20696                assert!(modified_catalog);
20697            }
20698            other => panic!("expected CommandOk, got {other:?}"),
20699        }
20700        assert!(e.publications().is_empty());
20701    }
20702
20703    #[test]
20704    fn publications_persist_across_snapshot_restore() {
20705        // The persist-across-restart ship-gate at the engine layer —
20706        // snapshot → restore_envelope round trip must preserve the
20707        // publication catalog. The spg-server e2e covers the
20708        // process-restart variant.
20709        let mut e = Engine::new();
20710        e.execute("CREATE PUBLICATION pub_a").unwrap();
20711        e.execute("CREATE PUBLICATION pub_b FOR ALL TABLES")
20712            .unwrap();
20713        let snap = e.snapshot();
20714        let e2 = Engine::restore_envelope(&snap).unwrap();
20715        assert_eq!(e2.publications().len(), 2);
20716        assert!(e2.publications().contains("pub_a"));
20717        assert!(e2.publications().contains("pub_b"));
20718    }
20719
20720    #[test]
20721    fn create_publication_allowed_inside_transaction() {
20722        // v6.1.4 dropped the v6.1.2 in-TX guard — PG allows
20723        // CREATE PUBLICATION inside a TX and the auto-commit
20724        // wrap path needs the same allowance.
20725        let mut e = Engine::new();
20726        e.execute("BEGIN").unwrap();
20727        e.execute("CREATE PUBLICATION pub_a").unwrap();
20728        e.execute("COMMIT").unwrap();
20729        assert!(e.publications().contains("pub_a"));
20730    }
20731
20732    // ── v6.1.3: SHOW PUBLICATIONS + FOR-list variants ───────
20733
20734    #[test]
20735    fn create_publication_for_table_list_lands_with_scope() {
20736        let mut e = Engine::new();
20737        e.execute("CREATE TABLE t1 (id INT NOT NULL)").unwrap();
20738        e.execute("CREATE TABLE t2 (id INT NOT NULL)").unwrap();
20739        e.execute("CREATE PUBLICATION pub_a FOR TABLE t1, t2")
20740            .unwrap();
20741        let scope = e.publications().get("pub_a").cloned();
20742        let Some(spg_sql::ast::PublicationScope::ForTables(ts)) = scope else {
20743            panic!("expected ForTables scope, got {scope:?}")
20744        };
20745        assert_eq!(ts, alloc::vec!["t1".to_string(), "t2".to_string()]);
20746    }
20747
20748    #[test]
20749    fn create_publication_all_tables_except_lands_with_scope() {
20750        let mut e = Engine::new();
20751        e.execute("CREATE PUBLICATION pub_a FOR ALL TABLES EXCEPT t3")
20752            .unwrap();
20753        let scope = e.publications().get("pub_a").cloned();
20754        let Some(spg_sql::ast::PublicationScope::AllTablesExcept(ts)) = scope else {
20755            panic!("expected AllTablesExcept scope, got {scope:?}")
20756        };
20757        assert_eq!(ts, alloc::vec!["t3".to_string()]);
20758    }
20759
20760    #[test]
20761    fn show_publications_empty_returns_zero_rows() {
20762        let e = Engine::new();
20763        let r = e.execute_readonly("SHOW PUBLICATIONS").unwrap();
20764        let QueryResult::Rows { rows, columns } = r else {
20765            panic!()
20766        };
20767        assert!(rows.is_empty());
20768        assert_eq!(columns.len(), 3);
20769        assert_eq!(columns[0].name, "name");
20770        assert_eq!(columns[1].name, "scope");
20771        assert_eq!(columns[2].name, "table_count");
20772    }
20773
20774    #[test]
20775    fn show_publications_returns_one_row_per_publication_ordered_by_name() {
20776        let mut e = Engine::new();
20777        e.execute("CREATE PUBLICATION z_pub").unwrap();
20778        e.execute("CREATE PUBLICATION a_pub FOR TABLE t1, t2")
20779            .unwrap();
20780        e.execute("CREATE PUBLICATION m_pub FOR ALL TABLES EXCEPT bad")
20781            .unwrap();
20782        let r = e.execute_readonly("SHOW PUBLICATIONS").unwrap();
20783        let QueryResult::Rows { rows, .. } = r else {
20784            panic!()
20785        };
20786        assert_eq!(rows.len(), 3);
20787        // Alphabetical order: a_pub, m_pub, z_pub.
20788        let names: Vec<&str> = rows
20789            .iter()
20790            .map(|r| {
20791                if let Value::Text(s) = &r.values[0] {
20792                    s.as_str()
20793                } else {
20794                    panic!()
20795                }
20796            })
20797            .collect();
20798        assert_eq!(names, alloc::vec!["a_pub", "m_pub", "z_pub"]);
20799        // Row 0 — a_pub scope summary + table_count = 2.
20800        match &rows[0].values[1] {
20801            Value::Text(s) => assert_eq!(s, "FOR TABLE t1, t2"),
20802            other => panic!("expected Text, got {other:?}"),
20803        }
20804        assert_eq!(rows[0].values[2], Value::Int(2));
20805        // Row 1 — m_pub.
20806        match &rows[1].values[1] {
20807            Value::Text(s) => assert_eq!(s, "FOR ALL TABLES EXCEPT bad"),
20808            other => panic!("expected Text, got {other:?}"),
20809        }
20810        assert_eq!(rows[1].values[2], Value::Int(1));
20811        // Row 2 — z_pub (AllTables → NULL count).
20812        match &rows[2].values[1] {
20813            Value::Text(s) => assert_eq!(s, "FOR ALL TABLES"),
20814            other => panic!("expected Text, got {other:?}"),
20815        }
20816        assert_eq!(rows[2].values[2], Value::Null);
20817    }
20818
20819    #[test]
20820    fn for_list_scopes_persist_across_snapshot() {
20821        // The v6.1.2 envelope-v3 round-trip exercised AllTables;
20822        // v6.1.3 needs the scope-1 / scope-2 tags to survive too.
20823        let mut e = Engine::new();
20824        e.execute("CREATE PUBLICATION p1 FOR TABLE t1, t2").unwrap();
20825        e.execute("CREATE PUBLICATION p2 FOR ALL TABLES EXCEPT bad, worse")
20826            .unwrap();
20827        let snap = e.snapshot();
20828        let e2 = Engine::restore_envelope(&snap).unwrap();
20829        assert_eq!(e2.publications().len(), 2);
20830        let p1 = e2.publications().get("p1").cloned();
20831        let Some(spg_sql::ast::PublicationScope::ForTables(ts)) = p1 else {
20832            panic!("p1 scope lost: {p1:?}")
20833        };
20834        assert_eq!(ts, alloc::vec!["t1".to_string(), "t2".to_string()]);
20835        let p2 = e2.publications().get("p2").cloned();
20836        let Some(spg_sql::ast::PublicationScope::AllTablesExcept(ts)) = p2 else {
20837            panic!("p2 scope lost: {p2:?}")
20838        };
20839        assert_eq!(ts, alloc::vec!["bad".to_string(), "worse".to_string()]);
20840    }
20841
20842    // ── v6.1.4: CREATE / DROP SUBSCRIPTION + SHOW + envelope v4 ─
20843
20844    #[test]
20845    fn create_subscription_lands_in_catalog_with_defaults() {
20846        let mut e = Engine::new();
20847        e.execute(
20848            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=127.0.0.1 port=20002' PUBLICATION pub_a",
20849        )
20850        .unwrap();
20851        let s = e.subscriptions().get("sub_a").cloned().expect("present");
20852        assert_eq!(s.conn_str, "host=127.0.0.1 port=20002");
20853        assert_eq!(s.publications, alloc::vec!["pub_a".to_string()]);
20854        assert!(s.enabled);
20855        assert_eq!(s.last_received_pos, 0);
20856    }
20857
20858    #[test]
20859    fn create_subscription_duplicate_name_errors() {
20860        let mut e = Engine::new();
20861        e.execute("CREATE SUBSCRIPTION s CONNECTION 'host=x' PUBLICATION p")
20862            .unwrap();
20863        let err = e
20864            .execute("CREATE SUBSCRIPTION s CONNECTION 'host=y' PUBLICATION p")
20865            .unwrap_err();
20866        assert!(
20867            alloc::format!("{err:?}").contains("DuplicateName"),
20868            "got {err:?}"
20869        );
20870    }
20871
20872    #[test]
20873    fn drop_subscription_silent_when_absent() {
20874        let mut e = Engine::new();
20875        let r = e.execute("DROP SUBSCRIPTION never").unwrap();
20876        match r {
20877            QueryResult::CommandOk { affected, .. } => assert_eq!(affected, 0),
20878            other => panic!("expected CommandOk, got {other:?}"),
20879        }
20880    }
20881
20882    #[test]
20883    fn subscription_advance_updates_last_pos_monotone() {
20884        let mut e = Engine::new();
20885        e.execute("CREATE SUBSCRIPTION s CONNECTION 'h=x' PUBLICATION p")
20886            .unwrap();
20887        assert!(e.subscription_advance("s", 100));
20888        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 100);
20889        assert!(e.subscription_advance("s", 50)); // stale → ignored
20890        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 100);
20891        assert!(e.subscription_advance("s", 200));
20892        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 200);
20893        assert!(!e.subscription_advance("missing", 1));
20894    }
20895
20896    #[test]
20897    fn show_subscriptions_returns_rows_ordered_by_name() {
20898        let mut e = Engine::new();
20899        e.execute("CREATE SUBSCRIPTION z_sub CONNECTION 'h=x' PUBLICATION p1, p2")
20900            .unwrap();
20901        e.execute("CREATE SUBSCRIPTION a_sub CONNECTION 'h=y' PUBLICATION p3")
20902            .unwrap();
20903        let r = e.execute_readonly("SHOW SUBSCRIPTIONS").unwrap();
20904        let QueryResult::Rows { rows, columns } = r else {
20905            panic!()
20906        };
20907        assert_eq!(rows.len(), 2);
20908        assert_eq!(columns.len(), 5);
20909        assert_eq!(columns[0].name, "name");
20910        assert_eq!(columns[4].name, "last_received_pos");
20911        // Alphabetical: a_sub, z_sub.
20912        let names: Vec<&str> = rows
20913            .iter()
20914            .map(|r| {
20915                if let Value::Text(s) = &r.values[0] {
20916                    s.as_str()
20917                } else {
20918                    panic!()
20919                }
20920            })
20921            .collect();
20922        assert_eq!(names, alloc::vec!["a_sub", "z_sub"]);
20923        // Row 0: a_sub
20924        assert_eq!(rows[0].values[1], Value::Text("h=y".to_string()));
20925        assert_eq!(rows[0].values[2], Value::Text("p3".to_string()));
20926        assert_eq!(rows[0].values[3], Value::Bool(true));
20927        assert_eq!(rows[0].values[4], Value::BigInt(0));
20928        // Row 1: z_sub — publications join with ", "
20929        assert_eq!(rows[1].values[2], Value::Text("p1, p2".to_string()));
20930    }
20931
20932    #[test]
20933    fn subscriptions_persist_across_snapshot_envelope_v4() {
20934        let mut e = Engine::new();
20935        e.execute("CREATE SUBSCRIPTION s1 CONNECTION 'h=A' PUBLICATION p1, p2")
20936            .unwrap();
20937        e.execute("CREATE SUBSCRIPTION s2 CONNECTION 'h=B' PUBLICATION p3")
20938            .unwrap();
20939        e.subscription_advance("s2", 42);
20940        let snap = e.snapshot();
20941        let e2 = Engine::restore_envelope(&snap).unwrap();
20942        assert_eq!(e2.subscriptions().len(), 2);
20943        let s1 = e2.subscriptions().get("s1").unwrap();
20944        assert_eq!(s1.conn_str, "h=A");
20945        assert_eq!(
20946            s1.publications,
20947            alloc::vec!["p1".to_string(), "p2".to_string()]
20948        );
20949        assert_eq!(s1.last_received_pos, 0);
20950        let s2 = e2.subscriptions().get("s2").unwrap();
20951        assert_eq!(s2.last_received_pos, 42);
20952    }
20953
20954    #[test]
20955    fn v3_envelope_loads_with_empty_subscriptions() {
20956        // v3 snapshot (publications-only). Forge it by hand so we
20957        // verify v6.1.4 readers don't panic — they must surface
20958        // empty subscriptions and a populated publication table.
20959        let mut e = Engine::new();
20960        e.execute("CREATE PUBLICATION pub_legacy").unwrap();
20961        let catalog = e.catalog.serialize();
20962        let users = crate::users::serialize_users(&e.users);
20963        let pubs = e.publications.serialize();
20964        let mut buf = Vec::new();
20965        buf.extend_from_slice(b"SPGENV01");
20966        buf.push(3u8); // v3
20967        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
20968        buf.extend_from_slice(&catalog);
20969        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
20970        buf.extend_from_slice(&users);
20971        buf.extend_from_slice(&u32::try_from(pubs.len()).unwrap().to_le_bytes());
20972        buf.extend_from_slice(&pubs);
20973        let crc = spg_crypto::crc32::crc32(&buf);
20974        buf.extend_from_slice(&crc.to_le_bytes());
20975
20976        let e2 = Engine::restore_envelope(&buf).expect("v3 envelope restores under v4 reader");
20977        assert!(e2.subscriptions().is_empty());
20978        assert!(e2.publications().contains("pub_legacy"));
20979    }
20980
20981    #[test]
20982    fn create_subscription_allowed_inside_transaction() {
20983        let mut e = Engine::new();
20984        e.execute("BEGIN").unwrap();
20985        e.execute("CREATE SUBSCRIPTION s CONNECTION 'h=x' PUBLICATION p")
20986            .unwrap();
20987        e.execute("COMMIT").unwrap();
20988        assert!(e.subscriptions().contains("s"));
20989    }
20990
20991    // ── v6.2.0: ANALYZE + spg_statistic + envelope v5 ──────────
20992    #[test]
20993    fn analyze_populates_histogram_bounds() {
20994        let mut e = Engine::new();
20995        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT)")
20996            .unwrap();
20997        for i in 0..50 {
20998            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, 'name{i}')"))
20999                .unwrap();
21000        }
21001        e.execute("ANALYZE t").unwrap();
21002        let stats = e.statistics();
21003        let id_stats = stats.get("t", "id").unwrap();
21004        assert!(id_stats.histogram_bounds.len() >= 2);
21005        assert_eq!(id_stats.histogram_bounds.first().unwrap(), "0");
21006        assert_eq!(id_stats.histogram_bounds.last().unwrap(), "49");
21007        assert!((id_stats.null_frac - 0.0).abs() < 1e-6);
21008        assert_eq!(id_stats.n_distinct, 50);
21009    }
21010
21011    #[test]
21012    fn reanalyze_overwrites_prior_stats() {
21013        let mut e = Engine::new();
21014        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
21015        for i in 0..10 {
21016            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
21017                .unwrap();
21018        }
21019        e.execute("ANALYZE t").unwrap();
21020        let n1 = e.statistics().get("t", "id").unwrap().n_distinct;
21021        assert_eq!(n1, 10);
21022        for i in 10..30 {
21023            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
21024                .unwrap();
21025        }
21026        e.execute("ANALYZE t").unwrap();
21027        let n2 = e.statistics().get("t", "id").unwrap().n_distinct;
21028        assert_eq!(n2, 30);
21029    }
21030
21031    #[test]
21032    fn analyze_unknown_table_errors() {
21033        let mut e = Engine::new();
21034        let err = e.execute("ANALYZE nonexistent").unwrap_err();
21035        assert!(matches!(
21036            err,
21037            EngineError::Storage(StorageError::TableNotFound { .. })
21038        ));
21039    }
21040
21041    #[test]
21042    fn bare_analyze_covers_all_user_tables() {
21043        let mut e = Engine::new();
21044        e.execute("CREATE TABLE t1 (id INT NOT NULL)").unwrap();
21045        e.execute("CREATE TABLE t2 (name TEXT NOT NULL)").unwrap();
21046        e.execute("INSERT INTO t1 VALUES (1)").unwrap();
21047        e.execute("INSERT INTO t2 VALUES ('alice')").unwrap();
21048        let r = e.execute("ANALYZE").unwrap();
21049        match r {
21050            QueryResult::CommandOk {
21051                affected,
21052                modified_catalog,
21053            } => {
21054                assert_eq!(affected, 2);
21055                assert!(modified_catalog);
21056            }
21057            other => panic!("expected CommandOk, got {other:?}"),
21058        }
21059        assert!(e.statistics().get("t1", "id").is_some());
21060        assert!(e.statistics().get("t2", "name").is_some());
21061    }
21062
21063    #[test]
21064    fn select_from_spg_statistic_returns_rows_per_column() {
21065        let mut e = Engine::new();
21066        e.execute("CREATE TABLE t (id INT NOT NULL, label TEXT)")
21067            .unwrap();
21068        e.execute("INSERT INTO t VALUES (1, 'a')").unwrap();
21069        e.execute("INSERT INTO t VALUES (2, 'b')").unwrap();
21070        e.execute("ANALYZE t").unwrap();
21071        let r = e.execute_readonly("SELECT * FROM spg_statistic").unwrap();
21072        let QueryResult::Rows { rows, columns } = r else {
21073            panic!()
21074        };
21075        // v6.7.0 — spg_statistic gained a `cold_row_count` column.
21076        assert_eq!(columns.len(), 6);
21077        assert_eq!(columns[0].name, "table_name");
21078        assert_eq!(columns[4].name, "histogram_bounds");
21079        assert_eq!(columns[5].name, "cold_row_count");
21080        assert_eq!(rows.len(), 2, "one row per column of t");
21081        // Sorted by (table_name, column_name).
21082        match (&rows[0].values[0], &rows[0].values[1]) {
21083            (Value::Text(t), Value::Text(c)) => {
21084                assert_eq!(t, "t");
21085                // BTreeMap orders (table, column); columns "id" < "label".
21086                assert_eq!(c, "id");
21087            }
21088            _ => panic!(),
21089        }
21090    }
21091
21092    #[test]
21093    fn analyze_skips_vector_columns() {
21094        // Vector columns have their own stats shape (HNSW graph);
21095        // ANALYZE leaves them out of spg_statistic.
21096        let mut e = Engine::new();
21097        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(3) NOT NULL)")
21098            .unwrap();
21099        e.execute("INSERT INTO t VALUES (1, [1, 2, 3])").unwrap();
21100        e.execute("ANALYZE t").unwrap();
21101        assert!(e.statistics().get("t", "id").is_some());
21102        assert!(e.statistics().get("t", "v").is_none());
21103    }
21104
21105    #[test]
21106    fn statistics_persist_across_envelope_v5_round_trip() {
21107        let mut e = Engine::new();
21108        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
21109        for i in 0..20 {
21110            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
21111                .unwrap();
21112        }
21113        e.execute("ANALYZE").unwrap();
21114        let snap = e.snapshot();
21115        let e2 = Engine::restore_envelope(&snap).unwrap();
21116        let s = e2.statistics().get("t", "id").unwrap();
21117        assert_eq!(s.n_distinct, 20);
21118    }
21119
21120    // ── v6.2.1 auto-analyze threshold ───────────────────────────
21121
21122    #[test]
21123    fn auto_analyze_threshold_fires_after_10pct_of_min_rows_on_small_table() {
21124        // For a table with 0 rows then 10 inserts → modified=10,
21125        // row_count=10. Threshold = 0.1 × max(10, 100) = 10. So
21126        // after the 10th INSERT the threshold is met.
21127        let mut e = Engine::new();
21128        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
21129        for i in 0..9 {
21130            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
21131                .unwrap();
21132        }
21133        assert!(e.tables_needing_analyze().is_empty(), "9 < threshold");
21134        e.execute("INSERT INTO t VALUES (9)").unwrap();
21135        let needs = e.tables_needing_analyze();
21136        assert_eq!(needs, alloc::vec!["t".to_string()]);
21137    }
21138
21139    #[test]
21140    fn auto_analyze_threshold_uses_10pct_of_row_count_for_large_tables() {
21141        // After ANALYZE on 1000 rows, threshold = 0.1 × row_count.
21142        // Each new INSERT bumps both modified and row_count, so to
21143        // trigger from N=1000 we need modifications ≥ 0.1 × (1000+M),
21144        // i.e. M ≥ 112. The test inserts 50 (no fire), then 150
21145        // more (200 total mods, row_count=1200, threshold=120 → fire).
21146        let mut e = Engine::new();
21147        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
21148        for i in 0..1000 {
21149            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
21150                .unwrap();
21151        }
21152        e.execute("ANALYZE t").unwrap();
21153        assert!(e.tables_needing_analyze().is_empty(), "fresh ANALYZE");
21154        for i in 1000..1050 {
21155            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
21156                .unwrap();
21157        }
21158        assert!(
21159            e.tables_needing_analyze().is_empty(),
21160            "50 inserts < threshold of ~105"
21161        );
21162        for i in 1050..1200 {
21163            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
21164                .unwrap();
21165        }
21166        assert_eq!(
21167            e.tables_needing_analyze(),
21168            alloc::vec!["t".to_string()],
21169            "200 inserts > 0.1 × 1200 threshold"
21170        );
21171    }
21172
21173    #[test]
21174    fn auto_analyze_threshold_resets_after_analyze() {
21175        let mut e = Engine::new();
21176        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
21177        for i in 0..200 {
21178            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
21179                .unwrap();
21180        }
21181        assert!(!e.tables_needing_analyze().is_empty());
21182        e.execute("ANALYZE").unwrap();
21183        assert!(
21184            e.tables_needing_analyze().is_empty(),
21185            "ANALYZE must reset the counter"
21186        );
21187    }
21188
21189    #[test]
21190    fn auto_analyze_threshold_tracks_updates_and_deletes() {
21191        let mut e = Engine::new();
21192        e.execute("CREATE TABLE t (id INT NOT NULL, label TEXT)")
21193            .unwrap();
21194        for i in 0..50 {
21195            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, 'x')"))
21196                .unwrap();
21197        }
21198        e.execute("ANALYZE t").unwrap();
21199        // UPDATE 20 rows + DELETE 5 → modified=25. Threshold = 0.1
21200        // × max(50, 100) = 10. So 25 >= 10 → trigger.
21201        e.execute("UPDATE t SET label = 'y' WHERE id < 20").unwrap();
21202        e.execute("DELETE FROM t WHERE id >= 45").unwrap();
21203        assert_eq!(e.tables_needing_analyze(), alloc::vec!["t".to_string()]);
21204    }
21205
21206    #[test]
21207    fn v4_envelope_loads_with_empty_statistics() {
21208        // Forge a v4 envelope by hand: catalog + users + pubs +
21209        // subs trailer, no statistics. A v6.2.0 reader must accept
21210        // it and surface an empty Statistics.
21211        let mut e = Engine::new();
21212        e.create_user("alice", "secret", crate::users::Role::ReadOnly, [0u8; 16])
21213            .unwrap();
21214        let catalog = e.catalog.serialize();
21215        let users = crate::users::serialize_users(&e.users);
21216        let pubs = e.publications.serialize();
21217        let subs = e.subscriptions.serialize();
21218        let mut buf = Vec::new();
21219        buf.extend_from_slice(b"SPGENV01");
21220        buf.push(4u8);
21221        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
21222        buf.extend_from_slice(&catalog);
21223        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
21224        buf.extend_from_slice(&users);
21225        buf.extend_from_slice(&u32::try_from(pubs.len()).unwrap().to_le_bytes());
21226        buf.extend_from_slice(&pubs);
21227        buf.extend_from_slice(&u32::try_from(subs.len()).unwrap().to_le_bytes());
21228        buf.extend_from_slice(&subs);
21229        let crc = spg_crypto::crc32::crc32(&buf);
21230        buf.extend_from_slice(&crc.to_le_bytes());
21231        let e2 = Engine::restore_envelope(&buf).expect("v4 envelope restores");
21232        assert!(e2.statistics().is_empty());
21233    }
21234
21235    #[test]
21236    fn v1_v2_envelope_loads_with_empty_publications() {
21237        // A snapshot taken before v6.1.2 (no publication trailer,
21238        // envelope v2) must still deserialise — and the resulting
21239        // engine must report zero publications. Use the engine's own
21240        // round-trip with no publications: that emits v3 but with an
21241        // empty pubs block. Then forge a v2 envelope by hand to lock
21242        // the back-compat path.
21243        let mut e = Engine::new();
21244        // Force users to be non-empty so the snapshot takes the
21245        // envelope path rather than the bare-catalog fallback.
21246        e.create_user("alice", "secret", crate::users::Role::ReadOnly, [0u8; 16])
21247            .unwrap();
21248
21249        // Forge an envelope v2: same shape as v3 but no pubs trailer.
21250        let catalog = e.catalog.serialize();
21251        let users = crate::users::serialize_users(&e.users);
21252        let mut buf = Vec::new();
21253        buf.extend_from_slice(b"SPGENV01");
21254        buf.push(2u8); // v2
21255        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
21256        buf.extend_from_slice(&catalog);
21257        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
21258        buf.extend_from_slice(&users);
21259        let crc = spg_crypto::crc32::crc32(&buf);
21260        buf.extend_from_slice(&crc.to_le_bytes());
21261
21262        let e2 = Engine::restore_envelope(&buf).expect("v2 envelope restores");
21263        assert!(e2.publications().is_empty());
21264    }
21265}