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            let mut subs: Vec<&SelectStatement> = Vec::new();
11226            collect_scalar_subqueries(expr, &mut subs);
11227            let plan_ok = match m.expr_plans.get(&key) {
11228                Some((count, _, _)) => *count == subs.len(),
11229                None => false,
11230            };
11231            if !plan_ok && !subs.is_empty() {
11232                let mut plan: Vec<Option<alloc::rc::Rc<memoize::GroupMap>>> =
11233                    Vec::with_capacity(subs.len());
11234                for sub in &subs {
11235                    let repr = alloc::format!("{sub}");
11236                    if !m.group_maps.contains_key(&repr) {
11237                        let built = self
11238                            .try_batch_correlated_scalar(sub, cancel)?
11239                            .map(alloc::rc::Rc::new);
11240                        m.group_maps.insert(repr.clone(), built);
11241                    }
11242                    plan.push(m.group_maps.get(&repr).cloned().flatten());
11243                }
11244                let mut template = expr.clone();
11245                hollow_scalar_subqueries(&mut template);
11246                m.expr_plans.insert(key, (subs.len(), plan, template));
11247            }
11248            if let Some((_, plan, template)) = m.expr_plans.get(&key)
11249                && !plan.is_empty()
11250                && plan.iter().all(|p| p.is_some())
11251            {
11252                // Fast path: every scalar subquery resolves via its
11253                // map; clone the HOLLOW template (subquery bodies
11254                // emptied at plan time - cloning full subquery ASTs
11255                // per row was the dominant malloc load), splice map
11256                // values, eval. Exists/IN subqueries (if any) still
11257                // drop to the resolver.
11258                let plan = plan.clone();
11259                let mut e = template.clone();
11260                let mut idx = 0usize;
11261                let ok = splice_planned_subqueries(&mut e, &plan, &mut idx, row, ctx)?;
11262                if ok {
11263                    if expr_has_subquery(&e) {
11264                        self.resolve_correlated_in_expr(&mut e, row, ctx, cancel, memo)?;
11265                    }
11266                    return eval::eval_expr(&e, row, ctx).map_err(EngineError::Eval);
11267                }
11268            }
11269        }
11270        let mut e = expr.clone();
11271        self.resolve_correlated_in_expr(&mut e, row, ctx, cancel, memo)?;
11272        eval::eval_expr(&e, row, ctx).map_err(EngineError::Eval)
11273    }
11274
11275    fn resolve_correlated_in_expr(
11276        &self,
11277        e: &mut Expr,
11278        row: &Row,
11279        ctx: &EvalContext<'_>,
11280        cancel: CancelToken<'_>,
11281        mut memo: Option<&mut memoize::MemoizeCache>,
11282    ) -> Result<(), EngineError> {
11283        match e {
11284            Expr::AggregateOrdered { call, order_by, .. } => {
11285                self.resolve_correlated_in_expr(call, row, ctx, cancel, memo.as_deref_mut())?;
11286                for o in order_by.iter_mut() {
11287                    self.resolve_correlated_in_expr(
11288                        &mut o.expr,
11289                        row,
11290                        ctx,
11291                        cancel,
11292                        memo.as_deref_mut(),
11293                    )?;
11294                }
11295            }
11296            Expr::ScalarSubquery(inner) => {
11297                // v7.29 (round-22 phase 3) — batch path first: a
11298                // correlated scalar of the `inner_col = outer_col
11299                // [ORDER BY … LIMIT 1]` shape evaluates ONCE as a
11300                // grouped scan; per-row resolution becomes a map
11301                // lookup. 23.5k per-group executions (~900 ms) became
11302                // one scan + lookups.
11303                if memo.is_some() {
11304                    let repr = alloc::format!("{}", **inner);
11305                    let entry_known = memo
11306                        .as_ref()
11307                        .is_some_and(|m| m.group_maps.contains_key(&repr));
11308                    if !entry_known {
11309                        let built = self
11310                            .try_batch_correlated_scalar(inner, cancel)?
11311                            .map(alloc::rc::Rc::new);
11312                        if let Some(m) = memo.as_deref_mut() {
11313                            m.group_maps.insert(repr.clone(), built);
11314                        }
11315                    }
11316                    if let Some(m) = memo.as_deref_mut()
11317                        && let Some(Some(gm)) = m.group_maps.get(&repr)
11318                    {
11319                        let (outer_col, map) = gm.as_ref();
11320                        let key_v = eval::eval_expr(&Expr::Column(outer_col.clone()), row, ctx)
11321                            .map_err(EngineError::Eval)?;
11322                        let v = if matches!(key_v, Value::Null) {
11323                            Value::Null
11324                        } else {
11325                            map.get(&aggregate::encode_key(core::slice::from_ref(&key_v)))
11326                                .cloned()
11327                                .unwrap_or(Value::Null)
11328                        };
11329                        *e = value_to_literal_expr(v)?;
11330                        return Ok(());
11331                    }
11332                }
11333                // v6.2.6 — Memoize: build the cache key from the
11334                // pre-substitution subquery repr + the outer row's
11335                // values. Two outer rows with identical correlated
11336                // values hit the same entry.
11337                let cache_key = memo.as_ref().map(|_| memoize::CacheKey {
11338                    subquery_repr: alloc::format!("{}", **inner),
11339                    outer_values: row.values.clone(),
11340                });
11341                if let (Some(cache), Some(k)) = (memo.as_deref_mut(), cache_key.as_ref())
11342                    && let Some(cached) = cache.get(k)
11343                {
11344                    *e = value_to_literal_expr(cached)?;
11345                    return Ok(());
11346                }
11347                let mut s = (**inner).clone();
11348                substitute_outer_columns(&mut s, row, ctx);
11349                let r = self.exec_select_cancel(&s, cancel)?;
11350                let QueryResult::Rows { rows, .. } = r else {
11351                    return Err(EngineError::Unsupported(
11352                        "scalar subquery: inner did not return rows".into(),
11353                    ));
11354                };
11355                let value = match rows.as_slice() {
11356                    [] => Value::Null,
11357                    [r0] => r0.values.first().cloned().unwrap_or(Value::Null),
11358                    _ => {
11359                        return Err(EngineError::Unsupported(alloc::format!(
11360                            "scalar subquery returned {} rows; expected 0 or 1",
11361                            rows.len()
11362                        )));
11363                    }
11364                };
11365                if let (Some(cache), Some(k)) = (memo.as_deref_mut(), cache_key) {
11366                    cache.insert(k, value.clone());
11367                }
11368                *e = value_to_literal_expr(value)?;
11369            }
11370            Expr::Exists { subquery, negated } => {
11371                let mut s = (**subquery).clone();
11372                substitute_outer_columns(&mut s, row, ctx);
11373                let r = self.exec_select_cancel(&s, cancel)?;
11374                let exists = matches!(r, QueryResult::Rows { rows, .. } if !rows.is_empty());
11375                let bit = if *negated { !exists } else { exists };
11376                *e = Expr::Literal(Literal::Bool(bit));
11377            }
11378            Expr::InSubquery {
11379                expr: lhs,
11380                subquery,
11381                negated,
11382            } => {
11383                self.resolve_correlated_in_expr(lhs, row, ctx, cancel, memo.as_deref_mut())?;
11384                let lhs_val = eval::eval_expr(lhs, row, ctx).map_err(EngineError::Eval)?;
11385                let mut s = (**subquery).clone();
11386                substitute_outer_columns(&mut s, row, ctx);
11387                let r = self.exec_select_cancel(&s, cancel)?;
11388                let QueryResult::Rows { columns, rows, .. } = r else {
11389                    return Err(EngineError::Unsupported(
11390                        "IN-subquery: inner did not return rows".into(),
11391                    ));
11392                };
11393                if columns.len() != 1 {
11394                    return Err(EngineError::Unsupported(alloc::format!(
11395                        "IN-subquery must project exactly one column; got {}",
11396                        columns.len()
11397                    )));
11398                }
11399                let mut found = false;
11400                let mut any_null = false;
11401                for r0 in rows {
11402                    let v = r0.values.into_iter().next().unwrap_or(Value::Null);
11403                    if v.is_null() {
11404                        any_null = true;
11405                        continue;
11406                    }
11407                    if value_cmp(&v, &lhs_val) == core::cmp::Ordering::Equal {
11408                        found = true;
11409                        break;
11410                    }
11411                }
11412                let bit = if found {
11413                    !*negated
11414                } else if any_null {
11415                    return Err(EngineError::Unsupported(
11416                        "IN-subquery with NULL in result and no match: NULL semantics not yet implemented".into(),
11417                    ));
11418                } else {
11419                    *negated
11420                };
11421                *e = Expr::Literal(Literal::Bool(bit));
11422            }
11423            Expr::Binary { lhs, rhs, .. } => {
11424                self.resolve_correlated_in_expr(lhs, row, ctx, cancel, memo.as_deref_mut())?;
11425                self.resolve_correlated_in_expr(rhs, row, ctx, cancel, memo.as_deref_mut())?;
11426            }
11427            Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
11428                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
11429            }
11430            Expr::Like { expr, pattern, .. } => {
11431                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
11432                self.resolve_correlated_in_expr(pattern, row, ctx, cancel, memo.as_deref_mut())?;
11433            }
11434            Expr::FunctionCall { args, .. } => {
11435                for a in args {
11436                    self.resolve_correlated_in_expr(a, row, ctx, cancel, memo.as_deref_mut())?;
11437                }
11438            }
11439            Expr::Extract { source, .. } => {
11440                self.resolve_correlated_in_expr(source, row, ctx, cancel, memo.as_deref_mut())?;
11441            }
11442            Expr::WindowFunction { .. }
11443            | Expr::Literal(_)
11444            | Expr::Placeholder(_)
11445            | Expr::Column(_) => {}
11446            // v7.10.10 — recurse children.
11447            Expr::Array(items) => {
11448                for elem in items {
11449                    self.resolve_correlated_in_expr(elem, row, ctx, cancel, memo.as_deref_mut())?;
11450                }
11451            }
11452            Expr::ArraySubscript { target, index } => {
11453                self.resolve_correlated_in_expr(target, row, ctx, cancel, memo.as_deref_mut())?;
11454                self.resolve_correlated_in_expr(index, row, ctx, cancel, memo.as_deref_mut())?;
11455            }
11456            Expr::AnyAll { expr, array, .. } => {
11457                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
11458                self.resolve_correlated_in_expr(array, row, ctx, cancel, memo.as_deref_mut())?;
11459            }
11460            Expr::Case {
11461                operand,
11462                branches,
11463                else_branch,
11464            } => {
11465                if let Some(o) = operand {
11466                    self.resolve_correlated_in_expr(o, row, ctx, cancel, memo.as_deref_mut())?;
11467                }
11468                for (w, t) in branches {
11469                    self.resolve_correlated_in_expr(w, row, ctx, cancel, memo.as_deref_mut())?;
11470                    self.resolve_correlated_in_expr(t, row, ctx, cancel, memo.as_deref_mut())?;
11471                }
11472                if let Some(e) = else_branch {
11473                    self.resolve_correlated_in_expr(e, row, ctx, cancel, memo.as_deref_mut())?;
11474                }
11475            }
11476        }
11477        Ok(())
11478    }
11479
11480    fn subquery_replacement(
11481        &self,
11482        e: &Expr,
11483        cancel: CancelToken<'_>,
11484    ) -> Result<Option<Expr>, EngineError> {
11485        match e {
11486            Expr::ScalarSubquery(inner) => {
11487                let mut s = (**inner).clone();
11488                // Recurse into the inner SELECT first so nested
11489                // subqueries materialise bottom-up.
11490                self.resolve_select_subqueries(&mut s, cancel)?;
11491                let r = match self.exec_bare_select_cancel(&s, cancel) {
11492                    Ok(r) => r,
11493                    Err(e) if is_correlation_error(&e) => return Ok(None),
11494                    Err(e) => return Err(e),
11495                };
11496                let QueryResult::Rows { rows, .. } = r else {
11497                    return Err(EngineError::Unsupported(
11498                        "scalar subquery: inner statement did not return rows".into(),
11499                    ));
11500                };
11501                let value = match rows.as_slice() {
11502                    [] => Value::Null,
11503                    [row] => row.values.first().cloned().unwrap_or(Value::Null),
11504                    _ => {
11505                        return Err(EngineError::Unsupported(alloc::format!(
11506                            "scalar subquery returned {} rows; expected 0 or 1",
11507                            rows.len()
11508                        )));
11509                    }
11510                };
11511                Ok(Some(value_to_literal_expr(value)?))
11512            }
11513            Expr::Exists { subquery, negated } => {
11514                let mut s = (**subquery).clone();
11515                self.resolve_select_subqueries(&mut s, cancel)?;
11516                let r = match self.exec_bare_select_cancel(&s, cancel) {
11517                    Ok(r) => r,
11518                    Err(e) if is_correlation_error(&e) => return Ok(None),
11519                    Err(e) => return Err(e),
11520                };
11521                let exists = match r {
11522                    QueryResult::Rows { rows, .. } => !rows.is_empty(),
11523                    QueryResult::CommandOk { .. } => false,
11524                };
11525                let bit = if *negated { !exists } else { exists };
11526                Ok(Some(Expr::Literal(Literal::Bool(bit))))
11527            }
11528            Expr::InSubquery {
11529                expr,
11530                subquery,
11531                negated,
11532            } => {
11533                let mut s = (**subquery).clone();
11534                self.resolve_select_subqueries(&mut s, cancel)?;
11535                let r = match self.exec_bare_select_cancel(&s, cancel) {
11536                    Ok(r) => r,
11537                    Err(e) if is_correlation_error(&e) => return Ok(None),
11538                    Err(e) => return Err(e),
11539                };
11540                let QueryResult::Rows { columns, rows, .. } = r else {
11541                    return Err(EngineError::Unsupported(
11542                        "IN-subquery: inner statement did not return rows".into(),
11543                    ));
11544                };
11545                if columns.len() != 1 {
11546                    return Err(EngineError::Unsupported(alloc::format!(
11547                        "IN-subquery must project exactly one column; got {}",
11548                        columns.len()
11549                    )));
11550                }
11551                // Build the same OR-Eq chain the parse-time literal-list
11552                // path constructs, with each value lifted into a Literal.
11553                let mut acc: Option<Expr> = None;
11554                for row in rows {
11555                    let v = row.values.into_iter().next().unwrap_or(Value::Null);
11556                    let lit = value_to_literal_expr(v)?;
11557                    let cmp = Expr::Binary {
11558                        lhs: expr.clone(),
11559                        op: BinOp::Eq,
11560                        rhs: Box::new(lit),
11561                    };
11562                    acc = Some(match acc {
11563                        None => cmp,
11564                        Some(prev) => Expr::Binary {
11565                            lhs: Box::new(prev),
11566                            op: BinOp::Or,
11567                            rhs: Box::new(cmp),
11568                        },
11569                    });
11570                }
11571                let combined = acc.unwrap_or(Expr::Literal(Literal::Bool(false)));
11572                let final_expr = if *negated {
11573                    Expr::Unary {
11574                        op: UnOp::Not,
11575                        expr: Box::new(combined),
11576                    }
11577                } else {
11578                    combined
11579                };
11580                Ok(Some(final_expr))
11581            }
11582            _ => Ok(None),
11583        }
11584    }
11585}
11586
11587// ---- v4.12 window-function helpers ----
11588// The (partition-key, order-key, original-index) tuple shape used
11589// across these helpers is intrinsic to the planner. Factoring it
11590// into a typedef adds indirection without making the code clearer,
11591// so several lints are allowed inline on the affected functions
11592// rather than module-wide.
11593
11594/// v4.22: cheap structural scan for `FROM <name>` (qualified or
11595/// not) inside a SELECT — used to verify the anchor of a WITH
11596/// RECURSIVE CTE doesn't recurse into itself. Conservative: walks
11597/// FROM joins, subqueries, and unions.
11598fn select_refers_to(stmt: &SelectStatement, target: &str) -> bool {
11599    if let Some(from) = &stmt.from
11600        && from_refers_to(from, target)
11601    {
11602        return true;
11603    }
11604    for (_, peer) in &stmt.unions {
11605        if select_refers_to(peer, target) {
11606            return true;
11607        }
11608    }
11609    for item in &stmt.items {
11610        if let SelectItem::Expr { expr, .. } = item
11611            && expr_refers_to(expr, target)
11612        {
11613            return true;
11614        }
11615    }
11616    if let Some(w) = &stmt.where_
11617        && expr_refers_to(w, target)
11618    {
11619        return true;
11620    }
11621    false
11622}
11623
11624fn from_refers_to(from: &FromClause, target: &str) -> bool {
11625    if from.primary.name.eq_ignore_ascii_case(target) {
11626        return true;
11627    }
11628    from.joins
11629        .iter()
11630        .any(|j| j.table.name.eq_ignore_ascii_case(target))
11631}
11632
11633/// v7.28 (round-22) — collect every QUALIFIED column referenced
11634/// anywhere in a SELECT (subquery bodies included). Returns None
11635/// when a wildcard or a bare column name makes static attribution
11636/// unsafe — callers then keep every column.
11637fn collect_qualified_refs(
11638    stmt: &SelectStatement,
11639    out: &mut alloc::collections::BTreeSet<(String, String)>,
11640) -> Option<()> {
11641    for item in &stmt.items {
11642        match item {
11643            SelectItem::Wildcard => return None,
11644            SelectItem::Expr { expr, .. } => collect_qualified_refs_expr(expr, out)?,
11645        }
11646    }
11647    if let Some(w) = &stmt.where_ {
11648        collect_qualified_refs_expr(w, out)?;
11649    }
11650    if let Some(from) = &stmt.from {
11651        for j in &from.joins {
11652            if let Some(on) = &j.on {
11653                collect_qualified_refs_expr(on, out)?;
11654            }
11655            if j.table.lateral_subquery.is_some() {
11656                return None;
11657            }
11658        }
11659    }
11660    if let Some(gs) = &stmt.group_by {
11661        for g in gs {
11662            collect_qualified_refs_expr(g, out)?;
11663        }
11664    }
11665    if let Some(h) = &stmt.having {
11666        collect_qualified_refs_expr(h, out)?;
11667    }
11668    for o in &stmt.order_by {
11669        collect_qualified_refs_expr(&o.expr, out)?;
11670    }
11671    for (_, peer) in &stmt.unions {
11672        collect_qualified_refs(peer, out)?;
11673    }
11674    for cte in &stmt.ctes {
11675        collect_qualified_refs(&cte.body, out)?;
11676    }
11677    Some(())
11678}
11679
11680fn collect_qualified_refs_expr(
11681    e: &Expr,
11682    out: &mut alloc::collections::BTreeSet<(String, String)>,
11683) -> Option<()> {
11684    // Two passes so the column and subquery visitors don't both
11685    // capture `out` mutably.
11686    let mut cols: Vec<spg_sql::ast::ColumnName> = Vec::new();
11687    let mut subs: Vec<&SelectStatement> = Vec::new();
11688    visit_expr_columns_and_subqueries(
11689        e,
11690        &mut |c: &spg_sql::ast::ColumnName| cols.push(c.clone()),
11691        &mut |sub| subs.push(sub),
11692    );
11693    for c in cols {
11694        match c.qualifier {
11695            Some(q) => {
11696                out.insert((q, c.name));
11697            }
11698            None => return None,
11699        }
11700    }
11701    for sub in subs {
11702        collect_qualified_refs(sub, out)?;
11703    }
11704    Some(())
11705}
11706
11707/// Immutable walk over an Expr visiting every Column and every
11708/// nested SelectStatement (v7.28).
11709fn visit_expr_columns_and_subqueries<'a>(
11710    e: &'a Expr,
11711    on_col: &mut impl FnMut(&'a spg_sql::ast::ColumnName),
11712    on_sub: &mut impl FnMut(&'a SelectStatement),
11713) {
11714    match e {
11715        Expr::Column(c) => on_col(c),
11716        Expr::ScalarSubquery(s) => on_sub(s),
11717        Expr::Exists { subquery, .. } => on_sub(subquery),
11718        Expr::InSubquery { expr, subquery, .. } => {
11719            visit_expr_columns_and_subqueries(expr, on_col, on_sub);
11720            on_sub(subquery);
11721        }
11722        Expr::Binary { lhs, rhs, .. } => {
11723            visit_expr_columns_and_subqueries(lhs, on_col, on_sub);
11724            visit_expr_columns_and_subqueries(rhs, on_col, on_sub);
11725        }
11726        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
11727            visit_expr_columns_and_subqueries(expr, on_col, on_sub);
11728        }
11729        Expr::Like { expr, pattern, .. } => {
11730            visit_expr_columns_and_subqueries(expr, on_col, on_sub);
11731            visit_expr_columns_and_subqueries(pattern, on_col, on_sub);
11732        }
11733        Expr::FunctionCall { args, .. } => {
11734            for a in args {
11735                visit_expr_columns_and_subqueries(a, on_col, on_sub);
11736            }
11737        }
11738        Expr::AggregateOrdered { call, order_by, .. } => {
11739            visit_expr_columns_and_subqueries(call, on_col, on_sub);
11740            for o in order_by {
11741                visit_expr_columns_and_subqueries(&o.expr, on_col, on_sub);
11742            }
11743        }
11744        Expr::Case {
11745            operand,
11746            branches,
11747            else_branch,
11748        } => {
11749            if let Some(op) = operand {
11750                visit_expr_columns_and_subqueries(op, on_col, on_sub);
11751            }
11752            for (w, t) in branches {
11753                visit_expr_columns_and_subqueries(w, on_col, on_sub);
11754                visit_expr_columns_and_subqueries(t, on_col, on_sub);
11755            }
11756            if let Some(eb) = else_branch {
11757                visit_expr_columns_and_subqueries(eb, on_col, on_sub);
11758            }
11759        }
11760        Expr::ArraySubscript { target, index } => {
11761            visit_expr_columns_and_subqueries(target, on_col, on_sub);
11762            visit_expr_columns_and_subqueries(index, on_col, on_sub);
11763        }
11764        Expr::Literal(_) | Expr::Placeholder(_) => {}
11765        // Exotic nodes (window etc.) — visit nothing extra; their
11766        // columns are caught when the caller bails on bare names
11767        // elsewhere, and window queries skip pruning entirely at
11768        // the call sites.
11769        _ => {
11770            // Exotic node (window function etc.): report an
11771            // unattributable marker so callers disable pruning.
11772            static BAIL: spg_sql::ast::ColumnName = spg_sql::ast::ColumnName {
11773                qualifier: None,
11774                name: String::new(),
11775            };
11776            on_col(&BAIL);
11777        }
11778    }
11779}
11780
11781/// v7.28 (round-22) — collect every Column qualifier in an expr;
11782/// `all_qualified` flips false on any bare column (those can't be
11783/// attributed to one table safely, so the pushdown skips them).
11784fn collect_column_qualifiers<'e>(e: &'e Expr, out: &mut Vec<&'e str>, all_qualified: &mut bool) {
11785    if let Expr::Column(c) = e {
11786        match &c.qualifier {
11787            Some(q) => out.push(q.as_str()),
11788            None => *all_qualified = false,
11789        }
11790        return;
11791    }
11792    // Reuse the canonical immutable walk via describe's walker shape:
11793    // recurse the common containers.
11794    match e {
11795        Expr::Binary { lhs, rhs, .. } => {
11796            collect_column_qualifiers(lhs, out, all_qualified);
11797            collect_column_qualifiers(rhs, out, all_qualified);
11798        }
11799        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
11800            collect_column_qualifiers(expr, out, all_qualified);
11801        }
11802        Expr::Like { expr, pattern, .. } => {
11803            collect_column_qualifiers(expr, out, all_qualified);
11804            collect_column_qualifiers(pattern, out, all_qualified);
11805        }
11806        Expr::FunctionCall { args, .. } => {
11807            for a in args {
11808                collect_column_qualifiers(a, out, all_qualified);
11809            }
11810        }
11811        Expr::Literal(_) | Expr::Placeholder(_) => {}
11812        // Anything exotic (CASE, subquery, window, arrays…):
11813        // conservatively mark unattributable.
11814        _ => *all_qualified = false,
11815    }
11816}
11817
11818fn expr_refers_to(e: &Expr, target: &str) -> bool {
11819    match e {
11820        Expr::AggregateOrdered { call, order_by, .. } => {
11821            expr_refers_to(call, target) || order_by.iter().any(|o| expr_refers_to(&o.expr, target))
11822        }
11823        Expr::ScalarSubquery(s) => select_refers_to(s, target),
11824        Expr::Exists { subquery, .. } | Expr::InSubquery { subquery, .. } => {
11825            select_refers_to(subquery, target)
11826        }
11827        Expr::Binary { lhs, rhs, .. } => expr_refers_to(lhs, target) || expr_refers_to(rhs, target),
11828        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
11829            expr_refers_to(expr, target)
11830        }
11831        Expr::Like { expr, pattern, .. } => {
11832            expr_refers_to(expr, target) || expr_refers_to(pattern, target)
11833        }
11834        Expr::FunctionCall { args, .. } => args.iter().any(|a| expr_refers_to(a, target)),
11835        Expr::Extract { source, .. } => expr_refers_to(source, target),
11836        Expr::WindowFunction {
11837            args,
11838            partition_by,
11839            order_by,
11840            ..
11841        } => {
11842            args.iter().any(|a| expr_refers_to(a, target))
11843                || partition_by.iter().any(|p| expr_refers_to(p, target))
11844                || order_by.iter().any(|(o, _, _)| expr_refers_to(o, target))
11845        }
11846        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => false,
11847        Expr::Array(items) => items.iter().any(|e| expr_refers_to(e, target)),
11848        Expr::ArraySubscript { target: t, index } => {
11849            expr_refers_to(t, target) || expr_refers_to(index, target)
11850        }
11851        Expr::AnyAll { expr, array, .. } => {
11852            expr_refers_to(expr, target) || expr_refers_to(array, target)
11853        }
11854        Expr::Case {
11855            operand,
11856            branches,
11857            else_branch,
11858        } => {
11859            operand
11860                .as_deref()
11861                .is_some_and(|o| expr_refers_to(o, target))
11862                || branches
11863                    .iter()
11864                    .any(|(w, t)| expr_refers_to(w, target) || expr_refers_to(t, target))
11865                || else_branch
11866                    .as_deref()
11867                    .is_some_and(|e| expr_refers_to(e, target))
11868        }
11869    }
11870}
11871
11872/// v4.22: pick more specific column types from observed rows when
11873/// the projection builder defaulted to Text (the v1.x behavior for
11874/// non-column expressions). Lets `WITH t(n) AS (SELECT 1 ...)`
11875/// land an Int column in the CTE storage table rather than failing
11876/// the insert with "expected TEXT, got INT".
11877/// v7.16.2 — map an SPG [`DataType`] to the PG-canonical
11878/// `information_schema.columns.data_type` text. Covers the
11879/// values mailrs's migrations probe (`'ARRAY'`, `'integer'`,
11880/// `'text'`, …). Unknown variants fall back to the SPG name
11881/// downcased — better than panicking on a future DataType.
11882fn pg_data_type_text(ty: DataType) -> alloc::string::String {
11883    let s = match ty {
11884        DataType::Int => "integer",
11885        DataType::BigInt => "bigint",
11886        DataType::SmallInt => "smallint",
11887        DataType::Float => "double precision",
11888        DataType::Bool => "boolean",
11889        DataType::Text => "text",
11890        DataType::Varchar(_) => "character varying",
11891        DataType::Date => "date",
11892        DataType::Timestamp => "timestamp without time zone",
11893        DataType::Timestamptz => "timestamp with time zone",
11894        DataType::Json => "jsonb",
11895        DataType::Bytes => "bytea",
11896        DataType::TextArray | DataType::IntArray | DataType::BigIntArray => "ARRAY",
11897        DataType::TsVector => "tsvector",
11898        DataType::TsQuery => "tsquery",
11899        DataType::Vector { .. } => "USER-DEFINED",
11900        // Non-exhaustive — fall back to "USER-DEFINED" the way
11901        // PG labels any pg_type it doesn't recognise.
11902        _ => "USER-DEFINED",
11903    };
11904    alloc::string::String::from(s)
11905}
11906
11907/// v7.16.2 — synthesise `information_schema.columns`. mailrs
11908/// queries are of shape `SELECT 1 FROM information_schema.columns
11909/// WHERE table_name = … AND column_name = … AND data_type = …` —
11910/// the v7.16.2 view returns the columns mailrs probes; broader
11911/// PG-spec parity (ordinal_position, is_nullable, character_
11912/// maximum_length, udt_name, …) lands as needed.
11913fn synth_information_schema_columns(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11914    let schema = alloc::vec![
11915        ColumnSchema::new("table_catalog", DataType::Text, false),
11916        ColumnSchema::new("table_schema", DataType::Text, false),
11917        ColumnSchema::new("table_name", DataType::Text, false),
11918        ColumnSchema::new("column_name", DataType::Text, false),
11919        ColumnSchema::new("ordinal_position", DataType::Int, false),
11920        ColumnSchema::new("is_nullable", DataType::Text, false),
11921        ColumnSchema::new("data_type", DataType::Text, false),
11922    ];
11923    let mut rows: Vec<Row> = Vec::new();
11924    for tname in cat.table_names() {
11925        let Some(t) = cat.get(&tname) else { continue };
11926        for (i, col) in t.schema().columns.iter().enumerate() {
11927            #[allow(clippy::cast_possible_wrap)]
11928            let ordinal = (i + 1) as i32;
11929            rows.push(Row::new(alloc::vec![
11930                Value::Text("spg".into()),
11931                Value::Text("public".into()),
11932                Value::Text(tname.clone()),
11933                Value::Text(col.name.clone()),
11934                Value::Int(ordinal),
11935                Value::Text(if col.nullable {
11936                    "YES".into()
11937                } else {
11938                    "NO".into()
11939                }),
11940                Value::Text(pg_data_type_text(col.ty)),
11941            ]));
11942        }
11943    }
11944    (schema, rows)
11945}
11946
11947/// v7.16.2 — synthesise `information_schema.tables`.
11948fn synth_information_schema_tables(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11949    let schema = alloc::vec![
11950        ColumnSchema::new("table_catalog", DataType::Text, false),
11951        ColumnSchema::new("table_schema", DataType::Text, false),
11952        ColumnSchema::new("table_name", DataType::Text, false),
11953        ColumnSchema::new("table_type", DataType::Text, false),
11954    ];
11955    let mut rows: Vec<Row> = Vec::new();
11956    for tname in cat.table_names() {
11957        rows.push(Row::new(alloc::vec![
11958            Value::Text("spg".into()),
11959            Value::Text("public".into()),
11960            Value::Text(tname.clone()),
11961            Value::Text("BASE TABLE".into()),
11962        ]));
11963    }
11964    (schema, rows)
11965}
11966
11967/// v7.16.2 — synthesise `pg_catalog.pg_class`. Minimum shape
11968/// for psql `\d` / ORM probes: `relname` + `relkind`. Each
11969/// user table emits one row.
11970fn synth_pg_class(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11971    let schema = alloc::vec![
11972        ColumnSchema::new("relname", DataType::Text, false),
11973        ColumnSchema::new("relkind", DataType::Text, false),
11974        ColumnSchema::new("relnamespace", DataType::BigInt, false),
11975    ];
11976    let mut rows: Vec<Row> = Vec::new();
11977    for tname in cat.table_names() {
11978        rows.push(Row::new(alloc::vec![
11979            Value::Text(tname.clone()),
11980            Value::Text("r".into()),
11981            Value::BigInt(2200), // PG's `public` namespace OID
11982        ]));
11983    }
11984    (schema, rows)
11985}
11986
11987/// v7.16.2 — synthesise `pg_catalog.pg_attribute`. Minimum
11988/// shape: `attrelid` (text — SPG has no OID), `attname`,
11989/// `attnum`, `atttypid` (text), `attnotnull`.
11990fn synth_pg_attribute(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11991    let schema = alloc::vec![
11992        ColumnSchema::new("attrelid", DataType::Text, false),
11993        ColumnSchema::new("attname", DataType::Text, false),
11994        ColumnSchema::new("attnum", DataType::Int, false),
11995        ColumnSchema::new("atttypid", DataType::Text, false),
11996        ColumnSchema::new("attnotnull", DataType::Bool, false),
11997    ];
11998    let mut rows: Vec<Row> = Vec::new();
11999    for tname in cat.table_names() {
12000        let Some(t) = cat.get(&tname) else { continue };
12001        for (i, col) in t.schema().columns.iter().enumerate() {
12002            #[allow(clippy::cast_possible_wrap)]
12003            let ordinal = (i + 1) as i32;
12004            rows.push(Row::new(alloc::vec![
12005                Value::Text(tname.clone()),
12006                Value::Text(col.name.clone()),
12007                Value::Int(ordinal),
12008                Value::Text(pg_data_type_text(col.ty)),
12009                Value::Bool(!col.nullable),
12010            ]));
12011        }
12012    }
12013    (schema, rows)
12014}
12015
12016/// v7.17.0 Phase 3.P0-50 — synthesise `pg_catalog.pg_type`. The
12017/// returned rows cover every built-in scalar / array type sqlx,
12018/// SQLAlchemy, Diesel and pgAdmin look up at compile / connect
12019/// time. PG-canonical schema columns we expose:
12020///   * oid           — type OID (the lookup key sqlx uses)
12021///   * typname       — canonical type name (`int4`, `text`, …)
12022///   * typlen        — width in bytes (-1 for var-length)
12023///   * typtype       — `b`ase / `c`omposite / `e`num / etc.
12024///   * typcategory   — PG type category single-char
12025///   * typelem       — element OID for arrays (0 otherwise)
12026///   * typarray      — array-type OID (0 if no array type)
12027///   * typnamespace  — schema OID (always `public` = 2200)
12028///
12029/// Other pg_type columns (typowner, typinput/typoutput, etc.)
12030/// land in follow-up work — sqlx encoders don't query them at
12031/// connect time.
12032fn synth_pg_type(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12033    let schema = alloc::vec![
12034        ColumnSchema::new("oid", DataType::BigInt, false),
12035        ColumnSchema::new("typname", DataType::Text, false),
12036        ColumnSchema::new("typlen", DataType::SmallInt, false),
12037        ColumnSchema::new("typtype", DataType::Text, false),
12038        ColumnSchema::new("typcategory", DataType::Text, false),
12039        ColumnSchema::new("typelem", DataType::BigInt, false),
12040        ColumnSchema::new("typarray", DataType::BigInt, false),
12041        ColumnSchema::new("typnamespace", DataType::BigInt, false),
12042    ];
12043    // (oid, name, len, type, cat, elem, array_oid). PG OID
12044    // numbers come straight from `pg_type.dat`.
12045    let scalars: &[(i64, &str, i16, &str, &str, i64, i64)] = &[
12046        // bool
12047        (16, "bool", 1, "b", "B", 0, 1000),
12048        (17, "bytea", -1, "b", "U", 0, 1001),
12049        (18, "char", 1, "b", "S", 0, 1002),
12050        (19, "name", 64, "b", "S", 0, 1003),
12051        (20, "int8", 8, "b", "N", 0, 1016),
12052        (21, "int2", 2, "b", "N", 0, 1005),
12053        (23, "int4", 4, "b", "N", 0, 1007),
12054        (24, "regproc", 4, "b", "N", 0, 1008),
12055        (25, "text", -1, "b", "S", 0, 1009),
12056        (26, "oid", 4, "b", "N", 0, 1028),
12057        (114, "json", -1, "b", "U", 0, 199),
12058        (142, "xml", -1, "b", "U", 0, 143),
12059        (700, "float4", 4, "b", "N", 0, 1021),
12060        (701, "float8", 8, "b", "N", 0, 1022),
12061        (650, "cidr", -1, "b", "I", 0, 651),
12062        (869, "inet", -1, "b", "I", 0, 1041),
12063        (829, "macaddr", 6, "b", "U", 0, 1040),
12064        (1042, "bpchar", -1, "b", "S", 0, 1014),
12065        (1043, "varchar", -1, "b", "S", 0, 1015),
12066        (1082, "date", 4, "b", "D", 0, 1182),
12067        (1083, "time", 8, "b", "D", 0, 1183),
12068        (1114, "timestamp", 8, "b", "D", 0, 1115),
12069        (1184, "timestamptz", 8, "b", "D", 0, 1185),
12070        (1186, "interval", 16, "b", "T", 0, 1187),
12071        (1266, "timetz", 12, "b", "D", 0, 1270),
12072        (1700, "numeric", -1, "b", "N", 0, 1231),
12073        (790, "money", 8, "b", "N", 0, 791),
12074        (2950, "uuid", 16, "b", "U", 0, 2951),
12075        (3802, "jsonb", -1, "b", "U", 0, 3807),
12076        (3614, "tsvector", -1, "b", "U", 0, 3643),
12077        (3615, "tsquery", -1, "b", "U", 0, 3645),
12078        // hstore + range types — typcategory 'U' (user) / 'R' (range).
12079        (3908, "tstzrange", -1, "r", "R", 0, 3909),
12080        (3910, "tsrange", -1, "r", "R", 0, 3911),
12081        (3904, "int4range", -1, "r", "R", 0, 3905),
12082        (3926, "int8range", -1, "r", "R", 0, 3927),
12083        (3906, "numrange", -1, "r", "R", 0, 3907),
12084        (3912, "daterange", -1, "r", "R", 0, 3913),
12085    ];
12086    // Array companion types share the typelem / typcategory='A'.
12087    // We emit just the array OIDs the scalars reference.
12088    let arrays: &[(i64, &str, i64)] = &[
12089        (1000, "_bool", 16),
12090        (1001, "_bytea", 17),
12091        (1002, "_char", 18),
12092        (1003, "_name", 19),
12093        (1016, "_int8", 20),
12094        (1005, "_int2", 21),
12095        (1007, "_int4", 23),
12096        (1008, "_regproc", 24),
12097        (1009, "_text", 25),
12098        (1028, "_oid", 26),
12099        (199, "_json", 114),
12100        (143, "_xml", 142),
12101        (1021, "_float4", 700),
12102        (1022, "_float8", 701),
12103        (651, "_cidr", 650),
12104        (1041, "_inet", 869),
12105        (1040, "_macaddr", 829),
12106        (1014, "_bpchar", 1042),
12107        (1015, "_varchar", 1043),
12108        (1182, "_date", 1082),
12109        (1183, "_time", 1083),
12110        (1115, "_timestamp", 1114),
12111        (1185, "_timestamptz", 1184),
12112        (1187, "_interval", 1186),
12113        (1270, "_timetz", 1266),
12114        (1231, "_numeric", 1700),
12115        (791, "_money", 790),
12116        (2951, "_uuid", 2950),
12117        (3807, "_jsonb", 3802),
12118        (3643, "_tsvector", 3614),
12119        (3645, "_tsquery", 3615),
12120    ];
12121    let mut rows: Vec<Row> = Vec::with_capacity(scalars.len() + arrays.len());
12122    for &(oid, name, len, ty, cat, elem, arr) in scalars {
12123        rows.push(Row::new(alloc::vec![
12124            Value::BigInt(oid),
12125            Value::Text(name.into()),
12126            Value::SmallInt(len),
12127            Value::Text(ty.into()),
12128            Value::Text(cat.into()),
12129            Value::BigInt(elem),
12130            Value::BigInt(arr),
12131            Value::BigInt(2200),
12132        ]));
12133    }
12134    for &(oid, name, elem) in arrays {
12135        rows.push(Row::new(alloc::vec![
12136            Value::BigInt(oid),
12137            Value::Text(name.into()),
12138            Value::SmallInt(-1),
12139            Value::Text("b".into()),
12140            Value::Text("A".into()),
12141            Value::BigInt(elem),
12142            Value::BigInt(0),
12143            Value::BigInt(2200),
12144        ]));
12145    }
12146    (schema, rows)
12147}
12148
12149/// v7.17.0 Phase 3.P0-51 — synthesise `pg_catalog.pg_proc`. ORM /
12150/// pgAdmin probes look up functions by name; SPG synthesises rows
12151/// for the built-in scalar functions / aggregates / window funcs
12152/// the engine actually dispatches. SPG has no user-defined
12153/// functions yet so the table is a stable static list.
12154///
12155/// Schema columns exposed:
12156///   * oid (BigInt) — function OID from PG's pg_proc.dat
12157///   * proname (Text) — function name (lowercase)
12158///   * pronamespace (BigInt) — 11 (`pg_catalog`)
12159///   * prokind (Text) — 'f' function, 'a' aggregate, 'w' window
12160///   * pronargs (SmallInt) — declared arg count (-1 for variadic)
12161///   * prorettype (BigInt) — return type OID (matches synth_pg_type)
12162/// v7.24 (round-16 D) — synthesise `pg_catalog.pg_trigger` from the
12163/// live catalog. PG-shaped core columns (tgname, tgenabled with
12164/// 'O'/'D') plus pragmatic text columns PG keeps relational
12165/// (relname, timing, events, function) so health checks don't need
12166/// oid joins.
12167fn synth_pg_trigger(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12168    let schema = alloc::vec![
12169        ColumnSchema::new("tgname", DataType::Text, false),
12170        ColumnSchema::new("relname", DataType::Text, false),
12171        ColumnSchema::new("tgenabled", DataType::Text, false),
12172        ColumnSchema::new("timing", DataType::Text, false),
12173        ColumnSchema::new("events", DataType::Text, false),
12174        ColumnSchema::new("function", DataType::Text, false),
12175    ];
12176    let rows: Vec<Row> = cat
12177        .triggers()
12178        .iter()
12179        .map(|t| {
12180            Row::new(alloc::vec![
12181                Value::Text(t.name.clone()),
12182                Value::Text(t.table.clone()),
12183                Value::Text(if t.enabled { "O".into() } else { "D".into() }),
12184                Value::Text(t.timing.clone()),
12185                Value::Text(t.events.join(" OR ")),
12186                Value::Text(t.function.clone()),
12187            ])
12188        })
12189        .collect();
12190    (schema, rows)
12191}
12192
12193fn synth_pg_proc(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12194    let schema = alloc::vec![
12195        ColumnSchema::new("oid", DataType::BigInt, false),
12196        ColumnSchema::new("proname", DataType::Text, false),
12197        ColumnSchema::new("pronamespace", DataType::BigInt, false),
12198        ColumnSchema::new("prokind", DataType::Text, false),
12199        ColumnSchema::new("pronargs", DataType::Int, false),
12200        ColumnSchema::new("prorettype", DataType::BigInt, false),
12201    ];
12202    // (oid, name, kind, nargs, rettype). OIDs taken from PG's
12203    // pg_proc.dat for the common subset.
12204    let funcs: &[(i64, &str, &str, i32, i64)] = &[
12205        // Scalar functions.
12206        (1318, "length", "f", 1, 23),
12207        (871, "upper", "f", 1, 25),
12208        (870, "lower", "f", 1, 25),
12209        (936, "substring", "f", 3, 25),
12210        (937, "substring", "f", 2, 25),
12211        (3055, "btrim", "f", 1, 25),
12212        (885, "btrim", "f", 2, 25),
12213        (3056, "ltrim", "f", 1, 25),
12214        (875, "ltrim", "f", 2, 25),
12215        (3057, "rtrim", "f", 1, 25),
12216        (876, "rtrim", "f", 2, 25),
12217        (1397, "abs", "f", 1, 23),
12218        (1396, "abs", "f", 1, 20),
12219        (1606, "round", "f", 1, 1700),
12220        (1707, "round", "f", 2, 1700),
12221        (2308, "ceil", "f", 1, 701),
12222        (2309, "ceiling", "f", 1, 701),
12223        (2310, "floor", "f", 1, 701),
12224        (1376, "sqrt", "f", 1, 701),
12225        (1369, "ln", "f", 1, 701),
12226        (1373, "exp", "f", 1, 701),
12227        (1368, "power", "f", 2, 701),
12228        (2228, "random", "f", 0, 701),
12229        // Date / time.
12230        (1299, "now", "f", 0, 1184),
12231        (1274, "current_timestamp", "f", 0, 1184),
12232        (1140, "current_date", "f", 0, 1082),
12233        (2050, "current_time", "f", 0, 1083),
12234        (1158, "date_trunc", "f", 2, 1184),
12235        (1171, "date_part", "f", 2, 701),
12236        (1172, "age", "f", 1, 1186),
12237        (936, "to_char", "f", 2, 25),
12238        // Session / introspection.
12239        (861, "current_database", "f", 0, 19),
12240        (745, "current_user", "f", 0, 19),
12241        (745, "session_user", "f", 0, 19),
12242        (1402, "current_schema", "f", 0, 19),
12243        // String concat / format.
12244        (3058, "concat", "f", -1, 25),
12245        (3059, "concat_ws", "f", -1, 25),
12246        (3539, "format", "f", -1, 25),
12247        // Type introspection.
12248        (2877, "pg_typeof", "f", 1, 2206),
12249        // JSON.
12250        (3198, "json_build_object", "f", -1, 114),
12251        (3199, "jsonb_build_object", "f", -1, 3802),
12252        (3271, "json_build_array", "f", -1, 114),
12253        (3272, "jsonb_build_array", "f", -1, 3802),
12254        // UUID.
12255        (3253, "gen_random_uuid", "f", 0, 2950),
12256        (3252, "uuid_generate_v4", "f", 0, 2950),
12257        // Aggregates.
12258        (2147, "count", "a", 0, 20),
12259        (2803, "count", "a", -1, 20),
12260        (2116, "max", "a", 1, 23),
12261        (2132, "min", "a", 1, 23),
12262        (2108, "sum", "a", 1, 20),
12263        (2100, "avg", "a", 1, 1700),
12264        (2517, "string_agg", "a", 2, 25),
12265        (2747, "array_agg", "a", 1, 1009),
12266        (2517, "bool_and", "a", 1, 16),
12267        (2518, "bool_or", "a", 1, 16),
12268        (2519, "every", "a", 1, 16),
12269        // Window functions.
12270        (3100, "row_number", "w", 0, 20),
12271        (3101, "rank", "w", 0, 20),
12272        (3102, "dense_rank", "w", 0, 20),
12273        (3103, "percent_rank", "w", 0, 701),
12274        (3104, "cume_dist", "w", 0, 701),
12275        (3105, "lag", "w", -1, 2283),
12276        (3106, "lead", "w", -1, 2283),
12277        (3107, "first_value", "w", 1, 2283),
12278        (3108, "last_value", "w", 1, 2283),
12279        (3109, "nth_value", "w", 2, 2283),
12280    ];
12281    let mut rows: Vec<Row> = Vec::with_capacity(funcs.len());
12282    for &(oid, name, kind, nargs, rettype) in funcs {
12283        rows.push(Row::new(alloc::vec![
12284            Value::BigInt(oid),
12285            Value::Text(name.into()),
12286            Value::BigInt(11),
12287            Value::Text(kind.into()),
12288            Value::Int(nargs),
12289            Value::BigInt(rettype),
12290        ]));
12291    }
12292    (schema, rows)
12293}
12294
12295/// v7.17.0 Phase 3.P0-65 — synthesise `mysql.user`. MySQL admin
12296/// queries (`SELECT user, host FROM mysql.user`) probe this at
12297/// connect time to list accounts. SPG ships one row per
12298/// UserStore entry plus a synthetic `root` superuser row for
12299/// MySQL bootstrap compat.
12300fn synth_mysql_user(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
12301    let schema = alloc::vec![
12302        ColumnSchema::new("user", DataType::Text, false),
12303        ColumnSchema::new("host", DataType::Text, false),
12304        ColumnSchema::new("select_priv", DataType::Text, false),
12305    ];
12306    let mut rows: Vec<Row> = Vec::new();
12307    rows.push(Row::new(alloc::vec![
12308        Value::Text("root".into()),
12309        Value::Text("localhost".into()),
12310        Value::Text("Y".into()),
12311    ]));
12312    for (name, _) in engine.users.iter() {
12313        if name != "root" {
12314            rows.push(Row::new(alloc::vec![
12315                Value::Text(name.to_string()),
12316                Value::Text("%".into()),
12317                Value::Text("Y".into()),
12318            ]));
12319        }
12320    }
12321    (schema, rows)
12322}
12323
12324/// v7.17.0 Phase 3.P0-65 — synthesise `mysql.db`. The
12325/// per-database privileges table. SPG is single-database so the
12326/// table surfaces one row per declared user with full privileges
12327/// on the canonical `postgres` database.
12328fn synth_mysql_db() -> (Vec<ColumnSchema>, Vec<Row>) {
12329    let schema = alloc::vec![
12330        ColumnSchema::new("host", DataType::Text, false),
12331        ColumnSchema::new("db", DataType::Text, false),
12332        ColumnSchema::new("user", DataType::Text, false),
12333        ColumnSchema::new("select_priv", DataType::Text, false),
12334    ];
12335    let rows = alloc::vec![Row::new(alloc::vec![
12336        Value::Text("localhost".into()),
12337        Value::Text("postgres".into()),
12338        Value::Text("root".into()),
12339        Value::Text("Y".into()),
12340    ])];
12341    (schema, rows)
12342}
12343
12344/// v7.17.0 Phase 3.P0-63 — synthesise
12345/// `information_schema.KEY_COLUMN_USAGE`. ORM migration tools
12346/// (Alembic, Sequelize, TypeORM) walk this view to discover FK
12347/// relationships in MySQL-flavoured introspection queries.
12348///
12349/// Schema columns exposed:
12350///   * CONSTRAINT_NAME (Text)
12351///   * TABLE_NAME (Text)
12352///   * COLUMN_NAME (Text)
12353///   * ORDINAL_POSITION (Int)
12354///   * REFERENCED_TABLE_NAME (Text) — empty for non-FK rows
12355///   * REFERENCED_COLUMN_NAME (Text) — empty for non-FK rows
12356fn synth_info_key_column_usage(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12357    let schema = alloc::vec![
12358        ColumnSchema::new("constraint_name", DataType::Text, false),
12359        ColumnSchema::new("table_name", DataType::Text, false),
12360        ColumnSchema::new("column_name", DataType::Text, false),
12361        ColumnSchema::new("ordinal_position", DataType::Int, false),
12362        ColumnSchema::new("referenced_table_name", DataType::Text, false),
12363        ColumnSchema::new("referenced_column_name", DataType::Text, false),
12364    ];
12365    let mut rows: Vec<Row> = Vec::new();
12366    for tname in cat.table_names() {
12367        let Some(t) = cat.get(&tname) else { continue };
12368        let cols = &t.schema().columns;
12369        let col_name_at = |pos: usize| -> String {
12370            cols.get(pos)
12371                .map_or_else(|| alloc::format!("col{pos}"), |c| c.name.clone())
12372        };
12373        // FKs.
12374        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
12375            let conname = fk
12376                .name
12377                .clone()
12378                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
12379            for (i, (&local, &parent)) in fk
12380                .local_columns
12381                .iter()
12382                .zip(fk.parent_columns.iter())
12383                .enumerate()
12384            {
12385                let parent_name = cat
12386                    .get(&fk.parent_table)
12387                    .and_then(|pt| pt.schema().columns.get(parent).map(|c| c.name.clone()))
12388                    .unwrap_or_else(|| alloc::format!("col{parent}"));
12389                #[allow(clippy::cast_possible_wrap)]
12390                let ordinal = (i + 1) as i32;
12391                rows.push(Row::new(alloc::vec![
12392                    Value::Text(conname.clone()),
12393                    Value::Text(tname.clone()),
12394                    Value::Text(col_name_at(local)),
12395                    Value::Int(ordinal),
12396                    Value::Text(fk.parent_table.clone()),
12397                    Value::Text(parent_name),
12398                ]));
12399            }
12400        }
12401        // PK / composite UC entries.
12402        for (ci, uc) in t.schema().uniqueness_constraints.iter().enumerate() {
12403            let conname = if uc.is_primary_key {
12404                alloc::format!("{}_pkey", tname)
12405            } else {
12406                alloc::format!("{}_uniq{ci}", tname)
12407            };
12408            for (i, &local) in uc.columns.iter().enumerate() {
12409                #[allow(clippy::cast_possible_wrap)]
12410                let ordinal = (i + 1) as i32;
12411                rows.push(Row::new(alloc::vec![
12412                    Value::Text(conname.clone()),
12413                    Value::Text(tname.clone()),
12414                    Value::Text(col_name_at(local)),
12415                    Value::Int(ordinal),
12416                    Value::Text(String::new()),
12417                    Value::Text(String::new()),
12418                ]));
12419            }
12420        }
12421    }
12422    (schema, rows)
12423}
12424
12425/// v7.17.0 Phase 3.P0-64 — synthesise
12426/// `information_schema.REFERENTIAL_CONSTRAINTS`. One row per FK.
12427fn synth_info_referential_constraints(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12428    let schema = alloc::vec![
12429        ColumnSchema::new("constraint_name", DataType::Text, false),
12430        ColumnSchema::new("table_name", DataType::Text, false),
12431        ColumnSchema::new("referenced_table_name", DataType::Text, false),
12432        ColumnSchema::new("update_rule", DataType::Text, false),
12433        ColumnSchema::new("delete_rule", DataType::Text, false),
12434    ];
12435    fn rule_name(a: spg_storage::FkAction) -> &'static str {
12436        match a {
12437            spg_storage::FkAction::Cascade => "CASCADE",
12438            spg_storage::FkAction::SetNull => "SET NULL",
12439            spg_storage::FkAction::SetDefault => "SET DEFAULT",
12440            spg_storage::FkAction::Restrict => "RESTRICT",
12441            spg_storage::FkAction::NoAction => "NO ACTION",
12442        }
12443    }
12444    let mut rows: Vec<Row> = Vec::new();
12445    for tname in cat.table_names() {
12446        let Some(t) = cat.get(&tname) else { continue };
12447        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
12448            let conname = fk
12449                .name
12450                .clone()
12451                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
12452            rows.push(Row::new(alloc::vec![
12453                Value::Text(conname),
12454                Value::Text(tname.clone()),
12455                Value::Text(fk.parent_table.clone()),
12456                Value::Text(rule_name(fk.on_update).into()),
12457                Value::Text(rule_name(fk.on_delete).into()),
12458            ]));
12459        }
12460    }
12461    (schema, rows)
12462}
12463
12464/// v7.17.0 Phase 3.P0-64 — synthesise `information_schema.STATISTICS`.
12465/// One row per (index × column) — admin tools walk this to
12466/// surface index-cardinality estimates.
12467fn synth_info_statistics(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12468    let schema = alloc::vec![
12469        ColumnSchema::new("table_name", DataType::Text, false),
12470        ColumnSchema::new("index_name", DataType::Text, false),
12471        ColumnSchema::new("column_name", DataType::Text, false),
12472        ColumnSchema::new("seq_in_index", DataType::Int, false),
12473        ColumnSchema::new("non_unique", DataType::Int, false),
12474        ColumnSchema::new("index_type", DataType::Text, false),
12475    ];
12476    let mut rows: Vec<Row> = Vec::new();
12477    for tname in cat.table_names() {
12478        let Some(t) = cat.get(&tname) else { continue };
12479        for idx in t.indices() {
12480            let col = t
12481                .schema()
12482                .columns
12483                .get(idx.column_position)
12484                .map_or("?".into(), |c| c.name.clone());
12485            rows.push(Row::new(alloc::vec![
12486                Value::Text(tname.clone()),
12487                Value::Text(idx.name.clone()),
12488                Value::Text(col),
12489                Value::Int(1),
12490                Value::Int(i32::from(!idx.is_unique)),
12491                Value::Text("BTREE".into()),
12492            ]));
12493        }
12494    }
12495    (schema, rows)
12496}
12497
12498/// v7.17.0 Phase 3.P0-64 — synthesise `information_schema.ROUTINES`.
12499/// SPG has no user-defined functions in v7.17 so the surface is
12500/// always empty; admin tools just need the table to exist.
12501fn synth_info_routines() -> (Vec<ColumnSchema>, Vec<Row>) {
12502    let schema = alloc::vec![
12503        ColumnSchema::new("routine_name", DataType::Text, false),
12504        ColumnSchema::new("routine_type", DataType::Text, false),
12505        ColumnSchema::new("data_type", DataType::Text, false),
12506    ];
12507    (schema, Vec::new())
12508}
12509
12510/// v7.17.0 Phase 3.P0-54 — synthesise `pg_catalog.pg_constraint`.
12511/// ORM compilers (Diesel, sea-orm) and admin tools probe this for
12512/// FK / UNIQUE / PK / CHECK definitions to surface relationship
12513/// graphs and validation rules. SPG ships one row per
12514/// uniqueness constraint + foreign key declared in the catalog.
12515///
12516/// Schema columns exposed:
12517///   * conname (Text) — constraint name (synthetic when anonymous)
12518///   * contype (Text) — `p` PK, `u` UNIQUE, `f` FK, `c` CHECK
12519///   * conrelid (Text) — owner table name
12520///   * confrelid (Text) — referenced parent table (FK only;
12521///     empty string otherwise)
12522///   * conkey (Text) — comma-separated column names
12523///   * confkey (Text) — comma-separated parent column names (FK only)
12524fn synth_pg_constraint(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12525    let schema = alloc::vec![
12526        ColumnSchema::new("conname", DataType::Text, false),
12527        ColumnSchema::new("contype", DataType::Text, false),
12528        ColumnSchema::new("conrelid", DataType::Text, false),
12529        ColumnSchema::new("confrelid", DataType::Text, false),
12530        ColumnSchema::new("conkey", DataType::Text, false),
12531        ColumnSchema::new("confkey", DataType::Text, false),
12532    ];
12533    let mut rows: Vec<Row> = Vec::new();
12534    for tname in cat.table_names() {
12535        let Some(t) = cat.get(&tname) else { continue };
12536        let cols = &t.schema().columns;
12537        let col_name_at = |pos: usize| -> String {
12538            cols.get(pos)
12539                .map_or_else(|| alloc::format!("col{pos}"), |c| c.name.clone())
12540        };
12541        // Uniqueness constraints (composite UNIQUE / PRIMARY KEY).
12542        for (ci, uc) in t.schema().uniqueness_constraints.iter().enumerate() {
12543            let kind = if uc.is_primary_key { "p" } else { "u" };
12544            let conname = if uc.is_primary_key {
12545                alloc::format!("{}_pkey", tname)
12546            } else {
12547                alloc::format!("{}_uniq{ci}", tname)
12548            };
12549            let conkey: Vec<String> = uc.columns.iter().map(|&p| col_name_at(p)).collect();
12550            rows.push(Row::new(alloc::vec![
12551                Value::Text(conname),
12552                Value::Text(kind.into()),
12553                Value::Text(tname.clone()),
12554                Value::Text(String::new()),
12555                Value::Text(conkey.join(",")),
12556                Value::Text(String::new()),
12557            ]));
12558        }
12559        // Single-column PK / UNIQUE indexes that have no
12560        // matching entry in `uniqueness_constraints` (the engine
12561        // creates only the BTree index for the bare-column case;
12562        // composite forms ride the UC path above).
12563        for idx in t.indices() {
12564            if !idx.is_unique {
12565                continue;
12566            }
12567            let is_primary = idx.name.ends_with("_pkey");
12568            let conname = idx.name.clone();
12569            let kind = if is_primary { "p" } else { "u" };
12570            let col_name = col_name_at(idx.column_position);
12571            // Skip if already emitted via the UC loop above (same
12572            // tuple shape — single-column).
12573            let already = t
12574                .schema()
12575                .uniqueness_constraints
12576                .iter()
12577                .any(|uc| uc.columns.len() == 1 && uc.columns[0] == idx.column_position);
12578            if already {
12579                continue;
12580            }
12581            rows.push(Row::new(alloc::vec![
12582                Value::Text(conname),
12583                Value::Text(kind.into()),
12584                Value::Text(tname.clone()),
12585                Value::Text(String::new()),
12586                Value::Text(col_name),
12587                Value::Text(String::new()),
12588            ]));
12589        }
12590        // Foreign keys.
12591        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
12592            let conname = fk
12593                .name
12594                .clone()
12595                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
12596            let conkey: Vec<String> = fk.local_columns.iter().map(|&p| col_name_at(p)).collect();
12597            // Parent column names: look up the parent table's
12598            // schema if it exists; otherwise emit positions.
12599            let confkey: Vec<String> = if let Some(parent) = cat.get(&fk.parent_table) {
12600                fk.parent_columns
12601                    .iter()
12602                    .map(|&p| {
12603                        parent
12604                            .schema()
12605                            .columns
12606                            .get(p)
12607                            .map_or_else(|| alloc::format!("col{p}"), |c| c.name.clone())
12608                    })
12609                    .collect()
12610            } else {
12611                fk.parent_columns
12612                    .iter()
12613                    .map(|p| alloc::format!("col{p}"))
12614                    .collect()
12615            };
12616            rows.push(Row::new(alloc::vec![
12617                Value::Text(conname),
12618                Value::Text("f".into()),
12619                Value::Text(tname.clone()),
12620                Value::Text(fk.parent_table.clone()),
12621                Value::Text(conkey.join(",")),
12622                Value::Text(confkey.join(",")),
12623            ]));
12624        }
12625    }
12626    (schema, rows)
12627}
12628
12629/// v7.17.0 Phase 3.P0-55 — synthesise `pg_catalog.pg_database`.
12630/// SPG is single-database so we surface a single row keyed on the
12631/// canonical `postgres` database name (matching what every PG
12632/// admin tool's startup screen expects to find).
12633fn synth_pg_database(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12634    let schema = alloc::vec![
12635        ColumnSchema::new("oid", DataType::BigInt, false),
12636        ColumnSchema::new("datname", DataType::Text, false),
12637        ColumnSchema::new("datdba", DataType::BigInt, false),
12638        ColumnSchema::new("encoding", DataType::Int, false),
12639        ColumnSchema::new("datcollate", DataType::Text, false),
12640    ];
12641    let rows = alloc::vec![Row::new(alloc::vec![
12642        Value::BigInt(16384),
12643        Value::Text("postgres".into()),
12644        Value::BigInt(10),
12645        Value::Int(6), // UTF8
12646        Value::Text("en_US.UTF-8".into()),
12647    ])];
12648    (schema, rows)
12649}
12650
12651/// v7.17.0 Phase 3.P0-55 — synthesise `pg_catalog.pg_roles`. PG's
12652/// pg_roles is a view over pg_authid showing all roles. SPG ships
12653/// one row per declared user from the engine's UserStore so admin
12654/// tool startup screens can populate.
12655fn synth_pg_roles(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
12656    let schema = alloc::vec![
12657        ColumnSchema::new("oid", DataType::BigInt, false),
12658        ColumnSchema::new("rolname", DataType::Text, false),
12659        ColumnSchema::new("rolsuper", DataType::Bool, false),
12660        ColumnSchema::new("rolinherit", DataType::Bool, false),
12661        ColumnSchema::new("rolcanlogin", DataType::Bool, false),
12662    ];
12663    let mut rows: Vec<Row> = Vec::new();
12664    let oid: i64 = 10;
12665    for (i, (name, _)) in engine.users.iter().enumerate() {
12666        rows.push(Row::new(alloc::vec![
12667            Value::BigInt(oid + (i as i64) + 1),
12668            Value::Text(name.to_string()),
12669            Value::Bool(false),
12670            Value::Bool(true),
12671            Value::Bool(true),
12672        ]));
12673    }
12674    // Always include `postgres` as the bootstrap superuser if not
12675    // already present — admin tools probe for it.
12676    if !rows
12677        .iter()
12678        .any(|r| matches!(&r.values[1], Value::Text(s) if s == "postgres"))
12679    {
12680        rows.insert(
12681            0,
12682            Row::new(alloc::vec![
12683                Value::BigInt(10),
12684                Value::Text("postgres".into()),
12685                Value::Bool(true),
12686                Value::Bool(true),
12687                Value::Bool(true),
12688            ]),
12689        );
12690    }
12691    (schema, rows)
12692}
12693
12694/// v7.17.0 Phase 3.P0-56 — synthesise `pg_catalog.pg_views`. PG's
12695/// pg_views is a view listing every catalog view; SPG ships one
12696/// row per declared view + its definition text.
12697/// Synthesise `pg_catalog.pg_extension`. SPG ships its "extension"
12698/// surfaces natively (vector, pg_trgm, plpgsql-shaped DO blocks), so
12699/// the table lists those as installed — `SELECT … FROM pg_extension
12700/// WHERE extname = 'vector'` probes from PG clients (mailrs embed
12701/// round-12) answer truthfully about capability presence.
12702fn synth_pg_extension() -> (Vec<ColumnSchema>, Vec<Row>) {
12703    let schema = alloc::vec![
12704        ColumnSchema::new("oid", DataType::BigInt, false),
12705        ColumnSchema::new("extname", DataType::Text, false),
12706        ColumnSchema::new("extversion", DataType::Text, false),
12707        ColumnSchema::new("extnamespace", DataType::Text, false),
12708    ];
12709    let exts: &[(&str, &str)] = &[("plpgsql", "1.0"), ("vector", "0.8.0"), ("pg_trgm", "1.6")];
12710    let rows = exts
12711        .iter()
12712        .enumerate()
12713        .map(|(i, (name, ver))| {
12714            Row::new(alloc::vec![
12715                Value::BigInt(16384 + i as i64),
12716                Value::Text((*name).into()),
12717                Value::Text((*ver).into()),
12718                Value::Text("pg_catalog".into()),
12719            ])
12720        })
12721        .collect();
12722    (schema, rows)
12723}
12724
12725fn synth_pg_views(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12726    let schema = alloc::vec![
12727        ColumnSchema::new("schemaname", DataType::Text, false),
12728        ColumnSchema::new("viewname", DataType::Text, false),
12729        ColumnSchema::new("definition", DataType::Text, false),
12730    ];
12731    let mut rows: Vec<Row> = Vec::new();
12732    for (name, def) in cat.views() {
12733        rows.push(Row::new(alloc::vec![
12734            Value::Text("public".into()),
12735            Value::Text(name.clone()),
12736            Value::Text(def.body.clone()),
12737        ]));
12738    }
12739    (schema, rows)
12740}
12741
12742/// v7.17.0 Phase 3.P0-57 — synthesise `pg_catalog.pg_settings`. ORM
12743/// connection-checkers (sqlx pre-flight, Diesel migrator) and admin
12744/// tools read `pg_settings` to discover server-side configuration.
12745/// SPG surfaces every session_param + a small set of canonical PG
12746/// defaults so the pre-flight queries match.
12747fn synth_pg_settings(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
12748    let schema = alloc::vec![
12749        ColumnSchema::new("name", DataType::Text, false),
12750        ColumnSchema::new("setting", DataType::Text, false),
12751        ColumnSchema::new("category", DataType::Text, false),
12752    ];
12753    let mut rows: Vec<Row> = Vec::new();
12754    // Canonical defaults every admin tool expects to find.
12755    let defaults: &[(&str, &str, &str)] = &[
12756        ("server_version", "16.0 (spg)", "Preset Options"),
12757        ("server_encoding", "UTF8", "Client Connection Defaults"),
12758        ("client_encoding", "UTF8", "Client Connection Defaults"),
12759        ("DateStyle", "ISO, MDY", "Client Connection Defaults"),
12760        ("TimeZone", "UTC", "Client Connection Defaults"),
12761        ("standard_conforming_strings", "on", "Compatibility"),
12762        ("integer_datetimes", "on", "Compatibility"),
12763        ("max_connections", "100", "Connections and Authentication"),
12764    ];
12765    for &(name, val, cat) in defaults {
12766        rows.push(Row::new(alloc::vec![
12767            Value::Text(name.into()),
12768            Value::Text(val.into()),
12769            Value::Text(cat.into()),
12770        ]));
12771    }
12772    // Session-set params override the static defaults.
12773    for (k, v) in &engine.session_params {
12774        if !defaults
12775            .iter()
12776            .any(|(n, _, _)| (*n).eq_ignore_ascii_case(k))
12777        {
12778            rows.push(Row::new(alloc::vec![
12779                Value::Text(k.clone()),
12780                Value::Text(v.clone()),
12781                Value::Text("Session".into()),
12782            ]));
12783        }
12784    }
12785    (schema, rows)
12786}
12787
12788/// v7.17.0 Phase 3.P0-53 — synthesise `pg_catalog.pg_indexes`.
12789/// PG's pg_indexes is a real view on pg_index + pg_class + pg_attribute.
12790/// SPG ships it as a synthesised flat table so admin tools (pgAdmin,
12791/// DataGrip) can list indexes by tablename without joining four catalogs.
12792///
12793/// Schema columns exposed:
12794///   * schemaname (Text) — always `public`
12795///   * tablename (Text)
12796///   * indexname (Text)
12797///   * indexdef (Text) — best-effort CREATE INDEX DDL
12798fn synth_pg_indexes(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12799    let schema = alloc::vec![
12800        ColumnSchema::new("schemaname", DataType::Text, false),
12801        ColumnSchema::new("tablename", DataType::Text, false),
12802        ColumnSchema::new("indexname", DataType::Text, false),
12803        ColumnSchema::new("indexdef", DataType::Text, false),
12804    ];
12805    let mut rows: Vec<Row> = Vec::new();
12806    for tname in cat.table_names() {
12807        let Some(t) = cat.get(&tname) else { continue };
12808        for idx in t.indices() {
12809            let col_name = t
12810                .schema()
12811                .columns
12812                .get(idx.column_position)
12813                .map_or("?".into(), |c| c.name.clone());
12814            let unique_kw = if idx.is_unique { "UNIQUE " } else { "" };
12815            let indexdef = alloc::format!(
12816                "CREATE {unique_kw}INDEX {} ON public.{} ({})",
12817                idx.name,
12818                tname,
12819                col_name
12820            );
12821            rows.push(Row::new(alloc::vec![
12822                Value::Text("public".into()),
12823                Value::Text(tname.clone()),
12824                Value::Text(idx.name.clone()),
12825                Value::Text(indexdef),
12826            ]));
12827        }
12828    }
12829    (schema, rows)
12830}
12831
12832/// v7.17.0 Phase 3.P0-53 — synthesise `pg_catalog.pg_index`. The
12833/// "raw" pg_index catalog used by PG-internal tooling for index
12834/// flags and ordinal information. SPG ships the columns ORM probes
12835/// actually filter on.
12836///
12837/// Schema columns exposed:
12838///   * indexrelid (BigInt) — index OID (synthetic = position+1)
12839///   * indrelid (BigInt) — table OID (synthetic = position+1)
12840///   * indnatts (Int) — number of indexed columns
12841///   * indisunique (Bool)
12842///   * indisprimary (Bool)
12843fn synth_pg_index_raw(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12844    let schema = alloc::vec![
12845        ColumnSchema::new("indexrelid", DataType::BigInt, false),
12846        ColumnSchema::new("indrelid", DataType::BigInt, false),
12847        ColumnSchema::new("indnatts", DataType::Int, false),
12848        ColumnSchema::new("indisunique", DataType::Bool, false),
12849        ColumnSchema::new("indisprimary", DataType::Bool, false),
12850    ];
12851    let mut rows: Vec<Row> = Vec::new();
12852    let mut idx_oid: i64 = 100_000;
12853    for (table_idx, tname) in cat.table_names().iter().enumerate() {
12854        let Some(t) = cat.get(tname) else { continue };
12855        for idx in t.indices() {
12856            idx_oid += 1;
12857            #[allow(clippy::cast_possible_wrap)]
12858            let nattrs = (1 + idx.extra_column_positions.len()) as i32;
12859            // is_primary: SPG / PG flag the primary via the
12860            // index name convention `<table>_pkey`.
12861            let is_primary = idx.name.ends_with("_pkey");
12862            rows.push(Row::new(alloc::vec![
12863                Value::BigInt(idx_oid),
12864                Value::BigInt((table_idx + 1) as i64),
12865                Value::Int(nattrs),
12866                Value::Bool(idx.is_unique),
12867                Value::Bool(is_primary),
12868            ]));
12869        }
12870    }
12871    (schema, rows)
12872}
12873
12874/// v7.17.0 Phase 3.P0-52 — synthesise `pg_catalog.pg_namespace`.
12875/// SPG is single-schema so we expose the canonical PG schemas:
12876/// `public` (user-facing), `pg_catalog` (built-in), and
12877/// `information_schema` (PG meta).
12878fn synth_pg_namespace(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12879    let schema = alloc::vec![
12880        ColumnSchema::new("oid", DataType::BigInt, false),
12881        ColumnSchema::new("nspname", DataType::Text, false),
12882        ColumnSchema::new("nspowner", DataType::BigInt, false),
12883    ];
12884    let rows = alloc::vec![
12885        Row::new(alloc::vec![
12886            Value::BigInt(11),
12887            Value::Text("pg_catalog".into()),
12888            Value::BigInt(10),
12889        ]),
12890        Row::new(alloc::vec![
12891            Value::BigInt(2200),
12892            Value::Text("public".into()),
12893            Value::BigInt(10),
12894        ]),
12895        Row::new(alloc::vec![
12896            Value::BigInt(13000),
12897            Value::Text("information_schema".into()),
12898            Value::BigInt(10),
12899        ]),
12900    ];
12901    (schema, rows)
12902}
12903
12904/// v7.16.2 — drop the synthesised meta view into the enriched
12905/// catalog so the regular FROM-resolution path can see it.
12906fn materialise_meta_view(
12907    catalog: &mut Catalog,
12908    name: &str,
12909    columns: Vec<ColumnSchema>,
12910    rows: Vec<Row>,
12911) -> Result<(), EngineError> {
12912    let schema = TableSchema::new(name.to_string(), columns);
12913    catalog.create_table(schema).map_err(EngineError::Storage)?;
12914    let table = catalog
12915        .get_mut(name)
12916        .expect("just-created meta view must exist");
12917    for row in rows {
12918        table.insert(row).map_err(EngineError::Storage)?;
12919    }
12920    Ok(())
12921}
12922
12923/// v7.16.2 — true when the SELECT statement references any
12924/// `__spg_info_*` or `__spg_pg_*` synthetic table name (the
12925/// parser produces these for `information_schema.X` /
12926/// `pg_catalog.X`). Used by `exec_select_cancel` to short-
12927/// circuit into the meta-view materialisation path.
12928/// v7.17.0 Phase 1.2 — append the names of any catalog-known
12929/// views referenced by `tref` to `into`. Helper for
12930/// `Engine::expand_views_in_select`. A view that's been already
12931/// materialised as a table (e.g. via the synthetic CTE pass for
12932/// SELECT FROM v) is skipped — the table form wins so the
12933/// recursive exec_select_cancel call inside exec_with_ctes
12934/// doesn't re-expand and trigger the CTE-shadow guard.
12935fn collect_view_refs(
12936    tref: &spg_sql::ast::TableRef,
12937    cat: &spg_storage::Catalog,
12938    into: &mut Vec<String>,
12939) {
12940    if cat.views().contains_key(&tref.name)
12941        && cat.get(&tref.name).is_none()
12942        && !into.iter().any(|n| n == &tref.name)
12943    {
12944        into.push(tref.name.clone());
12945    }
12946}
12947
12948fn select_references_meta_view(stmt: &SelectStatement) -> bool {
12949    fn is_meta(name: &str) -> bool {
12950        name.starts_with("__spg_info_")
12951            || name.starts_with("__spg_pg_")
12952            || name.starts_with("__spg_mysql_")
12953    }
12954    if let Some(from) = &stmt.from {
12955        if is_meta(&from.primary.name) {
12956            return true;
12957        }
12958        for j in &from.joins {
12959            if is_meta(&j.table.name) {
12960                return true;
12961            }
12962        }
12963    }
12964    for cte in &stmt.ctes {
12965        if select_references_meta_view(&cte.body) {
12966            return true;
12967        }
12968    }
12969    false
12970}
12971
12972/// v7.16.2 — collect every meta-view name a SELECT touches.
12973/// Returns a deduplicated, sorted list. Caller materialises
12974/// each one into the enriched catalog before re-running the
12975/// SELECT. Walks JOINs, CTEs, and the primary FROM.
12976fn collect_meta_view_names(
12977    stmt: &SelectStatement,
12978    into: &mut alloc::collections::BTreeSet<String>,
12979) {
12980    fn is_meta(name: &str) -> bool {
12981        name.starts_with("__spg_info_")
12982            || name.starts_with("__spg_pg_")
12983            || name.starts_with("__spg_mysql_")
12984    }
12985    if let Some(from) = &stmt.from {
12986        if is_meta(&from.primary.name) {
12987            into.insert(from.primary.name.clone());
12988        }
12989        for j in &from.joins {
12990            if is_meta(&j.table.name) {
12991                into.insert(j.table.name.clone());
12992            }
12993        }
12994    }
12995    for cte in &stmt.ctes {
12996        collect_meta_view_names(&cte.body, into);
12997    }
12998}
12999
13000fn infer_column_types(columns: &[ColumnSchema], rows: &[Row]) -> Vec<ColumnSchema> {
13001    let mut out = columns.to_vec();
13002    for (col_idx, col) in out.iter_mut().enumerate() {
13003        if col.ty != DataType::Text {
13004            continue;
13005        }
13006        let mut inferred: Option<DataType> = None;
13007        let mut all_null = true;
13008        for row in rows {
13009            let Some(v) = row.values.get(col_idx) else {
13010                continue;
13011            };
13012            let ty = match v {
13013                Value::Null => continue,
13014                Value::SmallInt(_) => DataType::SmallInt,
13015                Value::Int(_) => DataType::Int,
13016                Value::BigInt(_) => DataType::BigInt,
13017                Value::Float(_) => DataType::Float,
13018                Value::Bool(_) => DataType::Bool,
13019                Value::Vector(_) => DataType::Vector {
13020                    dim: 0,
13021                    encoding: VecEncoding::F32,
13022                },
13023                _ => DataType::Text,
13024            };
13025            all_null = false;
13026            inferred = Some(match inferred {
13027                None => ty,
13028                Some(prev) if prev == ty => prev,
13029                Some(_) => DataType::Text,
13030            });
13031        }
13032        if let Some(t) = inferred {
13033            col.ty = t;
13034            col.nullable = true;
13035        } else if all_null {
13036            col.nullable = true;
13037        }
13038    }
13039    out
13040}
13041
13042/// v4.26: render a human-readable plan tree for `EXPLAIN <select>`.
13043/// Lines are pushed into `out`; `depth` controls indentation. We
13044/// describe the rewritten SELECT — what the executor *would* do —
13045/// using the engine handle to spot indexed lookups and table shapes.
13046#[allow(clippy::too_many_lines, clippy::format_push_string)]
13047/// v6.2.4 — Walk every line of the rendered plan tree and append
13048/// per-operator stats. Lines that name a known operator get
13049/// `(rows=N)` (`actual_rows` of the top-level operator equals the
13050/// final result row count; scans report their catalog row count
13051/// as the rows-considered metric). Other lines — Filter / Join /
13052/// GroupBy / OrderBy etc. — are marked `(—)` so the surface is
13053/// complete-by-construction; v6.2.5 fills these in via inline
13054/// executor counters.
13055/// v6.8.3 — surface "CREATE INDEX …" suggestions for every
13056/// `(table, column)` pair the query touches via WHERE / JOIN
13057/// that doesn't already have an index on the owning table.
13058/// Walks the SELECT's FROM clauses + WHERE expression tree;
13059/// returns one line per missing index. Deterministic order:
13060/// FROM-clause iteration order, then column-reference walk
13061/// order inside each WHERE. Each suggestion is a copy-pastable
13062/// DDL string.
13063fn build_index_suggestions(stmt: &SelectStatement, engine: &Engine) -> Vec<String> {
13064    use alloc::collections::BTreeSet;
13065    let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
13066    let mut out: Vec<String> = Vec::new();
13067    let cat = engine.active_catalog();
13068    // Build a (table, qualifier-or-alias) list from the FROM clause
13069    // so unqualified column refs in WHERE resolve to the correct
13070    // table.
13071    let Some(from) = &stmt.from else {
13072        return out;
13073    };
13074    let mut tables: Vec<String> = Vec::new();
13075    tables.push(from.primary.name.clone());
13076    for j in &from.joins {
13077        tables.push(j.table.name.clone());
13078    }
13079    // Collect column refs from the WHERE expression. JOIN ON
13080    // predicates also feed in.
13081    let mut col_refs: Vec<spg_sql::ast::ColumnName> = Vec::new();
13082    if let Some(w) = &stmt.where_ {
13083        collect_column_refs(w, &mut col_refs);
13084    }
13085    for j in &from.joins {
13086        if let Some(on) = &j.on {
13087            collect_column_refs(on, &mut col_refs);
13088        }
13089    }
13090    for cn in &col_refs {
13091        // Resolve owner table: explicit qualifier first, else
13092        // first table in FROM that has a column of this name.
13093        let owner: Option<String> = if let Some(q) = &cn.qualifier {
13094            tables.iter().find(|t| t == &q).cloned()
13095        } else {
13096            tables.iter().find_map(|t| {
13097                cat.get(t).and_then(|tbl| {
13098                    if tbl.schema().column_position(&cn.name).is_some() {
13099                        Some(t.clone())
13100                    } else {
13101                        None
13102                    }
13103                })
13104            })
13105        };
13106        let Some(owner) = owner else {
13107            continue;
13108        };
13109        let Some(tbl) = cat.get(&owner) else {
13110            continue;
13111        };
13112        let Some(col_pos) = tbl.schema().column_position(&cn.name) else {
13113            continue;
13114        };
13115        // Skip if any BTree index already covers this column as
13116        // its key.
13117        let already_indexed = tbl.indices().iter().any(|i| {
13118            matches!(i.kind, spg_storage::IndexKind::BTree(_))
13119                && i.column_position == col_pos
13120                && i.expression.is_none()
13121                && i.partial_predicate.is_none()
13122        });
13123        if already_indexed {
13124            continue;
13125        }
13126        if seen.insert((owner.clone(), cn.name.clone())) {
13127            out.push(alloc::format!(
13128                "SUGGEST: CREATE INDEX ix_{}_{} ON {} ({})",
13129                owner,
13130                cn.name,
13131                owner,
13132                cn.name
13133            ));
13134        }
13135    }
13136    out
13137}
13138
13139/// Walks an `Expr` and pushes every `ColumnName` it references.
13140/// Order is depth-first, left-to-right.
13141fn collect_column_refs(expr: &Expr, out: &mut Vec<spg_sql::ast::ColumnName>) {
13142    match expr {
13143        Expr::Column(cn) => out.push(cn.clone()),
13144        Expr::FunctionCall { args, .. } => {
13145            for a in args {
13146                collect_column_refs(a, out);
13147            }
13148        }
13149        Expr::Binary { lhs, rhs, .. } => {
13150            collect_column_refs(lhs, out);
13151            collect_column_refs(rhs, out);
13152        }
13153        Expr::Unary { expr: e, .. } => collect_column_refs(e, out),
13154        _ => {}
13155    }
13156}
13157
13158fn annotate_explain_lines(lines: &mut [String], total_rows: usize, engine: &Engine) {
13159    let catalog = engine.active_catalog();
13160    let cold_ids = catalog.cold_segment_ids_global();
13161    let any_cold = !cold_ids.is_empty();
13162    let cold_ids_repr = if any_cold {
13163        let mut s = alloc::string::String::from("[");
13164        for (i, id) in cold_ids.iter().enumerate() {
13165            if i > 0 {
13166                s.push(',');
13167            }
13168            s.push_str(&alloc::format!("{id}"));
13169        }
13170        s.push(']');
13171        s
13172    } else {
13173        alloc::string::String::new()
13174    };
13175    for (idx, line) in lines.iter_mut().enumerate() {
13176        let trimmed = line.trim_start();
13177        let is_top_level = idx == 0;
13178        if is_top_level {
13179            line.push_str(&alloc::format!(" (rows={total_rows})"));
13180            continue;
13181        }
13182        if let Some(rest) = trimmed.strip_prefix("From: ") {
13183            let (name, scan_kind) = match rest.split_once(" [") {
13184                Some((n, k)) => (n.trim(), k.trim_end_matches(']')),
13185                None => (rest.trim(), ""),
13186            };
13187            let bare = name.split_whitespace().next().unwrap_or(name);
13188            let hot = catalog.get(bare).map(|t| t.rows().len());
13189            // v6.2.7 — `cold_segments=[id0,id1,…]` enumerates every
13190            // cold-tier segment the scan COULD have walked. v6.2.x
13191            // can tighten to per-table by walking the table's
13192            // BTree-index cold locators.
13193            let annot = match (hot, scan_kind) {
13194                (Some(h), "full scan") => {
13195                    let mut s = alloc::format!(" (hot_rows={h}");
13196                    if any_cold {
13197                        s.push_str(&alloc::format!(
13198                            ", cold_tier=present, cold_segments={cold_ids_repr}"
13199                        ));
13200                    }
13201                    s.push(')');
13202                    s
13203                }
13204                (Some(h), "index seek") => {
13205                    let mut s = alloc::format!(" (hot_rows≤{h}");
13206                    if any_cold {
13207                        s.push_str(&alloc::format!(
13208                            ", cold_tier=present, cold_segments={cold_ids_repr}"
13209                        ));
13210                    }
13211                    s.push(')');
13212                    s
13213                }
13214                _ => " (rows=—)".to_string(),
13215            };
13216            line.push_str(&annot);
13217            continue;
13218        }
13219        // Filter / GroupBy / Having / OrderBy / Limit / Join etc.
13220        line.push_str(" (rows=—)");
13221    }
13222}
13223
13224fn explain_select(stmt: &SelectStatement, engine: &Engine, depth: usize, out: &mut Vec<String>) {
13225    let pad = "  ".repeat(depth);
13226    // 1) Top-level operator label.
13227    let top = if !stmt.ctes.is_empty() {
13228        if stmt.ctes.iter().any(|c| c.recursive) {
13229            "CTEScan (WITH RECURSIVE)"
13230        } else {
13231            "CTEScan (WITH)"
13232        }
13233    } else if !stmt.unions.is_empty() {
13234        "UnionScan"
13235    } else if select_has_window(stmt) {
13236        "WindowAgg"
13237    } else if aggregate::uses_aggregate(stmt) {
13238        "Aggregate"
13239    } else if stmt.distinct {
13240        "Distinct"
13241    } else if stmt.from.is_some() {
13242        "TableScan"
13243    } else {
13244        "Result"
13245    };
13246    out.push(alloc::format!("{pad}{top}"));
13247    let child = "  ".repeat(depth + 1);
13248    // 2) CTE bodies.
13249    for cte in &stmt.ctes {
13250        let head = if cte.recursive {
13251            alloc::format!("{child}CTE (recursive): {}", cte.name)
13252        } else {
13253            alloc::format!("{child}CTE: {}", cte.name)
13254        };
13255        out.push(head);
13256        explain_select(&cte.body, engine, depth + 2, out);
13257    }
13258    // 3) FROM details — primary table + joins, index hits.
13259    if let Some(from) = &stmt.from {
13260        let mut tag = alloc::format!("{child}From: {}", from.primary.name);
13261        if let Some(alias) = &from.primary.alias {
13262            tag.push_str(&alloc::format!(" AS {alias}"));
13263        }
13264        // Try to detect an index-seek opportunity on WHERE against
13265        // the primary table — same heuristic the executor uses.
13266        if let Some(w) = &stmt.where_
13267            && let Some(table) = engine.active_catalog().get(&from.primary.name)
13268        {
13269            let alias = from.primary.alias.as_deref().unwrap_or(&from.primary.name);
13270            let cols = &table.schema().columns;
13271            if try_index_seek(w, cols, engine.active_catalog(), table, alias).is_some() {
13272                tag.push_str(" [index seek]");
13273            } else {
13274                tag.push_str(" [full scan]");
13275            }
13276        } else {
13277            tag.push_str(" [full scan]");
13278        }
13279        out.push(tag);
13280        for j in &from.joins {
13281            let kind = match j.kind {
13282                spg_sql::ast::JoinKind::Inner => "INNER JOIN",
13283                spg_sql::ast::JoinKind::Left => "LEFT JOIN",
13284                spg_sql::ast::JoinKind::Cross => "CROSS JOIN",
13285            };
13286            let mut s = alloc::format!("{child}{kind}: {}", j.table.name);
13287            if let Some(alias) = &j.table.alias {
13288                s.push_str(&alloc::format!(" AS {alias}"));
13289            }
13290            if j.on.is_some() {
13291                s.push_str(" (ON …)");
13292            }
13293            out.push(s);
13294        }
13295    }
13296    // 4) WHERE / GROUP BY / HAVING / ORDER BY / LIMIT / OFFSET.
13297    if let Some(w) = &stmt.where_ {
13298        let mut s = alloc::format!("{child}Filter: {w}");
13299        if expr_has_subquery(w) {
13300            s.push_str(" [subquery]");
13301        }
13302        out.push(s);
13303    }
13304    if let Some(gs) = &stmt.group_by {
13305        let mut parts = Vec::new();
13306        for g in gs {
13307            parts.push(alloc::format!("{g}"));
13308        }
13309        out.push(alloc::format!("{child}GroupBy: {}", parts.join(", ")));
13310    }
13311    if let Some(h) = &stmt.having {
13312        out.push(alloc::format!("{child}Having: {h}"));
13313    }
13314    for o in &stmt.order_by {
13315        let dir = if o.desc { "DESC" } else { "ASC" };
13316        out.push(alloc::format!("{child}OrderBy: {} {dir}", o.expr));
13317    }
13318    if let Some(lim) = stmt.limit {
13319        out.push(alloc::format!("{child}Limit: {lim}"));
13320    }
13321    if let Some(off) = stmt.offset {
13322        out.push(alloc::format!("{child}Offset: {off}"));
13323    }
13324    // 5) Projection — collapse Wildcard or render N items.
13325    if stmt
13326        .items
13327        .iter()
13328        .any(|it| matches!(it, SelectItem::Wildcard))
13329    {
13330        out.push(alloc::format!("{child}Project: *"));
13331    } else {
13332        out.push(alloc::format!(
13333            "{child}Project: {} item(s)",
13334            stmt.items.len()
13335        ));
13336    }
13337    // 6) Recurse into UNION peers.
13338    for (kind, peer) in &stmt.unions {
13339        let label = match kind {
13340            UnionKind::All => "UNION ALL",
13341            UnionKind::Distinct => "UNION",
13342        };
13343        out.push(alloc::format!("{child}{label}"));
13344        explain_select(peer, engine, depth + 2, out);
13345    }
13346}
13347
13348/// v4.23: recognise the engine errors that indicate the inner
13349/// SELECT couldn't be evaluated in isolation because it references
13350/// an outer column — used by `subquery_replacement` to skip
13351/// materialisation and let row-eval handle it instead.
13352fn is_correlation_error(e: &EngineError) -> bool {
13353    matches!(
13354        e,
13355        EngineError::Eval(
13356            eval::EvalError::ColumnNotFound { .. } | eval::EvalError::UnknownQualifier { .. }
13357        )
13358    )
13359}
13360
13361/// v4.23: walk every Expr in `stmt` and replace each Column ref
13362/// that targets the outer scope (qualifier matches the outer
13363/// table alias) with a Literal carrying the outer row's value.
13364/// Conservative: only qualified refs are substituted, so the user
13365/// must write `outer_alias.col` to reference an outer column. This
13366/// matches PG's lexical scoping for correlated subqueries and
13367/// avoids accidentally rebinding inner columns of the same name.
13368/// v7.17.0 Phase 3.P0-41 — LATERAL peer descriptor. Either eagerly
13369/// materialised (every regular table / unnest / generate_series) or
13370/// lateral (subquery re-evaluated per outer row).
13371struct JoinedPeer<'a> {
13372    eager_rows: Option<Vec<Row>>,
13373    cols: Vec<ColumnSchema>,
13374    alias: String,
13375    kind: JoinKind,
13376    on: Option<&'a Expr>,
13377    lateral: Option<&'a SelectStatement>,
13378    /// v7.28 (round-22) — plain-table name for the index-nested-loop
13379    /// path. None for unnest/lateral.
13380    join_table: Option<String>,
13381}
13382
13383/// v7.17.0 Phase 3.P0-41 — synthesise a column name for a LATERAL
13384/// projection item that has no explicit alias. PG names anonymous
13385/// projection items by the function call's name or by `column<i>`.
13386/// SPG mirrors the latter (lower-overhead than walking arbitrary
13387/// Expr shapes) so the probe-schema fallback path produces stable
13388/// names for the lateral peer's columns.
13389fn synth_lateral_col_name(expr: &Expr, idx: usize) -> String {
13390    match expr {
13391        // Bare column reference — use the column's own name.
13392        Expr::Column(c) => c.name.clone(),
13393        // Function call — use the function name (PG canonical:
13394        // `count` / `max` / `lower` …).
13395        Expr::FunctionCall { name, .. } => name.clone(),
13396        // Cast — drill into the inner expression.
13397        Expr::Cast { expr: inner, .. } => synth_lateral_col_name(inner, idx),
13398        // Everything else falls back to PG's `column<N>` placeholder.
13399        _ => alloc::format!("column{}", idx + 1),
13400    }
13401}
13402
13403/// v7.17.0 Phase 3.P0-41 — substitute every `<alias>.<col>` Expr
13404/// reference whose `<alias>.<col>` exists in the outer composite
13405/// schema with the matching value from the outer row. Walks the
13406/// entire SELECT body (items, WHERE, GROUP BY, HAVING, ORDER BY,
13407/// UNION peers) so any depth of outer reference inside the
13408/// LATERAL subquery resolves before execution.
13409fn substitute_outer_columns_multi(
13410    stmt: &mut SelectStatement,
13411    outer_row: &Row,
13412    outer_schema: &[ColumnSchema],
13413) {
13414    substitute_outer_in_select(stmt, outer_row, outer_schema);
13415}
13416
13417fn substitute_outer_in_select(
13418    stmt: &mut SelectStatement,
13419    outer_row: &Row,
13420    outer_schema: &[ColumnSchema],
13421) {
13422    for item in &mut stmt.items {
13423        if let SelectItem::Expr { expr, .. } = item {
13424            substitute_outer_in_expr(expr, outer_row, outer_schema);
13425        }
13426    }
13427    if let Some(w) = &mut stmt.where_ {
13428        substitute_outer_in_expr(w, outer_row, outer_schema);
13429    }
13430    if let Some(gs) = &mut stmt.group_by {
13431        for g in gs {
13432            substitute_outer_in_expr(g, outer_row, outer_schema);
13433        }
13434    }
13435    if let Some(h) = &mut stmt.having {
13436        substitute_outer_in_expr(h, outer_row, outer_schema);
13437    }
13438    for o in &mut stmt.order_by {
13439        substitute_outer_in_expr(&mut o.expr, outer_row, outer_schema);
13440    }
13441    for (_, peer) in &mut stmt.unions {
13442        substitute_outer_in_select(peer, outer_row, outer_schema);
13443    }
13444}
13445
13446fn substitute_outer_in_expr(e: &mut Expr, outer_row: &Row, outer_schema: &[ColumnSchema]) {
13447    if let Expr::Column(c) = e
13448        && let Some(qual) = &c.qualifier
13449    {
13450        let composite = alloc::format!("{qual}.{}", c.name);
13451        if let Some(idx) = outer_schema
13452            .iter()
13453            .position(|sc| sc.name.eq_ignore_ascii_case(&composite))
13454        {
13455            let v = outer_row.values.get(idx).cloned().unwrap_or(Value::Null);
13456            if let Ok(lit) = value_to_literal_expr(v) {
13457                *e = lit;
13458                return;
13459            }
13460        }
13461    }
13462    match e {
13463        Expr::Binary { lhs, rhs, .. } => {
13464            substitute_outer_in_expr(lhs, outer_row, outer_schema);
13465            substitute_outer_in_expr(rhs, outer_row, outer_schema);
13466        }
13467        Expr::Unary { expr: inner, .. } => {
13468            substitute_outer_in_expr(inner, outer_row, outer_schema);
13469        }
13470        Expr::FunctionCall { args, .. } => {
13471            for a in args {
13472                substitute_outer_in_expr(a, outer_row, outer_schema);
13473            }
13474        }
13475        Expr::Cast { expr: inner, .. } => {
13476            substitute_outer_in_expr(inner, outer_row, outer_schema);
13477        }
13478        Expr::Case {
13479            operand,
13480            branches,
13481            else_branch,
13482        } => {
13483            if let Some(op) = operand {
13484                substitute_outer_in_expr(op, outer_row, outer_schema);
13485            }
13486            for (cond, val) in branches {
13487                substitute_outer_in_expr(cond, outer_row, outer_schema);
13488                substitute_outer_in_expr(val, outer_row, outer_schema);
13489            }
13490            if let Some(e) = else_branch {
13491                substitute_outer_in_expr(e, outer_row, outer_schema);
13492            }
13493        }
13494        _ => {}
13495    }
13496}
13497
13498impl Engine {
13499    /// v7.29 (round-22 phase 3) — try to batch-evaluate a correlated
13500    /// scalar subquery of the shape
13501    ///   (SELECT expr FROM … WHERE inner_preds AND inner_col = outer_col
13502    ///    [ORDER BY o [DESC]] [LIMIT 1])
13503    /// by running the subquery ONCE without the correlation and
13504    /// folding rows into a key→value map (group top-1 when ordered).
13505    /// Returns None when the shape doesn't qualify; correctness then
13506    /// falls back to per-row execution.
13507    fn try_batch_correlated_scalar(
13508        &self,
13509        inner: &SelectStatement,
13510        cancel: CancelToken<'_>,
13511    ) -> Result<Option<memoize::GroupMap>, EngineError> {
13512        use spg_sql::ast::{BinOp, SelectItem as SI};
13513        if !inner.ctes.is_empty()
13514            || !inner.unions.is_empty()
13515            || inner.group_by.is_some()
13516            || inner.having.is_some()
13517            || inner.distinct
13518            || inner.items.len() != 1
13519            || inner.order_by.len() > 1
13520            || inner.offset.is_some()
13521        {
13522            return Ok(None);
13523        }
13524        // LIMIT must be absent or literally 1 (top-1 semantics).
13525        if let Some(le) = inner.limit
13526            && le.as_literal() != Some(1)
13527        {
13528            return Ok(None);
13529        }
13530        let Some(from) = &inner.from else {
13531            return Ok(None);
13532        };
13533        if from.primary.lateral_subquery.is_some() || from.primary.unnest_expr.is_some() {
13534            return Ok(None);
13535        }
13536        // Inner alias set.
13537        let mut inner_aliases: Vec<String> = Vec::new();
13538        inner_aliases.push(
13539            from.primary
13540                .alias
13541                .clone()
13542                .unwrap_or_else(|| from.primary.name.clone()),
13543        );
13544        for j in &from.joins {
13545            if j.table.lateral_subquery.is_some() || j.table.unnest_expr.is_some() {
13546                return Ok(None);
13547            }
13548            inner_aliases.push(
13549                j.table
13550                    .alias
13551                    .clone()
13552                    .unwrap_or_else(|| j.table.name.clone()),
13553            );
13554        }
13555        let is_inner = |c: &spg_sql::ast::ColumnName| -> bool {
13556            match &c.qualifier {
13557                Some(q) => inner_aliases.iter().any(|a| a.eq_ignore_ascii_case(q)),
13558                None => false,
13559            }
13560        };
13561        let is_outer = |c: &spg_sql::ast::ColumnName| -> bool {
13562            match &c.qualifier {
13563                Some(q) => !inner_aliases.iter().any(|a| a.eq_ignore_ascii_case(q)),
13564                // Synthetic group columns arrive bare after the
13565                // aggregate rewrite.
13566                None => c.name.starts_with("__grp_") || c.name.starts_with("__agg_"),
13567            }
13568        };
13569        // Every expression OTHER than the correlation conjunct must be
13570        // fully inner (qualified to inner aliases).
13571        let all_inner = |e: &Expr| -> bool {
13572            let mut cols: Vec<spg_sql::ast::ColumnName> = Vec::new();
13573            let mut subs: Vec<&SelectStatement> = Vec::new();
13574            visit_expr_columns_and_subqueries(e, &mut |c| cols.push(c.clone()), &mut |sub| {
13575                subs.push(sub)
13576            });
13577            subs.is_empty() && cols.iter().all(|c| is_inner(c) && !c.name.is_empty())
13578        };
13579        let Some(w) = &inner.where_ else {
13580            return Ok(None);
13581        };
13582        let conjuncts = reorder::split_and_conjunctions(w);
13583        let mut corr: Option<(spg_sql::ast::ColumnName, spg_sql::ast::ColumnName)> = None; // (inner, outer)
13584        let mut rest: Vec<&Expr> = Vec::new();
13585        for c in conjuncts {
13586            if let Expr::Binary {
13587                lhs,
13588                op: BinOp::Eq,
13589                rhs,
13590            } = c
13591                && let (Expr::Column(a), Expr::Column(b)) = (lhs.as_ref(), rhs.as_ref())
13592            {
13593                let pair = if is_inner(a) && is_outer(b) {
13594                    Some((a.clone(), b.clone()))
13595                } else if is_inner(b) && is_outer(a) {
13596                    Some((b.clone(), a.clone()))
13597                } else {
13598                    None
13599                };
13600                if let Some(p) = pair {
13601                    if corr.is_some() {
13602                        return Ok(None); // more than one correlation
13603                    }
13604                    corr = Some(p);
13605                    continue;
13606                }
13607            }
13608            if !all_inner(c) {
13609                return Ok(None);
13610            }
13611            rest.push(c);
13612        }
13613        let Some((inner_col, outer_col)) = corr else {
13614            return Ok(None);
13615        };
13616        let SI::Expr { expr: out_expr, .. } = &inner.items[0] else {
13617            return Ok(None);
13618        };
13619        if !all_inner(out_expr) {
13620            return Ok(None);
13621        }
13622        let order = inner.order_by.first();
13623        if let Some(o) = order
13624            && !all_inner(&o.expr)
13625        {
13626            return Ok(None);
13627        }
13628        // Build the batch statement: SELECT inner_col, [order], expr
13629        // FROM … WHERE rest — no correlation, no order, no limit.
13630        let mut batch = inner.clone();
13631        batch.limit = None;
13632        batch.offset = None;
13633        batch.order_by = Vec::new();
13634        batch.where_ = rest
13635            .iter()
13636            .map(|e| (*e).clone())
13637            .reduce(|a, b| Expr::Binary {
13638                lhs: alloc::boxed::Box::new(a),
13639                op: BinOp::And,
13640                rhs: alloc::boxed::Box::new(b),
13641            });
13642        let mut items: Vec<SI> = alloc::vec![SI::Expr {
13643            expr: Expr::Column(inner_col),
13644            alias: None,
13645        }];
13646        if let Some(o) = order {
13647            items.push(SI::Expr {
13648                expr: o.expr.clone(),
13649                alias: None,
13650            });
13651        }
13652        items.push(SI::Expr {
13653            expr: out_expr.clone(),
13654            alias: None,
13655        });
13656        batch.items = items;
13657        let r = self.exec_select_cancel(&batch, cancel)?;
13658        let QueryResult::Rows { rows, .. } = r else {
13659            return Ok(None);
13660        };
13661        let has_order = order.is_some();
13662        let (desc, nf) = order
13663            .map(|o| (o.desc, o.nulls_first))
13664            .unwrap_or((false, None));
13665        let mut best: alloc::collections::BTreeMap<String, (Option<Value>, Value)> =
13666            alloc::collections::BTreeMap::new();
13667        for row in rows {
13668            let key_v = row.values.first().cloned().unwrap_or(Value::Null);
13669            if matches!(key_v, Value::Null) {
13670                continue;
13671            }
13672            let key = aggregate::encode_key(core::slice::from_ref(&key_v));
13673            let (ord_v, out_v) = if has_order {
13674                (
13675                    Some(row.values.get(1).cloned().unwrap_or(Value::Null)),
13676                    row.values.get(2).cloned().unwrap_or(Value::Null),
13677                )
13678            } else {
13679                (None, row.values.get(1).cloned().unwrap_or(Value::Null))
13680            };
13681            match best.get(&key) {
13682                None => {
13683                    best.insert(key, (ord_v, out_v));
13684                }
13685                Some((cur_ord, _)) if has_order => {
13686                    // The sorted-first row wins: candidate beats the
13687                    // incumbent when it compares LESS under the key's
13688                    // ordering.
13689                    let cand = ord_v.clone().unwrap_or(Value::Null);
13690                    let cur = cur_ord.clone().unwrap_or(Value::Null);
13691                    if order_by_value_cmp(desc, nf, &cand, &cur) == core::cmp::Ordering::Less {
13692                        best.insert(key, (ord_v, out_v));
13693                    }
13694                }
13695                Some(_) => {} // unordered: first row stands (any row is valid)
13696            }
13697        }
13698        let map = best.into_iter().map(|(k, (_, v))| (k, v)).collect();
13699        Ok(Some((outer_col, map)))
13700    }
13701}
13702
13703/// v7.29 (3c) — pre-order collection of SCALAR subquery nodes in a
13704/// host expression (no descent into subquery bodies). The splice
13705/// walk below uses the same order; the pair must stay in lockstep.
13706fn collect_scalar_subqueries<'a>(e: &'a Expr, out: &mut Vec<&'a SelectStatement>) {
13707    match e {
13708        Expr::ScalarSubquery(s) => out.push(s),
13709        Expr::Exists { .. } | Expr::InSubquery { .. } => {}
13710        Expr::Binary { lhs, rhs, .. } => {
13711            collect_scalar_subqueries(lhs, out);
13712            collect_scalar_subqueries(rhs, out);
13713        }
13714        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13715            collect_scalar_subqueries(expr, out);
13716        }
13717        Expr::Like { expr, pattern, .. } => {
13718            collect_scalar_subqueries(expr, out);
13719            collect_scalar_subqueries(pattern, out);
13720        }
13721        Expr::FunctionCall { args, .. } => {
13722            for a in args {
13723                collect_scalar_subqueries(a, out);
13724            }
13725        }
13726        Expr::AggregateOrdered { call, order_by, .. } => {
13727            collect_scalar_subqueries(call, out);
13728            for o in order_by {
13729                collect_scalar_subqueries(&o.expr, out);
13730            }
13731        }
13732        Expr::Case {
13733            operand,
13734            branches,
13735            else_branch,
13736        } => {
13737            if let Some(op) = operand {
13738                collect_scalar_subqueries(op, out);
13739            }
13740            for (w, t) in branches {
13741                collect_scalar_subqueries(w, out);
13742                collect_scalar_subqueries(t, out);
13743            }
13744            if let Some(eb) = else_branch {
13745                collect_scalar_subqueries(eb, out);
13746            }
13747        }
13748        Expr::ArraySubscript { target, index } => {
13749            collect_scalar_subqueries(target, out);
13750            collect_scalar_subqueries(index, out);
13751        }
13752        _ => {}
13753    }
13754}
13755
13756/// v7.29 (3d) — empty every scalar-subquery BODY in a host
13757/// expression (node kept so the splice pre-order still matches).
13758fn hollow_scalar_subqueries(e: &mut Expr) {
13759    match e {
13760        Expr::ScalarSubquery(s) => {
13761            let hollow = SelectStatement {
13762                items: Vec::new(),
13763                ..SelectStatement::default()
13764            };
13765            **s = hollow;
13766        }
13767        Expr::Exists { .. } | Expr::InSubquery { .. } => {}
13768        Expr::Binary { lhs, rhs, .. } => {
13769            hollow_scalar_subqueries(lhs);
13770            hollow_scalar_subqueries(rhs);
13771        }
13772        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13773            hollow_scalar_subqueries(expr);
13774        }
13775        Expr::Like { expr, pattern, .. } => {
13776            hollow_scalar_subqueries(expr);
13777            hollow_scalar_subqueries(pattern);
13778        }
13779        Expr::FunctionCall { args, .. } => {
13780            for a in args.iter_mut() {
13781                hollow_scalar_subqueries(a);
13782            }
13783        }
13784        Expr::AggregateOrdered { call, order_by, .. } => {
13785            hollow_scalar_subqueries(call);
13786            for o in order_by.iter_mut() {
13787                hollow_scalar_subqueries(&mut o.expr);
13788            }
13789        }
13790        Expr::Case {
13791            operand,
13792            branches,
13793            else_branch,
13794        } => {
13795            if let Some(op) = operand {
13796                hollow_scalar_subqueries(op);
13797            }
13798            for (w, t) in branches.iter_mut() {
13799                hollow_scalar_subqueries(w);
13800                hollow_scalar_subqueries(t);
13801            }
13802            if let Some(eb) = else_branch {
13803                hollow_scalar_subqueries(eb);
13804            }
13805        }
13806        Expr::ArraySubscript { target, index } => {
13807            hollow_scalar_subqueries(target);
13808            hollow_scalar_subqueries(index);
13809        }
13810        _ => {}
13811    }
13812}
13813
13814/// v7.29 (3c) — splice the i-th scalar subquery's batched value into
13815/// the cloned tree (same pre-order as collect_scalar_subqueries).
13816/// Returns Ok(false) if a literal conversion fails (caller falls
13817/// back to the resolver path).
13818fn splice_planned_subqueries(
13819    e: &mut Expr,
13820    plan: &[Option<alloc::rc::Rc<memoize::GroupMap>>],
13821    idx: &mut usize,
13822    row: &Row,
13823    ctx: &EvalContext<'_>,
13824) -> Result<bool, EngineError> {
13825    match e {
13826        Expr::ScalarSubquery(_) => {
13827            let Some(Some(gm)) = plan.get(*idx) else {
13828                return Ok(false);
13829            };
13830            *idx += 1;
13831            let (outer_col, map) = gm.as_ref();
13832            let key_v = eval::eval_expr(&Expr::Column(outer_col.clone()), row, ctx)
13833                .map_err(EngineError::Eval)?;
13834            let v = if matches!(key_v, Value::Null) {
13835                Value::Null
13836            } else {
13837                map.get(&aggregate::encode_key(core::slice::from_ref(&key_v)))
13838                    .cloned()
13839                    .unwrap_or(Value::Null)
13840            };
13841            *e = value_to_literal_expr(v)?;
13842            Ok(true)
13843        }
13844        Expr::Exists { .. } | Expr::InSubquery { .. } => Ok(true),
13845        Expr::Binary { lhs, rhs, .. } => Ok(splice_planned_subqueries(lhs, plan, idx, row, ctx)?
13846            && splice_planned_subqueries(rhs, plan, idx, row, ctx)?),
13847        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13848            splice_planned_subqueries(expr, plan, idx, row, ctx)
13849        }
13850        Expr::Like { expr, pattern, .. } => {
13851            Ok(splice_planned_subqueries(expr, plan, idx, row, ctx)?
13852                && splice_planned_subqueries(pattern, plan, idx, row, ctx)?)
13853        }
13854        Expr::FunctionCall { args, .. } => {
13855            for a in args.iter_mut() {
13856                if !splice_planned_subqueries(a, plan, idx, row, ctx)? {
13857                    return Ok(false);
13858                }
13859            }
13860            Ok(true)
13861        }
13862        Expr::AggregateOrdered { call, order_by, .. } => {
13863            if !splice_planned_subqueries(call, plan, idx, row, ctx)? {
13864                return Ok(false);
13865            }
13866            for o in order_by.iter_mut() {
13867                if !splice_planned_subqueries(&mut o.expr, plan, idx, row, ctx)? {
13868                    return Ok(false);
13869                }
13870            }
13871            Ok(true)
13872        }
13873        Expr::Case {
13874            operand,
13875            branches,
13876            else_branch,
13877        } => {
13878            if let Some(op) = operand {
13879                if !splice_planned_subqueries(op, plan, idx, row, ctx)? {
13880                    return Ok(false);
13881                }
13882            }
13883            for (w, t) in branches.iter_mut() {
13884                if !splice_planned_subqueries(w, plan, idx, row, ctx)?
13885                    || !splice_planned_subqueries(t, plan, idx, row, ctx)?
13886                {
13887                    return Ok(false);
13888                }
13889            }
13890            if let Some(eb) = else_branch {
13891                if !splice_planned_subqueries(eb, plan, idx, row, ctx)? {
13892                    return Ok(false);
13893                }
13894            }
13895            Ok(true)
13896        }
13897        Expr::ArraySubscript { target, index } => {
13898            Ok(splice_planned_subqueries(target, plan, idx, row, ctx)?
13899                && splice_planned_subqueries(index, plan, idx, row, ctx)?)
13900        }
13901        _ => Ok(true),
13902    }
13903}
13904
13905fn substitute_outer_columns(stmt: &mut SelectStatement, row: &Row, ctx: &EvalContext<'_>) {
13906    // v7.24 (round-16 B) — joined outer contexts carry no single
13907    // table alias; their schemas use composite "alias.column" names
13908    // instead. Pass an unmatchable alias and let the composite
13909    // lookup in substitute_in_expr do the work (a correlated EXISTS
13910    // under a JOIN previously skipped substitution entirely and
13911    // died with "unknown table qualifier").
13912    let outer_alias = ctx.table_alias.unwrap_or("");
13913    substitute_in_select(stmt, row, ctx, outer_alias);
13914}
13915
13916fn substitute_in_select(
13917    stmt: &mut SelectStatement,
13918    row: &Row,
13919    ctx: &EvalContext<'_>,
13920    outer_alias: &str,
13921) {
13922    for item in &mut stmt.items {
13923        if let SelectItem::Expr { expr, .. } = item {
13924            substitute_in_expr(expr, row, ctx, outer_alias);
13925        }
13926    }
13927    if let Some(w) = &mut stmt.where_ {
13928        substitute_in_expr(w, row, ctx, outer_alias);
13929    }
13930    if let Some(gs) = &mut stmt.group_by {
13931        for g in gs {
13932            substitute_in_expr(g, row, ctx, outer_alias);
13933        }
13934    }
13935    if let Some(h) = &mut stmt.having {
13936        substitute_in_expr(h, row, ctx, outer_alias);
13937    }
13938    for o in &mut stmt.order_by {
13939        substitute_in_expr(&mut o.expr, row, ctx, outer_alias);
13940    }
13941    for (_, peer) in &mut stmt.unions {
13942        substitute_in_select(peer, row, ctx, outer_alias);
13943    }
13944}
13945
13946fn substitute_in_expr(e: &mut Expr, row: &Row, ctx: &EvalContext<'_>, outer_alias: &str) {
13947    // v7.25.2 (round-19 A) — bare synthetic columns. The aggregate
13948    // rewriter replaces group-key references INSIDE subquery bodies
13949    // with `__grp_N` so a correlated subquery in a GROUP BY select
13950    // list can resolve against the synthesised group row. The names
13951    // are engine-generated, so they can't shadow user columns.
13952    if let Expr::Column(c) = e
13953        && c.qualifier.is_none()
13954        && (c.name.starts_with("__grp_") || c.name.starts_with("__agg_"))
13955        && let Some(idx) = ctx.columns.iter().position(|sc| sc.name == c.name)
13956    {
13957        let v = row.values.get(idx).cloned().unwrap_or(Value::Null);
13958        if let Ok(lit) = value_to_literal_expr(v) {
13959            *e = lit;
13960            return;
13961        }
13962    }
13963    if let Expr::Column(c) = e
13964        && let Some(qual) = &c.qualifier
13965    {
13966        // Look up the column's index in the outer schema: plain name
13967        // when the qualifier is the outer table's alias, composite
13968        // "alias.column" for joined outer schemas (v7.24).
13969        let idx = if !outer_alias.is_empty() && qual.eq_ignore_ascii_case(outer_alias) {
13970            ctx.columns
13971                .iter()
13972                .position(|sc| sc.name.eq_ignore_ascii_case(&c.name))
13973        } else {
13974            None
13975        }
13976        .or_else(|| {
13977            let composite = alloc::format!("{qual}.{name}", name = c.name);
13978            ctx.columns
13979                .iter()
13980                .position(|sc| sc.name.eq_ignore_ascii_case(&composite))
13981        });
13982        if let Some(idx) = idx {
13983            let v = row.values.get(idx).cloned().unwrap_or(Value::Null);
13984            if let Ok(lit) = value_to_literal_expr(v) {
13985                *e = lit;
13986                return;
13987            }
13988        }
13989    }
13990    match e {
13991        Expr::AggregateOrdered { call, order_by, .. } => {
13992            substitute_in_expr(call, row, ctx, outer_alias);
13993            for o in order_by.iter_mut() {
13994                substitute_in_expr(&mut o.expr, row, ctx, outer_alias);
13995            }
13996        }
13997        Expr::Binary { lhs, rhs, .. } => {
13998            substitute_in_expr(lhs, row, ctx, outer_alias);
13999            substitute_in_expr(rhs, row, ctx, outer_alias);
14000        }
14001        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
14002            substitute_in_expr(expr, row, ctx, outer_alias);
14003        }
14004        Expr::Like { expr, pattern, .. } => {
14005            substitute_in_expr(expr, row, ctx, outer_alias);
14006            substitute_in_expr(pattern, row, ctx, outer_alias);
14007        }
14008        Expr::FunctionCall { args, .. } => {
14009            for a in args {
14010                substitute_in_expr(a, row, ctx, outer_alias);
14011            }
14012        }
14013        Expr::Extract { source, .. } => substitute_in_expr(source, row, ctx, outer_alias),
14014        Expr::WindowFunction {
14015            args,
14016            partition_by,
14017            order_by,
14018            ..
14019        } => {
14020            for a in args {
14021                substitute_in_expr(a, row, ctx, outer_alias);
14022            }
14023            for p in partition_by {
14024                substitute_in_expr(p, row, ctx, outer_alias);
14025            }
14026            for (o, _, _) in order_by {
14027                substitute_in_expr(o, row, ctx, outer_alias);
14028            }
14029        }
14030        Expr::ScalarSubquery(s) => substitute_in_select(s, row, ctx, outer_alias),
14031        Expr::Exists { subquery, .. } | Expr::InSubquery { subquery, .. } => {
14032            substitute_in_select(subquery, row, ctx, outer_alias);
14033        }
14034        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => {}
14035        Expr::Array(items) => {
14036            for elem in items {
14037                substitute_in_expr(elem, row, ctx, outer_alias);
14038            }
14039        }
14040        Expr::ArraySubscript { target, index } => {
14041            substitute_in_expr(target, row, ctx, outer_alias);
14042            substitute_in_expr(index, row, ctx, outer_alias);
14043        }
14044        Expr::AnyAll { expr, array, .. } => {
14045            substitute_in_expr(expr, row, ctx, outer_alias);
14046            substitute_in_expr(array, row, ctx, outer_alias);
14047        }
14048        Expr::Case {
14049            operand,
14050            branches,
14051            else_branch,
14052        } => {
14053            if let Some(o) = operand {
14054                substitute_in_expr(o, row, ctx, outer_alias);
14055            }
14056            for (w, t) in branches {
14057                substitute_in_expr(w, row, ctx, outer_alias);
14058                substitute_in_expr(t, row, ctx, outer_alias);
14059            }
14060            if let Some(e) = else_branch {
14061                substitute_in_expr(e, row, ctx, outer_alias);
14062            }
14063        }
14064    }
14065}
14066
14067/// v4.22: encode a Row to a comparable byte key for UNION-DISTINCT
14068/// dedup inside the recursive iteration. Crude but deterministic
14069/// — Debug prints embed type discriminants so NULL ≠ "" ≠ 0.
14070fn encode_row_key(row: &Row) -> Vec<u8> {
14071    let mut out = Vec::new();
14072    for v in &row.values {
14073        let s = alloc::format!("{v:?}|");
14074        out.extend_from_slice(s.as_bytes());
14075    }
14076    out
14077}
14078
14079fn select_has_window(stmt: &SelectStatement) -> bool {
14080    for item in &stmt.items {
14081        if let SelectItem::Expr { expr, .. } = item
14082            && expr_has_window(expr)
14083        {
14084            return true;
14085        }
14086    }
14087    false
14088}
14089
14090fn expr_has_window(e: &Expr) -> bool {
14091    match e {
14092        Expr::WindowFunction { .. } => true,
14093        Expr::AggregateOrdered { call, order_by, .. } => {
14094            expr_has_window(call) || order_by.iter().any(|o| expr_has_window(&o.expr))
14095        }
14096        Expr::Binary { lhs, rhs, .. } => expr_has_window(lhs) || expr_has_window(rhs),
14097        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
14098            expr_has_window(expr)
14099        }
14100        Expr::FunctionCall { args, .. } => args.iter().any(expr_has_window),
14101        Expr::Like { expr, pattern, .. } => expr_has_window(expr) || expr_has_window(pattern),
14102        Expr::Extract { source, .. } => expr_has_window(source),
14103        Expr::ScalarSubquery(_)
14104        | Expr::Exists { .. }
14105        | Expr::InSubquery { .. }
14106        | Expr::Literal(_)
14107        | Expr::Placeholder(_)
14108        | Expr::Column(_) => false,
14109        Expr::Array(items) => items.iter().any(expr_has_window),
14110        Expr::ArraySubscript { target, index } => expr_has_window(target) || expr_has_window(index),
14111        Expr::AnyAll { expr, array, .. } => expr_has_window(expr) || expr_has_window(array),
14112        Expr::Case {
14113            operand,
14114            branches,
14115            else_branch,
14116        } => {
14117            operand.as_deref().is_some_and(expr_has_window)
14118                || branches
14119                    .iter()
14120                    .any(|(w, t)| expr_has_window(w) || expr_has_window(t))
14121                || else_branch.as_deref().is_some_and(expr_has_window)
14122        }
14123    }
14124}
14125
14126fn collect_window_nodes(e: &Expr, out: &mut Vec<Expr>) {
14127    if let Expr::WindowFunction { .. } = e {
14128        // Deduplicate by structural equality on the expression
14129        // (cheap because window args + partition + order are
14130        // small). Without dedup we'd recompute identical windows
14131        // once per occurrence in the projection.
14132        if !out.iter().any(|x| x == e) {
14133            out.push(e.clone());
14134        }
14135        return;
14136    }
14137    match e {
14138        // Already handled by the early-return at the top.
14139        Expr::WindowFunction { .. } => unreachable!(),
14140        Expr::Binary { lhs, rhs, .. } => {
14141            collect_window_nodes(lhs, out);
14142            collect_window_nodes(rhs, out);
14143        }
14144        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
14145            collect_window_nodes(expr, out);
14146        }
14147        Expr::FunctionCall { args, .. } => {
14148            for a in args {
14149                collect_window_nodes(a, out);
14150            }
14151        }
14152        Expr::Like { expr, pattern, .. } => {
14153            collect_window_nodes(expr, out);
14154            collect_window_nodes(pattern, out);
14155        }
14156        Expr::Extract { source, .. } => collect_window_nodes(source, out),
14157        _ => {}
14158    }
14159}
14160
14161fn rewrite_window_to_columns(e: &mut Expr, window_nodes: &[Expr]) {
14162    if let Expr::WindowFunction { .. } = e
14163        && let Some(idx) = window_nodes.iter().position(|w| w == e)
14164    {
14165        *e = Expr::Column(spg_sql::ast::ColumnName {
14166            qualifier: None,
14167            name: alloc::format!("__win_{idx}"),
14168        });
14169        return;
14170    }
14171    match e {
14172        Expr::Binary { lhs, rhs, .. } => {
14173            rewrite_window_to_columns(lhs, window_nodes);
14174            rewrite_window_to_columns(rhs, window_nodes);
14175        }
14176        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
14177            rewrite_window_to_columns(expr, window_nodes);
14178        }
14179        Expr::FunctionCall { args, .. } => {
14180            for a in args {
14181                rewrite_window_to_columns(a, window_nodes);
14182            }
14183        }
14184        Expr::Like { expr, pattern, .. } => {
14185            rewrite_window_to_columns(expr, window_nodes);
14186            rewrite_window_to_columns(pattern, window_nodes);
14187        }
14188        Expr::Extract { source, .. } => rewrite_window_to_columns(source, window_nodes),
14189        _ => {}
14190    }
14191}
14192
14193/// Total order over partition-key tuples. NULL sorts as the
14194/// lowest value (matches the `<` partial order's NULL-last
14195/// behaviour with `INFINITY` flipped).
14196fn partition_key_cmp(a: &[Value], b: &[Value]) -> core::cmp::Ordering {
14197    for (x, y) in a.iter().zip(b.iter()) {
14198        let c = value_cmp(x, y);
14199        if c != core::cmp::Ordering::Equal {
14200            return c;
14201        }
14202    }
14203    a.len().cmp(&b.len())
14204}
14205
14206fn order_key_cmp(
14207    a: &[(Value, bool, Option<bool>)],
14208    b: &[(Value, bool, Option<bool>)],
14209) -> core::cmp::Ordering {
14210    // v7.24.1 — per-key DESC + effective NULLS placement (shared
14211    // contract with order_by_value_cmp).
14212    for ((va, desc, nf), (vb, _, _)) in a.iter().zip(b.iter()) {
14213        let c = order_by_value_cmp(*desc, *nf, va, vb);
14214        if c != core::cmp::Ordering::Equal {
14215            return c;
14216        }
14217    }
14218    a.len().cmp(&b.len())
14219}
14220
14221/// v7.17.0 Phase 3.10 — true when the Value is one of the
14222/// integer-shaped variants `generate_series` accepts as a start
14223/// / stop / step component. Float / NUMERIC are rejected — PG's
14224/// `generate_series(numeric, numeric)` overload is out of v7.17
14225/// scope.
14226const fn value_is_integer(v: &Value) -> bool {
14227    matches!(v, Value::SmallInt(_) | Value::Int(_) | Value::BigInt(_))
14228}
14229
14230/// v7.17.0 Phase 3.10 — widen any integer-shaped Value to i64 for
14231/// the generate_series iteration loop. Non-integer inputs panic;
14232/// caller guards via `value_is_integer`.
14233const fn value_to_i64(v: &Value) -> i64 {
14234    match v {
14235        Value::SmallInt(n) => *n as i64,
14236        Value::Int(n) => *n as i64,
14237        Value::BigInt(n) => *n,
14238        _ => panic!("value_to_i64 called on non-integer Value"),
14239    }
14240}
14241
14242/// v7.17.0 Phase 3.10 — integer-mode generate_series materialiser.
14243/// Step direction follows the sign: positive step iterates upward
14244/// (stops when current > stop); negative iterates downward; zero
14245/// errors. Caller-facing row stream is `BigInt`-typed so a single
14246/// projection schema covers SmallInt / Int / BigInt callers.
14247fn generate_series_integers(
14248    start: i64,
14249    stop: i64,
14250    step: i64,
14251    cancel: &CancelToken<'_>,
14252) -> Result<alloc::vec::Vec<Row>, EngineError> {
14253    if step == 0 {
14254        return Err(EngineError::Unsupported(
14255            "generate_series(): step argument cannot be zero".into(),
14256        ));
14257    }
14258    let mut out = alloc::vec::Vec::new();
14259    let mut cur = start;
14260    // Hard cap to keep a runaway call from eating all memory. PG
14261    // has no such cap but does honour query timeout; SPG's cancel
14262    // token will fire too — this is a defense-in-depth backstop.
14263    const MAX_ROWS: usize = 10_000_000;
14264    loop {
14265        cancel.check()?;
14266        if step > 0 && cur > stop {
14267            break;
14268        }
14269        if step < 0 && cur < stop {
14270            break;
14271        }
14272        out.push(Row::new(alloc::vec![Value::BigInt(cur)]));
14273        if out.len() > MAX_ROWS {
14274            return Err(EngineError::Unsupported(alloc::format!(
14275                "generate_series(): exceeded {MAX_ROWS} rows; \
14276                 narrow start/stop or use a larger step"
14277            )));
14278        }
14279        cur = match cur.checked_add(step) {
14280            Some(n) => n,
14281            None => break,
14282        };
14283    }
14284    Ok(out)
14285}
14286
14287/// v7.17.0 Phase 3.10 — timestamp-mode generate_series. step is a
14288/// `Value::Interval { months, micros }` per the caller's guard;
14289/// each iteration adds the interval via `apply_binary_interval`
14290/// so month-shifting handles short-month rollover (PG semantics).
14291fn generate_series_timestamps(
14292    start: i64,
14293    stop: i64,
14294    step: Value,
14295    cancel: &CancelToken<'_>,
14296) -> Result<alloc::vec::Vec<Row>, EngineError> {
14297    let (months, micros) = match &step {
14298        Value::Interval { months, micros } => (*months, *micros),
14299        _ => unreachable!("caller guards step.is_interval"),
14300    };
14301    if months == 0 && micros == 0 {
14302        return Err(EngineError::Unsupported(
14303            "generate_series(): INTERVAL step cannot be zero".into(),
14304        ));
14305    }
14306    let ascending = months > 0 || micros > 0;
14307    let mut out = alloc::vec::Vec::new();
14308    let mut cur = Value::Timestamp(start);
14309    const MAX_ROWS: usize = 10_000_000;
14310    loop {
14311        cancel.check()?;
14312        let cur_t = match cur {
14313            Value::Timestamp(t) => t,
14314            _ => unreachable!("loop invariant: cur is Timestamp"),
14315        };
14316        if ascending && cur_t > stop {
14317            break;
14318        }
14319        if !ascending && cur_t < stop {
14320            break;
14321        }
14322        out.push(Row::new(alloc::vec![Value::Timestamp(cur_t)]));
14323        if out.len() > MAX_ROWS {
14324            return Err(EngineError::Unsupported(alloc::format!(
14325                "generate_series(): exceeded {MAX_ROWS} rows; \
14326                 narrow start/stop or use a larger step"
14327            )));
14328        }
14329        let next = eval::apply_binary_interval(
14330            spg_sql::ast::BinOp::Add,
14331            &cur,
14332            &Value::Interval { months, micros },
14333        )
14334        .map_err(EngineError::Eval)?;
14335        cur = match next {
14336            Some(v) => v,
14337            None => break,
14338        };
14339    }
14340    Ok(out)
14341}
14342
14343#[allow(clippy::match_same_arms)] // explicit arms per type document the supported pairs
14344/// v7.24 (round-16 A) — per-key ORDER BY comparator honouring DESC
14345/// and the effective NULLS placement (explicit NULLS FIRST/LAST,
14346/// else the PG default: NULLS LAST for ASC, NULLS FIRST for DESC).
14347/// NULL placement is absolute — it does not flip with DESC.
14348pub(crate) fn order_by_value_cmp(
14349    desc: bool,
14350    nulls_first: Option<bool>,
14351    a: &Value,
14352    b: &Value,
14353) -> core::cmp::Ordering {
14354    use core::cmp::Ordering;
14355    let nf = nulls_first.unwrap_or(desc);
14356    match (matches!(a, Value::Null), matches!(b, Value::Null)) {
14357        (true, true) => Ordering::Equal,
14358        (true, false) => {
14359            if nf {
14360                Ordering::Less
14361            } else {
14362                Ordering::Greater
14363            }
14364        }
14365        (false, true) => {
14366            if nf {
14367                Ordering::Greater
14368            } else {
14369                Ordering::Less
14370            }
14371        }
14372        (false, false) => {
14373            let c = value_cmp(a, b);
14374            if desc { c.reverse() } else { c }
14375        }
14376    }
14377}
14378
14379fn value_cmp(a: &Value, b: &Value) -> core::cmp::Ordering {
14380    use core::cmp::Ordering;
14381    match (a, b) {
14382        (Value::Null, Value::Null) => Ordering::Equal,
14383        (Value::Null, _) => Ordering::Less,
14384        (_, Value::Null) => Ordering::Greater,
14385        (Value::Int(x), Value::Int(y)) => x.cmp(y),
14386        (Value::BigInt(x), Value::BigInt(y)) => x.cmp(y),
14387        (Value::SmallInt(x), Value::SmallInt(y)) => x.cmp(y),
14388        (Value::Text(x), Value::Text(y)) => x.cmp(y),
14389        (Value::Bool(x), Value::Bool(y)) => x.cmp(y),
14390        (Value::Float(x), Value::Float(y)) => x.partial_cmp(y).unwrap_or(Ordering::Equal),
14391        (Value::Date(x), Value::Date(y)) => x.cmp(y),
14392        (Value::Timestamp(x), Value::Timestamp(y)) => x.cmp(y),
14393        // Cross-type compare: fall back to the debug rendering —
14394        // same-partition is the goal, exact order is irrelevant.
14395        _ => alloc::format!("{a:?}").cmp(&alloc::format!("{b:?}")),
14396    }
14397}
14398
14399/// Compute the window function's per-row output for one partition.
14400/// `slice` has (partition key, order key, original-row-index)
14401/// tuples already sorted by order key. `filtered_rows` is the
14402/// full row list indexed by original-row-index. `out_vals` is
14403/// the destination, also indexed by original-row-index.
14404#[allow(
14405    clippy::too_many_arguments,
14406    clippy::cast_possible_truncation,
14407    clippy::cast_possible_wrap,
14408    clippy::cast_precision_loss,
14409    clippy::cast_sign_loss,
14410    clippy::doc_markdown,
14411    clippy::too_many_lines,
14412    clippy::type_complexity,
14413    clippy::match_same_arms
14414)]
14415fn compute_window_partition(
14416    name: &str,
14417    args: &[Expr],
14418    ordered: bool,
14419    frame: Option<&WindowFrame>,
14420    null_treatment: spg_sql::ast::NullTreatment,
14421    slice: &[(Vec<Value>, Vec<(Value, bool, Option<bool>)>, usize)],
14422    filtered_rows: &[&Row],
14423    ctx: &EvalContext<'_>,
14424    out_vals: &mut [Value],
14425) -> Result<(), EngineError> {
14426    let ignore_nulls = matches!(null_treatment, spg_sql::ast::NullTreatment::Ignore);
14427    let lower = name.to_ascii_lowercase();
14428    match lower.as_str() {
14429        "row_number" => {
14430            for (rank, (_, _, idx)) in slice.iter().enumerate() {
14431                out_vals[*idx] = Value::BigInt((rank + 1) as i64);
14432            }
14433            Ok(())
14434        }
14435        "rank" => {
14436            let mut prev_key: Option<&[(Value, bool, Option<bool>)]> = None;
14437            let mut current_rank: i64 = 1;
14438            for (i, (_, okey, idx)) in slice.iter().enumerate() {
14439                if let Some(p) = prev_key
14440                    && order_key_cmp(p, okey) != core::cmp::Ordering::Equal
14441                {
14442                    current_rank = (i + 1) as i64;
14443                }
14444                if prev_key.is_none() {
14445                    current_rank = 1;
14446                }
14447                out_vals[*idx] = Value::BigInt(current_rank);
14448                prev_key = Some(okey.as_slice());
14449            }
14450            Ok(())
14451        }
14452        "dense_rank" => {
14453            let mut prev_key: Option<&[(Value, bool, Option<bool>)]> = None;
14454            let mut current_rank: i64 = 0;
14455            for (_, okey, idx) in slice {
14456                if prev_key.is_none_or(|p| order_key_cmp(p, okey) != core::cmp::Ordering::Equal) {
14457                    current_rank += 1;
14458                }
14459                out_vals[*idx] = Value::BigInt(current_rank);
14460                prev_key = Some(okey.as_slice());
14461            }
14462            Ok(())
14463        }
14464        "sum" | "avg" | "min" | "max" | "count" | "count_star" => {
14465            // Pre-evaluate the function arg per row in the slice
14466            // (count_star has no arg).
14467            let arg_values: Vec<Value> = if lower == "count_star" || args.is_empty() {
14468                slice.iter().map(|_| Value::Null).collect()
14469            } else {
14470                slice
14471                    .iter()
14472                    .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
14473                    .collect::<Result<_, _>>()
14474                    .map_err(EngineError::Eval)?
14475            };
14476            // v4.20: pick the effective frame. Explicit frame
14477            // overrides the implicit default (running for ordered,
14478            // whole-partition for unordered).
14479            let eff = effective_frame(frame, ordered)?;
14480            #[allow(clippy::needless_range_loop)]
14481            for i in 0..slice.len() {
14482                let (lo, hi) = frame_bounds_for_row(&eff, i, slice);
14483                let mut sum: f64 = 0.0;
14484                let mut count: i64 = 0;
14485                let mut min_v: Option<f64> = None;
14486                let mut max_v: Option<f64> = None;
14487                let mut row_count: i64 = 0;
14488                if lo <= hi {
14489                    for j in lo..=hi {
14490                        let v = &arg_values[j];
14491                        match lower.as_str() {
14492                            "count_star" => row_count += 1,
14493                            "count" => {
14494                                if !v.is_null() {
14495                                    count += 1;
14496                                }
14497                            }
14498                            _ => {
14499                                if let Some(x) = value_to_f64(v) {
14500                                    sum += x;
14501                                    count += 1;
14502                                    min_v = Some(min_v.map_or(x, |m| m.min(x)));
14503                                    max_v = Some(max_v.map_or(x, |m| m.max(x)));
14504                                }
14505                            }
14506                        }
14507                    }
14508                }
14509                let value = match lower.as_str() {
14510                    "count_star" => Value::BigInt(row_count),
14511                    "count" => Value::BigInt(count),
14512                    "sum" => Value::Float(sum),
14513                    "avg" => {
14514                        if count == 0 {
14515                            Value::Null
14516                        } else {
14517                            Value::Float(sum / count as f64)
14518                        }
14519                    }
14520                    "min" => min_v.map_or(Value::Null, Value::Float),
14521                    "max" => max_v.map_or(Value::Null, Value::Float),
14522                    _ => unreachable!(),
14523                };
14524                let (_, _, idx) = &slice[i];
14525                out_vals[*idx] = value;
14526            }
14527            Ok(())
14528        }
14529        "lag" | "lead" => {
14530            // lag(expr [, offset [, default]])
14531            // lead(expr [, offset [, default]])
14532            if args.is_empty() {
14533                return Err(EngineError::Unsupported(alloc::format!(
14534                    "{lower}() requires at least one argument"
14535                )));
14536            }
14537            let offset: i64 = if args.len() >= 2 {
14538                let v = eval::eval_expr(&args[1], filtered_rows[slice[0].2], ctx)
14539                    .map_err(EngineError::Eval)?;
14540                match v {
14541                    Value::SmallInt(n) => i64::from(n),
14542                    Value::Int(n) => i64::from(n),
14543                    Value::BigInt(n) => n,
14544                    _ => {
14545                        return Err(EngineError::Unsupported(alloc::format!(
14546                            "{lower}() offset must be integer"
14547                        )));
14548                    }
14549                }
14550            } else {
14551                1
14552            };
14553            let default: Value = if args.len() >= 3 {
14554                eval::eval_expr(&args[2], filtered_rows[slice[0].2], ctx)
14555                    .map_err(EngineError::Eval)?
14556            } else {
14557                Value::Null
14558            };
14559            let values: Vec<Value> = slice
14560                .iter()
14561                .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
14562                .collect::<Result<_, _>>()
14563                .map_err(EngineError::Eval)?;
14564            let n = slice.len();
14565            for (i, (_, _, idx)) in slice.iter().enumerate() {
14566                let signed_offset = if lower == "lag" { -offset } else { offset };
14567                let v = if ignore_nulls {
14568                    // v6.4.2 — IGNORE NULLS: walk in the offset direction
14569                    // skipping NULL values; the `offset`-th non-NULL
14570                    // encountered is the result.
14571                    let step: i64 = if signed_offset >= 0 { 1 } else { -1 };
14572                    let needed: i64 = signed_offset.abs();
14573                    if needed == 0 {
14574                        values[i].clone()
14575                    } else {
14576                        let mut j: i64 = i as i64;
14577                        let mut hits: i64 = 0;
14578                        let mut found: Option<Value> = None;
14579                        loop {
14580                            j += step;
14581                            if j < 0 || j >= n as i64 {
14582                                break;
14583                            }
14584                            #[allow(clippy::cast_sign_loss)]
14585                            let v = &values[j as usize];
14586                            if !v.is_null() {
14587                                hits += 1;
14588                                if hits == needed {
14589                                    found = Some(v.clone());
14590                                    break;
14591                                }
14592                            }
14593                        }
14594                        found.unwrap_or_else(|| default.clone())
14595                    }
14596                } else {
14597                    let target_signed = i64::try_from(i).unwrap_or(i64::MAX) + signed_offset;
14598                    if target_signed < 0 || target_signed >= i64::try_from(n).unwrap_or(i64::MAX) {
14599                        default.clone()
14600                    } else {
14601                        #[allow(clippy::cast_sign_loss)]
14602                        {
14603                            values[target_signed as usize].clone()
14604                        }
14605                    }
14606                };
14607                out_vals[*idx] = v;
14608            }
14609            Ok(())
14610        }
14611        "first_value" | "last_value" | "nth_value" => {
14612            if args.is_empty() {
14613                return Err(EngineError::Unsupported(alloc::format!(
14614                    "{lower}() requires at least one argument"
14615                )));
14616            }
14617            let values: Vec<Value> = slice
14618                .iter()
14619                .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
14620                .collect::<Result<_, _>>()
14621                .map_err(EngineError::Eval)?;
14622            let nth: usize = if lower == "nth_value" {
14623                if args.len() < 2 {
14624                    return Err(EngineError::Unsupported(
14625                        "nth_value() requires (expr, n)".into(),
14626                    ));
14627                }
14628                let v = eval::eval_expr(&args[1], filtered_rows[slice[0].2], ctx)
14629                    .map_err(EngineError::Eval)?;
14630                let raw = match v {
14631                    Value::SmallInt(n) => i64::from(n),
14632                    Value::Int(n) => i64::from(n),
14633                    Value::BigInt(n) => n,
14634                    _ => {
14635                        return Err(EngineError::Unsupported(
14636                            "nth_value() n must be integer".into(),
14637                        ));
14638                    }
14639                };
14640                if raw < 1 {
14641                    return Err(EngineError::Unsupported(
14642                        "nth_value() n must be >= 1".into(),
14643                    ));
14644                }
14645                #[allow(clippy::cast_sign_loss)]
14646                {
14647                    raw as usize
14648                }
14649            } else {
14650                0
14651            };
14652            let eff = effective_frame(frame, ordered)?;
14653            for i in 0..slice.len() {
14654                let (lo, hi) = frame_bounds_for_row(&eff, i, slice);
14655                let (_, _, idx) = &slice[i];
14656                let v = if lo > hi {
14657                    Value::Null
14658                } else if ignore_nulls && matches!(lower.as_str(), "first_value" | "last_value") {
14659                    // v6.4.2 — IGNORE NULLS: skip NULL cells when
14660                    // selecting the boundary value within the frame.
14661                    if lower == "first_value" {
14662                        (lo..=hi)
14663                            .find_map(|j| {
14664                                let v = &values[j];
14665                                (!v.is_null()).then(|| v.clone())
14666                            })
14667                            .unwrap_or(Value::Null)
14668                    } else {
14669                        (lo..=hi)
14670                            .rev()
14671                            .find_map(|j| {
14672                                let v = &values[j];
14673                                (!v.is_null()).then(|| v.clone())
14674                            })
14675                            .unwrap_or(Value::Null)
14676                    }
14677                } else {
14678                    match lower.as_str() {
14679                        "first_value" => values[lo].clone(),
14680                        "last_value" => values[hi].clone(),
14681                        "nth_value" => {
14682                            let pos = lo + nth - 1;
14683                            if pos > hi {
14684                                Value::Null
14685                            } else {
14686                                values[pos].clone()
14687                            }
14688                        }
14689                        _ => unreachable!(),
14690                    }
14691                };
14692                out_vals[*idx] = v;
14693            }
14694            Ok(())
14695        }
14696        "ntile" => {
14697            if args.is_empty() {
14698                return Err(EngineError::Unsupported(
14699                    "ntile(n) requires an integer argument".into(),
14700                ));
14701            }
14702            let v = eval::eval_expr(&args[0], filtered_rows[slice[0].2], ctx)
14703                .map_err(EngineError::Eval)?;
14704            let bucket_count: i64 = match v {
14705                Value::SmallInt(n) => i64::from(n),
14706                Value::Int(n) => i64::from(n),
14707                Value::BigInt(n) => n,
14708                _ => {
14709                    return Err(EngineError::Unsupported(
14710                        "ntile() argument must be integer".into(),
14711                    ));
14712                }
14713            };
14714            if bucket_count < 1 {
14715                return Err(EngineError::Unsupported(
14716                    "ntile() argument must be >= 1".into(),
14717                ));
14718            }
14719            #[allow(clippy::cast_sign_loss)]
14720            let buckets = bucket_count as usize;
14721            let n = slice.len();
14722            // Each bucket gets `base` rows; the first `extras` buckets
14723            // get one extra. PG semantics.
14724            let base = n / buckets;
14725            let extras = n % buckets;
14726            let mut bucket: usize = 1;
14727            let mut remaining_in_bucket = if extras > 0 { base + 1 } else { base };
14728            let mut buckets_with_extra_remaining = extras;
14729            for (_, _, idx) in slice {
14730                if remaining_in_bucket == 0 {
14731                    bucket += 1;
14732                    buckets_with_extra_remaining = buckets_with_extra_remaining.saturating_sub(1);
14733                    remaining_in_bucket = if buckets_with_extra_remaining > 0 {
14734                        base + 1
14735                    } else {
14736                        base
14737                    };
14738                    // Edge: if base==0 and extras==0, all rows fit;
14739                    // shouldn't reach here, but guard anyway.
14740                    if remaining_in_bucket == 0 {
14741                        remaining_in_bucket = 1;
14742                    }
14743                }
14744                out_vals[*idx] = Value::BigInt(i64::try_from(bucket).unwrap_or(i64::MAX));
14745                remaining_in_bucket -= 1;
14746            }
14747            Ok(())
14748        }
14749        "percent_rank" => {
14750            // (rank - 1) / (n - 1) where rank is the standard RANK().
14751            // Single-row partitions get 0.
14752            let n = slice.len();
14753            let mut prev_key: Option<&[(Value, bool, Option<bool>)]> = None;
14754            let mut current_rank: i64 = 1;
14755            for (i, (_, okey, idx)) in slice.iter().enumerate() {
14756                if let Some(p) = prev_key
14757                    && order_key_cmp(p, okey) != core::cmp::Ordering::Equal
14758                {
14759                    current_rank = i64::try_from(i + 1).unwrap_or(i64::MAX);
14760                }
14761                if prev_key.is_none() {
14762                    current_rank = 1;
14763                }
14764                #[allow(clippy::cast_precision_loss)]
14765                let pr = if n <= 1 {
14766                    0.0
14767                } else {
14768                    (current_rank - 1) as f64 / (n - 1) as f64
14769                };
14770                out_vals[*idx] = Value::Float(pr);
14771                prev_key = Some(okey.as_slice());
14772            }
14773            Ok(())
14774        }
14775        "cume_dist" => {
14776            // # rows up to and including this row's peer group / n.
14777            let n = slice.len();
14778            // First pass: find peer-group-end rank for each row.
14779            for i in 0..slice.len() {
14780                let peer_end = peer_group_end(slice, i);
14781                #[allow(clippy::cast_precision_loss)]
14782                let cd = (peer_end + 1) as f64 / n as f64;
14783                let (_, _, idx) = &slice[i];
14784                out_vals[*idx] = Value::Float(cd);
14785            }
14786            Ok(())
14787        }
14788        other => Err(EngineError::Unsupported(alloc::format!(
14789            "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)"
14790        ))),
14791    }
14792}
14793
14794/// v4.20: resolve the user-provided frame down to a normalised
14795/// `(kind, start, end)`. `None` means default — derive from
14796/// `ordered`: ordered ⇒ RANGE UNBOUNDED PRECEDING AND CURRENT ROW,
14797/// unordered ⇒ ROWS UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
14798/// Single-bound shorthand (e.g. `ROWS 5 PRECEDING`) normalises
14799/// end → CURRENT ROW per the PG spec.
14800fn effective_frame(
14801    frame: Option<&WindowFrame>,
14802    ordered: bool,
14803) -> Result<(FrameKind, FrameBound, FrameBound), EngineError> {
14804    match frame {
14805        None => {
14806            if ordered {
14807                Ok((
14808                    FrameKind::Range,
14809                    FrameBound::UnboundedPreceding,
14810                    FrameBound::CurrentRow,
14811                ))
14812            } else {
14813                Ok((
14814                    FrameKind::Rows,
14815                    FrameBound::UnboundedPreceding,
14816                    FrameBound::UnboundedFollowing,
14817                ))
14818            }
14819        }
14820        Some(fr) => {
14821            let end = fr.end.clone().unwrap_or(FrameBound::CurrentRow);
14822            // Reject start > end (a few impossible combinations).
14823            if matches!(fr.start, FrameBound::UnboundedFollowing)
14824                || matches!(end, FrameBound::UnboundedPreceding)
14825            {
14826                return Err(EngineError::Unsupported(alloc::format!(
14827                    "invalid frame: start={:?} end={:?}",
14828                    fr.start,
14829                    end
14830                )));
14831            }
14832            // RANGE OFFSET PRECEDING / FOLLOWING needs value-typed
14833            // arithmetic on the ORDER BY key (e.g. `RANGE BETWEEN
14834            // INTERVAL '1 day' PRECEDING AND CURRENT ROW`). Not
14835            // implemented in v4.20.
14836            if fr.kind == FrameKind::Range
14837                && (matches!(
14838                    fr.start,
14839                    FrameBound::OffsetPreceding(_) | FrameBound::OffsetFollowing(_)
14840                ) || matches!(
14841                    end,
14842                    FrameBound::OffsetPreceding(_) | FrameBound::OffsetFollowing(_)
14843                ))
14844            {
14845                return Err(EngineError::Unsupported(
14846                    "RANGE with explicit offset bounds is not supported (v4.20: only UNBOUNDED / CURRENT ROW for RANGE)".into(),
14847                ));
14848            }
14849            Ok((fr.kind, fr.start.clone(), end))
14850        }
14851    }
14852}
14853
14854/// Compute `(lo, hi)` row-index bounds inside the partition slice
14855/// for the row at position `i`. Inclusive, clamped to
14856/// `[0, slice.len()-1]`. Empty result if `lo > hi`.
14857#[allow(clippy::type_complexity)]
14858fn frame_bounds_for_row(
14859    eff: &(FrameKind, FrameBound, FrameBound),
14860    i: usize,
14861    slice: &[(Vec<Value>, Vec<(Value, bool, Option<bool>)>, usize)],
14862) -> (usize, usize) {
14863    let (kind, start, end) = eff;
14864    let n = slice.len();
14865    let last = n.saturating_sub(1);
14866    let (mut lo, mut hi) = match kind {
14867        FrameKind::Rows => {
14868            let lo = match start {
14869                FrameBound::UnboundedPreceding => 0,
14870                FrameBound::OffsetPreceding(k) => {
14871                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
14872                    i.saturating_sub(k)
14873                }
14874                FrameBound::CurrentRow => i,
14875                FrameBound::OffsetFollowing(k) => {
14876                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
14877                    i.saturating_add(k).min(last)
14878                }
14879                FrameBound::UnboundedFollowing => last,
14880            };
14881            let hi = match end {
14882                FrameBound::UnboundedPreceding => 0,
14883                FrameBound::OffsetPreceding(k) => {
14884                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
14885                    i.saturating_sub(k)
14886                }
14887                FrameBound::CurrentRow => i,
14888                FrameBound::OffsetFollowing(k) => {
14889                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
14890                    i.saturating_add(k).min(last)
14891                }
14892                FrameBound::UnboundedFollowing => last,
14893            };
14894            (lo, hi)
14895        }
14896        FrameKind::Range => {
14897            // RANGE bounds are peer-aware. With only UNBOUNDED and
14898            // CURRENT ROW supported (rejected at effective_frame for
14899            // explicit offsets), the start/end map to the
14900            // partition's full extent at the same-order-key peer
14901            // group boundary.
14902            let lo = match start {
14903                FrameBound::UnboundedPreceding => 0,
14904                FrameBound::CurrentRow => peer_group_start(slice, i),
14905                FrameBound::UnboundedFollowing => last,
14906                _ => unreachable!("offset bounds rejected for RANGE"),
14907            };
14908            let hi = match end {
14909                FrameBound::UnboundedPreceding => 0,
14910                FrameBound::CurrentRow => peer_group_end(slice, i),
14911                FrameBound::UnboundedFollowing => last,
14912                _ => unreachable!("offset bounds rejected for RANGE"),
14913            };
14914            (lo, hi)
14915        }
14916    };
14917    if hi >= n {
14918        hi = last;
14919    }
14920    if lo >= n {
14921        lo = last;
14922    }
14923    (lo, hi)
14924}
14925
14926/// Find the inclusive index of the first row with the same ORDER
14927/// BY key as `slice[i]`. Slice is already sorted by partition then
14928/// order, so peers are contiguous.
14929#[allow(clippy::type_complexity)]
14930fn peer_group_start(
14931    slice: &[(Vec<Value>, Vec<(Value, bool, Option<bool>)>, usize)],
14932    i: usize,
14933) -> usize {
14934    let key = &slice[i].1;
14935    let mut j = i;
14936    while j > 0 && order_key_cmp(&slice[j - 1].1, key) == core::cmp::Ordering::Equal {
14937        j -= 1;
14938    }
14939    j
14940}
14941
14942/// Find the inclusive index of the last row with the same ORDER
14943/// BY key as `slice[i]`.
14944#[allow(clippy::type_complexity)]
14945fn peer_group_end(
14946    slice: &[(Vec<Value>, Vec<(Value, bool, Option<bool>)>, usize)],
14947    i: usize,
14948) -> usize {
14949    let key = &slice[i].1;
14950    let mut j = i;
14951    while j + 1 < slice.len() && order_key_cmp(&slice[j + 1].1, key) == core::cmp::Ordering::Equal {
14952        j += 1;
14953    }
14954    j
14955}
14956
14957fn value_to_f64(v: &Value) -> Option<f64> {
14958    match v {
14959        Value::SmallInt(n) => Some(f64::from(*n)),
14960        Value::Int(n) => Some(f64::from(*n)),
14961        #[allow(clippy::cast_precision_loss)]
14962        Value::BigInt(n) => Some(*n as f64),
14963        Value::Float(x) => Some(*x),
14964        _ => None,
14965    }
14966}
14967
14968/// Quick scan for any subquery-bearing node in a SELECT's WHERE /
14969/// projection / `order_by` — saves cloning the AST when there are
14970/// none (the common case).
14971fn expr_tree_has_subquery(stmt: &SelectStatement) -> bool {
14972    let mut any = false;
14973    for item in &stmt.items {
14974        if let SelectItem::Expr { expr, .. } = item {
14975            any = any || expr_has_subquery(expr);
14976        }
14977    }
14978    if let Some(w) = &stmt.where_ {
14979        any = any || expr_has_subquery(w);
14980    }
14981    if let Some(h) = &stmt.having {
14982        any = any || expr_has_subquery(h);
14983    }
14984    for o in &stmt.order_by {
14985        any = any || expr_has_subquery(&o.expr);
14986    }
14987    for (_, peer) in &stmt.unions {
14988        any = any || expr_tree_has_subquery(peer);
14989    }
14990    any
14991}
14992
14993pub(crate) fn expr_has_subquery(e: &Expr) -> bool {
14994    match e {
14995        Expr::ScalarSubquery(_) | Expr::Exists { .. } | Expr::InSubquery { .. } => true,
14996        Expr::AggregateOrdered { call, order_by, .. } => {
14997            expr_has_subquery(call) || order_by.iter().any(|o| expr_has_subquery(&o.expr))
14998        }
14999        Expr::Binary { lhs, rhs, .. } => expr_has_subquery(lhs) || expr_has_subquery(rhs),
15000        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
15001            expr_has_subquery(expr)
15002        }
15003        Expr::FunctionCall { args, .. } => args.iter().any(expr_has_subquery),
15004        Expr::Like { expr, pattern, .. } => expr_has_subquery(expr) || expr_has_subquery(pattern),
15005        Expr::Extract { source, .. } => expr_has_subquery(source),
15006        Expr::WindowFunction {
15007            args,
15008            partition_by,
15009            order_by,
15010            ..
15011        } => {
15012            args.iter().any(expr_has_subquery)
15013                || partition_by.iter().any(expr_has_subquery)
15014                || order_by.iter().any(|(e, _, _)| expr_has_subquery(e))
15015        }
15016        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => false,
15017        Expr::Array(items) => items.iter().any(expr_has_subquery),
15018        Expr::ArraySubscript { target, index } => {
15019            expr_has_subquery(target) || expr_has_subquery(index)
15020        }
15021        Expr::AnyAll { expr, array, .. } => expr_has_subquery(expr) || expr_has_subquery(array),
15022        Expr::Case {
15023            operand,
15024            branches,
15025            else_branch,
15026        } => {
15027            operand.as_deref().is_some_and(expr_has_subquery)
15028                || branches
15029                    .iter()
15030                    .any(|(w, t)| expr_has_subquery(w) || expr_has_subquery(t))
15031                || else_branch.as_deref().is_some_and(expr_has_subquery)
15032        }
15033    }
15034}
15035
15036/// v4.10 helper: materialise a runtime `Value` back into an AST
15037/// `Expr::Literal` for the subquery-rewrite path. Supports the
15038/// types `Literal` can represent (Integer / Float / Text / Bool /
15039/// Null). Date / Timestamp / Numeric / Vector / Interval / JSON
15040/// would lose precision through Literal and aren't supported in
15041/// uncorrelated-subquery results; they error with a clear hint.
15042fn value_to_literal_expr(v: Value) -> Result<Expr, EngineError> {
15043    let lit = match v {
15044        Value::Null => Literal::Null,
15045        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
15046        Value::Int(n) => Literal::Integer(i64::from(n)),
15047        Value::BigInt(n) => Literal::Integer(n),
15048        Value::Float(x) => Literal::Float(x),
15049        Value::Text(s) | Value::Json(s) => Literal::String(s),
15050        Value::Bool(b) => Literal::Bool(b),
15051        other => {
15052            return Err(EngineError::Unsupported(alloc::format!(
15053                "subquery result type {:?} not yet materialisable; cast to text or integer in the inner SELECT",
15054                other.data_type()
15055            )));
15056        }
15057    };
15058    Ok(Expr::Literal(lit))
15059}
15060
15061/// v7.13.0 — wider helper used by `INSERT … SELECT` (mailrs
15062/// round-5 G4). Covers the most common `Value` variants. Types
15063/// that need lossy textual round-trip (BYTEA, arrays, ts*)
15064/// surface as an Unsupported error so the caller can add a cast
15065/// in the inner SELECT.
15066fn value_to_literal_expr_permissive(v: Value) -> Result<Expr, EngineError> {
15067    let lit = match v {
15068        Value::Null => Literal::Null,
15069        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
15070        Value::Int(n) => Literal::Integer(i64::from(n)),
15071        Value::BigInt(n) => Literal::Integer(n),
15072        Value::Float(x) => Literal::Float(x),
15073        Value::Text(s) | Value::Json(s) => Literal::String(s),
15074        Value::Bool(b) => Literal::Bool(b),
15075        Value::Vector(xs) => Literal::Vector(xs),
15076        // Date / Timestamp / Timestamptz / Numeric round-trip
15077        // through a TEXT literal that `coerce_value` re-parses
15078        // against the target column type.
15079        Value::Date(days) => {
15080            let micros = (i64::from(days)) * 86_400_000_000;
15081            Literal::String(format_timestamp_micros_as_date(micros))
15082        }
15083        Value::Timestamp(us) => Literal::String(format_timestamp_micros(us)),
15084        Value::Numeric { scaled, scale } => Literal::String(format_numeric(scaled, scale)),
15085        other => {
15086            return Err(EngineError::Unsupported(alloc::format!(
15087                "INSERT … SELECT cannot materialise value of type {:?}; \
15088                 add an explicit CAST in the inner SELECT",
15089                other.data_type()
15090            )));
15091        }
15092    };
15093    Ok(Expr::Literal(lit))
15094}
15095
15096fn format_timestamp_micros(us: i64) -> String {
15097    // Same Y/M/D split used by the wire layer; epoch-relative.
15098    let days = us.div_euclid(86_400_000_000);
15099    let intra_day = us.rem_euclid(86_400_000_000);
15100    let date = format_timestamp_micros_as_date(days * 86_400_000_000);
15101    let secs = intra_day / 1_000_000;
15102    let us_rem = intra_day % 1_000_000;
15103    let h = (secs / 3600) % 24;
15104    let m = (secs / 60) % 60;
15105    let s = secs % 60;
15106    if us_rem == 0 {
15107        alloc::format!("{date} {h:02}:{m:02}:{s:02}")
15108    } else {
15109        alloc::format!("{date} {h:02}:{m:02}:{s:02}.{us_rem:06}")
15110    }
15111}
15112
15113fn format_timestamp_micros_as_date(us: i64) -> String {
15114    // Days since 1970-01-01 → calendar Y-M-D via the proleptic
15115    // Gregorian conversion used by spg-engine's date helpers.
15116    let days = us.div_euclid(86_400_000_000);
15117    // 1970-01-01 = JDN 2440588.
15118    let jdn = days + 2_440_588;
15119    let (y, mo, d) = jdn_to_ymd(jdn);
15120    alloc::format!("{y:04}-{mo:02}-{d:02}")
15121}
15122
15123fn jdn_to_ymd(jdn: i64) -> (i64, u32, u32) {
15124    // Fliegel & Van Flandern (1968) — works for all positive JDNs.
15125    let l = jdn + 68569;
15126    let n = (4 * l) / 146_097;
15127    let l = l - (146_097 * n + 3) / 4;
15128    let i = (4000 * (l + 1)) / 1_461_001;
15129    let l = l - (1461 * i) / 4 + 31;
15130    let j = (80 * l) / 2447;
15131    let day = (l - (2447 * j) / 80) as u32;
15132    let l = j / 11;
15133    let month = (j + 2 - 12 * l) as u32;
15134    let year = 100 * (n - 49) + i + l;
15135    (year, month, day)
15136}
15137
15138fn format_numeric(scaled: i128, scale: u8) -> String {
15139    if scale == 0 {
15140        return alloc::format!("{scaled}");
15141    }
15142    let abs = scaled.unsigned_abs();
15143    let divisor = 10u128.pow(u32::from(scale));
15144    let whole = abs / divisor;
15145    let frac = abs % divisor;
15146    let sign = if scaled < 0 { "-" } else { "" };
15147    alloc::format!("{sign}{whole}.{frac:0width$}", width = usize::from(scale))
15148}
15149
15150/// v6.1.1 — walk the prepared `Statement` AST and replace every
15151/// `Expr::Placeholder(n)` with `Expr::Literal(value_to_literal(
15152/// params[n-1]))`. The dispatch downstream sees a `Statement`
15153/// indistinguishable from a simple-query parse, so the exec path
15154/// stays unchanged.
15155///
15156/// Errors fall into one shape: a `$N` references past the bound
15157/// `params.len()`. Out-of-range happens when the Bind didn't
15158/// supply enough values; pgwire surfaces this as a protocol error
15159/// to the client.
15160/// v7.15.0 — rewrite every (potentially-qualified) column
15161/// identifier matching `old` to `new` in a stored SQL source
15162/// string. Used by `ALTER TABLE … RENAME COLUMN` to patch
15163/// CHECK predicate sources, partial-index predicate sources,
15164/// and runtime DEFAULT expression sources before they get
15165/// re-parsed on the next INSERT/UPDATE.
15166///
15167/// Round-trips through the parser, so the rewritten output is
15168/// the canonical Display form (matches what the engine stores
15169/// for fresh predicates). If the source doesn't parse, surfaces
15170/// the parse error — the invariant that stored predicates are
15171/// in canonical Display form means a parse failure here is a
15172/// real bug, not a user mistake to swallow.
15173fn rewrite_column_in_source(
15174    src: &str,
15175    old: &str,
15176    new: &str,
15177) -> Result<alloc::string::String, EngineError> {
15178    let mut expr = spg_sql::parser::parse_expression(src).map_err(|e| {
15179        EngineError::Unsupported(alloc::format!(
15180            "ALTER TABLE RENAME COLUMN: stored predicate source {src:?} \
15181             failed to parse for rewrite ({e})"
15182        ))
15183    })?;
15184    rewrite_column_in_expr(&mut expr, old, new);
15185    Ok(alloc::format!("{expr}"))
15186}
15187
15188/// v7.15.0 — Expr walker that swaps `Expr::Column { name: old, .. }`
15189/// for `Expr::Column { name: new, .. }`. Qualifier is preserved
15190/// (e.g. `t.old` → `t.new`); a foreign-table qualifier still
15191/// gets rewritten because the AST has no way to tell us this
15192/// predicate is on table T versus table T2 — predicate sources
15193/// in SPG are always scoped to the owning table, so any
15194/// qualifier present is either redundant or wrong.
15195fn rewrite_column_in_expr(e: &mut Expr, old: &str, new: &str) {
15196    match e {
15197        Expr::AggregateOrdered { call, order_by, .. } => {
15198            rewrite_column_in_expr(call, old, new);
15199            for o in order_by.iter_mut() {
15200                rewrite_column_in_expr(&mut o.expr, old, new);
15201            }
15202        }
15203        Expr::Column(c) => {
15204            if c.name.eq_ignore_ascii_case(old) {
15205                c.name = new.to_string();
15206            }
15207        }
15208        Expr::Binary { lhs, rhs, .. } => {
15209            rewrite_column_in_expr(lhs, old, new);
15210            rewrite_column_in_expr(rhs, old, new);
15211        }
15212        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
15213            rewrite_column_in_expr(expr, old, new);
15214        }
15215        Expr::FunctionCall { args, .. } => {
15216            for a in args {
15217                rewrite_column_in_expr(a, old, new);
15218            }
15219        }
15220        Expr::Like { expr, pattern, .. } => {
15221            rewrite_column_in_expr(expr, old, new);
15222            rewrite_column_in_expr(pattern, old, new);
15223        }
15224        Expr::Extract { source, .. } => rewrite_column_in_expr(source, old, new),
15225        Expr::WindowFunction {
15226            args,
15227            partition_by,
15228            order_by,
15229            ..
15230        } => {
15231            for a in args {
15232                rewrite_column_in_expr(a, old, new);
15233            }
15234            for p in partition_by {
15235                rewrite_column_in_expr(p, old, new);
15236            }
15237            for (o, _, _) in order_by {
15238                rewrite_column_in_expr(o, old, new);
15239            }
15240        }
15241        Expr::Array(items) => {
15242            for elem in items {
15243                rewrite_column_in_expr(elem, old, new);
15244            }
15245        }
15246        Expr::ArraySubscript { target, index } => {
15247            rewrite_column_in_expr(target, old, new);
15248            rewrite_column_in_expr(index, old, new);
15249        }
15250        Expr::AnyAll { expr, array, .. } => {
15251            rewrite_column_in_expr(expr, old, new);
15252            rewrite_column_in_expr(array, old, new);
15253        }
15254        Expr::Case {
15255            operand,
15256            branches,
15257            else_branch,
15258        } => {
15259            if let Some(o) = operand {
15260                rewrite_column_in_expr(o, old, new);
15261            }
15262            for (w, t) in branches {
15263                rewrite_column_in_expr(w, old, new);
15264                rewrite_column_in_expr(t, old, new);
15265            }
15266            if let Some(e) = else_branch {
15267                rewrite_column_in_expr(e, old, new);
15268            }
15269        }
15270        // Stored predicate sources never contain subqueries —
15271        // CHECK / partial-index / runtime_default are all scalar.
15272        // If a future feature changes that, recurse here.
15273        Expr::ScalarSubquery(_) | Expr::Exists { .. } | Expr::InSubquery { .. } => {}
15274        Expr::Literal(_) | Expr::Placeholder(_) => {}
15275    }
15276}
15277
15278/// v7.16.0 — walks a parsed statement and replaces every
15279/// `Expr::Placeholder(N)` with the corresponding `params[N-1]`
15280/// re-encoded as an `Expr::Literal`. Used internally by
15281/// `Engine::execute_prepared` AND surfaced for the spg-embedded
15282/// WAL path (which needs the bind-final AST so replay sees a
15283/// simple-query-shaped statement, not a `$1`-shaped one). Errors
15284/// when a placeholder references an index past the params slice.
15285pub fn substitute_placeholders(stmt: &mut Statement, params: &[Value]) -> Result<(), EngineError> {
15286    match stmt {
15287        Statement::Select(s) => substitute_select(s, params)?,
15288        Statement::Insert(ins) => {
15289            for row in &mut ins.rows {
15290                for e in row {
15291                    substitute_expr(e, params)?;
15292                }
15293            }
15294            // ON CONFLICT DO UPDATE assignments / WHERE can carry
15295            // placeholders too (`… DO UPDATE SET reason = $2` —
15296            // mailrs embed round-12).
15297            if let Some(clause) = &mut ins.on_conflict
15298                && let spg_sql::ast::OnConflictAction::Update {
15299                    assignments,
15300                    where_,
15301                } = &mut clause.action
15302            {
15303                for (_, e) in assignments.iter_mut() {
15304                    substitute_expr(e, params)?;
15305                }
15306                if let Some(w) = where_ {
15307                    substitute_expr(w, params)?;
15308                }
15309            }
15310        }
15311        Statement::Update(u) => {
15312            for (_, e) in &mut u.assignments {
15313                substitute_expr(e, params)?;
15314            }
15315            if let Some(w) = &mut u.where_ {
15316                substitute_expr(w, params)?;
15317            }
15318        }
15319        Statement::Delete(d) => {
15320            if let Some(w) = &mut d.where_ {
15321                substitute_expr(w, params)?;
15322            }
15323        }
15324        Statement::Explain(e) => substitute_select(&mut e.inner, params)?,
15325        // Other statements (CREATE / BEGIN / SHOW / …) have no
15326        // expression slots; no walk needed.
15327        _ => {}
15328    }
15329    Ok(())
15330}
15331
15332/// v7.25.1 (mailrs round-18) — THE canonical mutable traversal of
15333/// every expression slot in a SelectStatement, including every
15334/// nested SelectStatement (CTE bodies, UNION peers, LATERAL derived
15335/// tables) and the JOIN ON conditions. Round-12 #7b and round-18
15336/// were both "a hand-rolled Select walker forgot one subtree";
15337/// every whole-statement rewrite pass (placeholders, clock) must go
15338/// through here so a new AST slot only needs adding once.
15339/// Expression-INTERNAL recursion (into subquery nodes inside an
15340/// Expr) stays the visitor's own responsibility.
15341pub(crate) fn walk_select_exprs_mut(
15342    s: &mut SelectStatement,
15343    f: &mut impl FnMut(&mut Expr) -> Result<(), EngineError>,
15344) -> Result<(), EngineError> {
15345    for cte in &mut s.ctes {
15346        walk_select_exprs_mut(&mut cte.body, f)?;
15347    }
15348    for item in &mut s.items {
15349        if let SelectItem::Expr { expr, .. } = item {
15350            f(expr)?;
15351        }
15352    }
15353    if let Some(from) = &mut s.from {
15354        if let Some(sub) = &mut from.primary.lateral_subquery {
15355            walk_select_exprs_mut(sub, f)?;
15356        }
15357        for j in &mut from.joins {
15358            if let Some(sub) = &mut j.table.lateral_subquery {
15359                walk_select_exprs_mut(sub, f)?;
15360            }
15361            if let Some(on) = &mut j.on {
15362                f(on)?;
15363            }
15364        }
15365    }
15366    if let Some(w) = &mut s.where_ {
15367        f(w)?;
15368    }
15369    if let Some(gs) = &mut s.group_by {
15370        for g in gs {
15371            f(g)?;
15372        }
15373    }
15374    if let Some(h) = &mut s.having {
15375        f(h)?;
15376    }
15377    for o in &mut s.order_by {
15378        f(&mut o.expr)?;
15379    }
15380    for (_, peer) in &mut s.unions {
15381        walk_select_exprs_mut(peer, f)?;
15382    }
15383    Ok(())
15384}
15385
15386fn substitute_select(s: &mut SelectStatement, params: &[Value]) -> Result<(), EngineError> {
15387    walk_select_exprs_mut(s, &mut |e| substitute_expr(e, params))?;
15388    // v7.25.1 — LIMIT/OFFSET placeholders inside CTE bodies and
15389    // UNION peers resolve through their own recursion (the walker
15390    // above only visits Expr slots), so handle them per nested
15391    // statement here.
15392    for cte in &mut s.ctes {
15393        resolve_limit_offset_placeholders(&mut cte.body, params)?;
15394    }
15395    for (_, peer) in &mut s.unions {
15396        resolve_limit_offset_placeholders(peer, params)?;
15397    }
15398    // v7.9.24 — LIMIT $N / OFFSET $N placeholder resolution.
15399    // mailrs H2. After this pass each LIMIT/OFFSET that was a
15400    // Placeholder is rewritten to Literal so the existing
15401    // `LimitExpr::as_literal` path consumes a concrete u32.
15402    if let Some(le) = s.limit {
15403        s.limit = Some(resolve_limit_placeholder(le, params)?);
15404    }
15405    if let Some(le) = s.offset {
15406        s.offset = Some(resolve_limit_placeholder(le, params)?);
15407    }
15408    Ok(())
15409}
15410
15411/// v7.25.1 — recursive LIMIT/OFFSET placeholder resolution for
15412/// nested statements (CTE bodies / UNION peers).
15413fn resolve_limit_offset_placeholders(
15414    s: &mut SelectStatement,
15415    params: &[Value],
15416) -> Result<(), EngineError> {
15417    if let Some(le) = s.limit {
15418        s.limit = Some(resolve_limit_placeholder(le, params)?);
15419    }
15420    if let Some(le) = s.offset {
15421        s.offset = Some(resolve_limit_placeholder(le, params)?);
15422    }
15423    for cte in &mut s.ctes {
15424        resolve_limit_offset_placeholders(&mut cte.body, params)?;
15425    }
15426    for (_, peer) in &mut s.unions {
15427        resolve_limit_offset_placeholders(peer, params)?;
15428    }
15429    Ok(())
15430}
15431
15432fn resolve_limit_placeholder(
15433    le: spg_sql::ast::LimitExpr,
15434    params: &[Value],
15435) -> Result<spg_sql::ast::LimitExpr, EngineError> {
15436    use spg_sql::ast::LimitExpr;
15437    match le {
15438        LimitExpr::Literal(_) => Ok(le),
15439        LimitExpr::Placeholder(n) => {
15440            let idx = usize::from(n).saturating_sub(1);
15441            let v = params.get(idx).ok_or_else(|| {
15442                EngineError::Eval(EvalError::PlaceholderOutOfRange {
15443                    n,
15444                    bound: u16::try_from(params.len()).unwrap_or(u16::MAX),
15445                })
15446            })?;
15447            let int = match v {
15448                Value::SmallInt(x) => Some(i64::from(*x)),
15449                Value::Int(x) => Some(i64::from(*x)),
15450                Value::BigInt(x) => Some(*x),
15451                _ => None,
15452            }
15453            .ok_or_else(|| {
15454                EngineError::Unsupported(alloc::format!(
15455                    "LIMIT/OFFSET ${n} bound to non-integer {v:?}"
15456                ))
15457            })?;
15458            if int < 0 {
15459                return Err(EngineError::Unsupported(alloc::format!(
15460                    "LIMIT/OFFSET ${n} bound to negative value {int}"
15461                )));
15462            }
15463            let bounded = u32::try_from(int).map_err(|_| {
15464                EngineError::Unsupported(alloc::format!(
15465                    "LIMIT/OFFSET ${n} value {int} exceeds u32 range"
15466                ))
15467            })?;
15468            Ok(LimitExpr::Literal(bounded))
15469        }
15470    }
15471}
15472
15473fn substitute_expr(e: &mut Expr, params: &[Value]) -> Result<(), EngineError> {
15474    if let Expr::Placeholder(n) = e {
15475        let idx = usize::from(*n).saturating_sub(1);
15476        let v = params.get(idx).ok_or_else(|| {
15477            EngineError::Eval(EvalError::PlaceholderOutOfRange {
15478                n: *n,
15479                bound: u16::try_from(params.len()).unwrap_or(u16::MAX),
15480            })
15481        })?;
15482        *e = Expr::Literal(value_to_literal(v.clone()));
15483        return Ok(());
15484    }
15485    match e {
15486        Expr::AggregateOrdered { call, order_by, .. } => {
15487            substitute_expr(call, params)?;
15488            for o in order_by.iter_mut() {
15489                substitute_expr(&mut o.expr, params)?;
15490            }
15491        }
15492        Expr::Binary { lhs, rhs, .. } => {
15493            substitute_expr(lhs, params)?;
15494            substitute_expr(rhs, params)?;
15495        }
15496        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
15497            substitute_expr(expr, params)?;
15498        }
15499        Expr::FunctionCall { args, .. } => {
15500            for a in args {
15501                substitute_expr(a, params)?;
15502            }
15503        }
15504        Expr::Like { expr, pattern, .. } => {
15505            substitute_expr(expr, params)?;
15506            substitute_expr(pattern, params)?;
15507        }
15508        Expr::Extract { source, .. } => substitute_expr(source, params)?,
15509        Expr::ScalarSubquery(s) => substitute_select(s, params)?,
15510        Expr::Exists { subquery, .. } => substitute_select(subquery, params)?,
15511        Expr::InSubquery { expr, subquery, .. } => {
15512            substitute_expr(expr, params)?;
15513            substitute_select(subquery, params)?;
15514        }
15515        Expr::WindowFunction {
15516            args,
15517            partition_by,
15518            order_by,
15519            ..
15520        } => {
15521            for a in args {
15522                substitute_expr(a, params)?;
15523            }
15524            for p in partition_by {
15525                substitute_expr(p, params)?;
15526            }
15527            for (e, _, _) in order_by {
15528                substitute_expr(e, params)?;
15529            }
15530        }
15531        Expr::Literal(_) | Expr::Column(_) => {}
15532        // Already handled above.
15533        Expr::Placeholder(_) => unreachable!("Placeholder handled at top of fn"),
15534        Expr::Array(items) => {
15535            for elem in items {
15536                substitute_expr(elem, params)?;
15537            }
15538        }
15539        Expr::ArraySubscript { target, index } => {
15540            substitute_expr(target, params)?;
15541            substitute_expr(index, params)?;
15542        }
15543        Expr::AnyAll { expr, array, .. } => {
15544            substitute_expr(expr, params)?;
15545            substitute_expr(array, params)?;
15546        }
15547        Expr::Case {
15548            operand,
15549            branches,
15550            else_branch,
15551        } => {
15552            if let Some(o) = operand {
15553                substitute_expr(o, params)?;
15554            }
15555            for (w, t) in branches {
15556                substitute_expr(w, params)?;
15557                substitute_expr(t, params)?;
15558            }
15559            if let Some(e) = else_branch {
15560                substitute_expr(e, params)?;
15561            }
15562        }
15563    }
15564    Ok(())
15565}
15566
15567/// v6.1.1 — convert a runtime `Value` into the closest matching
15568/// `Literal` for the substitute walker. Lossless for the simple
15569/// scalars (Int / Float / Text / Bool); Numeric / Date / Timestamp
15570/// / Json / Interval render as their canonical text form so the
15571/// downstream coerce_value can re-parse against the target column
15572/// type. SQ8 / HalfVector cells are NOT expected as bind params;
15573/// pgwire's Bind decodes vector params to the f32 representation
15574/// before they reach this helper.
15575/// v6.2.0 — total ordering on `Value`s used by ANALYZE to sort a
15576/// column's non-NULL sample before histogram building. Cross-type
15577/// pairs (Int vs Float, Date vs Timestamp, …) compare via the
15578/// same widening the eval-side `compare` operator uses; everything
15579/// else (the genuinely-incompatible pairs) falls back to ordering
15580/// by canonical string form so the sort is still total + stable.
15581/// Vector / SQ8 / Half / Json / Numeric / Interval values reach
15582/// here only via the string-fallback path because vector columns
15583/// are filtered out upstream.
15584fn sort_values_for_histogram(a: &Value, b: &Value) -> core::cmp::Ordering {
15585    use core::cmp::Ordering;
15586    match (a, b) {
15587        (Value::SmallInt(a), Value::SmallInt(b)) => a.cmp(b),
15588        (Value::Int(a), Value::Int(b)) => a.cmp(b),
15589        (Value::BigInt(a), Value::BigInt(b)) => a.cmp(b),
15590        (Value::SmallInt(a), Value::Int(b)) => i32::from(*a).cmp(b),
15591        (Value::Int(a), Value::SmallInt(b)) => a.cmp(&i32::from(*b)),
15592        (Value::Int(a), Value::BigInt(b)) => i64::from(*a).cmp(b),
15593        (Value::BigInt(a), Value::Int(b)) => a.cmp(&i64::from(*b)),
15594        (Value::SmallInt(a), Value::BigInt(b)) => i64::from(*a).cmp(b),
15595        (Value::BigInt(a), Value::SmallInt(b)) => a.cmp(&i64::from(*b)),
15596        (Value::Float(a), Value::Float(b)) => a.partial_cmp(b).unwrap_or(Ordering::Equal),
15597        (Value::Text(a), Value::Text(b)) | (Value::Json(a), Value::Json(b)) => a.cmp(b),
15598        (Value::Bool(a), Value::Bool(b)) => a.cmp(b),
15599        (Value::Date(a), Value::Date(b)) => a.cmp(b),
15600        (Value::Timestamp(a), Value::Timestamp(b)) => a.cmp(b),
15601        // Mixed numeric/float — widen to f64 and compare.
15602        (Value::SmallInt(n), Value::Float(x)) => {
15603            (f64::from(*n)).partial_cmp(x).unwrap_or(Ordering::Equal)
15604        }
15605        (Value::Float(x), Value::SmallInt(n)) => {
15606            x.partial_cmp(&f64::from(*n)).unwrap_or(Ordering::Equal)
15607        }
15608        (Value::Int(n), Value::Float(x)) => {
15609            (f64::from(*n)).partial_cmp(x).unwrap_or(Ordering::Equal)
15610        }
15611        (Value::Float(x), Value::Int(n)) => {
15612            x.partial_cmp(&f64::from(*n)).unwrap_or(Ordering::Equal)
15613        }
15614        (Value::BigInt(n), Value::Float(x)) => {
15615            #[allow(clippy::cast_precision_loss)]
15616            let nf = *n as f64;
15617            nf.partial_cmp(x).unwrap_or(Ordering::Equal)
15618        }
15619        (Value::Float(x), Value::BigInt(n)) => {
15620            #[allow(clippy::cast_precision_loss)]
15621            let nf = *n as f64;
15622            x.partial_cmp(&nf).unwrap_or(Ordering::Equal)
15623        }
15624        // Cross-type fallback: lexicographic on canonical form.
15625        // Total + stable so the sort is well-defined.
15626        _ => canonical_value_repr(a).cmp(&canonical_value_repr(b)),
15627    }
15628}
15629
15630/// v6.2.0 — render the histogram bounds list as a `[v0, v1, ...]`
15631/// string for the `spg_statistic.histogram_bounds` column. Values
15632/// containing `,` or `[` / `]` are JSON-style escaped so the
15633/// rendering round-trips through a future parser; v6.2.0 only
15634/// uses the rendered form for human consumption, so the escaping
15635/// is conservative.
15636fn render_histogram_bounds(bounds: &[alloc::string::String]) -> alloc::string::String {
15637    let mut out = alloc::string::String::with_capacity(bounds.len() * 8 + 2);
15638    out.push('[');
15639    for (i, b) in bounds.iter().enumerate() {
15640        if i > 0 {
15641            out.push_str(", ");
15642        }
15643        let needs_quote = b.contains([',', '[', ']', '"']) || b.is_empty();
15644        if needs_quote {
15645            out.push('"');
15646            for ch in b.chars() {
15647                if ch == '"' || ch == '\\' {
15648                    out.push('\\');
15649                }
15650                out.push(ch);
15651            }
15652            out.push('"');
15653        } else {
15654            out.push_str(b);
15655        }
15656    }
15657    out.push(']');
15658    out
15659}
15660
15661/// v6.2.0 — canonical textual form of a `Value` for histogram
15662/// bound storage. Strings used by ANALYZE for sort + bound output.
15663/// INT / BIGINT → decimal; FLOAT → shortest-round-trip via
15664/// `{:?}`; TEXT pass-through; BOOL → `t` / `f`; DATE / TIMESTAMP →
15665/// the same form `format_date` / `format_timestamp` produce for
15666/// SQL Display. Vector / SQ8 / Half / Json / Numeric / Interval
15667/// reach this only via a non-Vector column (vector columns are
15668/// skipped upstream); they fall back to a Debug-derived form so
15669/// stats still serialise without crashing.
15670pub(crate) fn canonical_value_repr(v: &Value) -> alloc::string::String {
15671    match v {
15672        Value::Null => "NULL".to_string(),
15673        Value::SmallInt(n) => alloc::format!("{n}"),
15674        Value::Int(n) => alloc::format!("{n}"),
15675        Value::BigInt(n) => alloc::format!("{n}"),
15676        Value::Float(x) => alloc::format!("{x:?}"),
15677        Value::Text(s) | Value::Json(s) => s.clone(),
15678        Value::Bool(b) => if *b { "t" } else { "f" }.to_string(),
15679        Value::Date(d) => eval::format_date(*d),
15680        Value::Timestamp(t) => eval::format_timestamp(*t),
15681        // v7.17.0 Phase 3.P0-32 — PG TIME canonical text form.
15682        Value::Time(us) => eval::format_time(*us),
15683        // v7.17.0 Phase 3.P0-33 — MySQL YEAR 4-digit zero-padded.
15684        Value::Year(y) => alloc::format!("{y:04}"),
15685        // v7.17.0 Phase 3.P0-34 — PG TIMETZ canonical text form.
15686        Value::TimeTz { us, offset_secs } => eval::format_timetz(*us, *offset_secs),
15687        // v7.17.0 Phase 3.P0-35 — PG MONEY canonical en_US text form.
15688        Value::Money(c) => eval::format_money(*c),
15689        // v7.17.0 Phase 3.P0-38 — PG range canonical text form.
15690        v @ Value::Range { .. } => format_range_str(v),
15691        // v7.17.0 Phase 3.P0-39 — PG hstore canonical text form.
15692        Value::Hstore(pairs) => format_hstore_str(pairs),
15693        // v7.17.0 Phase 3.P0-40 — 2D array canonical text form.
15694        Value::IntArray2D(rows) => format_int_2d_text(rows),
15695        Value::BigIntArray2D(rows) => format_bigint_2d_text(rows),
15696        Value::TextArray2D(rows) => format_text_2d_text(rows),
15697        Value::Interval { months, micros } => eval::format_interval(*months, *micros),
15698        Value::Numeric { scaled, scale } => eval::format_numeric(*scaled, *scale),
15699        Value::Vector(_) | Value::Sq8Vector(_) | Value::HalfVector(_) => {
15700            // Unreachable in practice (vector columns are filtered
15701            // out before this). Defensive fallback so a future
15702            // vector-stats path doesn't crash.
15703            alloc::format!("{v:?}")
15704        }
15705        // v7.5.0 — Value is #[non_exhaustive] for downstream
15706        // forward-compat. Future variants fall through to Debug
15707        // form here (same shape as the vector fallback above).
15708        _ => alloc::format!("{v:?}"),
15709    }
15710}
15711
15712/// v6.2.0 — true for engine-managed catalog tables that the bare
15713/// `ANALYZE` (no target) should skip. v6.2.0 has no internal
15714/// tables yet (publications / subscriptions / users / statistics
15715/// all live as engine fields, not catalog tables), so this is a
15716/// reserved future-proofing hook — every existing user table is
15717/// analysed.
15718const fn is_internal_table_name(_name: &str) -> bool {
15719    false
15720}
15721
15722fn value_to_literal(v: Value) -> Literal {
15723    match v {
15724        Value::Null => Literal::Null,
15725        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
15726        Value::Int(n) => Literal::Integer(i64::from(n)),
15727        Value::BigInt(n) => Literal::Integer(n),
15728        Value::Float(x) => Literal::Float(x),
15729        Value::Text(s) | Value::Json(s) => Literal::String(s),
15730        Value::Bool(b) => Literal::Bool(b),
15731        Value::Vector(v) => Literal::Vector(v),
15732        Value::Numeric { scaled, scale } => Literal::String(eval::format_numeric(scaled, scale)),
15733        Value::Date(d) => Literal::String(eval::format_date(d)),
15734        Value::Timestamp(t) => Literal::String(eval::format_timestamp(t)),
15735        // v7.17.0 Phase 3.P0-69 — UUID round-trips via canonical
15736        // hyphenated text. Without this arm the fallback below
15737        // renders `Debug` form ("Uuid([85, …])") which the
15738        // engine's Text → Uuid coerce can't parse, breaking
15739        // prepared-bind round-trip from the spg-sqlx adapter.
15740        Value::Uuid(b) => Literal::String(spg_storage::format_uuid(&b)),
15741        // v7.16.0 — BYTEA round-trip for the spg-sqlx Bind path.
15742        // PG-canonical text rep is `\x` + lowercase hex; the
15743        // engine's coerce_value already accepts that on the
15744        // text → bytea direction.
15745        Value::Bytes(b) => Literal::String(eval::format_bytea_hex(&b)),
15746        // Arrays ride the AST natively (mailrs embed round-12) —
15747        // the prior `{a,b,c}` text form only worked where a column
15748        // type drove the re-parse; `= ANY($1)` has no column
15749        // context and saw a bare Text value.
15750        Value::TextArray(items) => Literal::TextArray(items),
15751        Value::IntArray(items) => Literal::IntArray(items),
15752        Value::BigIntArray(items) => Literal::BigIntArray(items),
15753        Value::Interval { months, micros } => Literal::Interval {
15754            months,
15755            micros,
15756            text: eval::format_interval(months, micros),
15757        },
15758        // SQ8 / halfvec cells dequantise to f32 before reaching the
15759        // substitute walker; pgwire's Bind path handles that.
15760        Value::Sq8Vector(q) => Literal::Vector(spg_storage::quantize::dequantize(&q)),
15761        Value::HalfVector(h) => Literal::Vector(h.to_f32_vec()),
15762        // v7.5.0 — Value is #[non_exhaustive]; future variants
15763        // render as Debug-form String literal until explicit
15764        // mapping is added.
15765        v => Literal::String(alloc::format!("{v:?}")),
15766    }
15767}
15768
15769fn rewrite_clock_calls(stmt: &mut Statement, now_micros: Option<i64>) {
15770    let Some(now) = now_micros else {
15771        return;
15772    };
15773    match stmt {
15774        Statement::Select(s) => rewrite_select_clock(s, now),
15775        Statement::Insert(ins) => {
15776            for row in &mut ins.rows {
15777                for e in row {
15778                    rewrite_expr_clock(e, now);
15779                }
15780            }
15781            // `ON CONFLICT … DO UPDATE SET created_at = NOW()` —
15782            // the upsert assignments carry clock calls too (mailrs
15783            // embed round-12).
15784            if let Some(clause) = &mut ins.on_conflict
15785                && let spg_sql::ast::OnConflictAction::Update {
15786                    assignments,
15787                    where_,
15788                } = &mut clause.action
15789            {
15790                for (_, e) in assignments.iter_mut() {
15791                    rewrite_expr_clock(e, now);
15792                }
15793                if let Some(w) = where_ {
15794                    rewrite_expr_clock(w, now);
15795                }
15796            }
15797        }
15798        // `UPDATE … SET seen_at = NOW() WHERE …` / `DELETE … WHERE
15799        // ts < NOW()` (mailrs embed round-12 — previously only
15800        // SELECT / INSERT-rows were walked).
15801        Statement::Update(u) => {
15802            for (_, e) in &mut u.assignments {
15803                rewrite_expr_clock(e, now);
15804            }
15805            if let Some(w) = &mut u.where_ {
15806                rewrite_expr_clock(w, now);
15807            }
15808        }
15809        Statement::Delete(d) => {
15810            if let Some(w) = &mut d.where_ {
15811                rewrite_expr_clock(w, now);
15812            }
15813        }
15814        _ => {}
15815    }
15816}
15817
15818fn rewrite_select_clock(s: &mut SelectStatement, now: i64) {
15819    // v7.25.1 (round-18) — shared traversal: CTE bodies, LATERAL
15820    // subqueries, JOIN ON, and UNION peers all get the clock
15821    // rewrite (NOW() inside a CTE previously survived to eval as
15822    // "unknown function `now`").
15823    let _ = walk_select_exprs_mut(s, &mut |e| {
15824        rewrite_expr_clock(e, now);
15825        Ok(())
15826    });
15827}
15828
15829/// v3.0.3 hot path: every recursion lands in exactly one `match` arm.
15830/// Literal / Column-with-qualifier (the dominant cases on a typical
15831/// AST) take a single pattern dispatch and exit. The clock-rewrite
15832/// targets (zero-arg `NOW` / `CURRENT_TIMESTAMP` / `CURRENT_DATE`
15833/// functions, and bare `CURRENT_TIMESTAMP` / `CURRENT_DATE` column
15834/// refs) sit on their own arms with match guards so the fall-through
15835/// to the recursive arms is unambiguous.
15836fn rewrite_expr_clock(e: &mut Expr, now: i64) {
15837    // Fast-path test on the no-recursion shapes first. We can't fold
15838    // them into the big match below because they need to *replace* `e`
15839    // outright; the recursive arms below match on its sub-fields.
15840    if let Some(replacement) = clock_replacement_for(e, now) {
15841        *e = replacement;
15842        return;
15843    }
15844    match e {
15845        Expr::AggregateOrdered { call, order_by, .. } => {
15846            rewrite_expr_clock(call, now);
15847            for o in order_by.iter_mut() {
15848                rewrite_expr_clock(&mut o.expr, now);
15849            }
15850        }
15851        Expr::Binary { lhs, rhs, .. } => {
15852            rewrite_expr_clock(lhs, now);
15853            rewrite_expr_clock(rhs, now);
15854        }
15855        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
15856            rewrite_expr_clock(expr, now);
15857        }
15858        Expr::FunctionCall { args, .. } => {
15859            for a in args {
15860                rewrite_expr_clock(a, now);
15861            }
15862        }
15863        Expr::Like { expr, pattern, .. } => {
15864            rewrite_expr_clock(expr, now);
15865            rewrite_expr_clock(pattern, now);
15866        }
15867        Expr::Extract { source, .. } => rewrite_expr_clock(source, now),
15868        // v4.10 subquery nodes — recurse into the inner SELECT's
15869        // expression slots so e.g. SELECT NOW() in a scalar
15870        // subquery picks up the same instant as the outer query.
15871        Expr::ScalarSubquery(s) => rewrite_select_clock(s, now),
15872        Expr::Exists { subquery, .. } => rewrite_select_clock(subquery, now),
15873        Expr::InSubquery { expr, subquery, .. } => {
15874            rewrite_expr_clock(expr, now);
15875            rewrite_select_clock(subquery, now);
15876        }
15877        // v4.12 window functions — args + PARTITION BY + ORDER BY
15878        // may all reference clock literals.
15879        Expr::WindowFunction {
15880            args,
15881            partition_by,
15882            order_by,
15883            ..
15884        } => {
15885            for a in args {
15886                rewrite_expr_clock(a, now);
15887            }
15888            for p in partition_by {
15889                rewrite_expr_clock(p, now);
15890            }
15891            for (e, _, _) in order_by {
15892                rewrite_expr_clock(e, now);
15893            }
15894        }
15895        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => {}
15896        Expr::Array(items) => {
15897            for elem in items {
15898                rewrite_expr_clock(elem, now);
15899            }
15900        }
15901        Expr::ArraySubscript { target, index } => {
15902            rewrite_expr_clock(target, now);
15903            rewrite_expr_clock(index, now);
15904        }
15905        Expr::AnyAll { expr, array, .. } => {
15906            rewrite_expr_clock(expr, now);
15907            rewrite_expr_clock(array, now);
15908        }
15909        Expr::Case {
15910            operand,
15911            branches,
15912            else_branch,
15913        } => {
15914            if let Some(o) = operand {
15915                rewrite_expr_clock(o, now);
15916            }
15917            for (w, t) in branches {
15918                rewrite_expr_clock(w, now);
15919                rewrite_expr_clock(t, now);
15920            }
15921            if let Some(e) = else_branch {
15922                rewrite_expr_clock(e, now);
15923            }
15924        }
15925    }
15926}
15927
15928/// Returns `Some(Expr)` when `e` is one of the clock-call shapes that
15929/// must be rewritten; otherwise `None` so the caller falls through to
15930/// the recursive walk. Identifies both function-call forms (`NOW()` /
15931/// `CURRENT_TIMESTAMP()` / `CURRENT_DATE()`) and bare-identifier forms
15932/// (`CURRENT_TIMESTAMP` / `CURRENT_DATE` as unqualified column refs,
15933/// which is how PG accepts them without parens).
15934fn clock_replacement_for(e: &Expr, now: i64) -> Option<Expr> {
15935    let (kind, name) = match e {
15936        Expr::FunctionCall { name, args } if args.is_empty() => (ClockSite::Fn, name.as_str()),
15937        Expr::Column(c) if c.qualifier.is_none() => (ClockSite::BareIdent, c.name.as_str()),
15938        _ => return None,
15939    };
15940    // ASCII case-insensitive name match. Each entry decides what
15941    // synthetic literal the call expands to.
15942    //
15943    // v7.17.0 Phase 3.P0-29 — `unix_timestamp` (no args) joins this
15944    // table as MySQL's epoch-seconds equivalent of `now()`. Folded
15945    // to a BigInt literal here so apply_function never needs a
15946    // clock dependency.
15947    enum ClockShape {
15948        Timestamp,
15949        Date,
15950        UnixSeconds,
15951    }
15952    let shape = match name.len() {
15953        3 if kind == ClockSite::Fn && name.eq_ignore_ascii_case("now") => {
15954            Some(ClockShape::Timestamp)
15955        }
15956        12 if name.eq_ignore_ascii_case("current_date") => Some(ClockShape::Date),
15957        14 if kind == ClockSite::Fn && name.eq_ignore_ascii_case("unix_timestamp") => {
15958            Some(ClockShape::UnixSeconds)
15959        }
15960        17 if name.eq_ignore_ascii_case("current_timestamp") => Some(ClockShape::Timestamp),
15961        _ => None,
15962    };
15963    let shape = shape?;
15964    let payload = match shape {
15965        ClockShape::Timestamp => now,
15966        ClockShape::Date => now.div_euclid(86_400_000_000),
15967        ClockShape::UnixSeconds => now.div_euclid(1_000_000),
15968    };
15969    let target = match shape {
15970        ClockShape::Timestamp => spg_sql::ast::CastTarget::Timestamp,
15971        ClockShape::Date => spg_sql::ast::CastTarget::Date,
15972        ClockShape::UnixSeconds => spg_sql::ast::CastTarget::BigInt,
15973    };
15974    Some(Expr::Cast {
15975        expr: alloc::boxed::Box::new(Expr::Literal(spg_sql::ast::Literal::Integer(payload))),
15976        target,
15977    })
15978}
15979
15980#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15981enum ClockSite {
15982    Fn,
15983    BareIdent,
15984}
15985
15986/// `ORDER BY <integer>` references the N-th SELECT item (1-based).
15987/// Swap the integer literal for the matching item's expression so the
15988/// executor doesn't need a special-case branch. Recurses into UNION
15989/// peers because each peer keeps its own SELECT list.
15990/// v6.4.1 — expand `GROUP BY ALL` to every non-aggregate SELECT-list
15991/// item. Mirrors DuckDB / PG 19 semantics. Wildcards (`SELECT * …`)
15992/// are NOT expanded by GROUP BY ALL (PG 19 leaves the wildcard intact
15993/// and groups by whatever explicit non-aggregates remain — none in
15994/// the wildcard-only case, which still works for non-aggregate
15995/// queries).
15996fn expand_group_by_all(s: &mut SelectStatement) {
15997    if !s.group_by_all {
15998        for (_, peer) in &mut s.unions {
15999            expand_group_by_all(peer);
16000        }
16001        return;
16002    }
16003    let mut groups: Vec<Expr> = Vec::new();
16004    for item in &s.items {
16005        if let SelectItem::Expr { expr, .. } = item
16006            && !aggregate::contains_aggregate(expr)
16007        {
16008            groups.push(expr.clone());
16009        }
16010    }
16011    s.group_by = Some(groups);
16012    s.group_by_all = false;
16013    for (_, peer) in &mut s.unions {
16014        expand_group_by_all(peer);
16015    }
16016}
16017
16018fn resolve_order_by_position(s: &mut SelectStatement) {
16019    // v6.4.0 — iterate every ORDER BY key. Position references
16020    // (`ORDER BY 2`) bind to the 1-based projection index;
16021    // identifier references that match a SELECT-list alias bind to
16022    // the projected expression (Step 4 of L3a).
16023    for order in &mut s.order_by {
16024        match &order.expr {
16025            Expr::Literal(Literal::Integer(n)) if *n >= 1 => {
16026                if let Ok(idx_one_based) = usize::try_from(*n) {
16027                    let idx = idx_one_based - 1;
16028                    if idx < s.items.len()
16029                        && let SelectItem::Expr { expr, .. } = &s.items[idx]
16030                    {
16031                        order.expr = expr.clone();
16032                    }
16033                }
16034            }
16035            Expr::Column(c) if c.qualifier.is_none() => {
16036                // Alias-in-ORDER-BY lookup.
16037                for item in &s.items {
16038                    if let SelectItem::Expr {
16039                        expr,
16040                        alias: Some(a),
16041                    } = item
16042                        && a == &c.name
16043                    {
16044                        order.expr = expr.clone();
16045                        break;
16046                    }
16047                }
16048            }
16049            _ => {}
16050        }
16051    }
16052    for (_, peer) in &mut s.unions {
16053        resolve_order_by_position(peer);
16054    }
16055}
16056
16057/// Sort `tagged` by `f64` key, reversing the comparator under DESC.
16058/// Used by the UNION ORDER BY path; per-block paths inline the same
16059/// comparator because they already hold `&OrderBy` directly.
16060/// v3.1.1: partial-sort helper. When `keep` (= offset + limit) is
16061/// strictly less than `tagged.len()`, run `select_nth_unstable_by` to
16062/// partition the prefix in O(n), then sort just that prefix in O(k
16063/// log k). Total O(n + k log k), vs O(n log n) for a full sort. The
16064/// caller decides what `keep` is; passing `None` (no LIMIT) keeps the
16065/// full-sort behaviour.
16066///
16067/// `tagged` holds `(Option<f64>, Row)` (the SELECT path) — `None` keys
16068/// sort last in ascending order, mirroring NULL-sorts-last in SQL.
16069fn partial_sort_tagged(tagged: &mut Vec<(Vec<f64>, Row)>, keep: Option<usize>, descs: &[bool]) {
16070    let cmp = |a: &(Vec<f64>, Row), b: &(Vec<f64>, Row)| cmp_multi_key(&a.0, &b.0, descs);
16071    match keep {
16072        Some(k) if k < tagged.len() && k > 0 => {
16073            let pivot = k - 1;
16074            tagged.select_nth_unstable_by(pivot, cmp);
16075            tagged[..k].sort_by(cmp);
16076            tagged.truncate(k);
16077        }
16078        _ => {
16079            tagged.sort_by(cmp);
16080        }
16081    }
16082}
16083
16084fn sort_by_keys(tagged: &mut [(Vec<f64>, Row)], descs: &[bool]) {
16085    tagged.sort_by(|a, b| cmp_multi_key(&a.0, &b.0, descs));
16086}
16087
16088/// v6.4.0 — multi-key ORDER BY comparator. Each key's per-key DESC
16089/// flag is honored independently. NULL is encoded as `f64::INFINITY`
16090/// so it sorts last in ASC and first in DESC (matches PG default).
16091fn cmp_multi_key(a: &[f64], b: &[f64], descs: &[bool]) -> core::cmp::Ordering {
16092    use core::cmp::Ordering;
16093    for (i, (ka, kb)) in a.iter().zip(b.iter()).enumerate() {
16094        let ord = ka.partial_cmp(kb).unwrap_or(Ordering::Equal);
16095        let ord = if descs.get(i).copied().unwrap_or(false) {
16096            ord.reverse()
16097        } else {
16098            ord
16099        };
16100        if ord != Ordering::Equal {
16101            return ord;
16102        }
16103    }
16104    Ordering::Equal
16105}
16106
16107/// v6.4.0 — eval every ORDER BY expression for a row and pack the
16108/// resulting keys into a `Vec<f64>`. NULL → `f64::INFINITY`.
16109fn build_order_keys(
16110    order_by: &[OrderBy],
16111    row: &Row,
16112    ctx: &EvalContext,
16113) -> Result<Vec<f64>, EngineError> {
16114    let mut keys = Vec::with_capacity(order_by.len());
16115    for o in order_by {
16116        let v = eval::eval_expr(&o.expr, row, ctx)?;
16117        // v7.24 (round-16 A) — explicit NULLS FIRST/LAST. The f64
16118        // packing sorts ascending THEN applies the per-key DESC
16119        // reverse, so a NULL must land at +INF exactly when the
16120        // effective placement agrees with the reverse direction:
16121        // nf == desc → +INF (ASC default last / DESC default
16122        // first), nf != desc → -INF (the explicit flips).
16123        if matches!(v, Value::Null) {
16124            let nf = o.nulls_first.unwrap_or(o.desc);
16125            keys.push(if nf == o.desc {
16126                f64::INFINITY
16127            } else {
16128                f64::NEG_INFINITY
16129            });
16130        } else {
16131            keys.push(value_to_order_key(&v)?);
16132        }
16133    }
16134    Ok(keys)
16135}
16136
16137/// Drop the first `offset` rows then truncate to `limit`. PG / `MySQL`
16138/// agree: OFFSET applies *after* ORDER BY but *before* LIMIT (so
16139/// `LIMIT 10 OFFSET 5` keeps rows 6..=15).
16140fn apply_offset_and_limit(rows: &mut Vec<Row>, offset: Option<u32>, limit: Option<u32>) {
16141    if let Some(off) = offset {
16142        let off = off as usize;
16143        if off >= rows.len() {
16144            rows.clear();
16145        } else {
16146            rows.drain(..off);
16147        }
16148    }
16149    if let Some(n) = limit {
16150        rows.truncate(n as usize);
16151    }
16152}
16153
16154/// v7.17.0 Phase 3.P0-49 — offset + limit applied to a tagged
16155/// `(order_keys, row)` sequence, with optional SQL:2008 `WITH
16156/// TIES` extension. When `with_ties` is set, the truncated tail
16157/// is extended through every subsequent row whose order keys
16158/// equal the last-kept row's keys (so a "top 3 by score" with
16159/// WITH TIES emits row 4 too when row 4 ties row 3 on `score`).
16160///
16161/// The order-key vector is the per-row sort key the caller already
16162/// computed via `build_order_keys`; equal-key detection therefore
16163/// matches the sort comparator exactly.
16164fn apply_offset_and_limit_tagged(
16165    tagged: &mut Vec<(Vec<f64>, Row)>,
16166    offset: Option<u32>,
16167    limit: Option<u32>,
16168    with_ties: bool,
16169) {
16170    if let Some(off) = offset {
16171        let off = off as usize;
16172        if off >= tagged.len() {
16173            tagged.clear();
16174        } else {
16175            tagged.drain(..off);
16176        }
16177    }
16178    if let Some(n) = limit {
16179        let n = n as usize;
16180        if with_ties && n > 0 && n < tagged.len() {
16181            let cutoff_key = tagged[n - 1].0.clone();
16182            let mut end = n;
16183            while end < tagged.len() && tagged[end].0 == cutoff_key {
16184                end += 1;
16185            }
16186            tagged.truncate(end);
16187        } else {
16188            tagged.truncate(n);
16189        }
16190    }
16191}
16192
16193/// v7.17.0 Phase 3.P0-49 — PG-canonical: `FETCH FIRST <n> ROWS
16194/// WITH TIES` requires an `ORDER BY`. Without one, there's no
16195/// way to identify "ties" deterministically, so PG errors at
16196/// plan time. SPG mirrors that surface so the same DDL / app
16197/// behaviour holds on cutover.
16198fn check_with_ties_requires_order_by(stmt: &SelectStatement) -> Result<(), EngineError> {
16199    if stmt.limit_with_ties && stmt.order_by.is_empty() {
16200        return Err(EngineError::Unsupported(alloc::string::String::from(
16201            "FETCH FIRST … ROWS WITH TIES requires an ORDER BY clause",
16202        )));
16203    }
16204    Ok(())
16205}
16206
16207/// v7.6.1 — resolve a parser-level `ForeignKeyConstraint` (column
16208/// names + parent table name) into the storage-layer shape (column
16209/// indices + same parent table). Validates everything the engine
16210/// needs to know about the FK at CREATE TABLE time:
16211///
16212///   - parent table exists (catalog lookup, unless self-referencing)
16213///   - parent columns exist on the parent table
16214///   - parent column list matches the local arity (defaults to the
16215///     parent's primary index column when omitted)
16216///   - parent columns are covered by a `BTree` UNIQUE-class index
16217///     (SPG's stand-in for `PRIMARY KEY`/`UNIQUE`) — required so
16218///     the v7.6.2 INSERT path can do an O(log n) parent lookup
16219///   - local columns exist on the table being created
16220fn resolve_foreign_key(
16221    local_table_name: &str,
16222    local_cols: &[ColumnSchema],
16223    fk: spg_sql::ast::ForeignKeyConstraint,
16224    catalog: &Catalog,
16225) -> Result<spg_storage::ForeignKeyConstraint, EngineError> {
16226    // Resolve local columns.
16227    let mut local_columns = Vec::with_capacity(fk.columns.len());
16228    for name in &fk.columns {
16229        let pos = local_cols
16230            .iter()
16231            .position(|c| c.name == *name)
16232            .ok_or_else(|| {
16233                EngineError::Unsupported(alloc::format!(
16234                    "FOREIGN KEY references unknown local column {name:?}"
16235                ))
16236            })?;
16237        local_columns.push(pos);
16238    }
16239    // Self-referencing FK: parent table is the one we're creating.
16240    // The parent column resolution uses the local column list since
16241    // the catalog doesn't have this table yet.
16242    let is_self_ref = fk.parent_table == local_table_name;
16243    let (parent_cols_for_lookup, parent_table_str): (&[ColumnSchema], &str) = if is_self_ref {
16244        (local_cols, local_table_name)
16245    } else {
16246        let parent_table = catalog.get(&fk.parent_table).ok_or_else(|| {
16247            EngineError::Storage(StorageError::TableNotFound {
16248                name: fk.parent_table.clone(),
16249            })
16250        })?;
16251        (
16252            parent_table.schema().columns.as_slice(),
16253            fk.parent_table.as_str(),
16254        )
16255    };
16256    // Resolve parent column names → positions. If the FK omitted the
16257    // parent column list, fall back to the parent's primary index
16258    // column (single-column only — composite default is rejected
16259    // because there's no unambiguous "PK" in SPG's index list).
16260    let parent_columns: Vec<usize> = if fk.parent_columns.is_empty() {
16261        if fk.columns.len() != 1 {
16262            return Err(EngineError::Unsupported(
16263                "composite FOREIGN KEY without explicit parent column list is not supported \
16264                 — list the parent columns explicitly"
16265                    .into(),
16266            ));
16267        }
16268        // Find a single BTree index on the parent and use its column.
16269        let pos = pick_pk_index_column(catalog, parent_table_str, is_self_ref, local_cols)
16270            .ok_or_else(|| {
16271                EngineError::Unsupported(alloc::format!(
16272                    "parent table {parent_table_str:?} has no PRIMARY-key / UNIQUE BTree index \
16273                     to default the FOREIGN KEY against"
16274                ))
16275            })?;
16276        alloc::vec![pos]
16277    } else {
16278        let mut out = Vec::with_capacity(fk.parent_columns.len());
16279        for name in &fk.parent_columns {
16280            let pos = parent_cols_for_lookup
16281                .iter()
16282                .position(|c| c.name == *name)
16283                .ok_or_else(|| {
16284                    EngineError::Unsupported(alloc::format!(
16285                        "FOREIGN KEY references unknown parent column \
16286                         {name:?} on table {parent_table_str:?}"
16287                    ))
16288                })?;
16289            out.push(pos);
16290        }
16291        out
16292    };
16293    if parent_columns.len() != local_columns.len() {
16294        return Err(EngineError::Unsupported(alloc::format!(
16295            "FOREIGN KEY arity mismatch: {} local columns vs {} parent columns",
16296            local_columns.len(),
16297            parent_columns.len()
16298        )));
16299    }
16300    // For non-self-referencing FKs, verify the parent column set is
16301    // covered by a BTree index. SPG doesn't have a `PRIMARY KEY`
16302    // declaration; the convention is "the parent column for FK
16303    // purposes must have a BTree index" — which the user creates via
16304    // `CREATE INDEX ... USING btree (col)` (the default). We accept
16305    // any single-column BTree index that covers a parent column;
16306    // composite parent column lists require an index whose `column_position`
16307    // matches the first parent column (multi-column BTree indices
16308    // are not in the v7.x roadmap).
16309    if !is_self_ref {
16310        let parent_table = catalog.get(&fk.parent_table).expect("checked above");
16311        let primary_parent_col = parent_columns[0];
16312        let has_btree = parent_table
16313            .schema()
16314            .columns
16315            .get(primary_parent_col)
16316            .is_some()
16317            && parent_table.indices().iter().any(|idx| {
16318                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
16319                    && idx.column_position == primary_parent_col
16320                    && idx.partial_predicate.is_none()
16321            });
16322        if !has_btree {
16323            return Err(EngineError::Unsupported(alloc::format!(
16324                "FOREIGN KEY parent column on {:?} is not covered by an unconditional BTree \
16325                 index — create one with `CREATE INDEX ... ON {} ({})` first",
16326                parent_table_str,
16327                parent_table_str,
16328                parent_table.schema().columns[primary_parent_col].name,
16329            )));
16330        }
16331    }
16332    let on_delete = fk_action_sql_to_storage(fk.on_delete);
16333    let on_update = fk_action_sql_to_storage(fk.on_update);
16334    Ok(spg_storage::ForeignKeyConstraint {
16335        name: fk.name,
16336        local_columns,
16337        parent_table: fk.parent_table,
16338        parent_columns,
16339        on_delete,
16340        on_update,
16341    })
16342}
16343
16344/// v7.6.1 — pick a sentinel "primary key" column from the parent
16345/// table when the FK didn't name parent columns. Picks the first
16346/// single-column unconditional BTree index — that's the closest
16347/// thing SPG has to a PRIMARY KEY today. Self-referencing FKs use
16348/// `local_cols` as the column source.
16349fn pick_pk_index_column(
16350    catalog: &Catalog,
16351    parent_name: &str,
16352    is_self_ref: bool,
16353    local_cols: &[ColumnSchema],
16354) -> Option<usize> {
16355    if is_self_ref {
16356        // Self-ref FK omitted parent columns: pick column 0 by
16357        // convention (no catalog entry yet). Engine will widen this
16358        // when v7.6.7 lands; v7.6.1 only handles the explicit form.
16359        let _ = local_cols;
16360        return Some(0);
16361    }
16362    let parent = catalog.get(parent_name)?;
16363    parent.indices().iter().find_map(|idx| {
16364        if matches!(idx.kind, spg_storage::IndexKind::BTree(_))
16365            && idx.partial_predicate.is_none()
16366            && idx.included_columns.is_empty()
16367            && idx.expression.is_none()
16368        {
16369            Some(idx.column_position)
16370        } else {
16371            None
16372        }
16373    })
16374}
16375
16376/// v7.9.8 / v7.9.10 — resolve the column positions that
16377/// identify a conflict for ON CONFLICT. Returns a Vec of
16378/// column positions (1 element for single-column form, N for
16379/// composite). When the user wrote bare `ON CONFLICT DO …`,
16380/// falls back to the table's first unconditional BTree index
16381/// (always single-column today).
16382/// Returns the conflict-key column positions plus whether the
16383/// matched constraint declares NULLS NOT DISTINCT (v7.29 — a NULL
16384/// in the key only rules out a conflict under the default
16385/// NULLS DISTINCT semantics).
16386fn resolve_on_conflict_columns(
16387    catalog: &Catalog,
16388    table_name: &str,
16389    target: &[String],
16390) -> Result<(Vec<usize>, bool), EngineError> {
16391    let table = catalog.get(table_name).ok_or_else(|| {
16392        EngineError::Storage(StorageError::TableNotFound {
16393            name: table_name.into(),
16394        })
16395    })?;
16396    if target.is_empty() {
16397        // v7.13.2 — mailrs round-6 S5 follow-up. Composite UNIQUE
16398        // constraints carry a multi-column tuple; the prior code
16399        // path picked only the leading column of the first BTree
16400        // index, which caused `ON CONFLICT DO NOTHING` to dedup
16401        // by leading column alone (3 rows with same group_id but
16402        // different permission collapsed to 1). PG semantics use
16403        // the full tuple. Prefer a UniquenessConstraint's full
16404        // column list when one exists; fall back to the leading
16405        // BTree column for legacy single-column UNIQUE.
16406        if let Some(uc) = table.schema().uniqueness_constraints.first() {
16407            return Ok((uc.columns.clone(), uc.nulls_not_distinct));
16408        }
16409        let pos = table
16410            .indices()
16411            .iter()
16412            .find_map(|idx| {
16413                if matches!(idx.kind, spg_storage::IndexKind::BTree(_))
16414                    && idx.partial_predicate.is_none()
16415                    && idx.included_columns.is_empty()
16416                    && idx.expression.is_none()
16417                {
16418                    Some(idx.column_position)
16419                } else {
16420                    None
16421                }
16422            })
16423            .ok_or_else(|| {
16424                EngineError::Unsupported(alloc::format!(
16425                    "ON CONFLICT without target requires a UNIQUE BTree index on {table_name:?}"
16426                ))
16427            })?;
16428        return Ok((alloc::vec![pos], false));
16429    }
16430    let mut out = Vec::with_capacity(target.len());
16431    for name in target {
16432        let pos = table
16433            .schema()
16434            .columns
16435            .iter()
16436            .position(|c| c.name == *name)
16437            .ok_or_else(|| {
16438                EngineError::Unsupported(alloc::format!(
16439                    "ON CONFLICT target column {name:?} not found on {table_name:?}"
16440                ))
16441            })?;
16442        out.push(pos);
16443    }
16444    // An explicit target matching a UNIQUE constraint inherits its
16445    // NULLS [NOT] DISTINCT declaration.
16446    let mut sorted = out.clone();
16447    sorted.sort_unstable();
16448    let nnd = table.schema().uniqueness_constraints.iter().any(|uc| {
16449        let mut u = uc.columns.clone();
16450        u.sort_unstable();
16451        u == sorted && uc.nulls_not_distinct
16452    });
16453    Ok((out, nnd))
16454}
16455
16456/// v7.9.8 — check whether the BTree index on `column_pos` of
16457/// `table_name` already has a row with this key.
16458fn on_conflict_key_exists(
16459    catalog: &Catalog,
16460    table_name: &str,
16461    column_pos: usize,
16462    key: &Value,
16463) -> bool {
16464    let Some(table) = catalog.get(table_name) else {
16465        return false;
16466    };
16467    let Some(idx_key) = spg_storage::IndexKey::from_value(key) else {
16468        return false;
16469    };
16470    table.indices().iter().any(|idx| {
16471        matches!(idx.kind, spg_storage::IndexKind::BTree(_))
16472            && idx.column_position == column_pos
16473            && idx.partial_predicate.is_none()
16474            && !idx.lookup_eq(&idx_key).is_empty()
16475    })
16476}
16477
16478/// v7.9.9 / v7.9.10 — look up an existing row's position by
16479/// matching all `column_positions` against the incoming `key`
16480/// tuple. Single-column shape (one column) reduces to the
16481/// canonical PK lookup; composite shapes scan linearly until
16482/// every position matches.
16483fn lookup_row_position_by_keys(
16484    catalog: &Catalog,
16485    table_name: &str,
16486    column_positions: &[usize],
16487    key: &[&Value],
16488) -> Option<usize> {
16489    let table = catalog.get(table_name)?;
16490    table.rows().iter().position(|r| {
16491        column_positions
16492            .iter()
16493            .enumerate()
16494            .all(|(i, &pos)| r.values.get(pos) == Some(key[i]))
16495    })
16496}
16497
16498/// v7.9.10 — does the table already contain a row whose
16499/// `column_positions` tuple equals `key`? Single-column shape
16500/// uses the existing BTree fast path; composite shapes fall
16501/// back to a row scan.
16502fn on_conflict_keys_exist(
16503    catalog: &Catalog,
16504    table_name: &str,
16505    column_positions: &[usize],
16506    key: &[&Value],
16507) -> bool {
16508    if column_positions.len() == 1 {
16509        return on_conflict_key_exists(catalog, table_name, column_positions[0], key[0]);
16510    }
16511    let Some(table) = catalog.get(table_name) else {
16512        return false;
16513    };
16514    table.rows().iter().any(|r| {
16515        column_positions
16516            .iter()
16517            .enumerate()
16518            .all(|(i, &pos)| r.values.get(pos) == Some(key[i]))
16519    })
16520}
16521
16522/// v7.9.9 — apply ON CONFLICT DO UPDATE SET assignments to an
16523/// existing row.
16524///
16525/// `incoming` is the rejected INSERT row (used to resolve
16526/// `EXCLUDED.col` references in the assignment exprs);
16527/// `target_pos` is the position of the existing row in the table.
16528/// Each assignment substitutes `EXCLUDED.col` with the matching
16529/// incoming value, evaluates the resulting expression against
16530/// the existing row, and writes the new value into the
16531/// corresponding column of the returned `Vec<Value>`. If
16532/// `where_` evaluates falsy, returns Ok(None) — PG behaviour:
16533/// the conflicting row is silently kept unchanged.
16534fn apply_on_conflict_assignments(
16535    catalog: &Catalog,
16536    table_name: &str,
16537    target_pos: usize,
16538    incoming: &[Value],
16539    assignments: &[(String, Expr)],
16540    where_: Option<&Expr>,
16541) -> Result<Option<Vec<Value>>, EngineError> {
16542    let table = catalog.get(table_name).ok_or_else(|| {
16543        EngineError::Storage(StorageError::TableNotFound {
16544            name: table_name.into(),
16545        })
16546    })?;
16547    let schema_cols = table.schema().columns.clone();
16548    let existing = table
16549        .rows()
16550        .get(target_pos)
16551        .ok_or_else(|| {
16552            EngineError::Unsupported(alloc::format!(
16553                "ON CONFLICT DO UPDATE: row position {target_pos} out of bounds on {table_name:?}"
16554            ))
16555        })?
16556        .clone();
16557    let ctx = eval::EvalContext::new(&schema_cols, Some(table_name));
16558    // Optional WHERE filter on the conflict row.
16559    if let Some(w) = where_ {
16560        let pred = w.clone();
16561        let pred = substitute_excluded_refs(pred, &schema_cols, incoming);
16562        let v = eval::eval_expr(&pred, &existing, &ctx)?;
16563        if !matches!(v, Value::Bool(true)) {
16564            return Ok(None);
16565        }
16566    }
16567    let mut new_values = existing.values.clone();
16568    for (col_name, expr) in assignments {
16569        let target_idx = schema_cols
16570            .iter()
16571            .position(|c| c.name == *col_name)
16572            .ok_or_else(|| {
16573                EngineError::Eval(EvalError::ColumnNotFound {
16574                    name: col_name.clone(),
16575                })
16576            })?;
16577        let sub = substitute_excluded_refs(expr.clone(), &schema_cols, incoming);
16578        let v = eval::eval_expr(&sub, &existing, &ctx)?;
16579        let coerced = coerce_value(v, schema_cols[target_idx].ty, col_name, target_idx)?;
16580        check_unsigned_range(&coerced, &schema_cols[target_idx], target_idx)?;
16581        new_values[target_idx] = coerced;
16582    }
16583    Ok(Some(new_values))
16584}
16585
16586/// v7.9.9 — walk an `Expr` tree replacing any `Column { qualifier:
16587/// "EXCLUDED", name }` reference with a `Literal` of the matching
16588/// value from the incoming-row vec. Resolution against the
16589/// child-table column list (by name).
16590fn substitute_excluded_refs(expr: Expr, schema_cols: &[ColumnSchema], incoming: &[Value]) -> Expr {
16591    use spg_sql::ast::ColumnName;
16592    match expr {
16593        Expr::Column(ColumnName { qualifier, name })
16594            if qualifier
16595                .as_deref()
16596                .is_some_and(|q| q.eq_ignore_ascii_case("excluded")) =>
16597        {
16598            let pos = schema_cols.iter().position(|c| c.name == name);
16599            match pos {
16600                Some(p) => {
16601                    let v = incoming.get(p).cloned().unwrap_or(Value::Null);
16602                    value_to_literal_expr(v)
16603                        .unwrap_or_else(|_| Expr::Literal(spg_sql::ast::Literal::Null))
16604                }
16605                None => Expr::Column(ColumnName { qualifier, name }),
16606            }
16607        }
16608        Expr::Binary { op, lhs, rhs } => Expr::Binary {
16609            op,
16610            lhs: Box::new(substitute_excluded_refs(*lhs, schema_cols, incoming)),
16611            rhs: Box::new(substitute_excluded_refs(*rhs, schema_cols, incoming)),
16612        },
16613        Expr::Unary { op, expr } => Expr::Unary {
16614            op,
16615            expr: Box::new(substitute_excluded_refs(*expr, schema_cols, incoming)),
16616        },
16617        Expr::FunctionCall { name, args } => Expr::FunctionCall {
16618            name,
16619            args: args
16620                .into_iter()
16621                .map(|a| substitute_excluded_refs(a, schema_cols, incoming))
16622                .collect(),
16623        },
16624        other => other,
16625    }
16626}
16627
16628/// v7.6.2 / v7.6.7 — INSERT-side FK enforcement. For every row
16629/// about to be inserted into `child_table`, every FK declared on
16630/// that table is checked: the row's FK columns must either be
16631/// NULL (SQL spec skip) or match an existing parent row via the
16632/// parent's BTree PK / UNIQUE index.
16633///
16634/// Returns `EngineError::Unsupported` with a `FOREIGN KEY violation`
16635/// payload on first failure.
16636///
16637/// **Self-referencing FKs (v7.6.7 widening):** when `fk.parent_table
16638/// == child_table`, the parent rows visible to this check are
16639///  (a) rows already committed to the table, plus
16640///  (b) earlier rows from the *same* `rows` batch.
16641/// This makes `INSERT INTO tree VALUES (1, NULL), (2, 1), (3, 2)`
16642/// work in a single statement — common pattern for bulk-loading
16643/// hierarchies.
16644/// v7.9.19 — enforce table-level UNIQUE / PRIMARY KEY tuple
16645/// constraints at INSERT time. For each constraint declared on
16646/// the target table, check that no existing row + no earlier row
16647/// in the same batch has the same full-column tuple. NULL in
16648/// any column lifts the row out of the check (SQL spec: NULL
16649/// ≠ NULL for uniqueness). mailrs G1 + G6.
16650fn enforce_uniqueness_inserts(
16651    catalog: &Catalog,
16652    child_table: &str,
16653    constraints: &[spg_storage::UniquenessConstraint],
16654    rows: &[Vec<Value>],
16655) -> Result<(), EngineError> {
16656    if constraints.is_empty() {
16657        return Ok(());
16658    }
16659    let table = catalog.get(child_table).ok_or_else(|| {
16660        EngineError::Storage(StorageError::TableNotFound {
16661            name: child_table.into(),
16662        })
16663    })?;
16664    let schema = table.schema();
16665    // v7.29 (mailrs round-23b) — set-based: ONE O(table) pass folds
16666    // existing keys into a hash set, then each batch row is a probe
16667    // + insert. The previous shape scanned the WHOLE table per
16668    // inserted row (and earlier batch rows per row), which made
16669    // bulk import O(n²) — a 104 MB dump extrapolated to ~1 hour
16670    // (PG: 2 min). Collation folding (Phase 3.P0-45) and
16671    // NULLS [NOT] DISTINCT semantics are unchanged: keys fold via
16672    // collated_key_cell before encoding, NULL-bearing keys skip the
16673    // set unless nulls_not_distinct.
16674    for uc in constraints {
16675        let fold_key = |values: &[Value]| -> Vec<Value> {
16676            uc.columns
16677                .iter()
16678                .map(|&i| {
16679                    let v = values.get(i).cloned().unwrap_or(Value::Null);
16680                    collated_key_cell(&v, i, schema)
16681                })
16682                .collect()
16683        };
16684        let mut seen: hashbrown::HashSet<String> =
16685            hashbrown::HashSet::with_capacity(table.rows().len() + rows.len());
16686        for prow in table.rows() {
16687            let key = fold_key(&prow.values);
16688            if key.iter().any(|v| matches!(v, Value::Null)) && !uc.nulls_not_distinct {
16689                continue;
16690            }
16691            seen.insert(aggregate::encode_key(&key));
16692        }
16693        for (batch_idx, row_values) in rows.iter().enumerate() {
16694            let key = fold_key(row_values);
16695            if key.iter().any(|v| matches!(v, Value::Null)) && !uc.nulls_not_distinct {
16696                continue;
16697            }
16698            if !seen.insert(aggregate::encode_key(&key)) {
16699                let kind = if uc.is_primary_key {
16700                    "PRIMARY KEY"
16701                } else {
16702                    "UNIQUE"
16703                };
16704                let col_names: Vec<String> = uc
16705                    .columns
16706                    .iter()
16707                    .map(|&i| table.schema().columns[i].name.clone())
16708                    .collect();
16709                return Err(EngineError::Unsupported(alloc::format!(
16710                    "{kind} violation on {child_table:?} columns {col_names:?}: \
16711                     row #{batch_idx} duplicates an existing key"
16712                )));
16713            }
16714        }
16715    }
16716    Ok(())
16717}
16718
16719/// v7.17.0 Phase 3.P0-45 — return a key cell folded by its column's
16720/// declared `Collation`. For `CaseInsensitive`, fold Text payloads to
16721/// ASCII lowercase (matches Phase 2.5's `*_ci` semantics: ASCII case-
16722/// fold only, non-ASCII bytes stay byte-wise). For `Binary` or non-Text
16723/// values, the cell passes through unchanged. The caller compares the
16724/// folded values with `==`.
16725fn collated_key_cell(
16726    v: &spg_storage::Value,
16727    column_position: usize,
16728    schema: &spg_storage::TableSchema,
16729) -> spg_storage::Value {
16730    match (v, schema.columns.get(column_position).map(|c| c.collation)) {
16731        (spg_storage::Value::Text(s), Some(spg_storage::Collation::CaseInsensitive)) => {
16732            spg_storage::Value::Text(s.to_ascii_lowercase())
16733        }
16734        _ => v.clone(),
16735    }
16736}
16737
16738/// v7.9.29 — `true` iff `v` counts as a truthy SQL value for a
16739/// WHERE-style predicate. NULL → false (three-valued logic
16740/// collapses to "skip this row" for index inclusion). Numeric
16741/// non-zero, BIGINT non-zero, TINYINT non-zero, BOOLEAN true → true.
16742/// Everything else (strings, vectors, JSON, …) is not a valid
16743/// predicate result and surfaces as `false` so a malformed
16744/// predicate degrades to "row not in index" rather than panicking.
16745fn predicate_truthy(v: &spg_storage::Value) -> bool {
16746    use spg_storage::Value as V;
16747    match v {
16748        V::Bool(b) => *b,
16749        V::Int(n) => *n != 0,
16750        V::BigInt(n) => *n != 0,
16751        V::SmallInt(n) => *n != 0,
16752        _ => false,
16753    }
16754}
16755
16756/// v7.9.29 — at CREATE UNIQUE INDEX time, scan the table's
16757/// committed rows for pre-existing duplicates. If any pair of rows
16758/// matches the predicate AND has the same index key, refuse to
16759/// create the index so the user fixes the data before retrying.
16760fn check_existing_unique_violation(
16761    idx: &spg_storage::Index,
16762    schema: &spg_storage::TableSchema,
16763    rows: &[spg_storage::Row],
16764) -> Result<(), EngineError> {
16765    let predicate_expr = match idx.partial_predicate.as_deref() {
16766        Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
16767            EngineError::Unsupported(alloc::format!(
16768                "stored partial predicate {s:?} failed to re-parse: {e:?}"
16769            ))
16770        })?),
16771        None => None,
16772    };
16773    let ctx = eval::EvalContext::new(&schema.columns, None);
16774    let key_positions = unique_key_positions(idx);
16775    let mut seen: alloc::vec::Vec<alloc::vec::Vec<spg_storage::Value>> = alloc::vec::Vec::new();
16776    for row in rows {
16777        if let Some(expr) = &predicate_expr {
16778            let v = eval::eval_expr(expr, row, &ctx).map_err(|e| {
16779                EngineError::Unsupported(alloc::format!(
16780                    "evaluating UNIQUE INDEX predicate against existing row: {e:?}"
16781                ))
16782            })?;
16783            if !predicate_truthy(&v) {
16784                continue;
16785            }
16786        }
16787        let key: alloc::vec::Vec<spg_storage::Value> = key_positions
16788            .iter()
16789            .map(|&p| {
16790                let v = row
16791                    .values
16792                    .get(p)
16793                    .cloned()
16794                    .unwrap_or(spg_storage::Value::Null);
16795                collated_key_cell(&v, p, schema)
16796            })
16797            .collect();
16798        if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
16799            continue;
16800        }
16801        if seen.iter().any(|other| *other == key) {
16802            return Err(EngineError::Unsupported(alloc::format!(
16803                "CREATE UNIQUE INDEX {:?}: existing rows already violate the constraint",
16804                idx.name
16805            )));
16806        }
16807        seen.push(key);
16808    }
16809    Ok(())
16810}
16811
16812/// v7.9.29 — full key tuple for a UNIQUE INDEX (leading +
16813/// extra positions). For single-column indexes this is just
16814/// `[column_position]`.
16815fn unique_key_positions(idx: &spg_storage::Index) -> alloc::vec::Vec<usize> {
16816    let mut out = alloc::vec::Vec::with_capacity(1 + idx.extra_column_positions.len());
16817    out.push(idx.column_position);
16818    out.extend_from_slice(&idx.extra_column_positions);
16819    out
16820}
16821
16822/// v7.9.29 — at INSERT time, walk every `is_unique` index on the
16823/// target table. For each, eval the index's optional predicate
16824/// against (a) the candidate row and (b) every committed row plus
16825/// earlier batch rows; only rows where the predicate is truthy
16826/// participate. A duplicate key among predicate-matching rows is a
16827/// uniqueness violation. NULL keys lift the row out of the check
16828/// (matching PG's "UNIQUE allows multiple NULLs" semantics).
16829fn enforce_unique_index_inserts(
16830    catalog: &Catalog,
16831    table_name: &str,
16832    rows: &[alloc::vec::Vec<spg_storage::Value>],
16833) -> Result<(), EngineError> {
16834    let table = catalog.get(table_name).ok_or_else(|| {
16835        EngineError::Storage(StorageError::TableNotFound {
16836            name: table_name.into(),
16837        })
16838    })?;
16839    let schema = table.schema();
16840    let ctx = eval::EvalContext::new(&schema.columns, None);
16841    for idx in table.indices() {
16842        if !idx.is_unique {
16843            continue;
16844        }
16845        // Re-parse the predicate once per index per batch.
16846        let predicate_expr = match idx.partial_predicate.as_deref() {
16847            Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
16848                EngineError::Unsupported(alloc::format!(
16849                    "UNIQUE INDEX {:?} predicate {s:?} failed to re-parse: {e:?}",
16850                    idx.name
16851                ))
16852            })?),
16853            None => None,
16854        };
16855        let key_positions = unique_key_positions(idx);
16856        let key_of = |values: &[spg_storage::Value]| -> alloc::vec::Vec<spg_storage::Value> {
16857            key_positions
16858                .iter()
16859                .map(|&p| {
16860                    let v = values.get(p).cloned().unwrap_or(spg_storage::Value::Null);
16861                    collated_key_cell(&v, p, schema)
16862                })
16863                .collect()
16864        };
16865        let participates = |values: &[spg_storage::Value]| -> Result<bool, EngineError> {
16866            let Some(expr) = &predicate_expr else {
16867                return Ok(true);
16868            };
16869            let tmp_row = spg_storage::Row {
16870                values: values.to_vec(),
16871            };
16872            let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
16873                EngineError::Unsupported(alloc::format!(
16874                    "UNIQUE INDEX {:?} predicate eval: {e:?}",
16875                    idx.name
16876                ))
16877            })?;
16878            Ok(predicate_truthy(&v))
16879        };
16880        // v7.29 (mailrs round-23b) — set-based: one O(table) pass
16881        // (predicate evaluated once per existing row instead of once
16882        // per row PAIR), then probe per batch row. The previous
16883        // nested scans made bulk import O(n²).
16884        let mut seen: hashbrown::HashSet<String> =
16885            hashbrown::HashSet::with_capacity(table.rows().len() + rows.len());
16886        for prow in table.rows() {
16887            if !participates(&prow.values)? {
16888                continue;
16889            }
16890            let key = key_of(&prow.values);
16891            if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
16892                continue;
16893            }
16894            seen.insert(aggregate::encode_key(&key));
16895        }
16896        for (batch_idx, row_values) in rows.iter().enumerate() {
16897            if !participates(row_values)? {
16898                continue;
16899            }
16900            let key = key_of(row_values);
16901            if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
16902                continue;
16903            }
16904            if !seen.insert(aggregate::encode_key(&key)) {
16905                return Err(EngineError::Unsupported(alloc::format!(
16906                    "UNIQUE INDEX {:?} violation on {table_name:?}: \
16907                     row #{batch_idx} duplicates an existing key",
16908                    idx.name
16909                )));
16910            }
16911        }
16912    }
16913    Ok(())
16914}
16915
16916/// v7.13.0 — `UPDATE OF cols` filter helper (mailrs round-5 G7).
16917/// Returns `true` when at least one of `filter_cols` has a
16918/// different value in `new_row` vs `old_row`. Column lookup is
16919/// case-insensitive against `schema_cols`; unknown filter columns
16920/// are treated as "not changed" (the trigger therefore won't
16921/// fire on them — surfacing a parse-time error would be too
16922/// strict for catalog reloads where the schema may have drifted).
16923fn any_column_changed(
16924    filter_cols: &[String],
16925    schema_cols: &[ColumnSchema],
16926    old_row: &Row,
16927    new_row: &Row,
16928) -> bool {
16929    for col_name in filter_cols {
16930        let Some(pos) = schema_cols
16931            .iter()
16932            .position(|c| c.name.eq_ignore_ascii_case(col_name))
16933        else {
16934            continue;
16935        };
16936        let old_v = old_row.values.get(pos);
16937        let new_v = new_row.values.get(pos);
16938        if old_v != new_v {
16939            return true;
16940        }
16941    }
16942    false
16943}
16944
16945/// v7.13.0 — evaluate every CHECK predicate on the schema against
16946/// each candidate row. Mirrors PG semantics: a `false` result
16947/// rejects the mutation; a NULL result *passes* (CHECK rejects
16948/// only on definite-false, not on unknown). mailrs round-5 G3.
16949fn enforce_check_constraints(
16950    catalog: &Catalog,
16951    table_name: &str,
16952    rows: &[alloc::vec::Vec<spg_storage::Value>],
16953) -> Result<(), EngineError> {
16954    let table = catalog.get(table_name).ok_or_else(|| {
16955        EngineError::Storage(StorageError::TableNotFound {
16956            name: table_name.into(),
16957        })
16958    })?;
16959    let schema = table.schema();
16960    // v7.17.0 Phase 1.5 — domain-level CHECKs are enforced in
16961    // parallel with table-level CHECKs. Collect both lists up
16962    // front; if neither exists we early-out.
16963    let mut domain_checks_per_col: alloc::vec::Vec<(usize, alloc::vec::Vec<Expr>)> =
16964        alloc::vec::Vec::new();
16965    for (idx, col) in schema.columns.iter().enumerate() {
16966        let Some(dname) = &col.user_domain_type else {
16967            continue;
16968        };
16969        let Some(dom) = catalog.domain_types().get(dname) else {
16970            continue;
16971        };
16972        let mut parsed_for_col: alloc::vec::Vec<Expr> =
16973            alloc::vec::Vec::with_capacity(dom.checks.len());
16974        for src in &dom.checks {
16975            let expr = spg_sql::parser::parse_expression(src).map_err(|e| {
16976                EngineError::Unsupported(alloc::format!(
16977                    "DOMAIN {dname:?} CHECK ({src:?}) on column {:?}: re-parse failed: {e:?}",
16978                    col.name
16979                ))
16980            })?;
16981            parsed_for_col.push(expr);
16982        }
16983        if !parsed_for_col.is_empty() {
16984            domain_checks_per_col.push((idx, parsed_for_col));
16985        }
16986    }
16987    if schema.checks.is_empty() && domain_checks_per_col.is_empty() {
16988        return Ok(());
16989    }
16990    let ctx = eval::EvalContext::new(&schema.columns, None);
16991    let mut parsed: alloc::vec::Vec<(usize, Expr)> = alloc::vec::Vec::new();
16992    for (i, src) in schema.checks.iter().enumerate() {
16993        let expr = spg_sql::parser::parse_expression(src).map_err(|e| {
16994            EngineError::Unsupported(alloc::format!(
16995                "CHECK constraint #{i} on {table_name:?} ({src:?}) failed to re-parse: {e:?}"
16996            ))
16997        })?;
16998        parsed.push((i, expr));
16999    }
17000    for (batch_idx, row_values) in rows.iter().enumerate() {
17001        let tmp_row = spg_storage::Row {
17002            values: row_values.clone(),
17003        };
17004        for (i, expr) in &parsed {
17005            let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
17006                EngineError::Unsupported(alloc::format!(
17007                    "CHECK constraint #{i} on {table_name:?} eval at row #{batch_idx}: {e:?}"
17008                ))
17009            })?;
17010            // PG: NULL passes (CHECK rejects on definite-false only).
17011            if matches!(v, spg_storage::Value::Bool(false)) {
17012                return Err(EngineError::Unsupported(alloc::format!(
17013                    "CHECK constraint violation on {table_name:?} (row #{batch_idx}): {:?}",
17014                    schema.checks[*i]
17015                )));
17016            }
17017        }
17018        // v7.17.0 Phase 1.5 — domain-level CHECKs. Each CHECK
17019        // expression references VALUE as a column-name; we
17020        // substitute the per-row cell into the eval context by
17021        // synthesising a single-column row of just that value
17022        // under a temporary `value` column schema.
17023        for (col_idx, checks) in &domain_checks_per_col {
17024            let cell = row_values
17025                .get(*col_idx)
17026                .cloned()
17027                .unwrap_or(spg_storage::Value::Null);
17028            let synth_cols = alloc::vec![spg_storage::ColumnSchema::new(
17029                "value",
17030                schema.columns[*col_idx].ty,
17031                schema.columns[*col_idx].nullable,
17032            )];
17033            let synth_ctx = eval::EvalContext::new(&synth_cols, None);
17034            let synth_row = spg_storage::Row {
17035                values: alloc::vec![cell],
17036            };
17037            for (ci, expr) in checks.iter().enumerate() {
17038                let v = eval::eval_expr(expr, &synth_row, &synth_ctx).map_err(|e| {
17039                    EngineError::Unsupported(alloc::format!(
17040                        "DOMAIN CHECK #{ci} on column {:?} eval at row #{batch_idx}: {e:?}",
17041                        schema.columns[*col_idx].name
17042                    ))
17043                })?;
17044                if matches!(v, spg_storage::Value::Bool(false)) {
17045                    return Err(EngineError::Unsupported(alloc::format!(
17046                        "DOMAIN CHECK violation on column {:?} (row #{batch_idx})",
17047                        schema.columns[*col_idx].name
17048                    )));
17049                }
17050            }
17051        }
17052    }
17053    Ok(())
17054}
17055
17056fn enforce_fk_inserts(
17057    catalog: &Catalog,
17058    child_table: &str,
17059    fks: &[spg_storage::ForeignKeyConstraint],
17060    rows: &[Vec<Value>],
17061) -> Result<(), EngineError> {
17062    for fk in fks {
17063        let parent_is_self = fk.parent_table == child_table;
17064        let parent = if parent_is_self {
17065            // Self-ref: read the current state of the same table.
17066            // The mut borrow on child has been dropped by the caller.
17067            catalog.get(child_table).ok_or_else(|| {
17068                EngineError::Storage(StorageError::TableNotFound {
17069                    name: child_table.into(),
17070                })
17071            })?
17072        } else {
17073            catalog.get(&fk.parent_table).ok_or_else(|| {
17074                EngineError::Storage(StorageError::TableNotFound {
17075                    name: fk.parent_table.clone(),
17076                })
17077            })?
17078        };
17079        for (batch_idx, row_values) in rows.iter().enumerate() {
17080            // Single-column FK fast path: try the parent's BTree
17081            // index for an O(log n) lookup. Composite FKs fall back
17082            // to a parent-row scan.
17083            if fk.local_columns.len() == 1 {
17084                let v = &row_values[fk.local_columns[0]];
17085                if matches!(v, Value::Null) {
17086                    continue;
17087                }
17088                let parent_col = fk.parent_columns[0];
17089                let key = spg_storage::IndexKey::from_value(v).ok_or_else(|| {
17090                    EngineError::Unsupported(alloc::format!(
17091                        "FOREIGN KEY column value of type {:?} is not index-eligible",
17092                        v.data_type()
17093                    ))
17094                })?;
17095                let present_committed = parent.indices().iter().any(|idx| {
17096                    matches!(idx.kind, spg_storage::IndexKind::BTree(_))
17097                        && idx.column_position == parent_col
17098                        && idx.partial_predicate.is_none()
17099                        && !idx.lookup_eq(&key).is_empty()
17100                });
17101                // v7.6.7 self-ref widening: also accept a match
17102                // against earlier rows in this same batch when the
17103                // FK points at the table being inserted into.
17104                let present_in_batch = parent_is_self
17105                    && rows[..batch_idx]
17106                        .iter()
17107                        .any(|earlier| earlier.get(parent_col) == Some(v));
17108                if !(present_committed || present_in_batch) {
17109                    return Err(EngineError::Unsupported(alloc::format!(
17110                        "FOREIGN KEY violation: no parent row in {:?} where {} = {:?}",
17111                        fk.parent_table,
17112                        parent
17113                            .schema()
17114                            .columns
17115                            .get(parent_col)
17116                            .map_or("?", |c| c.name.as_str()),
17117                        v,
17118                    )));
17119                }
17120            } else {
17121                // Composite FK: scan parent rows. v7.6.7 also
17122                // accepts a match against earlier rows in the same
17123                // batch (self-ref bulk-loading of hierarchies).
17124                if fk
17125                    .local_columns
17126                    .iter()
17127                    .all(|&i| matches!(row_values.get(i), Some(Value::Null)))
17128                {
17129                    continue;
17130                }
17131                let local: Vec<&Value> = fk.local_columns.iter().map(|&i| &row_values[i]).collect();
17132                let parent_match_committed = parent.rows().iter().any(|prow| {
17133                    fk.parent_columns
17134                        .iter()
17135                        .enumerate()
17136                        .all(|(i, &pi)| prow.values.get(pi) == Some(local[i]))
17137                });
17138                let parent_match_in_batch = parent_is_self
17139                    && rows[..batch_idx].iter().any(|earlier| {
17140                        fk.parent_columns
17141                            .iter()
17142                            .enumerate()
17143                            .all(|(i, &pi)| earlier.get(pi) == Some(local[i]))
17144                    });
17145                if !(parent_match_committed || parent_match_in_batch) {
17146                    return Err(EngineError::Unsupported(alloc::format!(
17147                        "FOREIGN KEY violation: no parent row in {:?} matching composite key",
17148                        fk.parent_table,
17149                    )));
17150                }
17151            }
17152        }
17153    }
17154    Ok(())
17155}
17156
17157/// v7.6.4 / v7.6.5 — one step of the FK action plan computed for a
17158/// DELETE on a parent. The plan is a list of these steps, stacked
17159/// across the FK graph by `plan_fk_parent_deletions`.
17160#[derive(Debug, Clone)]
17161struct FkChildStep {
17162    child_table: String,
17163    action: FkChildAction,
17164}
17165
17166#[derive(Debug, Clone)]
17167enum FkChildAction {
17168    /// CASCADE — remove these rows. Sorted, deduplicated positions.
17169    Delete { positions: Vec<usize> },
17170    /// SET NULL — for each (row, column) in the flat list, write
17171    /// NULL into that child cell. Multiple FKs on the same row may
17172    /// produce overlapping entries (deduped at plan time).
17173    SetNull {
17174        positions: Vec<usize>,
17175        columns: Vec<usize>,
17176    },
17177    /// SET DEFAULT — same shape as SetNull but writes the column's
17178    /// declared DEFAULT value (resolved at plan time). Columns
17179    /// without a DEFAULT raise an error during planning.
17180    SetDefault {
17181        positions: Vec<usize>,
17182        columns: Vec<usize>,
17183        defaults: Vec<Value>,
17184    },
17185}
17186
17187/// v7.6.3 → v7.6.5 — plan FK fallout for a DELETE on a parent table.
17188///
17189/// Walks every table in the catalog looking for FKs whose
17190/// `parent_table` is `parent_table_name`. For each such FK + each
17191/// to-be-deleted parent row:
17192///
17193///   - RESTRICT / NoAction → error, no plan returned
17194///   - CASCADE → child rows get scheduled for deletion; recursive
17195///   - SetNull → child FK column(s) scheduled to be NULL-ed.
17196///     Verified NULL-able at plan time.
17197///   - SetDefault → child FK column(s) scheduled to be reset to
17198///     their declared DEFAULT. Columns without a DEFAULT raise.
17199///
17200/// SET NULL / SET DEFAULT do NOT cascade further — the child row
17201/// stays; only one of its columns mutates.
17202fn plan_fk_parent_deletions(
17203    catalog: &Catalog,
17204    parent_table_name: &str,
17205    to_delete_positions: &[usize],
17206    to_delete_rows: &[Vec<Value>],
17207) -> Result<Vec<FkChildStep>, EngineError> {
17208    use alloc::collections::{BTreeMap, BTreeSet};
17209    if to_delete_rows.is_empty() {
17210        return Ok(Vec::new());
17211    }
17212    let mut delete_plan: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
17213    // setnull / setdefault keyed by child_table → (row_idx, col_idx) → optional default
17214    let mut setnull_plan: BTreeMap<String, BTreeSet<(usize, usize)>> = BTreeMap::new();
17215    let mut setdefault_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
17216    let mut visited: BTreeSet<(String, usize)> = BTreeSet::new();
17217    for &p in to_delete_positions {
17218        visited.insert((parent_table_name.to_string(), p));
17219    }
17220    let mut work: Vec<(String, Vec<Value>)> = to_delete_rows
17221        .iter()
17222        .map(|r| (parent_table_name.to_string(), r.clone()))
17223        .collect();
17224    while let Some((cur_parent, parent_row)) = work.pop() {
17225        for child_name in catalog.table_names() {
17226            let child = catalog
17227                .get(&child_name)
17228                .expect("table_names → catalog.get round-trip is total");
17229            for fk in &child.schema().foreign_keys {
17230                if fk.parent_table != cur_parent {
17231                    continue;
17232                }
17233                let parent_key: Vec<&Value> = fk
17234                    .parent_columns
17235                    .iter()
17236                    .map(|&pi| &parent_row[pi])
17237                    .collect();
17238                if parent_key.iter().any(|v| matches!(v, Value::Null)) {
17239                    continue;
17240                }
17241                for (child_row_idx, child_row) in child.rows().iter().enumerate() {
17242                    if child_name == cur_parent
17243                        && visited.contains(&(child_name.clone(), child_row_idx))
17244                    {
17245                        continue;
17246                    }
17247                    let matches_key = fk
17248                        .local_columns
17249                        .iter()
17250                        .enumerate()
17251                        .all(|(i, &li)| child_row.values.get(li) == Some(parent_key[i]));
17252                    if !matches_key {
17253                        continue;
17254                    }
17255                    match fk.on_delete {
17256                        spg_storage::FkAction::Restrict | spg_storage::FkAction::NoAction => {
17257                            return Err(EngineError::Unsupported(alloc::format!(
17258                                "FOREIGN KEY violation: DELETE on {cur_parent:?} is \
17259                                 restricted by FK from {child_name:?}.{:?}",
17260                                fk.local_columns,
17261                            )));
17262                        }
17263                        spg_storage::FkAction::Cascade => {
17264                            if visited.insert((child_name.clone(), child_row_idx)) {
17265                                delete_plan
17266                                    .entry(child_name.clone())
17267                                    .or_default()
17268                                    .insert(child_row_idx);
17269                                work.push((child_name.clone(), child_row.values.clone()));
17270                            }
17271                        }
17272                        spg_storage::FkAction::SetNull => {
17273                            // Verify every local FK column is NULL-able.
17274                            for &li in &fk.local_columns {
17275                                let col = child.schema().columns.get(li).ok_or_else(|| {
17276                                    EngineError::Unsupported(alloc::format!(
17277                                        "FK local column {li} missing in {child_name:?}"
17278                                    ))
17279                                })?;
17280                                if !col.nullable {
17281                                    return Err(EngineError::Unsupported(alloc::format!(
17282                                        "FOREIGN KEY ON DELETE SET NULL: column \
17283                                         {child_name:?}.{:?} is NOT NULL — cannot SET NULL",
17284                                        col.name,
17285                                    )));
17286                                }
17287                            }
17288                            let entry = setnull_plan.entry(child_name.clone()).or_default();
17289                            for &li in &fk.local_columns {
17290                                entry.insert((child_row_idx, li));
17291                            }
17292                        }
17293                        spg_storage::FkAction::SetDefault => {
17294                            // Resolve the DEFAULT for every local FK col.
17295                            let entry = setdefault_plan.entry(child_name.clone()).or_default();
17296                            for &li in &fk.local_columns {
17297                                let col = child.schema().columns.get(li).ok_or_else(|| {
17298                                    EngineError::Unsupported(alloc::format!(
17299                                        "FK local column {li} missing in {child_name:?}"
17300                                    ))
17301                                })?;
17302                                let default = col.default.clone().ok_or_else(|| {
17303                                    EngineError::Unsupported(alloc::format!(
17304                                        "FOREIGN KEY ON DELETE SET DEFAULT: column \
17305                                         {child_name:?}.{:?} has no DEFAULT declared",
17306                                        col.name,
17307                                    ))
17308                                })?;
17309                                entry.insert((child_row_idx, li), default);
17310                            }
17311                        }
17312                    }
17313                }
17314            }
17315        }
17316    }
17317    // Flatten the three plans into the ordered `FkChildStep` list.
17318    // Deletes are applied last per child (after any null/default
17319    // re-writes on the same child) so a child row that's both
17320    // re-written and then cascade-deleted only ends up deleted —
17321    // but in v7.6.5 SetNull/Cascade never overlap on the same row
17322    // (a single FK chooses exactly one action), so the order is
17323    // mostly a precaution.
17324    let mut steps: Vec<FkChildStep> = Vec::new();
17325    for (child_table, entries) in setnull_plan {
17326        let (positions, columns): (Vec<usize>, Vec<usize>) = entries.into_iter().unzip();
17327        steps.push(FkChildStep {
17328            child_table,
17329            action: FkChildAction::SetNull { positions, columns },
17330        });
17331    }
17332    for (child_table, entries) in setdefault_plan {
17333        let mut positions = Vec::with_capacity(entries.len());
17334        let mut columns = Vec::with_capacity(entries.len());
17335        let mut defaults = Vec::with_capacity(entries.len());
17336        for ((p, c), v) in entries {
17337            positions.push(p);
17338            columns.push(c);
17339            defaults.push(v);
17340        }
17341        steps.push(FkChildStep {
17342            child_table,
17343            action: FkChildAction::SetDefault {
17344                positions,
17345                columns,
17346                defaults,
17347            },
17348        });
17349    }
17350    for (child_table, positions) in delete_plan {
17351        steps.push(FkChildStep {
17352            child_table,
17353            action: FkChildAction::Delete {
17354                positions: positions.into_iter().collect(),
17355            },
17356        });
17357    }
17358    Ok(steps)
17359}
17360
17361/// v7.6.6 — plan FK fallout for an UPDATE that mutates parent-side
17362/// PK/UNIQUE columns. Walks every other table whose FK references
17363/// `parent_table_name`; for each FK whose parent_columns overlap a
17364/// mutated column, decides the action by `fk.on_update`.
17365///
17366///   - RESTRICT / NoAction → error if any child references the OLD
17367///     value
17368///   - CASCADE → child FK columns get rewritten to the NEW parent
17369///     value (a SetNull-style update step with the new value)
17370///   - SetNull → child FK columns set to NULL
17371///   - SetDefault → child FK columns set to declared default
17372///
17373/// `plan_with_old` is `(row_position, old_values, new_values)` so
17374/// the planner can detect "did this row's parent key actually
17375/// change?" — only rows where at least one referenced parent
17376/// column moved trigger inbound work.
17377fn plan_fk_parent_updates(
17378    catalog: &Catalog,
17379    parent_table_name: &str,
17380    plan_with_old: &[(usize, Vec<Value>, Vec<Value>)],
17381) -> Result<Vec<FkChildStep>, EngineError> {
17382    use alloc::collections::BTreeMap;
17383    if plan_with_old.is_empty() {
17384        return Ok(Vec::new());
17385    }
17386    // For each child table we may touch, build per-child step
17387    // lists. UPDATE never deletes children — `delete_plan` stays
17388    // empty here but is kept structurally aligned with
17389    // `plan_fk_parent_deletions` for future use.
17390    let delete_plan: BTreeMap<String, alloc::collections::BTreeSet<usize>> = BTreeMap::new();
17391    let mut setnull_plan: BTreeMap<String, alloc::collections::BTreeSet<(usize, usize)>> =
17392        BTreeMap::new();
17393    let mut setdefault_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
17394    // Cascade-update plan: child_table → row_idx → col_idx → new_value
17395    let mut cascade_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
17396
17397    for child_name in catalog.table_names() {
17398        let child = catalog
17399            .get(&child_name)
17400            .expect("table_names → catalog.get total");
17401        for fk in &child.schema().foreign_keys {
17402            if fk.parent_table != parent_table_name {
17403                continue;
17404            }
17405            for (_pos, old_row, new_row) in plan_with_old {
17406                // Did any parent FK column change?
17407                let key_changed = fk
17408                    .parent_columns
17409                    .iter()
17410                    .any(|&pi| old_row.get(pi) != new_row.get(pi));
17411                if !key_changed {
17412                    continue;
17413                }
17414                // The OLD parent key — used to find referring children.
17415                let old_key: Vec<&Value> =
17416                    fk.parent_columns.iter().map(|&pi| &old_row[pi]).collect();
17417                if old_key.iter().any(|v| matches!(v, Value::Null)) {
17418                    // NULL parent has no children — skip.
17419                    continue;
17420                }
17421                let new_key: Vec<&Value> =
17422                    fk.parent_columns.iter().map(|&pi| &new_row[pi]).collect();
17423                for (child_row_idx, child_row) in child.rows().iter().enumerate() {
17424                    // Self-ref same-row updates: a row updating its
17425                    // own PK doesn't restrict itself.
17426                    if child_name == parent_table_name
17427                        && plan_with_old.iter().any(|(p, _, _)| *p == child_row_idx)
17428                    {
17429                        continue;
17430                    }
17431                    let matches_key = fk
17432                        .local_columns
17433                        .iter()
17434                        .enumerate()
17435                        .all(|(i, &li)| child_row.values.get(li) == Some(old_key[i]));
17436                    if !matches_key {
17437                        continue;
17438                    }
17439                    match fk.on_update {
17440                        spg_storage::FkAction::Restrict | spg_storage::FkAction::NoAction => {
17441                            return Err(EngineError::Unsupported(alloc::format!(
17442                                "FOREIGN KEY violation: UPDATE on {parent_table_name:?} PK is \
17443                                 restricted by FK from {child_name:?}.{:?}",
17444                                fk.local_columns,
17445                            )));
17446                        }
17447                        spg_storage::FkAction::Cascade => {
17448                            // Rewrite child FK columns to new key.
17449                            let entry = cascade_plan.entry(child_name.clone()).or_default();
17450                            for (i, &li) in fk.local_columns.iter().enumerate() {
17451                                entry.insert((child_row_idx, li), new_key[i].clone());
17452                            }
17453                        }
17454                        spg_storage::FkAction::SetNull => {
17455                            for &li in &fk.local_columns {
17456                                let col = child.schema().columns.get(li).ok_or_else(|| {
17457                                    EngineError::Unsupported(alloc::format!(
17458                                        "FK local column {li} missing in {child_name:?}"
17459                                    ))
17460                                })?;
17461                                if !col.nullable {
17462                                    return Err(EngineError::Unsupported(alloc::format!(
17463                                        "FOREIGN KEY ON UPDATE SET NULL: column \
17464                                         {child_name:?}.{:?} is NOT NULL",
17465                                        col.name,
17466                                    )));
17467                                }
17468                            }
17469                            let entry = setnull_plan.entry(child_name.clone()).or_default();
17470                            for &li in &fk.local_columns {
17471                                entry.insert((child_row_idx, li));
17472                            }
17473                        }
17474                        spg_storage::FkAction::SetDefault => {
17475                            let entry = setdefault_plan.entry(child_name.clone()).or_default();
17476                            for &li in &fk.local_columns {
17477                                let col = child.schema().columns.get(li).ok_or_else(|| {
17478                                    EngineError::Unsupported(alloc::format!(
17479                                        "FK local column {li} missing in {child_name:?}"
17480                                    ))
17481                                })?;
17482                                let default = col.default.clone().ok_or_else(|| {
17483                                    EngineError::Unsupported(alloc::format!(
17484                                        "FOREIGN KEY ON UPDATE SET DEFAULT: column \
17485                                         {child_name:?}.{:?} has no DEFAULT",
17486                                        col.name,
17487                                    ))
17488                                })?;
17489                                entry.insert((child_row_idx, li), default);
17490                            }
17491                        }
17492                    }
17493                }
17494            }
17495        }
17496    }
17497    // Flatten into FkChildStep list. UPDATE doesn't produce
17498    // DeleteSteps (CASCADE on UPDATE just rewrites FK values).
17499    let mut steps: Vec<FkChildStep> = Vec::new();
17500    for (child_table, entries) in cascade_plan {
17501        let mut positions = Vec::with_capacity(entries.len());
17502        let mut columns = Vec::with_capacity(entries.len());
17503        let mut defaults = Vec::with_capacity(entries.len());
17504        for ((p, c), v) in entries {
17505            positions.push(p);
17506            columns.push(c);
17507            defaults.push(v);
17508        }
17509        // We reuse `FkChildAction::SetDefault` for cascade-update:
17510        // both shapes are "write a known value into specific cells"
17511        // — `apply_per_cell_writes` doesn't care whether the value
17512        // came from a DEFAULT declaration or a new parent key.
17513        steps.push(FkChildStep {
17514            child_table,
17515            action: FkChildAction::SetDefault {
17516                positions,
17517                columns,
17518                defaults,
17519            },
17520        });
17521    }
17522    for (child_table, entries) in setnull_plan {
17523        let (positions, columns): (Vec<usize>, Vec<usize>) = entries.into_iter().unzip();
17524        steps.push(FkChildStep {
17525            child_table,
17526            action: FkChildAction::SetNull { positions, columns },
17527        });
17528    }
17529    for (child_table, entries) in setdefault_plan {
17530        let mut positions = Vec::with_capacity(entries.len());
17531        let mut columns = Vec::with_capacity(entries.len());
17532        let mut defaults = Vec::with_capacity(entries.len());
17533        for ((p, c), v) in entries {
17534            positions.push(p);
17535            columns.push(c);
17536            defaults.push(v);
17537        }
17538        steps.push(FkChildStep {
17539            child_table,
17540            action: FkChildAction::SetDefault {
17541                positions,
17542                columns,
17543                defaults,
17544            },
17545        });
17546    }
17547    let _ = delete_plan; // UPDATE never deletes children.
17548    Ok(steps)
17549}
17550
17551/// v7.6.5 — apply one FK child step to the catalog. Encapsulates
17552/// the three action variants so the DELETE executor stays a
17553/// simple loop over the planned steps.
17554fn apply_fk_child_step(catalog: &mut Catalog, step: &FkChildStep) -> Result<(), EngineError> {
17555    let child = catalog.get_mut(&step.child_table).ok_or_else(|| {
17556        EngineError::Storage(StorageError::TableNotFound {
17557            name: step.child_table.clone(),
17558        })
17559    })?;
17560    match &step.action {
17561        FkChildAction::Delete { positions } => {
17562            let _ = child.delete_rows(positions);
17563        }
17564        FkChildAction::SetNull { positions, columns } => {
17565            apply_per_cell_writes(child, positions, columns, |_| Value::Null)?;
17566        }
17567        FkChildAction::SetDefault {
17568            positions,
17569            columns,
17570            defaults,
17571        } => {
17572            apply_per_cell_writes(child, positions, columns, |i| defaults[i].clone())?;
17573        }
17574    }
17575    Ok(())
17576}
17577
17578/// v7.6.5 — write new values into selected child cells via
17579/// `Table::update_row` (the catalog's existing UPDATE entry).
17580/// Groups writes by row position so multi-column updates on the
17581/// same row only call `update_row` once. `value_for(i)` produces
17582/// the new value for the i-th (position, column) entry.
17583fn apply_per_cell_writes(
17584    child: &mut spg_storage::Table,
17585    positions: &[usize],
17586    columns: &[usize],
17587    mut value_for: impl FnMut(usize) -> Value,
17588) -> Result<(), EngineError> {
17589    use alloc::collections::BTreeMap;
17590    let mut by_row: BTreeMap<usize, Vec<(usize, Value)>> = BTreeMap::new();
17591    for i in 0..positions.len() {
17592        by_row
17593            .entry(positions[i])
17594            .or_default()
17595            .push((columns[i], value_for(i)));
17596    }
17597    for (pos, mutations) in by_row {
17598        let mut new_values = child.rows()[pos].values.clone();
17599        for (col, v) in mutations {
17600            if let Some(slot) = new_values.get_mut(col) {
17601                *slot = v;
17602            }
17603        }
17604        child
17605            .update_row(pos, new_values)
17606            .map_err(EngineError::Storage)?;
17607    }
17608    Ok(())
17609}
17610
17611fn fk_action_sql_to_storage(a: spg_sql::ast::FkAction) -> spg_storage::FkAction {
17612    match a {
17613        spg_sql::ast::FkAction::Restrict => spg_storage::FkAction::Restrict,
17614        spg_sql::ast::FkAction::Cascade => spg_storage::FkAction::Cascade,
17615        spg_sql::ast::FkAction::SetNull => spg_storage::FkAction::SetNull,
17616        spg_sql::ast::FkAction::SetDefault => spg_storage::FkAction::SetDefault,
17617        spg_sql::ast::FkAction::NoAction => spg_storage::FkAction::NoAction,
17618    }
17619}
17620
17621/// v7.9.21 — resolve a column's DEFAULT for INSERT-time
17622/// default-fill. Free fn (rather than `&self`) so callers
17623/// with an active `&mut Table` borrow can still use it.
17624/// Literal defaults take the cached path (`col.default`);
17625/// runtime defaults hit `clock_fn` at each call. mailrs G4.
17626fn resolve_column_default_free(
17627    col: &ColumnSchema,
17628    clock_fn: Option<ClockFn>,
17629) -> Result<Value, EngineError> {
17630    if let Some(rt) = &col.runtime_default {
17631        return eval_runtime_default_free(rt, col.ty, clock_fn);
17632    }
17633    Ok(col.default.clone().unwrap_or(Value::Null))
17634}
17635
17636fn eval_runtime_default_free(
17637    rt: &str,
17638    ty: DataType,
17639    clock_fn: Option<ClockFn>,
17640) -> Result<Value, EngineError> {
17641    let s = rt.trim().to_ascii_lowercase();
17642    // v7.17.0 Phase 2.1 — also strip `(N)` precision suffix
17643    // so MySQL `CURRENT_TIMESTAMP(6)` resolves the same as
17644    // bare `CURRENT_TIMESTAMP`. SPG stores TIMESTAMP at fixed
17645    // microsecond resolution; the precision modifier is
17646    // parser-only.
17647    let with_no_parens = s.trim_end_matches("()");
17648    let canonical: &str = if let Some(open_idx) = with_no_parens.find('(') {
17649        if with_no_parens.ends_with(')') {
17650            &with_no_parens[..open_idx]
17651        } else {
17652            with_no_parens
17653        }
17654    } else {
17655        with_no_parens
17656    };
17657    let now_us = match clock_fn {
17658        Some(f) => f(),
17659        None => 0,
17660    };
17661    let v = match canonical {
17662        "now" | "current_timestamp" | "localtimestamp" => Value::Timestamp(now_us),
17663        "current_date" => Value::Date((now_us / 86_400_000_000) as i32),
17664        "current_time" | "localtime" => Value::Timestamp(now_us),
17665        // v7.17.0 — UUID generators in DEFAULT clauses. Required
17666        // for the canonical Django / Rails / Hibernate `id UUID
17667        // PRIMARY KEY DEFAULT gen_random_uuid()` pattern. Each
17668        // INSERT evaluates the function fresh; the per-row UUID
17669        // is the storage value, not a cached literal.
17670        "gen_random_uuid" | "uuid_generate_v4" => Value::Uuid(eval::gen_random_uuid_bytes()),
17671        other => {
17672            return Err(EngineError::Unsupported(alloc::format!(
17673                "runtime DEFAULT expression {other:?} not supported \
17674                 (v7.17.0 whitelist: now() / current_timestamp / \
17675                 current_date / current_time / localtimestamp / \
17676                 localtime / gen_random_uuid() / \
17677                 uuid_generate_v4())"
17678            )));
17679        }
17680    };
17681    coerce_value(v, ty, "DEFAULT", 0)
17682}
17683
17684/// v7.9.21 — true when a DEFAULT expression needs INSERT-time
17685/// evaluation rather than being cacheable as a literal Value.
17686/// FunctionCall is the immediate case (`now()`,
17687/// `current_timestamp`). Literal expressions and simple sign-
17688/// flipped numerics still take the static-cache path.
17689fn is_runtime_default_expr(expr: &Expr) -> bool {
17690    match expr {
17691        Expr::FunctionCall { .. } => true,
17692        Expr::Unary { expr, .. } => is_runtime_default_expr(expr),
17693        _ => false,
17694    }
17695}
17696
17697/// v7.17.0 Phase 1.4 — INSERT/UPDATE-time enum label check. When
17698/// `col_idx` has a registered label list, the cell value must be
17699/// NULL or one of the labels (case-sensitive per PG).
17700/// v7.17.0 Phase 3.P0-37 — validate + canonicalise a MySQL inline
17701/// SET cell. For non-SET columns this is a no-op pass-through.
17702///
17703/// Semantics:
17704///   * NULL preserved.
17705///   * Empty string → `''` (zero flags).
17706///   * Otherwise split on ',', trim each token, validate every
17707///     token against the column's variant list (error on miss),
17708///     de-dup, then re-emit in DEFINITION order joined by ','.
17709fn canonicalize_set_value(
17710    lookup: &alloc::collections::BTreeMap<usize, Vec<String>>,
17711    col_idx: usize,
17712    col_name: &str,
17713    value: Value,
17714) -> Result<Value, EngineError> {
17715    let Some(variants) = lookup.get(&col_idx) else {
17716        return Ok(value);
17717    };
17718    match value {
17719        Value::Null => Ok(Value::Null),
17720        Value::Text(s) => {
17721            if s.is_empty() {
17722                return Ok(Value::Text(alloc::string::String::new()));
17723            }
17724            // Collect a presence-set of variant indices to keep
17725            // definition order + handle de-dup in one pass.
17726            let mut present = alloc::vec![false; variants.len()];
17727            for raw in s.split(',') {
17728                let tok = raw.trim();
17729                if tok.is_empty() {
17730                    continue;
17731                }
17732                let idx = variants.iter().position(|v| v == tok).ok_or_else(|| {
17733                    EngineError::Unsupported(alloc::format!(
17734                        "column {col_name:?}: invalid SET token {tok:?}; \
17735                         allowed: {variants:?}"
17736                    ))
17737                })?;
17738                present[idx] = true;
17739            }
17740            // Re-emit in definition order.
17741            let mut out = alloc::string::String::new();
17742            let mut first = true;
17743            for (i, keep) in present.iter().enumerate() {
17744                if !keep {
17745                    continue;
17746                }
17747                if !first {
17748                    out.push(',');
17749                }
17750                first = false;
17751                out.push_str(&variants[i]);
17752            }
17753            Ok(Value::Text(out))
17754        }
17755        other => Err(EngineError::Unsupported(alloc::format!(
17756            "column {col_name:?}: SET-typed column expects TEXT, got {:?}",
17757            other.data_type()
17758        ))),
17759    }
17760}
17761
17762fn enforce_enum_label(
17763    lookup: &alloc::collections::BTreeMap<usize, Vec<String>>,
17764    col_idx: usize,
17765    col_name: &str,
17766    value: &Value,
17767) -> Result<(), EngineError> {
17768    if let Some(labels) = lookup.get(&col_idx) {
17769        match value {
17770            Value::Null => Ok(()),
17771            Value::Text(s) => {
17772                if labels.iter().any(|l| l == s) {
17773                    Ok(())
17774                } else {
17775                    Err(EngineError::Unsupported(alloc::format!(
17776                        "column {col_name:?}: invalid enum label {s:?}; allowed: {labels:?}"
17777                    )))
17778                }
17779            }
17780            other => Err(EngineError::Unsupported(alloc::format!(
17781                "column {col_name:?}: enum-typed column expects TEXT, got {:?}",
17782                other.data_type()
17783            ))),
17784        }
17785    } else {
17786        Ok(())
17787    }
17788}
17789
17790fn column_def_to_schema(c: ColumnDef) -> Result<ColumnSchema, EngineError> {
17791    let ty = column_type_to_data_type(c.ty);
17792    let mut schema = ColumnSchema::new(c.name.clone(), ty, c.nullable);
17793    // user_type_ref is the raw ident the parser couldn't resolve
17794    // to a built-in; classification into enum vs domain happens
17795    // at exec_create_table where we have catalog access. We
17796    // park it temporarily as user_enum_type and the engine
17797    // promotes domain bindings to user_domain_type before the
17798    // table is stored.
17799    if let Some(name) = c.user_type_ref {
17800        schema.user_enum_type = Some(name);
17801    }
17802    // v7.17.0 Phase 2.1 — render the ON UPDATE expression to
17803    // canonical text (the engine re-parses at UPDATE time).
17804    if let Some(expr) = c.on_update_runtime {
17805        schema.on_update_runtime = Some(alloc::format!("{expr}"));
17806    }
17807    // v7.17.0 Phase 2.5 — bridge the AST `Collation` enum to the
17808    // storage one. Same variants, different crates (spg-storage
17809    // owns no dep on spg-sql).
17810    schema.collation = match c.collation {
17811        spg_sql::ast::Collation::Binary => spg_storage::Collation::Binary,
17812        spg_sql::ast::Collation::CaseInsensitive => spg_storage::Collation::CaseInsensitive,
17813    };
17814    // v7.17.0 Phase 4.4 — MySQL `UNSIGNED` flag propagates to
17815    // storage so engine INSERT / UPDATE can range-check.
17816    schema.is_unsigned = c.is_unsigned;
17817    // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM variant list.
17818    // INSERT validation lives in coerce_value (Text → Text path
17819    // with the column's variant list as the accept-set).
17820    schema.inline_enum_variants = c.inline_enum_variants;
17821    // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant list.
17822    // INSERT canonicalisation (de-dup + sort by definition order)
17823    // lives in the exec_insert path next to the ENUM check.
17824    schema.inline_set_variants = c.inline_set_variants;
17825    if let Some(default_expr) = c.default {
17826        // v7.9.21 — distinguish literal defaults (evaluated once
17827        // at CREATE TABLE) from expression defaults (deferred to
17828        // INSERT). Function calls (`now()`, `current_timestamp`
17829        // — see v7.9.20 keyword promotion) take the runtime path.
17830        // Literals continue to cache. mailrs G4.
17831        if is_runtime_default_expr(&default_expr) {
17832            let display = alloc::format!("{default_expr}");
17833            schema = schema.with_runtime_default(display);
17834        } else {
17835            let raw = literal_expr_to_value(default_expr)?;
17836            let coerced = coerce_value(raw, ty, &c.name, 0)?;
17837            schema = schema.with_default(coerced);
17838        }
17839    }
17840    if c.auto_increment {
17841        // AUTO_INCREMENT only makes sense on integer-shaped columns.
17842        if !matches!(ty, DataType::SmallInt | DataType::Int | DataType::BigInt) {
17843            return Err(EngineError::Unsupported(alloc::format!(
17844                "AUTO_INCREMENT requires an integer column type, got {ty:?}"
17845            )));
17846        }
17847        schema = schema.with_auto_increment();
17848    }
17849    Ok(schema)
17850}
17851
17852/// v7.10.4 — decode a BYTEA literal. Accepts:
17853///   * `\xDEADBEEF` (case-insensitive hex; whitespace stripped)
17854///   * `Hello\000world` (backslash escape form; `\\` for literal backslash)
17855///   * Anything else → raw UTF-8 bytes of the input (PG accepts this too).
17856fn decode_bytea_literal(s: &str) -> Result<alloc::vec::Vec<u8>, &'static str> {
17857    let s = s.trim();
17858    if let Some(hex) = s.strip_prefix("\\x").or_else(|| s.strip_prefix("\\X")) {
17859        // Hex form. Each pair of hex digits → one byte.
17860        let cleaned: alloc::string::String = hex.chars().filter(|c| !c.is_whitespace()).collect();
17861        if cleaned.len() % 2 != 0 {
17862            return Err("odd-length hex literal");
17863        }
17864        let mut out = alloc::vec::Vec::with_capacity(cleaned.len() / 2);
17865        let cleaned_bytes = cleaned.as_bytes();
17866        for i in (0..cleaned_bytes.len()).step_by(2) {
17867            let hi = hex_nibble(cleaned_bytes[i])?;
17868            let lo = hex_nibble(cleaned_bytes[i + 1])?;
17869            out.push((hi << 4) | lo);
17870        }
17871        return Ok(out);
17872    }
17873    // Escape form or raw. Walk char-by-char; `\\` and `\NNN` octal
17874    // sequences decode; anything else is a literal byte.
17875    let bytes = s.as_bytes();
17876    let mut out = alloc::vec::Vec::with_capacity(bytes.len());
17877    let mut i = 0;
17878    while i < bytes.len() {
17879        let b = bytes[i];
17880        if b == b'\\' && i + 1 < bytes.len() {
17881            let n = bytes[i + 1];
17882            if n == b'\\' {
17883                out.push(b'\\');
17884                i += 2;
17885                continue;
17886            }
17887            if n.is_ascii_digit()
17888                && i + 3 < bytes.len()
17889                && bytes[i + 2].is_ascii_digit()
17890                && bytes[i + 3].is_ascii_digit()
17891            {
17892                let oct = |x: u8| (x - b'0') as u32;
17893                let v = oct(n) * 64 + oct(bytes[i + 2]) * 8 + oct(bytes[i + 3]);
17894                if v <= 0xFF {
17895                    out.push(v as u8);
17896                    i += 4;
17897                    continue;
17898                }
17899            }
17900        }
17901        out.push(b);
17902        i += 1;
17903    }
17904    Ok(out)
17905}
17906
17907fn hex_nibble(b: u8) -> Result<u8, &'static str> {
17908    match b {
17909        b'0'..=b'9' => Ok(b - b'0'),
17910        b'a'..=b'f' => Ok(b - b'a' + 10),
17911        b'A'..=b'F' => Ok(b - b'A' + 10),
17912        _ => Err("invalid hex digit"),
17913    }
17914}
17915
17916/// v7.10.11 — decode a PG TEXT[] external array form
17917/// (`{a,b,NULL}` with optional double-quoted elements). The
17918/// engine takes a leading/trailing `{`/`}` and splits at commas.
17919/// Quoted elements (`"hello, world"`) preserve embedded commas;
17920/// `\\` and `\"` decode to literal backslash / quote. Plain
17921/// unquoted `NULL` (case-insensitive) maps to `None`.
17922/// v7.11.13 — pick the array type for `ARRAY[lit, …]` from the
17923/// element values. Single-element-type rules:
17924///   - all NULL / all Text → TextArray
17925///   - all Int (or Int+NULL) → IntArray
17926///   - any BigInt without Text → BigIntArray (widening)
17927///   - any Text → TextArray (fallback; non-string elements
17928///     render as text)
17929fn array_literal_widen(items: alloc::vec::Vec<Value>) -> Value {
17930    let mut has_text = false;
17931    let mut has_bigint = false;
17932    let mut has_int = false;
17933    for v in &items {
17934        match v {
17935            Value::Null => {}
17936            Value::Text(_) | Value::Json(_) => has_text = true,
17937            Value::BigInt(_) => has_bigint = true,
17938            Value::Int(_) | Value::SmallInt(_) => has_int = true,
17939            _ => has_text = true,
17940        }
17941    }
17942    if has_text || (!has_bigint && !has_int) {
17943        let out: alloc::vec::Vec<Option<alloc::string::String>> = items
17944            .into_iter()
17945            .map(|v| match v {
17946                Value::Null => None,
17947                Value::Text(s) | Value::Json(s) => Some(s),
17948                other => Some(alloc::format!("{other:?}")),
17949            })
17950            .collect();
17951        return Value::TextArray(out);
17952    }
17953    if has_bigint {
17954        let out: alloc::vec::Vec<Option<i64>> = items
17955            .into_iter()
17956            .map(|v| match v {
17957                Value::Null => None,
17958                Value::Int(n) => Some(i64::from(n)),
17959                Value::SmallInt(n) => Some(i64::from(n)),
17960                Value::BigInt(n) => Some(n),
17961                _ => unreachable!("widen: unexpected non-integer in BigInt path"),
17962            })
17963            .collect();
17964        return Value::BigIntArray(out);
17965    }
17966    let out: alloc::vec::Vec<Option<i32>> = items
17967        .into_iter()
17968        .map(|v| match v {
17969            Value::Null => None,
17970            Value::Int(n) => Some(n),
17971            Value::SmallInt(n) => Some(i32::from(n)),
17972            _ => unreachable!("widen: unexpected non-i32-compatible in Int path"),
17973        })
17974        .collect();
17975    Value::IntArray(out)
17976}
17977
17978fn decode_text_array_literal(
17979    s: &str,
17980) -> Result<alloc::vec::Vec<Option<alloc::string::String>>, &'static str> {
17981    let trimmed = s.trim();
17982    let inner = trimmed
17983        .strip_prefix('{')
17984        .and_then(|x| x.strip_suffix('}'))
17985        .ok_or("TEXT[] literal must be enclosed in '{...}'")?;
17986    let mut out: alloc::vec::Vec<Option<alloc::string::String>> = alloc::vec::Vec::new();
17987    if inner.trim().is_empty() {
17988        return Ok(out);
17989    }
17990    let bytes = inner.as_bytes();
17991    let mut i = 0;
17992    while i <= bytes.len() {
17993        // Skip leading whitespace.
17994        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
17995            i += 1;
17996        }
17997        // Quoted element.
17998        if i < bytes.len() && bytes[i] == b'"' {
17999            i += 1; // open quote
18000            let mut buf = alloc::string::String::new();
18001            while i < bytes.len() && bytes[i] != b'"' {
18002                if bytes[i] == b'\\' && i + 1 < bytes.len() {
18003                    buf.push(bytes[i + 1] as char);
18004                    i += 2;
18005                } else {
18006                    buf.push(bytes[i] as char);
18007                    i += 1;
18008                }
18009            }
18010            if i >= bytes.len() {
18011                return Err("unterminated quoted element");
18012            }
18013            i += 1; // close quote
18014            out.push(Some(buf));
18015        } else {
18016            // Unquoted element — read until next comma or end.
18017            let start = i;
18018            while i < bytes.len() && bytes[i] != b',' {
18019                i += 1;
18020            }
18021            let raw = inner[start..i].trim();
18022            if raw.eq_ignore_ascii_case("NULL") {
18023                out.push(None);
18024            } else {
18025                out.push(Some(alloc::string::ToString::to_string(raw)));
18026            }
18027        }
18028        // Skip whitespace, expect comma or end.
18029        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
18030            i += 1;
18031        }
18032        if i >= bytes.len() {
18033            break;
18034        }
18035        if bytes[i] != b',' {
18036            return Err("expected ',' between TEXT[] elements");
18037        }
18038        i += 1;
18039    }
18040    Ok(out)
18041}
18042
18043/// v7.10.11 — encode a TEXT[] back into the PG external array
18044/// form. NULL elements become the literal `NULL`; elements
18045/// containing commas, quotes, backslashes, or braces are
18046/// double-quoted with `\\` / `\"` escapes.
18047fn encode_text_array(items: &[Option<alloc::string::String>]) -> alloc::string::String {
18048    let mut out = alloc::string::String::with_capacity(2 + items.len() * 8);
18049    out.push('{');
18050    for (i, item) in items.iter().enumerate() {
18051        if i > 0 {
18052            out.push(',');
18053        }
18054        match item {
18055            None => out.push_str("NULL"),
18056            Some(s) => {
18057                let needs_quote = s.is_empty()
18058                    || s.eq_ignore_ascii_case("NULL")
18059                    || s.chars()
18060                        .any(|c| matches!(c, ',' | '{' | '}' | '"' | '\\' | ' ' | '\t'));
18061                if needs_quote {
18062                    out.push('"');
18063                    for c in s.chars() {
18064                        if c == '"' || c == '\\' {
18065                            out.push('\\');
18066                        }
18067                        out.push(c);
18068                    }
18069                    out.push('"');
18070                } else {
18071                    out.push_str(s);
18072                }
18073            }
18074        }
18075    }
18076    out.push('}');
18077    out
18078}
18079
18080/// v7.10.4 — encode BYTEA bytes in PG hex output format
18081/// (`\x` prefix, lowercase hex pairs). Used by Text-side
18082/// round-trip + the wire layer's text-mode encoder.
18083fn encode_bytea_hex(b: &[u8]) -> alloc::string::String {
18084    let mut out = alloc::string::String::with_capacity(2 + 2 * b.len());
18085    out.push_str("\\x");
18086    for byte in b {
18087        let hi = byte >> 4;
18088        let lo = byte & 0x0F;
18089        out.push(hex_digit(hi));
18090        out.push(hex_digit(lo));
18091    }
18092    out
18093}
18094
18095const fn hex_digit(n: u8) -> char {
18096    match n {
18097        0..=9 => (b'0' + n) as char,
18098        10..=15 => (b'a' + n - 10) as char,
18099        _ => '?',
18100    }
18101}
18102
18103/// v7.17.0 Phase 3.P0-39 — parse a PG `hstore` text literal into
18104/// a flat key→value map. Empty string → empty map. Duplicate
18105/// keys take last-write-wins (matches PG `hstore_in`).
18106///
18107/// Accepted shapes (minimal subset):
18108///   * `'a=>1, b=>2'`            — bareword keys/values
18109///   * `'"a"=>"1", "b"=>"2"'`    — quoted keys/values
18110///   * `'a=>NULL'`               — case-insensitive NULL token
18111///     surfaces as `None` (no quotes around NULL)
18112///
18113/// Returns None on parse failure → caller surfaces as hard error.
18114fn parse_hstore_str(
18115    s: &str,
18116) -> Option<Vec<(alloc::string::String, Option<alloc::string::String>)>> {
18117    let bytes = s.as_bytes();
18118    let mut i = 0;
18119    let mut out: Vec<(alloc::string::String, Option<alloc::string::String>)> = Vec::new();
18120    let skip_ws = |bytes: &[u8], i: &mut usize| {
18121        while *i < bytes.len() && matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r') {
18122            *i += 1;
18123        }
18124    };
18125    let parse_token = |bytes: &[u8], i: &mut usize| -> Option<alloc::string::String> {
18126        if *i >= bytes.len() {
18127            return None;
18128        }
18129        if bytes[*i] == b'"' {
18130            *i += 1;
18131            let mut out = alloc::string::String::new();
18132            while *i < bytes.len() {
18133                match bytes[*i] {
18134                    b'"' => {
18135                        *i += 1;
18136                        return Some(out);
18137                    }
18138                    b'\\' if *i + 1 < bytes.len() => {
18139                        out.push(bytes[*i + 1] as char);
18140                        *i += 2;
18141                    }
18142                    c => {
18143                        out.push(c as char);
18144                        *i += 1;
18145                    }
18146                }
18147            }
18148            None
18149        } else {
18150            let start = *i;
18151            while *i < bytes.len()
18152                && !matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r' | b',' | b'=')
18153            {
18154                *i += 1;
18155            }
18156            if *i == start {
18157                return None;
18158            }
18159            Some(alloc::str::from_utf8(&bytes[start..*i]).ok()?.to_string())
18160        }
18161    };
18162    skip_ws(bytes, &mut i);
18163    while i < bytes.len() {
18164        let key = parse_token(bytes, &mut i)?;
18165        skip_ws(bytes, &mut i);
18166        if i + 1 >= bytes.len() || bytes[i] != b'=' || bytes[i + 1] != b'>' {
18167            return None;
18168        }
18169        i += 2;
18170        skip_ws(bytes, &mut i);
18171        // Check for unquoted NULL token (case-insensitive).
18172        let val_token = if i + 4 <= bytes.len()
18173            && bytes[i..i + 4].eq_ignore_ascii_case(b"NULL")
18174            && (i + 4 == bytes.len() || matches!(bytes[i + 4], b' ' | b'\t' | b',' | b'\n' | b'\r'))
18175        {
18176            i += 4;
18177            None
18178        } else {
18179            Some(parse_token(bytes, &mut i)?)
18180        };
18181        // Replace any existing entry with the same key (last-wins).
18182        if let Some(pos) = out.iter().position(|(k, _)| k == &key) {
18183            out[pos] = (key, val_token);
18184        } else {
18185            out.push((key, val_token));
18186        }
18187        skip_ws(bytes, &mut i);
18188        if i >= bytes.len() {
18189            break;
18190        }
18191        if bytes[i] == b',' {
18192            i += 1;
18193            skip_ws(bytes, &mut i);
18194            continue;
18195        }
18196        return None;
18197    }
18198    Some(out)
18199}
18200
18201/// v7.17.0 Phase 3.P0-39 — render a hstore as canonical PG text
18202/// form `"k"=>"v"` (keys and non-NULL values always quoted;
18203/// NULL token is bare).
18204fn format_hstore_str(
18205    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
18206) -> alloc::string::String {
18207    let mut out = alloc::string::String::new();
18208    for (i, (k, v)) in pairs.iter().enumerate() {
18209        if i > 0 {
18210            out.push_str(", ");
18211        }
18212        out.push('"');
18213        out.push_str(k);
18214        out.push_str("\"=>");
18215        match v {
18216            None => out.push_str("NULL"),
18217            Some(val) => {
18218                out.push('"');
18219                out.push_str(val);
18220                out.push('"');
18221            }
18222        }
18223    }
18224    out
18225}
18226
18227/// v7.17.0 Phase 3.P0-39 — pub re-export so pgwire + sqllogictest
18228/// share the single hstore renderer.
18229pub fn format_hstore_text(
18230    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
18231) -> alloc::string::String {
18232    format_hstore_str(pairs)
18233}
18234
18235// ─── v7.17.0 Phase 3.P0-40 — 2D array parse + display ─────────
18236
18237/// Split a PG external 2D-array literal `'{{a,b},{c,d}}'` into
18238/// per-row token lists. Returns Err on shape mismatch.
18239fn split_2d_literal(s: &str) -> Result<Vec<Vec<alloc::string::String>>, &'static str> {
18240    let s = s.trim();
18241    let outer = s
18242        .strip_prefix('{')
18243        .and_then(|x| x.strip_suffix('}'))
18244        .ok_or("missing outer '{...}' braces")?;
18245    let trimmed = outer.trim();
18246    if trimmed.is_empty() {
18247        return Ok(Vec::new());
18248    }
18249    let mut rows: Vec<Vec<alloc::string::String>> = Vec::new();
18250    let mut i = 0;
18251    let bytes = trimmed.as_bytes();
18252    while i < bytes.len() {
18253        while i < bytes.len() && matches!(bytes[i], b' ' | b'\t' | b'\n' | b'\r' | b',') {
18254            i += 1;
18255        }
18256        if i >= bytes.len() {
18257            break;
18258        }
18259        if bytes[i] != b'{' {
18260            return Err("expected '{' opening a row");
18261        }
18262        i += 1;
18263        let row_start = i;
18264        let mut depth = 1;
18265        while i < bytes.len() && depth > 0 {
18266            match bytes[i] {
18267                b'{' => depth += 1,
18268                b'}' => depth -= 1,
18269                _ => {}
18270            }
18271            if depth > 0 {
18272                i += 1;
18273            }
18274        }
18275        if depth != 0 {
18276            return Err("unbalanced '{...}' in row");
18277        }
18278        let row_text = &trimmed[row_start..i];
18279        i += 1;
18280        let cells: Vec<alloc::string::String> = if row_text.trim().is_empty() {
18281            Vec::new()
18282        } else {
18283            row_text.split(',').map(|t| t.trim().to_string()).collect()
18284        };
18285        rows.push(cells);
18286    }
18287    if let Some(first) = rows.first() {
18288        let cols = first.len();
18289        for r in &rows {
18290            if r.len() != cols {
18291                return Err("ragged 2D array (rows have different column counts)");
18292            }
18293        }
18294    }
18295    Ok(rows)
18296}
18297
18298fn parse_int_2d_literal(s: &str) -> Result<Vec<Vec<Option<i32>>>, &'static str> {
18299    let raw = split_2d_literal(s)?;
18300    raw.into_iter()
18301        .map(|row| {
18302            row.into_iter()
18303                .map(|cell| {
18304                    if cell.eq_ignore_ascii_case("NULL") {
18305                        Ok(None)
18306                    } else {
18307                        cell.parse::<i32>()
18308                            .map(Some)
18309                            .map_err(|_| "invalid int element")
18310                    }
18311                })
18312                .collect()
18313        })
18314        .collect()
18315}
18316
18317fn parse_bigint_2d_literal(s: &str) -> Result<Vec<Vec<Option<i64>>>, &'static str> {
18318    let raw = split_2d_literal(s)?;
18319    raw.into_iter()
18320        .map(|row| {
18321            row.into_iter()
18322                .map(|cell| {
18323                    if cell.eq_ignore_ascii_case("NULL") {
18324                        Ok(None)
18325                    } else {
18326                        cell.parse::<i64>()
18327                            .map(Some)
18328                            .map_err(|_| "invalid bigint element")
18329                    }
18330                })
18331                .collect()
18332        })
18333        .collect()
18334}
18335
18336fn parse_text_2d_literal(s: &str) -> Result<Vec<Vec<Option<alloc::string::String>>>, &'static str> {
18337    let raw = split_2d_literal(s)?;
18338    Ok(raw
18339        .into_iter()
18340        .map(|row| {
18341            row.into_iter()
18342                .map(|cell| {
18343                    if cell.eq_ignore_ascii_case("NULL") {
18344                        None
18345                    } else {
18346                        Some(cell.trim_matches('"').to_string())
18347                    }
18348                })
18349                .collect()
18350        })
18351        .collect())
18352}
18353
18354fn format_int_2d_text(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
18355    let mut out = alloc::string::String::from("{");
18356    for (i, row) in rows.iter().enumerate() {
18357        if i > 0 {
18358            out.push(',');
18359        }
18360        out.push('{');
18361        for (j, cell) in row.iter().enumerate() {
18362            if j > 0 {
18363                out.push(',');
18364            }
18365            match cell {
18366                None => out.push_str("NULL"),
18367                Some(n) => out.push_str(&alloc::format!("{n}")),
18368            }
18369        }
18370        out.push('}');
18371    }
18372    out.push('}');
18373    out
18374}
18375
18376fn format_bigint_2d_text(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
18377    let mut out = alloc::string::String::from("{");
18378    for (i, row) in rows.iter().enumerate() {
18379        if i > 0 {
18380            out.push(',');
18381        }
18382        out.push('{');
18383        for (j, cell) in row.iter().enumerate() {
18384            if j > 0 {
18385                out.push(',');
18386            }
18387            match cell {
18388                None => out.push_str("NULL"),
18389                Some(n) => out.push_str(&alloc::format!("{n}")),
18390            }
18391        }
18392        out.push('}');
18393    }
18394    out.push('}');
18395    out
18396}
18397
18398fn format_text_2d_text(rows: &[Vec<Option<alloc::string::String>>]) -> alloc::string::String {
18399    let mut out = alloc::string::String::from("{");
18400    for (i, row) in rows.iter().enumerate() {
18401        if i > 0 {
18402            out.push(',');
18403        }
18404        out.push('{');
18405        for (j, cell) in row.iter().enumerate() {
18406            if j > 0 {
18407                out.push(',');
18408            }
18409            match cell {
18410                None => out.push_str("NULL"),
18411                Some(s) => out.push_str(s),
18412            }
18413        }
18414        out.push('}');
18415    }
18416    out.push('}');
18417    out
18418}
18419
18420/// v7.17.0 Phase 3.P0-40 — pub re-exports so pgwire + sqllogictest
18421/// share the single 2D-array renderer.
18422pub fn format_int_2d_text_pub(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
18423    format_int_2d_text(rows)
18424}
18425pub fn format_bigint_2d_text_pub(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
18426    format_bigint_2d_text(rows)
18427}
18428pub fn format_text_2d_text_pub(
18429    rows: &[Vec<Option<alloc::string::String>>],
18430) -> alloc::string::String {
18431    format_text_2d_text(rows)
18432}
18433
18434/// v7.17.0 Phase 3.P0-38 — parse a PG range literal of the form
18435/// `'[lo,up)'` / `'(lo,up]'` / `'[lo,up]'` / `'(lo,up)'` /
18436/// `'empty'`. Lower / upper may be empty (unbounded). Returns
18437/// `None` on any parse failure; caller surfaces as hard error.
18438fn parse_range_str(s: &str, kind: spg_storage::RangeKind) -> Option<Value> {
18439    let s = s.trim();
18440    if s.eq_ignore_ascii_case("empty") {
18441        return Some(Value::Range {
18442            kind,
18443            lower: None,
18444            upper: None,
18445            lower_inc: false,
18446            upper_inc: false,
18447            empty: true,
18448        });
18449    }
18450    let bytes = s.as_bytes();
18451    if bytes.len() < 3 {
18452        return None;
18453    }
18454    let lower_inc = match bytes[0] {
18455        b'[' => true,
18456        b'(' => false,
18457        _ => return None,
18458    };
18459    let upper_inc = match bytes[bytes.len() - 1] {
18460        b']' => true,
18461        b')' => false,
18462        _ => return None,
18463    };
18464    let inner = &s[1..s.len() - 1];
18465    let (lo_text, up_text) = inner.split_once(',')?;
18466    let lower = if lo_text.is_empty() {
18467        None
18468    } else {
18469        Some(alloc::boxed::Box::new(parse_range_element(lo_text, kind)?))
18470    };
18471    let upper = if up_text.is_empty() {
18472        None
18473    } else {
18474        Some(alloc::boxed::Box::new(parse_range_element(up_text, kind)?))
18475    };
18476    Some(Value::Range {
18477        kind,
18478        lower,
18479        upper,
18480        lower_inc,
18481        upper_inc,
18482        empty: false,
18483    })
18484}
18485
18486/// v7.17.0 Phase 3.P0-38 — parse a single range bound text into
18487/// the matching element Value for the RangeKind.
18488fn parse_range_element(text: &str, kind: spg_storage::RangeKind) -> Option<Value> {
18489    let text = text.trim().trim_matches('"');
18490    use spg_storage::RangeKind as K;
18491    match kind {
18492        K::Int4 => text.parse::<i32>().ok().map(Value::Int),
18493        K::Int8 => text.parse::<i64>().ok().map(Value::BigInt),
18494        K::Num => {
18495            // Reuse the Numeric parse via the engine's text-coercion
18496            // path; bail to None on failure.
18497            let dot = text.find('.');
18498            let scale: u8 = dot.map_or(0, |p| (text.len() - p - 1) as u8);
18499            let digits: alloc::string::String = text
18500                .chars()
18501                .filter(|c| *c == '-' || c.is_ascii_digit())
18502                .collect();
18503            let scaled: i128 = digits.parse().ok()?;
18504            Some(Value::Numeric { scaled, scale })
18505        }
18506        K::Ts | K::TsTz => {
18507            // Reuse the existing timestamp parse path. v7.17.0
18508            // expects `'YYYY-MM-DD HH:MM:SS[.ffffff]'` in range
18509            // bounds (TZ offset on TsTz is OOS for the initial
18510            // P0-38; ship plain Timestamp shape).
18511            crate::eval::parse_timestamp_literal(text).map(Value::Timestamp)
18512        }
18513        K::Date => crate::eval::parse_date_literal(text).map(Value::Date),
18514    }
18515}
18516
18517/// v7.17.0 Phase 3.P0-38 — render a Range value as its canonical
18518/// PG text form. Re-exported via [`format_range_text`] for use
18519/// from spg-server's pgwire layer.
18520pub fn format_range_text(v: &Value) -> alloc::string::String {
18521    format_range_str(v)
18522}
18523
18524fn format_range_str(v: &Value) -> alloc::string::String {
18525    let Value::Range {
18526        lower,
18527        upper,
18528        lower_inc,
18529        upper_inc,
18530        empty,
18531        ..
18532    } = v
18533    else {
18534        return alloc::string::String::new();
18535    };
18536    if *empty {
18537        return "empty".into();
18538    }
18539    let mut out = alloc::string::String::new();
18540    out.push(if *lower_inc { '[' } else { '(' });
18541    if let Some(l) = lower {
18542        out.push_str(&format_range_element(l));
18543    }
18544    out.push(',');
18545    if let Some(u) = upper {
18546        out.push_str(&format_range_element(u));
18547    }
18548    out.push(if *upper_inc { ']' } else { ')' });
18549    out
18550}
18551
18552fn format_range_element(v: &Value) -> alloc::string::String {
18553    match v {
18554        Value::Int(n) => alloc::format!("{n}"),
18555        Value::BigInt(n) => alloc::format!("{n}"),
18556        Value::Date(d) => crate::eval::format_date(*d),
18557        Value::Timestamp(t) => crate::eval::format_timestamp(*t),
18558        Value::Numeric { scaled, scale } => crate::eval::format_numeric(*scaled, *scale),
18559        other => alloc::format!("{other:?}"),
18560    }
18561}
18562
18563/// v7.17.0 Phase 3.P0-35 — parse a PG `money` literal into i64
18564/// cents. Accepts:
18565///   * Optional leading `-` (negative)
18566///   * Optional `$` prefix
18567///   * Integer portion with optional `,` thousands separators
18568///   * Optional `.` followed by 1-2 digits (cents); 1 digit
18569///     auto-pads to 2 (`.5` → 50 cents).
18570///
18571/// Returns None on any parse failure — caller surfaces as hard
18572/// SQL error.
18573fn parse_money_str(s: &str) -> Option<i64> {
18574    let s = s.trim();
18575    let (neg, rest) = match s.strip_prefix('-') {
18576        Some(r) => (true, r.trim_start()),
18577        None => (false, s),
18578    };
18579    let rest = rest.strip_prefix('$').unwrap_or(rest).trim_start();
18580    let (int_part, frac_part) = match rest.split_once('.') {
18581        Some((i, f)) => (i, Some(f)),
18582        None => (rest, None),
18583    };
18584    if int_part.is_empty() {
18585        return None;
18586    }
18587    // Validate + strip commas from the integer portion.
18588    let mut int_digits = alloc::string::String::with_capacity(int_part.len());
18589    for b in int_part.bytes() {
18590        match b {
18591            b',' => {}
18592            b'0'..=b'9' => int_digits.push(b as char),
18593            _ => return None,
18594        }
18595    }
18596    if int_digits.is_empty() {
18597        return None;
18598    }
18599    let dollars: i64 = int_digits.parse().ok()?;
18600    let cents: i64 = match frac_part {
18601        None => 0,
18602        Some(f) => {
18603            if f.is_empty() || f.len() > 2 || !f.bytes().all(|b| b.is_ascii_digit()) {
18604                return None;
18605            }
18606            let padded = if f.len() == 1 {
18607                alloc::format!("{f}0")
18608            } else {
18609                f.to_string()
18610            };
18611            padded.parse().ok()?
18612        }
18613    };
18614    let total = dollars.checked_mul(100)?.checked_add(cents)?;
18615    Some(if neg { -total } else { total })
18616}
18617
18618/// v7.17.0 Phase 3.P0-34 — parse a PG `timetz` literal
18619/// `HH:MM:SS[.fraction]±HH[:MM]` into (us, offset_secs).
18620///
18621/// The offset suffix is MANDATORY: SPG doesn't have a session TZ
18622/// wired into eval, so a bare `HH:MM:SS` literal would be
18623/// ambiguous. Returns None for any parse failure or out-of-range
18624/// component — caller surfaces as a hard SQL error.
18625///
18626/// Offset range: ±14 hours (±50400 seconds), matching PG's
18627/// internal limit.
18628fn parse_timetz_str(s: &str) -> Option<(i64, i32)> {
18629    let s = s.trim();
18630    // Find the offset sign — scan from right since the time part
18631    // never contains '+' / '-' (after the optional fractional dot
18632    // it's all digits and ':').
18633    let bytes = s.as_bytes();
18634    let sign_pos = bytes
18635        .iter()
18636        .enumerate()
18637        .rev()
18638        .find(|&(_, &b)| b == b'+' || b == b'-')
18639        .map(|(i, _)| i)?;
18640    if sign_pos == 0 {
18641        return None; // bare sign — no time component
18642    }
18643    let time_part = &s[..sign_pos];
18644    let offset_part = &s[sign_pos..];
18645    let us = parse_time_str(time_part)?;
18646    let sign: i32 = if offset_part.starts_with('+') { 1 } else { -1 };
18647    let offset_body = &offset_part[1..];
18648    let (hh_str, mm_str) = match offset_body.split_once(':') {
18649        Some((h, m)) => (h, m),
18650        None => (offset_body, "0"),
18651    };
18652    let hh: i32 = hh_str.parse().ok()?;
18653    let mm: i32 = mm_str.parse().ok()?;
18654    if !(0..=14).contains(&hh) || !(0..=59).contains(&mm) {
18655        return None;
18656    }
18657    let total = sign * (hh * 3600 + mm * 60);
18658    if total.abs() > 50_400 {
18659        return None;
18660    }
18661    Some((us, total))
18662}
18663
18664/// v7.17.0 Phase 3.P0-33 — funnel an integer literal through MySQL
18665/// YEAR range validation: 0 sentinel or 1901..=2155. Out-of-range
18666/// surfaces as a hard SQL error (no silent truncation, mirrors PG
18667/// `time_in` / `uuid_in` discipline).
18668fn coerce_int_to_year(n: i64, col_name: &str) -> Result<Value, EngineError> {
18669    if n == 0 || (1901..=2155).contains(&n) {
18670        // u16::try_from cannot fail in this range; the cast also
18671        // covers the 0 sentinel.
18672        return Ok(Value::Year(n as u16));
18673    }
18674    Err(EngineError::Eval(EvalError::TypeMismatch {
18675        detail: alloc::format!(
18676            "year value out of range: {n} (column `{col_name}`; \
18677             MySQL accepts 0 or 1901..=2155)"
18678        ),
18679    }))
18680}
18681
18682/// v7.17.0 Phase 3.P0-32 — parse a PG `time` literal
18683/// `HH:MM:SS[.fraction]` into microseconds since 00:00:00.
18684///
18685/// Accepts:
18686///   * `HH:MM:SS`            — exact-second precision
18687///   * `HH:MM:SS.f` .. `.ffffff` — 1-6 fractional digits, right-padded
18688///     with zeros to microseconds
18689///
18690/// Range: hour 0..=23, minute 0..=59, second 0..=59. Anything else
18691/// returns None — caller surfaces as a hard SQL error (no silent
18692/// truncation, matches PG's `time_in` behaviour).
18693fn parse_time_str(s: &str) -> Option<i64> {
18694    let s = s.trim();
18695    let (hms, frac) = match s.split_once('.') {
18696        Some((h, f)) => (h, Some(f)),
18697        None => (s, None),
18698    };
18699    let mut parts = hms.split(':');
18700    let hh: u32 = parts.next()?.parse().ok()?;
18701    let mm: u32 = parts.next()?.parse().ok()?;
18702    let ss: u32 = parts.next()?.parse().ok()?;
18703    if parts.next().is_some() {
18704        return None;
18705    }
18706    if hh > 23 || mm > 59 || ss > 59 {
18707        return None;
18708    }
18709    let frac_us: i64 = match frac {
18710        None => 0,
18711        Some(f) => {
18712            if f.is_empty() || f.len() > 6 || !f.bytes().all(|b| b.is_ascii_digit()) {
18713                return None;
18714            }
18715            // Right-pad with zeros so '.5' = 500000 µsec.
18716            let mut padded = alloc::string::String::with_capacity(6);
18717            padded.push_str(f);
18718            while padded.len() < 6 {
18719                padded.push('0');
18720            }
18721            padded.parse().ok()?
18722        }
18723    };
18724    Some(
18725        i64::from(hh) * 3_600_000_000
18726            + i64::from(mm) * 60_000_000
18727            + i64::from(ss) * 1_000_000
18728            + frac_us,
18729    )
18730}
18731
18732const fn column_type_to_data_type(t: ColumnTypeName) -> DataType {
18733    match t {
18734        ColumnTypeName::SmallInt => DataType::SmallInt,
18735        ColumnTypeName::Int => DataType::Int,
18736        ColumnTypeName::BigInt => DataType::BigInt,
18737        ColumnTypeName::Float => DataType::Float,
18738        ColumnTypeName::Text => DataType::Text,
18739        ColumnTypeName::Varchar(n) => DataType::Varchar(n),
18740        ColumnTypeName::Char(n) => DataType::Char(n),
18741        ColumnTypeName::Bool => DataType::Bool,
18742        ColumnTypeName::Vector { dim, encoding } => DataType::Vector {
18743            dim,
18744            encoding: match encoding {
18745                SqlVecEncoding::F32 => VecEncoding::F32,
18746                SqlVecEncoding::Sq8 => VecEncoding::Sq8,
18747                SqlVecEncoding::F16 => VecEncoding::F16,
18748            },
18749        },
18750        ColumnTypeName::Numeric(precision, scale) => DataType::Numeric { precision, scale },
18751        ColumnTypeName::Date => DataType::Date,
18752        ColumnTypeName::Timestamp => DataType::Timestamp,
18753        ColumnTypeName::Timestamptz => DataType::Timestamptz,
18754        ColumnTypeName::Json => DataType::Json,
18755        ColumnTypeName::Jsonb => DataType::Jsonb,
18756        ColumnTypeName::Bytes => DataType::Bytes,
18757        ColumnTypeName::TextArray => DataType::TextArray,
18758        ColumnTypeName::IntArray => DataType::IntArray,
18759        ColumnTypeName::BigIntArray => DataType::BigIntArray,
18760        ColumnTypeName::TsVector => DataType::TsVector,
18761        ColumnTypeName::TsQuery => DataType::TsQuery,
18762        ColumnTypeName::Uuid => DataType::Uuid,
18763        ColumnTypeName::Time => DataType::Time,
18764        ColumnTypeName::Year => DataType::Year,
18765        ColumnTypeName::TimeTz => DataType::TimeTz,
18766        ColumnTypeName::Money => DataType::Money,
18767        ColumnTypeName::Range(k) => DataType::Range(match k {
18768            spg_sql::ast::RangeKindAst::Int4 => spg_storage::RangeKind::Int4,
18769            spg_sql::ast::RangeKindAst::Int8 => spg_storage::RangeKind::Int8,
18770            spg_sql::ast::RangeKindAst::Num => spg_storage::RangeKind::Num,
18771            spg_sql::ast::RangeKindAst::Ts => spg_storage::RangeKind::Ts,
18772            spg_sql::ast::RangeKindAst::TsTz => spg_storage::RangeKind::TsTz,
18773            spg_sql::ast::RangeKindAst::Date => spg_storage::RangeKind::Date,
18774        }),
18775        ColumnTypeName::Hstore => DataType::Hstore,
18776        ColumnTypeName::IntArray2D => DataType::IntArray2D,
18777        ColumnTypeName::BigIntArray2D => DataType::BigIntArray2D,
18778        ColumnTypeName::TextArray2D => DataType::TextArray2D,
18779    }
18780}
18781
18782/// Convert an INSERT VALUES expression to a storage Value. Supports literal
18783/// expressions, unary-minus over numeric literals, and pgvector-style
18784/// `'[..]'::vector` cast (v1.2). Anything more complex returns `Unsupported`.
18785fn literal_expr_to_value(expr: Expr) -> Result<Value, EngineError> {
18786    match expr {
18787        Expr::Literal(l) => Ok(literal_to_value(l)),
18788        Expr::Cast { expr, target } => {
18789            let inner_value = literal_expr_to_value(*expr)?;
18790            crate::eval::cast_value(inner_value, target).map_err(EngineError::Eval)
18791        }
18792        Expr::Unary {
18793            op: UnOp::Neg,
18794            expr,
18795        } => match *expr {
18796            Expr::Literal(Literal::Integer(n)) => {
18797                // Fold to i32 if it fits, else BigInt. Parser emits Integer(i64)
18798                // — overflow on negate of i64::MIN is the one edge case.
18799                let neg = n.checked_neg().ok_or_else(|| {
18800                    EngineError::Unsupported("integer literal overflow on negation".into())
18801                })?;
18802                Ok(int_value_for(neg))
18803            }
18804            Expr::Literal(Literal::Float(x)) => Ok(Value::Float(-x)),
18805            other => Err(EngineError::Unsupported(alloc::format!(
18806                "unary minus over non-literal expression: {other:?}"
18807            ))),
18808        },
18809        // v7.10.10 — `ARRAY[lit, lit, …]` constructor accepted at
18810        // INSERT-time. Each element must reduce to a Value through
18811        // `literal_expr_to_value`; NULL elements become `None`.
18812        // v7.11.13 — deduce shape from element values: all Int →
18813        // IntArray; any BigInt → BigIntArray (widening); any Text
18814        // → TextArray. Cast targets (`ARRAY[]::INT[]`) flow through
18815        // the outer Cast arm before reaching here and re-coerce.
18816        Expr::Array(items) => {
18817            let mut materialised: alloc::vec::Vec<Value> =
18818                alloc::vec::Vec::with_capacity(items.len());
18819            for elem in items {
18820                materialised.push(literal_expr_to_value(elem)?);
18821            }
18822            Ok(array_literal_widen(materialised))
18823        }
18824        // Any other Expr shape — fall back to a general evaluation
18825        // against an empty row + empty schema. This unblocks the
18826        // app-common patterns where INSERT VALUES carries a
18827        // non-correlated function call:
18828        //   INSERT INTO t VALUES (concat('U-', 42))
18829        //   INSERT INTO t VALUES (now())
18830        //   INSERT INTO t VALUES (format('%s-%s', 'a', 'b'))
18831        // Any expression that references a column or `$N`
18832        // placeholder fails cleanly inside `eval_expr` with a
18833        // descriptive error; literals + casts + ARRAY[…] continue
18834        // to take the fast paths above so the hot INSERT path is
18835        // unchanged on the common case.
18836        other => {
18837            let empty_schema: alloc::vec::Vec<spg_storage::ColumnSchema> = alloc::vec::Vec::new();
18838            let ctx = EvalContext::new(&empty_schema, None);
18839            let empty_row = spg_storage::Row::new(alloc::vec::Vec::new());
18840            crate::eval::eval_expr(&other, &empty_row, &ctx).map_err(EngineError::Eval)
18841        }
18842    }
18843}
18844
18845fn literal_to_value(l: Literal) -> Value {
18846    match l {
18847        Literal::Integer(n) => int_value_for(n),
18848        Literal::Float(x) => Value::Float(x),
18849        Literal::String(s) => Value::Text(s),
18850        Literal::Bool(b) => Value::Bool(b),
18851        Literal::Null => Value::Null,
18852        Literal::Vector(v) => Value::Vector(v),
18853        Literal::TextArray(items) => Value::TextArray(items),
18854        Literal::IntArray(items) => Value::IntArray(items),
18855        Literal::BigIntArray(items) => Value::BigIntArray(items),
18856        Literal::Interval { months, micros, .. } => Value::Interval { months, micros },
18857    }
18858}
18859
18860/// Pick `Int` (`i32`) when the literal fits, else `BigInt`. `INT` vs `BIGINT`
18861/// columns will still enforce the right tag downstream — this is just the
18862/// default we synthesise from an unannotated integer literal.
18863fn int_value_for(n: i64) -> Value {
18864    if let Ok(small) = i32::try_from(n) {
18865        Value::Int(small)
18866    } else {
18867        Value::BigInt(n)
18868    }
18869}
18870
18871/// Widen / narrow `v` to fit `expected`. Numerics permit safe widening
18872/// (`Int → BigInt`, `Int/BigInt → Float`) and best-effort narrowing
18873/// (`BigInt → Int` succeeds only when the value fits in `i32`). Everything
18874/// else returns `TypeMismatch` carrying the column name for caller diagnostics.
18875/// `NULL` is always permitted; the nullability check happens later in storage.
18876#[allow(clippy::too_many_lines)]
18877/// v7.17.0 Phase 4.4 — reject negative integer values on UNSIGNED
18878/// columns. Called after `coerce_value` at each INSERT / UPDATE
18879/// site that has ColumnSchema context. NULL passes through (a
18880/// nullable UNSIGNED column can legitimately hold NULL).
18881fn check_unsigned_range(
18882    v: &Value,
18883    schema: &ColumnSchema,
18884    position: usize,
18885) -> Result<(), EngineError> {
18886    if !schema.is_unsigned {
18887        return Ok(());
18888    }
18889    let n = match v {
18890        Value::SmallInt(x) => i64::from(*x),
18891        Value::Int(x) => i64::from(*x),
18892        Value::BigInt(x) => *x,
18893        _ => return Ok(()), // non-integer cells (NULL, default) skip
18894    };
18895    if n < 0 {
18896        return Err(EngineError::Unsupported(alloc::format!(
18897            "column {:?} is UNSIGNED but got negative value {n} at position {position}",
18898            schema.name
18899        )));
18900    }
18901    Ok(())
18902}
18903
18904fn coerce_value(
18905    v: Value,
18906    expected: DataType,
18907    col_name: &str,
18908    position: usize,
18909) -> Result<Value, EngineError> {
18910    if v.is_null() {
18911        return Ok(Value::Null);
18912    }
18913    let actual = v.data_type().expect("non-null");
18914    if actual == expected {
18915        return Ok(v);
18916    }
18917    let coerced = match (v, expected) {
18918        (Value::Int(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
18919        (Value::Int(n), DataType::Float) => Some(Value::Float(f64::from(n))),
18920        (Value::Int(n), DataType::SmallInt) => i16::try_from(n).ok().map(Value::SmallInt),
18921        (Value::Int(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
18922            i128::from(n),
18923            precision,
18924            scale,
18925            col_name,
18926        )?),
18927        (Value::SmallInt(n), DataType::Int) => Some(Value::Int(i32::from(n))),
18928        (Value::SmallInt(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
18929        (Value::SmallInt(n), DataType::Float) => Some(Value::Float(f64::from(n))),
18930        (Value::SmallInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
18931            i128::from(n),
18932            precision,
18933            scale,
18934            col_name,
18935        )?),
18936        (Value::BigInt(n), DataType::Int) => i32::try_from(n).ok().map(Value::Int),
18937        (Value::BigInt(n), DataType::SmallInt) => i16::try_from(n).ok().map(Value::SmallInt),
18938        #[allow(clippy::cast_precision_loss)]
18939        (Value::BigInt(n), DataType::Float) => Some(Value::Float(n as f64)),
18940        (Value::BigInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
18941            i128::from(n),
18942            precision,
18943            scale,
18944            col_name,
18945        )?),
18946        (Value::Float(x), DataType::Numeric { precision, scale }) => {
18947            Some(numeric_from_float(x, precision, scale, col_name)?)
18948        }
18949        // v7.17.0 Phase 3.P0-67 — Text → NUMERIC. Parse a
18950        // canonical decimal text (`"-1234.56"` / `"42"` /
18951        // `"0.0001"`) into `(mantissa, source_scale)` and rescale
18952        // to the column's declared scale. Required for prepared
18953        // binds: `value_to_literal` flattens a Value::Numeric
18954        // into a TEXT literal because Literal carries no native
18955        // Numeric variant, so the placeholder substitution path
18956        // reaches coerce_value as Text → Numeric. Without this
18957        // arm the round-trip surfaces a TypeMismatch even though
18958        // the cell already left the engine as a valid Numeric.
18959        (Value::Text(s), DataType::Numeric { precision, scale }) => {
18960            let Some((mantissa, src_scale)) = parse_numeric_text(&s) else {
18961                return Err(EngineError::Eval(EvalError::TypeMismatch {
18962                    detail: alloc::format!("cannot parse {s:?} as NUMERIC for column `{col_name}`"),
18963                }));
18964            };
18965            Some(numeric_rescale(
18966                mantissa, src_scale, precision, scale, col_name,
18967            )?)
18968        }
18969        // Text → DATE / TIMESTAMP: parse canonical text forms.
18970        (Value::Text(s), DataType::Date) => {
18971            let d = eval::parse_date_literal(&s).ok_or_else(|| {
18972                EngineError::Eval(EvalError::TypeMismatch {
18973                    detail: alloc::format!("cannot parse {s:?} as DATE for column `{col_name}`"),
18974                })
18975            })?;
18976            Some(Value::Date(d))
18977        }
18978        // v7.14.0 — MySQL DEFAULT clauses quote integer / float
18979        // / boolean literals (`DEFAULT '0'`, `DEFAULT '1'`,
18980        // `DEFAULT '3.14'`, `DEFAULT 'true'`). Coerce the text
18981        // form to the column's numeric / bool type at DEFAULT-
18982        // installation time so the storage check sees a typed
18983        // value. Parse failures fall through to TypeMismatch.
18984        (Value::Text(s), DataType::SmallInt) => s.parse::<i16>().ok().map(Value::SmallInt),
18985        (Value::Text(s), DataType::Int) => s.parse::<i32>().ok().map(Value::Int),
18986        (Value::Text(s), DataType::BigInt) => s.parse::<i64>().ok().map(Value::BigInt),
18987        (Value::Text(s), DataType::Float) => s.parse::<f64>().ok().map(Value::Float),
18988        (Value::Text(s), DataType::Bool) => match s.to_ascii_lowercase().as_str() {
18989            "0" | "false" | "f" | "no" | "off" => Some(Value::Bool(false)),
18990            "1" | "true" | "t" | "yes" | "on" => Some(Value::Bool(true)),
18991            _ => None,
18992        },
18993        // v7.17.0 Phase 3.P0-46 — MySQL TINYINT(1) (which Phase 4.3
18994        // classifies as DataType::Bool) is the storage shape every
18995        // mysqldump-restored boolean column lands in. mysqldump emits
18996        // the values as integer `0` / `1` literals, so int → bool
18997        // coerce on INSERT is required for a 0-change cutover. MySQL's
18998        // rule is "any non-zero is truthy"; we follow that for all
18999        // signed int widths so the same coerce path serves an
19000        // explicit `BOOLEAN` column too.
19001        (Value::Int(n), DataType::Bool) => Some(Value::Bool(n != 0)),
19002        (Value::SmallInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
19003        (Value::BigInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
19004        // v4.9: Text ↔ JSON coercion. No structural validation —
19005        // any text literal is accepted; the responsibility for
19006        // valid JSON lies with the producer.
19007        (Value::Text(s), DataType::Json | DataType::Jsonb) => Some(Value::Json(s)),
19008        (Value::Json(s), DataType::Text) => Some(Value::Text(s)),
19009        // v7.13.3 — mailrs round-7 S10. SPG's storage represents
19010        // both JSON and JSONB on-disk as `Value::Json(String)` —
19011        // they share the underlying text payload. The cast
19012        // `'<text>'::jsonb` produces a Value::Json that needs to
19013        // satisfy a DataType::Jsonb column. Identity coerce in
19014        // both directions so JSON ↔ JSONB assignments work at all
19015        // INSERT / ALTER COLUMN TYPE / DEFAULT contexts.
19016        (Value::Json(s), DataType::Jsonb | DataType::Json) => Some(Value::Json(s)),
19017        // v7.10.4 — Text → BYTEA. Decode PG-style literal forms:
19018        //   - Hex:    `\x48656c6c6f`  (case-insensitive hex pairs)
19019        //   - Escape: `Hello\\000world`  (backslash + octal triples)
19020        //   - Plain:  any string → raw UTF-8 bytes (PG also accepts)
19021        // Errors surface as TypeMismatch so the operator gets a
19022        // clear "this literal isn't a bytea literal" hint.
19023        (Value::Text(s), DataType::Bytes) => {
19024            let bytes = decode_bytea_literal(&s).map_err(|e| {
19025                EngineError::Eval(EvalError::TypeMismatch {
19026                    detail: alloc::format!(
19027                        "cannot parse {s:?} as BYTEA for column `{col_name}`: {e}"
19028                    ),
19029                })
19030            })?;
19031            Some(Value::Bytes(bytes))
19032        }
19033        // v7.10.4 — BYTEA → Text round-trip uses the PG hex
19034        // output (lowercase, `\x` prefix). Important when a
19035        // SELECT pulls a bytea cell through a Text column path.
19036        (Value::Bytes(b), DataType::Text) => Some(Value::Text(encode_bytea_hex(&b))),
19037        // v7.17.0 — Text → UUID. PG accepts canonical hyphenated,
19038        // unhyphenated, uppercase, and `{...}`-braced forms; we
19039        // funnel all four through `spg_storage::parse_uuid_str`.
19040        // A malformed literal surfaces as a SQL TypeMismatch
19041        // rather than silently inserting garbage — `0-change
19042        // cutover` requires that an app inserting bad UUID text
19043        // sees the same hard error PG would raise.
19044        (Value::Text(s), DataType::Uuid) => match spg_storage::parse_uuid_str(&s) {
19045            Some(b) => Some(Value::Uuid(b)),
19046            None => {
19047                return Err(EngineError::Eval(EvalError::TypeMismatch {
19048                    detail: alloc::format!(
19049                        "invalid input syntax for type uuid: {s:?} (column `{col_name}`)"
19050                    ),
19051                }));
19052            }
19053        },
19054        // v7.17.0 — UUID → Text canonical 8-4-4-4-12 lowercase.
19055        // Surfaces when a SELECT plucks a uuid cell through a
19056        // Text column path (e.g. INSERT INTO log SELECT id::text
19057        // FROM other_table).
19058        (Value::Uuid(b), DataType::Text) => Some(Value::Text(spg_storage::format_uuid(&b))),
19059        // v7.17.0 Phase 3.P0-32 — Text → TIME. Accepts
19060        // `HH:MM:SS` and `HH:MM:SS.ffffff` (1-6 fractional digits).
19061        // Out-of-range hour/min/sec is a hard SQL error (no
19062        // silent truncation — same 0-change-cutover discipline
19063        // we apply to UUID).
19064        (Value::Text(s), DataType::Time) => match parse_time_str(&s) {
19065            Some(us) => Some(Value::Time(us)),
19066            None => {
19067                return Err(EngineError::Eval(EvalError::TypeMismatch {
19068                    detail: alloc::format!(
19069                        "invalid input syntax for type time: {s:?} (column `{col_name}`)"
19070                    ),
19071                }));
19072            }
19073        },
19074        // v7.17.0 Phase 3.P0-32 — TIME → Text canonical `HH:MM:SS[.ffffff]`.
19075        (Value::Time(us), DataType::Text) => Some(Value::Text(eval::format_time(us))),
19076        // v7.17.0 Phase 3.P0-33 — int / bigint → YEAR. Range
19077        // check enforces the MySQL canonical 1901..=2155 + 0
19078        // sentinel; out-of-range is a hard SQL error (no silent
19079        // truncation, mirrors P0-32 / P0-25 discipline).
19080        (Value::SmallInt(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
19081        (Value::Int(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
19082        (Value::BigInt(n), DataType::Year) => Some(coerce_int_to_year(n, col_name)?),
19083        // Text → YEAR. Accepts the 4-digit decimal form only;
19084        // two-digit YEAR (`'99'` → 1999) was deprecated in MySQL
19085        // 5.7 and is out of scope for v7.17.0.
19086        (Value::Text(s), DataType::Year) => match s.trim().parse::<i64>() {
19087            Ok(n) => Some(coerce_int_to_year(n, col_name)?),
19088            Err(_) => {
19089                return Err(EngineError::Eval(EvalError::TypeMismatch {
19090                    detail: alloc::format!(
19091                        "invalid input syntax for type year: {s:?} (column `{col_name}`)"
19092                    ),
19093                }));
19094            }
19095        },
19096        // YEAR → Text 4-digit zero-padded.
19097        (Value::Year(y), DataType::Text) => Some(Value::Text(alloc::format!("{y:04}"))),
19098        // v7.17.0 Phase 3.P0-34 — Text → TIMETZ. Mandatory
19099        // signed offset suffix; missing offset is a hard error
19100        // (SPG has no session TZ wired into eval, unlike PG).
19101        (Value::Text(s), DataType::TimeTz) => match parse_timetz_str(&s) {
19102            Some((us, offset_secs)) => Some(Value::TimeTz { us, offset_secs }),
19103            None => {
19104                return Err(EngineError::Eval(EvalError::TypeMismatch {
19105                    detail: alloc::format!(
19106                        "invalid input syntax for type time with time zone: \
19107                         {s:?} (column `{col_name}`)"
19108                    ),
19109                }));
19110            }
19111        },
19112        // TIMETZ → Text canonical `HH:MM:SS[.ffffff]±HH[:MM]`.
19113        (Value::TimeTz { us, offset_secs }, DataType::Text) => {
19114            Some(Value::Text(eval::format_timetz(us, offset_secs)))
19115        }
19116        // v7.17.0 Phase 3.P0-35 — Text → MONEY. Accepts `$N.NN`,
19117        // `$N,NNN.NN`, optional leading `-`. Bare numeric literals
19118        // arrive via the Int/BigInt/Float/Numeric arms below.
19119        (Value::Text(s), DataType::Money) => match parse_money_str(&s) {
19120            Some(c) => Some(Value::Money(c)),
19121            None => {
19122                return Err(EngineError::Eval(EvalError::TypeMismatch {
19123                    detail: alloc::format!(
19124                        "invalid input syntax for type money: {s:?} (column `{col_name}`)"
19125                    ),
19126                }));
19127            }
19128        },
19129        // Int / BigInt / SmallInt / Float / Numeric → MONEY.
19130        // Bare numeric literal is interpreted as a major-unit
19131        // amount (matches PG: `100`::money → $100.00 = 10000 cents).
19132        (Value::SmallInt(n), DataType::Money) => {
19133            Some(Value::Money(i64::from(n).saturating_mul(100)))
19134        }
19135        (Value::Int(n), DataType::Money) => Some(Value::Money(i64::from(n).saturating_mul(100))),
19136        (Value::BigInt(n), DataType::Money) => Some(Value::Money(n.saturating_mul(100))),
19137        (Value::Float(x), DataType::Money) => {
19138            // Round half-away-from-zero to cents (no_std — no
19139            // `f64::round`, so hand-roll via biased truncation).
19140            let scaled = x * 100.0;
19141            let cents = if scaled >= 0.0 {
19142                (scaled + 0.5) as i64
19143            } else {
19144                (scaled - 0.5) as i64
19145            };
19146            Some(Value::Money(cents))
19147        }
19148        (Value::Numeric { scaled, scale }, DataType::Money) => {
19149            // Convert exact decimal to cents (scale 2). If scale > 2,
19150            // round half-away-from-zero. If scale < 2, multiply up.
19151            let cents = if scale == 2 {
19152                scaled
19153            } else if scale < 2 {
19154                let mult = 10_i128.pow(u32::from(2 - scale));
19155                scaled.saturating_mul(mult)
19156            } else {
19157                let div = 10_i128.pow(u32::from(scale - 2));
19158                let half = div / 2;
19159                let bias = if scaled >= 0 { half } else { -half };
19160                (scaled + bias) / div
19161            };
19162            Some(Value::Money(i64::try_from(cents).unwrap_or(i64::MAX)))
19163        }
19164        // MONEY → Text canonical `$N,NNN.CC`.
19165        (Value::Money(c), DataType::Text) => Some(Value::Text(eval::format_money(c))),
19166        // v7.17.0 Phase 3.P0-38 — Text → Range. Accepts canonical
19167        // PG forms: `'empty'`, `'[a,b)'`, `'(a,b]'`, `'[a,b]'`,
19168        // `'(a,b)'`, with empty lower or upper for unbounded.
19169        (Value::Text(s), DataType::Range(kind)) => match parse_range_str(&s, kind) {
19170            Some(v) => Some(v),
19171            None => {
19172                return Err(EngineError::Eval(EvalError::TypeMismatch {
19173                    detail: alloc::format!(
19174                        "invalid input syntax for range type: {s:?} (column `{col_name}`)"
19175                    ),
19176                }));
19177            }
19178        },
19179        // Range → Text canonical form (`[a,b)`, `'empty'`, etc).
19180        (v @ Value::Range { .. }, DataType::Text) => Some(Value::Text(format_range_str(&v))),
19181        // v7.17.0 Phase 3.P0-39 — Text → Hstore.
19182        (Value::Text(s), DataType::Hstore) => match parse_hstore_str(&s) {
19183            Some(pairs) => Some(Value::Hstore(pairs)),
19184            None => {
19185                return Err(EngineError::Eval(EvalError::TypeMismatch {
19186                    detail: alloc::format!(
19187                        "invalid input syntax for type hstore: {s:?} (column `{col_name}`)"
19188                    ),
19189                }));
19190            }
19191        },
19192        // Hstore → Text canonical `"k"=>"v"` form.
19193        (Value::Hstore(pairs), DataType::Text) => Some(Value::Text(format_hstore_str(&pairs))),
19194        // v7.17.0 Phase 3.P0-40 — Text → 2D arrays via PG
19195        // external `'{{a,b},{c,d}}'` literal.
19196        (Value::Text(s), DataType::IntArray2D) => match parse_int_2d_literal(&s) {
19197            Ok(m) => Some(Value::IntArray2D(m)),
19198            Err(e) => {
19199                return Err(EngineError::Eval(EvalError::TypeMismatch {
19200                    detail: alloc::format!(
19201                        "invalid input syntax for INT[][]: {s:?} (column `{col_name}`): {e}"
19202                    ),
19203                }));
19204            }
19205        },
19206        (Value::Text(s), DataType::BigIntArray2D) => match parse_bigint_2d_literal(&s) {
19207            Ok(m) => Some(Value::BigIntArray2D(m)),
19208            Err(e) => {
19209                return Err(EngineError::Eval(EvalError::TypeMismatch {
19210                    detail: alloc::format!(
19211                        "invalid input syntax for BIGINT[][]: {s:?} (column `{col_name}`): {e}"
19212                    ),
19213                }));
19214            }
19215        },
19216        (Value::Text(s), DataType::TextArray2D) => match parse_text_2d_literal(&s) {
19217            Ok(m) => Some(Value::TextArray2D(m)),
19218            Err(e) => {
19219                return Err(EngineError::Eval(EvalError::TypeMismatch {
19220                    detail: alloc::format!(
19221                        "invalid input syntax for TEXT[][]: {s:?} (column `{col_name}`): {e}"
19222                    ),
19223                }));
19224            }
19225        },
19226        // 2D arrays → Text canonical nested form.
19227        (Value::IntArray2D(rows), DataType::Text) => Some(Value::Text(format_int_2d_text(&rows))),
19228        (Value::BigIntArray2D(rows), DataType::Text) => {
19229            Some(Value::Text(format_bigint_2d_text(&rows)))
19230        }
19231        (Value::TextArray2D(rows), DataType::Text) => Some(Value::Text(format_text_2d_text(&rows))),
19232        // v7.10.11 — Text → TEXT[]. Decode PG's external array
19233        // form `'{a,b,NULL}'`. NULL element token (case-insensitive)
19234        // is the literal `NULL`; everything else is a quoted or
19235        // unquoted text element. mailrs `'{label1,label2}'::TEXT[]`.
19236        (Value::Text(s), DataType::TextArray) => {
19237            let arr = decode_text_array_literal(&s).map_err(|e| {
19238                EngineError::Eval(EvalError::TypeMismatch {
19239                    detail: alloc::format!(
19240                        "cannot parse {s:?} as TEXT[] for column `{col_name}`: {e}"
19241                    ),
19242                })
19243            })?;
19244            Some(Value::TextArray(arr))
19245        }
19246        // v7.16.0 — Text → IntArray / BigIntArray for the
19247        // spg-sqlx Bind path. Decode the PG external form
19248        // `{1,2,3}` as a TEXT array first, then parse each
19249        // element as int. Same shape as the TextArray decode
19250        // above with an element-wise narrow.
19251        (Value::Text(s), DataType::IntArray) => {
19252            let arr = decode_text_array_literal(&s).map_err(|e| {
19253                EngineError::Eval(EvalError::TypeMismatch {
19254                    detail: alloc::format!(
19255                        "cannot parse {s:?} as INT[] for column `{col_name}`: {e}"
19256                    ),
19257                })
19258            })?;
19259            let mut out: Vec<Option<i32>> = Vec::with_capacity(arr.len());
19260            for elem in arr {
19261                match elem {
19262                    None => out.push(None),
19263                    Some(t) => {
19264                        let n: i32 = t.parse().map_err(|_| {
19265                            EngineError::Eval(EvalError::TypeMismatch {
19266                                detail: alloc::format!(
19267                                    "cannot parse {t:?} as INT element for `{col_name}`"
19268                                ),
19269                            })
19270                        })?;
19271                        out.push(Some(n));
19272                    }
19273                }
19274            }
19275            Some(Value::IntArray(out))
19276        }
19277        (Value::Text(s), DataType::BigIntArray) => {
19278            let arr = decode_text_array_literal(&s).map_err(|e| {
19279                EngineError::Eval(EvalError::TypeMismatch {
19280                    detail: alloc::format!(
19281                        "cannot parse {s:?} as BIGINT[] for column `{col_name}`: {e}"
19282                    ),
19283                })
19284            })?;
19285            let mut out: Vec<Option<i64>> = Vec::with_capacity(arr.len());
19286            for elem in arr {
19287                match elem {
19288                    None => out.push(None),
19289                    Some(t) => {
19290                        let n: i64 = t.parse().map_err(|_| {
19291                            EngineError::Eval(EvalError::TypeMismatch {
19292                                detail: alloc::format!(
19293                                    "cannot parse {t:?} as BIGINT element for `{col_name}`"
19294                                ),
19295                            })
19296                        })?;
19297                        out.push(Some(n));
19298                    }
19299                }
19300            }
19301            Some(Value::BigIntArray(out))
19302        }
19303        // v7.10.11 — TEXT[] → Text round-trip uses PG's
19304        // external array form (`{a,b,NULL}`). Lets a SELECT
19305        // pull an array column through any Text-side codepath.
19306        (Value::TextArray(items), DataType::Text) => Some(Value::Text(encode_text_array(&items))),
19307        // v7.17.0 Phase 3.P0-68 — Text → VECTOR auto-coerce.
19308        // Matches the existing Text → TsVector arm and the
19309        // `::vector` cast: PG-canonical pgvector external form
19310        // (`'[1, 2, -3]'`) becomes a typed Vector value at the
19311        // column boundary. Dim mismatch surfaces as TypeMismatch.
19312        // For SQ8 / HALF encodings we chain through the standard
19313        // quantise helpers so the storage shape matches the
19314        // declared encoding without a second coerce pass.
19315        (Value::Text(s), DataType::Vector { dim, encoding }) => {
19316            let parsed = eval::parse_vector_text(&s).ok_or_else(|| {
19317                EngineError::Eval(EvalError::TypeMismatch {
19318                    detail: alloc::format!("cannot parse {s:?} as VECTOR for column `{col_name}`"),
19319                })
19320            })?;
19321            if parsed.len() != dim as usize {
19322                return Err(EngineError::Eval(EvalError::TypeMismatch {
19323                    detail: alloc::format!(
19324                        "VECTOR({dim}) column `{col_name}` rejects literal of length {}",
19325                        parsed.len()
19326                    ),
19327                }));
19328            }
19329            Some(match encoding {
19330                VecEncoding::F32 => Value::Vector(parsed),
19331                VecEncoding::Sq8 => Value::Sq8Vector(spg_storage::quantize::quantize(&parsed)),
19332                VecEncoding::F16 => {
19333                    Value::HalfVector(spg_storage::halfvec::HalfVector::from_f32_slice(&parsed))
19334                }
19335            })
19336        }
19337        // v7.16.1 — Text → TSVECTOR auto-coerce for the
19338        // INSERT-side wire path (mailrs round-9 A.2.a). PG
19339        // implicitly promotes the TEXT literal at INSERT into a
19340        // TSVECTOR column; SPG previously rejected with a hard
19341        // type mismatch, blocking 23,276 pg_dump rows into
19342        // `messages.search_vector`. We route through the same
19343        // `decode_tsvector_external` the `::tsvector` cast
19344        // already uses, so PG-canonical forms (`'word'`,
19345        // `'word:1A,2B'`, multi-lexeme, empty `''`) all parse.
19346        (Value::Text(s), DataType::TsVector) => {
19347            let lexs = eval::decode_tsvector_external(&s).map_err(|e| {
19348                EngineError::Eval(EvalError::TypeMismatch {
19349                    detail: alloc::format!(
19350                        "cannot parse {s:?} as TSVECTOR for column `{col_name}`: {e}"
19351                    ),
19352                })
19353            })?;
19354            Some(Value::TsVector(lexs))
19355        }
19356        (Value::Text(s), DataType::Timestamp | DataType::Timestamptz) => {
19357            let t = eval::parse_timestamp_literal(&s).ok_or_else(|| {
19358                EngineError::Eval(EvalError::TypeMismatch {
19359                    detail: alloc::format!(
19360                        "cannot parse {s:?} as TIMESTAMP for column `{col_name}`"
19361                    ),
19362                })
19363            })?;
19364            Some(Value::Timestamp(t))
19365        }
19366        // DATE ↔ TIMESTAMP convertibility (DATE → midnight,
19367        // TIMESTAMP → day truncation).
19368        (Value::Date(d), DataType::Timestamp | DataType::Timestamptz) => {
19369            Some(Value::Timestamp(i64::from(d) * 86_400_000_000))
19370        }
19371        // v7.9.21 — Value::Timestamp lands in either Timestamp
19372        // or Timestamptz columns; the on-disk layout is the
19373        // same i64 microseconds UTC.
19374        (Value::Timestamp(t), DataType::Timestamptz) => Some(Value::Timestamp(t)),
19375        (Value::Timestamp(t), DataType::Date) => {
19376            let days = t.div_euclid(86_400_000_000);
19377            i32::try_from(days).ok().map(Value::Date)
19378        }
19379        (
19380            Value::Numeric {
19381                scaled,
19382                scale: src_scale,
19383            },
19384            DataType::Numeric { precision, scale },
19385        ) => Some(numeric_rescale(
19386            scaled, src_scale, precision, scale, col_name,
19387        )?),
19388        #[allow(clippy::cast_precision_loss)]
19389        (Value::Numeric { scaled, scale }, DataType::Float) => {
19390            let mut div = 1.0_f64;
19391            for _ in 0..scale {
19392                div *= 10.0;
19393            }
19394            Some(Value::Float((scaled as f64) / div))
19395        }
19396        (Value::Numeric { scaled, scale }, DataType::Int) => {
19397            let truncated = numeric_truncate_to_integer(scaled, scale);
19398            i32::try_from(truncated).ok().map(Value::Int)
19399        }
19400        (Value::Numeric { scaled, scale }, DataType::BigInt) => {
19401            let truncated = numeric_truncate_to_integer(scaled, scale);
19402            i64::try_from(truncated).ok().map(Value::BigInt)
19403        }
19404        (Value::Numeric { scaled, scale }, DataType::SmallInt) => {
19405            let truncated = numeric_truncate_to_integer(scaled, scale);
19406            i16::try_from(truncated).ok().map(Value::SmallInt)
19407        }
19408        // VARCHAR(n) enforces an upper bound on character count.
19409        (Value::Text(s), DataType::Varchar(max)) => {
19410            if u32::try_from(s.chars().count()).unwrap_or(u32::MAX) <= max {
19411                Some(Value::Text(s))
19412            } else {
19413                return Err(EngineError::Unsupported(alloc::format!(
19414                    "value for VARCHAR({max}) column `{col_name}` exceeds length: \
19415                     {} chars",
19416                    s.chars().count()
19417                )));
19418            }
19419        }
19420        // v6.0.1: f32 → SQ8 INSERT-time quantisation. Triggered
19421        // when the column declares `VECTOR(N) USING SQ8` and
19422        // the INSERT VALUES expression yields a raw f32 vector
19423        // (the normal pgvector-shape literal). Dim mismatch
19424        // falls through the `_ => None` arm and surfaces as
19425        // `TypeMismatch` with the expected SQ8 column type —
19426        // matching the F32 path's existing error.
19427        (
19428            Value::Vector(v),
19429            DataType::Vector {
19430                dim,
19431                encoding: VecEncoding::Sq8,
19432            },
19433        ) if v.len() == dim as usize => Some(Value::Sq8Vector(spg_storage::quantize::quantize(&v))),
19434        // v6.0.3: f32 → f16 INSERT-time conversion for HALF
19435        // columns. Bit-exact at the storage layer (modulo
19436        // half-precision rounding); no rerank pass needed at
19437        // search time.
19438        (
19439            Value::Vector(v),
19440            DataType::Vector {
19441                dim,
19442                encoding: VecEncoding::F16,
19443            },
19444        ) if v.len() == dim as usize => Some(Value::HalfVector(
19445            spg_storage::halfvec::HalfVector::from_f32_slice(&v),
19446        )),
19447        // CHAR(n) right-pads with U+0020 to exactly n chars; if the input
19448        // is already longer we reject (PG truncates trailing-space-only;
19449        // staying strict for v1).
19450        (Value::Text(s), DataType::Char(size)) => {
19451            let len = u32::try_from(s.chars().count()).unwrap_or(u32::MAX);
19452            if len > size {
19453                return Err(EngineError::Unsupported(alloc::format!(
19454                    "value for CHAR({size}) column `{col_name}` exceeds length: \
19455                     {len} chars"
19456                )));
19457            }
19458            let need = (size - len) as usize;
19459            let mut padded = s;
19460            padded.reserve(need);
19461            for _ in 0..need {
19462                padded.push(' ');
19463            }
19464            Some(Value::Text(padded))
19465        }
19466        _ => None,
19467    };
19468    coerced.ok_or(EngineError::Storage(StorageError::TypeMismatch {
19469        column: col_name.into(),
19470        expected,
19471        actual,
19472        position,
19473    }))
19474}
19475
19476/// v7.12.4 — render a function arg list into the
19477/// canonical form the storage layer caches as
19478/// [`spg_storage::FunctionDef::args_repr`]. The catalogue uses
19479/// this string for both display + as a coarse signature key
19480/// for the (deferred) overload resolution v7.12.5+ adds.
19481fn render_function_args(args: &[spg_sql::ast::FunctionArg]) -> alloc::string::String {
19482    use core::fmt::Write;
19483    let mut out = alloc::string::String::from("(");
19484    for (i, a) in args.iter().enumerate() {
19485        if i > 0 {
19486            out.push_str(", ");
19487        }
19488        match a.mode {
19489            spg_sql::ast::FunctionArgMode::In => {}
19490            spg_sql::ast::FunctionArgMode::Out => out.push_str("OUT "),
19491            spg_sql::ast::FunctionArgMode::InOut => out.push_str("INOUT "),
19492        }
19493        if let Some(n) = &a.name {
19494            out.push_str(n);
19495            out.push(' ');
19496        }
19497        match &a.ty {
19498            spg_sql::ast::FunctionArgType::Typed(t) => {
19499                let _ = write!(out, "{t}");
19500            }
19501            spg_sql::ast::FunctionArgType::Raw(s) => out.push_str(s),
19502        }
19503    }
19504    out.push(')');
19505    out
19506}
19507
19508/// v7.19 P5 — true iff `expr` is `unnest(arg)` at the top level
19509/// (case-insensitive). Used by `exec_select_cancel`'s
19510/// projection loop to detect Set-Returning-Function rows that
19511/// need per-row expansion. Only the top-level call counts —
19512/// `coalesce(unnest(arr), 'x')` is NOT a SRF row from the
19513/// projection's perspective; it would surface as an "unknown
19514/// function" mismatch downstream, which is what we want
19515/// (multi-SRF / nested SRF is documented carve-out for v7.19).
19516fn is_top_level_unnest(expr: &spg_sql::ast::Expr) -> bool {
19517    match expr {
19518        spg_sql::ast::Expr::FunctionCall { name, args } => {
19519            name.eq_ignore_ascii_case("unnest") && args.len() == 1
19520        }
19521        _ => false,
19522    }
19523}
19524
19525/// v7.19 P5 — extract the array argument out of a top-level
19526/// `unnest(arg)` call. `None` if `expr` isn't a `unnest` call
19527/// of arity 1 (mirrors `is_top_level_unnest`).
19528fn top_level_unnest_arg(expr: &spg_sql::ast::Expr) -> Option<&spg_sql::ast::Expr> {
19529    match expr {
19530        spg_sql::ast::Expr::FunctionCall { name, args }
19531            if name.eq_ignore_ascii_case("unnest") && args.len() == 1 =>
19532        {
19533            Some(&args[0])
19534        }
19535        _ => None,
19536    }
19537}
19538
19539/// v7.19 P5 — turn an array-typed `Value` into the element list
19540/// `unnest()` projection emits. NULL → empty list (PG: `unnest(NULL)
19541/// = (no rows)`). Non-array values fall through to a type-mismatch
19542/// error.
19543fn array_value_to_elements(v: &Value) -> Result<Vec<Value>, EngineError> {
19544    match v {
19545        Value::Null => Ok(Vec::new()),
19546        Value::TextArray(items) => Ok(items
19547            .iter()
19548            .map(|opt| {
19549                opt.as_ref()
19550                    .map(|s| Value::Text(s.clone()))
19551                    .unwrap_or(Value::Null)
19552            })
19553            .collect()),
19554        Value::IntArray(items) => Ok(items
19555            .iter()
19556            .map(|opt| opt.map(Value::Int).unwrap_or(Value::Null))
19557            .collect()),
19558        Value::BigIntArray(items) => Ok(items
19559            .iter()
19560            .map(|opt| opt.map(Value::BigInt).unwrap_or(Value::Null))
19561            .collect()),
19562        other => Err(EngineError::Eval(EvalError::TypeMismatch {
19563            detail: alloc::format!(
19564                "unnest() expects an array argument, got {:?}",
19565                other.data_type()
19566            ),
19567        })),
19568    }
19569}
19570
19571#[cfg(test)]
19572mod tests {
19573    use super::*;
19574    use alloc::vec;
19575
19576    fn unwrap_command_ok(r: &QueryResult) -> usize {
19577        match r {
19578            QueryResult::CommandOk { affected, .. } => *affected,
19579            QueryResult::Rows { .. } => panic!("expected CommandOk, got Rows"),
19580        }
19581    }
19582
19583    #[test]
19584    fn update_seek_positions_engages_on_indexed_eq() {
19585        let mut e = Engine::new();
19586        e.execute("CREATE TABLE b (id INT NOT NULL, v INT NOT NULL)")
19587            .unwrap();
19588        e.execute("CREATE INDEX b_id ON b (id)").unwrap();
19589        for i in 0..100 {
19590            e.execute(&alloc::format!("INSERT INTO b VALUES ({i}, {i})"))
19591                .unwrap();
19592        }
19593        let stmt = spg_sql::parser::parse_statement("UPDATE b SET v = v + 1 WHERE id = 42")
19594            .expect("parse");
19595        let Statement::Update(u) = stmt else {
19596            panic!("expected Update, got {stmt:?}");
19597        };
19598        let w = u.where_.as_ref().expect("where");
19599        let table = e.catalog().get("b").unwrap();
19600        let schema_cols = table.schema().columns.clone();
19601        // step-by-step: each sub-resolution must succeed.
19602        let Expr::Binary { lhs, op, rhs } = w else {
19603            panic!("WHERE not Binary: {w:?}");
19604        };
19605        assert_eq!(*op, BinOp::Eq, "op not Eq");
19606        let pair = resolve_col_literal_pair(lhs, rhs, &schema_cols, "b");
19607        assert!(
19608            pair.is_some(),
19609            "resolve_col_literal_pair None: lhs={lhs:?} rhs={rhs:?}"
19610        );
19611        let (col_pos, value) = pair.unwrap();
19612        assert!(
19613            table.index_on(col_pos).is_some(),
19614            "no index on col {col_pos}"
19615        );
19616        assert!(
19617            IndexKey::from_value(&value).is_some(),
19618            "IndexKey::from_value None for {value:?}"
19619        );
19620        let positions = try_index_seek_positions(w, &schema_cols, table, "b");
19621        assert_eq!(positions, Some(vec![42]), "seek did not engage");
19622    }
19623
19624    #[test]
19625    fn create_table_registers_schema() {
19626        let mut e = Engine::new();
19627        e.execute("CREATE TABLE foo (a INT NOT NULL, b TEXT)")
19628            .unwrap();
19629        assert_eq!(e.catalog().table_count(), 1);
19630        let t = e.catalog().get("foo").unwrap();
19631        assert_eq!(t.schema().columns.len(), 2);
19632        assert_eq!(t.schema().columns[0].ty, DataType::Int);
19633        assert!(!t.schema().columns[0].nullable);
19634        assert_eq!(t.schema().columns[1].ty, DataType::Text);
19635    }
19636
19637    #[test]
19638    fn create_table_vector_default_is_f32_encoded() {
19639        let mut e = Engine::new();
19640        e.execute("CREATE TABLE t (v VECTOR(8))").unwrap();
19641        let t = e.catalog().get("t").unwrap();
19642        assert_eq!(
19643            t.schema().columns[0].ty,
19644            DataType::Vector {
19645                dim: 8,
19646                encoding: VecEncoding::F32,
19647            },
19648        );
19649    }
19650
19651    #[test]
19652    fn create_table_vector_using_sq8_succeeds() {
19653        // v6.0.1 step 3: the step-1 fence in `column_def_to_schema`
19654        // is lifted. CREATE TABLE persists an SQ8 column type in
19655        // the catalog; INSERT (next test) quantises raw f32 input.
19656        let mut e = Engine::new();
19657        e.execute("CREATE TABLE t (v VECTOR(8) USING SQ8)").unwrap();
19658        let t = e.catalog().get("t").unwrap();
19659        assert_eq!(
19660            t.schema().columns[0].ty,
19661            DataType::Vector {
19662                dim: 8,
19663                encoding: VecEncoding::Sq8,
19664            },
19665        );
19666    }
19667
19668    #[test]
19669    fn insert_into_sq8_column_quantises_f32_payload() {
19670        // v6.0.1 step 3: INSERT-time `coerce_value` rewrites a raw
19671        // `Value::Vector(Vec<f32>)` literal into the column's
19672        // quantised representation. The row that lands in the
19673        // catalog must therefore hold a `Value::Sq8Vector`, not the
19674        // original f32 buffer — that's the bit that delivers the
19675        // 4× compression target.
19676        let mut e = Engine::new();
19677        e.execute("CREATE TABLE t (v VECTOR(4) USING SQ8)").unwrap();
19678        e.execute("INSERT INTO t VALUES ([0.0, 0.25, 0.5, 1.0])")
19679            .unwrap();
19680        let t = e.catalog().get("t").unwrap();
19681        assert_eq!(t.rows().len(), 1);
19682        match &t.rows()[0].values[0] {
19683            Value::Sq8Vector(q) => {
19684                assert_eq!(q.bytes.len(), 4);
19685                // min/max are derived from the payload: min=0.0, max=1.0.
19686                assert!((q.min - 0.0).abs() < 1e-6);
19687                assert!((q.max - 1.0).abs() < 1e-6);
19688            }
19689            other => panic!("expected Sq8Vector cell, got {other:?}"),
19690        }
19691    }
19692
19693    #[test]
19694    fn create_table_vector_using_half_succeeds_and_insert_converts_to_f16() {
19695        // v6.0.3: CREATE TABLE accepts USING HALF; INSERT path
19696        // converts the incoming `Value::Vector(Vec<f32>)` cell
19697        // into `Value::HalfVector(HalfVector)` via the new
19698        // `coerce_value` arm. The dequantised round-trip is
19699        // bit-exact for f16-representable values, so 0.0 / 0.25
19700        // / 0.5 / 1.0 hit their grid points exactly.
19701        let mut e = Engine::new();
19702        e.execute("CREATE TABLE t (v VECTOR(4) USING HALF)")
19703            .unwrap();
19704        e.execute("INSERT INTO t VALUES ([0.0, 0.25, 0.5, 1.0])")
19705            .unwrap();
19706        let t = e.catalog().get("t").unwrap();
19707        assert_eq!(t.rows().len(), 1);
19708        match &t.rows()[0].values[0] {
19709            Value::HalfVector(h) => {
19710                assert_eq!(h.dim(), 4);
19711                let back = h.to_f32_vec();
19712                let expected = alloc::vec![0.0_f32, 0.25, 0.5, 1.0];
19713                for (g, e) in back.iter().zip(expected.iter()) {
19714                    assert!(
19715                        (g - e).abs() < 1e-6,
19716                        "{g} vs {e} should be exact on f16 grid"
19717                    );
19718                }
19719            }
19720            other => panic!("expected HalfVector cell, got {other:?}"),
19721        }
19722    }
19723
19724    #[test]
19725    fn alter_index_rebuild_in_place_succeeds() {
19726        // v6.0.4: bare REBUILD (no encoding switch) walks every
19727        // row again to rebuild the NSW graph. Verifies the engine
19728        // dispatch + storage helper plumbing without changing any
19729        // cell encoding.
19730        let mut e = Engine::new();
19731        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(3) NOT NULL)")
19732            .unwrap();
19733        for i in 0..8_i32 {
19734            #[allow(clippy::cast_precision_loss)]
19735            let base = (i as f32) * 0.1;
19736            e.execute(&alloc::format!(
19737                "INSERT INTO t VALUES ({i}, [{base}, {b1}, {b2}])",
19738                b1 = base + 0.01,
19739                b2 = base + 0.02,
19740            ))
19741            .unwrap();
19742        }
19743        e.execute("CREATE INDEX t_idx ON t USING hnsw (v)").unwrap();
19744        e.execute("ALTER INDEX t_idx REBUILD").unwrap();
19745        // Schema encoding stays F32 (no encoding clause).
19746        assert_eq!(
19747            e.catalog().get("t").unwrap().schema().columns[1].ty,
19748            DataType::Vector {
19749                dim: 3,
19750                encoding: VecEncoding::F32,
19751            },
19752        );
19753    }
19754
19755    #[test]
19756    fn alter_index_rebuild_with_encoding_switches_cell_type() {
19757        // v6.0.4: REBUILD WITH (encoding = SQ8) recodes every
19758        // stored cell from F32 → SQ8 + rebuilds the graph atop the
19759        // new encoding. Post-rebuild, cells must be Sq8Vector and
19760        // the schema must report encoding = Sq8.
19761        let mut e = Engine::new();
19762        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(4) NOT NULL)")
19763            .unwrap();
19764        e.execute("INSERT INTO t VALUES (1, [0.0, 0.25, 0.5, 1.0])")
19765            .unwrap();
19766        e.execute("CREATE INDEX t_idx ON t USING hnsw (v)").unwrap();
19767        e.execute("ALTER INDEX t_idx REBUILD WITH (encoding = SQ8)")
19768            .unwrap();
19769        let t = e.catalog().get("t").unwrap();
19770        assert_eq!(
19771            t.schema().columns[1].ty,
19772            DataType::Vector {
19773                dim: 4,
19774                encoding: VecEncoding::Sq8,
19775            },
19776        );
19777        assert!(matches!(t.rows()[0].values[1], Value::Sq8Vector(_)));
19778    }
19779
19780    #[test]
19781    fn alter_index_rebuild_unknown_index_errors() {
19782        let mut e = Engine::new();
19783        let err = e.execute("ALTER INDEX nope REBUILD").unwrap_err();
19784        assert!(
19785            matches!(
19786                &err,
19787                EngineError::Storage(StorageError::IndexNotFound { name }) if name == "nope"
19788            ),
19789            "got: {err}"
19790        );
19791    }
19792
19793    #[test]
19794    fn alter_index_rebuild_on_btree_index_errors() {
19795        // REBUILD on a B-tree index has no semantic meaning in
19796        // v6.0.4 — rejected at the storage layer with `Unsupported`.
19797        let mut e = Engine::new();
19798        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19799        e.execute("INSERT INTO t VALUES (1)").unwrap();
19800        e.execute("CREATE INDEX t_idx ON t (id)").unwrap();
19801        let err = e.execute("ALTER INDEX t_idx REBUILD").unwrap_err();
19802        assert!(
19803            matches!(&err, EngineError::Storage(StorageError::Unsupported(_))),
19804            "got: {err}"
19805        );
19806    }
19807
19808    #[test]
19809    fn prepared_insert_substitutes_placeholders() {
19810        // v6.1.1: prepare() parses once; execute_prepared() walks the
19811        // AST and replaces $1/$2 with the param Values BEFORE the
19812        // dispatch sees them. Same logical result as a simple-query
19813        // INSERT, but parse happens once per *statement*, not per
19814        // execution.
19815        let mut e = Engine::new();
19816        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)")
19817            .unwrap();
19818        let stmt = e.prepare("INSERT INTO t VALUES ($1, $2)").unwrap();
19819        for (id, name) in [(1, "alice"), (2, "bob"), (3, "carol")] {
19820            e.execute_prepared(stmt.clone(), &[Value::Int(id), Value::Text(name.into())])
19821                .unwrap();
19822        }
19823        // Read back via simple-query SELECT.
19824        let rows_result = e.execute("SELECT id, name FROM t").unwrap();
19825        let QueryResult::Rows { rows, .. } = rows_result else {
19826            panic!("expected Rows")
19827        };
19828        assert_eq!(rows.len(), 3);
19829    }
19830
19831    #[test]
19832    fn prepared_select_with_placeholder_filters_rows() {
19833        let mut e = Engine::new();
19834        e.execute("CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)")
19835            .unwrap();
19836        for i in 0..10_i32 {
19837            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, {})", i * 7))
19838                .unwrap();
19839        }
19840        let stmt = e.prepare("SELECT id FROM t WHERE v = $1").unwrap();
19841        let QueryResult::Rows { rows, .. } = e.execute_prepared(stmt, &[Value::Int(35)]).unwrap()
19842        else {
19843            panic!("expected Rows")
19844        };
19845        // v = 35 means i*7 = 35 → i = 5.
19846        assert_eq!(rows.len(), 1);
19847        assert_eq!(rows[0].values[0], Value::Int(5));
19848    }
19849
19850    #[test]
19851    fn prepared_too_few_params_errors() {
19852        let mut e = Engine::new();
19853        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19854        let stmt = e.prepare("INSERT INTO t VALUES ($1)").unwrap();
19855        let err = e.execute_prepared(stmt, &[]).unwrap_err();
19856        assert!(
19857            matches!(
19858                &err,
19859                EngineError::Eval(EvalError::PlaceholderOutOfRange { n: 1, bound: 0 })
19860            ),
19861            "got: {err}"
19862        );
19863    }
19864
19865    #[test]
19866    fn bytea_cast_round_trips_text_input() {
19867        // v7.18 — `'hello'::bytea` produces the raw bytes. Closes
19868        // the mailrs D-pre #3 reverse-acceptance gap.
19869        let e = Engine::new();
19870        let r = e.execute_readonly("SELECT 'hello'::bytea").unwrap();
19871        let QueryResult::Rows { rows, .. } = r else {
19872            panic!("expected Rows")
19873        };
19874        assert_eq!(rows.len(), 1);
19875        assert_eq!(rows[0].values[0], Value::Bytes(b"hello".to_vec()));
19876    }
19877
19878    #[test]
19879    fn bytea_cast_pg_escape_hex_form() {
19880        // E'\\xdeadbeef'::bytea — E-string decodes to `\xdeadbeef`
19881        // (literal 10 chars), then ::bytea reads it as PG hex
19882        // form bytea literal → 4 bytes.
19883        let e = Engine::new();
19884        let r = e.execute_readonly(r"SELECT E'\\xdeadbeef'::bytea").unwrap();
19885        let QueryResult::Rows { rows, .. } = r else {
19886            panic!("expected Rows")
19887        };
19888        assert_eq!(
19889            rows[0].values[0],
19890            Value::Bytes(vec![0xde, 0xad, 0xbe, 0xef])
19891        );
19892    }
19893
19894    #[test]
19895    fn bytea_cast_chains_through_octet_length() {
19896        // octet_length('hello'::bytea) → 5. Confirms the cast
19897        // composes inside larger expressions, not just at top
19898        // level.
19899        let e = Engine::new();
19900        let r = e
19901            .execute_readonly("SELECT octet_length('hello'::bytea)")
19902            .unwrap();
19903        let QueryResult::Rows { rows, .. } = r else {
19904            panic!("expected Rows")
19905        };
19906        match &rows[0].values[0] {
19907            Value::Int(n) => assert_eq!(*n, 5),
19908            Value::BigInt(n) => assert_eq!(*n, 5),
19909            other => panic!("expected integer length, got {other:?}"),
19910        }
19911    }
19912
19913    #[test]
19914    fn readonly_prepared_on_snapshot_select_with_placeholder() {
19915        // v7.18 — sqlx Pool fan-out relies on running prepared
19916        // SELECTs against a frozen snapshot without re-entering
19917        // the writer engine. Mirrors the simple-query SELECT path
19918        // in `execute_readonly_on_snapshot` but takes a Statement
19919        // + bound params (the shape sqlx's Execute path produces).
19920        let mut e = Engine::new();
19921        e.execute("CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)")
19922            .unwrap();
19923        for i in 0..10_i32 {
19924            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, {})", i * 7))
19925                .unwrap();
19926        }
19927        let snapshot = e.clone_snapshot();
19928        let stmt = e.prepare("SELECT id FROM t WHERE v = $1").unwrap();
19929        let QueryResult::Rows { rows, .. } =
19930            Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(35)])
19931                .unwrap()
19932        else {
19933            panic!("expected Rows")
19934        };
19935        assert_eq!(rows.len(), 1);
19936        assert_eq!(rows[0].values[0], Value::Int(5));
19937    }
19938
19939    #[test]
19940    fn readonly_prepared_on_snapshot_rejects_writes() {
19941        // DDL / DML prepared statements on the readonly path must
19942        // surface `WriteRequired` so the spg-sqlx connection layer
19943        // routes them to the writer mutex instead of the snapshot.
19944        let mut e = Engine::new();
19945        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19946        let snapshot = e.clone_snapshot();
19947        let stmt = e.prepare("INSERT INTO t VALUES ($1)").unwrap();
19948        let err = Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(1)])
19949            .unwrap_err();
19950        assert!(matches!(&err, EngineError::WriteRequired), "got: {err}");
19951    }
19952
19953    #[test]
19954    fn readonly_prepared_on_snapshot_frozen_view() {
19955        // The snapshot reflects engine state at clone_snapshot()
19956        // time. Writes after the snapshot are NOT visible — caller
19957        // takes a fresh snapshot (or `AsyncReadHandle::refresh()`)
19958        // to see them. This is the contract the per-statement
19959        // refresh in spg-sqlx relies on.
19960        let mut e = Engine::new();
19961        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19962        e.execute("INSERT INTO t VALUES (1)").unwrap();
19963        let snapshot = e.clone_snapshot();
19964        e.execute("INSERT INTO t VALUES (2)").unwrap();
19965        let stmt = e.prepare("SELECT id FROM t WHERE id = $1").unwrap();
19966        let QueryResult::Rows { rows, .. } =
19967            Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(2)])
19968                .unwrap()
19969        else {
19970            panic!("expected Rows")
19971        };
19972        assert!(rows.is_empty(), "id=2 was inserted after snapshot");
19973    }
19974
19975    #[test]
19976    fn describe_prepared_on_snapshot_resolves_columns() {
19977        // v7.18 — sqlx's Executor::describe path on the readonly
19978        // fan-out needs to resolve column names + types against
19979        // the snapshot's catalog (not the live engine's catalog,
19980        // which may have moved on).
19981        let mut e = Engine::new();
19982        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)")
19983            .unwrap();
19984        let snapshot = e.clone_snapshot();
19985        let stmt = e.prepare("SELECT id, name FROM t WHERE id = $1").unwrap();
19986        let (_params, cols) = Engine::describe_prepared_on_snapshot(&snapshot, &stmt);
19987        assert_eq!(cols.len(), 2);
19988        assert_eq!(cols[0].name, "id");
19989        assert_eq!(cols[0].ty, DataType::Int);
19990        assert_eq!(cols[1].name, "name");
19991        assert_eq!(cols[1].ty, DataType::Text);
19992    }
19993
19994    #[test]
19995    fn insert_into_half_column_dim_mismatch_errors() {
19996        let mut e = Engine::new();
19997        e.execute("CREATE TABLE t (v VECTOR(4) USING HALF)")
19998            .unwrap();
19999        let err = e.execute("INSERT INTO t VALUES ([1.0, 2.0])").unwrap_err();
20000        assert!(matches!(
20001            &err,
20002            EngineError::Storage(StorageError::TypeMismatch { .. })
20003        ));
20004    }
20005
20006    #[test]
20007    fn insert_into_sq8_column_dim_mismatch_errors() {
20008        // Dim mismatch falls through the `coerce_value` Vector→Sq8
20009        // arm's guard and surfaces as `TypeMismatch` — the same
20010        // error the F32 path produces today, so client error
20011        // handling stays uniform across encodings.
20012        let mut e = Engine::new();
20013        e.execute("CREATE TABLE t (v VECTOR(4) USING SQ8)").unwrap();
20014        let err = e.execute("INSERT INTO t VALUES ([1.0, 2.0])").unwrap_err();
20015        assert!(
20016            matches!(
20017                &err,
20018                EngineError::Storage(StorageError::TypeMismatch { .. })
20019            ),
20020            "got: {err}",
20021        );
20022    }
20023
20024    #[test]
20025    fn create_table_duplicate_errors() {
20026        let mut e = Engine::new();
20027        e.execute("CREATE TABLE foo (a INT)").unwrap();
20028        let err = e.execute("CREATE TABLE foo (a INT)").unwrap_err();
20029        assert!(matches!(
20030            err,
20031            EngineError::Storage(StorageError::DuplicateTable { ref name }) if name == "foo"
20032        ));
20033    }
20034
20035    #[test]
20036    fn insert_into_unknown_table_errors() {
20037        let mut e = Engine::new();
20038        let err = e.execute("INSERT INTO ghost VALUES (1)").unwrap_err();
20039        assert!(matches!(
20040            err,
20041            EngineError::Storage(StorageError::TableNotFound { ref name }) if name == "ghost"
20042        ));
20043    }
20044
20045    #[test]
20046    fn insert_happy_path_reports_one_affected() {
20047        let mut e = Engine::new();
20048        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
20049        let r = e.execute("INSERT INTO foo VALUES (42)").unwrap();
20050        assert_eq!(unwrap_command_ok(&r), 1);
20051        assert_eq!(e.catalog().get("foo").unwrap().row_count(), 1);
20052    }
20053
20054    #[test]
20055    fn insert_arity_mismatch_propagates() {
20056        let mut e = Engine::new();
20057        e.execute("CREATE TABLE foo (a INT, b TEXT)").unwrap();
20058        let err = e.execute("INSERT INTO foo VALUES (1)").unwrap_err();
20059        assert!(matches!(
20060            err,
20061            EngineError::Storage(StorageError::ArityMismatch { .. })
20062        ));
20063    }
20064
20065    #[test]
20066    fn insert_negative_integer_via_unary_minus() {
20067        let mut e = Engine::new();
20068        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
20069        e.execute("INSERT INTO foo VALUES (-7)").unwrap();
20070        let rows = e.catalog().get("foo").unwrap().rows();
20071        assert_eq!(rows[0].values[0], Value::Int(-7));
20072    }
20073
20074    #[test]
20075    fn insert_expression_evaluated_against_empty_context() {
20076        // PG-canonical: INSERT VALUES accepts an arbitrary scalar
20077        // expression. The engine evaluates against an empty row
20078        // context — column references would error, but pure
20079        // arithmetic / function calls are fine.
20080        let mut e = Engine::new();
20081        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
20082        e.execute("INSERT INTO foo VALUES (1 + 2)").unwrap();
20083        let rows = e.catalog().get("foo").unwrap().rows();
20084        assert_eq!(rows[0].values[0], Value::Int(3));
20085    }
20086
20087    #[test]
20088    fn select_star_returns_all_rows_in_insertion_order() {
20089        let mut e = Engine::new();
20090        e.execute("CREATE TABLE foo (a INT NOT NULL, b TEXT NOT NULL)")
20091            .unwrap();
20092        e.execute("INSERT INTO foo VALUES (1, 'one')").unwrap();
20093        e.execute("INSERT INTO foo VALUES (2, 'two')").unwrap();
20094        e.execute("INSERT INTO foo VALUES (3, 'three')").unwrap();
20095
20096        let r = e.execute("SELECT * FROM foo").unwrap();
20097        let QueryResult::Rows { columns, rows } = r else {
20098            panic!("expected Rows")
20099        };
20100        assert_eq!(columns.len(), 2);
20101        assert_eq!(columns[0].name, "a");
20102        assert_eq!(rows.len(), 3);
20103        assert_eq!(
20104            rows[1].values,
20105            vec![Value::Int(2), Value::Text("two".into())]
20106        );
20107    }
20108
20109    #[test]
20110    fn select_star_on_empty_table_returns_zero_rows() {
20111        let mut e = Engine::new();
20112        e.execute("CREATE TABLE foo (a INT)").unwrap();
20113        let r = e.execute("SELECT * FROM foo").unwrap();
20114        match r {
20115            QueryResult::Rows { rows, .. } => assert!(rows.is_empty()),
20116            QueryResult::CommandOk { .. } => panic!("expected Rows"),
20117        }
20118    }
20119
20120    // --- v0.4: WHERE + projection ------------------------------------------
20121
20122    fn make_three_row_users(e: &mut Engine) {
20123        e.execute("CREATE TABLE users (id INT NOT NULL, name TEXT NOT NULL, score INT)")
20124            .unwrap();
20125        e.execute("INSERT INTO users VALUES (1, 'alice', 90)")
20126            .unwrap();
20127        e.execute("INSERT INTO users VALUES (2, 'bob', NULL)")
20128            .unwrap();
20129        e.execute("INSERT INTO users VALUES (3, 'cara', 70)")
20130            .unwrap();
20131    }
20132
20133    fn unwrap_rows(r: QueryResult) -> (Vec<ColumnSchema>, Vec<Row>) {
20134        match r {
20135            QueryResult::Rows { columns, rows } => (columns, rows),
20136            QueryResult::CommandOk { .. } => panic!("expected Rows"),
20137        }
20138    }
20139
20140    #[test]
20141    fn where_filter_passes_only_true_rows() {
20142        let mut e = Engine::new();
20143        make_three_row_users(&mut e);
20144        let r = e.execute("SELECT * FROM users WHERE id > 1").unwrap();
20145        let (_, rows) = unwrap_rows(r);
20146        assert_eq!(rows.len(), 2);
20147        assert_eq!(rows[0].values[0], Value::Int(2));
20148        assert_eq!(rows[1].values[0], Value::Int(3));
20149    }
20150
20151    #[test]
20152    fn where_with_null_result_filters_out_row() {
20153        let mut e = Engine::new();
20154        make_three_row_users(&mut e);
20155        // score is NULL for bob → score > 80 is NULL → row excluded
20156        let r = e.execute("SELECT * FROM users WHERE score > 80").unwrap();
20157        let (_, rows) = unwrap_rows(r);
20158        assert_eq!(rows.len(), 1);
20159        assert_eq!(rows[0].values[1], Value::Text("alice".into()));
20160    }
20161
20162    #[test]
20163    fn projection_named_columns() {
20164        let mut e = Engine::new();
20165        make_three_row_users(&mut e);
20166        let r = e.execute("SELECT name, score FROM users").unwrap();
20167        let (cols, rows) = unwrap_rows(r);
20168        assert_eq!(cols.len(), 2);
20169        assert_eq!(cols[0].name, "name");
20170        assert_eq!(cols[1].name, "score");
20171        assert_eq!(rows.len(), 3);
20172        assert_eq!(
20173            rows[0].values,
20174            vec![Value::Text("alice".into()), Value::Int(90)]
20175        );
20176    }
20177
20178    #[test]
20179    fn projection_with_column_alias() {
20180        let mut e = Engine::new();
20181        make_three_row_users(&mut e);
20182        let r = e
20183            .execute("SELECT name AS who FROM users WHERE id = 1")
20184            .unwrap();
20185        let (cols, rows) = unwrap_rows(r);
20186        assert_eq!(cols[0].name, "who");
20187        assert_eq!(rows.len(), 1);
20188        assert_eq!(rows[0].values[0], Value::Text("alice".into()));
20189    }
20190
20191    #[test]
20192    fn qualified_column_with_table_alias_resolves() {
20193        let mut e = Engine::new();
20194        make_three_row_users(&mut e);
20195        let r = e
20196            .execute("SELECT u.id, u.name FROM users AS u WHERE u.id < 3")
20197            .unwrap();
20198        let (cols, rows) = unwrap_rows(r);
20199        assert_eq!(cols.len(), 2);
20200        assert_eq!(rows.len(), 2);
20201    }
20202
20203    #[test]
20204    fn qualified_column_with_wrong_alias_errors() {
20205        let mut e = Engine::new();
20206        make_three_row_users(&mut e);
20207        let err = e.execute("SELECT x.id FROM users AS u").unwrap_err();
20208        assert!(matches!(
20209            err,
20210            EngineError::Eval(EvalError::UnknownQualifier { ref qualifier }) if qualifier == "x"
20211        ));
20212    }
20213
20214    #[test]
20215    fn select_unknown_column_errors_in_projection() {
20216        let mut e = Engine::new();
20217        make_three_row_users(&mut e);
20218        let err = e.execute("SELECT ghost FROM users").unwrap_err();
20219        assert!(matches!(
20220            err,
20221            EngineError::Eval(EvalError::ColumnNotFound { ref name }) if name == "ghost"
20222        ));
20223    }
20224
20225    #[test]
20226    fn where_unknown_column_errors() {
20227        let mut e = Engine::new();
20228        make_three_row_users(&mut e);
20229        let err = e
20230            .execute("SELECT * FROM users WHERE ghost = 1")
20231            .unwrap_err();
20232        assert!(matches!(
20233            err,
20234            EngineError::Eval(EvalError::ColumnNotFound { .. })
20235        ));
20236    }
20237
20238    #[test]
20239    fn expression_projection_evaluates_and_renders() {
20240        // Compound expressions in the SELECT list are evaluated per row;
20241        // the output column is typed TEXT, name defaults to the expression.
20242        let mut e = Engine::new();
20243        e.execute("CREATE TABLE t (a INT NOT NULL)").unwrap();
20244        e.execute("INSERT INTO t VALUES (3)").unwrap();
20245        let (_, rows) = unwrap_rows(e.execute("SELECT 1 + 2 FROM t").unwrap());
20246        assert_eq!(rows.len(), 1);
20247        // The expression evaluates to integer 3; rendered as the cell value
20248        // (storage::Value::Int(3) since arithmetic kept ints).
20249        assert_eq!(rows[0].values[0], Value::Int(3));
20250    }
20251
20252    #[test]
20253    fn select_unknown_table_errors() {
20254        let mut e = Engine::new();
20255        let err = e.execute("SELECT * FROM ghost").unwrap_err();
20256        assert!(matches!(
20257            err,
20258            EngineError::Storage(StorageError::TableNotFound { .. })
20259        ));
20260    }
20261
20262    #[test]
20263    fn invalid_sql_returns_parse_error() {
20264        // v4.4: UPDATE is now real SQL, so use a true syntactic
20265        // garbage payload for the parse-error path.
20266        let mut e = Engine::new();
20267        let err = e.execute("THIS_IS_NOT_A_KEYWORD foo bar baz").unwrap_err();
20268        assert!(matches!(err, EngineError::Parse(_)));
20269    }
20270
20271    // --- v0.8 CREATE INDEX + index seek ------------------------------------
20272
20273    #[test]
20274    fn create_index_registers_on_table() {
20275        let mut e = Engine::new();
20276        make_three_row_users(&mut e);
20277        e.execute("CREATE INDEX by_name ON users (name)").unwrap();
20278        let t = e.catalog().get("users").unwrap();
20279        assert_eq!(t.indices().len(), 1);
20280        assert_eq!(t.indices()[0].name, "by_name");
20281    }
20282
20283    #[test]
20284    fn create_index_on_unknown_table_errors() {
20285        let mut e = Engine::new();
20286        let err = e.execute("CREATE INDEX i ON ghost (a)").unwrap_err();
20287        assert!(matches!(
20288            err,
20289            EngineError::Storage(StorageError::TableNotFound { .. })
20290        ));
20291    }
20292
20293    #[test]
20294    fn create_index_on_unknown_column_errors() {
20295        let mut e = Engine::new();
20296        make_three_row_users(&mut e);
20297        let err = e.execute("CREATE INDEX i ON users (ghost)").unwrap_err();
20298        assert!(matches!(
20299            err,
20300            EngineError::Storage(StorageError::ColumnNotFound { .. })
20301        ));
20302    }
20303
20304    #[test]
20305    fn select_eq_uses_index_returns_same_rows_as_scan() {
20306        // Build two engines: one with an index, one without. Same query →
20307        // same row set (index is a planner optimisation, not a semantic
20308        // change).
20309        let mut without = Engine::new();
20310        make_three_row_users(&mut without);
20311        let mut with = Engine::new();
20312        make_three_row_users(&mut with);
20313        with.execute("CREATE INDEX by_id ON users (id)").unwrap();
20314
20315        let q = "SELECT * FROM users WHERE id = 2";
20316        let (_, no_idx_rows) = unwrap_rows(without.execute(q).unwrap());
20317        let (_, idx_rows) = unwrap_rows(with.execute(q).unwrap());
20318        assert_eq!(no_idx_rows, idx_rows);
20319        assert_eq!(idx_rows.len(), 1);
20320    }
20321
20322    #[test]
20323    fn select_eq_with_no_matching_index_value_returns_empty() {
20324        let mut e = Engine::new();
20325        make_three_row_users(&mut e);
20326        e.execute("CREATE INDEX by_id ON users (id)").unwrap();
20327        let (_, rows) = unwrap_rows(e.execute("SELECT * FROM users WHERE id = 999").unwrap());
20328        assert_eq!(rows.len(), 0);
20329    }
20330
20331    // --- v0.9 transactions -------------------------------------------------
20332
20333    #[test]
20334    fn begin_sets_in_transaction_flag() {
20335        let mut e = Engine::new();
20336        assert!(!e.in_transaction());
20337        e.execute("BEGIN").unwrap();
20338        assert!(e.in_transaction());
20339    }
20340
20341    #[test]
20342    fn double_begin_errors() {
20343        let mut e = Engine::new();
20344        e.execute("BEGIN").unwrap();
20345        let err = e.execute("BEGIN").unwrap_err();
20346        assert_eq!(err, EngineError::TransactionAlreadyOpen);
20347    }
20348
20349    #[test]
20350    fn commit_without_begin_errors() {
20351        let mut e = Engine::new();
20352        let err = e.execute("COMMIT").unwrap_err();
20353        assert_eq!(err, EngineError::NoActiveTransaction);
20354    }
20355
20356    #[test]
20357    fn rollback_without_begin_errors() {
20358        let mut e = Engine::new();
20359        let err = e.execute("ROLLBACK").unwrap_err();
20360        assert_eq!(err, EngineError::NoActiveTransaction);
20361    }
20362
20363    #[test]
20364    fn commit_applies_shadow_to_committed_catalog() {
20365        let mut e = Engine::new();
20366        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
20367        e.execute("BEGIN").unwrap();
20368        e.execute("INSERT INTO t VALUES (1)").unwrap();
20369        e.execute("INSERT INTO t VALUES (2)").unwrap();
20370        e.execute("COMMIT").unwrap();
20371        assert!(!e.in_transaction());
20372        assert_eq!(e.catalog().get("t").unwrap().row_count(), 2);
20373    }
20374
20375    #[test]
20376    fn rollback_discards_shadow() {
20377        let mut e = Engine::new();
20378        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
20379        e.execute("BEGIN").unwrap();
20380        e.execute("INSERT INTO t VALUES (1)").unwrap();
20381        e.execute("INSERT INTO t VALUES (2)").unwrap();
20382        e.execute("ROLLBACK").unwrap();
20383        assert!(!e.in_transaction());
20384        assert_eq!(e.catalog().get("t").unwrap().row_count(), 0);
20385    }
20386
20387    #[test]
20388    fn select_during_tx_sees_uncommitted_writes_own_session() {
20389        // The shadow catalog is read by SELECTs while a TX is open — the
20390        // session can see its own pending writes.
20391        let mut e = Engine::new();
20392        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
20393        e.execute("BEGIN").unwrap();
20394        e.execute("INSERT INTO t VALUES (42)").unwrap();
20395        let (_, rows) = unwrap_rows(e.execute("SELECT * FROM t").unwrap());
20396        assert_eq!(rows.len(), 1);
20397        assert_eq!(rows[0].values[0], Value::Int(42));
20398    }
20399
20400    #[test]
20401    fn snapshot_with_no_users_is_bare_catalog_format() {
20402        let mut e = Engine::new();
20403        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
20404        let bytes = e.snapshot();
20405        assert_eq!(
20406            &bytes[..8],
20407            b"SPGDB001",
20408            "must be the bare v3.x catalog magic"
20409        );
20410        let e2 = Engine::restore_envelope(&bytes).unwrap();
20411        assert!(e2.users().is_empty());
20412        assert_eq!(e2.catalog().table_count(), 1);
20413    }
20414
20415    #[test]
20416    fn snapshot_with_users_round_trips_both_via_envelope() {
20417        let mut e = Engine::new();
20418        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
20419        e.create_user("alice", "pw1", Role::Admin, [9; 16]).unwrap();
20420        e.create_user("bob", "pw2", Role::ReadOnly, [5; 16])
20421            .unwrap();
20422        let bytes = e.snapshot();
20423        assert_eq!(&bytes[..8], b"SPGENV01", "must be the v4.1 envelope magic");
20424        let e2 = Engine::restore_envelope(&bytes).unwrap();
20425        assert_eq!(e2.users().len(), 2);
20426        assert_eq!(e2.verify_user("alice", "pw1"), Some(Role::Admin));
20427        assert_eq!(e2.verify_user("bob", "pw2"), Some(Role::ReadOnly));
20428        assert_eq!(e2.verify_user("alice", "wrong"), None);
20429        assert_eq!(e2.catalog().table_count(), 1);
20430    }
20431
20432    #[test]
20433    fn ddl_inside_tx_also_rolled_back() {
20434        let mut e = Engine::new();
20435        e.execute("BEGIN").unwrap();
20436        e.execute("CREATE TABLE t (v INT)").unwrap();
20437        // Visible inside the TX.
20438        e.execute("SELECT * FROM t").unwrap();
20439        e.execute("ROLLBACK").unwrap();
20440        // Gone after rollback.
20441        let err = e.execute("SELECT * FROM t").unwrap_err();
20442        assert!(matches!(
20443            err,
20444            EngineError::Storage(StorageError::TableNotFound { .. })
20445        ));
20446    }
20447
20448    // ── v6.1.2: CREATE / DROP PUBLICATION (engine-side) ──────
20449
20450    #[test]
20451    fn create_publication_lands_in_catalog() {
20452        let mut e = Engine::new();
20453        assert!(e.publications().is_empty());
20454        e.execute("CREATE PUBLICATION pub_a").unwrap();
20455        assert_eq!(e.publications().len(), 1);
20456        assert!(e.publications().contains("pub_a"));
20457    }
20458
20459    #[test]
20460    fn create_publication_duplicate_errors() {
20461        let mut e = Engine::new();
20462        e.execute("CREATE PUBLICATION pub_a").unwrap();
20463        let err = e.execute("CREATE PUBLICATION pub_a").unwrap_err();
20464        assert!(
20465            alloc::format!("{err:?}").contains("DuplicateName"),
20466            "got {err:?}"
20467        );
20468    }
20469
20470    #[test]
20471    fn drop_publication_silent_when_absent() {
20472        let mut e = Engine::new();
20473        // PG-compatible: DROP a publication that doesn't exist
20474        // succeeds (no-op) but reports zero affected.
20475        let r = e.execute("DROP PUBLICATION nope").unwrap();
20476        match r {
20477            QueryResult::CommandOk { affected, .. } => assert_eq!(affected, 0),
20478            other => panic!("expected CommandOk, got {other:?}"),
20479        }
20480    }
20481
20482    #[test]
20483    fn drop_publication_present_reports_one_affected() {
20484        let mut e = Engine::new();
20485        e.execute("CREATE PUBLICATION pub_a").unwrap();
20486        let r = e.execute("DROP PUBLICATION pub_a").unwrap();
20487        match r {
20488            QueryResult::CommandOk {
20489                affected,
20490                modified_catalog,
20491            } => {
20492                assert_eq!(affected, 1);
20493                assert!(modified_catalog);
20494            }
20495            other => panic!("expected CommandOk, got {other:?}"),
20496        }
20497        assert!(e.publications().is_empty());
20498    }
20499
20500    #[test]
20501    fn publications_persist_across_snapshot_restore() {
20502        // The persist-across-restart ship-gate at the engine layer —
20503        // snapshot → restore_envelope round trip must preserve the
20504        // publication catalog. The spg-server e2e covers the
20505        // process-restart variant.
20506        let mut e = Engine::new();
20507        e.execute("CREATE PUBLICATION pub_a").unwrap();
20508        e.execute("CREATE PUBLICATION pub_b FOR ALL TABLES")
20509            .unwrap();
20510        let snap = e.snapshot();
20511        let e2 = Engine::restore_envelope(&snap).unwrap();
20512        assert_eq!(e2.publications().len(), 2);
20513        assert!(e2.publications().contains("pub_a"));
20514        assert!(e2.publications().contains("pub_b"));
20515    }
20516
20517    #[test]
20518    fn create_publication_allowed_inside_transaction() {
20519        // v6.1.4 dropped the v6.1.2 in-TX guard — PG allows
20520        // CREATE PUBLICATION inside a TX and the auto-commit
20521        // wrap path needs the same allowance.
20522        let mut e = Engine::new();
20523        e.execute("BEGIN").unwrap();
20524        e.execute("CREATE PUBLICATION pub_a").unwrap();
20525        e.execute("COMMIT").unwrap();
20526        assert!(e.publications().contains("pub_a"));
20527    }
20528
20529    // ── v6.1.3: SHOW PUBLICATIONS + FOR-list variants ───────
20530
20531    #[test]
20532    fn create_publication_for_table_list_lands_with_scope() {
20533        let mut e = Engine::new();
20534        e.execute("CREATE TABLE t1 (id INT NOT NULL)").unwrap();
20535        e.execute("CREATE TABLE t2 (id INT NOT NULL)").unwrap();
20536        e.execute("CREATE PUBLICATION pub_a FOR TABLE t1, t2")
20537            .unwrap();
20538        let scope = e.publications().get("pub_a").cloned();
20539        let Some(spg_sql::ast::PublicationScope::ForTables(ts)) = scope else {
20540            panic!("expected ForTables scope, got {scope:?}")
20541        };
20542        assert_eq!(ts, alloc::vec!["t1".to_string(), "t2".to_string()]);
20543    }
20544
20545    #[test]
20546    fn create_publication_all_tables_except_lands_with_scope() {
20547        let mut e = Engine::new();
20548        e.execute("CREATE PUBLICATION pub_a FOR ALL TABLES EXCEPT t3")
20549            .unwrap();
20550        let scope = e.publications().get("pub_a").cloned();
20551        let Some(spg_sql::ast::PublicationScope::AllTablesExcept(ts)) = scope else {
20552            panic!("expected AllTablesExcept scope, got {scope:?}")
20553        };
20554        assert_eq!(ts, alloc::vec!["t3".to_string()]);
20555    }
20556
20557    #[test]
20558    fn show_publications_empty_returns_zero_rows() {
20559        let e = Engine::new();
20560        let r = e.execute_readonly("SHOW PUBLICATIONS").unwrap();
20561        let QueryResult::Rows { rows, columns } = r else {
20562            panic!()
20563        };
20564        assert!(rows.is_empty());
20565        assert_eq!(columns.len(), 3);
20566        assert_eq!(columns[0].name, "name");
20567        assert_eq!(columns[1].name, "scope");
20568        assert_eq!(columns[2].name, "table_count");
20569    }
20570
20571    #[test]
20572    fn show_publications_returns_one_row_per_publication_ordered_by_name() {
20573        let mut e = Engine::new();
20574        e.execute("CREATE PUBLICATION z_pub").unwrap();
20575        e.execute("CREATE PUBLICATION a_pub FOR TABLE t1, t2")
20576            .unwrap();
20577        e.execute("CREATE PUBLICATION m_pub FOR ALL TABLES EXCEPT bad")
20578            .unwrap();
20579        let r = e.execute_readonly("SHOW PUBLICATIONS").unwrap();
20580        let QueryResult::Rows { rows, .. } = r else {
20581            panic!()
20582        };
20583        assert_eq!(rows.len(), 3);
20584        // Alphabetical order: a_pub, m_pub, z_pub.
20585        let names: Vec<&str> = rows
20586            .iter()
20587            .map(|r| {
20588                if let Value::Text(s) = &r.values[0] {
20589                    s.as_str()
20590                } else {
20591                    panic!()
20592                }
20593            })
20594            .collect();
20595        assert_eq!(names, alloc::vec!["a_pub", "m_pub", "z_pub"]);
20596        // Row 0 — a_pub scope summary + table_count = 2.
20597        match &rows[0].values[1] {
20598            Value::Text(s) => assert_eq!(s, "FOR TABLE t1, t2"),
20599            other => panic!("expected Text, got {other:?}"),
20600        }
20601        assert_eq!(rows[0].values[2], Value::Int(2));
20602        // Row 1 — m_pub.
20603        match &rows[1].values[1] {
20604            Value::Text(s) => assert_eq!(s, "FOR ALL TABLES EXCEPT bad"),
20605            other => panic!("expected Text, got {other:?}"),
20606        }
20607        assert_eq!(rows[1].values[2], Value::Int(1));
20608        // Row 2 — z_pub (AllTables → NULL count).
20609        match &rows[2].values[1] {
20610            Value::Text(s) => assert_eq!(s, "FOR ALL TABLES"),
20611            other => panic!("expected Text, got {other:?}"),
20612        }
20613        assert_eq!(rows[2].values[2], Value::Null);
20614    }
20615
20616    #[test]
20617    fn for_list_scopes_persist_across_snapshot() {
20618        // The v6.1.2 envelope-v3 round-trip exercised AllTables;
20619        // v6.1.3 needs the scope-1 / scope-2 tags to survive too.
20620        let mut e = Engine::new();
20621        e.execute("CREATE PUBLICATION p1 FOR TABLE t1, t2").unwrap();
20622        e.execute("CREATE PUBLICATION p2 FOR ALL TABLES EXCEPT bad, worse")
20623            .unwrap();
20624        let snap = e.snapshot();
20625        let e2 = Engine::restore_envelope(&snap).unwrap();
20626        assert_eq!(e2.publications().len(), 2);
20627        let p1 = e2.publications().get("p1").cloned();
20628        let Some(spg_sql::ast::PublicationScope::ForTables(ts)) = p1 else {
20629            panic!("p1 scope lost: {p1:?}")
20630        };
20631        assert_eq!(ts, alloc::vec!["t1".to_string(), "t2".to_string()]);
20632        let p2 = e2.publications().get("p2").cloned();
20633        let Some(spg_sql::ast::PublicationScope::AllTablesExcept(ts)) = p2 else {
20634            panic!("p2 scope lost: {p2:?}")
20635        };
20636        assert_eq!(ts, alloc::vec!["bad".to_string(), "worse".to_string()]);
20637    }
20638
20639    // ── v6.1.4: CREATE / DROP SUBSCRIPTION + SHOW + envelope v4 ─
20640
20641    #[test]
20642    fn create_subscription_lands_in_catalog_with_defaults() {
20643        let mut e = Engine::new();
20644        e.execute(
20645            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=127.0.0.1 port=20002' PUBLICATION pub_a",
20646        )
20647        .unwrap();
20648        let s = e.subscriptions().get("sub_a").cloned().expect("present");
20649        assert_eq!(s.conn_str, "host=127.0.0.1 port=20002");
20650        assert_eq!(s.publications, alloc::vec!["pub_a".to_string()]);
20651        assert!(s.enabled);
20652        assert_eq!(s.last_received_pos, 0);
20653    }
20654
20655    #[test]
20656    fn create_subscription_duplicate_name_errors() {
20657        let mut e = Engine::new();
20658        e.execute("CREATE SUBSCRIPTION s CONNECTION 'host=x' PUBLICATION p")
20659            .unwrap();
20660        let err = e
20661            .execute("CREATE SUBSCRIPTION s CONNECTION 'host=y' PUBLICATION p")
20662            .unwrap_err();
20663        assert!(
20664            alloc::format!("{err:?}").contains("DuplicateName"),
20665            "got {err:?}"
20666        );
20667    }
20668
20669    #[test]
20670    fn drop_subscription_silent_when_absent() {
20671        let mut e = Engine::new();
20672        let r = e.execute("DROP SUBSCRIPTION never").unwrap();
20673        match r {
20674            QueryResult::CommandOk { affected, .. } => assert_eq!(affected, 0),
20675            other => panic!("expected CommandOk, got {other:?}"),
20676        }
20677    }
20678
20679    #[test]
20680    fn subscription_advance_updates_last_pos_monotone() {
20681        let mut e = Engine::new();
20682        e.execute("CREATE SUBSCRIPTION s CONNECTION 'h=x' PUBLICATION p")
20683            .unwrap();
20684        assert!(e.subscription_advance("s", 100));
20685        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 100);
20686        assert!(e.subscription_advance("s", 50)); // stale → ignored
20687        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 100);
20688        assert!(e.subscription_advance("s", 200));
20689        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 200);
20690        assert!(!e.subscription_advance("missing", 1));
20691    }
20692
20693    #[test]
20694    fn show_subscriptions_returns_rows_ordered_by_name() {
20695        let mut e = Engine::new();
20696        e.execute("CREATE SUBSCRIPTION z_sub CONNECTION 'h=x' PUBLICATION p1, p2")
20697            .unwrap();
20698        e.execute("CREATE SUBSCRIPTION a_sub CONNECTION 'h=y' PUBLICATION p3")
20699            .unwrap();
20700        let r = e.execute_readonly("SHOW SUBSCRIPTIONS").unwrap();
20701        let QueryResult::Rows { rows, columns } = r else {
20702            panic!()
20703        };
20704        assert_eq!(rows.len(), 2);
20705        assert_eq!(columns.len(), 5);
20706        assert_eq!(columns[0].name, "name");
20707        assert_eq!(columns[4].name, "last_received_pos");
20708        // Alphabetical: a_sub, z_sub.
20709        let names: Vec<&str> = rows
20710            .iter()
20711            .map(|r| {
20712                if let Value::Text(s) = &r.values[0] {
20713                    s.as_str()
20714                } else {
20715                    panic!()
20716                }
20717            })
20718            .collect();
20719        assert_eq!(names, alloc::vec!["a_sub", "z_sub"]);
20720        // Row 0: a_sub
20721        assert_eq!(rows[0].values[1], Value::Text("h=y".to_string()));
20722        assert_eq!(rows[0].values[2], Value::Text("p3".to_string()));
20723        assert_eq!(rows[0].values[3], Value::Bool(true));
20724        assert_eq!(rows[0].values[4], Value::BigInt(0));
20725        // Row 1: z_sub — publications join with ", "
20726        assert_eq!(rows[1].values[2], Value::Text("p1, p2".to_string()));
20727    }
20728
20729    #[test]
20730    fn subscriptions_persist_across_snapshot_envelope_v4() {
20731        let mut e = Engine::new();
20732        e.execute("CREATE SUBSCRIPTION s1 CONNECTION 'h=A' PUBLICATION p1, p2")
20733            .unwrap();
20734        e.execute("CREATE SUBSCRIPTION s2 CONNECTION 'h=B' PUBLICATION p3")
20735            .unwrap();
20736        e.subscription_advance("s2", 42);
20737        let snap = e.snapshot();
20738        let e2 = Engine::restore_envelope(&snap).unwrap();
20739        assert_eq!(e2.subscriptions().len(), 2);
20740        let s1 = e2.subscriptions().get("s1").unwrap();
20741        assert_eq!(s1.conn_str, "h=A");
20742        assert_eq!(
20743            s1.publications,
20744            alloc::vec!["p1".to_string(), "p2".to_string()]
20745        );
20746        assert_eq!(s1.last_received_pos, 0);
20747        let s2 = e2.subscriptions().get("s2").unwrap();
20748        assert_eq!(s2.last_received_pos, 42);
20749    }
20750
20751    #[test]
20752    fn v3_envelope_loads_with_empty_subscriptions() {
20753        // v3 snapshot (publications-only). Forge it by hand so we
20754        // verify v6.1.4 readers don't panic — they must surface
20755        // empty subscriptions and a populated publication table.
20756        let mut e = Engine::new();
20757        e.execute("CREATE PUBLICATION pub_legacy").unwrap();
20758        let catalog = e.catalog.serialize();
20759        let users = crate::users::serialize_users(&e.users);
20760        let pubs = e.publications.serialize();
20761        let mut buf = Vec::new();
20762        buf.extend_from_slice(b"SPGENV01");
20763        buf.push(3u8); // v3
20764        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
20765        buf.extend_from_slice(&catalog);
20766        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
20767        buf.extend_from_slice(&users);
20768        buf.extend_from_slice(&u32::try_from(pubs.len()).unwrap().to_le_bytes());
20769        buf.extend_from_slice(&pubs);
20770        let crc = spg_crypto::crc32::crc32(&buf);
20771        buf.extend_from_slice(&crc.to_le_bytes());
20772
20773        let e2 = Engine::restore_envelope(&buf).expect("v3 envelope restores under v4 reader");
20774        assert!(e2.subscriptions().is_empty());
20775        assert!(e2.publications().contains("pub_legacy"));
20776    }
20777
20778    #[test]
20779    fn create_subscription_allowed_inside_transaction() {
20780        let mut e = Engine::new();
20781        e.execute("BEGIN").unwrap();
20782        e.execute("CREATE SUBSCRIPTION s CONNECTION 'h=x' PUBLICATION p")
20783            .unwrap();
20784        e.execute("COMMIT").unwrap();
20785        assert!(e.subscriptions().contains("s"));
20786    }
20787
20788    // ── v6.2.0: ANALYZE + spg_statistic + envelope v5 ──────────
20789    #[test]
20790    fn analyze_populates_histogram_bounds() {
20791        let mut e = Engine::new();
20792        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT)")
20793            .unwrap();
20794        for i in 0..50 {
20795            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, 'name{i}')"))
20796                .unwrap();
20797        }
20798        e.execute("ANALYZE t").unwrap();
20799        let stats = e.statistics();
20800        let id_stats = stats.get("t", "id").unwrap();
20801        assert!(id_stats.histogram_bounds.len() >= 2);
20802        assert_eq!(id_stats.histogram_bounds.first().unwrap(), "0");
20803        assert_eq!(id_stats.histogram_bounds.last().unwrap(), "49");
20804        assert!((id_stats.null_frac - 0.0).abs() < 1e-6);
20805        assert_eq!(id_stats.n_distinct, 50);
20806    }
20807
20808    #[test]
20809    fn reanalyze_overwrites_prior_stats() {
20810        let mut e = Engine::new();
20811        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
20812        for i in 0..10 {
20813            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
20814                .unwrap();
20815        }
20816        e.execute("ANALYZE t").unwrap();
20817        let n1 = e.statistics().get("t", "id").unwrap().n_distinct;
20818        assert_eq!(n1, 10);
20819        for i in 10..30 {
20820            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
20821                .unwrap();
20822        }
20823        e.execute("ANALYZE t").unwrap();
20824        let n2 = e.statistics().get("t", "id").unwrap().n_distinct;
20825        assert_eq!(n2, 30);
20826    }
20827
20828    #[test]
20829    fn analyze_unknown_table_errors() {
20830        let mut e = Engine::new();
20831        let err = e.execute("ANALYZE nonexistent").unwrap_err();
20832        assert!(matches!(
20833            err,
20834            EngineError::Storage(StorageError::TableNotFound { .. })
20835        ));
20836    }
20837
20838    #[test]
20839    fn bare_analyze_covers_all_user_tables() {
20840        let mut e = Engine::new();
20841        e.execute("CREATE TABLE t1 (id INT NOT NULL)").unwrap();
20842        e.execute("CREATE TABLE t2 (name TEXT NOT NULL)").unwrap();
20843        e.execute("INSERT INTO t1 VALUES (1)").unwrap();
20844        e.execute("INSERT INTO t2 VALUES ('alice')").unwrap();
20845        let r = e.execute("ANALYZE").unwrap();
20846        match r {
20847            QueryResult::CommandOk {
20848                affected,
20849                modified_catalog,
20850            } => {
20851                assert_eq!(affected, 2);
20852                assert!(modified_catalog);
20853            }
20854            other => panic!("expected CommandOk, got {other:?}"),
20855        }
20856        assert!(e.statistics().get("t1", "id").is_some());
20857        assert!(e.statistics().get("t2", "name").is_some());
20858    }
20859
20860    #[test]
20861    fn select_from_spg_statistic_returns_rows_per_column() {
20862        let mut e = Engine::new();
20863        e.execute("CREATE TABLE t (id INT NOT NULL, label TEXT)")
20864            .unwrap();
20865        e.execute("INSERT INTO t VALUES (1, 'a')").unwrap();
20866        e.execute("INSERT INTO t VALUES (2, 'b')").unwrap();
20867        e.execute("ANALYZE t").unwrap();
20868        let r = e.execute_readonly("SELECT * FROM spg_statistic").unwrap();
20869        let QueryResult::Rows { rows, columns } = r else {
20870            panic!()
20871        };
20872        // v6.7.0 — spg_statistic gained a `cold_row_count` column.
20873        assert_eq!(columns.len(), 6);
20874        assert_eq!(columns[0].name, "table_name");
20875        assert_eq!(columns[4].name, "histogram_bounds");
20876        assert_eq!(columns[5].name, "cold_row_count");
20877        assert_eq!(rows.len(), 2, "one row per column of t");
20878        // Sorted by (table_name, column_name).
20879        match (&rows[0].values[0], &rows[0].values[1]) {
20880            (Value::Text(t), Value::Text(c)) => {
20881                assert_eq!(t, "t");
20882                // BTreeMap orders (table, column); columns "id" < "label".
20883                assert_eq!(c, "id");
20884            }
20885            _ => panic!(),
20886        }
20887    }
20888
20889    #[test]
20890    fn analyze_skips_vector_columns() {
20891        // Vector columns have their own stats shape (HNSW graph);
20892        // ANALYZE leaves them out of spg_statistic.
20893        let mut e = Engine::new();
20894        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(3) NOT NULL)")
20895            .unwrap();
20896        e.execute("INSERT INTO t VALUES (1, [1, 2, 3])").unwrap();
20897        e.execute("ANALYZE t").unwrap();
20898        assert!(e.statistics().get("t", "id").is_some());
20899        assert!(e.statistics().get("t", "v").is_none());
20900    }
20901
20902    #[test]
20903    fn statistics_persist_across_envelope_v5_round_trip() {
20904        let mut e = Engine::new();
20905        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
20906        for i in 0..20 {
20907            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
20908                .unwrap();
20909        }
20910        e.execute("ANALYZE").unwrap();
20911        let snap = e.snapshot();
20912        let e2 = Engine::restore_envelope(&snap).unwrap();
20913        let s = e2.statistics().get("t", "id").unwrap();
20914        assert_eq!(s.n_distinct, 20);
20915    }
20916
20917    // ── v6.2.1 auto-analyze threshold ───────────────────────────
20918
20919    #[test]
20920    fn auto_analyze_threshold_fires_after_10pct_of_min_rows_on_small_table() {
20921        // For a table with 0 rows then 10 inserts → modified=10,
20922        // row_count=10. Threshold = 0.1 × max(10, 100) = 10. So
20923        // after the 10th INSERT the threshold is met.
20924        let mut e = Engine::new();
20925        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
20926        for i in 0..9 {
20927            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
20928                .unwrap();
20929        }
20930        assert!(e.tables_needing_analyze().is_empty(), "9 < threshold");
20931        e.execute("INSERT INTO t VALUES (9)").unwrap();
20932        let needs = e.tables_needing_analyze();
20933        assert_eq!(needs, alloc::vec!["t".to_string()]);
20934    }
20935
20936    #[test]
20937    fn auto_analyze_threshold_uses_10pct_of_row_count_for_large_tables() {
20938        // After ANALYZE on 1000 rows, threshold = 0.1 × row_count.
20939        // Each new INSERT bumps both modified and row_count, so to
20940        // trigger from N=1000 we need modifications ≥ 0.1 × (1000+M),
20941        // i.e. M ≥ 112. The test inserts 50 (no fire), then 150
20942        // more (200 total mods, row_count=1200, threshold=120 → fire).
20943        let mut e = Engine::new();
20944        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
20945        for i in 0..1000 {
20946            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
20947                .unwrap();
20948        }
20949        e.execute("ANALYZE t").unwrap();
20950        assert!(e.tables_needing_analyze().is_empty(), "fresh ANALYZE");
20951        for i in 1000..1050 {
20952            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
20953                .unwrap();
20954        }
20955        assert!(
20956            e.tables_needing_analyze().is_empty(),
20957            "50 inserts < threshold of ~105"
20958        );
20959        for i in 1050..1200 {
20960            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
20961                .unwrap();
20962        }
20963        assert_eq!(
20964            e.tables_needing_analyze(),
20965            alloc::vec!["t".to_string()],
20966            "200 inserts > 0.1 × 1200 threshold"
20967        );
20968    }
20969
20970    #[test]
20971    fn auto_analyze_threshold_resets_after_analyze() {
20972        let mut e = Engine::new();
20973        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
20974        for i in 0..200 {
20975            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
20976                .unwrap();
20977        }
20978        assert!(!e.tables_needing_analyze().is_empty());
20979        e.execute("ANALYZE").unwrap();
20980        assert!(
20981            e.tables_needing_analyze().is_empty(),
20982            "ANALYZE must reset the counter"
20983        );
20984    }
20985
20986    #[test]
20987    fn auto_analyze_threshold_tracks_updates_and_deletes() {
20988        let mut e = Engine::new();
20989        e.execute("CREATE TABLE t (id INT NOT NULL, label TEXT)")
20990            .unwrap();
20991        for i in 0..50 {
20992            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, 'x')"))
20993                .unwrap();
20994        }
20995        e.execute("ANALYZE t").unwrap();
20996        // UPDATE 20 rows + DELETE 5 → modified=25. Threshold = 0.1
20997        // × max(50, 100) = 10. So 25 >= 10 → trigger.
20998        e.execute("UPDATE t SET label = 'y' WHERE id < 20").unwrap();
20999        e.execute("DELETE FROM t WHERE id >= 45").unwrap();
21000        assert_eq!(e.tables_needing_analyze(), alloc::vec!["t".to_string()]);
21001    }
21002
21003    #[test]
21004    fn v4_envelope_loads_with_empty_statistics() {
21005        // Forge a v4 envelope by hand: catalog + users + pubs +
21006        // subs trailer, no statistics. A v6.2.0 reader must accept
21007        // it and surface an empty Statistics.
21008        let mut e = Engine::new();
21009        e.create_user("alice", "secret", crate::users::Role::ReadOnly, [0u8; 16])
21010            .unwrap();
21011        let catalog = e.catalog.serialize();
21012        let users = crate::users::serialize_users(&e.users);
21013        let pubs = e.publications.serialize();
21014        let subs = e.subscriptions.serialize();
21015        let mut buf = Vec::new();
21016        buf.extend_from_slice(b"SPGENV01");
21017        buf.push(4u8);
21018        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
21019        buf.extend_from_slice(&catalog);
21020        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
21021        buf.extend_from_slice(&users);
21022        buf.extend_from_slice(&u32::try_from(pubs.len()).unwrap().to_le_bytes());
21023        buf.extend_from_slice(&pubs);
21024        buf.extend_from_slice(&u32::try_from(subs.len()).unwrap().to_le_bytes());
21025        buf.extend_from_slice(&subs);
21026        let crc = spg_crypto::crc32::crc32(&buf);
21027        buf.extend_from_slice(&crc.to_le_bytes());
21028        let e2 = Engine::restore_envelope(&buf).expect("v4 envelope restores");
21029        assert!(e2.statistics().is_empty());
21030    }
21031
21032    #[test]
21033    fn v1_v2_envelope_loads_with_empty_publications() {
21034        // A snapshot taken before v6.1.2 (no publication trailer,
21035        // envelope v2) must still deserialise — and the resulting
21036        // engine must report zero publications. Use the engine's own
21037        // round-trip with no publications: that emits v3 but with an
21038        // empty pubs block. Then forge a v2 envelope by hand to lock
21039        // the back-compat path.
21040        let mut e = Engine::new();
21041        // Force users to be non-empty so the snapshot takes the
21042        // envelope path rather than the bare-catalog fallback.
21043        e.create_user("alice", "secret", crate::users::Role::ReadOnly, [0u8; 16])
21044            .unwrap();
21045
21046        // Forge an envelope v2: same shape as v3 but no pubs trailer.
21047        let catalog = e.catalog.serialize();
21048        let users = crate::users::serialize_users(&e.users);
21049        let mut buf = Vec::new();
21050        buf.extend_from_slice(b"SPGENV01");
21051        buf.push(2u8); // v2
21052        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
21053        buf.extend_from_slice(&catalog);
21054        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
21055        buf.extend_from_slice(&users);
21056        let crc = spg_crypto::crc32::crc32(&buf);
21057        buf.extend_from_slice(&crc.to_le_bytes());
21058
21059        let e2 = Engine::restore_envelope(&buf).expect("v2 envelope restores");
21060        assert!(e2.publications().is_empty());
21061    }
21062}