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.10.10 — recurse children.
11167            Expr::Array(items) => {
11168                for elem in items {
11169                    self.resolve_expr_subqueries(elem, cancel)?;
11170                }
11171            }
11172            Expr::ArraySubscript { target, index } => {
11173                self.resolve_expr_subqueries(target, cancel)?;
11174                self.resolve_expr_subqueries(index, cancel)?;
11175            }
11176            Expr::AnyAll { expr, array, .. } => {
11177                self.resolve_expr_subqueries(expr, cancel)?;
11178                self.resolve_expr_subqueries(array, cancel)?;
11179            }
11180            Expr::Case {
11181                operand,
11182                branches,
11183                else_branch,
11184            } => {
11185                if let Some(o) = operand {
11186                    self.resolve_expr_subqueries(o, cancel)?;
11187                }
11188                for (w, t) in branches {
11189                    self.resolve_expr_subqueries(w, cancel)?;
11190                    self.resolve_expr_subqueries(t, cancel)?;
11191                }
11192                if let Some(e) = else_branch {
11193                    self.resolve_expr_subqueries(e, cancel)?;
11194                }
11195            }
11196        }
11197        Ok(())
11198    }
11199
11200    /// v4.23: per-row eval that handles correlated subqueries.
11201    /// Equivalent to `eval::eval_expr` when the expression has no
11202    /// subqueries; otherwise clones the expression, substitutes
11203    /// outer-row columns into each surviving subquery node, runs
11204    /// the inner SELECT, and replaces the node with the literal
11205    /// result. Only the WHERE-filter call sites use this path so
11206    /// the uncorrelated fast path is preserved everywhere else.
11207    fn eval_expr_with_correlated(
11208        &self,
11209        expr: &Expr,
11210        row: &Row,
11211        ctx: &EvalContext<'_>,
11212        cancel: CancelToken<'_>,
11213        mut memo: Option<&mut memoize::MemoizeCache>,
11214    ) -> Result<Value, EngineError> {
11215        if !expr_has_subquery(expr) {
11216            return eval::eval_expr(expr, row, ctx).map_err(EngineError::Eval);
11217        }
11218        // v7.29 (3c) - per-expression plan: the batch maps for this
11219        // host expression's scalar subqueries are looked up by the
11220        // expression's ADDRESS (stable across the row loop), so the
11221        // hot path does zero AST formatting. Building the plan (and
11222        // its Display-keyed group maps) happens once per expression.
11223        if let Some(m) = memo.as_deref_mut() {
11224            let key = core::ptr::from_ref::<Expr>(expr) as usize;
11225            // Plan hit: skip the collection walk entirely (it ran
11226            // once per group otherwise - 70k walks per inbox query).
11227            // The memo is per-query and host expressions outlive it,
11228            // so an address that hit once stays valid.
11229            let plan_hit = m.expr_plans.contains_key(&key);
11230            let mut subs: Vec<&SelectStatement> = Vec::new();
11231            if !plan_hit {
11232                collect_scalar_subqueries(expr, &mut subs);
11233            }
11234            if !plan_hit && !subs.is_empty() {
11235                let mut plan: Vec<Option<alloc::rc::Rc<memoize::GroupMap>>> =
11236                    Vec::with_capacity(subs.len());
11237                for sub in &subs {
11238                    let repr = alloc::format!("{sub}");
11239                    if !m.group_maps.contains_key(&repr) {
11240                        let built = self
11241                            .try_batch_correlated_scalar(sub, cancel)?
11242                            .map(alloc::rc::Rc::new);
11243                        m.group_maps.insert(repr.clone(), built);
11244                    }
11245                    plan.push(m.group_maps.get(&repr).cloned().flatten());
11246                }
11247                let mut template = expr.clone();
11248                hollow_scalar_subqueries(&mut template);
11249                m.expr_plans.insert(key, (subs.len(), plan, template));
11250            }
11251            if let Some((_, plan, template)) = m.expr_plans.get(&key)
11252                && !plan.is_empty()
11253                && plan.iter().all(|p| p.is_some())
11254            {
11255                // Fast path: every scalar subquery resolves via its
11256                // map; clone the HOLLOW template (subquery bodies
11257                // emptied at plan time - cloning full subquery ASTs
11258                // per row was the dominant malloc load), splice map
11259                // values, eval. Exists/IN subqueries (if any) still
11260                // drop to the resolver.
11261                let plan = plan.clone();
11262                let mut e = template.clone();
11263                let mut idx = 0usize;
11264                let ok = splice_planned_subqueries(&mut e, &plan, &mut idx, row, ctx)?;
11265                if ok {
11266                    if expr_has_subquery(&e) {
11267                        self.resolve_correlated_in_expr(&mut e, row, ctx, cancel, memo)?;
11268                    }
11269                    return eval::eval_expr(&e, row, ctx).map_err(EngineError::Eval);
11270                }
11271            }
11272        }
11273        let mut e = expr.clone();
11274        self.resolve_correlated_in_expr(&mut e, row, ctx, cancel, memo)?;
11275        eval::eval_expr(&e, row, ctx).map_err(EngineError::Eval)
11276    }
11277
11278    fn resolve_correlated_in_expr(
11279        &self,
11280        e: &mut Expr,
11281        row: &Row,
11282        ctx: &EvalContext<'_>,
11283        cancel: CancelToken<'_>,
11284        mut memo: Option<&mut memoize::MemoizeCache>,
11285    ) -> Result<(), EngineError> {
11286        match e {
11287            Expr::AggregateOrdered { call, order_by, .. } => {
11288                self.resolve_correlated_in_expr(call, row, ctx, cancel, memo.as_deref_mut())?;
11289                for o in order_by.iter_mut() {
11290                    self.resolve_correlated_in_expr(
11291                        &mut o.expr,
11292                        row,
11293                        ctx,
11294                        cancel,
11295                        memo.as_deref_mut(),
11296                    )?;
11297                }
11298            }
11299            Expr::ScalarSubquery(inner) => {
11300                // v7.29 (round-22 phase 3) — batch path first: a
11301                // correlated scalar of the `inner_col = outer_col
11302                // [ORDER BY … LIMIT 1]` shape evaluates ONCE as a
11303                // grouped scan; per-row resolution becomes a map
11304                // lookup. 23.5k per-group executions (~900 ms) became
11305                // one scan + lookups.
11306                if memo.is_some() {
11307                    let repr = alloc::format!("{}", **inner);
11308                    let entry_known = memo
11309                        .as_ref()
11310                        .is_some_and(|m| m.group_maps.contains_key(&repr));
11311                    if !entry_known {
11312                        let built = self
11313                            .try_batch_correlated_scalar(inner, cancel)?
11314                            .map(alloc::rc::Rc::new);
11315                        if let Some(m) = memo.as_deref_mut() {
11316                            m.group_maps.insert(repr.clone(), built);
11317                        }
11318                    }
11319                    if let Some(m) = memo.as_deref_mut()
11320                        && let Some(Some(gm)) = m.group_maps.get(&repr)
11321                    {
11322                        let (outer_col, map) = gm.as_ref();
11323                        let key_v = eval::eval_expr(&Expr::Column(outer_col.clone()), row, ctx)
11324                            .map_err(EngineError::Eval)?;
11325                        let v = if matches!(key_v, Value::Null) {
11326                            Value::Null
11327                        } else {
11328                            map.get(&aggregate::encode_key(core::slice::from_ref(&key_v)))
11329                                .cloned()
11330                                .unwrap_or(Value::Null)
11331                        };
11332                        *e = value_to_literal_expr(v)?;
11333                        return Ok(());
11334                    }
11335                }
11336                // v6.2.6 — Memoize: build the cache key from the
11337                // pre-substitution subquery repr + the outer row's
11338                // values. Two outer rows with identical correlated
11339                // values hit the same entry.
11340                let cache_key = memo.as_ref().map(|_| memoize::CacheKey {
11341                    subquery_repr: alloc::format!("{}", **inner),
11342                    outer_values: row.values.clone(),
11343                });
11344                if let (Some(cache), Some(k)) = (memo.as_deref_mut(), cache_key.as_ref())
11345                    && let Some(cached) = cache.get(k)
11346                {
11347                    *e = value_to_literal_expr(cached)?;
11348                    return Ok(());
11349                }
11350                let mut s = (**inner).clone();
11351                substitute_outer_columns(&mut s, row, ctx);
11352                let r = self.exec_select_cancel(&s, cancel)?;
11353                let QueryResult::Rows { rows, .. } = r else {
11354                    return Err(EngineError::Unsupported(
11355                        "scalar subquery: inner did not return rows".into(),
11356                    ));
11357                };
11358                let value = match rows.as_slice() {
11359                    [] => Value::Null,
11360                    [r0] => r0.values.first().cloned().unwrap_or(Value::Null),
11361                    _ => {
11362                        return Err(EngineError::Unsupported(alloc::format!(
11363                            "scalar subquery returned {} rows; expected 0 or 1",
11364                            rows.len()
11365                        )));
11366                    }
11367                };
11368                if let (Some(cache), Some(k)) = (memo.as_deref_mut(), cache_key) {
11369                    cache.insert(k, value.clone());
11370                }
11371                *e = value_to_literal_expr(value)?;
11372            }
11373            Expr::Exists { subquery, negated } => {
11374                let mut s = (**subquery).clone();
11375                substitute_outer_columns(&mut s, row, ctx);
11376                let r = self.exec_select_cancel(&s, cancel)?;
11377                let exists = matches!(r, QueryResult::Rows { rows, .. } if !rows.is_empty());
11378                let bit = if *negated { !exists } else { exists };
11379                *e = Expr::Literal(Literal::Bool(bit));
11380            }
11381            Expr::InSubquery {
11382                expr: lhs,
11383                subquery,
11384                negated,
11385            } => {
11386                self.resolve_correlated_in_expr(lhs, row, ctx, cancel, memo.as_deref_mut())?;
11387                let lhs_val = eval::eval_expr(lhs, row, ctx).map_err(EngineError::Eval)?;
11388                let mut s = (**subquery).clone();
11389                substitute_outer_columns(&mut s, row, ctx);
11390                let r = self.exec_select_cancel(&s, cancel)?;
11391                let QueryResult::Rows { columns, rows, .. } = r else {
11392                    return Err(EngineError::Unsupported(
11393                        "IN-subquery: inner did not return rows".into(),
11394                    ));
11395                };
11396                if columns.len() != 1 {
11397                    return Err(EngineError::Unsupported(alloc::format!(
11398                        "IN-subquery must project exactly one column; got {}",
11399                        columns.len()
11400                    )));
11401                }
11402                let mut found = false;
11403                let mut any_null = false;
11404                for r0 in rows {
11405                    let v = r0.values.into_iter().next().unwrap_or(Value::Null);
11406                    if v.is_null() {
11407                        any_null = true;
11408                        continue;
11409                    }
11410                    if value_cmp(&v, &lhs_val) == core::cmp::Ordering::Equal {
11411                        found = true;
11412                        break;
11413                    }
11414                }
11415                let bit = if found {
11416                    !*negated
11417                } else if any_null {
11418                    return Err(EngineError::Unsupported(
11419                        "IN-subquery with NULL in result and no match: NULL semantics not yet implemented".into(),
11420                    ));
11421                } else {
11422                    *negated
11423                };
11424                *e = Expr::Literal(Literal::Bool(bit));
11425            }
11426            Expr::Binary { lhs, rhs, .. } => {
11427                self.resolve_correlated_in_expr(lhs, row, ctx, cancel, memo.as_deref_mut())?;
11428                self.resolve_correlated_in_expr(rhs, row, ctx, cancel, memo.as_deref_mut())?;
11429            }
11430            Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
11431                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
11432            }
11433            Expr::Like { expr, pattern, .. } => {
11434                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
11435                self.resolve_correlated_in_expr(pattern, row, ctx, cancel, memo.as_deref_mut())?;
11436            }
11437            Expr::FunctionCall { args, .. } => {
11438                for a in args {
11439                    self.resolve_correlated_in_expr(a, row, ctx, cancel, memo.as_deref_mut())?;
11440                }
11441            }
11442            Expr::Extract { source, .. } => {
11443                self.resolve_correlated_in_expr(source, row, ctx, cancel, memo.as_deref_mut())?;
11444            }
11445            Expr::WindowFunction { .. }
11446            | Expr::Literal(_)
11447            | Expr::Placeholder(_)
11448            | Expr::Column(_) => {}
11449            // v7.10.10 — recurse children.
11450            Expr::Array(items) => {
11451                for elem in items {
11452                    self.resolve_correlated_in_expr(elem, row, ctx, cancel, memo.as_deref_mut())?;
11453                }
11454            }
11455            Expr::ArraySubscript { target, index } => {
11456                self.resolve_correlated_in_expr(target, row, ctx, cancel, memo.as_deref_mut())?;
11457                self.resolve_correlated_in_expr(index, row, ctx, cancel, memo.as_deref_mut())?;
11458            }
11459            Expr::AnyAll { expr, array, .. } => {
11460                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
11461                self.resolve_correlated_in_expr(array, row, ctx, cancel, memo.as_deref_mut())?;
11462            }
11463            Expr::Case {
11464                operand,
11465                branches,
11466                else_branch,
11467            } => {
11468                if let Some(o) = operand {
11469                    self.resolve_correlated_in_expr(o, row, ctx, cancel, memo.as_deref_mut())?;
11470                }
11471                for (w, t) in branches {
11472                    self.resolve_correlated_in_expr(w, row, ctx, cancel, memo.as_deref_mut())?;
11473                    self.resolve_correlated_in_expr(t, row, ctx, cancel, memo.as_deref_mut())?;
11474                }
11475                if let Some(e) = else_branch {
11476                    self.resolve_correlated_in_expr(e, row, ctx, cancel, memo.as_deref_mut())?;
11477                }
11478            }
11479        }
11480        Ok(())
11481    }
11482
11483    fn subquery_replacement(
11484        &self,
11485        e: &Expr,
11486        cancel: CancelToken<'_>,
11487    ) -> Result<Option<Expr>, EngineError> {
11488        match e {
11489            Expr::ScalarSubquery(inner) => {
11490                let mut s = (**inner).clone();
11491                // Recurse into the inner SELECT first so nested
11492                // subqueries materialise bottom-up.
11493                self.resolve_select_subqueries(&mut s, cancel)?;
11494                let r = match self.exec_bare_select_cancel(&s, cancel) {
11495                    Ok(r) => r,
11496                    Err(e) if is_correlation_error(&e) => return Ok(None),
11497                    Err(e) => return Err(e),
11498                };
11499                let QueryResult::Rows { rows, .. } = r else {
11500                    return Err(EngineError::Unsupported(
11501                        "scalar subquery: inner statement did not return rows".into(),
11502                    ));
11503                };
11504                let value = match rows.as_slice() {
11505                    [] => Value::Null,
11506                    [row] => row.values.first().cloned().unwrap_or(Value::Null),
11507                    _ => {
11508                        return Err(EngineError::Unsupported(alloc::format!(
11509                            "scalar subquery returned {} rows; expected 0 or 1",
11510                            rows.len()
11511                        )));
11512                    }
11513                };
11514                Ok(Some(value_to_literal_expr(value)?))
11515            }
11516            Expr::Exists { subquery, negated } => {
11517                let mut s = (**subquery).clone();
11518                self.resolve_select_subqueries(&mut s, cancel)?;
11519                let r = match self.exec_bare_select_cancel(&s, cancel) {
11520                    Ok(r) => r,
11521                    Err(e) if is_correlation_error(&e) => return Ok(None),
11522                    Err(e) => return Err(e),
11523                };
11524                let exists = match r {
11525                    QueryResult::Rows { rows, .. } => !rows.is_empty(),
11526                    QueryResult::CommandOk { .. } => false,
11527                };
11528                let bit = if *negated { !exists } else { exists };
11529                Ok(Some(Expr::Literal(Literal::Bool(bit))))
11530            }
11531            Expr::InSubquery {
11532                expr,
11533                subquery,
11534                negated,
11535            } => {
11536                let mut s = (**subquery).clone();
11537                self.resolve_select_subqueries(&mut s, cancel)?;
11538                let r = match self.exec_bare_select_cancel(&s, cancel) {
11539                    Ok(r) => r,
11540                    Err(e) if is_correlation_error(&e) => return Ok(None),
11541                    Err(e) => return Err(e),
11542                };
11543                let QueryResult::Rows { columns, rows, .. } = r else {
11544                    return Err(EngineError::Unsupported(
11545                        "IN-subquery: inner statement did not return rows".into(),
11546                    ));
11547                };
11548                if columns.len() != 1 {
11549                    return Err(EngineError::Unsupported(alloc::format!(
11550                        "IN-subquery must project exactly one column; got {}",
11551                        columns.len()
11552                    )));
11553                }
11554                // Build the same OR-Eq chain the parse-time literal-list
11555                // path constructs, with each value lifted into a Literal.
11556                let mut acc: Option<Expr> = None;
11557                for row in rows {
11558                    let v = row.values.into_iter().next().unwrap_or(Value::Null);
11559                    let lit = value_to_literal_expr(v)?;
11560                    let cmp = Expr::Binary {
11561                        lhs: expr.clone(),
11562                        op: BinOp::Eq,
11563                        rhs: Box::new(lit),
11564                    };
11565                    acc = Some(match acc {
11566                        None => cmp,
11567                        Some(prev) => Expr::Binary {
11568                            lhs: Box::new(prev),
11569                            op: BinOp::Or,
11570                            rhs: Box::new(cmp),
11571                        },
11572                    });
11573                }
11574                let combined = acc.unwrap_or(Expr::Literal(Literal::Bool(false)));
11575                let final_expr = if *negated {
11576                    Expr::Unary {
11577                        op: UnOp::Not,
11578                        expr: Box::new(combined),
11579                    }
11580                } else {
11581                    combined
11582                };
11583                Ok(Some(final_expr))
11584            }
11585            _ => Ok(None),
11586        }
11587    }
11588}
11589
11590// ---- v4.12 window-function helpers ----
11591// The (partition-key, order-key, original-index) tuple shape used
11592// across these helpers is intrinsic to the planner. Factoring it
11593// into a typedef adds indirection without making the code clearer,
11594// so several lints are allowed inline on the affected functions
11595// rather than module-wide.
11596
11597/// v4.22: cheap structural scan for `FROM <name>` (qualified or
11598/// not) inside a SELECT — used to verify the anchor of a WITH
11599/// RECURSIVE CTE doesn't recurse into itself. Conservative: walks
11600/// FROM joins, subqueries, and unions.
11601fn select_refers_to(stmt: &SelectStatement, target: &str) -> bool {
11602    if let Some(from) = &stmt.from
11603        && from_refers_to(from, target)
11604    {
11605        return true;
11606    }
11607    for (_, peer) in &stmt.unions {
11608        if select_refers_to(peer, target) {
11609            return true;
11610        }
11611    }
11612    for item in &stmt.items {
11613        if let SelectItem::Expr { expr, .. } = item
11614            && expr_refers_to(expr, target)
11615        {
11616            return true;
11617        }
11618    }
11619    if let Some(w) = &stmt.where_
11620        && expr_refers_to(w, target)
11621    {
11622        return true;
11623    }
11624    false
11625}
11626
11627fn from_refers_to(from: &FromClause, target: &str) -> bool {
11628    if from.primary.name.eq_ignore_ascii_case(target) {
11629        return true;
11630    }
11631    from.joins
11632        .iter()
11633        .any(|j| j.table.name.eq_ignore_ascii_case(target))
11634}
11635
11636/// v7.28 (round-22) — collect every QUALIFIED column referenced
11637/// anywhere in a SELECT (subquery bodies included). Returns None
11638/// when a wildcard or a bare column name makes static attribution
11639/// unsafe — callers then keep every column.
11640fn collect_qualified_refs(
11641    stmt: &SelectStatement,
11642    out: &mut alloc::collections::BTreeSet<(String, String)>,
11643) -> Option<()> {
11644    for item in &stmt.items {
11645        match item {
11646            SelectItem::Wildcard => return None,
11647            SelectItem::Expr { expr, .. } => collect_qualified_refs_expr(expr, out)?,
11648        }
11649    }
11650    if let Some(w) = &stmt.where_ {
11651        collect_qualified_refs_expr(w, out)?;
11652    }
11653    if let Some(from) = &stmt.from {
11654        for j in &from.joins {
11655            if let Some(on) = &j.on {
11656                collect_qualified_refs_expr(on, out)?;
11657            }
11658            if j.table.lateral_subquery.is_some() {
11659                return None;
11660            }
11661        }
11662    }
11663    if let Some(gs) = &stmt.group_by {
11664        for g in gs {
11665            collect_qualified_refs_expr(g, out)?;
11666        }
11667    }
11668    if let Some(h) = &stmt.having {
11669        collect_qualified_refs_expr(h, out)?;
11670    }
11671    for o in &stmt.order_by {
11672        collect_qualified_refs_expr(&o.expr, out)?;
11673    }
11674    for (_, peer) in &stmt.unions {
11675        collect_qualified_refs(peer, out)?;
11676    }
11677    for cte in &stmt.ctes {
11678        collect_qualified_refs(&cte.body, out)?;
11679    }
11680    Some(())
11681}
11682
11683fn collect_qualified_refs_expr(
11684    e: &Expr,
11685    out: &mut alloc::collections::BTreeSet<(String, String)>,
11686) -> Option<()> {
11687    // Two passes so the column and subquery visitors don't both
11688    // capture `out` mutably.
11689    let mut cols: Vec<spg_sql::ast::ColumnName> = Vec::new();
11690    let mut subs: Vec<&SelectStatement> = Vec::new();
11691    visit_expr_columns_and_subqueries(
11692        e,
11693        &mut |c: &spg_sql::ast::ColumnName| cols.push(c.clone()),
11694        &mut |sub| subs.push(sub),
11695    );
11696    for c in cols {
11697        match c.qualifier {
11698            Some(q) => {
11699                out.insert((q, c.name));
11700            }
11701            None => return None,
11702        }
11703    }
11704    for sub in subs {
11705        collect_qualified_refs(sub, out)?;
11706    }
11707    Some(())
11708}
11709
11710/// Immutable walk over an Expr visiting every Column and every
11711/// nested SelectStatement (v7.28).
11712fn visit_expr_columns_and_subqueries<'a>(
11713    e: &'a Expr,
11714    on_col: &mut impl FnMut(&'a spg_sql::ast::ColumnName),
11715    on_sub: &mut impl FnMut(&'a SelectStatement),
11716) {
11717    match e {
11718        Expr::Column(c) => on_col(c),
11719        Expr::ScalarSubquery(s) => on_sub(s),
11720        Expr::Exists { subquery, .. } => on_sub(subquery),
11721        Expr::InSubquery { expr, subquery, .. } => {
11722            visit_expr_columns_and_subqueries(expr, on_col, on_sub);
11723            on_sub(subquery);
11724        }
11725        Expr::Binary { lhs, rhs, .. } => {
11726            visit_expr_columns_and_subqueries(lhs, on_col, on_sub);
11727            visit_expr_columns_and_subqueries(rhs, on_col, on_sub);
11728        }
11729        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
11730            visit_expr_columns_and_subqueries(expr, on_col, on_sub);
11731        }
11732        Expr::Like { expr, pattern, .. } => {
11733            visit_expr_columns_and_subqueries(expr, on_col, on_sub);
11734            visit_expr_columns_and_subqueries(pattern, on_col, on_sub);
11735        }
11736        Expr::FunctionCall { args, .. } => {
11737            for a in args {
11738                visit_expr_columns_and_subqueries(a, on_col, on_sub);
11739            }
11740        }
11741        Expr::AggregateOrdered { call, order_by, .. } => {
11742            visit_expr_columns_and_subqueries(call, on_col, on_sub);
11743            for o in order_by {
11744                visit_expr_columns_and_subqueries(&o.expr, on_col, on_sub);
11745            }
11746        }
11747        Expr::Case {
11748            operand,
11749            branches,
11750            else_branch,
11751        } => {
11752            if let Some(op) = operand {
11753                visit_expr_columns_and_subqueries(op, on_col, on_sub);
11754            }
11755            for (w, t) in branches {
11756                visit_expr_columns_and_subqueries(w, on_col, on_sub);
11757                visit_expr_columns_and_subqueries(t, on_col, on_sub);
11758            }
11759            if let Some(eb) = else_branch {
11760                visit_expr_columns_and_subqueries(eb, on_col, on_sub);
11761            }
11762        }
11763        Expr::ArraySubscript { target, index } => {
11764            visit_expr_columns_and_subqueries(target, on_col, on_sub);
11765            visit_expr_columns_and_subqueries(index, on_col, on_sub);
11766        }
11767        Expr::Literal(_) | Expr::Placeholder(_) => {}
11768        // Exotic nodes (window etc.) — visit nothing extra; their
11769        // columns are caught when the caller bails on bare names
11770        // elsewhere, and window queries skip pruning entirely at
11771        // the call sites.
11772        _ => {
11773            // Exotic node (window function etc.): report an
11774            // unattributable marker so callers disable pruning.
11775            static BAIL: spg_sql::ast::ColumnName = spg_sql::ast::ColumnName {
11776                qualifier: None,
11777                name: String::new(),
11778            };
11779            on_col(&BAIL);
11780        }
11781    }
11782}
11783
11784/// v7.28 (round-22) — collect every Column qualifier in an expr;
11785/// `all_qualified` flips false on any bare column (those can't be
11786/// attributed to one table safely, so the pushdown skips them).
11787fn collect_column_qualifiers<'e>(e: &'e Expr, out: &mut Vec<&'e str>, all_qualified: &mut bool) {
11788    if let Expr::Column(c) = e {
11789        match &c.qualifier {
11790            Some(q) => out.push(q.as_str()),
11791            None => *all_qualified = false,
11792        }
11793        return;
11794    }
11795    // Reuse the canonical immutable walk via describe's walker shape:
11796    // recurse the common containers.
11797    match e {
11798        Expr::Binary { lhs, rhs, .. } => {
11799            collect_column_qualifiers(lhs, out, all_qualified);
11800            collect_column_qualifiers(rhs, out, all_qualified);
11801        }
11802        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
11803            collect_column_qualifiers(expr, out, all_qualified);
11804        }
11805        Expr::Like { expr, pattern, .. } => {
11806            collect_column_qualifiers(expr, out, all_qualified);
11807            collect_column_qualifiers(pattern, out, all_qualified);
11808        }
11809        Expr::FunctionCall { args, .. } => {
11810            for a in args {
11811                collect_column_qualifiers(a, out, all_qualified);
11812            }
11813        }
11814        Expr::Literal(_) | Expr::Placeholder(_) => {}
11815        // Anything exotic (CASE, subquery, window, arrays…):
11816        // conservatively mark unattributable.
11817        _ => *all_qualified = false,
11818    }
11819}
11820
11821fn expr_refers_to(e: &Expr, target: &str) -> bool {
11822    match e {
11823        Expr::AggregateOrdered { call, order_by, .. } => {
11824            expr_refers_to(call, target) || order_by.iter().any(|o| expr_refers_to(&o.expr, target))
11825        }
11826        Expr::ScalarSubquery(s) => select_refers_to(s, target),
11827        Expr::Exists { subquery, .. } | Expr::InSubquery { subquery, .. } => {
11828            select_refers_to(subquery, target)
11829        }
11830        Expr::Binary { lhs, rhs, .. } => expr_refers_to(lhs, target) || expr_refers_to(rhs, target),
11831        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
11832            expr_refers_to(expr, target)
11833        }
11834        Expr::Like { expr, pattern, .. } => {
11835            expr_refers_to(expr, target) || expr_refers_to(pattern, target)
11836        }
11837        Expr::FunctionCall { args, .. } => args.iter().any(|a| expr_refers_to(a, target)),
11838        Expr::Extract { source, .. } => expr_refers_to(source, target),
11839        Expr::WindowFunction {
11840            args,
11841            partition_by,
11842            order_by,
11843            ..
11844        } => {
11845            args.iter().any(|a| expr_refers_to(a, target))
11846                || partition_by.iter().any(|p| expr_refers_to(p, target))
11847                || order_by.iter().any(|(o, _, _)| expr_refers_to(o, target))
11848        }
11849        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => false,
11850        Expr::Array(items) => items.iter().any(|e| expr_refers_to(e, target)),
11851        Expr::ArraySubscript { target: t, index } => {
11852            expr_refers_to(t, target) || expr_refers_to(index, target)
11853        }
11854        Expr::AnyAll { expr, array, .. } => {
11855            expr_refers_to(expr, target) || expr_refers_to(array, target)
11856        }
11857        Expr::Case {
11858            operand,
11859            branches,
11860            else_branch,
11861        } => {
11862            operand
11863                .as_deref()
11864                .is_some_and(|o| expr_refers_to(o, target))
11865                || branches
11866                    .iter()
11867                    .any(|(w, t)| expr_refers_to(w, target) || expr_refers_to(t, target))
11868                || else_branch
11869                    .as_deref()
11870                    .is_some_and(|e| expr_refers_to(e, target))
11871        }
11872    }
11873}
11874
11875/// v4.22: pick more specific column types from observed rows when
11876/// the projection builder defaulted to Text (the v1.x behavior for
11877/// non-column expressions). Lets `WITH t(n) AS (SELECT 1 ...)`
11878/// land an Int column in the CTE storage table rather than failing
11879/// the insert with "expected TEXT, got INT".
11880/// v7.16.2 — map an SPG [`DataType`] to the PG-canonical
11881/// `information_schema.columns.data_type` text. Covers the
11882/// values mailrs's migrations probe (`'ARRAY'`, `'integer'`,
11883/// `'text'`, …). Unknown variants fall back to the SPG name
11884/// downcased — better than panicking on a future DataType.
11885fn pg_data_type_text(ty: DataType) -> alloc::string::String {
11886    let s = match ty {
11887        DataType::Int => "integer",
11888        DataType::BigInt => "bigint",
11889        DataType::SmallInt => "smallint",
11890        DataType::Float => "double precision",
11891        DataType::Bool => "boolean",
11892        DataType::Text => "text",
11893        DataType::Varchar(_) => "character varying",
11894        DataType::Date => "date",
11895        DataType::Timestamp => "timestamp without time zone",
11896        DataType::Timestamptz => "timestamp with time zone",
11897        DataType::Json => "jsonb",
11898        DataType::Bytes => "bytea",
11899        DataType::TextArray | DataType::IntArray | DataType::BigIntArray => "ARRAY",
11900        DataType::TsVector => "tsvector",
11901        DataType::TsQuery => "tsquery",
11902        DataType::Vector { .. } => "USER-DEFINED",
11903        // Non-exhaustive — fall back to "USER-DEFINED" the way
11904        // PG labels any pg_type it doesn't recognise.
11905        _ => "USER-DEFINED",
11906    };
11907    alloc::string::String::from(s)
11908}
11909
11910/// v7.16.2 — synthesise `information_schema.columns`. mailrs
11911/// queries are of shape `SELECT 1 FROM information_schema.columns
11912/// WHERE table_name = … AND column_name = … AND data_type = …` —
11913/// the v7.16.2 view returns the columns mailrs probes; broader
11914/// PG-spec parity (ordinal_position, is_nullable, character_
11915/// maximum_length, udt_name, …) lands as needed.
11916fn synth_information_schema_columns(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11917    let schema = alloc::vec![
11918        ColumnSchema::new("table_catalog", DataType::Text, false),
11919        ColumnSchema::new("table_schema", DataType::Text, false),
11920        ColumnSchema::new("table_name", DataType::Text, false),
11921        ColumnSchema::new("column_name", DataType::Text, false),
11922        ColumnSchema::new("ordinal_position", DataType::Int, false),
11923        ColumnSchema::new("is_nullable", DataType::Text, false),
11924        ColumnSchema::new("data_type", DataType::Text, false),
11925    ];
11926    let mut rows: Vec<Row> = Vec::new();
11927    for tname in cat.table_names() {
11928        let Some(t) = cat.get(&tname) else { continue };
11929        for (i, col) in t.schema().columns.iter().enumerate() {
11930            #[allow(clippy::cast_possible_wrap)]
11931            let ordinal = (i + 1) as i32;
11932            rows.push(Row::new(alloc::vec![
11933                Value::Text("spg".into()),
11934                Value::Text("public".into()),
11935                Value::Text(tname.clone()),
11936                Value::Text(col.name.clone()),
11937                Value::Int(ordinal),
11938                Value::Text(if col.nullable {
11939                    "YES".into()
11940                } else {
11941                    "NO".into()
11942                }),
11943                Value::Text(pg_data_type_text(col.ty)),
11944            ]));
11945        }
11946    }
11947    (schema, rows)
11948}
11949
11950/// v7.16.2 — synthesise `information_schema.tables`.
11951fn synth_information_schema_tables(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11952    let schema = alloc::vec![
11953        ColumnSchema::new("table_catalog", DataType::Text, false),
11954        ColumnSchema::new("table_schema", DataType::Text, false),
11955        ColumnSchema::new("table_name", DataType::Text, false),
11956        ColumnSchema::new("table_type", DataType::Text, false),
11957    ];
11958    let mut rows: Vec<Row> = Vec::new();
11959    for tname in cat.table_names() {
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("BASE TABLE".into()),
11965        ]));
11966    }
11967    (schema, rows)
11968}
11969
11970/// v7.16.2 — synthesise `pg_catalog.pg_class`. Minimum shape
11971/// for psql `\d` / ORM probes: `relname` + `relkind`. Each
11972/// user table emits one row.
11973fn synth_pg_class(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11974    let schema = alloc::vec![
11975        ColumnSchema::new("relname", DataType::Text, false),
11976        ColumnSchema::new("relkind", DataType::Text, false),
11977        ColumnSchema::new("relnamespace", DataType::BigInt, false),
11978    ];
11979    let mut rows: Vec<Row> = Vec::new();
11980    for tname in cat.table_names() {
11981        rows.push(Row::new(alloc::vec![
11982            Value::Text(tname.clone()),
11983            Value::Text("r".into()),
11984            Value::BigInt(2200), // PG's `public` namespace OID
11985        ]));
11986    }
11987    (schema, rows)
11988}
11989
11990/// v7.16.2 — synthesise `pg_catalog.pg_attribute`. Minimum
11991/// shape: `attrelid` (text — SPG has no OID), `attname`,
11992/// `attnum`, `atttypid` (text), `attnotnull`.
11993fn synth_pg_attribute(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11994    let schema = alloc::vec![
11995        ColumnSchema::new("attrelid", DataType::Text, false),
11996        ColumnSchema::new("attname", DataType::Text, false),
11997        ColumnSchema::new("attnum", DataType::Int, false),
11998        ColumnSchema::new("atttypid", DataType::Text, false),
11999        ColumnSchema::new("attnotnull", DataType::Bool, false),
12000    ];
12001    let mut rows: Vec<Row> = Vec::new();
12002    for tname in cat.table_names() {
12003        let Some(t) = cat.get(&tname) else { continue };
12004        for (i, col) in t.schema().columns.iter().enumerate() {
12005            #[allow(clippy::cast_possible_wrap)]
12006            let ordinal = (i + 1) as i32;
12007            rows.push(Row::new(alloc::vec![
12008                Value::Text(tname.clone()),
12009                Value::Text(col.name.clone()),
12010                Value::Int(ordinal),
12011                Value::Text(pg_data_type_text(col.ty)),
12012                Value::Bool(!col.nullable),
12013            ]));
12014        }
12015    }
12016    (schema, rows)
12017}
12018
12019/// v7.17.0 Phase 3.P0-50 — synthesise `pg_catalog.pg_type`. The
12020/// returned rows cover every built-in scalar / array type sqlx,
12021/// SQLAlchemy, Diesel and pgAdmin look up at compile / connect
12022/// time. PG-canonical schema columns we expose:
12023///   * oid           — type OID (the lookup key sqlx uses)
12024///   * typname       — canonical type name (`int4`, `text`, …)
12025///   * typlen        — width in bytes (-1 for var-length)
12026///   * typtype       — `b`ase / `c`omposite / `e`num / etc.
12027///   * typcategory   — PG type category single-char
12028///   * typelem       — element OID for arrays (0 otherwise)
12029///   * typarray      — array-type OID (0 if no array type)
12030///   * typnamespace  — schema OID (always `public` = 2200)
12031///
12032/// Other pg_type columns (typowner, typinput/typoutput, etc.)
12033/// land in follow-up work — sqlx encoders don't query them at
12034/// connect time.
12035fn synth_pg_type(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12036    let schema = alloc::vec![
12037        ColumnSchema::new("oid", DataType::BigInt, false),
12038        ColumnSchema::new("typname", DataType::Text, false),
12039        ColumnSchema::new("typlen", DataType::SmallInt, false),
12040        ColumnSchema::new("typtype", DataType::Text, false),
12041        ColumnSchema::new("typcategory", DataType::Text, false),
12042        ColumnSchema::new("typelem", DataType::BigInt, false),
12043        ColumnSchema::new("typarray", DataType::BigInt, false),
12044        ColumnSchema::new("typnamespace", DataType::BigInt, false),
12045    ];
12046    // (oid, name, len, type, cat, elem, array_oid). PG OID
12047    // numbers come straight from `pg_type.dat`.
12048    let scalars: &[(i64, &str, i16, &str, &str, i64, i64)] = &[
12049        // bool
12050        (16, "bool", 1, "b", "B", 0, 1000),
12051        (17, "bytea", -1, "b", "U", 0, 1001),
12052        (18, "char", 1, "b", "S", 0, 1002),
12053        (19, "name", 64, "b", "S", 0, 1003),
12054        (20, "int8", 8, "b", "N", 0, 1016),
12055        (21, "int2", 2, "b", "N", 0, 1005),
12056        (23, "int4", 4, "b", "N", 0, 1007),
12057        (24, "regproc", 4, "b", "N", 0, 1008),
12058        (25, "text", -1, "b", "S", 0, 1009),
12059        (26, "oid", 4, "b", "N", 0, 1028),
12060        (114, "json", -1, "b", "U", 0, 199),
12061        (142, "xml", -1, "b", "U", 0, 143),
12062        (700, "float4", 4, "b", "N", 0, 1021),
12063        (701, "float8", 8, "b", "N", 0, 1022),
12064        (650, "cidr", -1, "b", "I", 0, 651),
12065        (869, "inet", -1, "b", "I", 0, 1041),
12066        (829, "macaddr", 6, "b", "U", 0, 1040),
12067        (1042, "bpchar", -1, "b", "S", 0, 1014),
12068        (1043, "varchar", -1, "b", "S", 0, 1015),
12069        (1082, "date", 4, "b", "D", 0, 1182),
12070        (1083, "time", 8, "b", "D", 0, 1183),
12071        (1114, "timestamp", 8, "b", "D", 0, 1115),
12072        (1184, "timestamptz", 8, "b", "D", 0, 1185),
12073        (1186, "interval", 16, "b", "T", 0, 1187),
12074        (1266, "timetz", 12, "b", "D", 0, 1270),
12075        (1700, "numeric", -1, "b", "N", 0, 1231),
12076        (790, "money", 8, "b", "N", 0, 791),
12077        (2950, "uuid", 16, "b", "U", 0, 2951),
12078        (3802, "jsonb", -1, "b", "U", 0, 3807),
12079        (3614, "tsvector", -1, "b", "U", 0, 3643),
12080        (3615, "tsquery", -1, "b", "U", 0, 3645),
12081        // hstore + range types — typcategory 'U' (user) / 'R' (range).
12082        (3908, "tstzrange", -1, "r", "R", 0, 3909),
12083        (3910, "tsrange", -1, "r", "R", 0, 3911),
12084        (3904, "int4range", -1, "r", "R", 0, 3905),
12085        (3926, "int8range", -1, "r", "R", 0, 3927),
12086        (3906, "numrange", -1, "r", "R", 0, 3907),
12087        (3912, "daterange", -1, "r", "R", 0, 3913),
12088    ];
12089    // Array companion types share the typelem / typcategory='A'.
12090    // We emit just the array OIDs the scalars reference.
12091    let arrays: &[(i64, &str, i64)] = &[
12092        (1000, "_bool", 16),
12093        (1001, "_bytea", 17),
12094        (1002, "_char", 18),
12095        (1003, "_name", 19),
12096        (1016, "_int8", 20),
12097        (1005, "_int2", 21),
12098        (1007, "_int4", 23),
12099        (1008, "_regproc", 24),
12100        (1009, "_text", 25),
12101        (1028, "_oid", 26),
12102        (199, "_json", 114),
12103        (143, "_xml", 142),
12104        (1021, "_float4", 700),
12105        (1022, "_float8", 701),
12106        (651, "_cidr", 650),
12107        (1041, "_inet", 869),
12108        (1040, "_macaddr", 829),
12109        (1014, "_bpchar", 1042),
12110        (1015, "_varchar", 1043),
12111        (1182, "_date", 1082),
12112        (1183, "_time", 1083),
12113        (1115, "_timestamp", 1114),
12114        (1185, "_timestamptz", 1184),
12115        (1187, "_interval", 1186),
12116        (1270, "_timetz", 1266),
12117        (1231, "_numeric", 1700),
12118        (791, "_money", 790),
12119        (2951, "_uuid", 2950),
12120        (3807, "_jsonb", 3802),
12121        (3643, "_tsvector", 3614),
12122        (3645, "_tsquery", 3615),
12123    ];
12124    let mut rows: Vec<Row> = Vec::with_capacity(scalars.len() + arrays.len());
12125    for &(oid, name, len, ty, cat, elem, arr) in scalars {
12126        rows.push(Row::new(alloc::vec![
12127            Value::BigInt(oid),
12128            Value::Text(name.into()),
12129            Value::SmallInt(len),
12130            Value::Text(ty.into()),
12131            Value::Text(cat.into()),
12132            Value::BigInt(elem),
12133            Value::BigInt(arr),
12134            Value::BigInt(2200),
12135        ]));
12136    }
12137    for &(oid, name, elem) in arrays {
12138        rows.push(Row::new(alloc::vec![
12139            Value::BigInt(oid),
12140            Value::Text(name.into()),
12141            Value::SmallInt(-1),
12142            Value::Text("b".into()),
12143            Value::Text("A".into()),
12144            Value::BigInt(elem),
12145            Value::BigInt(0),
12146            Value::BigInt(2200),
12147        ]));
12148    }
12149    (schema, rows)
12150}
12151
12152/// v7.17.0 Phase 3.P0-51 — synthesise `pg_catalog.pg_proc`. ORM /
12153/// pgAdmin probes look up functions by name; SPG synthesises rows
12154/// for the built-in scalar functions / aggregates / window funcs
12155/// the engine actually dispatches. SPG has no user-defined
12156/// functions yet so the table is a stable static list.
12157///
12158/// Schema columns exposed:
12159///   * oid (BigInt) — function OID from PG's pg_proc.dat
12160///   * proname (Text) — function name (lowercase)
12161///   * pronamespace (BigInt) — 11 (`pg_catalog`)
12162///   * prokind (Text) — 'f' function, 'a' aggregate, 'w' window
12163///   * pronargs (SmallInt) — declared arg count (-1 for variadic)
12164///   * prorettype (BigInt) — return type OID (matches synth_pg_type)
12165/// v7.24 (round-16 D) — synthesise `pg_catalog.pg_trigger` from the
12166/// live catalog. PG-shaped core columns (tgname, tgenabled with
12167/// 'O'/'D') plus pragmatic text columns PG keeps relational
12168/// (relname, timing, events, function) so health checks don't need
12169/// oid joins.
12170fn synth_pg_trigger(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12171    let schema = alloc::vec![
12172        ColumnSchema::new("tgname", DataType::Text, false),
12173        ColumnSchema::new("relname", DataType::Text, false),
12174        ColumnSchema::new("tgenabled", DataType::Text, false),
12175        ColumnSchema::new("timing", DataType::Text, false),
12176        ColumnSchema::new("events", DataType::Text, false),
12177        ColumnSchema::new("function", DataType::Text, false),
12178    ];
12179    let rows: Vec<Row> = cat
12180        .triggers()
12181        .iter()
12182        .map(|t| {
12183            Row::new(alloc::vec![
12184                Value::Text(t.name.clone()),
12185                Value::Text(t.table.clone()),
12186                Value::Text(if t.enabled { "O".into() } else { "D".into() }),
12187                Value::Text(t.timing.clone()),
12188                Value::Text(t.events.join(" OR ")),
12189                Value::Text(t.function.clone()),
12190            ])
12191        })
12192        .collect();
12193    (schema, rows)
12194}
12195
12196fn synth_pg_proc(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12197    let schema = alloc::vec![
12198        ColumnSchema::new("oid", DataType::BigInt, false),
12199        ColumnSchema::new("proname", DataType::Text, false),
12200        ColumnSchema::new("pronamespace", DataType::BigInt, false),
12201        ColumnSchema::new("prokind", DataType::Text, false),
12202        ColumnSchema::new("pronargs", DataType::Int, false),
12203        ColumnSchema::new("prorettype", DataType::BigInt, false),
12204    ];
12205    // (oid, name, kind, nargs, rettype). OIDs taken from PG's
12206    // pg_proc.dat for the common subset.
12207    let funcs: &[(i64, &str, &str, i32, i64)] = &[
12208        // Scalar functions.
12209        (1318, "length", "f", 1, 23),
12210        (871, "upper", "f", 1, 25),
12211        (870, "lower", "f", 1, 25),
12212        (936, "substring", "f", 3, 25),
12213        (937, "substring", "f", 2, 25),
12214        (3055, "btrim", "f", 1, 25),
12215        (885, "btrim", "f", 2, 25),
12216        (3056, "ltrim", "f", 1, 25),
12217        (875, "ltrim", "f", 2, 25),
12218        (3057, "rtrim", "f", 1, 25),
12219        (876, "rtrim", "f", 2, 25),
12220        (1397, "abs", "f", 1, 23),
12221        (1396, "abs", "f", 1, 20),
12222        (1606, "round", "f", 1, 1700),
12223        (1707, "round", "f", 2, 1700),
12224        (2308, "ceil", "f", 1, 701),
12225        (2309, "ceiling", "f", 1, 701),
12226        (2310, "floor", "f", 1, 701),
12227        (1376, "sqrt", "f", 1, 701),
12228        (1369, "ln", "f", 1, 701),
12229        (1373, "exp", "f", 1, 701),
12230        (1368, "power", "f", 2, 701),
12231        (2228, "random", "f", 0, 701),
12232        // Date / time.
12233        (1299, "now", "f", 0, 1184),
12234        (1274, "current_timestamp", "f", 0, 1184),
12235        (1140, "current_date", "f", 0, 1082),
12236        (2050, "current_time", "f", 0, 1083),
12237        (1158, "date_trunc", "f", 2, 1184),
12238        (1171, "date_part", "f", 2, 701),
12239        (1172, "age", "f", 1, 1186),
12240        (936, "to_char", "f", 2, 25),
12241        // Session / introspection.
12242        (861, "current_database", "f", 0, 19),
12243        (745, "current_user", "f", 0, 19),
12244        (745, "session_user", "f", 0, 19),
12245        (1402, "current_schema", "f", 0, 19),
12246        // String concat / format.
12247        (3058, "concat", "f", -1, 25),
12248        (3059, "concat_ws", "f", -1, 25),
12249        (3539, "format", "f", -1, 25),
12250        // Type introspection.
12251        (2877, "pg_typeof", "f", 1, 2206),
12252        // JSON.
12253        (3198, "json_build_object", "f", -1, 114),
12254        (3199, "jsonb_build_object", "f", -1, 3802),
12255        (3271, "json_build_array", "f", -1, 114),
12256        (3272, "jsonb_build_array", "f", -1, 3802),
12257        // UUID.
12258        (3253, "gen_random_uuid", "f", 0, 2950),
12259        (3252, "uuid_generate_v4", "f", 0, 2950),
12260        // Aggregates.
12261        (2147, "count", "a", 0, 20),
12262        (2803, "count", "a", -1, 20),
12263        (2116, "max", "a", 1, 23),
12264        (2132, "min", "a", 1, 23),
12265        (2108, "sum", "a", 1, 20),
12266        (2100, "avg", "a", 1, 1700),
12267        (2517, "string_agg", "a", 2, 25),
12268        (2747, "array_agg", "a", 1, 1009),
12269        (2517, "bool_and", "a", 1, 16),
12270        (2518, "bool_or", "a", 1, 16),
12271        (2519, "every", "a", 1, 16),
12272        // Window functions.
12273        (3100, "row_number", "w", 0, 20),
12274        (3101, "rank", "w", 0, 20),
12275        (3102, "dense_rank", "w", 0, 20),
12276        (3103, "percent_rank", "w", 0, 701),
12277        (3104, "cume_dist", "w", 0, 701),
12278        (3105, "lag", "w", -1, 2283),
12279        (3106, "lead", "w", -1, 2283),
12280        (3107, "first_value", "w", 1, 2283),
12281        (3108, "last_value", "w", 1, 2283),
12282        (3109, "nth_value", "w", 2, 2283),
12283    ];
12284    let mut rows: Vec<Row> = Vec::with_capacity(funcs.len());
12285    for &(oid, name, kind, nargs, rettype) in funcs {
12286        rows.push(Row::new(alloc::vec![
12287            Value::BigInt(oid),
12288            Value::Text(name.into()),
12289            Value::BigInt(11),
12290            Value::Text(kind.into()),
12291            Value::Int(nargs),
12292            Value::BigInt(rettype),
12293        ]));
12294    }
12295    (schema, rows)
12296}
12297
12298/// v7.17.0 Phase 3.P0-65 — synthesise `mysql.user`. MySQL admin
12299/// queries (`SELECT user, host FROM mysql.user`) probe this at
12300/// connect time to list accounts. SPG ships one row per
12301/// UserStore entry plus a synthetic `root` superuser row for
12302/// MySQL bootstrap compat.
12303fn synth_mysql_user(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
12304    let schema = alloc::vec![
12305        ColumnSchema::new("user", DataType::Text, false),
12306        ColumnSchema::new("host", DataType::Text, false),
12307        ColumnSchema::new("select_priv", DataType::Text, false),
12308    ];
12309    let mut rows: Vec<Row> = Vec::new();
12310    rows.push(Row::new(alloc::vec![
12311        Value::Text("root".into()),
12312        Value::Text("localhost".into()),
12313        Value::Text("Y".into()),
12314    ]));
12315    for (name, _) in engine.users.iter() {
12316        if name != "root" {
12317            rows.push(Row::new(alloc::vec![
12318                Value::Text(name.to_string()),
12319                Value::Text("%".into()),
12320                Value::Text("Y".into()),
12321            ]));
12322        }
12323    }
12324    (schema, rows)
12325}
12326
12327/// v7.17.0 Phase 3.P0-65 — synthesise `mysql.db`. The
12328/// per-database privileges table. SPG is single-database so the
12329/// table surfaces one row per declared user with full privileges
12330/// on the canonical `postgres` database.
12331fn synth_mysql_db() -> (Vec<ColumnSchema>, Vec<Row>) {
12332    let schema = alloc::vec![
12333        ColumnSchema::new("host", DataType::Text, false),
12334        ColumnSchema::new("db", DataType::Text, false),
12335        ColumnSchema::new("user", DataType::Text, false),
12336        ColumnSchema::new("select_priv", DataType::Text, false),
12337    ];
12338    let rows = alloc::vec![Row::new(alloc::vec![
12339        Value::Text("localhost".into()),
12340        Value::Text("postgres".into()),
12341        Value::Text("root".into()),
12342        Value::Text("Y".into()),
12343    ])];
12344    (schema, rows)
12345}
12346
12347/// v7.17.0 Phase 3.P0-63 — synthesise
12348/// `information_schema.KEY_COLUMN_USAGE`. ORM migration tools
12349/// (Alembic, Sequelize, TypeORM) walk this view to discover FK
12350/// relationships in MySQL-flavoured introspection queries.
12351///
12352/// Schema columns exposed:
12353///   * CONSTRAINT_NAME (Text)
12354///   * TABLE_NAME (Text)
12355///   * COLUMN_NAME (Text)
12356///   * ORDINAL_POSITION (Int)
12357///   * REFERENCED_TABLE_NAME (Text) — empty for non-FK rows
12358///   * REFERENCED_COLUMN_NAME (Text) — empty for non-FK rows
12359fn synth_info_key_column_usage(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12360    let schema = alloc::vec![
12361        ColumnSchema::new("constraint_name", DataType::Text, false),
12362        ColumnSchema::new("table_name", DataType::Text, false),
12363        ColumnSchema::new("column_name", DataType::Text, false),
12364        ColumnSchema::new("ordinal_position", DataType::Int, false),
12365        ColumnSchema::new("referenced_table_name", DataType::Text, false),
12366        ColumnSchema::new("referenced_column_name", DataType::Text, false),
12367    ];
12368    let mut rows: Vec<Row> = Vec::new();
12369    for tname in cat.table_names() {
12370        let Some(t) = cat.get(&tname) else { continue };
12371        let cols = &t.schema().columns;
12372        let col_name_at = |pos: usize| -> String {
12373            cols.get(pos)
12374                .map_or_else(|| alloc::format!("col{pos}"), |c| c.name.clone())
12375        };
12376        // FKs.
12377        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
12378            let conname = fk
12379                .name
12380                .clone()
12381                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
12382            for (i, (&local, &parent)) in fk
12383                .local_columns
12384                .iter()
12385                .zip(fk.parent_columns.iter())
12386                .enumerate()
12387            {
12388                let parent_name = cat
12389                    .get(&fk.parent_table)
12390                    .and_then(|pt| pt.schema().columns.get(parent).map(|c| c.name.clone()))
12391                    .unwrap_or_else(|| alloc::format!("col{parent}"));
12392                #[allow(clippy::cast_possible_wrap)]
12393                let ordinal = (i + 1) as i32;
12394                rows.push(Row::new(alloc::vec![
12395                    Value::Text(conname.clone()),
12396                    Value::Text(tname.clone()),
12397                    Value::Text(col_name_at(local)),
12398                    Value::Int(ordinal),
12399                    Value::Text(fk.parent_table.clone()),
12400                    Value::Text(parent_name),
12401                ]));
12402            }
12403        }
12404        // PK / composite UC entries.
12405        for (ci, uc) in t.schema().uniqueness_constraints.iter().enumerate() {
12406            let conname = if uc.is_primary_key {
12407                alloc::format!("{}_pkey", tname)
12408            } else {
12409                alloc::format!("{}_uniq{ci}", tname)
12410            };
12411            for (i, &local) in uc.columns.iter().enumerate() {
12412                #[allow(clippy::cast_possible_wrap)]
12413                let ordinal = (i + 1) as i32;
12414                rows.push(Row::new(alloc::vec![
12415                    Value::Text(conname.clone()),
12416                    Value::Text(tname.clone()),
12417                    Value::Text(col_name_at(local)),
12418                    Value::Int(ordinal),
12419                    Value::Text(String::new()),
12420                    Value::Text(String::new()),
12421                ]));
12422            }
12423        }
12424    }
12425    (schema, rows)
12426}
12427
12428/// v7.17.0 Phase 3.P0-64 — synthesise
12429/// `information_schema.REFERENTIAL_CONSTRAINTS`. One row per FK.
12430fn synth_info_referential_constraints(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12431    let schema = alloc::vec![
12432        ColumnSchema::new("constraint_name", DataType::Text, false),
12433        ColumnSchema::new("table_name", DataType::Text, false),
12434        ColumnSchema::new("referenced_table_name", DataType::Text, false),
12435        ColumnSchema::new("update_rule", DataType::Text, false),
12436        ColumnSchema::new("delete_rule", DataType::Text, false),
12437    ];
12438    fn rule_name(a: spg_storage::FkAction) -> &'static str {
12439        match a {
12440            spg_storage::FkAction::Cascade => "CASCADE",
12441            spg_storage::FkAction::SetNull => "SET NULL",
12442            spg_storage::FkAction::SetDefault => "SET DEFAULT",
12443            spg_storage::FkAction::Restrict => "RESTRICT",
12444            spg_storage::FkAction::NoAction => "NO ACTION",
12445        }
12446    }
12447    let mut rows: Vec<Row> = Vec::new();
12448    for tname in cat.table_names() {
12449        let Some(t) = cat.get(&tname) else { continue };
12450        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
12451            let conname = fk
12452                .name
12453                .clone()
12454                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
12455            rows.push(Row::new(alloc::vec![
12456                Value::Text(conname),
12457                Value::Text(tname.clone()),
12458                Value::Text(fk.parent_table.clone()),
12459                Value::Text(rule_name(fk.on_update).into()),
12460                Value::Text(rule_name(fk.on_delete).into()),
12461            ]));
12462        }
12463    }
12464    (schema, rows)
12465}
12466
12467/// v7.17.0 Phase 3.P0-64 — synthesise `information_schema.STATISTICS`.
12468/// One row per (index × column) — admin tools walk this to
12469/// surface index-cardinality estimates.
12470fn synth_info_statistics(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12471    let schema = alloc::vec![
12472        ColumnSchema::new("table_name", DataType::Text, false),
12473        ColumnSchema::new("index_name", DataType::Text, false),
12474        ColumnSchema::new("column_name", DataType::Text, false),
12475        ColumnSchema::new("seq_in_index", DataType::Int, false),
12476        ColumnSchema::new("non_unique", DataType::Int, false),
12477        ColumnSchema::new("index_type", DataType::Text, false),
12478    ];
12479    let mut rows: Vec<Row> = Vec::new();
12480    for tname in cat.table_names() {
12481        let Some(t) = cat.get(&tname) else { continue };
12482        for idx in t.indices() {
12483            let col = t
12484                .schema()
12485                .columns
12486                .get(idx.column_position)
12487                .map_or("?".into(), |c| c.name.clone());
12488            rows.push(Row::new(alloc::vec![
12489                Value::Text(tname.clone()),
12490                Value::Text(idx.name.clone()),
12491                Value::Text(col),
12492                Value::Int(1),
12493                Value::Int(i32::from(!idx.is_unique)),
12494                Value::Text("BTREE".into()),
12495            ]));
12496        }
12497    }
12498    (schema, rows)
12499}
12500
12501/// v7.17.0 Phase 3.P0-64 — synthesise `information_schema.ROUTINES`.
12502/// SPG has no user-defined functions in v7.17 so the surface is
12503/// always empty; admin tools just need the table to exist.
12504fn synth_info_routines() -> (Vec<ColumnSchema>, Vec<Row>) {
12505    let schema = alloc::vec![
12506        ColumnSchema::new("routine_name", DataType::Text, false),
12507        ColumnSchema::new("routine_type", DataType::Text, false),
12508        ColumnSchema::new("data_type", DataType::Text, false),
12509    ];
12510    (schema, Vec::new())
12511}
12512
12513/// v7.17.0 Phase 3.P0-54 — synthesise `pg_catalog.pg_constraint`.
12514/// ORM compilers (Diesel, sea-orm) and admin tools probe this for
12515/// FK / UNIQUE / PK / CHECK definitions to surface relationship
12516/// graphs and validation rules. SPG ships one row per
12517/// uniqueness constraint + foreign key declared in the catalog.
12518///
12519/// Schema columns exposed:
12520///   * conname (Text) — constraint name (synthetic when anonymous)
12521///   * contype (Text) — `p` PK, `u` UNIQUE, `f` FK, `c` CHECK
12522///   * conrelid (Text) — owner table name
12523///   * confrelid (Text) — referenced parent table (FK only;
12524///     empty string otherwise)
12525///   * conkey (Text) — comma-separated column names
12526///   * confkey (Text) — comma-separated parent column names (FK only)
12527fn synth_pg_constraint(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12528    let schema = alloc::vec![
12529        ColumnSchema::new("conname", DataType::Text, false),
12530        ColumnSchema::new("contype", DataType::Text, false),
12531        ColumnSchema::new("conrelid", DataType::Text, false),
12532        ColumnSchema::new("confrelid", DataType::Text, false),
12533        ColumnSchema::new("conkey", DataType::Text, false),
12534        ColumnSchema::new("confkey", DataType::Text, false),
12535    ];
12536    let mut rows: Vec<Row> = Vec::new();
12537    for tname in cat.table_names() {
12538        let Some(t) = cat.get(&tname) else { continue };
12539        let cols = &t.schema().columns;
12540        let col_name_at = |pos: usize| -> String {
12541            cols.get(pos)
12542                .map_or_else(|| alloc::format!("col{pos}"), |c| c.name.clone())
12543        };
12544        // Uniqueness constraints (composite UNIQUE / PRIMARY KEY).
12545        for (ci, uc) in t.schema().uniqueness_constraints.iter().enumerate() {
12546            let kind = if uc.is_primary_key { "p" } else { "u" };
12547            let conname = if uc.is_primary_key {
12548                alloc::format!("{}_pkey", tname)
12549            } else {
12550                alloc::format!("{}_uniq{ci}", tname)
12551            };
12552            let conkey: Vec<String> = uc.columns.iter().map(|&p| col_name_at(p)).collect();
12553            rows.push(Row::new(alloc::vec![
12554                Value::Text(conname),
12555                Value::Text(kind.into()),
12556                Value::Text(tname.clone()),
12557                Value::Text(String::new()),
12558                Value::Text(conkey.join(",")),
12559                Value::Text(String::new()),
12560            ]));
12561        }
12562        // Single-column PK / UNIQUE indexes that have no
12563        // matching entry in `uniqueness_constraints` (the engine
12564        // creates only the BTree index for the bare-column case;
12565        // composite forms ride the UC path above).
12566        for idx in t.indices() {
12567            if !idx.is_unique {
12568                continue;
12569            }
12570            let is_primary = idx.name.ends_with("_pkey");
12571            let conname = idx.name.clone();
12572            let kind = if is_primary { "p" } else { "u" };
12573            let col_name = col_name_at(idx.column_position);
12574            // Skip if already emitted via the UC loop above (same
12575            // tuple shape — single-column).
12576            let already = t
12577                .schema()
12578                .uniqueness_constraints
12579                .iter()
12580                .any(|uc| uc.columns.len() == 1 && uc.columns[0] == idx.column_position);
12581            if already {
12582                continue;
12583            }
12584            rows.push(Row::new(alloc::vec![
12585                Value::Text(conname),
12586                Value::Text(kind.into()),
12587                Value::Text(tname.clone()),
12588                Value::Text(String::new()),
12589                Value::Text(col_name),
12590                Value::Text(String::new()),
12591            ]));
12592        }
12593        // Foreign keys.
12594        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
12595            let conname = fk
12596                .name
12597                .clone()
12598                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
12599            let conkey: Vec<String> = fk.local_columns.iter().map(|&p| col_name_at(p)).collect();
12600            // Parent column names: look up the parent table's
12601            // schema if it exists; otherwise emit positions.
12602            let confkey: Vec<String> = if let Some(parent) = cat.get(&fk.parent_table) {
12603                fk.parent_columns
12604                    .iter()
12605                    .map(|&p| {
12606                        parent
12607                            .schema()
12608                            .columns
12609                            .get(p)
12610                            .map_or_else(|| alloc::format!("col{p}"), |c| c.name.clone())
12611                    })
12612                    .collect()
12613            } else {
12614                fk.parent_columns
12615                    .iter()
12616                    .map(|p| alloc::format!("col{p}"))
12617                    .collect()
12618            };
12619            rows.push(Row::new(alloc::vec![
12620                Value::Text(conname),
12621                Value::Text("f".into()),
12622                Value::Text(tname.clone()),
12623                Value::Text(fk.parent_table.clone()),
12624                Value::Text(conkey.join(",")),
12625                Value::Text(confkey.join(",")),
12626            ]));
12627        }
12628    }
12629    (schema, rows)
12630}
12631
12632/// v7.17.0 Phase 3.P0-55 — synthesise `pg_catalog.pg_database`.
12633/// SPG is single-database so we surface a single row keyed on the
12634/// canonical `postgres` database name (matching what every PG
12635/// admin tool's startup screen expects to find).
12636fn synth_pg_database(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12637    let schema = alloc::vec![
12638        ColumnSchema::new("oid", DataType::BigInt, false),
12639        ColumnSchema::new("datname", DataType::Text, false),
12640        ColumnSchema::new("datdba", DataType::BigInt, false),
12641        ColumnSchema::new("encoding", DataType::Int, false),
12642        ColumnSchema::new("datcollate", DataType::Text, false),
12643    ];
12644    let rows = alloc::vec![Row::new(alloc::vec![
12645        Value::BigInt(16384),
12646        Value::Text("postgres".into()),
12647        Value::BigInt(10),
12648        Value::Int(6), // UTF8
12649        Value::Text("en_US.UTF-8".into()),
12650    ])];
12651    (schema, rows)
12652}
12653
12654/// v7.17.0 Phase 3.P0-55 — synthesise `pg_catalog.pg_roles`. PG's
12655/// pg_roles is a view over pg_authid showing all roles. SPG ships
12656/// one row per declared user from the engine's UserStore so admin
12657/// tool startup screens can populate.
12658fn synth_pg_roles(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
12659    let schema = alloc::vec![
12660        ColumnSchema::new("oid", DataType::BigInt, false),
12661        ColumnSchema::new("rolname", DataType::Text, false),
12662        ColumnSchema::new("rolsuper", DataType::Bool, false),
12663        ColumnSchema::new("rolinherit", DataType::Bool, false),
12664        ColumnSchema::new("rolcanlogin", DataType::Bool, false),
12665    ];
12666    let mut rows: Vec<Row> = Vec::new();
12667    let oid: i64 = 10;
12668    for (i, (name, _)) in engine.users.iter().enumerate() {
12669        rows.push(Row::new(alloc::vec![
12670            Value::BigInt(oid + (i as i64) + 1),
12671            Value::Text(name.to_string()),
12672            Value::Bool(false),
12673            Value::Bool(true),
12674            Value::Bool(true),
12675        ]));
12676    }
12677    // Always include `postgres` as the bootstrap superuser if not
12678    // already present — admin tools probe for it.
12679    if !rows
12680        .iter()
12681        .any(|r| matches!(&r.values[1], Value::Text(s) if s == "postgres"))
12682    {
12683        rows.insert(
12684            0,
12685            Row::new(alloc::vec![
12686                Value::BigInt(10),
12687                Value::Text("postgres".into()),
12688                Value::Bool(true),
12689                Value::Bool(true),
12690                Value::Bool(true),
12691            ]),
12692        );
12693    }
12694    (schema, rows)
12695}
12696
12697/// v7.17.0 Phase 3.P0-56 — synthesise `pg_catalog.pg_views`. PG's
12698/// pg_views is a view listing every catalog view; SPG ships one
12699/// row per declared view + its definition text.
12700/// Synthesise `pg_catalog.pg_extension`. SPG ships its "extension"
12701/// surfaces natively (vector, pg_trgm, plpgsql-shaped DO blocks), so
12702/// the table lists those as installed — `SELECT … FROM pg_extension
12703/// WHERE extname = 'vector'` probes from PG clients (mailrs embed
12704/// round-12) answer truthfully about capability presence.
12705fn synth_pg_extension() -> (Vec<ColumnSchema>, Vec<Row>) {
12706    let schema = alloc::vec![
12707        ColumnSchema::new("oid", DataType::BigInt, false),
12708        ColumnSchema::new("extname", DataType::Text, false),
12709        ColumnSchema::new("extversion", DataType::Text, false),
12710        ColumnSchema::new("extnamespace", DataType::Text, false),
12711    ];
12712    let exts: &[(&str, &str)] = &[("plpgsql", "1.0"), ("vector", "0.8.0"), ("pg_trgm", "1.6")];
12713    let rows = exts
12714        .iter()
12715        .enumerate()
12716        .map(|(i, (name, ver))| {
12717            Row::new(alloc::vec![
12718                Value::BigInt(16384 + i as i64),
12719                Value::Text((*name).into()),
12720                Value::Text((*ver).into()),
12721                Value::Text("pg_catalog".into()),
12722            ])
12723        })
12724        .collect();
12725    (schema, rows)
12726}
12727
12728fn synth_pg_views(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12729    let schema = alloc::vec![
12730        ColumnSchema::new("schemaname", DataType::Text, false),
12731        ColumnSchema::new("viewname", DataType::Text, false),
12732        ColumnSchema::new("definition", DataType::Text, false),
12733    ];
12734    let mut rows: Vec<Row> = Vec::new();
12735    for (name, def) in cat.views() {
12736        rows.push(Row::new(alloc::vec![
12737            Value::Text("public".into()),
12738            Value::Text(name.clone()),
12739            Value::Text(def.body.clone()),
12740        ]));
12741    }
12742    (schema, rows)
12743}
12744
12745/// v7.17.0 Phase 3.P0-57 — synthesise `pg_catalog.pg_settings`. ORM
12746/// connection-checkers (sqlx pre-flight, Diesel migrator) and admin
12747/// tools read `pg_settings` to discover server-side configuration.
12748/// SPG surfaces every session_param + a small set of canonical PG
12749/// defaults so the pre-flight queries match.
12750fn synth_pg_settings(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
12751    let schema = alloc::vec![
12752        ColumnSchema::new("name", DataType::Text, false),
12753        ColumnSchema::new("setting", DataType::Text, false),
12754        ColumnSchema::new("category", DataType::Text, false),
12755    ];
12756    let mut rows: Vec<Row> = Vec::new();
12757    // Canonical defaults every admin tool expects to find.
12758    let defaults: &[(&str, &str, &str)] = &[
12759        ("server_version", "16.0 (spg)", "Preset Options"),
12760        ("server_encoding", "UTF8", "Client Connection Defaults"),
12761        ("client_encoding", "UTF8", "Client Connection Defaults"),
12762        ("DateStyle", "ISO, MDY", "Client Connection Defaults"),
12763        ("TimeZone", "UTC", "Client Connection Defaults"),
12764        ("standard_conforming_strings", "on", "Compatibility"),
12765        ("integer_datetimes", "on", "Compatibility"),
12766        ("max_connections", "100", "Connections and Authentication"),
12767    ];
12768    for &(name, val, cat) in defaults {
12769        rows.push(Row::new(alloc::vec![
12770            Value::Text(name.into()),
12771            Value::Text(val.into()),
12772            Value::Text(cat.into()),
12773        ]));
12774    }
12775    // Session-set params override the static defaults.
12776    for (k, v) in &engine.session_params {
12777        if !defaults
12778            .iter()
12779            .any(|(n, _, _)| (*n).eq_ignore_ascii_case(k))
12780        {
12781            rows.push(Row::new(alloc::vec![
12782                Value::Text(k.clone()),
12783                Value::Text(v.clone()),
12784                Value::Text("Session".into()),
12785            ]));
12786        }
12787    }
12788    (schema, rows)
12789}
12790
12791/// v7.17.0 Phase 3.P0-53 — synthesise `pg_catalog.pg_indexes`.
12792/// PG's pg_indexes is a real view on pg_index + pg_class + pg_attribute.
12793/// SPG ships it as a synthesised flat table so admin tools (pgAdmin,
12794/// DataGrip) can list indexes by tablename without joining four catalogs.
12795///
12796/// Schema columns exposed:
12797///   * schemaname (Text) — always `public`
12798///   * tablename (Text)
12799///   * indexname (Text)
12800///   * indexdef (Text) — best-effort CREATE INDEX DDL
12801fn synth_pg_indexes(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12802    let schema = alloc::vec![
12803        ColumnSchema::new("schemaname", DataType::Text, false),
12804        ColumnSchema::new("tablename", DataType::Text, false),
12805        ColumnSchema::new("indexname", DataType::Text, false),
12806        ColumnSchema::new("indexdef", DataType::Text, false),
12807    ];
12808    let mut rows: Vec<Row> = Vec::new();
12809    for tname in cat.table_names() {
12810        let Some(t) = cat.get(&tname) else { continue };
12811        for idx in t.indices() {
12812            let col_name = t
12813                .schema()
12814                .columns
12815                .get(idx.column_position)
12816                .map_or("?".into(), |c| c.name.clone());
12817            let unique_kw = if idx.is_unique { "UNIQUE " } else { "" };
12818            let indexdef = alloc::format!(
12819                "CREATE {unique_kw}INDEX {} ON public.{} ({})",
12820                idx.name,
12821                tname,
12822                col_name
12823            );
12824            rows.push(Row::new(alloc::vec![
12825                Value::Text("public".into()),
12826                Value::Text(tname.clone()),
12827                Value::Text(idx.name.clone()),
12828                Value::Text(indexdef),
12829            ]));
12830        }
12831    }
12832    (schema, rows)
12833}
12834
12835/// v7.17.0 Phase 3.P0-53 — synthesise `pg_catalog.pg_index`. The
12836/// "raw" pg_index catalog used by PG-internal tooling for index
12837/// flags and ordinal information. SPG ships the columns ORM probes
12838/// actually filter on.
12839///
12840/// Schema columns exposed:
12841///   * indexrelid (BigInt) — index OID (synthetic = position+1)
12842///   * indrelid (BigInt) — table OID (synthetic = position+1)
12843///   * indnatts (Int) — number of indexed columns
12844///   * indisunique (Bool)
12845///   * indisprimary (Bool)
12846fn synth_pg_index_raw(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12847    let schema = alloc::vec![
12848        ColumnSchema::new("indexrelid", DataType::BigInt, false),
12849        ColumnSchema::new("indrelid", DataType::BigInt, false),
12850        ColumnSchema::new("indnatts", DataType::Int, false),
12851        ColumnSchema::new("indisunique", DataType::Bool, false),
12852        ColumnSchema::new("indisprimary", DataType::Bool, false),
12853    ];
12854    let mut rows: Vec<Row> = Vec::new();
12855    let mut idx_oid: i64 = 100_000;
12856    for (table_idx, tname) in cat.table_names().iter().enumerate() {
12857        let Some(t) = cat.get(tname) else { continue };
12858        for idx in t.indices() {
12859            idx_oid += 1;
12860            #[allow(clippy::cast_possible_wrap)]
12861            let nattrs = (1 + idx.extra_column_positions.len()) as i32;
12862            // is_primary: SPG / PG flag the primary via the
12863            // index name convention `<table>_pkey`.
12864            let is_primary = idx.name.ends_with("_pkey");
12865            rows.push(Row::new(alloc::vec![
12866                Value::BigInt(idx_oid),
12867                Value::BigInt((table_idx + 1) as i64),
12868                Value::Int(nattrs),
12869                Value::Bool(idx.is_unique),
12870                Value::Bool(is_primary),
12871            ]));
12872        }
12873    }
12874    (schema, rows)
12875}
12876
12877/// v7.17.0 Phase 3.P0-52 — synthesise `pg_catalog.pg_namespace`.
12878/// SPG is single-schema so we expose the canonical PG schemas:
12879/// `public` (user-facing), `pg_catalog` (built-in), and
12880/// `information_schema` (PG meta).
12881fn synth_pg_namespace(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12882    let schema = alloc::vec![
12883        ColumnSchema::new("oid", DataType::BigInt, false),
12884        ColumnSchema::new("nspname", DataType::Text, false),
12885        ColumnSchema::new("nspowner", DataType::BigInt, false),
12886    ];
12887    let rows = alloc::vec![
12888        Row::new(alloc::vec![
12889            Value::BigInt(11),
12890            Value::Text("pg_catalog".into()),
12891            Value::BigInt(10),
12892        ]),
12893        Row::new(alloc::vec![
12894            Value::BigInt(2200),
12895            Value::Text("public".into()),
12896            Value::BigInt(10),
12897        ]),
12898        Row::new(alloc::vec![
12899            Value::BigInt(13000),
12900            Value::Text("information_schema".into()),
12901            Value::BigInt(10),
12902        ]),
12903    ];
12904    (schema, rows)
12905}
12906
12907/// v7.16.2 — drop the synthesised meta view into the enriched
12908/// catalog so the regular FROM-resolution path can see it.
12909fn materialise_meta_view(
12910    catalog: &mut Catalog,
12911    name: &str,
12912    columns: Vec<ColumnSchema>,
12913    rows: Vec<Row>,
12914) -> Result<(), EngineError> {
12915    let schema = TableSchema::new(name.to_string(), columns);
12916    catalog.create_table(schema).map_err(EngineError::Storage)?;
12917    let table = catalog
12918        .get_mut(name)
12919        .expect("just-created meta view must exist");
12920    for row in rows {
12921        table.insert(row).map_err(EngineError::Storage)?;
12922    }
12923    Ok(())
12924}
12925
12926/// v7.16.2 — true when the SELECT statement references any
12927/// `__spg_info_*` or `__spg_pg_*` synthetic table name (the
12928/// parser produces these for `information_schema.X` /
12929/// `pg_catalog.X`). Used by `exec_select_cancel` to short-
12930/// circuit into the meta-view materialisation path.
12931/// v7.17.0 Phase 1.2 — append the names of any catalog-known
12932/// views referenced by `tref` to `into`. Helper for
12933/// `Engine::expand_views_in_select`. A view that's been already
12934/// materialised as a table (e.g. via the synthetic CTE pass for
12935/// SELECT FROM v) is skipped — the table form wins so the
12936/// recursive exec_select_cancel call inside exec_with_ctes
12937/// doesn't re-expand and trigger the CTE-shadow guard.
12938fn collect_view_refs(
12939    tref: &spg_sql::ast::TableRef,
12940    cat: &spg_storage::Catalog,
12941    into: &mut Vec<String>,
12942) {
12943    if cat.views().contains_key(&tref.name)
12944        && cat.get(&tref.name).is_none()
12945        && !into.iter().any(|n| n == &tref.name)
12946    {
12947        into.push(tref.name.clone());
12948    }
12949}
12950
12951fn select_references_meta_view(stmt: &SelectStatement) -> bool {
12952    fn is_meta(name: &str) -> bool {
12953        name.starts_with("__spg_info_")
12954            || name.starts_with("__spg_pg_")
12955            || name.starts_with("__spg_mysql_")
12956    }
12957    if let Some(from) = &stmt.from {
12958        if is_meta(&from.primary.name) {
12959            return true;
12960        }
12961        for j in &from.joins {
12962            if is_meta(&j.table.name) {
12963                return true;
12964            }
12965        }
12966    }
12967    for cte in &stmt.ctes {
12968        if select_references_meta_view(&cte.body) {
12969            return true;
12970        }
12971    }
12972    false
12973}
12974
12975/// v7.16.2 — collect every meta-view name a SELECT touches.
12976/// Returns a deduplicated, sorted list. Caller materialises
12977/// each one into the enriched catalog before re-running the
12978/// SELECT. Walks JOINs, CTEs, and the primary FROM.
12979fn collect_meta_view_names(
12980    stmt: &SelectStatement,
12981    into: &mut alloc::collections::BTreeSet<String>,
12982) {
12983    fn is_meta(name: &str) -> bool {
12984        name.starts_with("__spg_info_")
12985            || name.starts_with("__spg_pg_")
12986            || name.starts_with("__spg_mysql_")
12987    }
12988    if let Some(from) = &stmt.from {
12989        if is_meta(&from.primary.name) {
12990            into.insert(from.primary.name.clone());
12991        }
12992        for j in &from.joins {
12993            if is_meta(&j.table.name) {
12994                into.insert(j.table.name.clone());
12995            }
12996        }
12997    }
12998    for cte in &stmt.ctes {
12999        collect_meta_view_names(&cte.body, into);
13000    }
13001}
13002
13003fn infer_column_types(columns: &[ColumnSchema], rows: &[Row]) -> Vec<ColumnSchema> {
13004    let mut out = columns.to_vec();
13005    for (col_idx, col) in out.iter_mut().enumerate() {
13006        if col.ty != DataType::Text {
13007            continue;
13008        }
13009        let mut inferred: Option<DataType> = None;
13010        let mut all_null = true;
13011        for row in rows {
13012            let Some(v) = row.values.get(col_idx) else {
13013                continue;
13014            };
13015            let ty = match v {
13016                Value::Null => continue,
13017                Value::SmallInt(_) => DataType::SmallInt,
13018                Value::Int(_) => DataType::Int,
13019                Value::BigInt(_) => DataType::BigInt,
13020                Value::Float(_) => DataType::Float,
13021                Value::Bool(_) => DataType::Bool,
13022                Value::Vector(_) => DataType::Vector {
13023                    dim: 0,
13024                    encoding: VecEncoding::F32,
13025                },
13026                _ => DataType::Text,
13027            };
13028            all_null = false;
13029            inferred = Some(match inferred {
13030                None => ty,
13031                Some(prev) if prev == ty => prev,
13032                Some(_) => DataType::Text,
13033            });
13034        }
13035        if let Some(t) = inferred {
13036            col.ty = t;
13037            col.nullable = true;
13038        } else if all_null {
13039            col.nullable = true;
13040        }
13041    }
13042    out
13043}
13044
13045/// v4.26: render a human-readable plan tree for `EXPLAIN <select>`.
13046/// Lines are pushed into `out`; `depth` controls indentation. We
13047/// describe the rewritten SELECT — what the executor *would* do —
13048/// using the engine handle to spot indexed lookups and table shapes.
13049#[allow(clippy::too_many_lines, clippy::format_push_string)]
13050/// v6.2.4 — Walk every line of the rendered plan tree and append
13051/// per-operator stats. Lines that name a known operator get
13052/// `(rows=N)` (`actual_rows` of the top-level operator equals the
13053/// final result row count; scans report their catalog row count
13054/// as the rows-considered metric). Other lines — Filter / Join /
13055/// GroupBy / OrderBy etc. — are marked `(—)` so the surface is
13056/// complete-by-construction; v6.2.5 fills these in via inline
13057/// executor counters.
13058/// v6.8.3 — surface "CREATE INDEX …" suggestions for every
13059/// `(table, column)` pair the query touches via WHERE / JOIN
13060/// that doesn't already have an index on the owning table.
13061/// Walks the SELECT's FROM clauses + WHERE expression tree;
13062/// returns one line per missing index. Deterministic order:
13063/// FROM-clause iteration order, then column-reference walk
13064/// order inside each WHERE. Each suggestion is a copy-pastable
13065/// DDL string.
13066fn build_index_suggestions(stmt: &SelectStatement, engine: &Engine) -> Vec<String> {
13067    use alloc::collections::BTreeSet;
13068    let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
13069    let mut out: Vec<String> = Vec::new();
13070    let cat = engine.active_catalog();
13071    // Build a (table, qualifier-or-alias) list from the FROM clause
13072    // so unqualified column refs in WHERE resolve to the correct
13073    // table.
13074    let Some(from) = &stmt.from else {
13075        return out;
13076    };
13077    let mut tables: Vec<String> = Vec::new();
13078    tables.push(from.primary.name.clone());
13079    for j in &from.joins {
13080        tables.push(j.table.name.clone());
13081    }
13082    // Collect column refs from the WHERE expression. JOIN ON
13083    // predicates also feed in.
13084    let mut col_refs: Vec<spg_sql::ast::ColumnName> = Vec::new();
13085    if let Some(w) = &stmt.where_ {
13086        collect_column_refs(w, &mut col_refs);
13087    }
13088    for j in &from.joins {
13089        if let Some(on) = &j.on {
13090            collect_column_refs(on, &mut col_refs);
13091        }
13092    }
13093    for cn in &col_refs {
13094        // Resolve owner table: explicit qualifier first, else
13095        // first table in FROM that has a column of this name.
13096        let owner: Option<String> = if let Some(q) = &cn.qualifier {
13097            tables.iter().find(|t| t == &q).cloned()
13098        } else {
13099            tables.iter().find_map(|t| {
13100                cat.get(t).and_then(|tbl| {
13101                    if tbl.schema().column_position(&cn.name).is_some() {
13102                        Some(t.clone())
13103                    } else {
13104                        None
13105                    }
13106                })
13107            })
13108        };
13109        let Some(owner) = owner else {
13110            continue;
13111        };
13112        let Some(tbl) = cat.get(&owner) else {
13113            continue;
13114        };
13115        let Some(col_pos) = tbl.schema().column_position(&cn.name) else {
13116            continue;
13117        };
13118        // Skip if any BTree index already covers this column as
13119        // its key.
13120        let already_indexed = tbl.indices().iter().any(|i| {
13121            matches!(i.kind, spg_storage::IndexKind::BTree(_))
13122                && i.column_position == col_pos
13123                && i.expression.is_none()
13124                && i.partial_predicate.is_none()
13125        });
13126        if already_indexed {
13127            continue;
13128        }
13129        if seen.insert((owner.clone(), cn.name.clone())) {
13130            out.push(alloc::format!(
13131                "SUGGEST: CREATE INDEX ix_{}_{} ON {} ({})",
13132                owner,
13133                cn.name,
13134                owner,
13135                cn.name
13136            ));
13137        }
13138    }
13139    out
13140}
13141
13142/// Walks an `Expr` and pushes every `ColumnName` it references.
13143/// Order is depth-first, left-to-right.
13144fn collect_column_refs(expr: &Expr, out: &mut Vec<spg_sql::ast::ColumnName>) {
13145    match expr {
13146        Expr::Column(cn) => out.push(cn.clone()),
13147        Expr::FunctionCall { args, .. } => {
13148            for a in args {
13149                collect_column_refs(a, out);
13150            }
13151        }
13152        Expr::Binary { lhs, rhs, .. } => {
13153            collect_column_refs(lhs, out);
13154            collect_column_refs(rhs, out);
13155        }
13156        Expr::Unary { expr: e, .. } => collect_column_refs(e, out),
13157        _ => {}
13158    }
13159}
13160
13161fn annotate_explain_lines(lines: &mut [String], total_rows: usize, engine: &Engine) {
13162    let catalog = engine.active_catalog();
13163    let cold_ids = catalog.cold_segment_ids_global();
13164    let any_cold = !cold_ids.is_empty();
13165    let cold_ids_repr = if any_cold {
13166        let mut s = alloc::string::String::from("[");
13167        for (i, id) in cold_ids.iter().enumerate() {
13168            if i > 0 {
13169                s.push(',');
13170            }
13171            s.push_str(&alloc::format!("{id}"));
13172        }
13173        s.push(']');
13174        s
13175    } else {
13176        alloc::string::String::new()
13177    };
13178    for (idx, line) in lines.iter_mut().enumerate() {
13179        let trimmed = line.trim_start();
13180        let is_top_level = idx == 0;
13181        if is_top_level {
13182            line.push_str(&alloc::format!(" (rows={total_rows})"));
13183            continue;
13184        }
13185        if let Some(rest) = trimmed.strip_prefix("From: ") {
13186            let (name, scan_kind) = match rest.split_once(" [") {
13187                Some((n, k)) => (n.trim(), k.trim_end_matches(']')),
13188                None => (rest.trim(), ""),
13189            };
13190            let bare = name.split_whitespace().next().unwrap_or(name);
13191            let hot = catalog.get(bare).map(|t| t.rows().len());
13192            // v6.2.7 — `cold_segments=[id0,id1,…]` enumerates every
13193            // cold-tier segment the scan COULD have walked. v6.2.x
13194            // can tighten to per-table by walking the table's
13195            // BTree-index cold locators.
13196            let annot = match (hot, scan_kind) {
13197                (Some(h), "full scan") => {
13198                    let mut s = alloc::format!(" (hot_rows={h}");
13199                    if any_cold {
13200                        s.push_str(&alloc::format!(
13201                            ", cold_tier=present, cold_segments={cold_ids_repr}"
13202                        ));
13203                    }
13204                    s.push(')');
13205                    s
13206                }
13207                (Some(h), "index seek") => {
13208                    let mut s = alloc::format!(" (hot_rows≤{h}");
13209                    if any_cold {
13210                        s.push_str(&alloc::format!(
13211                            ", cold_tier=present, cold_segments={cold_ids_repr}"
13212                        ));
13213                    }
13214                    s.push(')');
13215                    s
13216                }
13217                _ => " (rows=—)".to_string(),
13218            };
13219            line.push_str(&annot);
13220            continue;
13221        }
13222        // Filter / GroupBy / Having / OrderBy / Limit / Join etc.
13223        line.push_str(" (rows=—)");
13224    }
13225}
13226
13227fn explain_select(stmt: &SelectStatement, engine: &Engine, depth: usize, out: &mut Vec<String>) {
13228    let pad = "  ".repeat(depth);
13229    // 1) Top-level operator label.
13230    let top = if !stmt.ctes.is_empty() {
13231        if stmt.ctes.iter().any(|c| c.recursive) {
13232            "CTEScan (WITH RECURSIVE)"
13233        } else {
13234            "CTEScan (WITH)"
13235        }
13236    } else if !stmt.unions.is_empty() {
13237        "UnionScan"
13238    } else if select_has_window(stmt) {
13239        "WindowAgg"
13240    } else if aggregate::uses_aggregate(stmt) {
13241        "Aggregate"
13242    } else if stmt.distinct {
13243        "Distinct"
13244    } else if stmt.from.is_some() {
13245        "TableScan"
13246    } else {
13247        "Result"
13248    };
13249    out.push(alloc::format!("{pad}{top}"));
13250    let child = "  ".repeat(depth + 1);
13251    // 2) CTE bodies.
13252    for cte in &stmt.ctes {
13253        let head = if cte.recursive {
13254            alloc::format!("{child}CTE (recursive): {}", cte.name)
13255        } else {
13256            alloc::format!("{child}CTE: {}", cte.name)
13257        };
13258        out.push(head);
13259        explain_select(&cte.body, engine, depth + 2, out);
13260    }
13261    // 3) FROM details — primary table + joins, index hits.
13262    if let Some(from) = &stmt.from {
13263        let mut tag = alloc::format!("{child}From: {}", from.primary.name);
13264        if let Some(alias) = &from.primary.alias {
13265            tag.push_str(&alloc::format!(" AS {alias}"));
13266        }
13267        // Try to detect an index-seek opportunity on WHERE against
13268        // the primary table — same heuristic the executor uses.
13269        if let Some(w) = &stmt.where_
13270            && let Some(table) = engine.active_catalog().get(&from.primary.name)
13271        {
13272            let alias = from.primary.alias.as_deref().unwrap_or(&from.primary.name);
13273            let cols = &table.schema().columns;
13274            if try_index_seek(w, cols, engine.active_catalog(), table, alias).is_some() {
13275                tag.push_str(" [index seek]");
13276            } else {
13277                tag.push_str(" [full scan]");
13278            }
13279        } else {
13280            tag.push_str(" [full scan]");
13281        }
13282        out.push(tag);
13283        for j in &from.joins {
13284            let kind = match j.kind {
13285                spg_sql::ast::JoinKind::Inner => "INNER JOIN",
13286                spg_sql::ast::JoinKind::Left => "LEFT JOIN",
13287                spg_sql::ast::JoinKind::Cross => "CROSS JOIN",
13288            };
13289            let mut s = alloc::format!("{child}{kind}: {}", j.table.name);
13290            if let Some(alias) = &j.table.alias {
13291                s.push_str(&alloc::format!(" AS {alias}"));
13292            }
13293            if j.on.is_some() {
13294                s.push_str(" (ON …)");
13295            }
13296            out.push(s);
13297        }
13298    }
13299    // 4) WHERE / GROUP BY / HAVING / ORDER BY / LIMIT / OFFSET.
13300    if let Some(w) = &stmt.where_ {
13301        let mut s = alloc::format!("{child}Filter: {w}");
13302        if expr_has_subquery(w) {
13303            s.push_str(" [subquery]");
13304        }
13305        out.push(s);
13306    }
13307    if let Some(gs) = &stmt.group_by {
13308        let mut parts = Vec::new();
13309        for g in gs {
13310            parts.push(alloc::format!("{g}"));
13311        }
13312        out.push(alloc::format!("{child}GroupBy: {}", parts.join(", ")));
13313    }
13314    if let Some(h) = &stmt.having {
13315        out.push(alloc::format!("{child}Having: {h}"));
13316    }
13317    for o in &stmt.order_by {
13318        let dir = if o.desc { "DESC" } else { "ASC" };
13319        out.push(alloc::format!("{child}OrderBy: {} {dir}", o.expr));
13320    }
13321    if let Some(lim) = stmt.limit {
13322        out.push(alloc::format!("{child}Limit: {lim}"));
13323    }
13324    if let Some(off) = stmt.offset {
13325        out.push(alloc::format!("{child}Offset: {off}"));
13326    }
13327    // 5) Projection — collapse Wildcard or render N items.
13328    if stmt
13329        .items
13330        .iter()
13331        .any(|it| matches!(it, SelectItem::Wildcard))
13332    {
13333        out.push(alloc::format!("{child}Project: *"));
13334    } else {
13335        out.push(alloc::format!(
13336            "{child}Project: {} item(s)",
13337            stmt.items.len()
13338        ));
13339    }
13340    // 6) Recurse into UNION peers.
13341    for (kind, peer) in &stmt.unions {
13342        let label = match kind {
13343            UnionKind::All => "UNION ALL",
13344            UnionKind::Distinct => "UNION",
13345        };
13346        out.push(alloc::format!("{child}{label}"));
13347        explain_select(peer, engine, depth + 2, out);
13348    }
13349}
13350
13351/// v4.23: recognise the engine errors that indicate the inner
13352/// SELECT couldn't be evaluated in isolation because it references
13353/// an outer column — used by `subquery_replacement` to skip
13354/// materialisation and let row-eval handle it instead.
13355fn is_correlation_error(e: &EngineError) -> bool {
13356    matches!(
13357        e,
13358        EngineError::Eval(
13359            eval::EvalError::ColumnNotFound { .. } | eval::EvalError::UnknownQualifier { .. }
13360        )
13361    )
13362}
13363
13364/// v4.23: walk every Expr in `stmt` and replace each Column ref
13365/// that targets the outer scope (qualifier matches the outer
13366/// table alias) with a Literal carrying the outer row's value.
13367/// Conservative: only qualified refs are substituted, so the user
13368/// must write `outer_alias.col` to reference an outer column. This
13369/// matches PG's lexical scoping for correlated subqueries and
13370/// avoids accidentally rebinding inner columns of the same name.
13371/// v7.17.0 Phase 3.P0-41 — LATERAL peer descriptor. Either eagerly
13372/// materialised (every regular table / unnest / generate_series) or
13373/// lateral (subquery re-evaluated per outer row).
13374struct JoinedPeer<'a> {
13375    eager_rows: Option<Vec<Row>>,
13376    cols: Vec<ColumnSchema>,
13377    alias: String,
13378    kind: JoinKind,
13379    on: Option<&'a Expr>,
13380    lateral: Option<&'a SelectStatement>,
13381    /// v7.28 (round-22) — plain-table name for the index-nested-loop
13382    /// path. None for unnest/lateral.
13383    join_table: Option<String>,
13384}
13385
13386/// v7.17.0 Phase 3.P0-41 — synthesise a column name for a LATERAL
13387/// projection item that has no explicit alias. PG names anonymous
13388/// projection items by the function call's name or by `column<i>`.
13389/// SPG mirrors the latter (lower-overhead than walking arbitrary
13390/// Expr shapes) so the probe-schema fallback path produces stable
13391/// names for the lateral peer's columns.
13392fn synth_lateral_col_name(expr: &Expr, idx: usize) -> String {
13393    match expr {
13394        // Bare column reference — use the column's own name.
13395        Expr::Column(c) => c.name.clone(),
13396        // Function call — use the function name (PG canonical:
13397        // `count` / `max` / `lower` …).
13398        Expr::FunctionCall { name, .. } => name.clone(),
13399        // Cast — drill into the inner expression.
13400        Expr::Cast { expr: inner, .. } => synth_lateral_col_name(inner, idx),
13401        // Everything else falls back to PG's `column<N>` placeholder.
13402        _ => alloc::format!("column{}", idx + 1),
13403    }
13404}
13405
13406/// v7.17.0 Phase 3.P0-41 — substitute every `<alias>.<col>` Expr
13407/// reference whose `<alias>.<col>` exists in the outer composite
13408/// schema with the matching value from the outer row. Walks the
13409/// entire SELECT body (items, WHERE, GROUP BY, HAVING, ORDER BY,
13410/// UNION peers) so any depth of outer reference inside the
13411/// LATERAL subquery resolves before execution.
13412fn substitute_outer_columns_multi(
13413    stmt: &mut SelectStatement,
13414    outer_row: &Row,
13415    outer_schema: &[ColumnSchema],
13416) {
13417    substitute_outer_in_select(stmt, outer_row, outer_schema);
13418}
13419
13420fn substitute_outer_in_select(
13421    stmt: &mut SelectStatement,
13422    outer_row: &Row,
13423    outer_schema: &[ColumnSchema],
13424) {
13425    for item in &mut stmt.items {
13426        if let SelectItem::Expr { expr, .. } = item {
13427            substitute_outer_in_expr(expr, outer_row, outer_schema);
13428        }
13429    }
13430    if let Some(w) = &mut stmt.where_ {
13431        substitute_outer_in_expr(w, outer_row, outer_schema);
13432    }
13433    if let Some(gs) = &mut stmt.group_by {
13434        for g in gs {
13435            substitute_outer_in_expr(g, outer_row, outer_schema);
13436        }
13437    }
13438    if let Some(h) = &mut stmt.having {
13439        substitute_outer_in_expr(h, outer_row, outer_schema);
13440    }
13441    for o in &mut stmt.order_by {
13442        substitute_outer_in_expr(&mut o.expr, outer_row, outer_schema);
13443    }
13444    for (_, peer) in &mut stmt.unions {
13445        substitute_outer_in_select(peer, outer_row, outer_schema);
13446    }
13447}
13448
13449fn substitute_outer_in_expr(e: &mut Expr, outer_row: &Row, outer_schema: &[ColumnSchema]) {
13450    if let Expr::Column(c) = e
13451        && let Some(qual) = &c.qualifier
13452    {
13453        let composite = alloc::format!("{qual}.{}", c.name);
13454        if let Some(idx) = outer_schema
13455            .iter()
13456            .position(|sc| sc.name.eq_ignore_ascii_case(&composite))
13457        {
13458            let v = outer_row.values.get(idx).cloned().unwrap_or(Value::Null);
13459            if let Ok(lit) = value_to_literal_expr(v) {
13460                *e = lit;
13461                return;
13462            }
13463        }
13464    }
13465    match e {
13466        Expr::Binary { lhs, rhs, .. } => {
13467            substitute_outer_in_expr(lhs, outer_row, outer_schema);
13468            substitute_outer_in_expr(rhs, outer_row, outer_schema);
13469        }
13470        Expr::Unary { expr: inner, .. } => {
13471            substitute_outer_in_expr(inner, outer_row, outer_schema);
13472        }
13473        Expr::FunctionCall { args, .. } => {
13474            for a in args {
13475                substitute_outer_in_expr(a, outer_row, outer_schema);
13476            }
13477        }
13478        Expr::Cast { expr: inner, .. } => {
13479            substitute_outer_in_expr(inner, outer_row, outer_schema);
13480        }
13481        Expr::Case {
13482            operand,
13483            branches,
13484            else_branch,
13485        } => {
13486            if let Some(op) = operand {
13487                substitute_outer_in_expr(op, outer_row, outer_schema);
13488            }
13489            for (cond, val) in branches {
13490                substitute_outer_in_expr(cond, outer_row, outer_schema);
13491                substitute_outer_in_expr(val, outer_row, outer_schema);
13492            }
13493            if let Some(e) = else_branch {
13494                substitute_outer_in_expr(e, outer_row, outer_schema);
13495            }
13496        }
13497        _ => {}
13498    }
13499}
13500
13501impl Engine {
13502    /// v7.29 (round-22 phase 3) — try to batch-evaluate a correlated
13503    /// scalar subquery of the shape
13504    ///   (SELECT expr FROM … WHERE inner_preds AND inner_col = outer_col
13505    ///    [ORDER BY o [DESC]] [LIMIT 1])
13506    /// by running the subquery ONCE without the correlation and
13507    /// folding rows into a key→value map (group top-1 when ordered).
13508    /// Returns None when the shape doesn't qualify; correctness then
13509    /// falls back to per-row execution.
13510    fn try_batch_correlated_scalar(
13511        &self,
13512        inner: &SelectStatement,
13513        cancel: CancelToken<'_>,
13514    ) -> Result<Option<memoize::GroupMap>, EngineError> {
13515        use spg_sql::ast::{BinOp, SelectItem as SI};
13516        if !inner.ctes.is_empty()
13517            || !inner.unions.is_empty()
13518            || inner.group_by.is_some()
13519            || inner.having.is_some()
13520            || inner.distinct
13521            || inner.items.len() != 1
13522            || inner.order_by.len() > 1
13523            || inner.offset.is_some()
13524        {
13525            return Ok(None);
13526        }
13527        // LIMIT must be absent or literally 1 (top-1 semantics).
13528        if let Some(le) = inner.limit
13529            && le.as_literal() != Some(1)
13530        {
13531            return Ok(None);
13532        }
13533        let Some(from) = &inner.from else {
13534            return Ok(None);
13535        };
13536        if from.primary.lateral_subquery.is_some() || from.primary.unnest_expr.is_some() {
13537            return Ok(None);
13538        }
13539        // Inner alias set.
13540        let mut inner_aliases: Vec<String> = Vec::new();
13541        inner_aliases.push(
13542            from.primary
13543                .alias
13544                .clone()
13545                .unwrap_or_else(|| from.primary.name.clone()),
13546        );
13547        for j in &from.joins {
13548            if j.table.lateral_subquery.is_some() || j.table.unnest_expr.is_some() {
13549                return Ok(None);
13550            }
13551            inner_aliases.push(
13552                j.table
13553                    .alias
13554                    .clone()
13555                    .unwrap_or_else(|| j.table.name.clone()),
13556            );
13557        }
13558        let is_inner = |c: &spg_sql::ast::ColumnName| -> bool {
13559            match &c.qualifier {
13560                Some(q) => inner_aliases.iter().any(|a| a.eq_ignore_ascii_case(q)),
13561                None => false,
13562            }
13563        };
13564        let is_outer = |c: &spg_sql::ast::ColumnName| -> bool {
13565            match &c.qualifier {
13566                Some(q) => !inner_aliases.iter().any(|a| a.eq_ignore_ascii_case(q)),
13567                // Synthetic group columns arrive bare after the
13568                // aggregate rewrite.
13569                None => c.name.starts_with("__grp_") || c.name.starts_with("__agg_"),
13570            }
13571        };
13572        // Every expression OTHER than the correlation conjunct must be
13573        // fully inner (qualified to inner aliases).
13574        let all_inner = |e: &Expr| -> bool {
13575            let mut cols: Vec<spg_sql::ast::ColumnName> = Vec::new();
13576            let mut subs: Vec<&SelectStatement> = Vec::new();
13577            visit_expr_columns_and_subqueries(e, &mut |c| cols.push(c.clone()), &mut |sub| {
13578                subs.push(sub)
13579            });
13580            subs.is_empty() && cols.iter().all(|c| is_inner(c) && !c.name.is_empty())
13581        };
13582        let Some(w) = &inner.where_ else {
13583            return Ok(None);
13584        };
13585        let conjuncts = reorder::split_and_conjunctions(w);
13586        let mut corr: Option<(spg_sql::ast::ColumnName, spg_sql::ast::ColumnName)> = None; // (inner, outer)
13587        let mut rest: Vec<&Expr> = Vec::new();
13588        for c in conjuncts {
13589            if let Expr::Binary {
13590                lhs,
13591                op: BinOp::Eq,
13592                rhs,
13593            } = c
13594                && let (Expr::Column(a), Expr::Column(b)) = (lhs.as_ref(), rhs.as_ref())
13595            {
13596                let pair = if is_inner(a) && is_outer(b) {
13597                    Some((a.clone(), b.clone()))
13598                } else if is_inner(b) && is_outer(a) {
13599                    Some((b.clone(), a.clone()))
13600                } else {
13601                    None
13602                };
13603                if let Some(p) = pair {
13604                    if corr.is_some() {
13605                        return Ok(None); // more than one correlation
13606                    }
13607                    corr = Some(p);
13608                    continue;
13609                }
13610            }
13611            if !all_inner(c) {
13612                return Ok(None);
13613            }
13614            rest.push(c);
13615        }
13616        let Some((inner_col, outer_col)) = corr else {
13617            return Ok(None);
13618        };
13619        let SI::Expr { expr: out_expr, .. } = &inner.items[0] else {
13620            return Ok(None);
13621        };
13622        if !all_inner(out_expr) {
13623            return Ok(None);
13624        }
13625        let order = inner.order_by.first();
13626        if let Some(o) = order
13627            && !all_inner(&o.expr)
13628        {
13629            return Ok(None);
13630        }
13631        // Build the batch statement: SELECT inner_col, [order], expr
13632        // FROM … WHERE rest — no correlation, no order, no limit.
13633        let mut batch = inner.clone();
13634        batch.limit = None;
13635        batch.offset = None;
13636        batch.order_by = Vec::new();
13637        batch.where_ = rest
13638            .iter()
13639            .map(|e| (*e).clone())
13640            .reduce(|a, b| Expr::Binary {
13641                lhs: alloc::boxed::Box::new(a),
13642                op: BinOp::And,
13643                rhs: alloc::boxed::Box::new(b),
13644            });
13645        let mut items: Vec<SI> = alloc::vec![SI::Expr {
13646            expr: Expr::Column(inner_col),
13647            alias: None,
13648        }];
13649        if let Some(o) = order {
13650            items.push(SI::Expr {
13651                expr: o.expr.clone(),
13652                alias: None,
13653            });
13654        }
13655        items.push(SI::Expr {
13656            expr: out_expr.clone(),
13657            alias: None,
13658        });
13659        batch.items = items;
13660        let r = self.exec_select_cancel(&batch, cancel)?;
13661        let QueryResult::Rows { rows, .. } = r else {
13662            return Ok(None);
13663        };
13664        let has_order = order.is_some();
13665        let (desc, nf) = order
13666            .map(|o| (o.desc, o.nulls_first))
13667            .unwrap_or((false, None));
13668        let mut best: alloc::collections::BTreeMap<String, (Option<Value>, Value)> =
13669            alloc::collections::BTreeMap::new();
13670        for row in rows {
13671            let key_v = row.values.first().cloned().unwrap_or(Value::Null);
13672            if matches!(key_v, Value::Null) {
13673                continue;
13674            }
13675            let key = aggregate::encode_key(core::slice::from_ref(&key_v));
13676            let (ord_v, out_v) = if has_order {
13677                (
13678                    Some(row.values.get(1).cloned().unwrap_or(Value::Null)),
13679                    row.values.get(2).cloned().unwrap_or(Value::Null),
13680                )
13681            } else {
13682                (None, row.values.get(1).cloned().unwrap_or(Value::Null))
13683            };
13684            match best.get(&key) {
13685                None => {
13686                    best.insert(key, (ord_v, out_v));
13687                }
13688                Some((cur_ord, _)) if has_order => {
13689                    // The sorted-first row wins: candidate beats the
13690                    // incumbent when it compares LESS under the key's
13691                    // ordering.
13692                    let cand = ord_v.clone().unwrap_or(Value::Null);
13693                    let cur = cur_ord.clone().unwrap_or(Value::Null);
13694                    if order_by_value_cmp(desc, nf, &cand, &cur) == core::cmp::Ordering::Less {
13695                        best.insert(key, (ord_v, out_v));
13696                    }
13697                }
13698                Some(_) => {} // unordered: first row stands (any row is valid)
13699            }
13700        }
13701        let map = best.into_iter().map(|(k, (_, v))| (k, v)).collect();
13702        Ok(Some((outer_col, map)))
13703    }
13704}
13705
13706/// v7.29 (3c) — pre-order collection of SCALAR subquery nodes in a
13707/// host expression (no descent into subquery bodies). The splice
13708/// walk below uses the same order; the pair must stay in lockstep.
13709fn collect_scalar_subqueries<'a>(e: &'a Expr, out: &mut Vec<&'a SelectStatement>) {
13710    match e {
13711        Expr::ScalarSubquery(s) => out.push(s),
13712        Expr::Exists { .. } | Expr::InSubquery { .. } => {}
13713        Expr::Binary { lhs, rhs, .. } => {
13714            collect_scalar_subqueries(lhs, out);
13715            collect_scalar_subqueries(rhs, out);
13716        }
13717        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13718            collect_scalar_subqueries(expr, out);
13719        }
13720        Expr::Like { expr, pattern, .. } => {
13721            collect_scalar_subqueries(expr, out);
13722            collect_scalar_subqueries(pattern, out);
13723        }
13724        Expr::FunctionCall { args, .. } => {
13725            for a in args {
13726                collect_scalar_subqueries(a, out);
13727            }
13728        }
13729        Expr::AggregateOrdered { call, order_by, .. } => {
13730            collect_scalar_subqueries(call, out);
13731            for o in order_by {
13732                collect_scalar_subqueries(&o.expr, out);
13733            }
13734        }
13735        Expr::Case {
13736            operand,
13737            branches,
13738            else_branch,
13739        } => {
13740            if let Some(op) = operand {
13741                collect_scalar_subqueries(op, out);
13742            }
13743            for (w, t) in branches {
13744                collect_scalar_subqueries(w, out);
13745                collect_scalar_subqueries(t, out);
13746            }
13747            if let Some(eb) = else_branch {
13748                collect_scalar_subqueries(eb, out);
13749            }
13750        }
13751        Expr::ArraySubscript { target, index } => {
13752            collect_scalar_subqueries(target, out);
13753            collect_scalar_subqueries(index, out);
13754        }
13755        _ => {}
13756    }
13757}
13758
13759/// v7.29 (3d) — empty every scalar-subquery BODY in a host
13760/// expression (node kept so the splice pre-order still matches).
13761fn hollow_scalar_subqueries(e: &mut Expr) {
13762    match e {
13763        Expr::ScalarSubquery(s) => {
13764            let hollow = SelectStatement {
13765                items: Vec::new(),
13766                ..SelectStatement::default()
13767            };
13768            **s = hollow;
13769        }
13770        Expr::Exists { .. } | Expr::InSubquery { .. } => {}
13771        Expr::Binary { lhs, rhs, .. } => {
13772            hollow_scalar_subqueries(lhs);
13773            hollow_scalar_subqueries(rhs);
13774        }
13775        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13776            hollow_scalar_subqueries(expr);
13777        }
13778        Expr::Like { expr, pattern, .. } => {
13779            hollow_scalar_subqueries(expr);
13780            hollow_scalar_subqueries(pattern);
13781        }
13782        Expr::FunctionCall { args, .. } => {
13783            for a in args.iter_mut() {
13784                hollow_scalar_subqueries(a);
13785            }
13786        }
13787        Expr::AggregateOrdered { call, order_by, .. } => {
13788            hollow_scalar_subqueries(call);
13789            for o in order_by.iter_mut() {
13790                hollow_scalar_subqueries(&mut o.expr);
13791            }
13792        }
13793        Expr::Case {
13794            operand,
13795            branches,
13796            else_branch,
13797        } => {
13798            if let Some(op) = operand {
13799                hollow_scalar_subqueries(op);
13800            }
13801            for (w, t) in branches.iter_mut() {
13802                hollow_scalar_subqueries(w);
13803                hollow_scalar_subqueries(t);
13804            }
13805            if let Some(eb) = else_branch {
13806                hollow_scalar_subqueries(eb);
13807            }
13808        }
13809        Expr::ArraySubscript { target, index } => {
13810            hollow_scalar_subqueries(target);
13811            hollow_scalar_subqueries(index);
13812        }
13813        _ => {}
13814    }
13815}
13816
13817/// v7.29 (3c) — splice the i-th scalar subquery's batched value into
13818/// the cloned tree (same pre-order as collect_scalar_subqueries).
13819/// Returns Ok(false) if a literal conversion fails (caller falls
13820/// back to the resolver path).
13821fn splice_planned_subqueries(
13822    e: &mut Expr,
13823    plan: &[Option<alloc::rc::Rc<memoize::GroupMap>>],
13824    idx: &mut usize,
13825    row: &Row,
13826    ctx: &EvalContext<'_>,
13827) -> Result<bool, EngineError> {
13828    match e {
13829        Expr::ScalarSubquery(_) => {
13830            let Some(Some(gm)) = plan.get(*idx) else {
13831                return Ok(false);
13832            };
13833            *idx += 1;
13834            let (outer_col, map) = gm.as_ref();
13835            let key_v = eval::eval_expr(&Expr::Column(outer_col.clone()), row, ctx)
13836                .map_err(EngineError::Eval)?;
13837            let v = if matches!(key_v, Value::Null) {
13838                Value::Null
13839            } else {
13840                map.get(&aggregate::encode_key(core::slice::from_ref(&key_v)))
13841                    .cloned()
13842                    .unwrap_or(Value::Null)
13843            };
13844            *e = value_to_literal_expr(v)?;
13845            Ok(true)
13846        }
13847        Expr::Exists { .. } | Expr::InSubquery { .. } => Ok(true),
13848        Expr::Binary { lhs, rhs, .. } => Ok(splice_planned_subqueries(lhs, plan, idx, row, ctx)?
13849            && splice_planned_subqueries(rhs, plan, idx, row, ctx)?),
13850        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13851            splice_planned_subqueries(expr, plan, idx, row, ctx)
13852        }
13853        Expr::Like { expr, pattern, .. } => {
13854            Ok(splice_planned_subqueries(expr, plan, idx, row, ctx)?
13855                && splice_planned_subqueries(pattern, plan, idx, row, ctx)?)
13856        }
13857        Expr::FunctionCall { args, .. } => {
13858            for a in args.iter_mut() {
13859                if !splice_planned_subqueries(a, plan, idx, row, ctx)? {
13860                    return Ok(false);
13861                }
13862            }
13863            Ok(true)
13864        }
13865        Expr::AggregateOrdered { call, order_by, .. } => {
13866            if !splice_planned_subqueries(call, plan, idx, row, ctx)? {
13867                return Ok(false);
13868            }
13869            for o in order_by.iter_mut() {
13870                if !splice_planned_subqueries(&mut o.expr, plan, idx, row, ctx)? {
13871                    return Ok(false);
13872                }
13873            }
13874            Ok(true)
13875        }
13876        Expr::Case {
13877            operand,
13878            branches,
13879            else_branch,
13880        } => {
13881            if let Some(op) = operand {
13882                if !splice_planned_subqueries(op, plan, idx, row, ctx)? {
13883                    return Ok(false);
13884                }
13885            }
13886            for (w, t) in branches.iter_mut() {
13887                if !splice_planned_subqueries(w, plan, idx, row, ctx)?
13888                    || !splice_planned_subqueries(t, plan, idx, row, ctx)?
13889                {
13890                    return Ok(false);
13891                }
13892            }
13893            if let Some(eb) = else_branch {
13894                if !splice_planned_subqueries(eb, plan, idx, row, ctx)? {
13895                    return Ok(false);
13896                }
13897            }
13898            Ok(true)
13899        }
13900        Expr::ArraySubscript { target, index } => {
13901            Ok(splice_planned_subqueries(target, plan, idx, row, ctx)?
13902                && splice_planned_subqueries(index, plan, idx, row, ctx)?)
13903        }
13904        _ => Ok(true),
13905    }
13906}
13907
13908fn substitute_outer_columns(stmt: &mut SelectStatement, row: &Row, ctx: &EvalContext<'_>) {
13909    // v7.24 (round-16 B) — joined outer contexts carry no single
13910    // table alias; their schemas use composite "alias.column" names
13911    // instead. Pass an unmatchable alias and let the composite
13912    // lookup in substitute_in_expr do the work (a correlated EXISTS
13913    // under a JOIN previously skipped substitution entirely and
13914    // died with "unknown table qualifier").
13915    let outer_alias = ctx.table_alias.unwrap_or("");
13916    substitute_in_select(stmt, row, ctx, outer_alias);
13917}
13918
13919fn substitute_in_select(
13920    stmt: &mut SelectStatement,
13921    row: &Row,
13922    ctx: &EvalContext<'_>,
13923    outer_alias: &str,
13924) {
13925    for item in &mut stmt.items {
13926        if let SelectItem::Expr { expr, .. } = item {
13927            substitute_in_expr(expr, row, ctx, outer_alias);
13928        }
13929    }
13930    if let Some(w) = &mut stmt.where_ {
13931        substitute_in_expr(w, row, ctx, outer_alias);
13932    }
13933    if let Some(gs) = &mut stmt.group_by {
13934        for g in gs {
13935            substitute_in_expr(g, row, ctx, outer_alias);
13936        }
13937    }
13938    if let Some(h) = &mut stmt.having {
13939        substitute_in_expr(h, row, ctx, outer_alias);
13940    }
13941    for o in &mut stmt.order_by {
13942        substitute_in_expr(&mut o.expr, row, ctx, outer_alias);
13943    }
13944    for (_, peer) in &mut stmt.unions {
13945        substitute_in_select(peer, row, ctx, outer_alias);
13946    }
13947}
13948
13949fn substitute_in_expr(e: &mut Expr, row: &Row, ctx: &EvalContext<'_>, outer_alias: &str) {
13950    // v7.25.2 (round-19 A) — bare synthetic columns. The aggregate
13951    // rewriter replaces group-key references INSIDE subquery bodies
13952    // with `__grp_N` so a correlated subquery in a GROUP BY select
13953    // list can resolve against the synthesised group row. The names
13954    // are engine-generated, so they can't shadow user columns.
13955    if let Expr::Column(c) = e
13956        && c.qualifier.is_none()
13957        && (c.name.starts_with("__grp_") || c.name.starts_with("__agg_"))
13958        && let Some(idx) = ctx.columns.iter().position(|sc| sc.name == c.name)
13959    {
13960        let v = row.values.get(idx).cloned().unwrap_or(Value::Null);
13961        if let Ok(lit) = value_to_literal_expr(v) {
13962            *e = lit;
13963            return;
13964        }
13965    }
13966    if let Expr::Column(c) = e
13967        && let Some(qual) = &c.qualifier
13968    {
13969        // Look up the column's index in the outer schema: plain name
13970        // when the qualifier is the outer table's alias, composite
13971        // "alias.column" for joined outer schemas (v7.24).
13972        let idx = if !outer_alias.is_empty() && qual.eq_ignore_ascii_case(outer_alias) {
13973            ctx.columns
13974                .iter()
13975                .position(|sc| sc.name.eq_ignore_ascii_case(&c.name))
13976        } else {
13977            None
13978        }
13979        .or_else(|| {
13980            let composite = alloc::format!("{qual}.{name}", name = c.name);
13981            ctx.columns
13982                .iter()
13983                .position(|sc| sc.name.eq_ignore_ascii_case(&composite))
13984        });
13985        if let Some(idx) = idx {
13986            let v = row.values.get(idx).cloned().unwrap_or(Value::Null);
13987            if let Ok(lit) = value_to_literal_expr(v) {
13988                *e = lit;
13989                return;
13990            }
13991        }
13992    }
13993    match e {
13994        Expr::AggregateOrdered { call, order_by, .. } => {
13995            substitute_in_expr(call, row, ctx, outer_alias);
13996            for o in order_by.iter_mut() {
13997                substitute_in_expr(&mut o.expr, row, ctx, outer_alias);
13998            }
13999        }
14000        Expr::Binary { lhs, rhs, .. } => {
14001            substitute_in_expr(lhs, row, ctx, outer_alias);
14002            substitute_in_expr(rhs, row, ctx, outer_alias);
14003        }
14004        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
14005            substitute_in_expr(expr, row, ctx, outer_alias);
14006        }
14007        Expr::Like { expr, pattern, .. } => {
14008            substitute_in_expr(expr, row, ctx, outer_alias);
14009            substitute_in_expr(pattern, row, ctx, outer_alias);
14010        }
14011        Expr::FunctionCall { args, .. } => {
14012            for a in args {
14013                substitute_in_expr(a, row, ctx, outer_alias);
14014            }
14015        }
14016        Expr::Extract { source, .. } => substitute_in_expr(source, row, ctx, outer_alias),
14017        Expr::WindowFunction {
14018            args,
14019            partition_by,
14020            order_by,
14021            ..
14022        } => {
14023            for a in args {
14024                substitute_in_expr(a, row, ctx, outer_alias);
14025            }
14026            for p in partition_by {
14027                substitute_in_expr(p, row, ctx, outer_alias);
14028            }
14029            for (o, _, _) in order_by {
14030                substitute_in_expr(o, row, ctx, outer_alias);
14031            }
14032        }
14033        Expr::ScalarSubquery(s) => substitute_in_select(s, row, ctx, outer_alias),
14034        Expr::Exists { subquery, .. } | Expr::InSubquery { subquery, .. } => {
14035            substitute_in_select(subquery, row, ctx, outer_alias);
14036        }
14037        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => {}
14038        Expr::Array(items) => {
14039            for elem in items {
14040                substitute_in_expr(elem, row, ctx, outer_alias);
14041            }
14042        }
14043        Expr::ArraySubscript { target, index } => {
14044            substitute_in_expr(target, row, ctx, outer_alias);
14045            substitute_in_expr(index, row, ctx, outer_alias);
14046        }
14047        Expr::AnyAll { expr, array, .. } => {
14048            substitute_in_expr(expr, row, ctx, outer_alias);
14049            substitute_in_expr(array, row, ctx, outer_alias);
14050        }
14051        Expr::Case {
14052            operand,
14053            branches,
14054            else_branch,
14055        } => {
14056            if let Some(o) = operand {
14057                substitute_in_expr(o, row, ctx, outer_alias);
14058            }
14059            for (w, t) in branches {
14060                substitute_in_expr(w, row, ctx, outer_alias);
14061                substitute_in_expr(t, row, ctx, outer_alias);
14062            }
14063            if let Some(e) = else_branch {
14064                substitute_in_expr(e, row, ctx, outer_alias);
14065            }
14066        }
14067    }
14068}
14069
14070/// v4.22: encode a Row to a comparable byte key for UNION-DISTINCT
14071/// dedup inside the recursive iteration. Crude but deterministic
14072/// — Debug prints embed type discriminants so NULL ≠ "" ≠ 0.
14073fn encode_row_key(row: &Row) -> Vec<u8> {
14074    let mut out = Vec::new();
14075    for v in &row.values {
14076        let s = alloc::format!("{v:?}|");
14077        out.extend_from_slice(s.as_bytes());
14078    }
14079    out
14080}
14081
14082fn select_has_window(stmt: &SelectStatement) -> bool {
14083    for item in &stmt.items {
14084        if let SelectItem::Expr { expr, .. } = item
14085            && expr_has_window(expr)
14086        {
14087            return true;
14088        }
14089    }
14090    false
14091}
14092
14093fn expr_has_window(e: &Expr) -> bool {
14094    match e {
14095        Expr::WindowFunction { .. } => true,
14096        Expr::AggregateOrdered { call, order_by, .. } => {
14097            expr_has_window(call) || order_by.iter().any(|o| expr_has_window(&o.expr))
14098        }
14099        Expr::Binary { lhs, rhs, .. } => expr_has_window(lhs) || expr_has_window(rhs),
14100        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
14101            expr_has_window(expr)
14102        }
14103        Expr::FunctionCall { args, .. } => args.iter().any(expr_has_window),
14104        Expr::Like { expr, pattern, .. } => expr_has_window(expr) || expr_has_window(pattern),
14105        Expr::Extract { source, .. } => expr_has_window(source),
14106        Expr::ScalarSubquery(_)
14107        | Expr::Exists { .. }
14108        | Expr::InSubquery { .. }
14109        | Expr::Literal(_)
14110        | Expr::Placeholder(_)
14111        | Expr::Column(_) => false,
14112        Expr::Array(items) => items.iter().any(expr_has_window),
14113        Expr::ArraySubscript { target, index } => expr_has_window(target) || expr_has_window(index),
14114        Expr::AnyAll { expr, array, .. } => expr_has_window(expr) || expr_has_window(array),
14115        Expr::Case {
14116            operand,
14117            branches,
14118            else_branch,
14119        } => {
14120            operand.as_deref().is_some_and(expr_has_window)
14121                || branches
14122                    .iter()
14123                    .any(|(w, t)| expr_has_window(w) || expr_has_window(t))
14124                || else_branch.as_deref().is_some_and(expr_has_window)
14125        }
14126    }
14127}
14128
14129fn collect_window_nodes(e: &Expr, out: &mut Vec<Expr>) {
14130    if let Expr::WindowFunction { .. } = e {
14131        // Deduplicate by structural equality on the expression
14132        // (cheap because window args + partition + order are
14133        // small). Without dedup we'd recompute identical windows
14134        // once per occurrence in the projection.
14135        if !out.iter().any(|x| x == e) {
14136            out.push(e.clone());
14137        }
14138        return;
14139    }
14140    match e {
14141        // Already handled by the early-return at the top.
14142        Expr::WindowFunction { .. } => unreachable!(),
14143        Expr::Binary { lhs, rhs, .. } => {
14144            collect_window_nodes(lhs, out);
14145            collect_window_nodes(rhs, out);
14146        }
14147        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
14148            collect_window_nodes(expr, out);
14149        }
14150        Expr::FunctionCall { args, .. } => {
14151            for a in args {
14152                collect_window_nodes(a, out);
14153            }
14154        }
14155        Expr::Like { expr, pattern, .. } => {
14156            collect_window_nodes(expr, out);
14157            collect_window_nodes(pattern, out);
14158        }
14159        Expr::Extract { source, .. } => collect_window_nodes(source, out),
14160        _ => {}
14161    }
14162}
14163
14164fn rewrite_window_to_columns(e: &mut Expr, window_nodes: &[Expr]) {
14165    if let Expr::WindowFunction { .. } = e
14166        && let Some(idx) = window_nodes.iter().position(|w| w == e)
14167    {
14168        *e = Expr::Column(spg_sql::ast::ColumnName {
14169            qualifier: None,
14170            name: alloc::format!("__win_{idx}"),
14171        });
14172        return;
14173    }
14174    match e {
14175        Expr::Binary { lhs, rhs, .. } => {
14176            rewrite_window_to_columns(lhs, window_nodes);
14177            rewrite_window_to_columns(rhs, window_nodes);
14178        }
14179        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
14180            rewrite_window_to_columns(expr, window_nodes);
14181        }
14182        Expr::FunctionCall { args, .. } => {
14183            for a in args {
14184                rewrite_window_to_columns(a, window_nodes);
14185            }
14186        }
14187        Expr::Like { expr, pattern, .. } => {
14188            rewrite_window_to_columns(expr, window_nodes);
14189            rewrite_window_to_columns(pattern, window_nodes);
14190        }
14191        Expr::Extract { source, .. } => rewrite_window_to_columns(source, window_nodes),
14192        _ => {}
14193    }
14194}
14195
14196/// Total order over partition-key tuples. NULL sorts as the
14197/// lowest value (matches the `<` partial order's NULL-last
14198/// behaviour with `INFINITY` flipped).
14199fn partition_key_cmp(a: &[Value], b: &[Value]) -> core::cmp::Ordering {
14200    for (x, y) in a.iter().zip(b.iter()) {
14201        let c = value_cmp(x, y);
14202        if c != core::cmp::Ordering::Equal {
14203            return c;
14204        }
14205    }
14206    a.len().cmp(&b.len())
14207}
14208
14209fn order_key_cmp(
14210    a: &[(Value, bool, Option<bool>)],
14211    b: &[(Value, bool, Option<bool>)],
14212) -> core::cmp::Ordering {
14213    // v7.24.1 — per-key DESC + effective NULLS placement (shared
14214    // contract with order_by_value_cmp).
14215    for ((va, desc, nf), (vb, _, _)) in a.iter().zip(b.iter()) {
14216        let c = order_by_value_cmp(*desc, *nf, va, vb);
14217        if c != core::cmp::Ordering::Equal {
14218            return c;
14219        }
14220    }
14221    a.len().cmp(&b.len())
14222}
14223
14224/// v7.17.0 Phase 3.10 — true when the Value is one of the
14225/// integer-shaped variants `generate_series` accepts as a start
14226/// / stop / step component. Float / NUMERIC are rejected — PG's
14227/// `generate_series(numeric, numeric)` overload is out of v7.17
14228/// scope.
14229const fn value_is_integer(v: &Value) -> bool {
14230    matches!(v, Value::SmallInt(_) | Value::Int(_) | Value::BigInt(_))
14231}
14232
14233/// v7.17.0 Phase 3.10 — widen any integer-shaped Value to i64 for
14234/// the generate_series iteration loop. Non-integer inputs panic;
14235/// caller guards via `value_is_integer`.
14236const fn value_to_i64(v: &Value) -> i64 {
14237    match v {
14238        Value::SmallInt(n) => *n as i64,
14239        Value::Int(n) => *n as i64,
14240        Value::BigInt(n) => *n,
14241        _ => panic!("value_to_i64 called on non-integer Value"),
14242    }
14243}
14244
14245/// v7.17.0 Phase 3.10 — integer-mode generate_series materialiser.
14246/// Step direction follows the sign: positive step iterates upward
14247/// (stops when current > stop); negative iterates downward; zero
14248/// errors. Caller-facing row stream is `BigInt`-typed so a single
14249/// projection schema covers SmallInt / Int / BigInt callers.
14250fn generate_series_integers(
14251    start: i64,
14252    stop: i64,
14253    step: i64,
14254    cancel: &CancelToken<'_>,
14255) -> Result<alloc::vec::Vec<Row>, EngineError> {
14256    if step == 0 {
14257        return Err(EngineError::Unsupported(
14258            "generate_series(): step argument cannot be zero".into(),
14259        ));
14260    }
14261    let mut out = alloc::vec::Vec::new();
14262    let mut cur = start;
14263    // Hard cap to keep a runaway call from eating all memory. PG
14264    // has no such cap but does honour query timeout; SPG's cancel
14265    // token will fire too — this is a defense-in-depth backstop.
14266    const MAX_ROWS: usize = 10_000_000;
14267    loop {
14268        cancel.check()?;
14269        if step > 0 && cur > stop {
14270            break;
14271        }
14272        if step < 0 && cur < stop {
14273            break;
14274        }
14275        out.push(Row::new(alloc::vec![Value::BigInt(cur)]));
14276        if out.len() > MAX_ROWS {
14277            return Err(EngineError::Unsupported(alloc::format!(
14278                "generate_series(): exceeded {MAX_ROWS} rows; \
14279                 narrow start/stop or use a larger step"
14280            )));
14281        }
14282        cur = match cur.checked_add(step) {
14283            Some(n) => n,
14284            None => break,
14285        };
14286    }
14287    Ok(out)
14288}
14289
14290/// v7.17.0 Phase 3.10 — timestamp-mode generate_series. step is a
14291/// `Value::Interval { months, micros }` per the caller's guard;
14292/// each iteration adds the interval via `apply_binary_interval`
14293/// so month-shifting handles short-month rollover (PG semantics).
14294fn generate_series_timestamps(
14295    start: i64,
14296    stop: i64,
14297    step: Value,
14298    cancel: &CancelToken<'_>,
14299) -> Result<alloc::vec::Vec<Row>, EngineError> {
14300    let (months, micros) = match &step {
14301        Value::Interval { months, micros } => (*months, *micros),
14302        _ => unreachable!("caller guards step.is_interval"),
14303    };
14304    if months == 0 && micros == 0 {
14305        return Err(EngineError::Unsupported(
14306            "generate_series(): INTERVAL step cannot be zero".into(),
14307        ));
14308    }
14309    let ascending = months > 0 || micros > 0;
14310    let mut out = alloc::vec::Vec::new();
14311    let mut cur = Value::Timestamp(start);
14312    const MAX_ROWS: usize = 10_000_000;
14313    loop {
14314        cancel.check()?;
14315        let cur_t = match cur {
14316            Value::Timestamp(t) => t,
14317            _ => unreachable!("loop invariant: cur is Timestamp"),
14318        };
14319        if ascending && cur_t > stop {
14320            break;
14321        }
14322        if !ascending && cur_t < stop {
14323            break;
14324        }
14325        out.push(Row::new(alloc::vec![Value::Timestamp(cur_t)]));
14326        if out.len() > MAX_ROWS {
14327            return Err(EngineError::Unsupported(alloc::format!(
14328                "generate_series(): exceeded {MAX_ROWS} rows; \
14329                 narrow start/stop or use a larger step"
14330            )));
14331        }
14332        let next = eval::apply_binary_interval(
14333            spg_sql::ast::BinOp::Add,
14334            &cur,
14335            &Value::Interval { months, micros },
14336        )
14337        .map_err(EngineError::Eval)?;
14338        cur = match next {
14339            Some(v) => v,
14340            None => break,
14341        };
14342    }
14343    Ok(out)
14344}
14345
14346#[allow(clippy::match_same_arms)] // explicit arms per type document the supported pairs
14347/// v7.24 (round-16 A) — per-key ORDER BY comparator honouring DESC
14348/// and the effective NULLS placement (explicit NULLS FIRST/LAST,
14349/// else the PG default: NULLS LAST for ASC, NULLS FIRST for DESC).
14350/// NULL placement is absolute — it does not flip with DESC.
14351pub(crate) fn order_by_value_cmp(
14352    desc: bool,
14353    nulls_first: Option<bool>,
14354    a: &Value,
14355    b: &Value,
14356) -> core::cmp::Ordering {
14357    use core::cmp::Ordering;
14358    let nf = nulls_first.unwrap_or(desc);
14359    match (matches!(a, Value::Null), matches!(b, Value::Null)) {
14360        (true, true) => Ordering::Equal,
14361        (true, false) => {
14362            if nf {
14363                Ordering::Less
14364            } else {
14365                Ordering::Greater
14366            }
14367        }
14368        (false, true) => {
14369            if nf {
14370                Ordering::Greater
14371            } else {
14372                Ordering::Less
14373            }
14374        }
14375        (false, false) => {
14376            let c = value_cmp(a, b);
14377            if desc { c.reverse() } else { c }
14378        }
14379    }
14380}
14381
14382fn value_cmp(a: &Value, b: &Value) -> core::cmp::Ordering {
14383    use core::cmp::Ordering;
14384    match (a, b) {
14385        (Value::Null, Value::Null) => Ordering::Equal,
14386        (Value::Null, _) => Ordering::Less,
14387        (_, Value::Null) => Ordering::Greater,
14388        (Value::Int(x), Value::Int(y)) => x.cmp(y),
14389        (Value::BigInt(x), Value::BigInt(y)) => x.cmp(y),
14390        (Value::SmallInt(x), Value::SmallInt(y)) => x.cmp(y),
14391        (Value::Text(x), Value::Text(y)) => x.cmp(y),
14392        (Value::Bool(x), Value::Bool(y)) => x.cmp(y),
14393        (Value::Float(x), Value::Float(y)) => x.partial_cmp(y).unwrap_or(Ordering::Equal),
14394        (Value::Date(x), Value::Date(y)) => x.cmp(y),
14395        (Value::Timestamp(x), Value::Timestamp(y)) => x.cmp(y),
14396        // Cross-type compare: fall back to the debug rendering —
14397        // same-partition is the goal, exact order is irrelevant.
14398        _ => alloc::format!("{a:?}").cmp(&alloc::format!("{b:?}")),
14399    }
14400}
14401
14402/// Compute the window function's per-row output for one partition.
14403/// `slice` has (partition key, order key, original-row-index)
14404/// tuples already sorted by order key. `filtered_rows` is the
14405/// full row list indexed by original-row-index. `out_vals` is
14406/// the destination, also indexed by original-row-index.
14407#[allow(
14408    clippy::too_many_arguments,
14409    clippy::cast_possible_truncation,
14410    clippy::cast_possible_wrap,
14411    clippy::cast_precision_loss,
14412    clippy::cast_sign_loss,
14413    clippy::doc_markdown,
14414    clippy::too_many_lines,
14415    clippy::type_complexity,
14416    clippy::match_same_arms
14417)]
14418fn compute_window_partition(
14419    name: &str,
14420    args: &[Expr],
14421    ordered: bool,
14422    frame: Option<&WindowFrame>,
14423    null_treatment: spg_sql::ast::NullTreatment,
14424    slice: &[(Vec<Value>, Vec<(Value, bool, Option<bool>)>, usize)],
14425    filtered_rows: &[&Row],
14426    ctx: &EvalContext<'_>,
14427    out_vals: &mut [Value],
14428) -> Result<(), EngineError> {
14429    let ignore_nulls = matches!(null_treatment, spg_sql::ast::NullTreatment::Ignore);
14430    let lower = name.to_ascii_lowercase();
14431    match lower.as_str() {
14432        "row_number" => {
14433            for (rank, (_, _, idx)) in slice.iter().enumerate() {
14434                out_vals[*idx] = Value::BigInt((rank + 1) as i64);
14435            }
14436            Ok(())
14437        }
14438        "rank" => {
14439            let mut prev_key: Option<&[(Value, bool, Option<bool>)]> = None;
14440            let mut current_rank: i64 = 1;
14441            for (i, (_, okey, idx)) in slice.iter().enumerate() {
14442                if let Some(p) = prev_key
14443                    && order_key_cmp(p, okey) != core::cmp::Ordering::Equal
14444                {
14445                    current_rank = (i + 1) as i64;
14446                }
14447                if prev_key.is_none() {
14448                    current_rank = 1;
14449                }
14450                out_vals[*idx] = Value::BigInt(current_rank);
14451                prev_key = Some(okey.as_slice());
14452            }
14453            Ok(())
14454        }
14455        "dense_rank" => {
14456            let mut prev_key: Option<&[(Value, bool, Option<bool>)]> = None;
14457            let mut current_rank: i64 = 0;
14458            for (_, okey, idx) in slice {
14459                if prev_key.is_none_or(|p| order_key_cmp(p, okey) != core::cmp::Ordering::Equal) {
14460                    current_rank += 1;
14461                }
14462                out_vals[*idx] = Value::BigInt(current_rank);
14463                prev_key = Some(okey.as_slice());
14464            }
14465            Ok(())
14466        }
14467        "sum" | "avg" | "min" | "max" | "count" | "count_star" => {
14468            // Pre-evaluate the function arg per row in the slice
14469            // (count_star has no arg).
14470            let arg_values: Vec<Value> = if lower == "count_star" || args.is_empty() {
14471                slice.iter().map(|_| Value::Null).collect()
14472            } else {
14473                slice
14474                    .iter()
14475                    .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
14476                    .collect::<Result<_, _>>()
14477                    .map_err(EngineError::Eval)?
14478            };
14479            // v4.20: pick the effective frame. Explicit frame
14480            // overrides the implicit default (running for ordered,
14481            // whole-partition for unordered).
14482            let eff = effective_frame(frame, ordered)?;
14483            #[allow(clippy::needless_range_loop)]
14484            for i in 0..slice.len() {
14485                let (lo, hi) = frame_bounds_for_row(&eff, i, slice);
14486                let mut sum: f64 = 0.0;
14487                let mut count: i64 = 0;
14488                let mut min_v: Option<f64> = None;
14489                let mut max_v: Option<f64> = None;
14490                let mut row_count: i64 = 0;
14491                if lo <= hi {
14492                    for j in lo..=hi {
14493                        let v = &arg_values[j];
14494                        match lower.as_str() {
14495                            "count_star" => row_count += 1,
14496                            "count" => {
14497                                if !v.is_null() {
14498                                    count += 1;
14499                                }
14500                            }
14501                            _ => {
14502                                if let Some(x) = value_to_f64(v) {
14503                                    sum += x;
14504                                    count += 1;
14505                                    min_v = Some(min_v.map_or(x, |m| m.min(x)));
14506                                    max_v = Some(max_v.map_or(x, |m| m.max(x)));
14507                                }
14508                            }
14509                        }
14510                    }
14511                }
14512                let value = match lower.as_str() {
14513                    "count_star" => Value::BigInt(row_count),
14514                    "count" => Value::BigInt(count),
14515                    "sum" => Value::Float(sum),
14516                    "avg" => {
14517                        if count == 0 {
14518                            Value::Null
14519                        } else {
14520                            Value::Float(sum / count as f64)
14521                        }
14522                    }
14523                    "min" => min_v.map_or(Value::Null, Value::Float),
14524                    "max" => max_v.map_or(Value::Null, Value::Float),
14525                    _ => unreachable!(),
14526                };
14527                let (_, _, idx) = &slice[i];
14528                out_vals[*idx] = value;
14529            }
14530            Ok(())
14531        }
14532        "lag" | "lead" => {
14533            // lag(expr [, offset [, default]])
14534            // lead(expr [, offset [, default]])
14535            if args.is_empty() {
14536                return Err(EngineError::Unsupported(alloc::format!(
14537                    "{lower}() requires at least one argument"
14538                )));
14539            }
14540            let offset: i64 = if args.len() >= 2 {
14541                let v = eval::eval_expr(&args[1], filtered_rows[slice[0].2], ctx)
14542                    .map_err(EngineError::Eval)?;
14543                match v {
14544                    Value::SmallInt(n) => i64::from(n),
14545                    Value::Int(n) => i64::from(n),
14546                    Value::BigInt(n) => n,
14547                    _ => {
14548                        return Err(EngineError::Unsupported(alloc::format!(
14549                            "{lower}() offset must be integer"
14550                        )));
14551                    }
14552                }
14553            } else {
14554                1
14555            };
14556            let default: Value = if args.len() >= 3 {
14557                eval::eval_expr(&args[2], filtered_rows[slice[0].2], ctx)
14558                    .map_err(EngineError::Eval)?
14559            } else {
14560                Value::Null
14561            };
14562            let values: Vec<Value> = slice
14563                .iter()
14564                .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
14565                .collect::<Result<_, _>>()
14566                .map_err(EngineError::Eval)?;
14567            let n = slice.len();
14568            for (i, (_, _, idx)) in slice.iter().enumerate() {
14569                let signed_offset = if lower == "lag" { -offset } else { offset };
14570                let v = if ignore_nulls {
14571                    // v6.4.2 — IGNORE NULLS: walk in the offset direction
14572                    // skipping NULL values; the `offset`-th non-NULL
14573                    // encountered is the result.
14574                    let step: i64 = if signed_offset >= 0 { 1 } else { -1 };
14575                    let needed: i64 = signed_offset.abs();
14576                    if needed == 0 {
14577                        values[i].clone()
14578                    } else {
14579                        let mut j: i64 = i as i64;
14580                        let mut hits: i64 = 0;
14581                        let mut found: Option<Value> = None;
14582                        loop {
14583                            j += step;
14584                            if j < 0 || j >= n as i64 {
14585                                break;
14586                            }
14587                            #[allow(clippy::cast_sign_loss)]
14588                            let v = &values[j as usize];
14589                            if !v.is_null() {
14590                                hits += 1;
14591                                if hits == needed {
14592                                    found = Some(v.clone());
14593                                    break;
14594                                }
14595                            }
14596                        }
14597                        found.unwrap_or_else(|| default.clone())
14598                    }
14599                } else {
14600                    let target_signed = i64::try_from(i).unwrap_or(i64::MAX) + signed_offset;
14601                    if target_signed < 0 || target_signed >= i64::try_from(n).unwrap_or(i64::MAX) {
14602                        default.clone()
14603                    } else {
14604                        #[allow(clippy::cast_sign_loss)]
14605                        {
14606                            values[target_signed as usize].clone()
14607                        }
14608                    }
14609                };
14610                out_vals[*idx] = v;
14611            }
14612            Ok(())
14613        }
14614        "first_value" | "last_value" | "nth_value" => {
14615            if args.is_empty() {
14616                return Err(EngineError::Unsupported(alloc::format!(
14617                    "{lower}() requires at least one argument"
14618                )));
14619            }
14620            let values: Vec<Value> = slice
14621                .iter()
14622                .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
14623                .collect::<Result<_, _>>()
14624                .map_err(EngineError::Eval)?;
14625            let nth: usize = if lower == "nth_value" {
14626                if args.len() < 2 {
14627                    return Err(EngineError::Unsupported(
14628                        "nth_value() requires (expr, n)".into(),
14629                    ));
14630                }
14631                let v = eval::eval_expr(&args[1], filtered_rows[slice[0].2], ctx)
14632                    .map_err(EngineError::Eval)?;
14633                let raw = match v {
14634                    Value::SmallInt(n) => i64::from(n),
14635                    Value::Int(n) => i64::from(n),
14636                    Value::BigInt(n) => n,
14637                    _ => {
14638                        return Err(EngineError::Unsupported(
14639                            "nth_value() n must be integer".into(),
14640                        ));
14641                    }
14642                };
14643                if raw < 1 {
14644                    return Err(EngineError::Unsupported(
14645                        "nth_value() n must be >= 1".into(),
14646                    ));
14647                }
14648                #[allow(clippy::cast_sign_loss)]
14649                {
14650                    raw as usize
14651                }
14652            } else {
14653                0
14654            };
14655            let eff = effective_frame(frame, ordered)?;
14656            for i in 0..slice.len() {
14657                let (lo, hi) = frame_bounds_for_row(&eff, i, slice);
14658                let (_, _, idx) = &slice[i];
14659                let v = if lo > hi {
14660                    Value::Null
14661                } else if ignore_nulls && matches!(lower.as_str(), "first_value" | "last_value") {
14662                    // v6.4.2 — IGNORE NULLS: skip NULL cells when
14663                    // selecting the boundary value within the frame.
14664                    if lower == "first_value" {
14665                        (lo..=hi)
14666                            .find_map(|j| {
14667                                let v = &values[j];
14668                                (!v.is_null()).then(|| v.clone())
14669                            })
14670                            .unwrap_or(Value::Null)
14671                    } else {
14672                        (lo..=hi)
14673                            .rev()
14674                            .find_map(|j| {
14675                                let v = &values[j];
14676                                (!v.is_null()).then(|| v.clone())
14677                            })
14678                            .unwrap_or(Value::Null)
14679                    }
14680                } else {
14681                    match lower.as_str() {
14682                        "first_value" => values[lo].clone(),
14683                        "last_value" => values[hi].clone(),
14684                        "nth_value" => {
14685                            let pos = lo + nth - 1;
14686                            if pos > hi {
14687                                Value::Null
14688                            } else {
14689                                values[pos].clone()
14690                            }
14691                        }
14692                        _ => unreachable!(),
14693                    }
14694                };
14695                out_vals[*idx] = v;
14696            }
14697            Ok(())
14698        }
14699        "ntile" => {
14700            if args.is_empty() {
14701                return Err(EngineError::Unsupported(
14702                    "ntile(n) requires an integer argument".into(),
14703                ));
14704            }
14705            let v = eval::eval_expr(&args[0], filtered_rows[slice[0].2], ctx)
14706                .map_err(EngineError::Eval)?;
14707            let bucket_count: i64 = match v {
14708                Value::SmallInt(n) => i64::from(n),
14709                Value::Int(n) => i64::from(n),
14710                Value::BigInt(n) => n,
14711                _ => {
14712                    return Err(EngineError::Unsupported(
14713                        "ntile() argument must be integer".into(),
14714                    ));
14715                }
14716            };
14717            if bucket_count < 1 {
14718                return Err(EngineError::Unsupported(
14719                    "ntile() argument must be >= 1".into(),
14720                ));
14721            }
14722            #[allow(clippy::cast_sign_loss)]
14723            let buckets = bucket_count as usize;
14724            let n = slice.len();
14725            // Each bucket gets `base` rows; the first `extras` buckets
14726            // get one extra. PG semantics.
14727            let base = n / buckets;
14728            let extras = n % buckets;
14729            let mut bucket: usize = 1;
14730            let mut remaining_in_bucket = if extras > 0 { base + 1 } else { base };
14731            let mut buckets_with_extra_remaining = extras;
14732            for (_, _, idx) in slice {
14733                if remaining_in_bucket == 0 {
14734                    bucket += 1;
14735                    buckets_with_extra_remaining = buckets_with_extra_remaining.saturating_sub(1);
14736                    remaining_in_bucket = if buckets_with_extra_remaining > 0 {
14737                        base + 1
14738                    } else {
14739                        base
14740                    };
14741                    // Edge: if base==0 and extras==0, all rows fit;
14742                    // shouldn't reach here, but guard anyway.
14743                    if remaining_in_bucket == 0 {
14744                        remaining_in_bucket = 1;
14745                    }
14746                }
14747                out_vals[*idx] = Value::BigInt(i64::try_from(bucket).unwrap_or(i64::MAX));
14748                remaining_in_bucket -= 1;
14749            }
14750            Ok(())
14751        }
14752        "percent_rank" => {
14753            // (rank - 1) / (n - 1) where rank is the standard RANK().
14754            // Single-row partitions get 0.
14755            let n = slice.len();
14756            let mut prev_key: Option<&[(Value, bool, Option<bool>)]> = None;
14757            let mut current_rank: i64 = 1;
14758            for (i, (_, okey, idx)) in slice.iter().enumerate() {
14759                if let Some(p) = prev_key
14760                    && order_key_cmp(p, okey) != core::cmp::Ordering::Equal
14761                {
14762                    current_rank = i64::try_from(i + 1).unwrap_or(i64::MAX);
14763                }
14764                if prev_key.is_none() {
14765                    current_rank = 1;
14766                }
14767                #[allow(clippy::cast_precision_loss)]
14768                let pr = if n <= 1 {
14769                    0.0
14770                } else {
14771                    (current_rank - 1) as f64 / (n - 1) as f64
14772                };
14773                out_vals[*idx] = Value::Float(pr);
14774                prev_key = Some(okey.as_slice());
14775            }
14776            Ok(())
14777        }
14778        "cume_dist" => {
14779            // # rows up to and including this row's peer group / n.
14780            let n = slice.len();
14781            // First pass: find peer-group-end rank for each row.
14782            for i in 0..slice.len() {
14783                let peer_end = peer_group_end(slice, i);
14784                #[allow(clippy::cast_precision_loss)]
14785                let cd = (peer_end + 1) as f64 / n as f64;
14786                let (_, _, idx) = &slice[i];
14787                out_vals[*idx] = Value::Float(cd);
14788            }
14789            Ok(())
14790        }
14791        other => Err(EngineError::Unsupported(alloc::format!(
14792            "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)"
14793        ))),
14794    }
14795}
14796
14797/// v4.20: resolve the user-provided frame down to a normalised
14798/// `(kind, start, end)`. `None` means default — derive from
14799/// `ordered`: ordered ⇒ RANGE UNBOUNDED PRECEDING AND CURRENT ROW,
14800/// unordered ⇒ ROWS UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
14801/// Single-bound shorthand (e.g. `ROWS 5 PRECEDING`) normalises
14802/// end → CURRENT ROW per the PG spec.
14803fn effective_frame(
14804    frame: Option<&WindowFrame>,
14805    ordered: bool,
14806) -> Result<(FrameKind, FrameBound, FrameBound), EngineError> {
14807    match frame {
14808        None => {
14809            if ordered {
14810                Ok((
14811                    FrameKind::Range,
14812                    FrameBound::UnboundedPreceding,
14813                    FrameBound::CurrentRow,
14814                ))
14815            } else {
14816                Ok((
14817                    FrameKind::Rows,
14818                    FrameBound::UnboundedPreceding,
14819                    FrameBound::UnboundedFollowing,
14820                ))
14821            }
14822        }
14823        Some(fr) => {
14824            let end = fr.end.clone().unwrap_or(FrameBound::CurrentRow);
14825            // Reject start > end (a few impossible combinations).
14826            if matches!(fr.start, FrameBound::UnboundedFollowing)
14827                || matches!(end, FrameBound::UnboundedPreceding)
14828            {
14829                return Err(EngineError::Unsupported(alloc::format!(
14830                    "invalid frame: start={:?} end={:?}",
14831                    fr.start,
14832                    end
14833                )));
14834            }
14835            // RANGE OFFSET PRECEDING / FOLLOWING needs value-typed
14836            // arithmetic on the ORDER BY key (e.g. `RANGE BETWEEN
14837            // INTERVAL '1 day' PRECEDING AND CURRENT ROW`). Not
14838            // implemented in v4.20.
14839            if fr.kind == FrameKind::Range
14840                && (matches!(
14841                    fr.start,
14842                    FrameBound::OffsetPreceding(_) | FrameBound::OffsetFollowing(_)
14843                ) || matches!(
14844                    end,
14845                    FrameBound::OffsetPreceding(_) | FrameBound::OffsetFollowing(_)
14846                ))
14847            {
14848                return Err(EngineError::Unsupported(
14849                    "RANGE with explicit offset bounds is not supported (v4.20: only UNBOUNDED / CURRENT ROW for RANGE)".into(),
14850                ));
14851            }
14852            Ok((fr.kind, fr.start.clone(), end))
14853        }
14854    }
14855}
14856
14857/// Compute `(lo, hi)` row-index bounds inside the partition slice
14858/// for the row at position `i`. Inclusive, clamped to
14859/// `[0, slice.len()-1]`. Empty result if `lo > hi`.
14860#[allow(clippy::type_complexity)]
14861fn frame_bounds_for_row(
14862    eff: &(FrameKind, FrameBound, FrameBound),
14863    i: usize,
14864    slice: &[(Vec<Value>, Vec<(Value, bool, Option<bool>)>, usize)],
14865) -> (usize, usize) {
14866    let (kind, start, end) = eff;
14867    let n = slice.len();
14868    let last = n.saturating_sub(1);
14869    let (mut lo, mut hi) = match kind {
14870        FrameKind::Rows => {
14871            let lo = match start {
14872                FrameBound::UnboundedPreceding => 0,
14873                FrameBound::OffsetPreceding(k) => {
14874                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
14875                    i.saturating_sub(k)
14876                }
14877                FrameBound::CurrentRow => i,
14878                FrameBound::OffsetFollowing(k) => {
14879                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
14880                    i.saturating_add(k).min(last)
14881                }
14882                FrameBound::UnboundedFollowing => last,
14883            };
14884            let hi = match end {
14885                FrameBound::UnboundedPreceding => 0,
14886                FrameBound::OffsetPreceding(k) => {
14887                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
14888                    i.saturating_sub(k)
14889                }
14890                FrameBound::CurrentRow => i,
14891                FrameBound::OffsetFollowing(k) => {
14892                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
14893                    i.saturating_add(k).min(last)
14894                }
14895                FrameBound::UnboundedFollowing => last,
14896            };
14897            (lo, hi)
14898        }
14899        FrameKind::Range => {
14900            // RANGE bounds are peer-aware. With only UNBOUNDED and
14901            // CURRENT ROW supported (rejected at effective_frame for
14902            // explicit offsets), the start/end map to the
14903            // partition's full extent at the same-order-key peer
14904            // group boundary.
14905            let lo = match start {
14906                FrameBound::UnboundedPreceding => 0,
14907                FrameBound::CurrentRow => peer_group_start(slice, i),
14908                FrameBound::UnboundedFollowing => last,
14909                _ => unreachable!("offset bounds rejected for RANGE"),
14910            };
14911            let hi = match end {
14912                FrameBound::UnboundedPreceding => 0,
14913                FrameBound::CurrentRow => peer_group_end(slice, i),
14914                FrameBound::UnboundedFollowing => last,
14915                _ => unreachable!("offset bounds rejected for RANGE"),
14916            };
14917            (lo, hi)
14918        }
14919    };
14920    if hi >= n {
14921        hi = last;
14922    }
14923    if lo >= n {
14924        lo = last;
14925    }
14926    (lo, hi)
14927}
14928
14929/// Find the inclusive index of the first row with the same ORDER
14930/// BY key as `slice[i]`. Slice is already sorted by partition then
14931/// order, so peers are contiguous.
14932#[allow(clippy::type_complexity)]
14933fn peer_group_start(
14934    slice: &[(Vec<Value>, Vec<(Value, bool, Option<bool>)>, usize)],
14935    i: usize,
14936) -> usize {
14937    let key = &slice[i].1;
14938    let mut j = i;
14939    while j > 0 && order_key_cmp(&slice[j - 1].1, key) == core::cmp::Ordering::Equal {
14940        j -= 1;
14941    }
14942    j
14943}
14944
14945/// Find the inclusive index of the last row with the same ORDER
14946/// BY key as `slice[i]`.
14947#[allow(clippy::type_complexity)]
14948fn peer_group_end(
14949    slice: &[(Vec<Value>, Vec<(Value, bool, Option<bool>)>, usize)],
14950    i: usize,
14951) -> usize {
14952    let key = &slice[i].1;
14953    let mut j = i;
14954    while j + 1 < slice.len() && order_key_cmp(&slice[j + 1].1, key) == core::cmp::Ordering::Equal {
14955        j += 1;
14956    }
14957    j
14958}
14959
14960fn value_to_f64(v: &Value) -> Option<f64> {
14961    match v {
14962        Value::SmallInt(n) => Some(f64::from(*n)),
14963        Value::Int(n) => Some(f64::from(*n)),
14964        #[allow(clippy::cast_precision_loss)]
14965        Value::BigInt(n) => Some(*n as f64),
14966        Value::Float(x) => Some(*x),
14967        _ => None,
14968    }
14969}
14970
14971/// Quick scan for any subquery-bearing node in a SELECT's WHERE /
14972/// projection / `order_by` — saves cloning the AST when there are
14973/// none (the common case).
14974fn expr_tree_has_subquery(stmt: &SelectStatement) -> bool {
14975    let mut any = false;
14976    for item in &stmt.items {
14977        if let SelectItem::Expr { expr, .. } = item {
14978            any = any || expr_has_subquery(expr);
14979        }
14980    }
14981    if let Some(w) = &stmt.where_ {
14982        any = any || expr_has_subquery(w);
14983    }
14984    if let Some(h) = &stmt.having {
14985        any = any || expr_has_subquery(h);
14986    }
14987    for o in &stmt.order_by {
14988        any = any || expr_has_subquery(&o.expr);
14989    }
14990    for (_, peer) in &stmt.unions {
14991        any = any || expr_tree_has_subquery(peer);
14992    }
14993    any
14994}
14995
14996pub(crate) fn expr_has_subquery(e: &Expr) -> bool {
14997    match e {
14998        Expr::ScalarSubquery(_) | Expr::Exists { .. } | Expr::InSubquery { .. } => true,
14999        Expr::AggregateOrdered { call, order_by, .. } => {
15000            expr_has_subquery(call) || order_by.iter().any(|o| expr_has_subquery(&o.expr))
15001        }
15002        Expr::Binary { lhs, rhs, .. } => expr_has_subquery(lhs) || expr_has_subquery(rhs),
15003        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
15004            expr_has_subquery(expr)
15005        }
15006        Expr::FunctionCall { args, .. } => args.iter().any(expr_has_subquery),
15007        Expr::Like { expr, pattern, .. } => expr_has_subquery(expr) || expr_has_subquery(pattern),
15008        Expr::Extract { source, .. } => expr_has_subquery(source),
15009        Expr::WindowFunction {
15010            args,
15011            partition_by,
15012            order_by,
15013            ..
15014        } => {
15015            args.iter().any(expr_has_subquery)
15016                || partition_by.iter().any(expr_has_subquery)
15017                || order_by.iter().any(|(e, _, _)| expr_has_subquery(e))
15018        }
15019        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => false,
15020        Expr::Array(items) => items.iter().any(expr_has_subquery),
15021        Expr::ArraySubscript { target, index } => {
15022            expr_has_subquery(target) || expr_has_subquery(index)
15023        }
15024        Expr::AnyAll { expr, array, .. } => expr_has_subquery(expr) || expr_has_subquery(array),
15025        Expr::Case {
15026            operand,
15027            branches,
15028            else_branch,
15029        } => {
15030            operand.as_deref().is_some_and(expr_has_subquery)
15031                || branches
15032                    .iter()
15033                    .any(|(w, t)| expr_has_subquery(w) || expr_has_subquery(t))
15034                || else_branch.as_deref().is_some_and(expr_has_subquery)
15035        }
15036    }
15037}
15038
15039/// v4.10 helper: materialise a runtime `Value` back into an AST
15040/// `Expr::Literal` for the subquery-rewrite path. Supports the
15041/// types `Literal` can represent (Integer / Float / Text / Bool /
15042/// Null). Date / Timestamp / Numeric / Vector / Interval / JSON
15043/// would lose precision through Literal and aren't supported in
15044/// uncorrelated-subquery results; they error with a clear hint.
15045fn value_to_literal_expr(v: Value) -> Result<Expr, EngineError> {
15046    let lit = match v {
15047        Value::Null => Literal::Null,
15048        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
15049        Value::Int(n) => Literal::Integer(i64::from(n)),
15050        Value::BigInt(n) => Literal::Integer(n),
15051        Value::Float(x) => Literal::Float(x),
15052        Value::Text(s) | Value::Json(s) => Literal::String(s),
15053        Value::Bool(b) => Literal::Bool(b),
15054        other => {
15055            return Err(EngineError::Unsupported(alloc::format!(
15056                "subquery result type {:?} not yet materialisable; cast to text or integer in the inner SELECT",
15057                other.data_type()
15058            )));
15059        }
15060    };
15061    Ok(Expr::Literal(lit))
15062}
15063
15064/// v7.13.0 — wider helper used by `INSERT … SELECT` (mailrs
15065/// round-5 G4). Covers the most common `Value` variants. Types
15066/// that need lossy textual round-trip (BYTEA, arrays, ts*)
15067/// surface as an Unsupported error so the caller can add a cast
15068/// in the inner SELECT.
15069fn value_to_literal_expr_permissive(v: Value) -> Result<Expr, EngineError> {
15070    let lit = match v {
15071        Value::Null => Literal::Null,
15072        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
15073        Value::Int(n) => Literal::Integer(i64::from(n)),
15074        Value::BigInt(n) => Literal::Integer(n),
15075        Value::Float(x) => Literal::Float(x),
15076        Value::Text(s) | Value::Json(s) => Literal::String(s),
15077        Value::Bool(b) => Literal::Bool(b),
15078        Value::Vector(xs) => Literal::Vector(xs),
15079        // Date / Timestamp / Timestamptz / Numeric round-trip
15080        // through a TEXT literal that `coerce_value` re-parses
15081        // against the target column type.
15082        Value::Date(days) => {
15083            let micros = (i64::from(days)) * 86_400_000_000;
15084            Literal::String(format_timestamp_micros_as_date(micros))
15085        }
15086        Value::Timestamp(us) => Literal::String(format_timestamp_micros(us)),
15087        Value::Numeric { scaled, scale } => Literal::String(format_numeric(scaled, scale)),
15088        other => {
15089            return Err(EngineError::Unsupported(alloc::format!(
15090                "INSERT … SELECT cannot materialise value of type {:?}; \
15091                 add an explicit CAST in the inner SELECT",
15092                other.data_type()
15093            )));
15094        }
15095    };
15096    Ok(Expr::Literal(lit))
15097}
15098
15099fn format_timestamp_micros(us: i64) -> String {
15100    // Same Y/M/D split used by the wire layer; epoch-relative.
15101    let days = us.div_euclid(86_400_000_000);
15102    let intra_day = us.rem_euclid(86_400_000_000);
15103    let date = format_timestamp_micros_as_date(days * 86_400_000_000);
15104    let secs = intra_day / 1_000_000;
15105    let us_rem = intra_day % 1_000_000;
15106    let h = (secs / 3600) % 24;
15107    let m = (secs / 60) % 60;
15108    let s = secs % 60;
15109    if us_rem == 0 {
15110        alloc::format!("{date} {h:02}:{m:02}:{s:02}")
15111    } else {
15112        alloc::format!("{date} {h:02}:{m:02}:{s:02}.{us_rem:06}")
15113    }
15114}
15115
15116fn format_timestamp_micros_as_date(us: i64) -> String {
15117    // Days since 1970-01-01 → calendar Y-M-D via the proleptic
15118    // Gregorian conversion used by spg-engine's date helpers.
15119    let days = us.div_euclid(86_400_000_000);
15120    // 1970-01-01 = JDN 2440588.
15121    let jdn = days + 2_440_588;
15122    let (y, mo, d) = jdn_to_ymd(jdn);
15123    alloc::format!("{y:04}-{mo:02}-{d:02}")
15124}
15125
15126fn jdn_to_ymd(jdn: i64) -> (i64, u32, u32) {
15127    // Fliegel & Van Flandern (1968) — works for all positive JDNs.
15128    let l = jdn + 68569;
15129    let n = (4 * l) / 146_097;
15130    let l = l - (146_097 * n + 3) / 4;
15131    let i = (4000 * (l + 1)) / 1_461_001;
15132    let l = l - (1461 * i) / 4 + 31;
15133    let j = (80 * l) / 2447;
15134    let day = (l - (2447 * j) / 80) as u32;
15135    let l = j / 11;
15136    let month = (j + 2 - 12 * l) as u32;
15137    let year = 100 * (n - 49) + i + l;
15138    (year, month, day)
15139}
15140
15141fn format_numeric(scaled: i128, scale: u8) -> String {
15142    if scale == 0 {
15143        return alloc::format!("{scaled}");
15144    }
15145    let abs = scaled.unsigned_abs();
15146    let divisor = 10u128.pow(u32::from(scale));
15147    let whole = abs / divisor;
15148    let frac = abs % divisor;
15149    let sign = if scaled < 0 { "-" } else { "" };
15150    alloc::format!("{sign}{whole}.{frac:0width$}", width = usize::from(scale))
15151}
15152
15153/// v6.1.1 — walk the prepared `Statement` AST and replace every
15154/// `Expr::Placeholder(n)` with `Expr::Literal(value_to_literal(
15155/// params[n-1]))`. The dispatch downstream sees a `Statement`
15156/// indistinguishable from a simple-query parse, so the exec path
15157/// stays unchanged.
15158///
15159/// Errors fall into one shape: a `$N` references past the bound
15160/// `params.len()`. Out-of-range happens when the Bind didn't
15161/// supply enough values; pgwire surfaces this as a protocol error
15162/// to the client.
15163/// v7.15.0 — rewrite every (potentially-qualified) column
15164/// identifier matching `old` to `new` in a stored SQL source
15165/// string. Used by `ALTER TABLE … RENAME COLUMN` to patch
15166/// CHECK predicate sources, partial-index predicate sources,
15167/// and runtime DEFAULT expression sources before they get
15168/// re-parsed on the next INSERT/UPDATE.
15169///
15170/// Round-trips through the parser, so the rewritten output is
15171/// the canonical Display form (matches what the engine stores
15172/// for fresh predicates). If the source doesn't parse, surfaces
15173/// the parse error — the invariant that stored predicates are
15174/// in canonical Display form means a parse failure here is a
15175/// real bug, not a user mistake to swallow.
15176fn rewrite_column_in_source(
15177    src: &str,
15178    old: &str,
15179    new: &str,
15180) -> Result<alloc::string::String, EngineError> {
15181    let mut expr = spg_sql::parser::parse_expression(src).map_err(|e| {
15182        EngineError::Unsupported(alloc::format!(
15183            "ALTER TABLE RENAME COLUMN: stored predicate source {src:?} \
15184             failed to parse for rewrite ({e})"
15185        ))
15186    })?;
15187    rewrite_column_in_expr(&mut expr, old, new);
15188    Ok(alloc::format!("{expr}"))
15189}
15190
15191/// v7.15.0 — Expr walker that swaps `Expr::Column { name: old, .. }`
15192/// for `Expr::Column { name: new, .. }`. Qualifier is preserved
15193/// (e.g. `t.old` → `t.new`); a foreign-table qualifier still
15194/// gets rewritten because the AST has no way to tell us this
15195/// predicate is on table T versus table T2 — predicate sources
15196/// in SPG are always scoped to the owning table, so any
15197/// qualifier present is either redundant or wrong.
15198fn rewrite_column_in_expr(e: &mut Expr, old: &str, new: &str) {
15199    match e {
15200        Expr::AggregateOrdered { call, order_by, .. } => {
15201            rewrite_column_in_expr(call, old, new);
15202            for o in order_by.iter_mut() {
15203                rewrite_column_in_expr(&mut o.expr, old, new);
15204            }
15205        }
15206        Expr::Column(c) => {
15207            if c.name.eq_ignore_ascii_case(old) {
15208                c.name = new.to_string();
15209            }
15210        }
15211        Expr::Binary { lhs, rhs, .. } => {
15212            rewrite_column_in_expr(lhs, old, new);
15213            rewrite_column_in_expr(rhs, old, new);
15214        }
15215        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
15216            rewrite_column_in_expr(expr, old, new);
15217        }
15218        Expr::FunctionCall { args, .. } => {
15219            for a in args {
15220                rewrite_column_in_expr(a, old, new);
15221            }
15222        }
15223        Expr::Like { expr, pattern, .. } => {
15224            rewrite_column_in_expr(expr, old, new);
15225            rewrite_column_in_expr(pattern, old, new);
15226        }
15227        Expr::Extract { source, .. } => rewrite_column_in_expr(source, old, new),
15228        Expr::WindowFunction {
15229            args,
15230            partition_by,
15231            order_by,
15232            ..
15233        } => {
15234            for a in args {
15235                rewrite_column_in_expr(a, old, new);
15236            }
15237            for p in partition_by {
15238                rewrite_column_in_expr(p, old, new);
15239            }
15240            for (o, _, _) in order_by {
15241                rewrite_column_in_expr(o, old, new);
15242            }
15243        }
15244        Expr::Array(items) => {
15245            for elem in items {
15246                rewrite_column_in_expr(elem, old, new);
15247            }
15248        }
15249        Expr::ArraySubscript { target, index } => {
15250            rewrite_column_in_expr(target, old, new);
15251            rewrite_column_in_expr(index, old, new);
15252        }
15253        Expr::AnyAll { expr, array, .. } => {
15254            rewrite_column_in_expr(expr, old, new);
15255            rewrite_column_in_expr(array, old, new);
15256        }
15257        Expr::Case {
15258            operand,
15259            branches,
15260            else_branch,
15261        } => {
15262            if let Some(o) = operand {
15263                rewrite_column_in_expr(o, old, new);
15264            }
15265            for (w, t) in branches {
15266                rewrite_column_in_expr(w, old, new);
15267                rewrite_column_in_expr(t, old, new);
15268            }
15269            if let Some(e) = else_branch {
15270                rewrite_column_in_expr(e, old, new);
15271            }
15272        }
15273        // Stored predicate sources never contain subqueries —
15274        // CHECK / partial-index / runtime_default are all scalar.
15275        // If a future feature changes that, recurse here.
15276        Expr::ScalarSubquery(_) | Expr::Exists { .. } | Expr::InSubquery { .. } => {}
15277        Expr::Literal(_) | Expr::Placeholder(_) => {}
15278    }
15279}
15280
15281/// v7.16.0 — walks a parsed statement and replaces every
15282/// `Expr::Placeholder(N)` with the corresponding `params[N-1]`
15283/// re-encoded as an `Expr::Literal`. Used internally by
15284/// `Engine::execute_prepared` AND surfaced for the spg-embedded
15285/// WAL path (which needs the bind-final AST so replay sees a
15286/// simple-query-shaped statement, not a `$1`-shaped one). Errors
15287/// when a placeholder references an index past the params slice.
15288pub fn substitute_placeholders(stmt: &mut Statement, params: &[Value]) -> Result<(), EngineError> {
15289    match stmt {
15290        Statement::Select(s) => substitute_select(s, params)?,
15291        Statement::Insert(ins) => {
15292            for row in &mut ins.rows {
15293                for e in row {
15294                    substitute_expr(e, params)?;
15295                }
15296            }
15297            // ON CONFLICT DO UPDATE assignments / WHERE can carry
15298            // placeholders too (`… DO UPDATE SET reason = $2` —
15299            // mailrs embed round-12).
15300            if let Some(clause) = &mut ins.on_conflict
15301                && let spg_sql::ast::OnConflictAction::Update {
15302                    assignments,
15303                    where_,
15304                } = &mut clause.action
15305            {
15306                for (_, e) in assignments.iter_mut() {
15307                    substitute_expr(e, params)?;
15308                }
15309                if let Some(w) = where_ {
15310                    substitute_expr(w, params)?;
15311                }
15312            }
15313        }
15314        Statement::Update(u) => {
15315            for (_, e) in &mut u.assignments {
15316                substitute_expr(e, params)?;
15317            }
15318            if let Some(w) = &mut u.where_ {
15319                substitute_expr(w, params)?;
15320            }
15321        }
15322        Statement::Delete(d) => {
15323            if let Some(w) = &mut d.where_ {
15324                substitute_expr(w, params)?;
15325            }
15326        }
15327        Statement::Explain(e) => substitute_select(&mut e.inner, params)?,
15328        // Other statements (CREATE / BEGIN / SHOW / …) have no
15329        // expression slots; no walk needed.
15330        _ => {}
15331    }
15332    Ok(())
15333}
15334
15335/// v7.25.1 (mailrs round-18) — THE canonical mutable traversal of
15336/// every expression slot in a SelectStatement, including every
15337/// nested SelectStatement (CTE bodies, UNION peers, LATERAL derived
15338/// tables) and the JOIN ON conditions. Round-12 #7b and round-18
15339/// were both "a hand-rolled Select walker forgot one subtree";
15340/// every whole-statement rewrite pass (placeholders, clock) must go
15341/// through here so a new AST slot only needs adding once.
15342/// Expression-INTERNAL recursion (into subquery nodes inside an
15343/// Expr) stays the visitor's own responsibility.
15344pub(crate) fn walk_select_exprs_mut(
15345    s: &mut SelectStatement,
15346    f: &mut impl FnMut(&mut Expr) -> Result<(), EngineError>,
15347) -> Result<(), EngineError> {
15348    for cte in &mut s.ctes {
15349        walk_select_exprs_mut(&mut cte.body, f)?;
15350    }
15351    for item in &mut s.items {
15352        if let SelectItem::Expr { expr, .. } = item {
15353            f(expr)?;
15354        }
15355    }
15356    if let Some(from) = &mut s.from {
15357        if let Some(sub) = &mut from.primary.lateral_subquery {
15358            walk_select_exprs_mut(sub, f)?;
15359        }
15360        for j in &mut from.joins {
15361            if let Some(sub) = &mut j.table.lateral_subquery {
15362                walk_select_exprs_mut(sub, f)?;
15363            }
15364            if let Some(on) = &mut j.on {
15365                f(on)?;
15366            }
15367        }
15368    }
15369    if let Some(w) = &mut s.where_ {
15370        f(w)?;
15371    }
15372    if let Some(gs) = &mut s.group_by {
15373        for g in gs {
15374            f(g)?;
15375        }
15376    }
15377    if let Some(h) = &mut s.having {
15378        f(h)?;
15379    }
15380    for o in &mut s.order_by {
15381        f(&mut o.expr)?;
15382    }
15383    for (_, peer) in &mut s.unions {
15384        walk_select_exprs_mut(peer, f)?;
15385    }
15386    Ok(())
15387}
15388
15389fn substitute_select(s: &mut SelectStatement, params: &[Value]) -> Result<(), EngineError> {
15390    walk_select_exprs_mut(s, &mut |e| substitute_expr(e, params))?;
15391    // v7.25.1 — LIMIT/OFFSET placeholders inside CTE bodies and
15392    // UNION peers resolve through their own recursion (the walker
15393    // above only visits Expr slots), so handle them per nested
15394    // statement here.
15395    for cte in &mut s.ctes {
15396        resolve_limit_offset_placeholders(&mut cte.body, params)?;
15397    }
15398    for (_, peer) in &mut s.unions {
15399        resolve_limit_offset_placeholders(peer, params)?;
15400    }
15401    // v7.9.24 — LIMIT $N / OFFSET $N placeholder resolution.
15402    // mailrs H2. After this pass each LIMIT/OFFSET that was a
15403    // Placeholder is rewritten to Literal so the existing
15404    // `LimitExpr::as_literal` path consumes a concrete u32.
15405    if let Some(le) = s.limit {
15406        s.limit = Some(resolve_limit_placeholder(le, params)?);
15407    }
15408    if let Some(le) = s.offset {
15409        s.offset = Some(resolve_limit_placeholder(le, params)?);
15410    }
15411    Ok(())
15412}
15413
15414/// v7.25.1 — recursive LIMIT/OFFSET placeholder resolution for
15415/// nested statements (CTE bodies / UNION peers).
15416fn resolve_limit_offset_placeholders(
15417    s: &mut SelectStatement,
15418    params: &[Value],
15419) -> Result<(), EngineError> {
15420    if let Some(le) = s.limit {
15421        s.limit = Some(resolve_limit_placeholder(le, params)?);
15422    }
15423    if let Some(le) = s.offset {
15424        s.offset = Some(resolve_limit_placeholder(le, params)?);
15425    }
15426    for cte in &mut s.ctes {
15427        resolve_limit_offset_placeholders(&mut cte.body, params)?;
15428    }
15429    for (_, peer) in &mut s.unions {
15430        resolve_limit_offset_placeholders(peer, params)?;
15431    }
15432    Ok(())
15433}
15434
15435fn resolve_limit_placeholder(
15436    le: spg_sql::ast::LimitExpr,
15437    params: &[Value],
15438) -> Result<spg_sql::ast::LimitExpr, EngineError> {
15439    use spg_sql::ast::LimitExpr;
15440    match le {
15441        LimitExpr::Literal(_) => Ok(le),
15442        LimitExpr::Placeholder(n) => {
15443            let idx = usize::from(n).saturating_sub(1);
15444            let v = params.get(idx).ok_or_else(|| {
15445                EngineError::Eval(EvalError::PlaceholderOutOfRange {
15446                    n,
15447                    bound: u16::try_from(params.len()).unwrap_or(u16::MAX),
15448                })
15449            })?;
15450            let int = match v {
15451                Value::SmallInt(x) => Some(i64::from(*x)),
15452                Value::Int(x) => Some(i64::from(*x)),
15453                Value::BigInt(x) => Some(*x),
15454                _ => None,
15455            }
15456            .ok_or_else(|| {
15457                EngineError::Unsupported(alloc::format!(
15458                    "LIMIT/OFFSET ${n} bound to non-integer {v:?}"
15459                ))
15460            })?;
15461            if int < 0 {
15462                return Err(EngineError::Unsupported(alloc::format!(
15463                    "LIMIT/OFFSET ${n} bound to negative value {int}"
15464                )));
15465            }
15466            let bounded = u32::try_from(int).map_err(|_| {
15467                EngineError::Unsupported(alloc::format!(
15468                    "LIMIT/OFFSET ${n} value {int} exceeds u32 range"
15469                ))
15470            })?;
15471            Ok(LimitExpr::Literal(bounded))
15472        }
15473    }
15474}
15475
15476fn substitute_expr(e: &mut Expr, params: &[Value]) -> Result<(), EngineError> {
15477    if let Expr::Placeholder(n) = e {
15478        let idx = usize::from(*n).saturating_sub(1);
15479        let v = params.get(idx).ok_or_else(|| {
15480            EngineError::Eval(EvalError::PlaceholderOutOfRange {
15481                n: *n,
15482                bound: u16::try_from(params.len()).unwrap_or(u16::MAX),
15483            })
15484        })?;
15485        *e = Expr::Literal(value_to_literal(v.clone()));
15486        return Ok(());
15487    }
15488    match e {
15489        Expr::AggregateOrdered { call, order_by, .. } => {
15490            substitute_expr(call, params)?;
15491            for o in order_by.iter_mut() {
15492                substitute_expr(&mut o.expr, params)?;
15493            }
15494        }
15495        Expr::Binary { lhs, rhs, .. } => {
15496            substitute_expr(lhs, params)?;
15497            substitute_expr(rhs, params)?;
15498        }
15499        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
15500            substitute_expr(expr, params)?;
15501        }
15502        Expr::FunctionCall { args, .. } => {
15503            for a in args {
15504                substitute_expr(a, params)?;
15505            }
15506        }
15507        Expr::Like { expr, pattern, .. } => {
15508            substitute_expr(expr, params)?;
15509            substitute_expr(pattern, params)?;
15510        }
15511        Expr::Extract { source, .. } => substitute_expr(source, params)?,
15512        Expr::ScalarSubquery(s) => substitute_select(s, params)?,
15513        Expr::Exists { subquery, .. } => substitute_select(subquery, params)?,
15514        Expr::InSubquery { expr, subquery, .. } => {
15515            substitute_expr(expr, params)?;
15516            substitute_select(subquery, params)?;
15517        }
15518        Expr::WindowFunction {
15519            args,
15520            partition_by,
15521            order_by,
15522            ..
15523        } => {
15524            for a in args {
15525                substitute_expr(a, params)?;
15526            }
15527            for p in partition_by {
15528                substitute_expr(p, params)?;
15529            }
15530            for (e, _, _) in order_by {
15531                substitute_expr(e, params)?;
15532            }
15533        }
15534        Expr::Literal(_) | Expr::Column(_) => {}
15535        // Already handled above.
15536        Expr::Placeholder(_) => unreachable!("Placeholder handled at top of fn"),
15537        Expr::Array(items) => {
15538            for elem in items {
15539                substitute_expr(elem, params)?;
15540            }
15541        }
15542        Expr::ArraySubscript { target, index } => {
15543            substitute_expr(target, params)?;
15544            substitute_expr(index, params)?;
15545        }
15546        Expr::AnyAll { expr, array, .. } => {
15547            substitute_expr(expr, params)?;
15548            substitute_expr(array, params)?;
15549        }
15550        Expr::Case {
15551            operand,
15552            branches,
15553            else_branch,
15554        } => {
15555            if let Some(o) = operand {
15556                substitute_expr(o, params)?;
15557            }
15558            for (w, t) in branches {
15559                substitute_expr(w, params)?;
15560                substitute_expr(t, params)?;
15561            }
15562            if let Some(e) = else_branch {
15563                substitute_expr(e, params)?;
15564            }
15565        }
15566    }
15567    Ok(())
15568}
15569
15570/// v6.1.1 — convert a runtime `Value` into the closest matching
15571/// `Literal` for the substitute walker. Lossless for the simple
15572/// scalars (Int / Float / Text / Bool); Numeric / Date / Timestamp
15573/// / Json / Interval render as their canonical text form so the
15574/// downstream coerce_value can re-parse against the target column
15575/// type. SQ8 / HalfVector cells are NOT expected as bind params;
15576/// pgwire's Bind decodes vector params to the f32 representation
15577/// before they reach this helper.
15578/// v6.2.0 — total ordering on `Value`s used by ANALYZE to sort a
15579/// column's non-NULL sample before histogram building. Cross-type
15580/// pairs (Int vs Float, Date vs Timestamp, …) compare via the
15581/// same widening the eval-side `compare` operator uses; everything
15582/// else (the genuinely-incompatible pairs) falls back to ordering
15583/// by canonical string form so the sort is still total + stable.
15584/// Vector / SQ8 / Half / Json / Numeric / Interval values reach
15585/// here only via the string-fallback path because vector columns
15586/// are filtered out upstream.
15587fn sort_values_for_histogram(a: &Value, b: &Value) -> core::cmp::Ordering {
15588    use core::cmp::Ordering;
15589    match (a, b) {
15590        (Value::SmallInt(a), Value::SmallInt(b)) => a.cmp(b),
15591        (Value::Int(a), Value::Int(b)) => a.cmp(b),
15592        (Value::BigInt(a), Value::BigInt(b)) => a.cmp(b),
15593        (Value::SmallInt(a), Value::Int(b)) => i32::from(*a).cmp(b),
15594        (Value::Int(a), Value::SmallInt(b)) => a.cmp(&i32::from(*b)),
15595        (Value::Int(a), Value::BigInt(b)) => i64::from(*a).cmp(b),
15596        (Value::BigInt(a), Value::Int(b)) => a.cmp(&i64::from(*b)),
15597        (Value::SmallInt(a), Value::BigInt(b)) => i64::from(*a).cmp(b),
15598        (Value::BigInt(a), Value::SmallInt(b)) => a.cmp(&i64::from(*b)),
15599        (Value::Float(a), Value::Float(b)) => a.partial_cmp(b).unwrap_or(Ordering::Equal),
15600        (Value::Text(a), Value::Text(b)) | (Value::Json(a), Value::Json(b)) => a.cmp(b),
15601        (Value::Bool(a), Value::Bool(b)) => a.cmp(b),
15602        (Value::Date(a), Value::Date(b)) => a.cmp(b),
15603        (Value::Timestamp(a), Value::Timestamp(b)) => a.cmp(b),
15604        // Mixed numeric/float — widen to f64 and compare.
15605        (Value::SmallInt(n), Value::Float(x)) => {
15606            (f64::from(*n)).partial_cmp(x).unwrap_or(Ordering::Equal)
15607        }
15608        (Value::Float(x), Value::SmallInt(n)) => {
15609            x.partial_cmp(&f64::from(*n)).unwrap_or(Ordering::Equal)
15610        }
15611        (Value::Int(n), Value::Float(x)) => {
15612            (f64::from(*n)).partial_cmp(x).unwrap_or(Ordering::Equal)
15613        }
15614        (Value::Float(x), Value::Int(n)) => {
15615            x.partial_cmp(&f64::from(*n)).unwrap_or(Ordering::Equal)
15616        }
15617        (Value::BigInt(n), Value::Float(x)) => {
15618            #[allow(clippy::cast_precision_loss)]
15619            let nf = *n as f64;
15620            nf.partial_cmp(x).unwrap_or(Ordering::Equal)
15621        }
15622        (Value::Float(x), Value::BigInt(n)) => {
15623            #[allow(clippy::cast_precision_loss)]
15624            let nf = *n as f64;
15625            x.partial_cmp(&nf).unwrap_or(Ordering::Equal)
15626        }
15627        // Cross-type fallback: lexicographic on canonical form.
15628        // Total + stable so the sort is well-defined.
15629        _ => canonical_value_repr(a).cmp(&canonical_value_repr(b)),
15630    }
15631}
15632
15633/// v6.2.0 — render the histogram bounds list as a `[v0, v1, ...]`
15634/// string for the `spg_statistic.histogram_bounds` column. Values
15635/// containing `,` or `[` / `]` are JSON-style escaped so the
15636/// rendering round-trips through a future parser; v6.2.0 only
15637/// uses the rendered form for human consumption, so the escaping
15638/// is conservative.
15639fn render_histogram_bounds(bounds: &[alloc::string::String]) -> alloc::string::String {
15640    let mut out = alloc::string::String::with_capacity(bounds.len() * 8 + 2);
15641    out.push('[');
15642    for (i, b) in bounds.iter().enumerate() {
15643        if i > 0 {
15644            out.push_str(", ");
15645        }
15646        let needs_quote = b.contains([',', '[', ']', '"']) || b.is_empty();
15647        if needs_quote {
15648            out.push('"');
15649            for ch in b.chars() {
15650                if ch == '"' || ch == '\\' {
15651                    out.push('\\');
15652                }
15653                out.push(ch);
15654            }
15655            out.push('"');
15656        } else {
15657            out.push_str(b);
15658        }
15659    }
15660    out.push(']');
15661    out
15662}
15663
15664/// v6.2.0 — canonical textual form of a `Value` for histogram
15665/// bound storage. Strings used by ANALYZE for sort + bound output.
15666/// INT / BIGINT → decimal; FLOAT → shortest-round-trip via
15667/// `{:?}`; TEXT pass-through; BOOL → `t` / `f`; DATE / TIMESTAMP →
15668/// the same form `format_date` / `format_timestamp` produce for
15669/// SQL Display. Vector / SQ8 / Half / Json / Numeric / Interval
15670/// reach this only via a non-Vector column (vector columns are
15671/// skipped upstream); they fall back to a Debug-derived form so
15672/// stats still serialise without crashing.
15673pub(crate) fn canonical_value_repr(v: &Value) -> alloc::string::String {
15674    match v {
15675        Value::Null => "NULL".to_string(),
15676        Value::SmallInt(n) => alloc::format!("{n}"),
15677        Value::Int(n) => alloc::format!("{n}"),
15678        Value::BigInt(n) => alloc::format!("{n}"),
15679        Value::Float(x) => alloc::format!("{x:?}"),
15680        Value::Text(s) | Value::Json(s) => s.clone(),
15681        Value::Bool(b) => if *b { "t" } else { "f" }.to_string(),
15682        Value::Date(d) => eval::format_date(*d),
15683        Value::Timestamp(t) => eval::format_timestamp(*t),
15684        // v7.17.0 Phase 3.P0-32 — PG TIME canonical text form.
15685        Value::Time(us) => eval::format_time(*us),
15686        // v7.17.0 Phase 3.P0-33 — MySQL YEAR 4-digit zero-padded.
15687        Value::Year(y) => alloc::format!("{y:04}"),
15688        // v7.17.0 Phase 3.P0-34 — PG TIMETZ canonical text form.
15689        Value::TimeTz { us, offset_secs } => eval::format_timetz(*us, *offset_secs),
15690        // v7.17.0 Phase 3.P0-35 — PG MONEY canonical en_US text form.
15691        Value::Money(c) => eval::format_money(*c),
15692        // v7.17.0 Phase 3.P0-38 — PG range canonical text form.
15693        v @ Value::Range { .. } => format_range_str(v),
15694        // v7.17.0 Phase 3.P0-39 — PG hstore canonical text form.
15695        Value::Hstore(pairs) => format_hstore_str(pairs),
15696        // v7.17.0 Phase 3.P0-40 — 2D array canonical text form.
15697        Value::IntArray2D(rows) => format_int_2d_text(rows),
15698        Value::BigIntArray2D(rows) => format_bigint_2d_text(rows),
15699        Value::TextArray2D(rows) => format_text_2d_text(rows),
15700        Value::Interval { months, micros } => eval::format_interval(*months, *micros),
15701        Value::Numeric { scaled, scale } => eval::format_numeric(*scaled, *scale),
15702        Value::Vector(_) | Value::Sq8Vector(_) | Value::HalfVector(_) => {
15703            // Unreachable in practice (vector columns are filtered
15704            // out before this). Defensive fallback so a future
15705            // vector-stats path doesn't crash.
15706            alloc::format!("{v:?}")
15707        }
15708        // v7.5.0 — Value is #[non_exhaustive] for downstream
15709        // forward-compat. Future variants fall through to Debug
15710        // form here (same shape as the vector fallback above).
15711        _ => alloc::format!("{v:?}"),
15712    }
15713}
15714
15715/// v6.2.0 — true for engine-managed catalog tables that the bare
15716/// `ANALYZE` (no target) should skip. v6.2.0 has no internal
15717/// tables yet (publications / subscriptions / users / statistics
15718/// all live as engine fields, not catalog tables), so this is a
15719/// reserved future-proofing hook — every existing user table is
15720/// analysed.
15721const fn is_internal_table_name(_name: &str) -> bool {
15722    false
15723}
15724
15725fn value_to_literal(v: Value) -> Literal {
15726    match v {
15727        Value::Null => Literal::Null,
15728        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
15729        Value::Int(n) => Literal::Integer(i64::from(n)),
15730        Value::BigInt(n) => Literal::Integer(n),
15731        Value::Float(x) => Literal::Float(x),
15732        Value::Text(s) | Value::Json(s) => Literal::String(s),
15733        Value::Bool(b) => Literal::Bool(b),
15734        Value::Vector(v) => Literal::Vector(v),
15735        Value::Numeric { scaled, scale } => Literal::String(eval::format_numeric(scaled, scale)),
15736        Value::Date(d) => Literal::String(eval::format_date(d)),
15737        Value::Timestamp(t) => Literal::String(eval::format_timestamp(t)),
15738        // v7.17.0 Phase 3.P0-69 — UUID round-trips via canonical
15739        // hyphenated text. Without this arm the fallback below
15740        // renders `Debug` form ("Uuid([85, …])") which the
15741        // engine's Text → Uuid coerce can't parse, breaking
15742        // prepared-bind round-trip from the spg-sqlx adapter.
15743        Value::Uuid(b) => Literal::String(spg_storage::format_uuid(&b)),
15744        // v7.16.0 — BYTEA round-trip for the spg-sqlx Bind path.
15745        // PG-canonical text rep is `\x` + lowercase hex; the
15746        // engine's coerce_value already accepts that on the
15747        // text → bytea direction.
15748        Value::Bytes(b) => Literal::String(eval::format_bytea_hex(&b)),
15749        // Arrays ride the AST natively (mailrs embed round-12) —
15750        // the prior `{a,b,c}` text form only worked where a column
15751        // type drove the re-parse; `= ANY($1)` has no column
15752        // context and saw a bare Text value.
15753        Value::TextArray(items) => Literal::TextArray(items),
15754        Value::IntArray(items) => Literal::IntArray(items),
15755        Value::BigIntArray(items) => Literal::BigIntArray(items),
15756        Value::Interval { months, micros } => Literal::Interval {
15757            months,
15758            micros,
15759            text: eval::format_interval(months, micros),
15760        },
15761        // SQ8 / halfvec cells dequantise to f32 before reaching the
15762        // substitute walker; pgwire's Bind path handles that.
15763        Value::Sq8Vector(q) => Literal::Vector(spg_storage::quantize::dequantize(&q)),
15764        Value::HalfVector(h) => Literal::Vector(h.to_f32_vec()),
15765        // v7.5.0 — Value is #[non_exhaustive]; future variants
15766        // render as Debug-form String literal until explicit
15767        // mapping is added.
15768        v => Literal::String(alloc::format!("{v:?}")),
15769    }
15770}
15771
15772fn rewrite_clock_calls(stmt: &mut Statement, now_micros: Option<i64>) {
15773    let Some(now) = now_micros else {
15774        return;
15775    };
15776    match stmt {
15777        Statement::Select(s) => rewrite_select_clock(s, now),
15778        Statement::Insert(ins) => {
15779            for row in &mut ins.rows {
15780                for e in row {
15781                    rewrite_expr_clock(e, now);
15782                }
15783            }
15784            // `ON CONFLICT … DO UPDATE SET created_at = NOW()` —
15785            // the upsert assignments carry clock calls too (mailrs
15786            // embed round-12).
15787            if let Some(clause) = &mut ins.on_conflict
15788                && let spg_sql::ast::OnConflictAction::Update {
15789                    assignments,
15790                    where_,
15791                } = &mut clause.action
15792            {
15793                for (_, e) in assignments.iter_mut() {
15794                    rewrite_expr_clock(e, now);
15795                }
15796                if let Some(w) = where_ {
15797                    rewrite_expr_clock(w, now);
15798                }
15799            }
15800        }
15801        // `UPDATE … SET seen_at = NOW() WHERE …` / `DELETE … WHERE
15802        // ts < NOW()` (mailrs embed round-12 — previously only
15803        // SELECT / INSERT-rows were walked).
15804        Statement::Update(u) => {
15805            for (_, e) in &mut u.assignments {
15806                rewrite_expr_clock(e, now);
15807            }
15808            if let Some(w) = &mut u.where_ {
15809                rewrite_expr_clock(w, now);
15810            }
15811        }
15812        Statement::Delete(d) => {
15813            if let Some(w) = &mut d.where_ {
15814                rewrite_expr_clock(w, now);
15815            }
15816        }
15817        _ => {}
15818    }
15819}
15820
15821fn rewrite_select_clock(s: &mut SelectStatement, now: i64) {
15822    // v7.25.1 (round-18) — shared traversal: CTE bodies, LATERAL
15823    // subqueries, JOIN ON, and UNION peers all get the clock
15824    // rewrite (NOW() inside a CTE previously survived to eval as
15825    // "unknown function `now`").
15826    let _ = walk_select_exprs_mut(s, &mut |e| {
15827        rewrite_expr_clock(e, now);
15828        Ok(())
15829    });
15830}
15831
15832/// v3.0.3 hot path: every recursion lands in exactly one `match` arm.
15833/// Literal / Column-with-qualifier (the dominant cases on a typical
15834/// AST) take a single pattern dispatch and exit. The clock-rewrite
15835/// targets (zero-arg `NOW` / `CURRENT_TIMESTAMP` / `CURRENT_DATE`
15836/// functions, and bare `CURRENT_TIMESTAMP` / `CURRENT_DATE` column
15837/// refs) sit on their own arms with match guards so the fall-through
15838/// to the recursive arms is unambiguous.
15839fn rewrite_expr_clock(e: &mut Expr, now: i64) {
15840    // Fast-path test on the no-recursion shapes first. We can't fold
15841    // them into the big match below because they need to *replace* `e`
15842    // outright; the recursive arms below match on its sub-fields.
15843    if let Some(replacement) = clock_replacement_for(e, now) {
15844        *e = replacement;
15845        return;
15846    }
15847    match e {
15848        Expr::AggregateOrdered { call, order_by, .. } => {
15849            rewrite_expr_clock(call, now);
15850            for o in order_by.iter_mut() {
15851                rewrite_expr_clock(&mut o.expr, now);
15852            }
15853        }
15854        Expr::Binary { lhs, rhs, .. } => {
15855            rewrite_expr_clock(lhs, now);
15856            rewrite_expr_clock(rhs, now);
15857        }
15858        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
15859            rewrite_expr_clock(expr, now);
15860        }
15861        Expr::FunctionCall { args, .. } => {
15862            for a in args {
15863                rewrite_expr_clock(a, now);
15864            }
15865        }
15866        Expr::Like { expr, pattern, .. } => {
15867            rewrite_expr_clock(expr, now);
15868            rewrite_expr_clock(pattern, now);
15869        }
15870        Expr::Extract { source, .. } => rewrite_expr_clock(source, now),
15871        // v4.10 subquery nodes — recurse into the inner SELECT's
15872        // expression slots so e.g. SELECT NOW() in a scalar
15873        // subquery picks up the same instant as the outer query.
15874        Expr::ScalarSubquery(s) => rewrite_select_clock(s, now),
15875        Expr::Exists { subquery, .. } => rewrite_select_clock(subquery, now),
15876        Expr::InSubquery { expr, subquery, .. } => {
15877            rewrite_expr_clock(expr, now);
15878            rewrite_select_clock(subquery, now);
15879        }
15880        // v4.12 window functions — args + PARTITION BY + ORDER BY
15881        // may all reference clock literals.
15882        Expr::WindowFunction {
15883            args,
15884            partition_by,
15885            order_by,
15886            ..
15887        } => {
15888            for a in args {
15889                rewrite_expr_clock(a, now);
15890            }
15891            for p in partition_by {
15892                rewrite_expr_clock(p, now);
15893            }
15894            for (e, _, _) in order_by {
15895                rewrite_expr_clock(e, now);
15896            }
15897        }
15898        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => {}
15899        Expr::Array(items) => {
15900            for elem in items {
15901                rewrite_expr_clock(elem, now);
15902            }
15903        }
15904        Expr::ArraySubscript { target, index } => {
15905            rewrite_expr_clock(target, now);
15906            rewrite_expr_clock(index, now);
15907        }
15908        Expr::AnyAll { expr, array, .. } => {
15909            rewrite_expr_clock(expr, now);
15910            rewrite_expr_clock(array, now);
15911        }
15912        Expr::Case {
15913            operand,
15914            branches,
15915            else_branch,
15916        } => {
15917            if let Some(o) = operand {
15918                rewrite_expr_clock(o, now);
15919            }
15920            for (w, t) in branches {
15921                rewrite_expr_clock(w, now);
15922                rewrite_expr_clock(t, now);
15923            }
15924            if let Some(e) = else_branch {
15925                rewrite_expr_clock(e, now);
15926            }
15927        }
15928    }
15929}
15930
15931/// Returns `Some(Expr)` when `e` is one of the clock-call shapes that
15932/// must be rewritten; otherwise `None` so the caller falls through to
15933/// the recursive walk. Identifies both function-call forms (`NOW()` /
15934/// `CURRENT_TIMESTAMP()` / `CURRENT_DATE()`) and bare-identifier forms
15935/// (`CURRENT_TIMESTAMP` / `CURRENT_DATE` as unqualified column refs,
15936/// which is how PG accepts them without parens).
15937fn clock_replacement_for(e: &Expr, now: i64) -> Option<Expr> {
15938    let (kind, name) = match e {
15939        Expr::FunctionCall { name, args } if args.is_empty() => (ClockSite::Fn, name.as_str()),
15940        Expr::Column(c) if c.qualifier.is_none() => (ClockSite::BareIdent, c.name.as_str()),
15941        _ => return None,
15942    };
15943    // ASCII case-insensitive name match. Each entry decides what
15944    // synthetic literal the call expands to.
15945    //
15946    // v7.17.0 Phase 3.P0-29 — `unix_timestamp` (no args) joins this
15947    // table as MySQL's epoch-seconds equivalent of `now()`. Folded
15948    // to a BigInt literal here so apply_function never needs a
15949    // clock dependency.
15950    enum ClockShape {
15951        Timestamp,
15952        Date,
15953        UnixSeconds,
15954    }
15955    let shape = match name.len() {
15956        3 if kind == ClockSite::Fn && name.eq_ignore_ascii_case("now") => {
15957            Some(ClockShape::Timestamp)
15958        }
15959        12 if name.eq_ignore_ascii_case("current_date") => Some(ClockShape::Date),
15960        14 if kind == ClockSite::Fn && name.eq_ignore_ascii_case("unix_timestamp") => {
15961            Some(ClockShape::UnixSeconds)
15962        }
15963        17 if name.eq_ignore_ascii_case("current_timestamp") => Some(ClockShape::Timestamp),
15964        _ => None,
15965    };
15966    let shape = shape?;
15967    let payload = match shape {
15968        ClockShape::Timestamp => now,
15969        ClockShape::Date => now.div_euclid(86_400_000_000),
15970        ClockShape::UnixSeconds => now.div_euclid(1_000_000),
15971    };
15972    let target = match shape {
15973        ClockShape::Timestamp => spg_sql::ast::CastTarget::Timestamp,
15974        ClockShape::Date => spg_sql::ast::CastTarget::Date,
15975        ClockShape::UnixSeconds => spg_sql::ast::CastTarget::BigInt,
15976    };
15977    Some(Expr::Cast {
15978        expr: alloc::boxed::Box::new(Expr::Literal(spg_sql::ast::Literal::Integer(payload))),
15979        target,
15980    })
15981}
15982
15983#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15984enum ClockSite {
15985    Fn,
15986    BareIdent,
15987}
15988
15989/// `ORDER BY <integer>` references the N-th SELECT item (1-based).
15990/// Swap the integer literal for the matching item's expression so the
15991/// executor doesn't need a special-case branch. Recurses into UNION
15992/// peers because each peer keeps its own SELECT list.
15993/// v6.4.1 — expand `GROUP BY ALL` to every non-aggregate SELECT-list
15994/// item. Mirrors DuckDB / PG 19 semantics. Wildcards (`SELECT * …`)
15995/// are NOT expanded by GROUP BY ALL (PG 19 leaves the wildcard intact
15996/// and groups by whatever explicit non-aggregates remain — none in
15997/// the wildcard-only case, which still works for non-aggregate
15998/// queries).
15999fn expand_group_by_all(s: &mut SelectStatement) {
16000    if !s.group_by_all {
16001        for (_, peer) in &mut s.unions {
16002            expand_group_by_all(peer);
16003        }
16004        return;
16005    }
16006    let mut groups: Vec<Expr> = Vec::new();
16007    for item in &s.items {
16008        if let SelectItem::Expr { expr, .. } = item
16009            && !aggregate::contains_aggregate(expr)
16010        {
16011            groups.push(expr.clone());
16012        }
16013    }
16014    s.group_by = Some(groups);
16015    s.group_by_all = false;
16016    for (_, peer) in &mut s.unions {
16017        expand_group_by_all(peer);
16018    }
16019}
16020
16021fn resolve_order_by_position(s: &mut SelectStatement) {
16022    // v6.4.0 — iterate every ORDER BY key. Position references
16023    // (`ORDER BY 2`) bind to the 1-based projection index;
16024    // identifier references that match a SELECT-list alias bind to
16025    // the projected expression (Step 4 of L3a).
16026    for order in &mut s.order_by {
16027        match &order.expr {
16028            Expr::Literal(Literal::Integer(n)) if *n >= 1 => {
16029                if let Ok(idx_one_based) = usize::try_from(*n) {
16030                    let idx = idx_one_based - 1;
16031                    if idx < s.items.len()
16032                        && let SelectItem::Expr { expr, .. } = &s.items[idx]
16033                    {
16034                        order.expr = expr.clone();
16035                    }
16036                }
16037            }
16038            Expr::Column(c) if c.qualifier.is_none() => {
16039                // Alias-in-ORDER-BY lookup.
16040                for item in &s.items {
16041                    if let SelectItem::Expr {
16042                        expr,
16043                        alias: Some(a),
16044                    } = item
16045                        && a == &c.name
16046                    {
16047                        order.expr = expr.clone();
16048                        break;
16049                    }
16050                }
16051            }
16052            _ => {}
16053        }
16054    }
16055    for (_, peer) in &mut s.unions {
16056        resolve_order_by_position(peer);
16057    }
16058}
16059
16060/// Sort `tagged` by `f64` key, reversing the comparator under DESC.
16061/// Used by the UNION ORDER BY path; per-block paths inline the same
16062/// comparator because they already hold `&OrderBy` directly.
16063/// v3.1.1: partial-sort helper. When `keep` (= offset + limit) is
16064/// strictly less than `tagged.len()`, run `select_nth_unstable_by` to
16065/// partition the prefix in O(n), then sort just that prefix in O(k
16066/// log k). Total O(n + k log k), vs O(n log n) for a full sort. The
16067/// caller decides what `keep` is; passing `None` (no LIMIT) keeps the
16068/// full-sort behaviour.
16069///
16070/// `tagged` holds `(Option<f64>, Row)` (the SELECT path) — `None` keys
16071/// sort last in ascending order, mirroring NULL-sorts-last in SQL.
16072fn partial_sort_tagged(tagged: &mut Vec<(Vec<f64>, Row)>, keep: Option<usize>, descs: &[bool]) {
16073    let cmp = |a: &(Vec<f64>, Row), b: &(Vec<f64>, Row)| cmp_multi_key(&a.0, &b.0, descs);
16074    match keep {
16075        Some(k) if k < tagged.len() && k > 0 => {
16076            let pivot = k - 1;
16077            tagged.select_nth_unstable_by(pivot, cmp);
16078            tagged[..k].sort_by(cmp);
16079            tagged.truncate(k);
16080        }
16081        _ => {
16082            tagged.sort_by(cmp);
16083        }
16084    }
16085}
16086
16087fn sort_by_keys(tagged: &mut [(Vec<f64>, Row)], descs: &[bool]) {
16088    tagged.sort_by(|a, b| cmp_multi_key(&a.0, &b.0, descs));
16089}
16090
16091/// v6.4.0 — multi-key ORDER BY comparator. Each key's per-key DESC
16092/// flag is honored independently. NULL is encoded as `f64::INFINITY`
16093/// so it sorts last in ASC and first in DESC (matches PG default).
16094fn cmp_multi_key(a: &[f64], b: &[f64], descs: &[bool]) -> core::cmp::Ordering {
16095    use core::cmp::Ordering;
16096    for (i, (ka, kb)) in a.iter().zip(b.iter()).enumerate() {
16097        let ord = ka.partial_cmp(kb).unwrap_or(Ordering::Equal);
16098        let ord = if descs.get(i).copied().unwrap_or(false) {
16099            ord.reverse()
16100        } else {
16101            ord
16102        };
16103        if ord != Ordering::Equal {
16104            return ord;
16105        }
16106    }
16107    Ordering::Equal
16108}
16109
16110/// v6.4.0 — eval every ORDER BY expression for a row and pack the
16111/// resulting keys into a `Vec<f64>`. NULL → `f64::INFINITY`.
16112fn build_order_keys(
16113    order_by: &[OrderBy],
16114    row: &Row,
16115    ctx: &EvalContext,
16116) -> Result<Vec<f64>, EngineError> {
16117    let mut keys = Vec::with_capacity(order_by.len());
16118    for o in order_by {
16119        let v = eval::eval_expr(&o.expr, row, ctx)?;
16120        // v7.24 (round-16 A) — explicit NULLS FIRST/LAST. The f64
16121        // packing sorts ascending THEN applies the per-key DESC
16122        // reverse, so a NULL must land at +INF exactly when the
16123        // effective placement agrees with the reverse direction:
16124        // nf == desc → +INF (ASC default last / DESC default
16125        // first), nf != desc → -INF (the explicit flips).
16126        if matches!(v, Value::Null) {
16127            let nf = o.nulls_first.unwrap_or(o.desc);
16128            keys.push(if nf == o.desc {
16129                f64::INFINITY
16130            } else {
16131                f64::NEG_INFINITY
16132            });
16133        } else {
16134            keys.push(value_to_order_key(&v)?);
16135        }
16136    }
16137    Ok(keys)
16138}
16139
16140/// Drop the first `offset` rows then truncate to `limit`. PG / `MySQL`
16141/// agree: OFFSET applies *after* ORDER BY but *before* LIMIT (so
16142/// `LIMIT 10 OFFSET 5` keeps rows 6..=15).
16143fn apply_offset_and_limit(rows: &mut Vec<Row>, offset: Option<u32>, limit: Option<u32>) {
16144    if let Some(off) = offset {
16145        let off = off as usize;
16146        if off >= rows.len() {
16147            rows.clear();
16148        } else {
16149            rows.drain(..off);
16150        }
16151    }
16152    if let Some(n) = limit {
16153        rows.truncate(n as usize);
16154    }
16155}
16156
16157/// v7.17.0 Phase 3.P0-49 — offset + limit applied to a tagged
16158/// `(order_keys, row)` sequence, with optional SQL:2008 `WITH
16159/// TIES` extension. When `with_ties` is set, the truncated tail
16160/// is extended through every subsequent row whose order keys
16161/// equal the last-kept row's keys (so a "top 3 by score" with
16162/// WITH TIES emits row 4 too when row 4 ties row 3 on `score`).
16163///
16164/// The order-key vector is the per-row sort key the caller already
16165/// computed via `build_order_keys`; equal-key detection therefore
16166/// matches the sort comparator exactly.
16167fn apply_offset_and_limit_tagged(
16168    tagged: &mut Vec<(Vec<f64>, Row)>,
16169    offset: Option<u32>,
16170    limit: Option<u32>,
16171    with_ties: bool,
16172) {
16173    if let Some(off) = offset {
16174        let off = off as usize;
16175        if off >= tagged.len() {
16176            tagged.clear();
16177        } else {
16178            tagged.drain(..off);
16179        }
16180    }
16181    if let Some(n) = limit {
16182        let n = n as usize;
16183        if with_ties && n > 0 && n < tagged.len() {
16184            let cutoff_key = tagged[n - 1].0.clone();
16185            let mut end = n;
16186            while end < tagged.len() && tagged[end].0 == cutoff_key {
16187                end += 1;
16188            }
16189            tagged.truncate(end);
16190        } else {
16191            tagged.truncate(n);
16192        }
16193    }
16194}
16195
16196/// v7.17.0 Phase 3.P0-49 — PG-canonical: `FETCH FIRST <n> ROWS
16197/// WITH TIES` requires an `ORDER BY`. Without one, there's no
16198/// way to identify "ties" deterministically, so PG errors at
16199/// plan time. SPG mirrors that surface so the same DDL / app
16200/// behaviour holds on cutover.
16201fn check_with_ties_requires_order_by(stmt: &SelectStatement) -> Result<(), EngineError> {
16202    if stmt.limit_with_ties && stmt.order_by.is_empty() {
16203        return Err(EngineError::Unsupported(alloc::string::String::from(
16204            "FETCH FIRST … ROWS WITH TIES requires an ORDER BY clause",
16205        )));
16206    }
16207    Ok(())
16208}
16209
16210/// v7.6.1 — resolve a parser-level `ForeignKeyConstraint` (column
16211/// names + parent table name) into the storage-layer shape (column
16212/// indices + same parent table). Validates everything the engine
16213/// needs to know about the FK at CREATE TABLE time:
16214///
16215///   - parent table exists (catalog lookup, unless self-referencing)
16216///   - parent columns exist on the parent table
16217///   - parent column list matches the local arity (defaults to the
16218///     parent's primary index column when omitted)
16219///   - parent columns are covered by a `BTree` UNIQUE-class index
16220///     (SPG's stand-in for `PRIMARY KEY`/`UNIQUE`) — required so
16221///     the v7.6.2 INSERT path can do an O(log n) parent lookup
16222///   - local columns exist on the table being created
16223fn resolve_foreign_key(
16224    local_table_name: &str,
16225    local_cols: &[ColumnSchema],
16226    fk: spg_sql::ast::ForeignKeyConstraint,
16227    catalog: &Catalog,
16228) -> Result<spg_storage::ForeignKeyConstraint, EngineError> {
16229    // Resolve local columns.
16230    let mut local_columns = Vec::with_capacity(fk.columns.len());
16231    for name in &fk.columns {
16232        let pos = local_cols
16233            .iter()
16234            .position(|c| c.name == *name)
16235            .ok_or_else(|| {
16236                EngineError::Unsupported(alloc::format!(
16237                    "FOREIGN KEY references unknown local column {name:?}"
16238                ))
16239            })?;
16240        local_columns.push(pos);
16241    }
16242    // Self-referencing FK: parent table is the one we're creating.
16243    // The parent column resolution uses the local column list since
16244    // the catalog doesn't have this table yet.
16245    let is_self_ref = fk.parent_table == local_table_name;
16246    let (parent_cols_for_lookup, parent_table_str): (&[ColumnSchema], &str) = if is_self_ref {
16247        (local_cols, local_table_name)
16248    } else {
16249        let parent_table = catalog.get(&fk.parent_table).ok_or_else(|| {
16250            EngineError::Storage(StorageError::TableNotFound {
16251                name: fk.parent_table.clone(),
16252            })
16253        })?;
16254        (
16255            parent_table.schema().columns.as_slice(),
16256            fk.parent_table.as_str(),
16257        )
16258    };
16259    // Resolve parent column names → positions. If the FK omitted the
16260    // parent column list, fall back to the parent's primary index
16261    // column (single-column only — composite default is rejected
16262    // because there's no unambiguous "PK" in SPG's index list).
16263    let parent_columns: Vec<usize> = if fk.parent_columns.is_empty() {
16264        if fk.columns.len() != 1 {
16265            return Err(EngineError::Unsupported(
16266                "composite FOREIGN KEY without explicit parent column list is not supported \
16267                 — list the parent columns explicitly"
16268                    .into(),
16269            ));
16270        }
16271        // Find a single BTree index on the parent and use its column.
16272        let pos = pick_pk_index_column(catalog, parent_table_str, is_self_ref, local_cols)
16273            .ok_or_else(|| {
16274                EngineError::Unsupported(alloc::format!(
16275                    "parent table {parent_table_str:?} has no PRIMARY-key / UNIQUE BTree index \
16276                     to default the FOREIGN KEY against"
16277                ))
16278            })?;
16279        alloc::vec![pos]
16280    } else {
16281        let mut out = Vec::with_capacity(fk.parent_columns.len());
16282        for name in &fk.parent_columns {
16283            let pos = parent_cols_for_lookup
16284                .iter()
16285                .position(|c| c.name == *name)
16286                .ok_or_else(|| {
16287                    EngineError::Unsupported(alloc::format!(
16288                        "FOREIGN KEY references unknown parent column \
16289                         {name:?} on table {parent_table_str:?}"
16290                    ))
16291                })?;
16292            out.push(pos);
16293        }
16294        out
16295    };
16296    if parent_columns.len() != local_columns.len() {
16297        return Err(EngineError::Unsupported(alloc::format!(
16298            "FOREIGN KEY arity mismatch: {} local columns vs {} parent columns",
16299            local_columns.len(),
16300            parent_columns.len()
16301        )));
16302    }
16303    // For non-self-referencing FKs, verify the parent column set is
16304    // covered by a BTree index. SPG doesn't have a `PRIMARY KEY`
16305    // declaration; the convention is "the parent column for FK
16306    // purposes must have a BTree index" — which the user creates via
16307    // `CREATE INDEX ... USING btree (col)` (the default). We accept
16308    // any single-column BTree index that covers a parent column;
16309    // composite parent column lists require an index whose `column_position`
16310    // matches the first parent column (multi-column BTree indices
16311    // are not in the v7.x roadmap).
16312    if !is_self_ref {
16313        let parent_table = catalog.get(&fk.parent_table).expect("checked above");
16314        let primary_parent_col = parent_columns[0];
16315        let has_btree = parent_table
16316            .schema()
16317            .columns
16318            .get(primary_parent_col)
16319            .is_some()
16320            && parent_table.indices().iter().any(|idx| {
16321                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
16322                    && idx.column_position == primary_parent_col
16323                    && idx.partial_predicate.is_none()
16324            });
16325        if !has_btree {
16326            return Err(EngineError::Unsupported(alloc::format!(
16327                "FOREIGN KEY parent column on {:?} is not covered by an unconditional BTree \
16328                 index — create one with `CREATE INDEX ... ON {} ({})` first",
16329                parent_table_str,
16330                parent_table_str,
16331                parent_table.schema().columns[primary_parent_col].name,
16332            )));
16333        }
16334    }
16335    let on_delete = fk_action_sql_to_storage(fk.on_delete);
16336    let on_update = fk_action_sql_to_storage(fk.on_update);
16337    Ok(spg_storage::ForeignKeyConstraint {
16338        name: fk.name,
16339        local_columns,
16340        parent_table: fk.parent_table,
16341        parent_columns,
16342        on_delete,
16343        on_update,
16344    })
16345}
16346
16347/// v7.6.1 — pick a sentinel "primary key" column from the parent
16348/// table when the FK didn't name parent columns. Picks the first
16349/// single-column unconditional BTree index — that's the closest
16350/// thing SPG has to a PRIMARY KEY today. Self-referencing FKs use
16351/// `local_cols` as the column source.
16352fn pick_pk_index_column(
16353    catalog: &Catalog,
16354    parent_name: &str,
16355    is_self_ref: bool,
16356    local_cols: &[ColumnSchema],
16357) -> Option<usize> {
16358    if is_self_ref {
16359        // Self-ref FK omitted parent columns: pick column 0 by
16360        // convention (no catalog entry yet). Engine will widen this
16361        // when v7.6.7 lands; v7.6.1 only handles the explicit form.
16362        let _ = local_cols;
16363        return Some(0);
16364    }
16365    let parent = catalog.get(parent_name)?;
16366    parent.indices().iter().find_map(|idx| {
16367        if matches!(idx.kind, spg_storage::IndexKind::BTree(_))
16368            && idx.partial_predicate.is_none()
16369            && idx.included_columns.is_empty()
16370            && idx.expression.is_none()
16371        {
16372            Some(idx.column_position)
16373        } else {
16374            None
16375        }
16376    })
16377}
16378
16379/// v7.9.8 / v7.9.10 — resolve the column positions that
16380/// identify a conflict for ON CONFLICT. Returns a Vec of
16381/// column positions (1 element for single-column form, N for
16382/// composite). When the user wrote bare `ON CONFLICT DO …`,
16383/// falls back to the table's first unconditional BTree index
16384/// (always single-column today).
16385/// Returns the conflict-key column positions plus whether the
16386/// matched constraint declares NULLS NOT DISTINCT (v7.29 — a NULL
16387/// in the key only rules out a conflict under the default
16388/// NULLS DISTINCT semantics).
16389fn resolve_on_conflict_columns(
16390    catalog: &Catalog,
16391    table_name: &str,
16392    target: &[String],
16393) -> Result<(Vec<usize>, bool), EngineError> {
16394    let table = catalog.get(table_name).ok_or_else(|| {
16395        EngineError::Storage(StorageError::TableNotFound {
16396            name: table_name.into(),
16397        })
16398    })?;
16399    if target.is_empty() {
16400        // v7.13.2 — mailrs round-6 S5 follow-up. Composite UNIQUE
16401        // constraints carry a multi-column tuple; the prior code
16402        // path picked only the leading column of the first BTree
16403        // index, which caused `ON CONFLICT DO NOTHING` to dedup
16404        // by leading column alone (3 rows with same group_id but
16405        // different permission collapsed to 1). PG semantics use
16406        // the full tuple. Prefer a UniquenessConstraint's full
16407        // column list when one exists; fall back to the leading
16408        // BTree column for legacy single-column UNIQUE.
16409        if let Some(uc) = table.schema().uniqueness_constraints.first() {
16410            return Ok((uc.columns.clone(), uc.nulls_not_distinct));
16411        }
16412        let pos = table
16413            .indices()
16414            .iter()
16415            .find_map(|idx| {
16416                if matches!(idx.kind, spg_storage::IndexKind::BTree(_))
16417                    && idx.partial_predicate.is_none()
16418                    && idx.included_columns.is_empty()
16419                    && idx.expression.is_none()
16420                {
16421                    Some(idx.column_position)
16422                } else {
16423                    None
16424                }
16425            })
16426            .ok_or_else(|| {
16427                EngineError::Unsupported(alloc::format!(
16428                    "ON CONFLICT without target requires a UNIQUE BTree index on {table_name:?}"
16429                ))
16430            })?;
16431        return Ok((alloc::vec![pos], false));
16432    }
16433    let mut out = Vec::with_capacity(target.len());
16434    for name in target {
16435        let pos = table
16436            .schema()
16437            .columns
16438            .iter()
16439            .position(|c| c.name == *name)
16440            .ok_or_else(|| {
16441                EngineError::Unsupported(alloc::format!(
16442                    "ON CONFLICT target column {name:?} not found on {table_name:?}"
16443                ))
16444            })?;
16445        out.push(pos);
16446    }
16447    // An explicit target matching a UNIQUE constraint inherits its
16448    // NULLS [NOT] DISTINCT declaration.
16449    let mut sorted = out.clone();
16450    sorted.sort_unstable();
16451    let nnd = table.schema().uniqueness_constraints.iter().any(|uc| {
16452        let mut u = uc.columns.clone();
16453        u.sort_unstable();
16454        u == sorted && uc.nulls_not_distinct
16455    });
16456    Ok((out, nnd))
16457}
16458
16459/// v7.9.8 — check whether the BTree index on `column_pos` of
16460/// `table_name` already has a row with this key.
16461fn on_conflict_key_exists(
16462    catalog: &Catalog,
16463    table_name: &str,
16464    column_pos: usize,
16465    key: &Value,
16466) -> bool {
16467    let Some(table) = catalog.get(table_name) else {
16468        return false;
16469    };
16470    let Some(idx_key) = spg_storage::IndexKey::from_value(key) else {
16471        return false;
16472    };
16473    table.indices().iter().any(|idx| {
16474        matches!(idx.kind, spg_storage::IndexKind::BTree(_))
16475            && idx.column_position == column_pos
16476            && idx.partial_predicate.is_none()
16477            && !idx.lookup_eq(&idx_key).is_empty()
16478    })
16479}
16480
16481/// v7.9.9 / v7.9.10 — look up an existing row's position by
16482/// matching all `column_positions` against the incoming `key`
16483/// tuple. Single-column shape (one column) reduces to the
16484/// canonical PK lookup; composite shapes scan linearly until
16485/// every position matches.
16486fn lookup_row_position_by_keys(
16487    catalog: &Catalog,
16488    table_name: &str,
16489    column_positions: &[usize],
16490    key: &[&Value],
16491) -> Option<usize> {
16492    let table = catalog.get(table_name)?;
16493    table.rows().iter().position(|r| {
16494        column_positions
16495            .iter()
16496            .enumerate()
16497            .all(|(i, &pos)| r.values.get(pos) == Some(key[i]))
16498    })
16499}
16500
16501/// v7.9.10 — does the table already contain a row whose
16502/// `column_positions` tuple equals `key`? Single-column shape
16503/// uses the existing BTree fast path; composite shapes fall
16504/// back to a row scan.
16505fn on_conflict_keys_exist(
16506    catalog: &Catalog,
16507    table_name: &str,
16508    column_positions: &[usize],
16509    key: &[&Value],
16510) -> bool {
16511    if column_positions.len() == 1 {
16512        return on_conflict_key_exists(catalog, table_name, column_positions[0], key[0]);
16513    }
16514    let Some(table) = catalog.get(table_name) else {
16515        return false;
16516    };
16517    table.rows().iter().any(|r| {
16518        column_positions
16519            .iter()
16520            .enumerate()
16521            .all(|(i, &pos)| r.values.get(pos) == Some(key[i]))
16522    })
16523}
16524
16525/// v7.9.9 — apply ON CONFLICT DO UPDATE SET assignments to an
16526/// existing row.
16527///
16528/// `incoming` is the rejected INSERT row (used to resolve
16529/// `EXCLUDED.col` references in the assignment exprs);
16530/// `target_pos` is the position of the existing row in the table.
16531/// Each assignment substitutes `EXCLUDED.col` with the matching
16532/// incoming value, evaluates the resulting expression against
16533/// the existing row, and writes the new value into the
16534/// corresponding column of the returned `Vec<Value>`. If
16535/// `where_` evaluates falsy, returns Ok(None) — PG behaviour:
16536/// the conflicting row is silently kept unchanged.
16537fn apply_on_conflict_assignments(
16538    catalog: &Catalog,
16539    table_name: &str,
16540    target_pos: usize,
16541    incoming: &[Value],
16542    assignments: &[(String, Expr)],
16543    where_: Option<&Expr>,
16544) -> Result<Option<Vec<Value>>, EngineError> {
16545    let table = catalog.get(table_name).ok_or_else(|| {
16546        EngineError::Storage(StorageError::TableNotFound {
16547            name: table_name.into(),
16548        })
16549    })?;
16550    let schema_cols = table.schema().columns.clone();
16551    let existing = table
16552        .rows()
16553        .get(target_pos)
16554        .ok_or_else(|| {
16555            EngineError::Unsupported(alloc::format!(
16556                "ON CONFLICT DO UPDATE: row position {target_pos} out of bounds on {table_name:?}"
16557            ))
16558        })?
16559        .clone();
16560    let ctx = eval::EvalContext::new(&schema_cols, Some(table_name));
16561    // Optional WHERE filter on the conflict row.
16562    if let Some(w) = where_ {
16563        let pred = w.clone();
16564        let pred = substitute_excluded_refs(pred, &schema_cols, incoming);
16565        let v = eval::eval_expr(&pred, &existing, &ctx)?;
16566        if !matches!(v, Value::Bool(true)) {
16567            return Ok(None);
16568        }
16569    }
16570    let mut new_values = existing.values.clone();
16571    for (col_name, expr) in assignments {
16572        let target_idx = schema_cols
16573            .iter()
16574            .position(|c| c.name == *col_name)
16575            .ok_or_else(|| {
16576                EngineError::Eval(EvalError::ColumnNotFound {
16577                    name: col_name.clone(),
16578                })
16579            })?;
16580        let sub = substitute_excluded_refs(expr.clone(), &schema_cols, incoming);
16581        let v = eval::eval_expr(&sub, &existing, &ctx)?;
16582        let coerced = coerce_value(v, schema_cols[target_idx].ty, col_name, target_idx)?;
16583        check_unsigned_range(&coerced, &schema_cols[target_idx], target_idx)?;
16584        new_values[target_idx] = coerced;
16585    }
16586    Ok(Some(new_values))
16587}
16588
16589/// v7.9.9 — walk an `Expr` tree replacing any `Column { qualifier:
16590/// "EXCLUDED", name }` reference with a `Literal` of the matching
16591/// value from the incoming-row vec. Resolution against the
16592/// child-table column list (by name).
16593fn substitute_excluded_refs(expr: Expr, schema_cols: &[ColumnSchema], incoming: &[Value]) -> Expr {
16594    use spg_sql::ast::ColumnName;
16595    match expr {
16596        Expr::Column(ColumnName { qualifier, name })
16597            if qualifier
16598                .as_deref()
16599                .is_some_and(|q| q.eq_ignore_ascii_case("excluded")) =>
16600        {
16601            let pos = schema_cols.iter().position(|c| c.name == name);
16602            match pos {
16603                Some(p) => {
16604                    let v = incoming.get(p).cloned().unwrap_or(Value::Null);
16605                    value_to_literal_expr(v)
16606                        .unwrap_or_else(|_| Expr::Literal(spg_sql::ast::Literal::Null))
16607                }
16608                None => Expr::Column(ColumnName { qualifier, name }),
16609            }
16610        }
16611        Expr::Binary { op, lhs, rhs } => Expr::Binary {
16612            op,
16613            lhs: Box::new(substitute_excluded_refs(*lhs, schema_cols, incoming)),
16614            rhs: Box::new(substitute_excluded_refs(*rhs, schema_cols, incoming)),
16615        },
16616        Expr::Unary { op, expr } => Expr::Unary {
16617            op,
16618            expr: Box::new(substitute_excluded_refs(*expr, schema_cols, incoming)),
16619        },
16620        Expr::FunctionCall { name, args } => Expr::FunctionCall {
16621            name,
16622            args: args
16623                .into_iter()
16624                .map(|a| substitute_excluded_refs(a, schema_cols, incoming))
16625                .collect(),
16626        },
16627        other => other,
16628    }
16629}
16630
16631/// v7.6.2 / v7.6.7 — INSERT-side FK enforcement. For every row
16632/// about to be inserted into `child_table`, every FK declared on
16633/// that table is checked: the row's FK columns must either be
16634/// NULL (SQL spec skip) or match an existing parent row via the
16635/// parent's BTree PK / UNIQUE index.
16636///
16637/// Returns `EngineError::Unsupported` with a `FOREIGN KEY violation`
16638/// payload on first failure.
16639///
16640/// **Self-referencing FKs (v7.6.7 widening):** when `fk.parent_table
16641/// == child_table`, the parent rows visible to this check are
16642///  (a) rows already committed to the table, plus
16643///  (b) earlier rows from the *same* `rows` batch.
16644/// This makes `INSERT INTO tree VALUES (1, NULL), (2, 1), (3, 2)`
16645/// work in a single statement — common pattern for bulk-loading
16646/// hierarchies.
16647/// v7.9.19 — enforce table-level UNIQUE / PRIMARY KEY tuple
16648/// constraints at INSERT time. For each constraint declared on
16649/// the target table, check that no existing row + no earlier row
16650/// in the same batch has the same full-column tuple. NULL in
16651/// any column lifts the row out of the check (SQL spec: NULL
16652/// ≠ NULL for uniqueness). mailrs G1 + G6.
16653fn enforce_uniqueness_inserts(
16654    catalog: &Catalog,
16655    child_table: &str,
16656    constraints: &[spg_storage::UniquenessConstraint],
16657    rows: &[Vec<Value>],
16658) -> Result<(), EngineError> {
16659    if constraints.is_empty() {
16660        return Ok(());
16661    }
16662    let table = catalog.get(child_table).ok_or_else(|| {
16663        EngineError::Storage(StorageError::TableNotFound {
16664            name: child_table.into(),
16665        })
16666    })?;
16667    let schema = table.schema();
16668    // v7.29 (mailrs round-23b) — set-based: ONE O(table) pass folds
16669    // existing keys into a hash set, then each batch row is a probe
16670    // + insert. The previous shape scanned the WHOLE table per
16671    // inserted row (and earlier batch rows per row), which made
16672    // bulk import O(n²) — a 104 MB dump extrapolated to ~1 hour
16673    // (PG: 2 min). Collation folding (Phase 3.P0-45) and
16674    // NULLS [NOT] DISTINCT semantics are unchanged: keys fold via
16675    // collated_key_cell before encoding, NULL-bearing keys skip the
16676    // set unless nulls_not_distinct.
16677    for uc in constraints {
16678        let fold_key = |values: &[Value]| -> Vec<Value> {
16679            uc.columns
16680                .iter()
16681                .map(|&i| {
16682                    let v = values.get(i).cloned().unwrap_or(Value::Null);
16683                    collated_key_cell(&v, i, schema)
16684                })
16685                .collect()
16686        };
16687        let mut seen: hashbrown::HashSet<String> =
16688            hashbrown::HashSet::with_capacity(table.rows().len() + rows.len());
16689        for prow in table.rows() {
16690            let key = fold_key(&prow.values);
16691            if key.iter().any(|v| matches!(v, Value::Null)) && !uc.nulls_not_distinct {
16692                continue;
16693            }
16694            seen.insert(aggregate::encode_key(&key));
16695        }
16696        for (batch_idx, row_values) in rows.iter().enumerate() {
16697            let key = fold_key(row_values);
16698            if key.iter().any(|v| matches!(v, Value::Null)) && !uc.nulls_not_distinct {
16699                continue;
16700            }
16701            if !seen.insert(aggregate::encode_key(&key)) {
16702                let kind = if uc.is_primary_key {
16703                    "PRIMARY KEY"
16704                } else {
16705                    "UNIQUE"
16706                };
16707                let col_names: Vec<String> = uc
16708                    .columns
16709                    .iter()
16710                    .map(|&i| table.schema().columns[i].name.clone())
16711                    .collect();
16712                return Err(EngineError::Unsupported(alloc::format!(
16713                    "{kind} violation on {child_table:?} columns {col_names:?}: \
16714                     row #{batch_idx} duplicates an existing key"
16715                )));
16716            }
16717        }
16718    }
16719    Ok(())
16720}
16721
16722/// v7.17.0 Phase 3.P0-45 — return a key cell folded by its column's
16723/// declared `Collation`. For `CaseInsensitive`, fold Text payloads to
16724/// ASCII lowercase (matches Phase 2.5's `*_ci` semantics: ASCII case-
16725/// fold only, non-ASCII bytes stay byte-wise). For `Binary` or non-Text
16726/// values, the cell passes through unchanged. The caller compares the
16727/// folded values with `==`.
16728fn collated_key_cell(
16729    v: &spg_storage::Value,
16730    column_position: usize,
16731    schema: &spg_storage::TableSchema,
16732) -> spg_storage::Value {
16733    match (v, schema.columns.get(column_position).map(|c| c.collation)) {
16734        (spg_storage::Value::Text(s), Some(spg_storage::Collation::CaseInsensitive)) => {
16735            spg_storage::Value::Text(s.to_ascii_lowercase())
16736        }
16737        _ => v.clone(),
16738    }
16739}
16740
16741/// v7.9.29 — `true` iff `v` counts as a truthy SQL value for a
16742/// WHERE-style predicate. NULL → false (three-valued logic
16743/// collapses to "skip this row" for index inclusion). Numeric
16744/// non-zero, BIGINT non-zero, TINYINT non-zero, BOOLEAN true → true.
16745/// Everything else (strings, vectors, JSON, …) is not a valid
16746/// predicate result and surfaces as `false` so a malformed
16747/// predicate degrades to "row not in index" rather than panicking.
16748fn predicate_truthy(v: &spg_storage::Value) -> bool {
16749    use spg_storage::Value as V;
16750    match v {
16751        V::Bool(b) => *b,
16752        V::Int(n) => *n != 0,
16753        V::BigInt(n) => *n != 0,
16754        V::SmallInt(n) => *n != 0,
16755        _ => false,
16756    }
16757}
16758
16759/// v7.9.29 — at CREATE UNIQUE INDEX time, scan the table's
16760/// committed rows for pre-existing duplicates. If any pair of rows
16761/// matches the predicate AND has the same index key, refuse to
16762/// create the index so the user fixes the data before retrying.
16763fn check_existing_unique_violation(
16764    idx: &spg_storage::Index,
16765    schema: &spg_storage::TableSchema,
16766    rows: &[spg_storage::Row],
16767) -> Result<(), EngineError> {
16768    let predicate_expr = match idx.partial_predicate.as_deref() {
16769        Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
16770            EngineError::Unsupported(alloc::format!(
16771                "stored partial predicate {s:?} failed to re-parse: {e:?}"
16772            ))
16773        })?),
16774        None => None,
16775    };
16776    let ctx = eval::EvalContext::new(&schema.columns, None);
16777    let key_positions = unique_key_positions(idx);
16778    let mut seen: alloc::vec::Vec<alloc::vec::Vec<spg_storage::Value>> = alloc::vec::Vec::new();
16779    for row in rows {
16780        if let Some(expr) = &predicate_expr {
16781            let v = eval::eval_expr(expr, row, &ctx).map_err(|e| {
16782                EngineError::Unsupported(alloc::format!(
16783                    "evaluating UNIQUE INDEX predicate against existing row: {e:?}"
16784                ))
16785            })?;
16786            if !predicate_truthy(&v) {
16787                continue;
16788            }
16789        }
16790        let key: alloc::vec::Vec<spg_storage::Value> = key_positions
16791            .iter()
16792            .map(|&p| {
16793                let v = row
16794                    .values
16795                    .get(p)
16796                    .cloned()
16797                    .unwrap_or(spg_storage::Value::Null);
16798                collated_key_cell(&v, p, schema)
16799            })
16800            .collect();
16801        if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
16802            continue;
16803        }
16804        if seen.iter().any(|other| *other == key) {
16805            return Err(EngineError::Unsupported(alloc::format!(
16806                "CREATE UNIQUE INDEX {:?}: existing rows already violate the constraint",
16807                idx.name
16808            )));
16809        }
16810        seen.push(key);
16811    }
16812    Ok(())
16813}
16814
16815/// v7.9.29 — full key tuple for a UNIQUE INDEX (leading +
16816/// extra positions). For single-column indexes this is just
16817/// `[column_position]`.
16818fn unique_key_positions(idx: &spg_storage::Index) -> alloc::vec::Vec<usize> {
16819    let mut out = alloc::vec::Vec::with_capacity(1 + idx.extra_column_positions.len());
16820    out.push(idx.column_position);
16821    out.extend_from_slice(&idx.extra_column_positions);
16822    out
16823}
16824
16825/// v7.9.29 — at INSERT time, walk every `is_unique` index on the
16826/// target table. For each, eval the index's optional predicate
16827/// against (a) the candidate row and (b) every committed row plus
16828/// earlier batch rows; only rows where the predicate is truthy
16829/// participate. A duplicate key among predicate-matching rows is a
16830/// uniqueness violation. NULL keys lift the row out of the check
16831/// (matching PG's "UNIQUE allows multiple NULLs" semantics).
16832fn enforce_unique_index_inserts(
16833    catalog: &Catalog,
16834    table_name: &str,
16835    rows: &[alloc::vec::Vec<spg_storage::Value>],
16836) -> Result<(), EngineError> {
16837    let table = catalog.get(table_name).ok_or_else(|| {
16838        EngineError::Storage(StorageError::TableNotFound {
16839            name: table_name.into(),
16840        })
16841    })?;
16842    let schema = table.schema();
16843    let ctx = eval::EvalContext::new(&schema.columns, None);
16844    for idx in table.indices() {
16845        if !idx.is_unique {
16846            continue;
16847        }
16848        // Re-parse the predicate once per index per batch.
16849        let predicate_expr = match idx.partial_predicate.as_deref() {
16850            Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
16851                EngineError::Unsupported(alloc::format!(
16852                    "UNIQUE INDEX {:?} predicate {s:?} failed to re-parse: {e:?}",
16853                    idx.name
16854                ))
16855            })?),
16856            None => None,
16857        };
16858        let key_positions = unique_key_positions(idx);
16859        let key_of = |values: &[spg_storage::Value]| -> alloc::vec::Vec<spg_storage::Value> {
16860            key_positions
16861                .iter()
16862                .map(|&p| {
16863                    let v = values.get(p).cloned().unwrap_or(spg_storage::Value::Null);
16864                    collated_key_cell(&v, p, schema)
16865                })
16866                .collect()
16867        };
16868        let participates = |values: &[spg_storage::Value]| -> Result<bool, EngineError> {
16869            let Some(expr) = &predicate_expr else {
16870                return Ok(true);
16871            };
16872            let tmp_row = spg_storage::Row {
16873                values: values.to_vec(),
16874            };
16875            let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
16876                EngineError::Unsupported(alloc::format!(
16877                    "UNIQUE INDEX {:?} predicate eval: {e:?}",
16878                    idx.name
16879                ))
16880            })?;
16881            Ok(predicate_truthy(&v))
16882        };
16883        // v7.29 (mailrs round-23b) — set-based: one O(table) pass
16884        // (predicate evaluated once per existing row instead of once
16885        // per row PAIR), then probe per batch row. The previous
16886        // nested scans made bulk import O(n²).
16887        let mut seen: hashbrown::HashSet<String> =
16888            hashbrown::HashSet::with_capacity(table.rows().len() + rows.len());
16889        for prow in table.rows() {
16890            if !participates(&prow.values)? {
16891                continue;
16892            }
16893            let key = key_of(&prow.values);
16894            if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
16895                continue;
16896            }
16897            seen.insert(aggregate::encode_key(&key));
16898        }
16899        for (batch_idx, row_values) in rows.iter().enumerate() {
16900            if !participates(row_values)? {
16901                continue;
16902            }
16903            let key = key_of(row_values);
16904            if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
16905                continue;
16906            }
16907            if !seen.insert(aggregate::encode_key(&key)) {
16908                return Err(EngineError::Unsupported(alloc::format!(
16909                    "UNIQUE INDEX {:?} violation on {table_name:?}: \
16910                     row #{batch_idx} duplicates an existing key",
16911                    idx.name
16912                )));
16913            }
16914        }
16915    }
16916    Ok(())
16917}
16918
16919/// v7.13.0 — `UPDATE OF cols` filter helper (mailrs round-5 G7).
16920/// Returns `true` when at least one of `filter_cols` has a
16921/// different value in `new_row` vs `old_row`. Column lookup is
16922/// case-insensitive against `schema_cols`; unknown filter columns
16923/// are treated as "not changed" (the trigger therefore won't
16924/// fire on them — surfacing a parse-time error would be too
16925/// strict for catalog reloads where the schema may have drifted).
16926fn any_column_changed(
16927    filter_cols: &[String],
16928    schema_cols: &[ColumnSchema],
16929    old_row: &Row,
16930    new_row: &Row,
16931) -> bool {
16932    for col_name in filter_cols {
16933        let Some(pos) = schema_cols
16934            .iter()
16935            .position(|c| c.name.eq_ignore_ascii_case(col_name))
16936        else {
16937            continue;
16938        };
16939        let old_v = old_row.values.get(pos);
16940        let new_v = new_row.values.get(pos);
16941        if old_v != new_v {
16942            return true;
16943        }
16944    }
16945    false
16946}
16947
16948/// v7.13.0 — evaluate every CHECK predicate on the schema against
16949/// each candidate row. Mirrors PG semantics: a `false` result
16950/// rejects the mutation; a NULL result *passes* (CHECK rejects
16951/// only on definite-false, not on unknown). mailrs round-5 G3.
16952fn enforce_check_constraints(
16953    catalog: &Catalog,
16954    table_name: &str,
16955    rows: &[alloc::vec::Vec<spg_storage::Value>],
16956) -> Result<(), EngineError> {
16957    let table = catalog.get(table_name).ok_or_else(|| {
16958        EngineError::Storage(StorageError::TableNotFound {
16959            name: table_name.into(),
16960        })
16961    })?;
16962    let schema = table.schema();
16963    // v7.17.0 Phase 1.5 — domain-level CHECKs are enforced in
16964    // parallel with table-level CHECKs. Collect both lists up
16965    // front; if neither exists we early-out.
16966    let mut domain_checks_per_col: alloc::vec::Vec<(usize, alloc::vec::Vec<Expr>)> =
16967        alloc::vec::Vec::new();
16968    for (idx, col) in schema.columns.iter().enumerate() {
16969        let Some(dname) = &col.user_domain_type else {
16970            continue;
16971        };
16972        let Some(dom) = catalog.domain_types().get(dname) else {
16973            continue;
16974        };
16975        let mut parsed_for_col: alloc::vec::Vec<Expr> =
16976            alloc::vec::Vec::with_capacity(dom.checks.len());
16977        for src in &dom.checks {
16978            let expr = spg_sql::parser::parse_expression(src).map_err(|e| {
16979                EngineError::Unsupported(alloc::format!(
16980                    "DOMAIN {dname:?} CHECK ({src:?}) on column {:?}: re-parse failed: {e:?}",
16981                    col.name
16982                ))
16983            })?;
16984            parsed_for_col.push(expr);
16985        }
16986        if !parsed_for_col.is_empty() {
16987            domain_checks_per_col.push((idx, parsed_for_col));
16988        }
16989    }
16990    if schema.checks.is_empty() && domain_checks_per_col.is_empty() {
16991        return Ok(());
16992    }
16993    let ctx = eval::EvalContext::new(&schema.columns, None);
16994    let mut parsed: alloc::vec::Vec<(usize, Expr)> = alloc::vec::Vec::new();
16995    for (i, src) in schema.checks.iter().enumerate() {
16996        let expr = spg_sql::parser::parse_expression(src).map_err(|e| {
16997            EngineError::Unsupported(alloc::format!(
16998                "CHECK constraint #{i} on {table_name:?} ({src:?}) failed to re-parse: {e:?}"
16999            ))
17000        })?;
17001        parsed.push((i, expr));
17002    }
17003    for (batch_idx, row_values) in rows.iter().enumerate() {
17004        let tmp_row = spg_storage::Row {
17005            values: row_values.clone(),
17006        };
17007        for (i, expr) in &parsed {
17008            let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
17009                EngineError::Unsupported(alloc::format!(
17010                    "CHECK constraint #{i} on {table_name:?} eval at row #{batch_idx}: {e:?}"
17011                ))
17012            })?;
17013            // PG: NULL passes (CHECK rejects on definite-false only).
17014            if matches!(v, spg_storage::Value::Bool(false)) {
17015                return Err(EngineError::Unsupported(alloc::format!(
17016                    "CHECK constraint violation on {table_name:?} (row #{batch_idx}): {:?}",
17017                    schema.checks[*i]
17018                )));
17019            }
17020        }
17021        // v7.17.0 Phase 1.5 — domain-level CHECKs. Each CHECK
17022        // expression references VALUE as a column-name; we
17023        // substitute the per-row cell into the eval context by
17024        // synthesising a single-column row of just that value
17025        // under a temporary `value` column schema.
17026        for (col_idx, checks) in &domain_checks_per_col {
17027            let cell = row_values
17028                .get(*col_idx)
17029                .cloned()
17030                .unwrap_or(spg_storage::Value::Null);
17031            let synth_cols = alloc::vec![spg_storage::ColumnSchema::new(
17032                "value",
17033                schema.columns[*col_idx].ty,
17034                schema.columns[*col_idx].nullable,
17035            )];
17036            let synth_ctx = eval::EvalContext::new(&synth_cols, None);
17037            let synth_row = spg_storage::Row {
17038                values: alloc::vec![cell],
17039            };
17040            for (ci, expr) in checks.iter().enumerate() {
17041                let v = eval::eval_expr(expr, &synth_row, &synth_ctx).map_err(|e| {
17042                    EngineError::Unsupported(alloc::format!(
17043                        "DOMAIN CHECK #{ci} on column {:?} eval at row #{batch_idx}: {e:?}",
17044                        schema.columns[*col_idx].name
17045                    ))
17046                })?;
17047                if matches!(v, spg_storage::Value::Bool(false)) {
17048                    return Err(EngineError::Unsupported(alloc::format!(
17049                        "DOMAIN CHECK violation on column {:?} (row #{batch_idx})",
17050                        schema.columns[*col_idx].name
17051                    )));
17052                }
17053            }
17054        }
17055    }
17056    Ok(())
17057}
17058
17059fn enforce_fk_inserts(
17060    catalog: &Catalog,
17061    child_table: &str,
17062    fks: &[spg_storage::ForeignKeyConstraint],
17063    rows: &[Vec<Value>],
17064) -> Result<(), EngineError> {
17065    for fk in fks {
17066        let parent_is_self = fk.parent_table == child_table;
17067        let parent = if parent_is_self {
17068            // Self-ref: read the current state of the same table.
17069            // The mut borrow on child has been dropped by the caller.
17070            catalog.get(child_table).ok_or_else(|| {
17071                EngineError::Storage(StorageError::TableNotFound {
17072                    name: child_table.into(),
17073                })
17074            })?
17075        } else {
17076            catalog.get(&fk.parent_table).ok_or_else(|| {
17077                EngineError::Storage(StorageError::TableNotFound {
17078                    name: fk.parent_table.clone(),
17079                })
17080            })?
17081        };
17082        for (batch_idx, row_values) in rows.iter().enumerate() {
17083            // Single-column FK fast path: try the parent's BTree
17084            // index for an O(log n) lookup. Composite FKs fall back
17085            // to a parent-row scan.
17086            if fk.local_columns.len() == 1 {
17087                let v = &row_values[fk.local_columns[0]];
17088                if matches!(v, Value::Null) {
17089                    continue;
17090                }
17091                let parent_col = fk.parent_columns[0];
17092                let key = spg_storage::IndexKey::from_value(v).ok_or_else(|| {
17093                    EngineError::Unsupported(alloc::format!(
17094                        "FOREIGN KEY column value of type {:?} is not index-eligible",
17095                        v.data_type()
17096                    ))
17097                })?;
17098                let present_committed = parent.indices().iter().any(|idx| {
17099                    matches!(idx.kind, spg_storage::IndexKind::BTree(_))
17100                        && idx.column_position == parent_col
17101                        && idx.partial_predicate.is_none()
17102                        && !idx.lookup_eq(&key).is_empty()
17103                });
17104                // v7.6.7 self-ref widening: also accept a match
17105                // against earlier rows in this same batch when the
17106                // FK points at the table being inserted into.
17107                let present_in_batch = parent_is_self
17108                    && rows[..batch_idx]
17109                        .iter()
17110                        .any(|earlier| earlier.get(parent_col) == Some(v));
17111                if !(present_committed || present_in_batch) {
17112                    return Err(EngineError::Unsupported(alloc::format!(
17113                        "FOREIGN KEY violation: no parent row in {:?} where {} = {:?}",
17114                        fk.parent_table,
17115                        parent
17116                            .schema()
17117                            .columns
17118                            .get(parent_col)
17119                            .map_or("?", |c| c.name.as_str()),
17120                        v,
17121                    )));
17122                }
17123            } else {
17124                // Composite FK: scan parent rows. v7.6.7 also
17125                // accepts a match against earlier rows in the same
17126                // batch (self-ref bulk-loading of hierarchies).
17127                if fk
17128                    .local_columns
17129                    .iter()
17130                    .all(|&i| matches!(row_values.get(i), Some(Value::Null)))
17131                {
17132                    continue;
17133                }
17134                let local: Vec<&Value> = fk.local_columns.iter().map(|&i| &row_values[i]).collect();
17135                let parent_match_committed = parent.rows().iter().any(|prow| {
17136                    fk.parent_columns
17137                        .iter()
17138                        .enumerate()
17139                        .all(|(i, &pi)| prow.values.get(pi) == Some(local[i]))
17140                });
17141                let parent_match_in_batch = parent_is_self
17142                    && rows[..batch_idx].iter().any(|earlier| {
17143                        fk.parent_columns
17144                            .iter()
17145                            .enumerate()
17146                            .all(|(i, &pi)| earlier.get(pi) == Some(local[i]))
17147                    });
17148                if !(parent_match_committed || parent_match_in_batch) {
17149                    return Err(EngineError::Unsupported(alloc::format!(
17150                        "FOREIGN KEY violation: no parent row in {:?} matching composite key",
17151                        fk.parent_table,
17152                    )));
17153                }
17154            }
17155        }
17156    }
17157    Ok(())
17158}
17159
17160/// v7.6.4 / v7.6.5 — one step of the FK action plan computed for a
17161/// DELETE on a parent. The plan is a list of these steps, stacked
17162/// across the FK graph by `plan_fk_parent_deletions`.
17163#[derive(Debug, Clone)]
17164struct FkChildStep {
17165    child_table: String,
17166    action: FkChildAction,
17167}
17168
17169#[derive(Debug, Clone)]
17170enum FkChildAction {
17171    /// CASCADE — remove these rows. Sorted, deduplicated positions.
17172    Delete { positions: Vec<usize> },
17173    /// SET NULL — for each (row, column) in the flat list, write
17174    /// NULL into that child cell. Multiple FKs on the same row may
17175    /// produce overlapping entries (deduped at plan time).
17176    SetNull {
17177        positions: Vec<usize>,
17178        columns: Vec<usize>,
17179    },
17180    /// SET DEFAULT — same shape as SetNull but writes the column's
17181    /// declared DEFAULT value (resolved at plan time). Columns
17182    /// without a DEFAULT raise an error during planning.
17183    SetDefault {
17184        positions: Vec<usize>,
17185        columns: Vec<usize>,
17186        defaults: Vec<Value>,
17187    },
17188}
17189
17190/// v7.6.3 → v7.6.5 — plan FK fallout for a DELETE on a parent table.
17191///
17192/// Walks every table in the catalog looking for FKs whose
17193/// `parent_table` is `parent_table_name`. For each such FK + each
17194/// to-be-deleted parent row:
17195///
17196///   - RESTRICT / NoAction → error, no plan returned
17197///   - CASCADE → child rows get scheduled for deletion; recursive
17198///   - SetNull → child FK column(s) scheduled to be NULL-ed.
17199///     Verified NULL-able at plan time.
17200///   - SetDefault → child FK column(s) scheduled to be reset to
17201///     their declared DEFAULT. Columns without a DEFAULT raise.
17202///
17203/// SET NULL / SET DEFAULT do NOT cascade further — the child row
17204/// stays; only one of its columns mutates.
17205fn plan_fk_parent_deletions(
17206    catalog: &Catalog,
17207    parent_table_name: &str,
17208    to_delete_positions: &[usize],
17209    to_delete_rows: &[Vec<Value>],
17210) -> Result<Vec<FkChildStep>, EngineError> {
17211    use alloc::collections::{BTreeMap, BTreeSet};
17212    if to_delete_rows.is_empty() {
17213        return Ok(Vec::new());
17214    }
17215    let mut delete_plan: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
17216    // setnull / setdefault keyed by child_table → (row_idx, col_idx) → optional default
17217    let mut setnull_plan: BTreeMap<String, BTreeSet<(usize, usize)>> = BTreeMap::new();
17218    let mut setdefault_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
17219    let mut visited: BTreeSet<(String, usize)> = BTreeSet::new();
17220    for &p in to_delete_positions {
17221        visited.insert((parent_table_name.to_string(), p));
17222    }
17223    let mut work: Vec<(String, Vec<Value>)> = to_delete_rows
17224        .iter()
17225        .map(|r| (parent_table_name.to_string(), r.clone()))
17226        .collect();
17227    while let Some((cur_parent, parent_row)) = work.pop() {
17228        for child_name in catalog.table_names() {
17229            let child = catalog
17230                .get(&child_name)
17231                .expect("table_names → catalog.get round-trip is total");
17232            for fk in &child.schema().foreign_keys {
17233                if fk.parent_table != cur_parent {
17234                    continue;
17235                }
17236                let parent_key: Vec<&Value> = fk
17237                    .parent_columns
17238                    .iter()
17239                    .map(|&pi| &parent_row[pi])
17240                    .collect();
17241                if parent_key.iter().any(|v| matches!(v, Value::Null)) {
17242                    continue;
17243                }
17244                for (child_row_idx, child_row) in child.rows().iter().enumerate() {
17245                    if child_name == cur_parent
17246                        && visited.contains(&(child_name.clone(), child_row_idx))
17247                    {
17248                        continue;
17249                    }
17250                    let matches_key = fk
17251                        .local_columns
17252                        .iter()
17253                        .enumerate()
17254                        .all(|(i, &li)| child_row.values.get(li) == Some(parent_key[i]));
17255                    if !matches_key {
17256                        continue;
17257                    }
17258                    match fk.on_delete {
17259                        spg_storage::FkAction::Restrict | spg_storage::FkAction::NoAction => {
17260                            return Err(EngineError::Unsupported(alloc::format!(
17261                                "FOREIGN KEY violation: DELETE on {cur_parent:?} is \
17262                                 restricted by FK from {child_name:?}.{:?}",
17263                                fk.local_columns,
17264                            )));
17265                        }
17266                        spg_storage::FkAction::Cascade => {
17267                            if visited.insert((child_name.clone(), child_row_idx)) {
17268                                delete_plan
17269                                    .entry(child_name.clone())
17270                                    .or_default()
17271                                    .insert(child_row_idx);
17272                                work.push((child_name.clone(), child_row.values.clone()));
17273                            }
17274                        }
17275                        spg_storage::FkAction::SetNull => {
17276                            // Verify every local FK column is NULL-able.
17277                            for &li in &fk.local_columns {
17278                                let col = child.schema().columns.get(li).ok_or_else(|| {
17279                                    EngineError::Unsupported(alloc::format!(
17280                                        "FK local column {li} missing in {child_name:?}"
17281                                    ))
17282                                })?;
17283                                if !col.nullable {
17284                                    return Err(EngineError::Unsupported(alloc::format!(
17285                                        "FOREIGN KEY ON DELETE SET NULL: column \
17286                                         {child_name:?}.{:?} is NOT NULL — cannot SET NULL",
17287                                        col.name,
17288                                    )));
17289                                }
17290                            }
17291                            let entry = setnull_plan.entry(child_name.clone()).or_default();
17292                            for &li in &fk.local_columns {
17293                                entry.insert((child_row_idx, li));
17294                            }
17295                        }
17296                        spg_storage::FkAction::SetDefault => {
17297                            // Resolve the DEFAULT for every local FK col.
17298                            let entry = setdefault_plan.entry(child_name.clone()).or_default();
17299                            for &li in &fk.local_columns {
17300                                let col = child.schema().columns.get(li).ok_or_else(|| {
17301                                    EngineError::Unsupported(alloc::format!(
17302                                        "FK local column {li} missing in {child_name:?}"
17303                                    ))
17304                                })?;
17305                                let default = col.default.clone().ok_or_else(|| {
17306                                    EngineError::Unsupported(alloc::format!(
17307                                        "FOREIGN KEY ON DELETE SET DEFAULT: column \
17308                                         {child_name:?}.{:?} has no DEFAULT declared",
17309                                        col.name,
17310                                    ))
17311                                })?;
17312                                entry.insert((child_row_idx, li), default);
17313                            }
17314                        }
17315                    }
17316                }
17317            }
17318        }
17319    }
17320    // Flatten the three plans into the ordered `FkChildStep` list.
17321    // Deletes are applied last per child (after any null/default
17322    // re-writes on the same child) so a child row that's both
17323    // re-written and then cascade-deleted only ends up deleted —
17324    // but in v7.6.5 SetNull/Cascade never overlap on the same row
17325    // (a single FK chooses exactly one action), so the order is
17326    // mostly a precaution.
17327    let mut steps: Vec<FkChildStep> = Vec::new();
17328    for (child_table, entries) in setnull_plan {
17329        let (positions, columns): (Vec<usize>, Vec<usize>) = entries.into_iter().unzip();
17330        steps.push(FkChildStep {
17331            child_table,
17332            action: FkChildAction::SetNull { positions, columns },
17333        });
17334    }
17335    for (child_table, entries) in setdefault_plan {
17336        let mut positions = Vec::with_capacity(entries.len());
17337        let mut columns = Vec::with_capacity(entries.len());
17338        let mut defaults = Vec::with_capacity(entries.len());
17339        for ((p, c), v) in entries {
17340            positions.push(p);
17341            columns.push(c);
17342            defaults.push(v);
17343        }
17344        steps.push(FkChildStep {
17345            child_table,
17346            action: FkChildAction::SetDefault {
17347                positions,
17348                columns,
17349                defaults,
17350            },
17351        });
17352    }
17353    for (child_table, positions) in delete_plan {
17354        steps.push(FkChildStep {
17355            child_table,
17356            action: FkChildAction::Delete {
17357                positions: positions.into_iter().collect(),
17358            },
17359        });
17360    }
17361    Ok(steps)
17362}
17363
17364/// v7.6.6 — plan FK fallout for an UPDATE that mutates parent-side
17365/// PK/UNIQUE columns. Walks every other table whose FK references
17366/// `parent_table_name`; for each FK whose parent_columns overlap a
17367/// mutated column, decides the action by `fk.on_update`.
17368///
17369///   - RESTRICT / NoAction → error if any child references the OLD
17370///     value
17371///   - CASCADE → child FK columns get rewritten to the NEW parent
17372///     value (a SetNull-style update step with the new value)
17373///   - SetNull → child FK columns set to NULL
17374///   - SetDefault → child FK columns set to declared default
17375///
17376/// `plan_with_old` is `(row_position, old_values, new_values)` so
17377/// the planner can detect "did this row's parent key actually
17378/// change?" — only rows where at least one referenced parent
17379/// column moved trigger inbound work.
17380fn plan_fk_parent_updates(
17381    catalog: &Catalog,
17382    parent_table_name: &str,
17383    plan_with_old: &[(usize, Vec<Value>, Vec<Value>)],
17384) -> Result<Vec<FkChildStep>, EngineError> {
17385    use alloc::collections::BTreeMap;
17386    if plan_with_old.is_empty() {
17387        return Ok(Vec::new());
17388    }
17389    // For each child table we may touch, build per-child step
17390    // lists. UPDATE never deletes children — `delete_plan` stays
17391    // empty here but is kept structurally aligned with
17392    // `plan_fk_parent_deletions` for future use.
17393    let delete_plan: BTreeMap<String, alloc::collections::BTreeSet<usize>> = BTreeMap::new();
17394    let mut setnull_plan: BTreeMap<String, alloc::collections::BTreeSet<(usize, usize)>> =
17395        BTreeMap::new();
17396    let mut setdefault_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
17397    // Cascade-update plan: child_table → row_idx → col_idx → new_value
17398    let mut cascade_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
17399
17400    for child_name in catalog.table_names() {
17401        let child = catalog
17402            .get(&child_name)
17403            .expect("table_names → catalog.get total");
17404        for fk in &child.schema().foreign_keys {
17405            if fk.parent_table != parent_table_name {
17406                continue;
17407            }
17408            for (_pos, old_row, new_row) in plan_with_old {
17409                // Did any parent FK column change?
17410                let key_changed = fk
17411                    .parent_columns
17412                    .iter()
17413                    .any(|&pi| old_row.get(pi) != new_row.get(pi));
17414                if !key_changed {
17415                    continue;
17416                }
17417                // The OLD parent key — used to find referring children.
17418                let old_key: Vec<&Value> =
17419                    fk.parent_columns.iter().map(|&pi| &old_row[pi]).collect();
17420                if old_key.iter().any(|v| matches!(v, Value::Null)) {
17421                    // NULL parent has no children — skip.
17422                    continue;
17423                }
17424                let new_key: Vec<&Value> =
17425                    fk.parent_columns.iter().map(|&pi| &new_row[pi]).collect();
17426                for (child_row_idx, child_row) in child.rows().iter().enumerate() {
17427                    // Self-ref same-row updates: a row updating its
17428                    // own PK doesn't restrict itself.
17429                    if child_name == parent_table_name
17430                        && plan_with_old.iter().any(|(p, _, _)| *p == child_row_idx)
17431                    {
17432                        continue;
17433                    }
17434                    let matches_key = fk
17435                        .local_columns
17436                        .iter()
17437                        .enumerate()
17438                        .all(|(i, &li)| child_row.values.get(li) == Some(old_key[i]));
17439                    if !matches_key {
17440                        continue;
17441                    }
17442                    match fk.on_update {
17443                        spg_storage::FkAction::Restrict | spg_storage::FkAction::NoAction => {
17444                            return Err(EngineError::Unsupported(alloc::format!(
17445                                "FOREIGN KEY violation: UPDATE on {parent_table_name:?} PK is \
17446                                 restricted by FK from {child_name:?}.{:?}",
17447                                fk.local_columns,
17448                            )));
17449                        }
17450                        spg_storage::FkAction::Cascade => {
17451                            // Rewrite child FK columns to new key.
17452                            let entry = cascade_plan.entry(child_name.clone()).or_default();
17453                            for (i, &li) in fk.local_columns.iter().enumerate() {
17454                                entry.insert((child_row_idx, li), new_key[i].clone());
17455                            }
17456                        }
17457                        spg_storage::FkAction::SetNull => {
17458                            for &li in &fk.local_columns {
17459                                let col = child.schema().columns.get(li).ok_or_else(|| {
17460                                    EngineError::Unsupported(alloc::format!(
17461                                        "FK local column {li} missing in {child_name:?}"
17462                                    ))
17463                                })?;
17464                                if !col.nullable {
17465                                    return Err(EngineError::Unsupported(alloc::format!(
17466                                        "FOREIGN KEY ON UPDATE SET NULL: column \
17467                                         {child_name:?}.{:?} is NOT NULL",
17468                                        col.name,
17469                                    )));
17470                                }
17471                            }
17472                            let entry = setnull_plan.entry(child_name.clone()).or_default();
17473                            for &li in &fk.local_columns {
17474                                entry.insert((child_row_idx, li));
17475                            }
17476                        }
17477                        spg_storage::FkAction::SetDefault => {
17478                            let entry = setdefault_plan.entry(child_name.clone()).or_default();
17479                            for &li in &fk.local_columns {
17480                                let col = child.schema().columns.get(li).ok_or_else(|| {
17481                                    EngineError::Unsupported(alloc::format!(
17482                                        "FK local column {li} missing in {child_name:?}"
17483                                    ))
17484                                })?;
17485                                let default = col.default.clone().ok_or_else(|| {
17486                                    EngineError::Unsupported(alloc::format!(
17487                                        "FOREIGN KEY ON UPDATE SET DEFAULT: column \
17488                                         {child_name:?}.{:?} has no DEFAULT",
17489                                        col.name,
17490                                    ))
17491                                })?;
17492                                entry.insert((child_row_idx, li), default);
17493                            }
17494                        }
17495                    }
17496                }
17497            }
17498        }
17499    }
17500    // Flatten into FkChildStep list. UPDATE doesn't produce
17501    // DeleteSteps (CASCADE on UPDATE just rewrites FK values).
17502    let mut steps: Vec<FkChildStep> = Vec::new();
17503    for (child_table, entries) in cascade_plan {
17504        let mut positions = Vec::with_capacity(entries.len());
17505        let mut columns = Vec::with_capacity(entries.len());
17506        let mut defaults = Vec::with_capacity(entries.len());
17507        for ((p, c), v) in entries {
17508            positions.push(p);
17509            columns.push(c);
17510            defaults.push(v);
17511        }
17512        // We reuse `FkChildAction::SetDefault` for cascade-update:
17513        // both shapes are "write a known value into specific cells"
17514        // — `apply_per_cell_writes` doesn't care whether the value
17515        // came from a DEFAULT declaration or a new parent key.
17516        steps.push(FkChildStep {
17517            child_table,
17518            action: FkChildAction::SetDefault {
17519                positions,
17520                columns,
17521                defaults,
17522            },
17523        });
17524    }
17525    for (child_table, entries) in setnull_plan {
17526        let (positions, columns): (Vec<usize>, Vec<usize>) = entries.into_iter().unzip();
17527        steps.push(FkChildStep {
17528            child_table,
17529            action: FkChildAction::SetNull { positions, columns },
17530        });
17531    }
17532    for (child_table, entries) in setdefault_plan {
17533        let mut positions = Vec::with_capacity(entries.len());
17534        let mut columns = Vec::with_capacity(entries.len());
17535        let mut defaults = Vec::with_capacity(entries.len());
17536        for ((p, c), v) in entries {
17537            positions.push(p);
17538            columns.push(c);
17539            defaults.push(v);
17540        }
17541        steps.push(FkChildStep {
17542            child_table,
17543            action: FkChildAction::SetDefault {
17544                positions,
17545                columns,
17546                defaults,
17547            },
17548        });
17549    }
17550    let _ = delete_plan; // UPDATE never deletes children.
17551    Ok(steps)
17552}
17553
17554/// v7.6.5 — apply one FK child step to the catalog. Encapsulates
17555/// the three action variants so the DELETE executor stays a
17556/// simple loop over the planned steps.
17557fn apply_fk_child_step(catalog: &mut Catalog, step: &FkChildStep) -> Result<(), EngineError> {
17558    let child = catalog.get_mut(&step.child_table).ok_or_else(|| {
17559        EngineError::Storage(StorageError::TableNotFound {
17560            name: step.child_table.clone(),
17561        })
17562    })?;
17563    match &step.action {
17564        FkChildAction::Delete { positions } => {
17565            let _ = child.delete_rows(positions);
17566        }
17567        FkChildAction::SetNull { positions, columns } => {
17568            apply_per_cell_writes(child, positions, columns, |_| Value::Null)?;
17569        }
17570        FkChildAction::SetDefault {
17571            positions,
17572            columns,
17573            defaults,
17574        } => {
17575            apply_per_cell_writes(child, positions, columns, |i| defaults[i].clone())?;
17576        }
17577    }
17578    Ok(())
17579}
17580
17581/// v7.6.5 — write new values into selected child cells via
17582/// `Table::update_row` (the catalog's existing UPDATE entry).
17583/// Groups writes by row position so multi-column updates on the
17584/// same row only call `update_row` once. `value_for(i)` produces
17585/// the new value for the i-th (position, column) entry.
17586fn apply_per_cell_writes(
17587    child: &mut spg_storage::Table,
17588    positions: &[usize],
17589    columns: &[usize],
17590    mut value_for: impl FnMut(usize) -> Value,
17591) -> Result<(), EngineError> {
17592    use alloc::collections::BTreeMap;
17593    let mut by_row: BTreeMap<usize, Vec<(usize, Value)>> = BTreeMap::new();
17594    for i in 0..positions.len() {
17595        by_row
17596            .entry(positions[i])
17597            .or_default()
17598            .push((columns[i], value_for(i)));
17599    }
17600    for (pos, mutations) in by_row {
17601        let mut new_values = child.rows()[pos].values.clone();
17602        for (col, v) in mutations {
17603            if let Some(slot) = new_values.get_mut(col) {
17604                *slot = v;
17605            }
17606        }
17607        child
17608            .update_row(pos, new_values)
17609            .map_err(EngineError::Storage)?;
17610    }
17611    Ok(())
17612}
17613
17614fn fk_action_sql_to_storage(a: spg_sql::ast::FkAction) -> spg_storage::FkAction {
17615    match a {
17616        spg_sql::ast::FkAction::Restrict => spg_storage::FkAction::Restrict,
17617        spg_sql::ast::FkAction::Cascade => spg_storage::FkAction::Cascade,
17618        spg_sql::ast::FkAction::SetNull => spg_storage::FkAction::SetNull,
17619        spg_sql::ast::FkAction::SetDefault => spg_storage::FkAction::SetDefault,
17620        spg_sql::ast::FkAction::NoAction => spg_storage::FkAction::NoAction,
17621    }
17622}
17623
17624/// v7.9.21 — resolve a column's DEFAULT for INSERT-time
17625/// default-fill. Free fn (rather than `&self`) so callers
17626/// with an active `&mut Table` borrow can still use it.
17627/// Literal defaults take the cached path (`col.default`);
17628/// runtime defaults hit `clock_fn` at each call. mailrs G4.
17629fn resolve_column_default_free(
17630    col: &ColumnSchema,
17631    clock_fn: Option<ClockFn>,
17632) -> Result<Value, EngineError> {
17633    if let Some(rt) = &col.runtime_default {
17634        return eval_runtime_default_free(rt, col.ty, clock_fn);
17635    }
17636    Ok(col.default.clone().unwrap_or(Value::Null))
17637}
17638
17639fn eval_runtime_default_free(
17640    rt: &str,
17641    ty: DataType,
17642    clock_fn: Option<ClockFn>,
17643) -> Result<Value, EngineError> {
17644    let s = rt.trim().to_ascii_lowercase();
17645    // v7.17.0 Phase 2.1 — also strip `(N)` precision suffix
17646    // so MySQL `CURRENT_TIMESTAMP(6)` resolves the same as
17647    // bare `CURRENT_TIMESTAMP`. SPG stores TIMESTAMP at fixed
17648    // microsecond resolution; the precision modifier is
17649    // parser-only.
17650    let with_no_parens = s.trim_end_matches("()");
17651    let canonical: &str = if let Some(open_idx) = with_no_parens.find('(') {
17652        if with_no_parens.ends_with(')') {
17653            &with_no_parens[..open_idx]
17654        } else {
17655            with_no_parens
17656        }
17657    } else {
17658        with_no_parens
17659    };
17660    let now_us = match clock_fn {
17661        Some(f) => f(),
17662        None => 0,
17663    };
17664    let v = match canonical {
17665        "now" | "current_timestamp" | "localtimestamp" => Value::Timestamp(now_us),
17666        "current_date" => Value::Date((now_us / 86_400_000_000) as i32),
17667        "current_time" | "localtime" => Value::Timestamp(now_us),
17668        // v7.17.0 — UUID generators in DEFAULT clauses. Required
17669        // for the canonical Django / Rails / Hibernate `id UUID
17670        // PRIMARY KEY DEFAULT gen_random_uuid()` pattern. Each
17671        // INSERT evaluates the function fresh; the per-row UUID
17672        // is the storage value, not a cached literal.
17673        "gen_random_uuid" | "uuid_generate_v4" => Value::Uuid(eval::gen_random_uuid_bytes()),
17674        other => {
17675            return Err(EngineError::Unsupported(alloc::format!(
17676                "runtime DEFAULT expression {other:?} not supported \
17677                 (v7.17.0 whitelist: now() / current_timestamp / \
17678                 current_date / current_time / localtimestamp / \
17679                 localtime / gen_random_uuid() / \
17680                 uuid_generate_v4())"
17681            )));
17682        }
17683    };
17684    coerce_value(v, ty, "DEFAULT", 0)
17685}
17686
17687/// v7.9.21 — true when a DEFAULT expression needs INSERT-time
17688/// evaluation rather than being cacheable as a literal Value.
17689/// FunctionCall is the immediate case (`now()`,
17690/// `current_timestamp`). Literal expressions and simple sign-
17691/// flipped numerics still take the static-cache path.
17692fn is_runtime_default_expr(expr: &Expr) -> bool {
17693    match expr {
17694        Expr::FunctionCall { .. } => true,
17695        Expr::Unary { expr, .. } => is_runtime_default_expr(expr),
17696        _ => false,
17697    }
17698}
17699
17700/// v7.17.0 Phase 1.4 — INSERT/UPDATE-time enum label check. When
17701/// `col_idx` has a registered label list, the cell value must be
17702/// NULL or one of the labels (case-sensitive per PG).
17703/// v7.17.0 Phase 3.P0-37 — validate + canonicalise a MySQL inline
17704/// SET cell. For non-SET columns this is a no-op pass-through.
17705///
17706/// Semantics:
17707///   * NULL preserved.
17708///   * Empty string → `''` (zero flags).
17709///   * Otherwise split on ',', trim each token, validate every
17710///     token against the column's variant list (error on miss),
17711///     de-dup, then re-emit in DEFINITION order joined by ','.
17712fn canonicalize_set_value(
17713    lookup: &alloc::collections::BTreeMap<usize, Vec<String>>,
17714    col_idx: usize,
17715    col_name: &str,
17716    value: Value,
17717) -> Result<Value, EngineError> {
17718    let Some(variants) = lookup.get(&col_idx) else {
17719        return Ok(value);
17720    };
17721    match value {
17722        Value::Null => Ok(Value::Null),
17723        Value::Text(s) => {
17724            if s.is_empty() {
17725                return Ok(Value::Text(alloc::string::String::new()));
17726            }
17727            // Collect a presence-set of variant indices to keep
17728            // definition order + handle de-dup in one pass.
17729            let mut present = alloc::vec![false; variants.len()];
17730            for raw in s.split(',') {
17731                let tok = raw.trim();
17732                if tok.is_empty() {
17733                    continue;
17734                }
17735                let idx = variants.iter().position(|v| v == tok).ok_or_else(|| {
17736                    EngineError::Unsupported(alloc::format!(
17737                        "column {col_name:?}: invalid SET token {tok:?}; \
17738                         allowed: {variants:?}"
17739                    ))
17740                })?;
17741                present[idx] = true;
17742            }
17743            // Re-emit in definition order.
17744            let mut out = alloc::string::String::new();
17745            let mut first = true;
17746            for (i, keep) in present.iter().enumerate() {
17747                if !keep {
17748                    continue;
17749                }
17750                if !first {
17751                    out.push(',');
17752                }
17753                first = false;
17754                out.push_str(&variants[i]);
17755            }
17756            Ok(Value::Text(out))
17757        }
17758        other => Err(EngineError::Unsupported(alloc::format!(
17759            "column {col_name:?}: SET-typed column expects TEXT, got {:?}",
17760            other.data_type()
17761        ))),
17762    }
17763}
17764
17765fn enforce_enum_label(
17766    lookup: &alloc::collections::BTreeMap<usize, Vec<String>>,
17767    col_idx: usize,
17768    col_name: &str,
17769    value: &Value,
17770) -> Result<(), EngineError> {
17771    if let Some(labels) = lookup.get(&col_idx) {
17772        match value {
17773            Value::Null => Ok(()),
17774            Value::Text(s) => {
17775                if labels.iter().any(|l| l == s) {
17776                    Ok(())
17777                } else {
17778                    Err(EngineError::Unsupported(alloc::format!(
17779                        "column {col_name:?}: invalid enum label {s:?}; allowed: {labels:?}"
17780                    )))
17781                }
17782            }
17783            other => Err(EngineError::Unsupported(alloc::format!(
17784                "column {col_name:?}: enum-typed column expects TEXT, got {:?}",
17785                other.data_type()
17786            ))),
17787        }
17788    } else {
17789        Ok(())
17790    }
17791}
17792
17793fn column_def_to_schema(c: ColumnDef) -> Result<ColumnSchema, EngineError> {
17794    let ty = column_type_to_data_type(c.ty);
17795    let mut schema = ColumnSchema::new(c.name.clone(), ty, c.nullable);
17796    // user_type_ref is the raw ident the parser couldn't resolve
17797    // to a built-in; classification into enum vs domain happens
17798    // at exec_create_table where we have catalog access. We
17799    // park it temporarily as user_enum_type and the engine
17800    // promotes domain bindings to user_domain_type before the
17801    // table is stored.
17802    if let Some(name) = c.user_type_ref {
17803        schema.user_enum_type = Some(name);
17804    }
17805    // v7.17.0 Phase 2.1 — render the ON UPDATE expression to
17806    // canonical text (the engine re-parses at UPDATE time).
17807    if let Some(expr) = c.on_update_runtime {
17808        schema.on_update_runtime = Some(alloc::format!("{expr}"));
17809    }
17810    // v7.17.0 Phase 2.5 — bridge the AST `Collation` enum to the
17811    // storage one. Same variants, different crates (spg-storage
17812    // owns no dep on spg-sql).
17813    schema.collation = match c.collation {
17814        spg_sql::ast::Collation::Binary => spg_storage::Collation::Binary,
17815        spg_sql::ast::Collation::CaseInsensitive => spg_storage::Collation::CaseInsensitive,
17816    };
17817    // v7.17.0 Phase 4.4 — MySQL `UNSIGNED` flag propagates to
17818    // storage so engine INSERT / UPDATE can range-check.
17819    schema.is_unsigned = c.is_unsigned;
17820    // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM variant list.
17821    // INSERT validation lives in coerce_value (Text → Text path
17822    // with the column's variant list as the accept-set).
17823    schema.inline_enum_variants = c.inline_enum_variants;
17824    // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant list.
17825    // INSERT canonicalisation (de-dup + sort by definition order)
17826    // lives in the exec_insert path next to the ENUM check.
17827    schema.inline_set_variants = c.inline_set_variants;
17828    if let Some(default_expr) = c.default {
17829        // v7.9.21 — distinguish literal defaults (evaluated once
17830        // at CREATE TABLE) from expression defaults (deferred to
17831        // INSERT). Function calls (`now()`, `current_timestamp`
17832        // — see v7.9.20 keyword promotion) take the runtime path.
17833        // Literals continue to cache. mailrs G4.
17834        if is_runtime_default_expr(&default_expr) {
17835            let display = alloc::format!("{default_expr}");
17836            schema = schema.with_runtime_default(display);
17837        } else {
17838            let raw = literal_expr_to_value(default_expr)?;
17839            let coerced = coerce_value(raw, ty, &c.name, 0)?;
17840            schema = schema.with_default(coerced);
17841        }
17842    }
17843    if c.auto_increment {
17844        // AUTO_INCREMENT only makes sense on integer-shaped columns.
17845        if !matches!(ty, DataType::SmallInt | DataType::Int | DataType::BigInt) {
17846            return Err(EngineError::Unsupported(alloc::format!(
17847                "AUTO_INCREMENT requires an integer column type, got {ty:?}"
17848            )));
17849        }
17850        schema = schema.with_auto_increment();
17851    }
17852    Ok(schema)
17853}
17854
17855/// v7.10.4 — decode a BYTEA literal. Accepts:
17856///   * `\xDEADBEEF` (case-insensitive hex; whitespace stripped)
17857///   * `Hello\000world` (backslash escape form; `\\` for literal backslash)
17858///   * Anything else → raw UTF-8 bytes of the input (PG accepts this too).
17859fn decode_bytea_literal(s: &str) -> Result<alloc::vec::Vec<u8>, &'static str> {
17860    let s = s.trim();
17861    if let Some(hex) = s.strip_prefix("\\x").or_else(|| s.strip_prefix("\\X")) {
17862        // Hex form. Each pair of hex digits → one byte.
17863        let cleaned: alloc::string::String = hex.chars().filter(|c| !c.is_whitespace()).collect();
17864        if cleaned.len() % 2 != 0 {
17865            return Err("odd-length hex literal");
17866        }
17867        let mut out = alloc::vec::Vec::with_capacity(cleaned.len() / 2);
17868        let cleaned_bytes = cleaned.as_bytes();
17869        for i in (0..cleaned_bytes.len()).step_by(2) {
17870            let hi = hex_nibble(cleaned_bytes[i])?;
17871            let lo = hex_nibble(cleaned_bytes[i + 1])?;
17872            out.push((hi << 4) | lo);
17873        }
17874        return Ok(out);
17875    }
17876    // Escape form or raw. Walk char-by-char; `\\` and `\NNN` octal
17877    // sequences decode; anything else is a literal byte.
17878    let bytes = s.as_bytes();
17879    let mut out = alloc::vec::Vec::with_capacity(bytes.len());
17880    let mut i = 0;
17881    while i < bytes.len() {
17882        let b = bytes[i];
17883        if b == b'\\' && i + 1 < bytes.len() {
17884            let n = bytes[i + 1];
17885            if n == b'\\' {
17886                out.push(b'\\');
17887                i += 2;
17888                continue;
17889            }
17890            if n.is_ascii_digit()
17891                && i + 3 < bytes.len()
17892                && bytes[i + 2].is_ascii_digit()
17893                && bytes[i + 3].is_ascii_digit()
17894            {
17895                let oct = |x: u8| (x - b'0') as u32;
17896                let v = oct(n) * 64 + oct(bytes[i + 2]) * 8 + oct(bytes[i + 3]);
17897                if v <= 0xFF {
17898                    out.push(v as u8);
17899                    i += 4;
17900                    continue;
17901                }
17902            }
17903        }
17904        out.push(b);
17905        i += 1;
17906    }
17907    Ok(out)
17908}
17909
17910fn hex_nibble(b: u8) -> Result<u8, &'static str> {
17911    match b {
17912        b'0'..=b'9' => Ok(b - b'0'),
17913        b'a'..=b'f' => Ok(b - b'a' + 10),
17914        b'A'..=b'F' => Ok(b - b'A' + 10),
17915        _ => Err("invalid hex digit"),
17916    }
17917}
17918
17919/// v7.10.11 — decode a PG TEXT[] external array form
17920/// (`{a,b,NULL}` with optional double-quoted elements). The
17921/// engine takes a leading/trailing `{`/`}` and splits at commas.
17922/// Quoted elements (`"hello, world"`) preserve embedded commas;
17923/// `\\` and `\"` decode to literal backslash / quote. Plain
17924/// unquoted `NULL` (case-insensitive) maps to `None`.
17925/// v7.11.13 — pick the array type for `ARRAY[lit, …]` from the
17926/// element values. Single-element-type rules:
17927///   - all NULL / all Text → TextArray
17928///   - all Int (or Int+NULL) → IntArray
17929///   - any BigInt without Text → BigIntArray (widening)
17930///   - any Text → TextArray (fallback; non-string elements
17931///     render as text)
17932fn array_literal_widen(items: alloc::vec::Vec<Value>) -> Value {
17933    let mut has_text = false;
17934    let mut has_bigint = false;
17935    let mut has_int = false;
17936    for v in &items {
17937        match v {
17938            Value::Null => {}
17939            Value::Text(_) | Value::Json(_) => has_text = true,
17940            Value::BigInt(_) => has_bigint = true,
17941            Value::Int(_) | Value::SmallInt(_) => has_int = true,
17942            _ => has_text = true,
17943        }
17944    }
17945    if has_text || (!has_bigint && !has_int) {
17946        let out: alloc::vec::Vec<Option<alloc::string::String>> = items
17947            .into_iter()
17948            .map(|v| match v {
17949                Value::Null => None,
17950                Value::Text(s) | Value::Json(s) => Some(s),
17951                other => Some(alloc::format!("{other:?}")),
17952            })
17953            .collect();
17954        return Value::TextArray(out);
17955    }
17956    if has_bigint {
17957        let out: alloc::vec::Vec<Option<i64>> = items
17958            .into_iter()
17959            .map(|v| match v {
17960                Value::Null => None,
17961                Value::Int(n) => Some(i64::from(n)),
17962                Value::SmallInt(n) => Some(i64::from(n)),
17963                Value::BigInt(n) => Some(n),
17964                _ => unreachable!("widen: unexpected non-integer in BigInt path"),
17965            })
17966            .collect();
17967        return Value::BigIntArray(out);
17968    }
17969    let out: alloc::vec::Vec<Option<i32>> = items
17970        .into_iter()
17971        .map(|v| match v {
17972            Value::Null => None,
17973            Value::Int(n) => Some(n),
17974            Value::SmallInt(n) => Some(i32::from(n)),
17975            _ => unreachable!("widen: unexpected non-i32-compatible in Int path"),
17976        })
17977        .collect();
17978    Value::IntArray(out)
17979}
17980
17981fn decode_text_array_literal(
17982    s: &str,
17983) -> Result<alloc::vec::Vec<Option<alloc::string::String>>, &'static str> {
17984    let trimmed = s.trim();
17985    let inner = trimmed
17986        .strip_prefix('{')
17987        .and_then(|x| x.strip_suffix('}'))
17988        .ok_or("TEXT[] literal must be enclosed in '{...}'")?;
17989    let mut out: alloc::vec::Vec<Option<alloc::string::String>> = alloc::vec::Vec::new();
17990    if inner.trim().is_empty() {
17991        return Ok(out);
17992    }
17993    let bytes = inner.as_bytes();
17994    let mut i = 0;
17995    while i <= bytes.len() {
17996        // Skip leading whitespace.
17997        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
17998            i += 1;
17999        }
18000        // Quoted element.
18001        if i < bytes.len() && bytes[i] == b'"' {
18002            i += 1; // open quote
18003            let mut buf = alloc::string::String::new();
18004            while i < bytes.len() && bytes[i] != b'"' {
18005                if bytes[i] == b'\\' && i + 1 < bytes.len() {
18006                    buf.push(bytes[i + 1] as char);
18007                    i += 2;
18008                } else {
18009                    buf.push(bytes[i] as char);
18010                    i += 1;
18011                }
18012            }
18013            if i >= bytes.len() {
18014                return Err("unterminated quoted element");
18015            }
18016            i += 1; // close quote
18017            out.push(Some(buf));
18018        } else {
18019            // Unquoted element — read until next comma or end.
18020            let start = i;
18021            while i < bytes.len() && bytes[i] != b',' {
18022                i += 1;
18023            }
18024            let raw = inner[start..i].trim();
18025            if raw.eq_ignore_ascii_case("NULL") {
18026                out.push(None);
18027            } else {
18028                out.push(Some(alloc::string::ToString::to_string(raw)));
18029            }
18030        }
18031        // Skip whitespace, expect comma or end.
18032        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
18033            i += 1;
18034        }
18035        if i >= bytes.len() {
18036            break;
18037        }
18038        if bytes[i] != b',' {
18039            return Err("expected ',' between TEXT[] elements");
18040        }
18041        i += 1;
18042    }
18043    Ok(out)
18044}
18045
18046/// v7.10.11 — encode a TEXT[] back into the PG external array
18047/// form. NULL elements become the literal `NULL`; elements
18048/// containing commas, quotes, backslashes, or braces are
18049/// double-quoted with `\\` / `\"` escapes.
18050fn encode_text_array(items: &[Option<alloc::string::String>]) -> alloc::string::String {
18051    let mut out = alloc::string::String::with_capacity(2 + items.len() * 8);
18052    out.push('{');
18053    for (i, item) in items.iter().enumerate() {
18054        if i > 0 {
18055            out.push(',');
18056        }
18057        match item {
18058            None => out.push_str("NULL"),
18059            Some(s) => {
18060                let needs_quote = s.is_empty()
18061                    || s.eq_ignore_ascii_case("NULL")
18062                    || s.chars()
18063                        .any(|c| matches!(c, ',' | '{' | '}' | '"' | '\\' | ' ' | '\t'));
18064                if needs_quote {
18065                    out.push('"');
18066                    for c in s.chars() {
18067                        if c == '"' || c == '\\' {
18068                            out.push('\\');
18069                        }
18070                        out.push(c);
18071                    }
18072                    out.push('"');
18073                } else {
18074                    out.push_str(s);
18075                }
18076            }
18077        }
18078    }
18079    out.push('}');
18080    out
18081}
18082
18083/// v7.10.4 — encode BYTEA bytes in PG hex output format
18084/// (`\x` prefix, lowercase hex pairs). Used by Text-side
18085/// round-trip + the wire layer's text-mode encoder.
18086fn encode_bytea_hex(b: &[u8]) -> alloc::string::String {
18087    let mut out = alloc::string::String::with_capacity(2 + 2 * b.len());
18088    out.push_str("\\x");
18089    for byte in b {
18090        let hi = byte >> 4;
18091        let lo = byte & 0x0F;
18092        out.push(hex_digit(hi));
18093        out.push(hex_digit(lo));
18094    }
18095    out
18096}
18097
18098const fn hex_digit(n: u8) -> char {
18099    match n {
18100        0..=9 => (b'0' + n) as char,
18101        10..=15 => (b'a' + n - 10) as char,
18102        _ => '?',
18103    }
18104}
18105
18106/// v7.17.0 Phase 3.P0-39 — parse a PG `hstore` text literal into
18107/// a flat key→value map. Empty string → empty map. Duplicate
18108/// keys take last-write-wins (matches PG `hstore_in`).
18109///
18110/// Accepted shapes (minimal subset):
18111///   * `'a=>1, b=>2'`            — bareword keys/values
18112///   * `'"a"=>"1", "b"=>"2"'`    — quoted keys/values
18113///   * `'a=>NULL'`               — case-insensitive NULL token
18114///     surfaces as `None` (no quotes around NULL)
18115///
18116/// Returns None on parse failure → caller surfaces as hard error.
18117fn parse_hstore_str(
18118    s: &str,
18119) -> Option<Vec<(alloc::string::String, Option<alloc::string::String>)>> {
18120    let bytes = s.as_bytes();
18121    let mut i = 0;
18122    let mut out: Vec<(alloc::string::String, Option<alloc::string::String>)> = Vec::new();
18123    let skip_ws = |bytes: &[u8], i: &mut usize| {
18124        while *i < bytes.len() && matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r') {
18125            *i += 1;
18126        }
18127    };
18128    let parse_token = |bytes: &[u8], i: &mut usize| -> Option<alloc::string::String> {
18129        if *i >= bytes.len() {
18130            return None;
18131        }
18132        if bytes[*i] == b'"' {
18133            *i += 1;
18134            let mut out = alloc::string::String::new();
18135            while *i < bytes.len() {
18136                match bytes[*i] {
18137                    b'"' => {
18138                        *i += 1;
18139                        return Some(out);
18140                    }
18141                    b'\\' if *i + 1 < bytes.len() => {
18142                        out.push(bytes[*i + 1] as char);
18143                        *i += 2;
18144                    }
18145                    c => {
18146                        out.push(c as char);
18147                        *i += 1;
18148                    }
18149                }
18150            }
18151            None
18152        } else {
18153            let start = *i;
18154            while *i < bytes.len()
18155                && !matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r' | b',' | b'=')
18156            {
18157                *i += 1;
18158            }
18159            if *i == start {
18160                return None;
18161            }
18162            Some(alloc::str::from_utf8(&bytes[start..*i]).ok()?.to_string())
18163        }
18164    };
18165    skip_ws(bytes, &mut i);
18166    while i < bytes.len() {
18167        let key = parse_token(bytes, &mut i)?;
18168        skip_ws(bytes, &mut i);
18169        if i + 1 >= bytes.len() || bytes[i] != b'=' || bytes[i + 1] != b'>' {
18170            return None;
18171        }
18172        i += 2;
18173        skip_ws(bytes, &mut i);
18174        // Check for unquoted NULL token (case-insensitive).
18175        let val_token = if i + 4 <= bytes.len()
18176            && bytes[i..i + 4].eq_ignore_ascii_case(b"NULL")
18177            && (i + 4 == bytes.len() || matches!(bytes[i + 4], b' ' | b'\t' | b',' | b'\n' | b'\r'))
18178        {
18179            i += 4;
18180            None
18181        } else {
18182            Some(parse_token(bytes, &mut i)?)
18183        };
18184        // Replace any existing entry with the same key (last-wins).
18185        if let Some(pos) = out.iter().position(|(k, _)| k == &key) {
18186            out[pos] = (key, val_token);
18187        } else {
18188            out.push((key, val_token));
18189        }
18190        skip_ws(bytes, &mut i);
18191        if i >= bytes.len() {
18192            break;
18193        }
18194        if bytes[i] == b',' {
18195            i += 1;
18196            skip_ws(bytes, &mut i);
18197            continue;
18198        }
18199        return None;
18200    }
18201    Some(out)
18202}
18203
18204/// v7.17.0 Phase 3.P0-39 — render a hstore as canonical PG text
18205/// form `"k"=>"v"` (keys and non-NULL values always quoted;
18206/// NULL token is bare).
18207fn format_hstore_str(
18208    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
18209) -> alloc::string::String {
18210    let mut out = alloc::string::String::new();
18211    for (i, (k, v)) in pairs.iter().enumerate() {
18212        if i > 0 {
18213            out.push_str(", ");
18214        }
18215        out.push('"');
18216        out.push_str(k);
18217        out.push_str("\"=>");
18218        match v {
18219            None => out.push_str("NULL"),
18220            Some(val) => {
18221                out.push('"');
18222                out.push_str(val);
18223                out.push('"');
18224            }
18225        }
18226    }
18227    out
18228}
18229
18230/// v7.17.0 Phase 3.P0-39 — pub re-export so pgwire + sqllogictest
18231/// share the single hstore renderer.
18232pub fn format_hstore_text(
18233    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
18234) -> alloc::string::String {
18235    format_hstore_str(pairs)
18236}
18237
18238// ─── v7.17.0 Phase 3.P0-40 — 2D array parse + display ─────────
18239
18240/// Split a PG external 2D-array literal `'{{a,b},{c,d}}'` into
18241/// per-row token lists. Returns Err on shape mismatch.
18242fn split_2d_literal(s: &str) -> Result<Vec<Vec<alloc::string::String>>, &'static str> {
18243    let s = s.trim();
18244    let outer = s
18245        .strip_prefix('{')
18246        .and_then(|x| x.strip_suffix('}'))
18247        .ok_or("missing outer '{...}' braces")?;
18248    let trimmed = outer.trim();
18249    if trimmed.is_empty() {
18250        return Ok(Vec::new());
18251    }
18252    let mut rows: Vec<Vec<alloc::string::String>> = Vec::new();
18253    let mut i = 0;
18254    let bytes = trimmed.as_bytes();
18255    while i < bytes.len() {
18256        while i < bytes.len() && matches!(bytes[i], b' ' | b'\t' | b'\n' | b'\r' | b',') {
18257            i += 1;
18258        }
18259        if i >= bytes.len() {
18260            break;
18261        }
18262        if bytes[i] != b'{' {
18263            return Err("expected '{' opening a row");
18264        }
18265        i += 1;
18266        let row_start = i;
18267        let mut depth = 1;
18268        while i < bytes.len() && depth > 0 {
18269            match bytes[i] {
18270                b'{' => depth += 1,
18271                b'}' => depth -= 1,
18272                _ => {}
18273            }
18274            if depth > 0 {
18275                i += 1;
18276            }
18277        }
18278        if depth != 0 {
18279            return Err("unbalanced '{...}' in row");
18280        }
18281        let row_text = &trimmed[row_start..i];
18282        i += 1;
18283        let cells: Vec<alloc::string::String> = if row_text.trim().is_empty() {
18284            Vec::new()
18285        } else {
18286            row_text.split(',').map(|t| t.trim().to_string()).collect()
18287        };
18288        rows.push(cells);
18289    }
18290    if let Some(first) = rows.first() {
18291        let cols = first.len();
18292        for r in &rows {
18293            if r.len() != cols {
18294                return Err("ragged 2D array (rows have different column counts)");
18295            }
18296        }
18297    }
18298    Ok(rows)
18299}
18300
18301fn parse_int_2d_literal(s: &str) -> Result<Vec<Vec<Option<i32>>>, &'static str> {
18302    let raw = split_2d_literal(s)?;
18303    raw.into_iter()
18304        .map(|row| {
18305            row.into_iter()
18306                .map(|cell| {
18307                    if cell.eq_ignore_ascii_case("NULL") {
18308                        Ok(None)
18309                    } else {
18310                        cell.parse::<i32>()
18311                            .map(Some)
18312                            .map_err(|_| "invalid int element")
18313                    }
18314                })
18315                .collect()
18316        })
18317        .collect()
18318}
18319
18320fn parse_bigint_2d_literal(s: &str) -> Result<Vec<Vec<Option<i64>>>, &'static str> {
18321    let raw = split_2d_literal(s)?;
18322    raw.into_iter()
18323        .map(|row| {
18324            row.into_iter()
18325                .map(|cell| {
18326                    if cell.eq_ignore_ascii_case("NULL") {
18327                        Ok(None)
18328                    } else {
18329                        cell.parse::<i64>()
18330                            .map(Some)
18331                            .map_err(|_| "invalid bigint element")
18332                    }
18333                })
18334                .collect()
18335        })
18336        .collect()
18337}
18338
18339fn parse_text_2d_literal(s: &str) -> Result<Vec<Vec<Option<alloc::string::String>>>, &'static str> {
18340    let raw = split_2d_literal(s)?;
18341    Ok(raw
18342        .into_iter()
18343        .map(|row| {
18344            row.into_iter()
18345                .map(|cell| {
18346                    if cell.eq_ignore_ascii_case("NULL") {
18347                        None
18348                    } else {
18349                        Some(cell.trim_matches('"').to_string())
18350                    }
18351                })
18352                .collect()
18353        })
18354        .collect())
18355}
18356
18357fn format_int_2d_text(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
18358    let mut out = alloc::string::String::from("{");
18359    for (i, row) in rows.iter().enumerate() {
18360        if i > 0 {
18361            out.push(',');
18362        }
18363        out.push('{');
18364        for (j, cell) in row.iter().enumerate() {
18365            if j > 0 {
18366                out.push(',');
18367            }
18368            match cell {
18369                None => out.push_str("NULL"),
18370                Some(n) => out.push_str(&alloc::format!("{n}")),
18371            }
18372        }
18373        out.push('}');
18374    }
18375    out.push('}');
18376    out
18377}
18378
18379fn format_bigint_2d_text(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
18380    let mut out = alloc::string::String::from("{");
18381    for (i, row) in rows.iter().enumerate() {
18382        if i > 0 {
18383            out.push(',');
18384        }
18385        out.push('{');
18386        for (j, cell) in row.iter().enumerate() {
18387            if j > 0 {
18388                out.push(',');
18389            }
18390            match cell {
18391                None => out.push_str("NULL"),
18392                Some(n) => out.push_str(&alloc::format!("{n}")),
18393            }
18394        }
18395        out.push('}');
18396    }
18397    out.push('}');
18398    out
18399}
18400
18401fn format_text_2d_text(rows: &[Vec<Option<alloc::string::String>>]) -> alloc::string::String {
18402    let mut out = alloc::string::String::from("{");
18403    for (i, row) in rows.iter().enumerate() {
18404        if i > 0 {
18405            out.push(',');
18406        }
18407        out.push('{');
18408        for (j, cell) in row.iter().enumerate() {
18409            if j > 0 {
18410                out.push(',');
18411            }
18412            match cell {
18413                None => out.push_str("NULL"),
18414                Some(s) => out.push_str(s),
18415            }
18416        }
18417        out.push('}');
18418    }
18419    out.push('}');
18420    out
18421}
18422
18423/// v7.17.0 Phase 3.P0-40 — pub re-exports so pgwire + sqllogictest
18424/// share the single 2D-array renderer.
18425pub fn format_int_2d_text_pub(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
18426    format_int_2d_text(rows)
18427}
18428pub fn format_bigint_2d_text_pub(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
18429    format_bigint_2d_text(rows)
18430}
18431pub fn format_text_2d_text_pub(
18432    rows: &[Vec<Option<alloc::string::String>>],
18433) -> alloc::string::String {
18434    format_text_2d_text(rows)
18435}
18436
18437/// v7.17.0 Phase 3.P0-38 — parse a PG range literal of the form
18438/// `'[lo,up)'` / `'(lo,up]'` / `'[lo,up]'` / `'(lo,up)'` /
18439/// `'empty'`. Lower / upper may be empty (unbounded). Returns
18440/// `None` on any parse failure; caller surfaces as hard error.
18441fn parse_range_str(s: &str, kind: spg_storage::RangeKind) -> Option<Value> {
18442    let s = s.trim();
18443    if s.eq_ignore_ascii_case("empty") {
18444        return Some(Value::Range {
18445            kind,
18446            lower: None,
18447            upper: None,
18448            lower_inc: false,
18449            upper_inc: false,
18450            empty: true,
18451        });
18452    }
18453    let bytes = s.as_bytes();
18454    if bytes.len() < 3 {
18455        return None;
18456    }
18457    let lower_inc = match bytes[0] {
18458        b'[' => true,
18459        b'(' => false,
18460        _ => return None,
18461    };
18462    let upper_inc = match bytes[bytes.len() - 1] {
18463        b']' => true,
18464        b')' => false,
18465        _ => return None,
18466    };
18467    let inner = &s[1..s.len() - 1];
18468    let (lo_text, up_text) = inner.split_once(',')?;
18469    let lower = if lo_text.is_empty() {
18470        None
18471    } else {
18472        Some(alloc::boxed::Box::new(parse_range_element(lo_text, kind)?))
18473    };
18474    let upper = if up_text.is_empty() {
18475        None
18476    } else {
18477        Some(alloc::boxed::Box::new(parse_range_element(up_text, kind)?))
18478    };
18479    Some(Value::Range {
18480        kind,
18481        lower,
18482        upper,
18483        lower_inc,
18484        upper_inc,
18485        empty: false,
18486    })
18487}
18488
18489/// v7.17.0 Phase 3.P0-38 — parse a single range bound text into
18490/// the matching element Value for the RangeKind.
18491fn parse_range_element(text: &str, kind: spg_storage::RangeKind) -> Option<Value> {
18492    let text = text.trim().trim_matches('"');
18493    use spg_storage::RangeKind as K;
18494    match kind {
18495        K::Int4 => text.parse::<i32>().ok().map(Value::Int),
18496        K::Int8 => text.parse::<i64>().ok().map(Value::BigInt),
18497        K::Num => {
18498            // Reuse the Numeric parse via the engine's text-coercion
18499            // path; bail to None on failure.
18500            let dot = text.find('.');
18501            let scale: u8 = dot.map_or(0, |p| (text.len() - p - 1) as u8);
18502            let digits: alloc::string::String = text
18503                .chars()
18504                .filter(|c| *c == '-' || c.is_ascii_digit())
18505                .collect();
18506            let scaled: i128 = digits.parse().ok()?;
18507            Some(Value::Numeric { scaled, scale })
18508        }
18509        K::Ts | K::TsTz => {
18510            // Reuse the existing timestamp parse path. v7.17.0
18511            // expects `'YYYY-MM-DD HH:MM:SS[.ffffff]'` in range
18512            // bounds (TZ offset on TsTz is OOS for the initial
18513            // P0-38; ship plain Timestamp shape).
18514            crate::eval::parse_timestamp_literal(text).map(Value::Timestamp)
18515        }
18516        K::Date => crate::eval::parse_date_literal(text).map(Value::Date),
18517    }
18518}
18519
18520/// v7.17.0 Phase 3.P0-38 — render a Range value as its canonical
18521/// PG text form. Re-exported via [`format_range_text`] for use
18522/// from spg-server's pgwire layer.
18523pub fn format_range_text(v: &Value) -> alloc::string::String {
18524    format_range_str(v)
18525}
18526
18527fn format_range_str(v: &Value) -> alloc::string::String {
18528    let Value::Range {
18529        lower,
18530        upper,
18531        lower_inc,
18532        upper_inc,
18533        empty,
18534        ..
18535    } = v
18536    else {
18537        return alloc::string::String::new();
18538    };
18539    if *empty {
18540        return "empty".into();
18541    }
18542    let mut out = alloc::string::String::new();
18543    out.push(if *lower_inc { '[' } else { '(' });
18544    if let Some(l) = lower {
18545        out.push_str(&format_range_element(l));
18546    }
18547    out.push(',');
18548    if let Some(u) = upper {
18549        out.push_str(&format_range_element(u));
18550    }
18551    out.push(if *upper_inc { ']' } else { ')' });
18552    out
18553}
18554
18555fn format_range_element(v: &Value) -> alloc::string::String {
18556    match v {
18557        Value::Int(n) => alloc::format!("{n}"),
18558        Value::BigInt(n) => alloc::format!("{n}"),
18559        Value::Date(d) => crate::eval::format_date(*d),
18560        Value::Timestamp(t) => crate::eval::format_timestamp(*t),
18561        Value::Numeric { scaled, scale } => crate::eval::format_numeric(*scaled, *scale),
18562        other => alloc::format!("{other:?}"),
18563    }
18564}
18565
18566/// v7.17.0 Phase 3.P0-35 — parse a PG `money` literal into i64
18567/// cents. Accepts:
18568///   * Optional leading `-` (negative)
18569///   * Optional `$` prefix
18570///   * Integer portion with optional `,` thousands separators
18571///   * Optional `.` followed by 1-2 digits (cents); 1 digit
18572///     auto-pads to 2 (`.5` → 50 cents).
18573///
18574/// Returns None on any parse failure — caller surfaces as hard
18575/// SQL error.
18576fn parse_money_str(s: &str) -> Option<i64> {
18577    let s = s.trim();
18578    let (neg, rest) = match s.strip_prefix('-') {
18579        Some(r) => (true, r.trim_start()),
18580        None => (false, s),
18581    };
18582    let rest = rest.strip_prefix('$').unwrap_or(rest).trim_start();
18583    let (int_part, frac_part) = match rest.split_once('.') {
18584        Some((i, f)) => (i, Some(f)),
18585        None => (rest, None),
18586    };
18587    if int_part.is_empty() {
18588        return None;
18589    }
18590    // Validate + strip commas from the integer portion.
18591    let mut int_digits = alloc::string::String::with_capacity(int_part.len());
18592    for b in int_part.bytes() {
18593        match b {
18594            b',' => {}
18595            b'0'..=b'9' => int_digits.push(b as char),
18596            _ => return None,
18597        }
18598    }
18599    if int_digits.is_empty() {
18600        return None;
18601    }
18602    let dollars: i64 = int_digits.parse().ok()?;
18603    let cents: i64 = match frac_part {
18604        None => 0,
18605        Some(f) => {
18606            if f.is_empty() || f.len() > 2 || !f.bytes().all(|b| b.is_ascii_digit()) {
18607                return None;
18608            }
18609            let padded = if f.len() == 1 {
18610                alloc::format!("{f}0")
18611            } else {
18612                f.to_string()
18613            };
18614            padded.parse().ok()?
18615        }
18616    };
18617    let total = dollars.checked_mul(100)?.checked_add(cents)?;
18618    Some(if neg { -total } else { total })
18619}
18620
18621/// v7.17.0 Phase 3.P0-34 — parse a PG `timetz` literal
18622/// `HH:MM:SS[.fraction]±HH[:MM]` into (us, offset_secs).
18623///
18624/// The offset suffix is MANDATORY: SPG doesn't have a session TZ
18625/// wired into eval, so a bare `HH:MM:SS` literal would be
18626/// ambiguous. Returns None for any parse failure or out-of-range
18627/// component — caller surfaces as a hard SQL error.
18628///
18629/// Offset range: ±14 hours (±50400 seconds), matching PG's
18630/// internal limit.
18631fn parse_timetz_str(s: &str) -> Option<(i64, i32)> {
18632    let s = s.trim();
18633    // Find the offset sign — scan from right since the time part
18634    // never contains '+' / '-' (after the optional fractional dot
18635    // it's all digits and ':').
18636    let bytes = s.as_bytes();
18637    let sign_pos = bytes
18638        .iter()
18639        .enumerate()
18640        .rev()
18641        .find(|&(_, &b)| b == b'+' || b == b'-')
18642        .map(|(i, _)| i)?;
18643    if sign_pos == 0 {
18644        return None; // bare sign — no time component
18645    }
18646    let time_part = &s[..sign_pos];
18647    let offset_part = &s[sign_pos..];
18648    let us = parse_time_str(time_part)?;
18649    let sign: i32 = if offset_part.starts_with('+') { 1 } else { -1 };
18650    let offset_body = &offset_part[1..];
18651    let (hh_str, mm_str) = match offset_body.split_once(':') {
18652        Some((h, m)) => (h, m),
18653        None => (offset_body, "0"),
18654    };
18655    let hh: i32 = hh_str.parse().ok()?;
18656    let mm: i32 = mm_str.parse().ok()?;
18657    if !(0..=14).contains(&hh) || !(0..=59).contains(&mm) {
18658        return None;
18659    }
18660    let total = sign * (hh * 3600 + mm * 60);
18661    if total.abs() > 50_400 {
18662        return None;
18663    }
18664    Some((us, total))
18665}
18666
18667/// v7.17.0 Phase 3.P0-33 — funnel an integer literal through MySQL
18668/// YEAR range validation: 0 sentinel or 1901..=2155. Out-of-range
18669/// surfaces as a hard SQL error (no silent truncation, mirrors PG
18670/// `time_in` / `uuid_in` discipline).
18671fn coerce_int_to_year(n: i64, col_name: &str) -> Result<Value, EngineError> {
18672    if n == 0 || (1901..=2155).contains(&n) {
18673        // u16::try_from cannot fail in this range; the cast also
18674        // covers the 0 sentinel.
18675        return Ok(Value::Year(n as u16));
18676    }
18677    Err(EngineError::Eval(EvalError::TypeMismatch {
18678        detail: alloc::format!(
18679            "year value out of range: {n} (column `{col_name}`; \
18680             MySQL accepts 0 or 1901..=2155)"
18681        ),
18682    }))
18683}
18684
18685/// v7.17.0 Phase 3.P0-32 — parse a PG `time` literal
18686/// `HH:MM:SS[.fraction]` into microseconds since 00:00:00.
18687///
18688/// Accepts:
18689///   * `HH:MM:SS`            — exact-second precision
18690///   * `HH:MM:SS.f` .. `.ffffff` — 1-6 fractional digits, right-padded
18691///     with zeros to microseconds
18692///
18693/// Range: hour 0..=23, minute 0..=59, second 0..=59. Anything else
18694/// returns None — caller surfaces as a hard SQL error (no silent
18695/// truncation, matches PG's `time_in` behaviour).
18696fn parse_time_str(s: &str) -> Option<i64> {
18697    let s = s.trim();
18698    let (hms, frac) = match s.split_once('.') {
18699        Some((h, f)) => (h, Some(f)),
18700        None => (s, None),
18701    };
18702    let mut parts = hms.split(':');
18703    let hh: u32 = parts.next()?.parse().ok()?;
18704    let mm: u32 = parts.next()?.parse().ok()?;
18705    let ss: u32 = parts.next()?.parse().ok()?;
18706    if parts.next().is_some() {
18707        return None;
18708    }
18709    if hh > 23 || mm > 59 || ss > 59 {
18710        return None;
18711    }
18712    let frac_us: i64 = match frac {
18713        None => 0,
18714        Some(f) => {
18715            if f.is_empty() || f.len() > 6 || !f.bytes().all(|b| b.is_ascii_digit()) {
18716                return None;
18717            }
18718            // Right-pad with zeros so '.5' = 500000 µsec.
18719            let mut padded = alloc::string::String::with_capacity(6);
18720            padded.push_str(f);
18721            while padded.len() < 6 {
18722                padded.push('0');
18723            }
18724            padded.parse().ok()?
18725        }
18726    };
18727    Some(
18728        i64::from(hh) * 3_600_000_000
18729            + i64::from(mm) * 60_000_000
18730            + i64::from(ss) * 1_000_000
18731            + frac_us,
18732    )
18733}
18734
18735const fn column_type_to_data_type(t: ColumnTypeName) -> DataType {
18736    match t {
18737        ColumnTypeName::SmallInt => DataType::SmallInt,
18738        ColumnTypeName::Int => DataType::Int,
18739        ColumnTypeName::BigInt => DataType::BigInt,
18740        ColumnTypeName::Float => DataType::Float,
18741        ColumnTypeName::Text => DataType::Text,
18742        ColumnTypeName::Varchar(n) => DataType::Varchar(n),
18743        ColumnTypeName::Char(n) => DataType::Char(n),
18744        ColumnTypeName::Bool => DataType::Bool,
18745        ColumnTypeName::Vector { dim, encoding } => DataType::Vector {
18746            dim,
18747            encoding: match encoding {
18748                SqlVecEncoding::F32 => VecEncoding::F32,
18749                SqlVecEncoding::Sq8 => VecEncoding::Sq8,
18750                SqlVecEncoding::F16 => VecEncoding::F16,
18751            },
18752        },
18753        ColumnTypeName::Numeric(precision, scale) => DataType::Numeric { precision, scale },
18754        ColumnTypeName::Date => DataType::Date,
18755        ColumnTypeName::Timestamp => DataType::Timestamp,
18756        ColumnTypeName::Timestamptz => DataType::Timestamptz,
18757        ColumnTypeName::Json => DataType::Json,
18758        ColumnTypeName::Jsonb => DataType::Jsonb,
18759        ColumnTypeName::Bytes => DataType::Bytes,
18760        ColumnTypeName::TextArray => DataType::TextArray,
18761        ColumnTypeName::IntArray => DataType::IntArray,
18762        ColumnTypeName::BigIntArray => DataType::BigIntArray,
18763        ColumnTypeName::TsVector => DataType::TsVector,
18764        ColumnTypeName::TsQuery => DataType::TsQuery,
18765        ColumnTypeName::Uuid => DataType::Uuid,
18766        ColumnTypeName::Time => DataType::Time,
18767        ColumnTypeName::Year => DataType::Year,
18768        ColumnTypeName::TimeTz => DataType::TimeTz,
18769        ColumnTypeName::Money => DataType::Money,
18770        ColumnTypeName::Range(k) => DataType::Range(match k {
18771            spg_sql::ast::RangeKindAst::Int4 => spg_storage::RangeKind::Int4,
18772            spg_sql::ast::RangeKindAst::Int8 => spg_storage::RangeKind::Int8,
18773            spg_sql::ast::RangeKindAst::Num => spg_storage::RangeKind::Num,
18774            spg_sql::ast::RangeKindAst::Ts => spg_storage::RangeKind::Ts,
18775            spg_sql::ast::RangeKindAst::TsTz => spg_storage::RangeKind::TsTz,
18776            spg_sql::ast::RangeKindAst::Date => spg_storage::RangeKind::Date,
18777        }),
18778        ColumnTypeName::Hstore => DataType::Hstore,
18779        ColumnTypeName::IntArray2D => DataType::IntArray2D,
18780        ColumnTypeName::BigIntArray2D => DataType::BigIntArray2D,
18781        ColumnTypeName::TextArray2D => DataType::TextArray2D,
18782    }
18783}
18784
18785/// Convert an INSERT VALUES expression to a storage Value. Supports literal
18786/// expressions, unary-minus over numeric literals, and pgvector-style
18787/// `'[..]'::vector` cast (v1.2). Anything more complex returns `Unsupported`.
18788fn literal_expr_to_value(expr: Expr) -> Result<Value, EngineError> {
18789    match expr {
18790        Expr::Literal(l) => Ok(literal_to_value(l)),
18791        Expr::Cast { expr, target } => {
18792            let inner_value = literal_expr_to_value(*expr)?;
18793            crate::eval::cast_value(inner_value, target).map_err(EngineError::Eval)
18794        }
18795        Expr::Unary {
18796            op: UnOp::Neg,
18797            expr,
18798        } => match *expr {
18799            Expr::Literal(Literal::Integer(n)) => {
18800                // Fold to i32 if it fits, else BigInt. Parser emits Integer(i64)
18801                // — overflow on negate of i64::MIN is the one edge case.
18802                let neg = n.checked_neg().ok_or_else(|| {
18803                    EngineError::Unsupported("integer literal overflow on negation".into())
18804                })?;
18805                Ok(int_value_for(neg))
18806            }
18807            Expr::Literal(Literal::Float(x)) => Ok(Value::Float(-x)),
18808            other => Err(EngineError::Unsupported(alloc::format!(
18809                "unary minus over non-literal expression: {other:?}"
18810            ))),
18811        },
18812        // v7.10.10 — `ARRAY[lit, lit, …]` constructor accepted at
18813        // INSERT-time. Each element must reduce to a Value through
18814        // `literal_expr_to_value`; NULL elements become `None`.
18815        // v7.11.13 — deduce shape from element values: all Int →
18816        // IntArray; any BigInt → BigIntArray (widening); any Text
18817        // → TextArray. Cast targets (`ARRAY[]::INT[]`) flow through
18818        // the outer Cast arm before reaching here and re-coerce.
18819        Expr::Array(items) => {
18820            let mut materialised: alloc::vec::Vec<Value> =
18821                alloc::vec::Vec::with_capacity(items.len());
18822            for elem in items {
18823                materialised.push(literal_expr_to_value(elem)?);
18824            }
18825            Ok(array_literal_widen(materialised))
18826        }
18827        // Any other Expr shape — fall back to a general evaluation
18828        // against an empty row + empty schema. This unblocks the
18829        // app-common patterns where INSERT VALUES carries a
18830        // non-correlated function call:
18831        //   INSERT INTO t VALUES (concat('U-', 42))
18832        //   INSERT INTO t VALUES (now())
18833        //   INSERT INTO t VALUES (format('%s-%s', 'a', 'b'))
18834        // Any expression that references a column or `$N`
18835        // placeholder fails cleanly inside `eval_expr` with a
18836        // descriptive error; literals + casts + ARRAY[…] continue
18837        // to take the fast paths above so the hot INSERT path is
18838        // unchanged on the common case.
18839        other => {
18840            let empty_schema: alloc::vec::Vec<spg_storage::ColumnSchema> = alloc::vec::Vec::new();
18841            let ctx = EvalContext::new(&empty_schema, None);
18842            let empty_row = spg_storage::Row::new(alloc::vec::Vec::new());
18843            crate::eval::eval_expr(&other, &empty_row, &ctx).map_err(EngineError::Eval)
18844        }
18845    }
18846}
18847
18848fn literal_to_value(l: Literal) -> Value {
18849    match l {
18850        Literal::Integer(n) => int_value_for(n),
18851        Literal::Float(x) => Value::Float(x),
18852        Literal::String(s) => Value::Text(s),
18853        Literal::Bool(b) => Value::Bool(b),
18854        Literal::Null => Value::Null,
18855        Literal::Vector(v) => Value::Vector(v),
18856        Literal::TextArray(items) => Value::TextArray(items),
18857        Literal::IntArray(items) => Value::IntArray(items),
18858        Literal::BigIntArray(items) => Value::BigIntArray(items),
18859        Literal::Interval { months, micros, .. } => Value::Interval { months, micros },
18860    }
18861}
18862
18863/// Pick `Int` (`i32`) when the literal fits, else `BigInt`. `INT` vs `BIGINT`
18864/// columns will still enforce the right tag downstream — this is just the
18865/// default we synthesise from an unannotated integer literal.
18866fn int_value_for(n: i64) -> Value {
18867    if let Ok(small) = i32::try_from(n) {
18868        Value::Int(small)
18869    } else {
18870        Value::BigInt(n)
18871    }
18872}
18873
18874/// Widen / narrow `v` to fit `expected`. Numerics permit safe widening
18875/// (`Int → BigInt`, `Int/BigInt → Float`) and best-effort narrowing
18876/// (`BigInt → Int` succeeds only when the value fits in `i32`). Everything
18877/// else returns `TypeMismatch` carrying the column name for caller diagnostics.
18878/// `NULL` is always permitted; the nullability check happens later in storage.
18879#[allow(clippy::too_many_lines)]
18880/// v7.17.0 Phase 4.4 — reject negative integer values on UNSIGNED
18881/// columns. Called after `coerce_value` at each INSERT / UPDATE
18882/// site that has ColumnSchema context. NULL passes through (a
18883/// nullable UNSIGNED column can legitimately hold NULL).
18884fn check_unsigned_range(
18885    v: &Value,
18886    schema: &ColumnSchema,
18887    position: usize,
18888) -> Result<(), EngineError> {
18889    if !schema.is_unsigned {
18890        return Ok(());
18891    }
18892    let n = match v {
18893        Value::SmallInt(x) => i64::from(*x),
18894        Value::Int(x) => i64::from(*x),
18895        Value::BigInt(x) => *x,
18896        _ => return Ok(()), // non-integer cells (NULL, default) skip
18897    };
18898    if n < 0 {
18899        return Err(EngineError::Unsupported(alloc::format!(
18900            "column {:?} is UNSIGNED but got negative value {n} at position {position}",
18901            schema.name
18902        )));
18903    }
18904    Ok(())
18905}
18906
18907fn coerce_value(
18908    v: Value,
18909    expected: DataType,
18910    col_name: &str,
18911    position: usize,
18912) -> Result<Value, EngineError> {
18913    if v.is_null() {
18914        return Ok(Value::Null);
18915    }
18916    let actual = v.data_type().expect("non-null");
18917    if actual == expected {
18918        return Ok(v);
18919    }
18920    let coerced = match (v, expected) {
18921        (Value::Int(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
18922        (Value::Int(n), DataType::Float) => Some(Value::Float(f64::from(n))),
18923        (Value::Int(n), DataType::SmallInt) => i16::try_from(n).ok().map(Value::SmallInt),
18924        (Value::Int(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
18925            i128::from(n),
18926            precision,
18927            scale,
18928            col_name,
18929        )?),
18930        (Value::SmallInt(n), DataType::Int) => Some(Value::Int(i32::from(n))),
18931        (Value::SmallInt(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
18932        (Value::SmallInt(n), DataType::Float) => Some(Value::Float(f64::from(n))),
18933        (Value::SmallInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
18934            i128::from(n),
18935            precision,
18936            scale,
18937            col_name,
18938        )?),
18939        (Value::BigInt(n), DataType::Int) => i32::try_from(n).ok().map(Value::Int),
18940        (Value::BigInt(n), DataType::SmallInt) => i16::try_from(n).ok().map(Value::SmallInt),
18941        #[allow(clippy::cast_precision_loss)]
18942        (Value::BigInt(n), DataType::Float) => Some(Value::Float(n as f64)),
18943        (Value::BigInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
18944            i128::from(n),
18945            precision,
18946            scale,
18947            col_name,
18948        )?),
18949        (Value::Float(x), DataType::Numeric { precision, scale }) => {
18950            Some(numeric_from_float(x, precision, scale, col_name)?)
18951        }
18952        // v7.17.0 Phase 3.P0-67 — Text → NUMERIC. Parse a
18953        // canonical decimal text (`"-1234.56"` / `"42"` /
18954        // `"0.0001"`) into `(mantissa, source_scale)` and rescale
18955        // to the column's declared scale. Required for prepared
18956        // binds: `value_to_literal` flattens a Value::Numeric
18957        // into a TEXT literal because Literal carries no native
18958        // Numeric variant, so the placeholder substitution path
18959        // reaches coerce_value as Text → Numeric. Without this
18960        // arm the round-trip surfaces a TypeMismatch even though
18961        // the cell already left the engine as a valid Numeric.
18962        (Value::Text(s), DataType::Numeric { precision, scale }) => {
18963            let Some((mantissa, src_scale)) = parse_numeric_text(&s) else {
18964                return Err(EngineError::Eval(EvalError::TypeMismatch {
18965                    detail: alloc::format!("cannot parse {s:?} as NUMERIC for column `{col_name}`"),
18966                }));
18967            };
18968            Some(numeric_rescale(
18969                mantissa, src_scale, precision, scale, col_name,
18970            )?)
18971        }
18972        // Text → DATE / TIMESTAMP: parse canonical text forms.
18973        (Value::Text(s), DataType::Date) => {
18974            let d = eval::parse_date_literal(&s).ok_or_else(|| {
18975                EngineError::Eval(EvalError::TypeMismatch {
18976                    detail: alloc::format!("cannot parse {s:?} as DATE for column `{col_name}`"),
18977                })
18978            })?;
18979            Some(Value::Date(d))
18980        }
18981        // v7.14.0 — MySQL DEFAULT clauses quote integer / float
18982        // / boolean literals (`DEFAULT '0'`, `DEFAULT '1'`,
18983        // `DEFAULT '3.14'`, `DEFAULT 'true'`). Coerce the text
18984        // form to the column's numeric / bool type at DEFAULT-
18985        // installation time so the storage check sees a typed
18986        // value. Parse failures fall through to TypeMismatch.
18987        (Value::Text(s), DataType::SmallInt) => s.parse::<i16>().ok().map(Value::SmallInt),
18988        (Value::Text(s), DataType::Int) => s.parse::<i32>().ok().map(Value::Int),
18989        (Value::Text(s), DataType::BigInt) => s.parse::<i64>().ok().map(Value::BigInt),
18990        (Value::Text(s), DataType::Float) => s.parse::<f64>().ok().map(Value::Float),
18991        (Value::Text(s), DataType::Bool) => match s.to_ascii_lowercase().as_str() {
18992            "0" | "false" | "f" | "no" | "off" => Some(Value::Bool(false)),
18993            "1" | "true" | "t" | "yes" | "on" => Some(Value::Bool(true)),
18994            _ => None,
18995        },
18996        // v7.17.0 Phase 3.P0-46 — MySQL TINYINT(1) (which Phase 4.3
18997        // classifies as DataType::Bool) is the storage shape every
18998        // mysqldump-restored boolean column lands in. mysqldump emits
18999        // the values as integer `0` / `1` literals, so int → bool
19000        // coerce on INSERT is required for a 0-change cutover. MySQL's
19001        // rule is "any non-zero is truthy"; we follow that for all
19002        // signed int widths so the same coerce path serves an
19003        // explicit `BOOLEAN` column too.
19004        (Value::Int(n), DataType::Bool) => Some(Value::Bool(n != 0)),
19005        (Value::SmallInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
19006        (Value::BigInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
19007        // v4.9: Text ↔ JSON coercion. No structural validation —
19008        // any text literal is accepted; the responsibility for
19009        // valid JSON lies with the producer.
19010        (Value::Text(s), DataType::Json | DataType::Jsonb) => Some(Value::Json(s)),
19011        (Value::Json(s), DataType::Text) => Some(Value::Text(s)),
19012        // v7.13.3 — mailrs round-7 S10. SPG's storage represents
19013        // both JSON and JSONB on-disk as `Value::Json(String)` —
19014        // they share the underlying text payload. The cast
19015        // `'<text>'::jsonb` produces a Value::Json that needs to
19016        // satisfy a DataType::Jsonb column. Identity coerce in
19017        // both directions so JSON ↔ JSONB assignments work at all
19018        // INSERT / ALTER COLUMN TYPE / DEFAULT contexts.
19019        (Value::Json(s), DataType::Jsonb | DataType::Json) => Some(Value::Json(s)),
19020        // v7.10.4 — Text → BYTEA. Decode PG-style literal forms:
19021        //   - Hex:    `\x48656c6c6f`  (case-insensitive hex pairs)
19022        //   - Escape: `Hello\\000world`  (backslash + octal triples)
19023        //   - Plain:  any string → raw UTF-8 bytes (PG also accepts)
19024        // Errors surface as TypeMismatch so the operator gets a
19025        // clear "this literal isn't a bytea literal" hint.
19026        (Value::Text(s), DataType::Bytes) => {
19027            let bytes = decode_bytea_literal(&s).map_err(|e| {
19028                EngineError::Eval(EvalError::TypeMismatch {
19029                    detail: alloc::format!(
19030                        "cannot parse {s:?} as BYTEA for column `{col_name}`: {e}"
19031                    ),
19032                })
19033            })?;
19034            Some(Value::Bytes(bytes))
19035        }
19036        // v7.10.4 — BYTEA → Text round-trip uses the PG hex
19037        // output (lowercase, `\x` prefix). Important when a
19038        // SELECT pulls a bytea cell through a Text column path.
19039        (Value::Bytes(b), DataType::Text) => Some(Value::Text(encode_bytea_hex(&b))),
19040        // v7.17.0 — Text → UUID. PG accepts canonical hyphenated,
19041        // unhyphenated, uppercase, and `{...}`-braced forms; we
19042        // funnel all four through `spg_storage::parse_uuid_str`.
19043        // A malformed literal surfaces as a SQL TypeMismatch
19044        // rather than silently inserting garbage — `0-change
19045        // cutover` requires that an app inserting bad UUID text
19046        // sees the same hard error PG would raise.
19047        (Value::Text(s), DataType::Uuid) => match spg_storage::parse_uuid_str(&s) {
19048            Some(b) => Some(Value::Uuid(b)),
19049            None => {
19050                return Err(EngineError::Eval(EvalError::TypeMismatch {
19051                    detail: alloc::format!(
19052                        "invalid input syntax for type uuid: {s:?} (column `{col_name}`)"
19053                    ),
19054                }));
19055            }
19056        },
19057        // v7.17.0 — UUID → Text canonical 8-4-4-4-12 lowercase.
19058        // Surfaces when a SELECT plucks a uuid cell through a
19059        // Text column path (e.g. INSERT INTO log SELECT id::text
19060        // FROM other_table).
19061        (Value::Uuid(b), DataType::Text) => Some(Value::Text(spg_storage::format_uuid(&b))),
19062        // v7.17.0 Phase 3.P0-32 — Text → TIME. Accepts
19063        // `HH:MM:SS` and `HH:MM:SS.ffffff` (1-6 fractional digits).
19064        // Out-of-range hour/min/sec is a hard SQL error (no
19065        // silent truncation — same 0-change-cutover discipline
19066        // we apply to UUID).
19067        (Value::Text(s), DataType::Time) => match parse_time_str(&s) {
19068            Some(us) => Some(Value::Time(us)),
19069            None => {
19070                return Err(EngineError::Eval(EvalError::TypeMismatch {
19071                    detail: alloc::format!(
19072                        "invalid input syntax for type time: {s:?} (column `{col_name}`)"
19073                    ),
19074                }));
19075            }
19076        },
19077        // v7.17.0 Phase 3.P0-32 — TIME → Text canonical `HH:MM:SS[.ffffff]`.
19078        (Value::Time(us), DataType::Text) => Some(Value::Text(eval::format_time(us))),
19079        // v7.17.0 Phase 3.P0-33 — int / bigint → YEAR. Range
19080        // check enforces the MySQL canonical 1901..=2155 + 0
19081        // sentinel; out-of-range is a hard SQL error (no silent
19082        // truncation, mirrors P0-32 / P0-25 discipline).
19083        (Value::SmallInt(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
19084        (Value::Int(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
19085        (Value::BigInt(n), DataType::Year) => Some(coerce_int_to_year(n, col_name)?),
19086        // Text → YEAR. Accepts the 4-digit decimal form only;
19087        // two-digit YEAR (`'99'` → 1999) was deprecated in MySQL
19088        // 5.7 and is out of scope for v7.17.0.
19089        (Value::Text(s), DataType::Year) => match s.trim().parse::<i64>() {
19090            Ok(n) => Some(coerce_int_to_year(n, col_name)?),
19091            Err(_) => {
19092                return Err(EngineError::Eval(EvalError::TypeMismatch {
19093                    detail: alloc::format!(
19094                        "invalid input syntax for type year: {s:?} (column `{col_name}`)"
19095                    ),
19096                }));
19097            }
19098        },
19099        // YEAR → Text 4-digit zero-padded.
19100        (Value::Year(y), DataType::Text) => Some(Value::Text(alloc::format!("{y:04}"))),
19101        // v7.17.0 Phase 3.P0-34 — Text → TIMETZ. Mandatory
19102        // signed offset suffix; missing offset is a hard error
19103        // (SPG has no session TZ wired into eval, unlike PG).
19104        (Value::Text(s), DataType::TimeTz) => match parse_timetz_str(&s) {
19105            Some((us, offset_secs)) => Some(Value::TimeTz { us, offset_secs }),
19106            None => {
19107                return Err(EngineError::Eval(EvalError::TypeMismatch {
19108                    detail: alloc::format!(
19109                        "invalid input syntax for type time with time zone: \
19110                         {s:?} (column `{col_name}`)"
19111                    ),
19112                }));
19113            }
19114        },
19115        // TIMETZ → Text canonical `HH:MM:SS[.ffffff]±HH[:MM]`.
19116        (Value::TimeTz { us, offset_secs }, DataType::Text) => {
19117            Some(Value::Text(eval::format_timetz(us, offset_secs)))
19118        }
19119        // v7.17.0 Phase 3.P0-35 — Text → MONEY. Accepts `$N.NN`,
19120        // `$N,NNN.NN`, optional leading `-`. Bare numeric literals
19121        // arrive via the Int/BigInt/Float/Numeric arms below.
19122        (Value::Text(s), DataType::Money) => match parse_money_str(&s) {
19123            Some(c) => Some(Value::Money(c)),
19124            None => {
19125                return Err(EngineError::Eval(EvalError::TypeMismatch {
19126                    detail: alloc::format!(
19127                        "invalid input syntax for type money: {s:?} (column `{col_name}`)"
19128                    ),
19129                }));
19130            }
19131        },
19132        // Int / BigInt / SmallInt / Float / Numeric → MONEY.
19133        // Bare numeric literal is interpreted as a major-unit
19134        // amount (matches PG: `100`::money → $100.00 = 10000 cents).
19135        (Value::SmallInt(n), DataType::Money) => {
19136            Some(Value::Money(i64::from(n).saturating_mul(100)))
19137        }
19138        (Value::Int(n), DataType::Money) => Some(Value::Money(i64::from(n).saturating_mul(100))),
19139        (Value::BigInt(n), DataType::Money) => Some(Value::Money(n.saturating_mul(100))),
19140        (Value::Float(x), DataType::Money) => {
19141            // Round half-away-from-zero to cents (no_std — no
19142            // `f64::round`, so hand-roll via biased truncation).
19143            let scaled = x * 100.0;
19144            let cents = if scaled >= 0.0 {
19145                (scaled + 0.5) as i64
19146            } else {
19147                (scaled - 0.5) as i64
19148            };
19149            Some(Value::Money(cents))
19150        }
19151        (Value::Numeric { scaled, scale }, DataType::Money) => {
19152            // Convert exact decimal to cents (scale 2). If scale > 2,
19153            // round half-away-from-zero. If scale < 2, multiply up.
19154            let cents = if scale == 2 {
19155                scaled
19156            } else if scale < 2 {
19157                let mult = 10_i128.pow(u32::from(2 - scale));
19158                scaled.saturating_mul(mult)
19159            } else {
19160                let div = 10_i128.pow(u32::from(scale - 2));
19161                let half = div / 2;
19162                let bias = if scaled >= 0 { half } else { -half };
19163                (scaled + bias) / div
19164            };
19165            Some(Value::Money(i64::try_from(cents).unwrap_or(i64::MAX)))
19166        }
19167        // MONEY → Text canonical `$N,NNN.CC`.
19168        (Value::Money(c), DataType::Text) => Some(Value::Text(eval::format_money(c))),
19169        // v7.17.0 Phase 3.P0-38 — Text → Range. Accepts canonical
19170        // PG forms: `'empty'`, `'[a,b)'`, `'(a,b]'`, `'[a,b]'`,
19171        // `'(a,b)'`, with empty lower or upper for unbounded.
19172        (Value::Text(s), DataType::Range(kind)) => match parse_range_str(&s, kind) {
19173            Some(v) => Some(v),
19174            None => {
19175                return Err(EngineError::Eval(EvalError::TypeMismatch {
19176                    detail: alloc::format!(
19177                        "invalid input syntax for range type: {s:?} (column `{col_name}`)"
19178                    ),
19179                }));
19180            }
19181        },
19182        // Range → Text canonical form (`[a,b)`, `'empty'`, etc).
19183        (v @ Value::Range { .. }, DataType::Text) => Some(Value::Text(format_range_str(&v))),
19184        // v7.17.0 Phase 3.P0-39 — Text → Hstore.
19185        (Value::Text(s), DataType::Hstore) => match parse_hstore_str(&s) {
19186            Some(pairs) => Some(Value::Hstore(pairs)),
19187            None => {
19188                return Err(EngineError::Eval(EvalError::TypeMismatch {
19189                    detail: alloc::format!(
19190                        "invalid input syntax for type hstore: {s:?} (column `{col_name}`)"
19191                    ),
19192                }));
19193            }
19194        },
19195        // Hstore → Text canonical `"k"=>"v"` form.
19196        (Value::Hstore(pairs), DataType::Text) => Some(Value::Text(format_hstore_str(&pairs))),
19197        // v7.17.0 Phase 3.P0-40 — Text → 2D arrays via PG
19198        // external `'{{a,b},{c,d}}'` literal.
19199        (Value::Text(s), DataType::IntArray2D) => match parse_int_2d_literal(&s) {
19200            Ok(m) => Some(Value::IntArray2D(m)),
19201            Err(e) => {
19202                return Err(EngineError::Eval(EvalError::TypeMismatch {
19203                    detail: alloc::format!(
19204                        "invalid input syntax for INT[][]: {s:?} (column `{col_name}`): {e}"
19205                    ),
19206                }));
19207            }
19208        },
19209        (Value::Text(s), DataType::BigIntArray2D) => match parse_bigint_2d_literal(&s) {
19210            Ok(m) => Some(Value::BigIntArray2D(m)),
19211            Err(e) => {
19212                return Err(EngineError::Eval(EvalError::TypeMismatch {
19213                    detail: alloc::format!(
19214                        "invalid input syntax for BIGINT[][]: {s:?} (column `{col_name}`): {e}"
19215                    ),
19216                }));
19217            }
19218        },
19219        (Value::Text(s), DataType::TextArray2D) => match parse_text_2d_literal(&s) {
19220            Ok(m) => Some(Value::TextArray2D(m)),
19221            Err(e) => {
19222                return Err(EngineError::Eval(EvalError::TypeMismatch {
19223                    detail: alloc::format!(
19224                        "invalid input syntax for TEXT[][]: {s:?} (column `{col_name}`): {e}"
19225                    ),
19226                }));
19227            }
19228        },
19229        // 2D arrays → Text canonical nested form.
19230        (Value::IntArray2D(rows), DataType::Text) => Some(Value::Text(format_int_2d_text(&rows))),
19231        (Value::BigIntArray2D(rows), DataType::Text) => {
19232            Some(Value::Text(format_bigint_2d_text(&rows)))
19233        }
19234        (Value::TextArray2D(rows), DataType::Text) => Some(Value::Text(format_text_2d_text(&rows))),
19235        // v7.10.11 — Text → TEXT[]. Decode PG's external array
19236        // form `'{a,b,NULL}'`. NULL element token (case-insensitive)
19237        // is the literal `NULL`; everything else is a quoted or
19238        // unquoted text element. mailrs `'{label1,label2}'::TEXT[]`.
19239        (Value::Text(s), DataType::TextArray) => {
19240            let arr = decode_text_array_literal(&s).map_err(|e| {
19241                EngineError::Eval(EvalError::TypeMismatch {
19242                    detail: alloc::format!(
19243                        "cannot parse {s:?} as TEXT[] for column `{col_name}`: {e}"
19244                    ),
19245                })
19246            })?;
19247            Some(Value::TextArray(arr))
19248        }
19249        // v7.16.0 — Text → IntArray / BigIntArray for the
19250        // spg-sqlx Bind path. Decode the PG external form
19251        // `{1,2,3}` as a TEXT array first, then parse each
19252        // element as int. Same shape as the TextArray decode
19253        // above with an element-wise narrow.
19254        (Value::Text(s), DataType::IntArray) => {
19255            let arr = decode_text_array_literal(&s).map_err(|e| {
19256                EngineError::Eval(EvalError::TypeMismatch {
19257                    detail: alloc::format!(
19258                        "cannot parse {s:?} as INT[] for column `{col_name}`: {e}"
19259                    ),
19260                })
19261            })?;
19262            let mut out: Vec<Option<i32>> = Vec::with_capacity(arr.len());
19263            for elem in arr {
19264                match elem {
19265                    None => out.push(None),
19266                    Some(t) => {
19267                        let n: i32 = t.parse().map_err(|_| {
19268                            EngineError::Eval(EvalError::TypeMismatch {
19269                                detail: alloc::format!(
19270                                    "cannot parse {t:?} as INT element for `{col_name}`"
19271                                ),
19272                            })
19273                        })?;
19274                        out.push(Some(n));
19275                    }
19276                }
19277            }
19278            Some(Value::IntArray(out))
19279        }
19280        (Value::Text(s), DataType::BigIntArray) => {
19281            let arr = decode_text_array_literal(&s).map_err(|e| {
19282                EngineError::Eval(EvalError::TypeMismatch {
19283                    detail: alloc::format!(
19284                        "cannot parse {s:?} as BIGINT[] for column `{col_name}`: {e}"
19285                    ),
19286                })
19287            })?;
19288            let mut out: Vec<Option<i64>> = Vec::with_capacity(arr.len());
19289            for elem in arr {
19290                match elem {
19291                    None => out.push(None),
19292                    Some(t) => {
19293                        let n: i64 = t.parse().map_err(|_| {
19294                            EngineError::Eval(EvalError::TypeMismatch {
19295                                detail: alloc::format!(
19296                                    "cannot parse {t:?} as BIGINT element for `{col_name}`"
19297                                ),
19298                            })
19299                        })?;
19300                        out.push(Some(n));
19301                    }
19302                }
19303            }
19304            Some(Value::BigIntArray(out))
19305        }
19306        // v7.10.11 — TEXT[] → Text round-trip uses PG's
19307        // external array form (`{a,b,NULL}`). Lets a SELECT
19308        // pull an array column through any Text-side codepath.
19309        (Value::TextArray(items), DataType::Text) => Some(Value::Text(encode_text_array(&items))),
19310        // v7.17.0 Phase 3.P0-68 — Text → VECTOR auto-coerce.
19311        // Matches the existing Text → TsVector arm and the
19312        // `::vector` cast: PG-canonical pgvector external form
19313        // (`'[1, 2, -3]'`) becomes a typed Vector value at the
19314        // column boundary. Dim mismatch surfaces as TypeMismatch.
19315        // For SQ8 / HALF encodings we chain through the standard
19316        // quantise helpers so the storage shape matches the
19317        // declared encoding without a second coerce pass.
19318        (Value::Text(s), DataType::Vector { dim, encoding }) => {
19319            let parsed = eval::parse_vector_text(&s).ok_or_else(|| {
19320                EngineError::Eval(EvalError::TypeMismatch {
19321                    detail: alloc::format!("cannot parse {s:?} as VECTOR for column `{col_name}`"),
19322                })
19323            })?;
19324            if parsed.len() != dim as usize {
19325                return Err(EngineError::Eval(EvalError::TypeMismatch {
19326                    detail: alloc::format!(
19327                        "VECTOR({dim}) column `{col_name}` rejects literal of length {}",
19328                        parsed.len()
19329                    ),
19330                }));
19331            }
19332            Some(match encoding {
19333                VecEncoding::F32 => Value::Vector(parsed),
19334                VecEncoding::Sq8 => Value::Sq8Vector(spg_storage::quantize::quantize(&parsed)),
19335                VecEncoding::F16 => {
19336                    Value::HalfVector(spg_storage::halfvec::HalfVector::from_f32_slice(&parsed))
19337                }
19338            })
19339        }
19340        // v7.16.1 — Text → TSVECTOR auto-coerce for the
19341        // INSERT-side wire path (mailrs round-9 A.2.a). PG
19342        // implicitly promotes the TEXT literal at INSERT into a
19343        // TSVECTOR column; SPG previously rejected with a hard
19344        // type mismatch, blocking 23,276 pg_dump rows into
19345        // `messages.search_vector`. We route through the same
19346        // `decode_tsvector_external` the `::tsvector` cast
19347        // already uses, so PG-canonical forms (`'word'`,
19348        // `'word:1A,2B'`, multi-lexeme, empty `''`) all parse.
19349        (Value::Text(s), DataType::TsVector) => {
19350            let lexs = eval::decode_tsvector_external(&s).map_err(|e| {
19351                EngineError::Eval(EvalError::TypeMismatch {
19352                    detail: alloc::format!(
19353                        "cannot parse {s:?} as TSVECTOR for column `{col_name}`: {e}"
19354                    ),
19355                })
19356            })?;
19357            Some(Value::TsVector(lexs))
19358        }
19359        (Value::Text(s), DataType::Timestamp | DataType::Timestamptz) => {
19360            let t = eval::parse_timestamp_literal(&s).ok_or_else(|| {
19361                EngineError::Eval(EvalError::TypeMismatch {
19362                    detail: alloc::format!(
19363                        "cannot parse {s:?} as TIMESTAMP for column `{col_name}`"
19364                    ),
19365                })
19366            })?;
19367            Some(Value::Timestamp(t))
19368        }
19369        // DATE ↔ TIMESTAMP convertibility (DATE → midnight,
19370        // TIMESTAMP → day truncation).
19371        (Value::Date(d), DataType::Timestamp | DataType::Timestamptz) => {
19372            Some(Value::Timestamp(i64::from(d) * 86_400_000_000))
19373        }
19374        // v7.9.21 — Value::Timestamp lands in either Timestamp
19375        // or Timestamptz columns; the on-disk layout is the
19376        // same i64 microseconds UTC.
19377        (Value::Timestamp(t), DataType::Timestamptz) => Some(Value::Timestamp(t)),
19378        (Value::Timestamp(t), DataType::Date) => {
19379            let days = t.div_euclid(86_400_000_000);
19380            i32::try_from(days).ok().map(Value::Date)
19381        }
19382        (
19383            Value::Numeric {
19384                scaled,
19385                scale: src_scale,
19386            },
19387            DataType::Numeric { precision, scale },
19388        ) => Some(numeric_rescale(
19389            scaled, src_scale, precision, scale, col_name,
19390        )?),
19391        #[allow(clippy::cast_precision_loss)]
19392        (Value::Numeric { scaled, scale }, DataType::Float) => {
19393            let mut div = 1.0_f64;
19394            for _ in 0..scale {
19395                div *= 10.0;
19396            }
19397            Some(Value::Float((scaled as f64) / div))
19398        }
19399        (Value::Numeric { scaled, scale }, DataType::Int) => {
19400            let truncated = numeric_truncate_to_integer(scaled, scale);
19401            i32::try_from(truncated).ok().map(Value::Int)
19402        }
19403        (Value::Numeric { scaled, scale }, DataType::BigInt) => {
19404            let truncated = numeric_truncate_to_integer(scaled, scale);
19405            i64::try_from(truncated).ok().map(Value::BigInt)
19406        }
19407        (Value::Numeric { scaled, scale }, DataType::SmallInt) => {
19408            let truncated = numeric_truncate_to_integer(scaled, scale);
19409            i16::try_from(truncated).ok().map(Value::SmallInt)
19410        }
19411        // VARCHAR(n) enforces an upper bound on character count.
19412        (Value::Text(s), DataType::Varchar(max)) => {
19413            if u32::try_from(s.chars().count()).unwrap_or(u32::MAX) <= max {
19414                Some(Value::Text(s))
19415            } else {
19416                return Err(EngineError::Unsupported(alloc::format!(
19417                    "value for VARCHAR({max}) column `{col_name}` exceeds length: \
19418                     {} chars",
19419                    s.chars().count()
19420                )));
19421            }
19422        }
19423        // v6.0.1: f32 → SQ8 INSERT-time quantisation. Triggered
19424        // when the column declares `VECTOR(N) USING SQ8` and
19425        // the INSERT VALUES expression yields a raw f32 vector
19426        // (the normal pgvector-shape literal). Dim mismatch
19427        // falls through the `_ => None` arm and surfaces as
19428        // `TypeMismatch` with the expected SQ8 column type —
19429        // matching the F32 path's existing error.
19430        (
19431            Value::Vector(v),
19432            DataType::Vector {
19433                dim,
19434                encoding: VecEncoding::Sq8,
19435            },
19436        ) if v.len() == dim as usize => Some(Value::Sq8Vector(spg_storage::quantize::quantize(&v))),
19437        // v6.0.3: f32 → f16 INSERT-time conversion for HALF
19438        // columns. Bit-exact at the storage layer (modulo
19439        // half-precision rounding); no rerank pass needed at
19440        // search time.
19441        (
19442            Value::Vector(v),
19443            DataType::Vector {
19444                dim,
19445                encoding: VecEncoding::F16,
19446            },
19447        ) if v.len() == dim as usize => Some(Value::HalfVector(
19448            spg_storage::halfvec::HalfVector::from_f32_slice(&v),
19449        )),
19450        // CHAR(n) right-pads with U+0020 to exactly n chars; if the input
19451        // is already longer we reject (PG truncates trailing-space-only;
19452        // staying strict for v1).
19453        (Value::Text(s), DataType::Char(size)) => {
19454            let len = u32::try_from(s.chars().count()).unwrap_or(u32::MAX);
19455            if len > size {
19456                return Err(EngineError::Unsupported(alloc::format!(
19457                    "value for CHAR({size}) column `{col_name}` exceeds length: \
19458                     {len} chars"
19459                )));
19460            }
19461            let need = (size - len) as usize;
19462            let mut padded = s;
19463            padded.reserve(need);
19464            for _ in 0..need {
19465                padded.push(' ');
19466            }
19467            Some(Value::Text(padded))
19468        }
19469        _ => None,
19470    };
19471    coerced.ok_or(EngineError::Storage(StorageError::TypeMismatch {
19472        column: col_name.into(),
19473        expected,
19474        actual,
19475        position,
19476    }))
19477}
19478
19479/// v7.12.4 — render a function arg list into the
19480/// canonical form the storage layer caches as
19481/// [`spg_storage::FunctionDef::args_repr`]. The catalogue uses
19482/// this string for both display + as a coarse signature key
19483/// for the (deferred) overload resolution v7.12.5+ adds.
19484fn render_function_args(args: &[spg_sql::ast::FunctionArg]) -> alloc::string::String {
19485    use core::fmt::Write;
19486    let mut out = alloc::string::String::from("(");
19487    for (i, a) in args.iter().enumerate() {
19488        if i > 0 {
19489            out.push_str(", ");
19490        }
19491        match a.mode {
19492            spg_sql::ast::FunctionArgMode::In => {}
19493            spg_sql::ast::FunctionArgMode::Out => out.push_str("OUT "),
19494            spg_sql::ast::FunctionArgMode::InOut => out.push_str("INOUT "),
19495        }
19496        if let Some(n) = &a.name {
19497            out.push_str(n);
19498            out.push(' ');
19499        }
19500        match &a.ty {
19501            spg_sql::ast::FunctionArgType::Typed(t) => {
19502                let _ = write!(out, "{t}");
19503            }
19504            spg_sql::ast::FunctionArgType::Raw(s) => out.push_str(s),
19505        }
19506    }
19507    out.push(')');
19508    out
19509}
19510
19511/// v7.19 P5 — true iff `expr` is `unnest(arg)` at the top level
19512/// (case-insensitive). Used by `exec_select_cancel`'s
19513/// projection loop to detect Set-Returning-Function rows that
19514/// need per-row expansion. Only the top-level call counts —
19515/// `coalesce(unnest(arr), 'x')` is NOT a SRF row from the
19516/// projection's perspective; it would surface as an "unknown
19517/// function" mismatch downstream, which is what we want
19518/// (multi-SRF / nested SRF is documented carve-out for v7.19).
19519fn is_top_level_unnest(expr: &spg_sql::ast::Expr) -> bool {
19520    match expr {
19521        spg_sql::ast::Expr::FunctionCall { name, args } => {
19522            name.eq_ignore_ascii_case("unnest") && args.len() == 1
19523        }
19524        _ => false,
19525    }
19526}
19527
19528/// v7.19 P5 — extract the array argument out of a top-level
19529/// `unnest(arg)` call. `None` if `expr` isn't a `unnest` call
19530/// of arity 1 (mirrors `is_top_level_unnest`).
19531fn top_level_unnest_arg(expr: &spg_sql::ast::Expr) -> Option<&spg_sql::ast::Expr> {
19532    match expr {
19533        spg_sql::ast::Expr::FunctionCall { name, args }
19534            if name.eq_ignore_ascii_case("unnest") && args.len() == 1 =>
19535        {
19536            Some(&args[0])
19537        }
19538        _ => None,
19539    }
19540}
19541
19542/// v7.19 P5 — turn an array-typed `Value` into the element list
19543/// `unnest()` projection emits. NULL → empty list (PG: `unnest(NULL)
19544/// = (no rows)`). Non-array values fall through to a type-mismatch
19545/// error.
19546fn array_value_to_elements(v: &Value) -> Result<Vec<Value>, EngineError> {
19547    match v {
19548        Value::Null => Ok(Vec::new()),
19549        Value::TextArray(items) => Ok(items
19550            .iter()
19551            .map(|opt| {
19552                opt.as_ref()
19553                    .map(|s| Value::Text(s.clone()))
19554                    .unwrap_or(Value::Null)
19555            })
19556            .collect()),
19557        Value::IntArray(items) => Ok(items
19558            .iter()
19559            .map(|opt| opt.map(Value::Int).unwrap_or(Value::Null))
19560            .collect()),
19561        Value::BigIntArray(items) => Ok(items
19562            .iter()
19563            .map(|opt| opt.map(Value::BigInt).unwrap_or(Value::Null))
19564            .collect()),
19565        other => Err(EngineError::Eval(EvalError::TypeMismatch {
19566            detail: alloc::format!(
19567                "unnest() expects an array argument, got {:?}",
19568                other.data_type()
19569            ),
19570        })),
19571    }
19572}
19573
19574#[cfg(test)]
19575mod tests {
19576    use super::*;
19577    use alloc::vec;
19578
19579    fn unwrap_command_ok(r: &QueryResult) -> usize {
19580        match r {
19581            QueryResult::CommandOk { affected, .. } => *affected,
19582            QueryResult::Rows { .. } => panic!("expected CommandOk, got Rows"),
19583        }
19584    }
19585
19586    #[test]
19587    fn update_seek_positions_engages_on_indexed_eq() {
19588        let mut e = Engine::new();
19589        e.execute("CREATE TABLE b (id INT NOT NULL, v INT NOT NULL)")
19590            .unwrap();
19591        e.execute("CREATE INDEX b_id ON b (id)").unwrap();
19592        for i in 0..100 {
19593            e.execute(&alloc::format!("INSERT INTO b VALUES ({i}, {i})"))
19594                .unwrap();
19595        }
19596        let stmt = spg_sql::parser::parse_statement("UPDATE b SET v = v + 1 WHERE id = 42")
19597            .expect("parse");
19598        let Statement::Update(u) = stmt else {
19599            panic!("expected Update, got {stmt:?}");
19600        };
19601        let w = u.where_.as_ref().expect("where");
19602        let table = e.catalog().get("b").unwrap();
19603        let schema_cols = table.schema().columns.clone();
19604        // step-by-step: each sub-resolution must succeed.
19605        let Expr::Binary { lhs, op, rhs } = w else {
19606            panic!("WHERE not Binary: {w:?}");
19607        };
19608        assert_eq!(*op, BinOp::Eq, "op not Eq");
19609        let pair = resolve_col_literal_pair(lhs, rhs, &schema_cols, "b");
19610        assert!(
19611            pair.is_some(),
19612            "resolve_col_literal_pair None: lhs={lhs:?} rhs={rhs:?}"
19613        );
19614        let (col_pos, value) = pair.unwrap();
19615        assert!(
19616            table.index_on(col_pos).is_some(),
19617            "no index on col {col_pos}"
19618        );
19619        assert!(
19620            IndexKey::from_value(&value).is_some(),
19621            "IndexKey::from_value None for {value:?}"
19622        );
19623        let positions = try_index_seek_positions(w, &schema_cols, table, "b");
19624        assert_eq!(positions, Some(vec![42]), "seek did not engage");
19625    }
19626
19627    #[test]
19628    fn create_table_registers_schema() {
19629        let mut e = Engine::new();
19630        e.execute("CREATE TABLE foo (a INT NOT NULL, b TEXT)")
19631            .unwrap();
19632        assert_eq!(e.catalog().table_count(), 1);
19633        let t = e.catalog().get("foo").unwrap();
19634        assert_eq!(t.schema().columns.len(), 2);
19635        assert_eq!(t.schema().columns[0].ty, DataType::Int);
19636        assert!(!t.schema().columns[0].nullable);
19637        assert_eq!(t.schema().columns[1].ty, DataType::Text);
19638    }
19639
19640    #[test]
19641    fn create_table_vector_default_is_f32_encoded() {
19642        let mut e = Engine::new();
19643        e.execute("CREATE TABLE t (v VECTOR(8))").unwrap();
19644        let t = e.catalog().get("t").unwrap();
19645        assert_eq!(
19646            t.schema().columns[0].ty,
19647            DataType::Vector {
19648                dim: 8,
19649                encoding: VecEncoding::F32,
19650            },
19651        );
19652    }
19653
19654    #[test]
19655    fn create_table_vector_using_sq8_succeeds() {
19656        // v6.0.1 step 3: the step-1 fence in `column_def_to_schema`
19657        // is lifted. CREATE TABLE persists an SQ8 column type in
19658        // the catalog; INSERT (next test) quantises raw f32 input.
19659        let mut e = Engine::new();
19660        e.execute("CREATE TABLE t (v VECTOR(8) USING SQ8)").unwrap();
19661        let t = e.catalog().get("t").unwrap();
19662        assert_eq!(
19663            t.schema().columns[0].ty,
19664            DataType::Vector {
19665                dim: 8,
19666                encoding: VecEncoding::Sq8,
19667            },
19668        );
19669    }
19670
19671    #[test]
19672    fn insert_into_sq8_column_quantises_f32_payload() {
19673        // v6.0.1 step 3: INSERT-time `coerce_value` rewrites a raw
19674        // `Value::Vector(Vec<f32>)` literal into the column's
19675        // quantised representation. The row that lands in the
19676        // catalog must therefore hold a `Value::Sq8Vector`, not the
19677        // original f32 buffer — that's the bit that delivers the
19678        // 4× compression target.
19679        let mut e = Engine::new();
19680        e.execute("CREATE TABLE t (v VECTOR(4) USING SQ8)").unwrap();
19681        e.execute("INSERT INTO t VALUES ([0.0, 0.25, 0.5, 1.0])")
19682            .unwrap();
19683        let t = e.catalog().get("t").unwrap();
19684        assert_eq!(t.rows().len(), 1);
19685        match &t.rows()[0].values[0] {
19686            Value::Sq8Vector(q) => {
19687                assert_eq!(q.bytes.len(), 4);
19688                // min/max are derived from the payload: min=0.0, max=1.0.
19689                assert!((q.min - 0.0).abs() < 1e-6);
19690                assert!((q.max - 1.0).abs() < 1e-6);
19691            }
19692            other => panic!("expected Sq8Vector cell, got {other:?}"),
19693        }
19694    }
19695
19696    #[test]
19697    fn create_table_vector_using_half_succeeds_and_insert_converts_to_f16() {
19698        // v6.0.3: CREATE TABLE accepts USING HALF; INSERT path
19699        // converts the incoming `Value::Vector(Vec<f32>)` cell
19700        // into `Value::HalfVector(HalfVector)` via the new
19701        // `coerce_value` arm. The dequantised round-trip is
19702        // bit-exact for f16-representable values, so 0.0 / 0.25
19703        // / 0.5 / 1.0 hit their grid points exactly.
19704        let mut e = Engine::new();
19705        e.execute("CREATE TABLE t (v VECTOR(4) USING HALF)")
19706            .unwrap();
19707        e.execute("INSERT INTO t VALUES ([0.0, 0.25, 0.5, 1.0])")
19708            .unwrap();
19709        let t = e.catalog().get("t").unwrap();
19710        assert_eq!(t.rows().len(), 1);
19711        match &t.rows()[0].values[0] {
19712            Value::HalfVector(h) => {
19713                assert_eq!(h.dim(), 4);
19714                let back = h.to_f32_vec();
19715                let expected = alloc::vec![0.0_f32, 0.25, 0.5, 1.0];
19716                for (g, e) in back.iter().zip(expected.iter()) {
19717                    assert!(
19718                        (g - e).abs() < 1e-6,
19719                        "{g} vs {e} should be exact on f16 grid"
19720                    );
19721                }
19722            }
19723            other => panic!("expected HalfVector cell, got {other:?}"),
19724        }
19725    }
19726
19727    #[test]
19728    fn alter_index_rebuild_in_place_succeeds() {
19729        // v6.0.4: bare REBUILD (no encoding switch) walks every
19730        // row again to rebuild the NSW graph. Verifies the engine
19731        // dispatch + storage helper plumbing without changing any
19732        // cell encoding.
19733        let mut e = Engine::new();
19734        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(3) NOT NULL)")
19735            .unwrap();
19736        for i in 0..8_i32 {
19737            #[allow(clippy::cast_precision_loss)]
19738            let base = (i as f32) * 0.1;
19739            e.execute(&alloc::format!(
19740                "INSERT INTO t VALUES ({i}, [{base}, {b1}, {b2}])",
19741                b1 = base + 0.01,
19742                b2 = base + 0.02,
19743            ))
19744            .unwrap();
19745        }
19746        e.execute("CREATE INDEX t_idx ON t USING hnsw (v)").unwrap();
19747        e.execute("ALTER INDEX t_idx REBUILD").unwrap();
19748        // Schema encoding stays F32 (no encoding clause).
19749        assert_eq!(
19750            e.catalog().get("t").unwrap().schema().columns[1].ty,
19751            DataType::Vector {
19752                dim: 3,
19753                encoding: VecEncoding::F32,
19754            },
19755        );
19756    }
19757
19758    #[test]
19759    fn alter_index_rebuild_with_encoding_switches_cell_type() {
19760        // v6.0.4: REBUILD WITH (encoding = SQ8) recodes every
19761        // stored cell from F32 → SQ8 + rebuilds the graph atop the
19762        // new encoding. Post-rebuild, cells must be Sq8Vector and
19763        // the schema must report encoding = Sq8.
19764        let mut e = Engine::new();
19765        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(4) NOT NULL)")
19766            .unwrap();
19767        e.execute("INSERT INTO t VALUES (1, [0.0, 0.25, 0.5, 1.0])")
19768            .unwrap();
19769        e.execute("CREATE INDEX t_idx ON t USING hnsw (v)").unwrap();
19770        e.execute("ALTER INDEX t_idx REBUILD WITH (encoding = SQ8)")
19771            .unwrap();
19772        let t = e.catalog().get("t").unwrap();
19773        assert_eq!(
19774            t.schema().columns[1].ty,
19775            DataType::Vector {
19776                dim: 4,
19777                encoding: VecEncoding::Sq8,
19778            },
19779        );
19780        assert!(matches!(t.rows()[0].values[1], Value::Sq8Vector(_)));
19781    }
19782
19783    #[test]
19784    fn alter_index_rebuild_unknown_index_errors() {
19785        let mut e = Engine::new();
19786        let err = e.execute("ALTER INDEX nope REBUILD").unwrap_err();
19787        assert!(
19788            matches!(
19789                &err,
19790                EngineError::Storage(StorageError::IndexNotFound { name }) if name == "nope"
19791            ),
19792            "got: {err}"
19793        );
19794    }
19795
19796    #[test]
19797    fn alter_index_rebuild_on_btree_index_errors() {
19798        // REBUILD on a B-tree index has no semantic meaning in
19799        // v6.0.4 — rejected at the storage layer with `Unsupported`.
19800        let mut e = Engine::new();
19801        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19802        e.execute("INSERT INTO t VALUES (1)").unwrap();
19803        e.execute("CREATE INDEX t_idx ON t (id)").unwrap();
19804        let err = e.execute("ALTER INDEX t_idx REBUILD").unwrap_err();
19805        assert!(
19806            matches!(&err, EngineError::Storage(StorageError::Unsupported(_))),
19807            "got: {err}"
19808        );
19809    }
19810
19811    #[test]
19812    fn prepared_insert_substitutes_placeholders() {
19813        // v6.1.1: prepare() parses once; execute_prepared() walks the
19814        // AST and replaces $1/$2 with the param Values BEFORE the
19815        // dispatch sees them. Same logical result as a simple-query
19816        // INSERT, but parse happens once per *statement*, not per
19817        // execution.
19818        let mut e = Engine::new();
19819        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)")
19820            .unwrap();
19821        let stmt = e.prepare("INSERT INTO t VALUES ($1, $2)").unwrap();
19822        for (id, name) in [(1, "alice"), (2, "bob"), (3, "carol")] {
19823            e.execute_prepared(stmt.clone(), &[Value::Int(id), Value::Text(name.into())])
19824                .unwrap();
19825        }
19826        // Read back via simple-query SELECT.
19827        let rows_result = e.execute("SELECT id, name FROM t").unwrap();
19828        let QueryResult::Rows { rows, .. } = rows_result else {
19829            panic!("expected Rows")
19830        };
19831        assert_eq!(rows.len(), 3);
19832    }
19833
19834    #[test]
19835    fn prepared_select_with_placeholder_filters_rows() {
19836        let mut e = Engine::new();
19837        e.execute("CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)")
19838            .unwrap();
19839        for i in 0..10_i32 {
19840            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, {})", i * 7))
19841                .unwrap();
19842        }
19843        let stmt = e.prepare("SELECT id FROM t WHERE v = $1").unwrap();
19844        let QueryResult::Rows { rows, .. } = e.execute_prepared(stmt, &[Value::Int(35)]).unwrap()
19845        else {
19846            panic!("expected Rows")
19847        };
19848        // v = 35 means i*7 = 35 → i = 5.
19849        assert_eq!(rows.len(), 1);
19850        assert_eq!(rows[0].values[0], Value::Int(5));
19851    }
19852
19853    #[test]
19854    fn prepared_too_few_params_errors() {
19855        let mut e = Engine::new();
19856        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19857        let stmt = e.prepare("INSERT INTO t VALUES ($1)").unwrap();
19858        let err = e.execute_prepared(stmt, &[]).unwrap_err();
19859        assert!(
19860            matches!(
19861                &err,
19862                EngineError::Eval(EvalError::PlaceholderOutOfRange { n: 1, bound: 0 })
19863            ),
19864            "got: {err}"
19865        );
19866    }
19867
19868    #[test]
19869    fn bytea_cast_round_trips_text_input() {
19870        // v7.18 — `'hello'::bytea` produces the raw bytes. Closes
19871        // the mailrs D-pre #3 reverse-acceptance gap.
19872        let e = Engine::new();
19873        let r = e.execute_readonly("SELECT 'hello'::bytea").unwrap();
19874        let QueryResult::Rows { rows, .. } = r else {
19875            panic!("expected Rows")
19876        };
19877        assert_eq!(rows.len(), 1);
19878        assert_eq!(rows[0].values[0], Value::Bytes(b"hello".to_vec()));
19879    }
19880
19881    #[test]
19882    fn bytea_cast_pg_escape_hex_form() {
19883        // E'\\xdeadbeef'::bytea — E-string decodes to `\xdeadbeef`
19884        // (literal 10 chars), then ::bytea reads it as PG hex
19885        // form bytea literal → 4 bytes.
19886        let e = Engine::new();
19887        let r = e.execute_readonly(r"SELECT E'\\xdeadbeef'::bytea").unwrap();
19888        let QueryResult::Rows { rows, .. } = r else {
19889            panic!("expected Rows")
19890        };
19891        assert_eq!(
19892            rows[0].values[0],
19893            Value::Bytes(vec![0xde, 0xad, 0xbe, 0xef])
19894        );
19895    }
19896
19897    #[test]
19898    fn bytea_cast_chains_through_octet_length() {
19899        // octet_length('hello'::bytea) → 5. Confirms the cast
19900        // composes inside larger expressions, not just at top
19901        // level.
19902        let e = Engine::new();
19903        let r = e
19904            .execute_readonly("SELECT octet_length('hello'::bytea)")
19905            .unwrap();
19906        let QueryResult::Rows { rows, .. } = r else {
19907            panic!("expected Rows")
19908        };
19909        match &rows[0].values[0] {
19910            Value::Int(n) => assert_eq!(*n, 5),
19911            Value::BigInt(n) => assert_eq!(*n, 5),
19912            other => panic!("expected integer length, got {other:?}"),
19913        }
19914    }
19915
19916    #[test]
19917    fn readonly_prepared_on_snapshot_select_with_placeholder() {
19918        // v7.18 — sqlx Pool fan-out relies on running prepared
19919        // SELECTs against a frozen snapshot without re-entering
19920        // the writer engine. Mirrors the simple-query SELECT path
19921        // in `execute_readonly_on_snapshot` but takes a Statement
19922        // + bound params (the shape sqlx's Execute path produces).
19923        let mut e = Engine::new();
19924        e.execute("CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)")
19925            .unwrap();
19926        for i in 0..10_i32 {
19927            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, {})", i * 7))
19928                .unwrap();
19929        }
19930        let snapshot = e.clone_snapshot();
19931        let stmt = e.prepare("SELECT id FROM t WHERE v = $1").unwrap();
19932        let QueryResult::Rows { rows, .. } =
19933            Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(35)])
19934                .unwrap()
19935        else {
19936            panic!("expected Rows")
19937        };
19938        assert_eq!(rows.len(), 1);
19939        assert_eq!(rows[0].values[0], Value::Int(5));
19940    }
19941
19942    #[test]
19943    fn readonly_prepared_on_snapshot_rejects_writes() {
19944        // DDL / DML prepared statements on the readonly path must
19945        // surface `WriteRequired` so the spg-sqlx connection layer
19946        // routes them to the writer mutex instead of the snapshot.
19947        let mut e = Engine::new();
19948        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19949        let snapshot = e.clone_snapshot();
19950        let stmt = e.prepare("INSERT INTO t VALUES ($1)").unwrap();
19951        let err = Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(1)])
19952            .unwrap_err();
19953        assert!(matches!(&err, EngineError::WriteRequired), "got: {err}");
19954    }
19955
19956    #[test]
19957    fn readonly_prepared_on_snapshot_frozen_view() {
19958        // The snapshot reflects engine state at clone_snapshot()
19959        // time. Writes after the snapshot are NOT visible — caller
19960        // takes a fresh snapshot (or `AsyncReadHandle::refresh()`)
19961        // to see them. This is the contract the per-statement
19962        // refresh in spg-sqlx relies on.
19963        let mut e = Engine::new();
19964        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19965        e.execute("INSERT INTO t VALUES (1)").unwrap();
19966        let snapshot = e.clone_snapshot();
19967        e.execute("INSERT INTO t VALUES (2)").unwrap();
19968        let stmt = e.prepare("SELECT id FROM t WHERE id = $1").unwrap();
19969        let QueryResult::Rows { rows, .. } =
19970            Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(2)])
19971                .unwrap()
19972        else {
19973            panic!("expected Rows")
19974        };
19975        assert!(rows.is_empty(), "id=2 was inserted after snapshot");
19976    }
19977
19978    #[test]
19979    fn describe_prepared_on_snapshot_resolves_columns() {
19980        // v7.18 — sqlx's Executor::describe path on the readonly
19981        // fan-out needs to resolve column names + types against
19982        // the snapshot's catalog (not the live engine's catalog,
19983        // which may have moved on).
19984        let mut e = Engine::new();
19985        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)")
19986            .unwrap();
19987        let snapshot = e.clone_snapshot();
19988        let stmt = e.prepare("SELECT id, name FROM t WHERE id = $1").unwrap();
19989        let (_params, cols) = Engine::describe_prepared_on_snapshot(&snapshot, &stmt);
19990        assert_eq!(cols.len(), 2);
19991        assert_eq!(cols[0].name, "id");
19992        assert_eq!(cols[0].ty, DataType::Int);
19993        assert_eq!(cols[1].name, "name");
19994        assert_eq!(cols[1].ty, DataType::Text);
19995    }
19996
19997    #[test]
19998    fn insert_into_half_column_dim_mismatch_errors() {
19999        let mut e = Engine::new();
20000        e.execute("CREATE TABLE t (v VECTOR(4) USING HALF)")
20001            .unwrap();
20002        let err = e.execute("INSERT INTO t VALUES ([1.0, 2.0])").unwrap_err();
20003        assert!(matches!(
20004            &err,
20005            EngineError::Storage(StorageError::TypeMismatch { .. })
20006        ));
20007    }
20008
20009    #[test]
20010    fn insert_into_sq8_column_dim_mismatch_errors() {
20011        // Dim mismatch falls through the `coerce_value` Vector→Sq8
20012        // arm's guard and surfaces as `TypeMismatch` — the same
20013        // error the F32 path produces today, so client error
20014        // handling stays uniform across encodings.
20015        let mut e = Engine::new();
20016        e.execute("CREATE TABLE t (v VECTOR(4) USING SQ8)").unwrap();
20017        let err = e.execute("INSERT INTO t VALUES ([1.0, 2.0])").unwrap_err();
20018        assert!(
20019            matches!(
20020                &err,
20021                EngineError::Storage(StorageError::TypeMismatch { .. })
20022            ),
20023            "got: {err}",
20024        );
20025    }
20026
20027    #[test]
20028    fn create_table_duplicate_errors() {
20029        let mut e = Engine::new();
20030        e.execute("CREATE TABLE foo (a INT)").unwrap();
20031        let err = e.execute("CREATE TABLE foo (a INT)").unwrap_err();
20032        assert!(matches!(
20033            err,
20034            EngineError::Storage(StorageError::DuplicateTable { ref name }) if name == "foo"
20035        ));
20036    }
20037
20038    #[test]
20039    fn insert_into_unknown_table_errors() {
20040        let mut e = Engine::new();
20041        let err = e.execute("INSERT INTO ghost VALUES (1)").unwrap_err();
20042        assert!(matches!(
20043            err,
20044            EngineError::Storage(StorageError::TableNotFound { ref name }) if name == "ghost"
20045        ));
20046    }
20047
20048    #[test]
20049    fn insert_happy_path_reports_one_affected() {
20050        let mut e = Engine::new();
20051        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
20052        let r = e.execute("INSERT INTO foo VALUES (42)").unwrap();
20053        assert_eq!(unwrap_command_ok(&r), 1);
20054        assert_eq!(e.catalog().get("foo").unwrap().row_count(), 1);
20055    }
20056
20057    #[test]
20058    fn insert_arity_mismatch_propagates() {
20059        let mut e = Engine::new();
20060        e.execute("CREATE TABLE foo (a INT, b TEXT)").unwrap();
20061        let err = e.execute("INSERT INTO foo VALUES (1)").unwrap_err();
20062        assert!(matches!(
20063            err,
20064            EngineError::Storage(StorageError::ArityMismatch { .. })
20065        ));
20066    }
20067
20068    #[test]
20069    fn insert_negative_integer_via_unary_minus() {
20070        let mut e = Engine::new();
20071        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
20072        e.execute("INSERT INTO foo VALUES (-7)").unwrap();
20073        let rows = e.catalog().get("foo").unwrap().rows();
20074        assert_eq!(rows[0].values[0], Value::Int(-7));
20075    }
20076
20077    #[test]
20078    fn insert_expression_evaluated_against_empty_context() {
20079        // PG-canonical: INSERT VALUES accepts an arbitrary scalar
20080        // expression. The engine evaluates against an empty row
20081        // context — column references would error, but pure
20082        // arithmetic / function calls are fine.
20083        let mut e = Engine::new();
20084        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
20085        e.execute("INSERT INTO foo VALUES (1 + 2)").unwrap();
20086        let rows = e.catalog().get("foo").unwrap().rows();
20087        assert_eq!(rows[0].values[0], Value::Int(3));
20088    }
20089
20090    #[test]
20091    fn select_star_returns_all_rows_in_insertion_order() {
20092        let mut e = Engine::new();
20093        e.execute("CREATE TABLE foo (a INT NOT NULL, b TEXT NOT NULL)")
20094            .unwrap();
20095        e.execute("INSERT INTO foo VALUES (1, 'one')").unwrap();
20096        e.execute("INSERT INTO foo VALUES (2, 'two')").unwrap();
20097        e.execute("INSERT INTO foo VALUES (3, 'three')").unwrap();
20098
20099        let r = e.execute("SELECT * FROM foo").unwrap();
20100        let QueryResult::Rows { columns, rows } = r else {
20101            panic!("expected Rows")
20102        };
20103        assert_eq!(columns.len(), 2);
20104        assert_eq!(columns[0].name, "a");
20105        assert_eq!(rows.len(), 3);
20106        assert_eq!(
20107            rows[1].values,
20108            vec![Value::Int(2), Value::Text("two".into())]
20109        );
20110    }
20111
20112    #[test]
20113    fn select_star_on_empty_table_returns_zero_rows() {
20114        let mut e = Engine::new();
20115        e.execute("CREATE TABLE foo (a INT)").unwrap();
20116        let r = e.execute("SELECT * FROM foo").unwrap();
20117        match r {
20118            QueryResult::Rows { rows, .. } => assert!(rows.is_empty()),
20119            QueryResult::CommandOk { .. } => panic!("expected Rows"),
20120        }
20121    }
20122
20123    // --- v0.4: WHERE + projection ------------------------------------------
20124
20125    fn make_three_row_users(e: &mut Engine) {
20126        e.execute("CREATE TABLE users (id INT NOT NULL, name TEXT NOT NULL, score INT)")
20127            .unwrap();
20128        e.execute("INSERT INTO users VALUES (1, 'alice', 90)")
20129            .unwrap();
20130        e.execute("INSERT INTO users VALUES (2, 'bob', NULL)")
20131            .unwrap();
20132        e.execute("INSERT INTO users VALUES (3, 'cara', 70)")
20133            .unwrap();
20134    }
20135
20136    fn unwrap_rows(r: QueryResult) -> (Vec<ColumnSchema>, Vec<Row>) {
20137        match r {
20138            QueryResult::Rows { columns, rows } => (columns, rows),
20139            QueryResult::CommandOk { .. } => panic!("expected Rows"),
20140        }
20141    }
20142
20143    #[test]
20144    fn where_filter_passes_only_true_rows() {
20145        let mut e = Engine::new();
20146        make_three_row_users(&mut e);
20147        let r = e.execute("SELECT * FROM users WHERE id > 1").unwrap();
20148        let (_, rows) = unwrap_rows(r);
20149        assert_eq!(rows.len(), 2);
20150        assert_eq!(rows[0].values[0], Value::Int(2));
20151        assert_eq!(rows[1].values[0], Value::Int(3));
20152    }
20153
20154    #[test]
20155    fn where_with_null_result_filters_out_row() {
20156        let mut e = Engine::new();
20157        make_three_row_users(&mut e);
20158        // score is NULL for bob → score > 80 is NULL → row excluded
20159        let r = e.execute("SELECT * FROM users WHERE score > 80").unwrap();
20160        let (_, rows) = unwrap_rows(r);
20161        assert_eq!(rows.len(), 1);
20162        assert_eq!(rows[0].values[1], Value::Text("alice".into()));
20163    }
20164
20165    #[test]
20166    fn projection_named_columns() {
20167        let mut e = Engine::new();
20168        make_three_row_users(&mut e);
20169        let r = e.execute("SELECT name, score FROM users").unwrap();
20170        let (cols, rows) = unwrap_rows(r);
20171        assert_eq!(cols.len(), 2);
20172        assert_eq!(cols[0].name, "name");
20173        assert_eq!(cols[1].name, "score");
20174        assert_eq!(rows.len(), 3);
20175        assert_eq!(
20176            rows[0].values,
20177            vec![Value::Text("alice".into()), Value::Int(90)]
20178        );
20179    }
20180
20181    #[test]
20182    fn projection_with_column_alias() {
20183        let mut e = Engine::new();
20184        make_three_row_users(&mut e);
20185        let r = e
20186            .execute("SELECT name AS who FROM users WHERE id = 1")
20187            .unwrap();
20188        let (cols, rows) = unwrap_rows(r);
20189        assert_eq!(cols[0].name, "who");
20190        assert_eq!(rows.len(), 1);
20191        assert_eq!(rows[0].values[0], Value::Text("alice".into()));
20192    }
20193
20194    #[test]
20195    fn qualified_column_with_table_alias_resolves() {
20196        let mut e = Engine::new();
20197        make_three_row_users(&mut e);
20198        let r = e
20199            .execute("SELECT u.id, u.name FROM users AS u WHERE u.id < 3")
20200            .unwrap();
20201        let (cols, rows) = unwrap_rows(r);
20202        assert_eq!(cols.len(), 2);
20203        assert_eq!(rows.len(), 2);
20204    }
20205
20206    #[test]
20207    fn qualified_column_with_wrong_alias_errors() {
20208        let mut e = Engine::new();
20209        make_three_row_users(&mut e);
20210        let err = e.execute("SELECT x.id FROM users AS u").unwrap_err();
20211        assert!(matches!(
20212            err,
20213            EngineError::Eval(EvalError::UnknownQualifier { ref qualifier }) if qualifier == "x"
20214        ));
20215    }
20216
20217    #[test]
20218    fn select_unknown_column_errors_in_projection() {
20219        let mut e = Engine::new();
20220        make_three_row_users(&mut e);
20221        let err = e.execute("SELECT ghost FROM users").unwrap_err();
20222        assert!(matches!(
20223            err,
20224            EngineError::Eval(EvalError::ColumnNotFound { ref name }) if name == "ghost"
20225        ));
20226    }
20227
20228    #[test]
20229    fn where_unknown_column_errors() {
20230        let mut e = Engine::new();
20231        make_three_row_users(&mut e);
20232        let err = e
20233            .execute("SELECT * FROM users WHERE ghost = 1")
20234            .unwrap_err();
20235        assert!(matches!(
20236            err,
20237            EngineError::Eval(EvalError::ColumnNotFound { .. })
20238        ));
20239    }
20240
20241    #[test]
20242    fn expression_projection_evaluates_and_renders() {
20243        // Compound expressions in the SELECT list are evaluated per row;
20244        // the output column is typed TEXT, name defaults to the expression.
20245        let mut e = Engine::new();
20246        e.execute("CREATE TABLE t (a INT NOT NULL)").unwrap();
20247        e.execute("INSERT INTO t VALUES (3)").unwrap();
20248        let (_, rows) = unwrap_rows(e.execute("SELECT 1 + 2 FROM t").unwrap());
20249        assert_eq!(rows.len(), 1);
20250        // The expression evaluates to integer 3; rendered as the cell value
20251        // (storage::Value::Int(3) since arithmetic kept ints).
20252        assert_eq!(rows[0].values[0], Value::Int(3));
20253    }
20254
20255    #[test]
20256    fn select_unknown_table_errors() {
20257        let mut e = Engine::new();
20258        let err = e.execute("SELECT * FROM ghost").unwrap_err();
20259        assert!(matches!(
20260            err,
20261            EngineError::Storage(StorageError::TableNotFound { .. })
20262        ));
20263    }
20264
20265    #[test]
20266    fn invalid_sql_returns_parse_error() {
20267        // v4.4: UPDATE is now real SQL, so use a true syntactic
20268        // garbage payload for the parse-error path.
20269        let mut e = Engine::new();
20270        let err = e.execute("THIS_IS_NOT_A_KEYWORD foo bar baz").unwrap_err();
20271        assert!(matches!(err, EngineError::Parse(_)));
20272    }
20273
20274    // --- v0.8 CREATE INDEX + index seek ------------------------------------
20275
20276    #[test]
20277    fn create_index_registers_on_table() {
20278        let mut e = Engine::new();
20279        make_three_row_users(&mut e);
20280        e.execute("CREATE INDEX by_name ON users (name)").unwrap();
20281        let t = e.catalog().get("users").unwrap();
20282        assert_eq!(t.indices().len(), 1);
20283        assert_eq!(t.indices()[0].name, "by_name");
20284    }
20285
20286    #[test]
20287    fn create_index_on_unknown_table_errors() {
20288        let mut e = Engine::new();
20289        let err = e.execute("CREATE INDEX i ON ghost (a)").unwrap_err();
20290        assert!(matches!(
20291            err,
20292            EngineError::Storage(StorageError::TableNotFound { .. })
20293        ));
20294    }
20295
20296    #[test]
20297    fn create_index_on_unknown_column_errors() {
20298        let mut e = Engine::new();
20299        make_three_row_users(&mut e);
20300        let err = e.execute("CREATE INDEX i ON users (ghost)").unwrap_err();
20301        assert!(matches!(
20302            err,
20303            EngineError::Storage(StorageError::ColumnNotFound { .. })
20304        ));
20305    }
20306
20307    #[test]
20308    fn select_eq_uses_index_returns_same_rows_as_scan() {
20309        // Build two engines: one with an index, one without. Same query →
20310        // same row set (index is a planner optimisation, not a semantic
20311        // change).
20312        let mut without = Engine::new();
20313        make_three_row_users(&mut without);
20314        let mut with = Engine::new();
20315        make_three_row_users(&mut with);
20316        with.execute("CREATE INDEX by_id ON users (id)").unwrap();
20317
20318        let q = "SELECT * FROM users WHERE id = 2";
20319        let (_, no_idx_rows) = unwrap_rows(without.execute(q).unwrap());
20320        let (_, idx_rows) = unwrap_rows(with.execute(q).unwrap());
20321        assert_eq!(no_idx_rows, idx_rows);
20322        assert_eq!(idx_rows.len(), 1);
20323    }
20324
20325    #[test]
20326    fn select_eq_with_no_matching_index_value_returns_empty() {
20327        let mut e = Engine::new();
20328        make_three_row_users(&mut e);
20329        e.execute("CREATE INDEX by_id ON users (id)").unwrap();
20330        let (_, rows) = unwrap_rows(e.execute("SELECT * FROM users WHERE id = 999").unwrap());
20331        assert_eq!(rows.len(), 0);
20332    }
20333
20334    // --- v0.9 transactions -------------------------------------------------
20335
20336    #[test]
20337    fn begin_sets_in_transaction_flag() {
20338        let mut e = Engine::new();
20339        assert!(!e.in_transaction());
20340        e.execute("BEGIN").unwrap();
20341        assert!(e.in_transaction());
20342    }
20343
20344    #[test]
20345    fn double_begin_errors() {
20346        let mut e = Engine::new();
20347        e.execute("BEGIN").unwrap();
20348        let err = e.execute("BEGIN").unwrap_err();
20349        assert_eq!(err, EngineError::TransactionAlreadyOpen);
20350    }
20351
20352    #[test]
20353    fn commit_without_begin_errors() {
20354        let mut e = Engine::new();
20355        let err = e.execute("COMMIT").unwrap_err();
20356        assert_eq!(err, EngineError::NoActiveTransaction);
20357    }
20358
20359    #[test]
20360    fn rollback_without_begin_errors() {
20361        let mut e = Engine::new();
20362        let err = e.execute("ROLLBACK").unwrap_err();
20363        assert_eq!(err, EngineError::NoActiveTransaction);
20364    }
20365
20366    #[test]
20367    fn commit_applies_shadow_to_committed_catalog() {
20368        let mut e = Engine::new();
20369        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
20370        e.execute("BEGIN").unwrap();
20371        e.execute("INSERT INTO t VALUES (1)").unwrap();
20372        e.execute("INSERT INTO t VALUES (2)").unwrap();
20373        e.execute("COMMIT").unwrap();
20374        assert!(!e.in_transaction());
20375        assert_eq!(e.catalog().get("t").unwrap().row_count(), 2);
20376    }
20377
20378    #[test]
20379    fn rollback_discards_shadow() {
20380        let mut e = Engine::new();
20381        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
20382        e.execute("BEGIN").unwrap();
20383        e.execute("INSERT INTO t VALUES (1)").unwrap();
20384        e.execute("INSERT INTO t VALUES (2)").unwrap();
20385        e.execute("ROLLBACK").unwrap();
20386        assert!(!e.in_transaction());
20387        assert_eq!(e.catalog().get("t").unwrap().row_count(), 0);
20388    }
20389
20390    #[test]
20391    fn select_during_tx_sees_uncommitted_writes_own_session() {
20392        // The shadow catalog is read by SELECTs while a TX is open — the
20393        // session can see its own pending writes.
20394        let mut e = Engine::new();
20395        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
20396        e.execute("BEGIN").unwrap();
20397        e.execute("INSERT INTO t VALUES (42)").unwrap();
20398        let (_, rows) = unwrap_rows(e.execute("SELECT * FROM t").unwrap());
20399        assert_eq!(rows.len(), 1);
20400        assert_eq!(rows[0].values[0], Value::Int(42));
20401    }
20402
20403    #[test]
20404    fn snapshot_with_no_users_is_bare_catalog_format() {
20405        let mut e = Engine::new();
20406        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
20407        let bytes = e.snapshot();
20408        assert_eq!(
20409            &bytes[..8],
20410            b"SPGDB001",
20411            "must be the bare v3.x catalog magic"
20412        );
20413        let e2 = Engine::restore_envelope(&bytes).unwrap();
20414        assert!(e2.users().is_empty());
20415        assert_eq!(e2.catalog().table_count(), 1);
20416    }
20417
20418    #[test]
20419    fn snapshot_with_users_round_trips_both_via_envelope() {
20420        let mut e = Engine::new();
20421        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
20422        e.create_user("alice", "pw1", Role::Admin, [9; 16]).unwrap();
20423        e.create_user("bob", "pw2", Role::ReadOnly, [5; 16])
20424            .unwrap();
20425        let bytes = e.snapshot();
20426        assert_eq!(&bytes[..8], b"SPGENV01", "must be the v4.1 envelope magic");
20427        let e2 = Engine::restore_envelope(&bytes).unwrap();
20428        assert_eq!(e2.users().len(), 2);
20429        assert_eq!(e2.verify_user("alice", "pw1"), Some(Role::Admin));
20430        assert_eq!(e2.verify_user("bob", "pw2"), Some(Role::ReadOnly));
20431        assert_eq!(e2.verify_user("alice", "wrong"), None);
20432        assert_eq!(e2.catalog().table_count(), 1);
20433    }
20434
20435    #[test]
20436    fn ddl_inside_tx_also_rolled_back() {
20437        let mut e = Engine::new();
20438        e.execute("BEGIN").unwrap();
20439        e.execute("CREATE TABLE t (v INT)").unwrap();
20440        // Visible inside the TX.
20441        e.execute("SELECT * FROM t").unwrap();
20442        e.execute("ROLLBACK").unwrap();
20443        // Gone after rollback.
20444        let err = e.execute("SELECT * FROM t").unwrap_err();
20445        assert!(matches!(
20446            err,
20447            EngineError::Storage(StorageError::TableNotFound { .. })
20448        ));
20449    }
20450
20451    // ── v6.1.2: CREATE / DROP PUBLICATION (engine-side) ──────
20452
20453    #[test]
20454    fn create_publication_lands_in_catalog() {
20455        let mut e = Engine::new();
20456        assert!(e.publications().is_empty());
20457        e.execute("CREATE PUBLICATION pub_a").unwrap();
20458        assert_eq!(e.publications().len(), 1);
20459        assert!(e.publications().contains("pub_a"));
20460    }
20461
20462    #[test]
20463    fn create_publication_duplicate_errors() {
20464        let mut e = Engine::new();
20465        e.execute("CREATE PUBLICATION pub_a").unwrap();
20466        let err = e.execute("CREATE PUBLICATION pub_a").unwrap_err();
20467        assert!(
20468            alloc::format!("{err:?}").contains("DuplicateName"),
20469            "got {err:?}"
20470        );
20471    }
20472
20473    #[test]
20474    fn drop_publication_silent_when_absent() {
20475        let mut e = Engine::new();
20476        // PG-compatible: DROP a publication that doesn't exist
20477        // succeeds (no-op) but reports zero affected.
20478        let r = e.execute("DROP PUBLICATION nope").unwrap();
20479        match r {
20480            QueryResult::CommandOk { affected, .. } => assert_eq!(affected, 0),
20481            other => panic!("expected CommandOk, got {other:?}"),
20482        }
20483    }
20484
20485    #[test]
20486    fn drop_publication_present_reports_one_affected() {
20487        let mut e = Engine::new();
20488        e.execute("CREATE PUBLICATION pub_a").unwrap();
20489        let r = e.execute("DROP PUBLICATION pub_a").unwrap();
20490        match r {
20491            QueryResult::CommandOk {
20492                affected,
20493                modified_catalog,
20494            } => {
20495                assert_eq!(affected, 1);
20496                assert!(modified_catalog);
20497            }
20498            other => panic!("expected CommandOk, got {other:?}"),
20499        }
20500        assert!(e.publications().is_empty());
20501    }
20502
20503    #[test]
20504    fn publications_persist_across_snapshot_restore() {
20505        // The persist-across-restart ship-gate at the engine layer —
20506        // snapshot → restore_envelope round trip must preserve the
20507        // publication catalog. The spg-server e2e covers the
20508        // process-restart variant.
20509        let mut e = Engine::new();
20510        e.execute("CREATE PUBLICATION pub_a").unwrap();
20511        e.execute("CREATE PUBLICATION pub_b FOR ALL TABLES")
20512            .unwrap();
20513        let snap = e.snapshot();
20514        let e2 = Engine::restore_envelope(&snap).unwrap();
20515        assert_eq!(e2.publications().len(), 2);
20516        assert!(e2.publications().contains("pub_a"));
20517        assert!(e2.publications().contains("pub_b"));
20518    }
20519
20520    #[test]
20521    fn create_publication_allowed_inside_transaction() {
20522        // v6.1.4 dropped the v6.1.2 in-TX guard — PG allows
20523        // CREATE PUBLICATION inside a TX and the auto-commit
20524        // wrap path needs the same allowance.
20525        let mut e = Engine::new();
20526        e.execute("BEGIN").unwrap();
20527        e.execute("CREATE PUBLICATION pub_a").unwrap();
20528        e.execute("COMMIT").unwrap();
20529        assert!(e.publications().contains("pub_a"));
20530    }
20531
20532    // ── v6.1.3: SHOW PUBLICATIONS + FOR-list variants ───────
20533
20534    #[test]
20535    fn create_publication_for_table_list_lands_with_scope() {
20536        let mut e = Engine::new();
20537        e.execute("CREATE TABLE t1 (id INT NOT NULL)").unwrap();
20538        e.execute("CREATE TABLE t2 (id INT NOT NULL)").unwrap();
20539        e.execute("CREATE PUBLICATION pub_a FOR TABLE t1, t2")
20540            .unwrap();
20541        let scope = e.publications().get("pub_a").cloned();
20542        let Some(spg_sql::ast::PublicationScope::ForTables(ts)) = scope else {
20543            panic!("expected ForTables scope, got {scope:?}")
20544        };
20545        assert_eq!(ts, alloc::vec!["t1".to_string(), "t2".to_string()]);
20546    }
20547
20548    #[test]
20549    fn create_publication_all_tables_except_lands_with_scope() {
20550        let mut e = Engine::new();
20551        e.execute("CREATE PUBLICATION pub_a FOR ALL TABLES EXCEPT t3")
20552            .unwrap();
20553        let scope = e.publications().get("pub_a").cloned();
20554        let Some(spg_sql::ast::PublicationScope::AllTablesExcept(ts)) = scope else {
20555            panic!("expected AllTablesExcept scope, got {scope:?}")
20556        };
20557        assert_eq!(ts, alloc::vec!["t3".to_string()]);
20558    }
20559
20560    #[test]
20561    fn show_publications_empty_returns_zero_rows() {
20562        let e = Engine::new();
20563        let r = e.execute_readonly("SHOW PUBLICATIONS").unwrap();
20564        let QueryResult::Rows { rows, columns } = r else {
20565            panic!()
20566        };
20567        assert!(rows.is_empty());
20568        assert_eq!(columns.len(), 3);
20569        assert_eq!(columns[0].name, "name");
20570        assert_eq!(columns[1].name, "scope");
20571        assert_eq!(columns[2].name, "table_count");
20572    }
20573
20574    #[test]
20575    fn show_publications_returns_one_row_per_publication_ordered_by_name() {
20576        let mut e = Engine::new();
20577        e.execute("CREATE PUBLICATION z_pub").unwrap();
20578        e.execute("CREATE PUBLICATION a_pub FOR TABLE t1, t2")
20579            .unwrap();
20580        e.execute("CREATE PUBLICATION m_pub FOR ALL TABLES EXCEPT bad")
20581            .unwrap();
20582        let r = e.execute_readonly("SHOW PUBLICATIONS").unwrap();
20583        let QueryResult::Rows { rows, .. } = r else {
20584            panic!()
20585        };
20586        assert_eq!(rows.len(), 3);
20587        // Alphabetical order: a_pub, m_pub, z_pub.
20588        let names: Vec<&str> = rows
20589            .iter()
20590            .map(|r| {
20591                if let Value::Text(s) = &r.values[0] {
20592                    s.as_str()
20593                } else {
20594                    panic!()
20595                }
20596            })
20597            .collect();
20598        assert_eq!(names, alloc::vec!["a_pub", "m_pub", "z_pub"]);
20599        // Row 0 — a_pub scope summary + table_count = 2.
20600        match &rows[0].values[1] {
20601            Value::Text(s) => assert_eq!(s, "FOR TABLE t1, t2"),
20602            other => panic!("expected Text, got {other:?}"),
20603        }
20604        assert_eq!(rows[0].values[2], Value::Int(2));
20605        // Row 1 — m_pub.
20606        match &rows[1].values[1] {
20607            Value::Text(s) => assert_eq!(s, "FOR ALL TABLES EXCEPT bad"),
20608            other => panic!("expected Text, got {other:?}"),
20609        }
20610        assert_eq!(rows[1].values[2], Value::Int(1));
20611        // Row 2 — z_pub (AllTables → NULL count).
20612        match &rows[2].values[1] {
20613            Value::Text(s) => assert_eq!(s, "FOR ALL TABLES"),
20614            other => panic!("expected Text, got {other:?}"),
20615        }
20616        assert_eq!(rows[2].values[2], Value::Null);
20617    }
20618
20619    #[test]
20620    fn for_list_scopes_persist_across_snapshot() {
20621        // The v6.1.2 envelope-v3 round-trip exercised AllTables;
20622        // v6.1.3 needs the scope-1 / scope-2 tags to survive too.
20623        let mut e = Engine::new();
20624        e.execute("CREATE PUBLICATION p1 FOR TABLE t1, t2").unwrap();
20625        e.execute("CREATE PUBLICATION p2 FOR ALL TABLES EXCEPT bad, worse")
20626            .unwrap();
20627        let snap = e.snapshot();
20628        let e2 = Engine::restore_envelope(&snap).unwrap();
20629        assert_eq!(e2.publications().len(), 2);
20630        let p1 = e2.publications().get("p1").cloned();
20631        let Some(spg_sql::ast::PublicationScope::ForTables(ts)) = p1 else {
20632            panic!("p1 scope lost: {p1:?}")
20633        };
20634        assert_eq!(ts, alloc::vec!["t1".to_string(), "t2".to_string()]);
20635        let p2 = e2.publications().get("p2").cloned();
20636        let Some(spg_sql::ast::PublicationScope::AllTablesExcept(ts)) = p2 else {
20637            panic!("p2 scope lost: {p2:?}")
20638        };
20639        assert_eq!(ts, alloc::vec!["bad".to_string(), "worse".to_string()]);
20640    }
20641
20642    // ── v6.1.4: CREATE / DROP SUBSCRIPTION + SHOW + envelope v4 ─
20643
20644    #[test]
20645    fn create_subscription_lands_in_catalog_with_defaults() {
20646        let mut e = Engine::new();
20647        e.execute(
20648            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=127.0.0.1 port=20002' PUBLICATION pub_a",
20649        )
20650        .unwrap();
20651        let s = e.subscriptions().get("sub_a").cloned().expect("present");
20652        assert_eq!(s.conn_str, "host=127.0.0.1 port=20002");
20653        assert_eq!(s.publications, alloc::vec!["pub_a".to_string()]);
20654        assert!(s.enabled);
20655        assert_eq!(s.last_received_pos, 0);
20656    }
20657
20658    #[test]
20659    fn create_subscription_duplicate_name_errors() {
20660        let mut e = Engine::new();
20661        e.execute("CREATE SUBSCRIPTION s CONNECTION 'host=x' PUBLICATION p")
20662            .unwrap();
20663        let err = e
20664            .execute("CREATE SUBSCRIPTION s CONNECTION 'host=y' PUBLICATION p")
20665            .unwrap_err();
20666        assert!(
20667            alloc::format!("{err:?}").contains("DuplicateName"),
20668            "got {err:?}"
20669        );
20670    }
20671
20672    #[test]
20673    fn drop_subscription_silent_when_absent() {
20674        let mut e = Engine::new();
20675        let r = e.execute("DROP SUBSCRIPTION never").unwrap();
20676        match r {
20677            QueryResult::CommandOk { affected, .. } => assert_eq!(affected, 0),
20678            other => panic!("expected CommandOk, got {other:?}"),
20679        }
20680    }
20681
20682    #[test]
20683    fn subscription_advance_updates_last_pos_monotone() {
20684        let mut e = Engine::new();
20685        e.execute("CREATE SUBSCRIPTION s CONNECTION 'h=x' PUBLICATION p")
20686            .unwrap();
20687        assert!(e.subscription_advance("s", 100));
20688        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 100);
20689        assert!(e.subscription_advance("s", 50)); // stale → ignored
20690        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 100);
20691        assert!(e.subscription_advance("s", 200));
20692        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 200);
20693        assert!(!e.subscription_advance("missing", 1));
20694    }
20695
20696    #[test]
20697    fn show_subscriptions_returns_rows_ordered_by_name() {
20698        let mut e = Engine::new();
20699        e.execute("CREATE SUBSCRIPTION z_sub CONNECTION 'h=x' PUBLICATION p1, p2")
20700            .unwrap();
20701        e.execute("CREATE SUBSCRIPTION a_sub CONNECTION 'h=y' PUBLICATION p3")
20702            .unwrap();
20703        let r = e.execute_readonly("SHOW SUBSCRIPTIONS").unwrap();
20704        let QueryResult::Rows { rows, columns } = r else {
20705            panic!()
20706        };
20707        assert_eq!(rows.len(), 2);
20708        assert_eq!(columns.len(), 5);
20709        assert_eq!(columns[0].name, "name");
20710        assert_eq!(columns[4].name, "last_received_pos");
20711        // Alphabetical: a_sub, z_sub.
20712        let names: Vec<&str> = rows
20713            .iter()
20714            .map(|r| {
20715                if let Value::Text(s) = &r.values[0] {
20716                    s.as_str()
20717                } else {
20718                    panic!()
20719                }
20720            })
20721            .collect();
20722        assert_eq!(names, alloc::vec!["a_sub", "z_sub"]);
20723        // Row 0: a_sub
20724        assert_eq!(rows[0].values[1], Value::Text("h=y".to_string()));
20725        assert_eq!(rows[0].values[2], Value::Text("p3".to_string()));
20726        assert_eq!(rows[0].values[3], Value::Bool(true));
20727        assert_eq!(rows[0].values[4], Value::BigInt(0));
20728        // Row 1: z_sub — publications join with ", "
20729        assert_eq!(rows[1].values[2], Value::Text("p1, p2".to_string()));
20730    }
20731
20732    #[test]
20733    fn subscriptions_persist_across_snapshot_envelope_v4() {
20734        let mut e = Engine::new();
20735        e.execute("CREATE SUBSCRIPTION s1 CONNECTION 'h=A' PUBLICATION p1, p2")
20736            .unwrap();
20737        e.execute("CREATE SUBSCRIPTION s2 CONNECTION 'h=B' PUBLICATION p3")
20738            .unwrap();
20739        e.subscription_advance("s2", 42);
20740        let snap = e.snapshot();
20741        let e2 = Engine::restore_envelope(&snap).unwrap();
20742        assert_eq!(e2.subscriptions().len(), 2);
20743        let s1 = e2.subscriptions().get("s1").unwrap();
20744        assert_eq!(s1.conn_str, "h=A");
20745        assert_eq!(
20746            s1.publications,
20747            alloc::vec!["p1".to_string(), "p2".to_string()]
20748        );
20749        assert_eq!(s1.last_received_pos, 0);
20750        let s2 = e2.subscriptions().get("s2").unwrap();
20751        assert_eq!(s2.last_received_pos, 42);
20752    }
20753
20754    #[test]
20755    fn v3_envelope_loads_with_empty_subscriptions() {
20756        // v3 snapshot (publications-only). Forge it by hand so we
20757        // verify v6.1.4 readers don't panic — they must surface
20758        // empty subscriptions and a populated publication table.
20759        let mut e = Engine::new();
20760        e.execute("CREATE PUBLICATION pub_legacy").unwrap();
20761        let catalog = e.catalog.serialize();
20762        let users = crate::users::serialize_users(&e.users);
20763        let pubs = e.publications.serialize();
20764        let mut buf = Vec::new();
20765        buf.extend_from_slice(b"SPGENV01");
20766        buf.push(3u8); // v3
20767        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
20768        buf.extend_from_slice(&catalog);
20769        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
20770        buf.extend_from_slice(&users);
20771        buf.extend_from_slice(&u32::try_from(pubs.len()).unwrap().to_le_bytes());
20772        buf.extend_from_slice(&pubs);
20773        let crc = spg_crypto::crc32::crc32(&buf);
20774        buf.extend_from_slice(&crc.to_le_bytes());
20775
20776        let e2 = Engine::restore_envelope(&buf).expect("v3 envelope restores under v4 reader");
20777        assert!(e2.subscriptions().is_empty());
20778        assert!(e2.publications().contains("pub_legacy"));
20779    }
20780
20781    #[test]
20782    fn create_subscription_allowed_inside_transaction() {
20783        let mut e = Engine::new();
20784        e.execute("BEGIN").unwrap();
20785        e.execute("CREATE SUBSCRIPTION s CONNECTION 'h=x' PUBLICATION p")
20786            .unwrap();
20787        e.execute("COMMIT").unwrap();
20788        assert!(e.subscriptions().contains("s"));
20789    }
20790
20791    // ── v6.2.0: ANALYZE + spg_statistic + envelope v5 ──────────
20792    #[test]
20793    fn analyze_populates_histogram_bounds() {
20794        let mut e = Engine::new();
20795        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT)")
20796            .unwrap();
20797        for i in 0..50 {
20798            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, 'name{i}')"))
20799                .unwrap();
20800        }
20801        e.execute("ANALYZE t").unwrap();
20802        let stats = e.statistics();
20803        let id_stats = stats.get("t", "id").unwrap();
20804        assert!(id_stats.histogram_bounds.len() >= 2);
20805        assert_eq!(id_stats.histogram_bounds.first().unwrap(), "0");
20806        assert_eq!(id_stats.histogram_bounds.last().unwrap(), "49");
20807        assert!((id_stats.null_frac - 0.0).abs() < 1e-6);
20808        assert_eq!(id_stats.n_distinct, 50);
20809    }
20810
20811    #[test]
20812    fn reanalyze_overwrites_prior_stats() {
20813        let mut e = Engine::new();
20814        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
20815        for i in 0..10 {
20816            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
20817                .unwrap();
20818        }
20819        e.execute("ANALYZE t").unwrap();
20820        let n1 = e.statistics().get("t", "id").unwrap().n_distinct;
20821        assert_eq!(n1, 10);
20822        for i in 10..30 {
20823            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
20824                .unwrap();
20825        }
20826        e.execute("ANALYZE t").unwrap();
20827        let n2 = e.statistics().get("t", "id").unwrap().n_distinct;
20828        assert_eq!(n2, 30);
20829    }
20830
20831    #[test]
20832    fn analyze_unknown_table_errors() {
20833        let mut e = Engine::new();
20834        let err = e.execute("ANALYZE nonexistent").unwrap_err();
20835        assert!(matches!(
20836            err,
20837            EngineError::Storage(StorageError::TableNotFound { .. })
20838        ));
20839    }
20840
20841    #[test]
20842    fn bare_analyze_covers_all_user_tables() {
20843        let mut e = Engine::new();
20844        e.execute("CREATE TABLE t1 (id INT NOT NULL)").unwrap();
20845        e.execute("CREATE TABLE t2 (name TEXT NOT NULL)").unwrap();
20846        e.execute("INSERT INTO t1 VALUES (1)").unwrap();
20847        e.execute("INSERT INTO t2 VALUES ('alice')").unwrap();
20848        let r = e.execute("ANALYZE").unwrap();
20849        match r {
20850            QueryResult::CommandOk {
20851                affected,
20852                modified_catalog,
20853            } => {
20854                assert_eq!(affected, 2);
20855                assert!(modified_catalog);
20856            }
20857            other => panic!("expected CommandOk, got {other:?}"),
20858        }
20859        assert!(e.statistics().get("t1", "id").is_some());
20860        assert!(e.statistics().get("t2", "name").is_some());
20861    }
20862
20863    #[test]
20864    fn select_from_spg_statistic_returns_rows_per_column() {
20865        let mut e = Engine::new();
20866        e.execute("CREATE TABLE t (id INT NOT NULL, label TEXT)")
20867            .unwrap();
20868        e.execute("INSERT INTO t VALUES (1, 'a')").unwrap();
20869        e.execute("INSERT INTO t VALUES (2, 'b')").unwrap();
20870        e.execute("ANALYZE t").unwrap();
20871        let r = e.execute_readonly("SELECT * FROM spg_statistic").unwrap();
20872        let QueryResult::Rows { rows, columns } = r else {
20873            panic!()
20874        };
20875        // v6.7.0 — spg_statistic gained a `cold_row_count` column.
20876        assert_eq!(columns.len(), 6);
20877        assert_eq!(columns[0].name, "table_name");
20878        assert_eq!(columns[4].name, "histogram_bounds");
20879        assert_eq!(columns[5].name, "cold_row_count");
20880        assert_eq!(rows.len(), 2, "one row per column of t");
20881        // Sorted by (table_name, column_name).
20882        match (&rows[0].values[0], &rows[0].values[1]) {
20883            (Value::Text(t), Value::Text(c)) => {
20884                assert_eq!(t, "t");
20885                // BTreeMap orders (table, column); columns "id" < "label".
20886                assert_eq!(c, "id");
20887            }
20888            _ => panic!(),
20889        }
20890    }
20891
20892    #[test]
20893    fn analyze_skips_vector_columns() {
20894        // Vector columns have their own stats shape (HNSW graph);
20895        // ANALYZE leaves them out of spg_statistic.
20896        let mut e = Engine::new();
20897        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(3) NOT NULL)")
20898            .unwrap();
20899        e.execute("INSERT INTO t VALUES (1, [1, 2, 3])").unwrap();
20900        e.execute("ANALYZE t").unwrap();
20901        assert!(e.statistics().get("t", "id").is_some());
20902        assert!(e.statistics().get("t", "v").is_none());
20903    }
20904
20905    #[test]
20906    fn statistics_persist_across_envelope_v5_round_trip() {
20907        let mut e = Engine::new();
20908        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
20909        for i in 0..20 {
20910            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
20911                .unwrap();
20912        }
20913        e.execute("ANALYZE").unwrap();
20914        let snap = e.snapshot();
20915        let e2 = Engine::restore_envelope(&snap).unwrap();
20916        let s = e2.statistics().get("t", "id").unwrap();
20917        assert_eq!(s.n_distinct, 20);
20918    }
20919
20920    // ── v6.2.1 auto-analyze threshold ───────────────────────────
20921
20922    #[test]
20923    fn auto_analyze_threshold_fires_after_10pct_of_min_rows_on_small_table() {
20924        // For a table with 0 rows then 10 inserts → modified=10,
20925        // row_count=10. Threshold = 0.1 × max(10, 100) = 10. So
20926        // after the 10th INSERT the threshold is met.
20927        let mut e = Engine::new();
20928        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
20929        for i in 0..9 {
20930            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
20931                .unwrap();
20932        }
20933        assert!(e.tables_needing_analyze().is_empty(), "9 < threshold");
20934        e.execute("INSERT INTO t VALUES (9)").unwrap();
20935        let needs = e.tables_needing_analyze();
20936        assert_eq!(needs, alloc::vec!["t".to_string()]);
20937    }
20938
20939    #[test]
20940    fn auto_analyze_threshold_uses_10pct_of_row_count_for_large_tables() {
20941        // After ANALYZE on 1000 rows, threshold = 0.1 × row_count.
20942        // Each new INSERT bumps both modified and row_count, so to
20943        // trigger from N=1000 we need modifications ≥ 0.1 × (1000+M),
20944        // i.e. M ≥ 112. The test inserts 50 (no fire), then 150
20945        // more (200 total mods, row_count=1200, threshold=120 → fire).
20946        let mut e = Engine::new();
20947        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
20948        for i in 0..1000 {
20949            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
20950                .unwrap();
20951        }
20952        e.execute("ANALYZE t").unwrap();
20953        assert!(e.tables_needing_analyze().is_empty(), "fresh ANALYZE");
20954        for i in 1000..1050 {
20955            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
20956                .unwrap();
20957        }
20958        assert!(
20959            e.tables_needing_analyze().is_empty(),
20960            "50 inserts < threshold of ~105"
20961        );
20962        for i in 1050..1200 {
20963            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
20964                .unwrap();
20965        }
20966        assert_eq!(
20967            e.tables_needing_analyze(),
20968            alloc::vec!["t".to_string()],
20969            "200 inserts > 0.1 × 1200 threshold"
20970        );
20971    }
20972
20973    #[test]
20974    fn auto_analyze_threshold_resets_after_analyze() {
20975        let mut e = Engine::new();
20976        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
20977        for i in 0..200 {
20978            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
20979                .unwrap();
20980        }
20981        assert!(!e.tables_needing_analyze().is_empty());
20982        e.execute("ANALYZE").unwrap();
20983        assert!(
20984            e.tables_needing_analyze().is_empty(),
20985            "ANALYZE must reset the counter"
20986        );
20987    }
20988
20989    #[test]
20990    fn auto_analyze_threshold_tracks_updates_and_deletes() {
20991        let mut e = Engine::new();
20992        e.execute("CREATE TABLE t (id INT NOT NULL, label TEXT)")
20993            .unwrap();
20994        for i in 0..50 {
20995            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, 'x')"))
20996                .unwrap();
20997        }
20998        e.execute("ANALYZE t").unwrap();
20999        // UPDATE 20 rows + DELETE 5 → modified=25. Threshold = 0.1
21000        // × max(50, 100) = 10. So 25 >= 10 → trigger.
21001        e.execute("UPDATE t SET label = 'y' WHERE id < 20").unwrap();
21002        e.execute("DELETE FROM t WHERE id >= 45").unwrap();
21003        assert_eq!(e.tables_needing_analyze(), alloc::vec!["t".to_string()]);
21004    }
21005
21006    #[test]
21007    fn v4_envelope_loads_with_empty_statistics() {
21008        // Forge a v4 envelope by hand: catalog + users + pubs +
21009        // subs trailer, no statistics. A v6.2.0 reader must accept
21010        // it and surface an empty Statistics.
21011        let mut e = Engine::new();
21012        e.create_user("alice", "secret", crate::users::Role::ReadOnly, [0u8; 16])
21013            .unwrap();
21014        let catalog = e.catalog.serialize();
21015        let users = crate::users::serialize_users(&e.users);
21016        let pubs = e.publications.serialize();
21017        let subs = e.subscriptions.serialize();
21018        let mut buf = Vec::new();
21019        buf.extend_from_slice(b"SPGENV01");
21020        buf.push(4u8);
21021        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
21022        buf.extend_from_slice(&catalog);
21023        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
21024        buf.extend_from_slice(&users);
21025        buf.extend_from_slice(&u32::try_from(pubs.len()).unwrap().to_le_bytes());
21026        buf.extend_from_slice(&pubs);
21027        buf.extend_from_slice(&u32::try_from(subs.len()).unwrap().to_le_bytes());
21028        buf.extend_from_slice(&subs);
21029        let crc = spg_crypto::crc32::crc32(&buf);
21030        buf.extend_from_slice(&crc.to_le_bytes());
21031        let e2 = Engine::restore_envelope(&buf).expect("v4 envelope restores");
21032        assert!(e2.statistics().is_empty());
21033    }
21034
21035    #[test]
21036    fn v1_v2_envelope_loads_with_empty_publications() {
21037        // A snapshot taken before v6.1.2 (no publication trailer,
21038        // envelope v2) must still deserialise — and the resulting
21039        // engine must report zero publications. Use the engine's own
21040        // round-trip with no publications: that emits v3 but with an
21041        // empty pubs block. Then forge a v2 envelope by hand to lock
21042        // the back-compat path.
21043        let mut e = Engine::new();
21044        // Force users to be non-empty so the snapshot takes the
21045        // envelope path rather than the bare-catalog fallback.
21046        e.create_user("alice", "secret", crate::users::Role::ReadOnly, [0u8; 16])
21047            .unwrap();
21048
21049        // Forge an envelope v2: same shape as v3 but no pubs trailer.
21050        let catalog = e.catalog.serialize();
21051        let users = crate::users::serialize_users(&e.users);
21052        let mut buf = Vec::new();
21053        buf.extend_from_slice(b"SPGENV01");
21054        buf.push(2u8); // v2
21055        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
21056        buf.extend_from_slice(&catalog);
21057        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
21058        buf.extend_from_slice(&users);
21059        let crc = spg_crypto::crc32::crc32(&buf);
21060        buf.extend_from_slice(&crc.to_le_bytes());
21061
21062        let e2 = Engine::restore_envelope(&buf).expect("v2 envelope restores");
21063        assert!(e2.publications().is_empty());
21064    }
21065}