Skip to main content

spg_engine/
lib.rs

1//! SPG execution engine — v0.3 wires the SQL front-end to the in-memory
2//! storage layer. Implements `CREATE TABLE`, single-row `INSERT VALUES`, and
3//! `SELECT * FROM <table>` (no WHERE yet — that lands in v0.4 alongside
4//! expression evaluation against rows).
5#![no_std]
6
7extern crate alloc;
8
9pub mod aggregate;
10pub mod copy;
11pub mod describe;
12pub mod eval;
13pub mod fts;
14pub mod json;
15pub mod memoize;
16pub mod plan_cache;
17pub mod publications;
18pub mod query_stats;
19pub mod reorder;
20pub mod selectivity;
21pub mod statistics;
22pub mod subscriptions;
23pub mod triggers;
24pub mod users;
25
26pub use crate::users::{Role, ScramSecrets, UserError, UserStore};
27
28use alloc::borrow::Cow;
29use alloc::boxed::Box;
30use alloc::collections::BTreeMap;
31use alloc::string::{String, ToString};
32use alloc::vec::Vec;
33use core::fmt;
34
35use spg_sql::ast::{
36    BinOp, ColumnDef, ColumnName, ColumnTypeName, CreateIndexStatement, CreatePublicationStatement,
37    CreateSubscriptionStatement, CreateTableStatement, CreateUserStatement, Expr, FrameBound,
38    FrameKind, FromClause, IndexMethod, InsertStatement, JoinKind, Literal, OrderBy, SelectItem,
39    SelectStatement, Statement, TableRef, UnOp, UnionKind, VecEncoding as SqlVecEncoding,
40    WindowFrame,
41};
42// v7.16.0 — re-export the parsed-statement AST so downstream
43// crates (spg-embedded → spg-sqlx) don't need a direct dep on
44// spg-sql for the prepare/bind handle.
45pub use spg_sql::ast::Statement as ParsedStatement;
46use spg_sql::parser::{self, ParseError};
47use spg_storage::{
48    Catalog, ColumnSchema, CompactReport, DataType, IndexKey, IndexKind, Row, StorageError, Table,
49    TableSchema, Value, VecEncoding,
50};
51
52use crate::eval::{EvalContext, EvalError};
53
54/// Result of executing one statement.
55#[derive(Debug, Clone, PartialEq)]
56#[non_exhaustive]
57pub enum QueryResult {
58    /// DDL or DML succeeded.
59    ///
60    /// `affected` is the row count for `INSERT` and 0 elsewhere.
61    /// `modified_catalog` tells the server whether this statement
62    /// caused the *committed* catalog to change — it's the signal to
63    /// snapshot/audit. False for `BEGIN`/`ROLLBACK`, false for writeful
64    /// statements executed inside a transaction (those only touch the
65    /// shadow), and true for `COMMIT` and for writes outside a TX.
66    CommandOk {
67        affected: usize,
68        modified_catalog: bool,
69    },
70    /// `SELECT` returned a (possibly empty) row set.
71    Rows {
72        columns: Vec<ColumnSchema>,
73        rows: Vec<Row>,
74    },
75}
76
77/// All errors the engine can return.
78///
79/// Marked `#[non_exhaustive]` from v7.5.0 onward: external `match`
80/// must include a `_` arm so new variants in subsequent v7.x releases
81/// are not breaking changes.
82#[derive(Debug, Clone, PartialEq)]
83#[non_exhaustive]
84pub enum EngineError {
85    Parse(ParseError),
86    Storage(StorageError),
87    Eval(EvalError),
88    /// Front-end accepted a construct that the v0.x executor doesn't support.
89    Unsupported(String),
90    /// `BEGIN` while another transaction is already open.
91    TransactionAlreadyOpen,
92    /// `COMMIT` / `ROLLBACK` with no active transaction.
93    NoActiveTransaction,
94    /// v4.0 sentinel: `execute_readonly` got a statement that
95    /// mutates engine state (INSERT / CREATE / BEGIN / COMMIT / …).
96    /// The caller should retake the write lock and dispatch through
97    /// `execute(&mut self)` instead.
98    WriteRequired,
99    /// v4.2: a SELECT would have returned more rows than the
100    /// configured `max_query_rows` cap. Carries the cap.
101    RowLimitExceeded(usize),
102    /// v4.5: cooperative cancellation — the host (server's
103    /// per-query watchdog) set the cancel flag while a long-running
104    /// SELECT / UPDATE / DELETE was scanning rows. The partial work
105    /// is discarded; the caller should surface this as a timeout
106    /// to the client.
107    Cancelled,
108}
109
110impl fmt::Display for EngineError {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        match self {
113            Self::Parse(e) => write!(f, "parse: {e}"),
114            Self::Storage(e) => write!(f, "storage: {e}"),
115            Self::Eval(e) => write!(f, "eval: {e}"),
116            Self::Unsupported(s) => write!(f, "unsupported: {s}"),
117            Self::TransactionAlreadyOpen => f.write_str("a transaction is already open"),
118            Self::NoActiveTransaction => f.write_str("no active transaction"),
119            Self::WriteRequired => {
120                f.write_str("statement requires a write lock (use execute, not execute_readonly)")
121            }
122            Self::RowLimitExceeded(n) => {
123                write!(f, "query exceeded max_query_rows={n}")
124            }
125            Self::Cancelled => f.write_str("query cancelled (timeout or client request)"),
126        }
127    }
128}
129
130impl From<ParseError> for EngineError {
131    fn from(e: ParseError) -> Self {
132        Self::Parse(e)
133    }
134}
135impl From<StorageError> for EngineError {
136    fn from(e: StorageError) -> Self {
137        Self::Storage(e)
138    }
139}
140impl From<EvalError> for EngineError {
141    fn from(e: EvalError) -> Self {
142        Self::Eval(e)
143    }
144}
145
146/// The execution engine. Holds the catalog and (later) other server-scope
147/// state. `Engine::new()` is intentionally cheap so callers can construct one
148/// per database, per test.
149/// Function pointer that returns "now" as microseconds since Unix
150/// epoch. The engine is `no_std`, so it can't reach for `std::time`
151/// itself — callers (`spg-server`, the sqllogictest runner) inject a
152/// concrete implementation. `None` means `NOW()` / `CURRENT_*` raise
153/// `Unsupported`.
154pub type ClockFn = fn() -> i64;
155
156/// Function pointer that produces 16 cryptographically random bytes.
157/// Like `ClockFn`, the engine is `no_std` and can't reach for /dev/urandom
158/// itself — host (`spg-server`) injects an OS-backed source. `None`
159/// means SQL-driven `CREATE USER` falls back to a deterministic salt
160/// derived from the username (acceptable in tests; the server always
161/// installs a real RNG so production paths never see this).
162pub type SaltFn = fn() -> [u8; 16];
163
164/// v4.5 cooperative cancellation token. A long-running SELECT /
165/// UPDATE / DELETE checks `is_cancelled` at row-loop checkpoints
166/// and bails with `EngineError::Cancelled`. The host
167/// (`spg-server`) creates an `AtomicBool` per query, spawns a
168/// watchdog thread that sets it after `SPG_QUERY_TIMEOUT_MS`,
169/// and passes it via `execute_with_cancel` / `execute_readonly_with_cancel`.
170///
171/// `CancelToken::none()` is a no-op — used by the legacy `execute`
172/// and `execute_readonly` entry points so existing callers don't
173/// change.
174/// v7.17.0 Phase 2.3 — monotonic time source for deadline-aware
175/// cancellation (PG `statement_timeout`). Returns microseconds
176/// since some host-stable monotonic origin (typically the first
177/// call into `Instant::now()` on the server). The engine never
178/// calls `Instant::now()` directly so the crate stays `#![no_std]`.
179pub type MonotonicNowFn = fn() -> u64;
180
181#[derive(Debug, Clone, Copy)]
182struct Deadline {
183    now_fn: MonotonicNowFn,
184    /// Absolute deadline in `now_fn()` units (microseconds).
185    deadline_us: u64,
186}
187
188#[derive(Debug, Clone, Copy)]
189pub struct CancelToken<'a> {
190    flag: Option<&'a core::sync::atomic::AtomicBool>,
191    // v7.17.0 Phase 2.3 — when set, every existing `cancel.check()`
192    // checkpoint also fires `EngineError::Cancelled` once
193    // `(now_fn)() >= deadline_us`. No new check sites, no thread
194    // spawn per query — the monotonic now-fn read is a vDSO
195    // `clock_gettime(CLOCK_MONOTONIC)` (~20ns) and only runs when
196    // the host actually wired a deadline (statement_timeout > 0).
197    deadline: Option<Deadline>,
198}
199
200impl<'a> CancelToken<'a> {
201    #[must_use]
202    pub const fn none() -> Self {
203        Self {
204            flag: None,
205            deadline: None,
206        }
207    }
208
209    #[must_use]
210    pub const fn from_flag(f: &'a core::sync::atomic::AtomicBool) -> Self {
211        Self {
212            flag: Some(f),
213            deadline: None,
214        }
215    }
216
217    /// v7.17.0 Phase 2.3 — attach a monotonic deadline. `now_fn`
218    /// must return microseconds since a stable origin; the token
219    /// trips when `now_fn() >= deadline_us`. Compose with
220    /// `from_flag(...)` when both a watchdog flag and a per-statement
221    /// timeout are in play (e.g. server-wide `SPG_QUERY_TIMEOUT_MS`
222    /// plus session `statement_timeout`); the tighter of the two
223    /// wins by virtue of either signaling first.
224    #[must_use]
225    pub const fn with_deadline(mut self, now_fn: MonotonicNowFn, deadline_us: u64) -> Self {
226        self.deadline = Some(Deadline {
227            now_fn,
228            deadline_us,
229        });
230        self
231    }
232
233    #[must_use]
234    pub fn is_cancelled(self) -> bool {
235        if self
236            .flag
237            .is_some_and(|f| f.load(core::sync::atomic::Ordering::Relaxed))
238        {
239            return true;
240        }
241        // Deadline check is the second branch so the "no timeout"
242        // hot path (`deadline: None`) elides the now-fn call —
243        // predicted-not-taken on the SLO INSERT loop.
244        if let Some(d) = self.deadline
245            && (d.now_fn)() >= d.deadline_us
246        {
247            return true;
248        }
249        false
250    }
251
252    /// Returns `Err(Cancelled)` if the token has been tripped.
253    /// Used at row-loop checkpoints to bail cooperatively without
254    /// scattering raw `is_cancelled` checks across the executor.
255    #[inline]
256    pub fn check(self) -> Result<(), EngineError> {
257        if self.is_cancelled() {
258            Err(EngineError::Cancelled)
259        } else {
260            Ok(())
261        }
262    }
263}
264
265// ---- snapshot envelope (v4.1, extended with CRC32 in v4.37,  ----
266// ----   publications in v6.1.2 v3, subscriptions in v6.1.4 v4) ----
267//
268// Wraps a catalog blob + a user blob behind a small header so the
269// server can persist both atomically without inventing a new file.
270// Bare catalog blobs (v3.x) still load via `restore_envelope` since
271// the magic check fails fast and the function falls back to
272// `Catalog::deserialize`.
273//
274// Layout — v1 (v4.1, no CRC):
275//   [8 bytes magic "SPGENV01"]
276//   [u8 version = 1]
277//   [u32 catalog_len][catalog bytes]
278//   [u32 users_len][users bytes]
279//
280// Layout — v2 (v4.37, CRC32 of body):
281//   [8 bytes magic "SPGENV01"]
282//   [u8 version = 2]
283//   [u32 catalog_len][catalog bytes]
284//   [u32 users_len][users bytes]
285//   [u32 crc32]                      ← CRC32 of every byte before it.
286//
287// Layout — v3 (v6.1.2, publications trailer):
288//   [8 bytes magic "SPGENV01"]
289//   [u8 version = 3]
290//   [u32 catalog_len][catalog bytes]
291//   [u32 users_len][users bytes]
292//   [u32 pubs_len][publications bytes]
293//   [u32 crc32]
294//
295// Layout — v4 (v6.1.4, subscriptions trailer):
296//   [8 bytes magic "SPGENV01"]
297//   [u8 version = 4]
298//   [u32 catalog_len][catalog bytes]
299//   [u32 users_len][users bytes]
300//   [u32 pubs_len][publications bytes]
301//   [u32 subs_len][subscriptions bytes]
302//   [u32 crc32]
303//
304// Layout — v5 (v6.2.0, statistics trailer):
305//   [8 bytes magic "SPGENV01"]
306//   [u8 version = 5]
307//   [u32 catalog_len][catalog bytes]
308//   [u32 users_len][users bytes]
309//   [u32 pubs_len][publications bytes]
310//   [u32 subs_len][subscriptions bytes]
311//   [u32 stats_len][statistics bytes]      ← NEW
312//   [u32 crc32]
313//
314// Writers emit v5 from v6.2.0 on. Readers accept all of {v1, v2,
315// v3, v4, v5}: v1/v2 load with empty publications / subscriptions /
316// statistics; v3 loads with empty subscriptions + statistics; v4
317// loads with empty statistics; v5 deserialises all three. Older
318// SPG versions reading a v5 envelope fall through the version
319// match to `EnvelopeParse::Bare` — pre-v6.2.0 binaries cannot
320// open v6.2.0+ snapshots (matches the v6.1.2 / v6.1.4 breaks).
321
322const ENVELOPE_MAGIC: &[u8; 8] = b"SPGENV01";
323const ENVELOPE_VERSION_V1: u8 = 1;
324const ENVELOPE_VERSION_V2: u8 = 2;
325const ENVELOPE_VERSION_V3: u8 = 3;
326const ENVELOPE_VERSION_V4: u8 = 4;
327const ENVELOPE_VERSION_V5: u8 = 5;
328
329fn build_envelope(catalog: &[u8], users: &[u8], pubs: &[u8], subs: &[u8], stats: &[u8]) -> Vec<u8> {
330    let mut out = Vec::with_capacity(
331        8 + 1
332            + 4
333            + catalog.len()
334            + 4
335            + users.len()
336            + 4
337            + pubs.len()
338            + 4
339            + subs.len()
340            + 4
341            + stats.len()
342            + 4,
343    );
344    out.extend_from_slice(ENVELOPE_MAGIC);
345    out.push(ENVELOPE_VERSION_V5);
346    out.extend_from_slice(
347        &u32::try_from(catalog.len())
348            .expect("≤ 4G catalog")
349            .to_le_bytes(),
350    );
351    out.extend_from_slice(catalog);
352    out.extend_from_slice(
353        &u32::try_from(users.len())
354            .expect("≤ 4G users")
355            .to_le_bytes(),
356    );
357    out.extend_from_slice(users);
358    out.extend_from_slice(
359        &u32::try_from(pubs.len())
360            .expect("≤ 4G publications")
361            .to_le_bytes(),
362    );
363    out.extend_from_slice(pubs);
364    out.extend_from_slice(
365        &u32::try_from(subs.len())
366            .expect("≤ 4G subscriptions")
367            .to_le_bytes(),
368    );
369    out.extend_from_slice(subs);
370    out.extend_from_slice(
371        &u32::try_from(stats.len())
372            .expect("≤ 4G statistics")
373            .to_le_bytes(),
374    );
375    out.extend_from_slice(stats);
376    let crc = spg_crypto::crc32::crc32(&out);
377    out.extend_from_slice(&crc.to_le_bytes());
378    out
379}
380
381/// Outcome of envelope parsing: either bare-catalog fallback, a
382/// successfully split section trio from a v1/v2/v3 envelope, or an
383/// explicit corruption error from a v2/v3 CRC mismatch. `Bare`
384/// (catalog-only fallback) preserves v3.x readability. v1/v2
385/// envelopes set `publications` to `None`; v3 sets it to the
386/// publications byte slice.
387enum EnvelopeParse<'a> {
388    Bare,
389    Pair {
390        catalog: &'a [u8],
391        users: &'a [u8],
392        publications: Option<&'a [u8]>,
393        subscriptions: Option<&'a [u8]>,
394        statistics: Option<&'a [u8]>,
395    },
396    CrcMismatch {
397        expected: u32,
398        computed: u32,
399    },
400}
401
402/// Returns `EnvelopeParse::Pair` for a valid v1 / v2 / v3 envelope,
403/// `Bare` for a buffer that doesn't look like an envelope (v3.x
404/// bare catalog fallback), and `CrcMismatch` for a v2/v3 envelope
405/// whose trailing CRC32 doesn't match the body.
406fn split_envelope(buf: &[u8]) -> EnvelopeParse<'_> {
407    if buf.len() < 8 + 1 + 4 || &buf[..8] != ENVELOPE_MAGIC {
408        return EnvelopeParse::Bare;
409    }
410    let version = buf[8];
411    if !matches!(
412        version,
413        ENVELOPE_VERSION_V1
414            | ENVELOPE_VERSION_V2
415            | ENVELOPE_VERSION_V3
416            | ENVELOPE_VERSION_V4
417            | ENVELOPE_VERSION_V5
418    ) {
419        return EnvelopeParse::Bare;
420    }
421    let mut p = 9usize;
422    let Some(cat_len_bytes) = buf.get(p..p + 4) else {
423        return EnvelopeParse::Bare;
424    };
425    let Ok(cat_len_arr) = cat_len_bytes.try_into() else {
426        return EnvelopeParse::Bare;
427    };
428    let cat_len = u32::from_le_bytes(cat_len_arr) as usize;
429    p += 4;
430    if p + cat_len + 4 > buf.len() {
431        return EnvelopeParse::Bare;
432    }
433    let catalog = &buf[p..p + cat_len];
434    p += cat_len;
435    let Some(user_len_bytes) = buf.get(p..p + 4) else {
436        return EnvelopeParse::Bare;
437    };
438    let Ok(user_len_arr) = user_len_bytes.try_into() else {
439        return EnvelopeParse::Bare;
440    };
441    let user_len = u32::from_le_bytes(user_len_arr) as usize;
442    p += 4;
443    if p + user_len > buf.len() {
444        return EnvelopeParse::Bare;
445    }
446    let users = &buf[p..p + user_len];
447    p += user_len;
448    let publications = if matches!(
449        version,
450        ENVELOPE_VERSION_V3 | ENVELOPE_VERSION_V4 | ENVELOPE_VERSION_V5
451    ) {
452        // [u32 pubs_len][publications bytes]
453        let Some(pubs_len_bytes) = buf.get(p..p + 4) else {
454            return EnvelopeParse::Bare;
455        };
456        let Ok(pubs_len_arr) = pubs_len_bytes.try_into() else {
457            return EnvelopeParse::Bare;
458        };
459        let pubs_len = u32::from_le_bytes(pubs_len_arr) as usize;
460        p += 4;
461        if p + pubs_len > buf.len() {
462            return EnvelopeParse::Bare;
463        }
464        let pubs_slice = &buf[p..p + pubs_len];
465        p += pubs_len;
466        Some(pubs_slice)
467    } else {
468        None
469    };
470    let subscriptions = if matches!(version, ENVELOPE_VERSION_V4 | ENVELOPE_VERSION_V5) {
471        // [u32 subs_len][subscriptions bytes]
472        let Some(subs_len_bytes) = buf.get(p..p + 4) else {
473            return EnvelopeParse::Bare;
474        };
475        let Ok(subs_len_arr) = subs_len_bytes.try_into() else {
476            return EnvelopeParse::Bare;
477        };
478        let subs_len = u32::from_le_bytes(subs_len_arr) as usize;
479        p += 4;
480        if p + subs_len > buf.len() {
481            return EnvelopeParse::Bare;
482        }
483        let subs_slice = &buf[p..p + subs_len];
484        p += subs_len;
485        Some(subs_slice)
486    } else {
487        None
488    };
489    let statistics = if version == ENVELOPE_VERSION_V5 {
490        // [u32 stats_len][statistics bytes]
491        let Some(stats_len_bytes) = buf.get(p..p + 4) else {
492            return EnvelopeParse::Bare;
493        };
494        let Ok(stats_len_arr) = stats_len_bytes.try_into() else {
495            return EnvelopeParse::Bare;
496        };
497        let stats_len = u32::from_le_bytes(stats_len_arr) as usize;
498        p += 4;
499        if p + stats_len > buf.len() {
500            return EnvelopeParse::Bare;
501        }
502        let stats_slice = &buf[p..p + stats_len];
503        p += stats_len;
504        Some(stats_slice)
505    } else {
506        None
507    };
508    if matches!(
509        version,
510        ENVELOPE_VERSION_V2 | ENVELOPE_VERSION_V3 | ENVELOPE_VERSION_V4 | ENVELOPE_VERSION_V5
511    ) {
512        if p + 4 != buf.len() {
513            return EnvelopeParse::Bare;
514        }
515        let Ok(crc_arr) = buf[p..p + 4].try_into() else {
516            return EnvelopeParse::Bare;
517        };
518        let expected = u32::from_le_bytes(crc_arr);
519        let computed = spg_crypto::crc32::crc32(&buf[..p]);
520        if expected != computed {
521            return EnvelopeParse::CrcMismatch { expected, computed };
522        }
523    } else if p != buf.len() {
524        // v1: must end exactly at the users section.
525        return EnvelopeParse::Bare;
526    }
527    EnvelopeParse::Pair {
528        catalog,
529        users,
530        publications,
531        subscriptions,
532        statistics,
533    }
534}
535
536/// v4.41.1 opaque transaction handle. Returned by `Engine::alloc_tx_id`,
537/// threaded through `Engine::execute_in` so dispatch can identify which
538/// in-flight TX a statement belongs to. `IMPLICIT_TX` is the reserved
539/// slot every legacy caller — engine self-tests, spg-cli, spg-embedded,
540/// startup replay — implicitly uses through the unchanged
541/// `Engine::execute(sql)` API. v4.41.1 keeps at most one active slot at
542/// runtime (dispatch holds `engine.write()` across the wrap, same as
543/// v4.34); the map shape is here to let v4.42 turn on N in-flight
544/// implicit TXs without reshuffling the engine internals.
545#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
546pub struct TxId(pub u64);
547
548/// Reserved slot used by `Engine::execute(sql)` — the legacy single-
549/// global-shadow path. New `alloc_tx_id` handles start at 1.
550pub const IMPLICIT_TX: TxId = TxId(0);
551
552/// v6.7.3 — default segment-size threshold used by `COMPACT COLD
553/// SEGMENTS` when no explicit target is supplied. Segments whose
554/// `OwnedSegment::bytes().len()` is **strictly** less than this
555/// value are eligible to merge. spg-server reads
556/// `SPG_COMPACTION_TARGET_SEGMENT_BYTES` to override.
557pub const COMPACTION_TARGET_DEFAULT_BYTES: u64 = 4 * 1024 * 1024;
558
559/// Per-slot transaction state. Held inside `tx_catalogs[tx_id]` for the
560/// lifetime of a BEGIN..COMMIT (or BEGIN..ROLLBACK) window. Drops when
561/// the TX commits (its `catalog` is moved over `Engine.catalog`) or
562/// rolls back (slot removed, catalog discarded).
563#[derive(Debug, Default, Clone)]
564struct TxState {
565    /// The TX's shadow copy of the catalog. Started as a clone of
566    /// `Engine.catalog` at BEGIN time; writes flow into it; COMMIT
567    /// installs it over `Engine.catalog`. `Catalog::clone()` is O(1)
568    /// since v4.40 (`PersistentVec` rows + `PersistentBTreeMap` indices).
569    catalog: Catalog,
570    /// Per-TX savepoint stack. Each entry pairs the savepoint name with
571    /// a clone of `catalog` at the moment `SAVEPOINT <name>` fired.
572    /// `ROLLBACK TO <name>` restores from the entry and pops everything
573    /// after it; `RELEASE <name>` discards the entry and everything
574    /// after; COMMIT/ROLLBACK clears the whole stack.
575    savepoints: Vec<(String, Catalog)>,
576}
577
578/// v7.11.0 — frozen read-only view of the engine's committed state.
579/// Constructed via [`Engine::clone_snapshot`]. Holds clones of the
580/// catalog, statistics, clock function, and row-cap config — the
581/// four fields the `execute_readonly` path actually reads. Cheap to
582/// `Clone` (each clone shares the underlying `PersistentVec` row
583/// storage; only the trie root pointers copy). Send + Sync so a
584/// snapshot can be moved across `tokio::task::spawn_blocking`
585/// boundaries without coordination.
586///
587/// The contract: a snapshot reflects the engine's state at the
588/// moment `clone_snapshot()` returned. Subsequent writes to the
589/// engine are NOT visible. Callers who need fresher data take a
590/// new snapshot.
591#[derive(Debug, Clone)]
592pub struct CatalogSnapshot {
593    catalog: Catalog,
594    statistics: statistics::Statistics,
595    clock: Option<ClockFn>,
596    max_query_rows: Option<usize>,
597}
598
599#[derive(Debug, Default)]
600pub struct Engine {
601    /// Committed catalog — what survives `Engine::snapshot()` and what
602    /// outside-TX `SELECT`s read.
603    catalog: Catalog,
604    /// Active TX slots, keyed by `TxId`. Empty when no TX is in flight.
605    /// v4.41.1 runtime invariant: at most one entry (single-writer
606    /// model unchanged). v4.42 will let dispatch hold multiple entries
607    /// concurrently for group commit + engine MVCC.
608    tx_catalogs: BTreeMap<TxId, TxState>,
609    /// Which slot the next exec_* call should mutate. Set by
610    /// `execute_in(sql, tx_id)` at the entry point; legacy `execute(sql)`
611    /// sets it to `IMPLICIT_TX`. None when no TX is in flight (read /
612    /// write goes straight against `catalog`).
613    current_tx: Option<TxId>,
614    /// Monotonic counter for `alloc_tx_id`. Starts at 1 — slot 0 is
615    /// reserved for `IMPLICIT_TX`.
616    next_tx_id: u64,
617    /// v7.22 (round-13 T3) — session string-literal dialect. `false`
618    /// (default) = PG semantics (backslash literal, `''` escape);
619    /// `true` = MySQL semantics (`\'` etc.). Flipped by the
620    /// deterministic session signals each dump emits: `SET sql_mode`
621    /// (only MySQL clients/dumps send it) turns it on,
622    /// `SET standard_conforming_strings = on` (every pg_dump
623    /// preamble) turns it off. The plan cache is cleared on every
624    /// flip — the same SQL text lexes differently per dialect.
625    backslash_escapes: bool,
626    /// Optional wall clock used to satisfy `NOW()` / `CURRENT_TIMESTAMP`
627    /// / `CURRENT_DATE`. Set by the host environment.
628    clock: Option<ClockFn>,
629    /// v4.1 cryptographic RNG for per-user password salt. Set by the
630    /// host. `None` means SQL-driven `CREATE USER` uses a
631    /// deterministic fallback — see `SaltFn`.
632    salt_fn: Option<SaltFn>,
633    /// v4.2 per-query row cap. `None` = unlimited. When set, a
634    /// SELECT that materialises more than `n` rows returns
635    /// `EngineError::RowLimitExceeded`. Enforced before the result
636    /// is shaped into wire frames so a runaway scan can't blow the
637    /// server's heap.
638    max_query_rows: Option<usize>,
639    /// v4.1 RBAC user table. Empty means "no RBAC configured yet" —
640    /// the server decides what that means at the auth boundary
641    /// (open mode vs legacy single-password mode). User CRUD goes
642    /// through `create_user`/`drop_user`/`verify_user`; persistence
643    /// rides the snapshot envelope alongside the catalog.
644    users: UserStore,
645    /// v6.1.2 logical-replication publication catalog. Empty until
646    /// `CREATE PUBLICATION` runs. Persistence rides the v3 envelope
647    /// trailer (see `build_envelope`).
648    publications: publications::Publications,
649    /// v6.1.4 logical-replication subscription catalog. Empty until
650    /// `CREATE SUBSCRIPTION` runs. Persistence rides the v4 envelope
651    /// trailer.
652    subscriptions: subscriptions::Subscriptions,
653    /// v6.2.0 — per-column statistics for the cost-based optimizer.
654    /// Populated by `ANALYZE`; queried via `spg_statistic` virtual
655    /// table. Persistence rides the v5 envelope trailer.
656    statistics: statistics::Statistics,
657    /// v6.3.0 — engine-level plan cache. Caches the post-`prepare()`
658    /// `Statement` keyed on SQL text. In-memory only — does NOT ride
659    /// the snapshot envelope (rebuilt on demand after restart).
660    plan_cache: plan_cache::PlanCache,
661    /// v6.5.1 — per-distinct-SQL execution stats. In-memory only,
662    /// surfaced via `spg_stat_query` virtual table. Updated by the
663    /// `execute_*` paths after a successful execute.
664    query_stats: query_stats::QueryStats,
665    /// v6.5.2 — connection-state provider callback. spg-server
666    /// registers a function at startup that snapshots its
667    /// per-pgwire-connection registry into `ActivityRow`s; engine
668    /// reads through it on every `SELECT * FROM spg_stat_activity`.
669    /// `None` ⇒ no-data (returns empty rows; matches the no_std
670    /// embedded callers that don't run pgwire).
671    activity_provider: Option<ActivityProvider>,
672    /// v6.5.3 — audit-chain provider + verifier. Same pattern as
673    /// activity_provider: spg-server registers both at startup;
674    /// engine reads through on `SELECT * FROM spg_audit_chain` and
675    /// `SELECT * FROM spg_audit_verify`. `None` ⇒ no-data.
676    audit_chain_provider: Option<AuditChainProvider>,
677    audit_verifier: Option<AuditVerifier>,
678    /// v6.5.6 — slow-query log threshold in microseconds. When set,
679    /// every successful execute whose elapsed exceeds the threshold
680    /// gets fed to the registered slow-query log callback (so
681    /// spg-server can emit a structured log line). Default `None`
682    /// = no slow-query logging.
683    slow_query_threshold_us: Option<u64>,
684    slow_query_logger: Option<SlowQueryLogger>,
685    /// v7.12.1 — session parameters set via `SET <name> = <value>`.
686    /// Only `default_text_search_config` is consumed by the engine
687    /// today (the FTS function dispatcher reads it when
688    /// `to_tsvector(text)` is called without an explicit config).
689    /// All other names are accepted + recorded so PG-dump output
690    /// loads, but have no behavioural effect.
691    session_params: BTreeMap<String, String>,
692    /// v7.12.7 — depth counter for trigger-emitted embedded SQL.
693    /// Each time the engine executes a `DeferredEmbeddedStmt` it
694    /// increments this; the recursive `execute_stmt_with_cancel`
695    /// inside that path checks against [`MAX_TRIGGER_RECURSION`]
696    /// to bound runaway cascades (trigger A's UPDATE on table B
697    /// fires trigger B which UPDATEs table A which fires trigger
698    /// A again…). Reset to 0 once the original DML returns.
699    trigger_recursion_depth: u32,
700    /// v7.14.0 — when `SET FOREIGN_KEY_CHECKS=0` is in effect
701    /// (mysqldump preamble), the FK existence + arity check at
702    /// CREATE TABLE time is deferred. FKs referencing a
703    /// not-yet-existing parent land in `pending_foreign_keys`
704    /// keyed by child table; `SET FOREIGN_KEY_CHECKS=1` drains
705    /// the queue and resolves each FK against the now-complete
706    /// catalog. Empty by default; the queue is drained on every
707    /// `RESET ALL` too.
708    foreign_key_checks: bool,
709    /// v7.16.2 — true on the temp Engine an outer
710    /// `exec_select_with_meta_views` builds, telling that
711    /// temp engine "stop short-circuiting into the meta-view
712    /// path — your catalog already has the materialised
713    /// tables; just run the regular SELECT." Without this we'd
714    /// infinite-loop since the meta-view name (e.g.
715    /// `__spg_info_columns`) still triggers
716    /// `select_references_meta_view`.
717    meta_views_materialised: bool,
718    pending_foreign_keys: Vec<(alloc::string::String, spg_sql::ast::ForeignKeyConstraint)>,
719}
720
721/// v7.12.7 — hard cap on nested trigger-emitted embedded SQL
722/// fires. 16 deep is well past anything a normal trigger graph
723/// uses while still preventing infinite-loop wedging.
724const MAX_TRIGGER_RECURSION: u32 = 16;
725
726/// v6.5.6 — callback signature for slow-query log emission. Called
727/// with `(sql, elapsed_us)` once per successful execute that crosses
728/// the threshold.
729pub type SlowQueryLogger = fn(&str, u64);
730
731/// v6.5.4 — synthesise a `CREATE TABLE` statement from catalog
732/// state. Round-trips through `Engine::execute` to recreate the
733/// same schema (sans data + indexes — indexes are emitted as a
734/// separate `CREATE INDEX` chain in `spg_database_ddl`).
735fn render_create_table(name: &str, columns: &[ColumnSchema]) -> String {
736    let mut out = alloc::format!("CREATE TABLE {name} (");
737    for (i, col) in columns.iter().enumerate() {
738        if i > 0 {
739            out.push_str(", ");
740        }
741        out.push_str(&col.name);
742        out.push(' ');
743        out.push_str(&render_data_type(col.ty));
744        if !col.nullable {
745            out.push_str(" NOT NULL");
746        }
747        if col.auto_increment {
748            out.push_str(" AUTO_INCREMENT");
749        }
750    }
751    out.push(')');
752    out
753}
754
755fn render_data_type(ty: DataType) -> String {
756    match ty {
757        DataType::SmallInt => "SMALLINT".into(),
758        DataType::Int => "INT".into(),
759        DataType::BigInt => "BIGINT".into(),
760        DataType::Float => "FLOAT".into(),
761        DataType::Text => "TEXT".into(),
762        DataType::Varchar(n) => alloc::format!("VARCHAR({n})"),
763        DataType::Char(n) => alloc::format!("CHAR({n})"),
764        DataType::Bool => "BOOL".into(),
765        DataType::Vector { dim, encoding } => match encoding {
766            spg_storage::VecEncoding::F32 => alloc::format!("VECTOR({dim})"),
767            spg_storage::VecEncoding::Sq8 => alloc::format!("VECTOR({dim}) USING SQ8"),
768            spg_storage::VecEncoding::F16 => alloc::format!("VECTOR({dim}) USING HALF"),
769        },
770        DataType::Numeric { precision, scale } => {
771            alloc::format!("NUMERIC({precision},{scale})")
772        }
773        DataType::Date => "DATE".into(),
774        DataType::Timestamp => "TIMESTAMP".into(),
775        DataType::Interval => "INTERVAL".into(),
776        DataType::Json => "JSON".into(),
777        DataType::Jsonb => "JSONB".into(),
778        DataType::Timestamptz => "TIMESTAMPTZ".into(),
779        DataType::Bytes => "BYTEA".into(),
780        DataType::TextArray => "TEXT[]".into(),
781        DataType::IntArray => "INT[]".into(),
782        DataType::BigIntArray => "BIGINT[]".into(),
783        DataType::TsVector => "TSVECTOR".into(),
784        DataType::TsQuery => "TSQUERY".into(),
785        DataType::Uuid => "UUID".into(),
786        DataType::Time => "TIME".into(),
787        DataType::Year => "YEAR".into(),
788        DataType::TimeTz => "TIMETZ".into(),
789        DataType::Money => "MONEY".into(),
790        DataType::Range(k) => k.keyword().into(),
791        DataType::Hstore => "HSTORE".into(),
792        DataType::IntArray2D => "INT[][]".into(),
793        DataType::BigIntArray2D => "BIGINT[][]".into(),
794        DataType::TextArray2D => "TEXT[][]".into(),
795    }
796}
797
798/// v6.5.2 — one row of `spg_stat_activity`. Engine-public so
799/// spg-server can construct rows without re-exporting internal
800/// dispatch types.
801#[derive(Debug, Clone)]
802pub struct ActivityRow {
803    pub pid: u32,
804    pub user: String,
805    pub started_at_us: i64,
806    pub current_sql: String,
807    pub wait_event: String,
808    pub elapsed_us: i64,
809    pub in_transaction: bool,
810    /// v7.17 Phase 2.4 — startup-param `application_name` (or the
811    /// last value the client sent via `SET application_name = '...'`).
812    /// Empty when the client never declared one.
813    pub application_name: String,
814}
815
816/// v6.5.2 — provider callback type. Fresh snapshot returned each
817/// call; engine doesn't cache the slice.
818pub type ActivityProvider = fn() -> Vec<ActivityRow>;
819
820/// v6.5.3 — one row of `spg_audit_chain`. Engine-public so
821/// spg-server can construct rows directly from `AuditEntry`.
822#[derive(Debug, Clone)]
823pub struct AuditRow {
824    pub seq: i64,
825    pub ts_ms: i64,
826    pub prev_hash_hex: String,
827    pub entry_hash_hex: String,
828    pub sql: String,
829}
830
831/// v6.5.3 — chain-table provider + verifier. spg-server registers
832/// fn pointers that snapshot / verify the audit log. `verify`
833/// returns `(verified_count, broken_at_seq)` — `broken_at_seq` is
834/// `-1` on a clean chain.
835pub type AuditChainProvider = fn() -> Vec<AuditRow>;
836pub type AuditVerifier = fn() -> (i64, i64);
837
838impl Engine {
839    pub fn new() -> Self {
840        Self {
841            catalog: Catalog::new(),
842            tx_catalogs: BTreeMap::new(),
843            current_tx: None,
844            backslash_escapes: false,
845            next_tx_id: 1,
846            clock: None,
847            salt_fn: None,
848            max_query_rows: None,
849            users: UserStore::new(),
850            publications: publications::Publications::new(),
851            subscriptions: subscriptions::Subscriptions::new(),
852            statistics: statistics::Statistics::new(),
853            plan_cache: plan_cache::PlanCache::new(),
854            query_stats: query_stats::QueryStats::new(),
855            activity_provider: None,
856            audit_chain_provider: None,
857            audit_verifier: None,
858            slow_query_threshold_us: None,
859            slow_query_logger: None,
860            session_params: BTreeMap::new(),
861            trigger_recursion_depth: 0,
862            foreign_key_checks: true,
863            meta_views_materialised: false,
864            pending_foreign_keys: Vec::new(),
865        }
866    }
867
868    /// v7.11.0 — clone the engine's committed catalog + read-time
869    /// state into a frozen `CatalogSnapshot`. Cheap (`Catalog` is
870    /// backed by `PersistentVec`; cloning is O(log n) per table).
871    /// Subsequent writes to this engine are invisible to the
872    /// snapshot; the snapshot is self-contained and can be moved
873    /// to another thread for concurrent `execute_readonly_on_snapshot`
874    /// calls. The basis for [`AsyncReadHandle`] in spg-embedded-tokio
875    /// and any other read-fanout pattern.
876    #[must_use]
877    pub fn clone_snapshot(&self) -> CatalogSnapshot {
878        CatalogSnapshot {
879            catalog: self.active_catalog().clone(),
880            statistics: self.statistics.clone(),
881            clock: self.clock,
882            max_query_rows: self.max_query_rows,
883        }
884    }
885
886    /// v7.11.1 — execute a read-only SQL statement against a
887    /// `CatalogSnapshot` without touching this engine. Same
888    /// semantics as `execute_readonly` but parameterised on the
889    /// snapshot's catalog. Reject DDL/DML the same way
890    /// `execute_readonly` does. Static-on-Self so the caller can
891    /// dispatch without holding an `Engine` borrow alongside the
892    /// snapshot.
893    pub fn execute_readonly_on_snapshot(
894        snapshot: &CatalogSnapshot,
895        sql: &str,
896    ) -> Result<QueryResult, EngineError> {
897        Self::execute_readonly_on_snapshot_with_cancel(snapshot, sql, CancelToken::none())
898    }
899
900    /// v7.11.1 — `execute_readonly_on_snapshot` with cooperative
901    /// cancellation. Builds a transient `Engine` over the snapshot
902    /// state, runs `execute_readonly_with_cancel`, drops. The
903    /// transient engine is cheap to construct (no I/O; everything
904    /// is just struct moves) and lets the existing read path stay
905    /// untouched.
906    pub fn execute_readonly_on_snapshot_with_cancel(
907        snapshot: &CatalogSnapshot,
908        sql: &str,
909        cancel: CancelToken<'_>,
910    ) -> Result<QueryResult, EngineError> {
911        let transient = Engine {
912            catalog: snapshot.catalog.clone(),
913            statistics: snapshot.statistics.clone(),
914            clock: snapshot.clock,
915            max_query_rows: snapshot.max_query_rows,
916            ..Engine::default()
917        };
918        transient.execute_readonly_with_cancel(sql, cancel)
919    }
920
921    /// v7.18 — execute a previously-prepared `Statement` against a
922    /// `CatalogSnapshot` in read-only mode. Mirror of
923    /// [`Engine::execute_prepared`] for the fan-out read path:
924    /// substitutes `Expr::Placeholder(n)` nodes from `params`, then
925    /// dispatches through [`Engine::execute_readonly_stmt_with_cancel`]
926    /// (writes / DDL hit `EngineError::WriteRequired`). Static-on-Self
927    /// so multiple readonly threads can dispatch against the same
928    /// snapshot concurrently without an `Engine` borrow.
929    ///
930    /// **Schema drift contract**. The `Statement` was prepared against
931    /// some prior catalog. If the snapshot's catalog has since
932    /// diverged (DDL renamed / dropped a referenced column / table),
933    /// execution surfaces the normal `EngineError` — same shape as
934    /// PG's "cached plan must not change result type". Caller decides
935    /// whether to re-prepare; engine does NOT auto-retry.
936    pub fn execute_readonly_prepared_on_snapshot(
937        snapshot: &CatalogSnapshot,
938        stmt: Statement,
939        params: &[Value],
940    ) -> Result<QueryResult, EngineError> {
941        Self::execute_readonly_prepared_on_snapshot_with_cancel(
942            snapshot,
943            stmt,
944            params,
945            CancelToken::none(),
946        )
947    }
948
949    /// v7.18 — cancellable variant of
950    /// [`Engine::execute_readonly_prepared_on_snapshot`].
951    pub fn execute_readonly_prepared_on_snapshot_with_cancel(
952        snapshot: &CatalogSnapshot,
953        mut stmt: Statement,
954        params: &[Value],
955        cancel: CancelToken<'_>,
956    ) -> Result<QueryResult, EngineError> {
957        cancel.check()?;
958        substitute_placeholders(&mut stmt, params)?;
959        let transient = Engine {
960            catalog: snapshot.catalog.clone(),
961            statistics: snapshot.statistics.clone(),
962            clock: snapshot.clock,
963            max_query_rows: snapshot.max_query_rows,
964            ..Engine::default()
965        };
966        transient.execute_readonly_stmt_with_cancel(stmt, cancel)
967    }
968
969    /// v7.18 — describe a prepared `Statement` against a
970    /// `CatalogSnapshot`. Same `(parameter_oids, output_columns)`
971    /// shape as [`Engine::describe_prepared`]; resolves names
972    /// against the snapshot's catalog instead of `self`. Pure
973    /// function — no engine state read.
974    pub fn describe_prepared_on_snapshot(
975        snapshot: &CatalogSnapshot,
976        stmt: &Statement,
977    ) -> (Vec<u32>, Vec<ColumnSchema>) {
978        describe::describe_prepared(stmt, &snapshot.catalog)
979    }
980
981    /// v7.18 — does this SQL string classify as read-only? Parses
982    /// `sql` with the engine parser and consults
983    /// `Statement::is_readonly()`. A parse error returns `false`
984    /// (route to the writer path so the user sees the canonical
985    /// parse error from the writer's simple-query dispatch).
986    /// Static-on-Self so the spg-sqlx connection layer can ask
987    /// without an `Engine` borrow.
988    #[must_use]
989    pub fn is_readonly_sql(sql: &str) -> bool {
990        parser::parse_statement(sql)
991            .as_ref()
992            .map(spg_sql::ast::Statement::is_readonly)
993            .unwrap_or(false)
994    }
995
996    /// v7.18 — parse + plan a SQL string against a
997    /// `CatalogSnapshot`. Mirror of [`Engine::prepare`] for the
998    /// readonly fan-out path: applies the same prepare-time
999    /// transforms (clock rewrite, `GROUP BY ALL` expansion, ORDER
1000    /// BY position resolve, cost-based JOIN reorder) but resolves
1001    /// catalog + statistics against the snapshot, not a live
1002    /// engine. Static-on-Self — `AsyncReadHandle::prepare` calls
1003    /// this without taking the writer lock so multiple read
1004    /// handles can prepare concurrently against frozen views.
1005    ///
1006    /// # Errors
1007    /// Propagates [`ParseError`] from the parser. Schema
1008    /// validation deferred to execute time, same as
1009    /// [`Engine::prepare`].
1010    pub fn prepare_on_snapshot(
1011        snapshot: &CatalogSnapshot,
1012        sql: &str,
1013    ) -> Result<Statement, ParseError> {
1014        let mut stmt = parser::parse_statement(sql)?;
1015        let now_micros = snapshot.clock.map(|f| f());
1016        rewrite_clock_calls(&mut stmt, now_micros);
1017        if let Statement::Select(s) = &mut stmt {
1018            expand_group_by_all(s);
1019            resolve_order_by_position(s);
1020            reorder::reorder_joins(s, &snapshot.catalog, &snapshot.statistics);
1021        }
1022        Ok(stmt)
1023    }
1024
1025    /// Construct an engine restored from a previously-snapshotted catalog
1026    /// (see `snapshot()`).
1027    pub fn restore(catalog: Catalog) -> Self {
1028        Self {
1029            catalog,
1030            tx_catalogs: BTreeMap::new(),
1031            current_tx: None,
1032            backslash_escapes: false,
1033            next_tx_id: 1,
1034            clock: None,
1035            salt_fn: None,
1036            max_query_rows: None,
1037            users: UserStore::new(),
1038            publications: publications::Publications::new(),
1039            subscriptions: subscriptions::Subscriptions::new(),
1040            statistics: statistics::Statistics::new(),
1041            plan_cache: plan_cache::PlanCache::new(),
1042            query_stats: query_stats::QueryStats::new(),
1043            activity_provider: None,
1044            audit_chain_provider: None,
1045            audit_verifier: None,
1046            slow_query_threshold_us: None,
1047            slow_query_logger: None,
1048            session_params: BTreeMap::new(),
1049            trigger_recursion_depth: 0,
1050            foreign_key_checks: true,
1051            meta_views_materialised: false,
1052            pending_foreign_keys: Vec::new(),
1053        }
1054    }
1055
1056    /// Restore an engine + user table from a v4.1 envelope produced
1057    /// by `snapshot_with_users()`. Falls back to plain catalog-only
1058    /// restore if the envelope magic isn't present (so v3.x snapshot
1059    /// files still load). v6.1.2 adds the optional publications
1060    /// trailer (envelope v3); a v1/v2 envelope deserialises to an
1061    /// empty publication table.
1062    pub fn restore_envelope(buf: &[u8]) -> Result<Self, EngineError> {
1063        match split_envelope(buf) {
1064            EnvelopeParse::Pair {
1065                catalog: catalog_bytes,
1066                users: user_bytes,
1067                publications: pub_bytes,
1068                subscriptions: sub_bytes,
1069                statistics: stats_bytes,
1070            } => {
1071                let catalog = Catalog::deserialize(catalog_bytes).map_err(EngineError::Storage)?;
1072                let users = users::deserialize_users(user_bytes)
1073                    .map_err(|e| EngineError::Unsupported(alloc::format!("users restore: {e}")))?;
1074                let publications = match pub_bytes {
1075                    Some(b) => publications::Publications::deserialize(b).map_err(|e| {
1076                        EngineError::Unsupported(alloc::format!("publications restore: {e:?}"))
1077                    })?,
1078                    None => publications::Publications::new(),
1079                };
1080                let subscriptions = match sub_bytes {
1081                    Some(b) => subscriptions::Subscriptions::deserialize(b).map_err(|e| {
1082                        EngineError::Unsupported(alloc::format!("subscriptions restore: {e:?}"))
1083                    })?,
1084                    None => subscriptions::Subscriptions::new(),
1085                };
1086                let statistics = match stats_bytes {
1087                    Some(b) => statistics::Statistics::deserialize(b).map_err(|e| {
1088                        EngineError::Unsupported(alloc::format!("statistics restore: {e:?}"))
1089                    })?,
1090                    None => statistics::Statistics::new(),
1091                };
1092                Ok(Self {
1093                    catalog,
1094                    tx_catalogs: BTreeMap::new(),
1095                    current_tx: None,
1096                    backslash_escapes: false,
1097                    next_tx_id: 1,
1098                    clock: None,
1099                    salt_fn: None,
1100                    max_query_rows: None,
1101                    users,
1102                    publications,
1103                    subscriptions,
1104                    statistics,
1105                    plan_cache: plan_cache::PlanCache::new(),
1106                    query_stats: query_stats::QueryStats::new(),
1107                    activity_provider: None,
1108                    audit_chain_provider: None,
1109                    audit_verifier: None,
1110                    slow_query_threshold_us: None,
1111                    slow_query_logger: None,
1112                    session_params: BTreeMap::new(),
1113                    trigger_recursion_depth: 0,
1114                    foreign_key_checks: true,
1115                    meta_views_materialised: false,
1116                    pending_foreign_keys: Vec::new(),
1117                })
1118            }
1119            EnvelopeParse::CrcMismatch { expected, computed } => {
1120                Err(EngineError::Storage(StorageError::Corrupt(alloc::format!(
1121                    "snapshot envelope CRC32 mismatch (expected={expected:#010x}, computed={computed:#010x})"
1122                ))))
1123            }
1124            EnvelopeParse::Bare => {
1125                let catalog = Catalog::deserialize(buf).map_err(EngineError::Storage)?;
1126                Ok(Self::restore(catalog))
1127            }
1128        }
1129    }
1130
1131    pub const fn users(&self) -> &UserStore {
1132        &self.users
1133    }
1134
1135    /// `salt` is supplied by the caller (the host has a random
1136    /// source; the engine is `no_std`). Caller should pass a fresh
1137    /// 16-byte random value per user.
1138    pub fn create_user(
1139        &mut self,
1140        name: &str,
1141        password: &str,
1142        role: Role,
1143        salt: [u8; 16],
1144    ) -> Result<(), UserError> {
1145        self.users.create(name, password, role, salt)?;
1146        // v4.8: also derive SCRAM-SHA-256 secrets so PG-wire SASL
1147        // auth can verify without re-running PBKDF2 per attempt.
1148        // Uses a fresh salt from the host RNG (falls back to a
1149        // deterministic per-username salt when no RNG is wired, same
1150        // as the legacy hash path).
1151        let scram_salt = self.salt_fn.map_or_else(
1152            || {
1153                let mut s = [0u8; users::SCRAM_SALT_LEN];
1154                let digest = spg_crypto::hash(name.as_bytes());
1155                // Use bytes 16..32 of BLAKE3 so we don't reuse the
1156                // exact same fallback salt as the BLAKE3 hash path.
1157                s.copy_from_slice(&digest[16..32]);
1158                s
1159            },
1160            |f| f(),
1161        );
1162        self.users
1163            .enable_scram(name, password, scram_salt, users::SCRAM_DEFAULT_ITERS)?;
1164        Ok(())
1165    }
1166
1167    pub fn drop_user(&mut self, name: &str) -> Result<(), UserError> {
1168        self.users.drop(name)
1169    }
1170
1171    pub fn verify_user(&self, name: &str, password: &str) -> Option<Role> {
1172        self.users.verify(name, password)
1173    }
1174
1175    /// Builder: attach a wall clock so `NOW()` / `CURRENT_TIMESTAMP` /
1176    /// `CURRENT_DATE` evaluate to a real value instead of erroring out.
1177    #[must_use]
1178    pub const fn with_clock(mut self, clock: ClockFn) -> Self {
1179        self.clock = Some(clock);
1180        self
1181    }
1182
1183    /// Builder: attach an OS-backed RNG for per-user password salts.
1184    /// The host (`spg-server`) typically wires this to `/dev/urandom`.
1185    #[must_use]
1186    pub const fn with_salt_fn(mut self, f: SaltFn) -> Self {
1187        self.salt_fn = Some(f);
1188        self
1189    }
1190
1191    /// Builder: cap the number of rows a single SELECT may return.
1192    /// Exceeding the cap raises `EngineError::RowLimitExceeded` —
1193    /// the bound is checked inside the executor so a runaway
1194    /// catalog scan can't allocate millions of rows before the
1195    /// server gets a chance to reject the result.
1196    #[must_use]
1197    pub const fn with_max_query_rows(mut self, n: usize) -> Self {
1198        self.max_query_rows = Some(n);
1199        self
1200    }
1201
1202    /// The *committed* catalog. Note: during a transaction this returns the
1203    /// pre-TX state — `SELECT` inside a TX goes through `execute()` and reads
1204    /// the shadow. Tests that inspect outside-TX state should use this.
1205    pub const fn catalog(&self) -> &Catalog {
1206        &self.catalog
1207    }
1208
1209    /// Serialize the *committed* catalog to bytes. v0.6 was full-snapshot; v0.9
1210    /// adds the rule that an open TX's shadow is never snapshotted — only the
1211    /// post-COMMIT state is persisted. v4.1 wraps the catalog in an envelope
1212    /// when there are users to persist; an empty user table snapshots as the
1213    /// bare catalog format (backwards-compat with v3.x readers). v6.1.2
1214    /// adds publications to the envelope condition: either non-empty
1215    /// users OR non-empty publications now triggers the envelope path.
1216    pub fn snapshot(&self) -> Vec<u8> {
1217        if self.users.is_empty()
1218            && self.publications.is_empty()
1219            && self.subscriptions.is_empty()
1220            && self.statistics.is_empty()
1221        {
1222            self.catalog.serialize()
1223        } else {
1224            build_envelope(
1225                &self.catalog.serialize(),
1226                &users::serialize_users(&self.users),
1227                &self.publications.serialize(),
1228                &self.subscriptions.serialize(),
1229                &self.statistics.serialize(),
1230            )
1231        }
1232    }
1233
1234    /// True when at least one TX slot is in flight. v4.41.1 runtime
1235    /// invariant: at most one slot active at a time (dispatch holds
1236    /// `engine.write()` across the entire wrap). v4.42 will let this
1237    /// return true with multiple slots concurrently.
1238    pub fn in_transaction(&self) -> bool {
1239        !self.tx_catalogs.is_empty()
1240    }
1241
1242    /// v4.41.1 allocate a fresh TX handle. Used by spg-server dispatch
1243    /// to scope each implicit-wrap BEGIN..stmt..COMMIT to its own slot
1244    /// in `tx_catalogs`. v4.42 — the commit-barrier leader allocates
1245    /// one of these per task in its group, runs `BEGIN`+sql+`COMMIT`
1246    /// sequentially under a single `engine.write()` so each task's
1247    /// mutations accumulate into shared state, then either keeps the
1248    /// accumulated state (fsync OK) or restores the pre-image via
1249    /// `replace_catalog` (fsync err).
1250    pub fn alloc_tx_id(&mut self) -> TxId {
1251        let id = TxId(self.next_tx_id);
1252        self.next_tx_id = self.next_tx_id.saturating_add(1);
1253        id
1254    }
1255
1256    /// v4.42 — atomically replace the live catalog. Used by the
1257    /// commit-barrier leader to roll back a group whose batched
1258    /// fsync failed: the leader snapshots `engine.catalog().clone()`
1259    /// (O(1) Arc bump after the v4.39/v4.40 persistent migration)
1260    /// at group start, sequentially applies each task's BEGIN+sql+
1261    /// COMMIT under the same write lock to accumulate mutations
1262    /// into shared state, batches the WAL bytes, fsyncs once, and
1263    /// on failure calls this with the pre-image to undo every
1264    /// task in the group at once.
1265    ///
1266    /// **Does NOT touch `tx_catalogs` / `current_tx`.** Any
1267    /// explicit-TX slot from a concurrent client (created via the
1268    /// legacy `IMPLICIT_TX`-less dispatch path or via the future
1269    /// MVCC-readers v5+ work) has its own snapshot baked into the
1270    /// slot — restoring `self.catalog` to the pre-image leaves
1271    /// those slots untouched, exactly as they were when the leader
1272    /// took the lock. The leader's own implicit-TX slots are all
1273    /// already discarded (`exec_commit` removed them as each
1274    /// task's COMMIT ran) by the time this is reached.
1275    pub fn replace_catalog(&mut self, catalog: Catalog) {
1276        self.catalog = catalog;
1277    }
1278
1279    /// v6.7.0 — public shim around `Catalog::freeze_oldest_to_cold`
1280    /// so tests + the spg-server freezer can drive a freeze without
1281    /// reaching into the private `active_catalog_mut`. v6.7.4
1282    /// parallel freezer will build on this surface.
1283    ///
1284    /// Marks the table's cached `cold_row_count` stale because the
1285    /// freeze added cold locators that ANALYZE hasn't yet refreshed.
1286    pub fn freeze_oldest_to_cold(
1287        &mut self,
1288        table_name: &str,
1289        index_name: &str,
1290        max_rows: usize,
1291    ) -> Result<spg_storage::FreezeReport, EngineError> {
1292        let report = self
1293            .active_catalog_mut()
1294            .freeze_oldest_to_cold(table_name, index_name, max_rows)
1295            .map_err(EngineError::Storage)?;
1296        if let Some(t) = self.active_catalog_mut().get_mut(table_name) {
1297            t.mark_cold_row_count_stale();
1298        }
1299        Ok(report)
1300    }
1301
1302    /// v6.7.5 — public shim used by the spg-server follower's
1303    /// segment-forwarding receiver. Registers a cold-tier segment
1304    /// at a specific id (the master's id, as transmitted on the
1305    /// wire) so the follower's BTree-Cold locators stay byte-
1306    /// identical with the master's. Wraps
1307    /// `Catalog::load_segment_bytes_at` under the standard
1308    /// clone-mutate-replace pattern.
1309    ///
1310    /// Returns `Ok(())` on success **and** on the "slot already
1311    /// occupied" case — a follower mid-reconnect may receive a
1312    /// segment chunk for a segment_id it already has on disk
1313    /// (forwarded last session); the caller should treat that
1314    /// path as a no-op rather than a fatal error.
1315    pub fn receive_cold_segment(
1316        &mut self,
1317        segment_id: u32,
1318        bytes: Vec<u8>,
1319    ) -> Result<(), EngineError> {
1320        let mut new_cat = self.catalog.clone();
1321        match new_cat.load_segment_bytes_at(segment_id, bytes) {
1322            Ok(()) => {
1323                self.replace_catalog(new_cat);
1324                Ok(())
1325            }
1326            Err(StorageError::Corrupt(msg)) if msg.contains("already occupied") => Ok(()),
1327            Err(e) => Err(EngineError::Storage(e)),
1328        }
1329    }
1330
1331    /// v6.7.3 — public shim around `Catalog::compact_cold_segments`
1332    /// driving every BTree index on every user table. Returns one
1333    /// `(table, index, report)` triple for each merge that
1334    /// actually happened (no-op (table, index) pairs are filtered
1335    /// out so callers can size persist-side work to the live
1336    /// merges). Caller is responsible for persisting each
1337    /// `report.merged_segment_bytes` and updating the on-disk
1338    /// segment registry; engine layer is no_std and never
1339    /// touches disk.
1340    ///
1341    /// Marks every touched table's cached `cold_row_count` stale
1342    /// — compaction GC'd some shadowed rows, so the count must be
1343    /// re-derived on the next ANALYZE.
1344    pub fn compact_cold_segments_with_target(
1345        &mut self,
1346        target_segment_bytes: u64,
1347    ) -> Result<Vec<(String, String, CompactReport)>, EngineError> {
1348        let table_names = self.active_catalog().table_names();
1349        let mut reports: Vec<(String, String, CompactReport)> = Vec::new();
1350        for tname in table_names {
1351            if is_internal_table_name(&tname) {
1352                continue;
1353            }
1354            let idx_names: Vec<String> = {
1355                let Some(t) = self.active_catalog().get(&tname) else {
1356                    continue;
1357                };
1358                t.indices()
1359                    .iter()
1360                    .filter(|i| matches!(i.kind, IndexKind::BTree(_)))
1361                    .map(|i| i.name.clone())
1362                    .collect()
1363            };
1364            for iname in idx_names {
1365                let report = self
1366                    .active_catalog_mut()
1367                    .compact_cold_segments(&tname, &iname, target_segment_bytes)
1368                    .map_err(EngineError::Storage)?;
1369                if report.merged_segment_id.is_some() {
1370                    if let Some(t) = self.active_catalog_mut().get_mut(&tname) {
1371                        t.mark_cold_row_count_stale();
1372                    }
1373                    reports.push((tname.clone(), iname, report));
1374                }
1375            }
1376        }
1377        Ok(reports)
1378    }
1379
1380    fn active_catalog(&self) -> &Catalog {
1381        match self.current_tx {
1382            Some(t) => self
1383                .tx_catalogs
1384                .get(&t)
1385                .map_or(&self.catalog, |s| &s.catalog),
1386            None => &self.catalog,
1387        }
1388    }
1389
1390    /// v7.12.4 — snapshot every row-level trigger on `table` that
1391    /// fires for `event` (`"INSERT"` / `"UPDATE"` / `"DELETE"`) at
1392    /// the given `timing` (`"BEFORE"` / `"AFTER"`), and clone its
1393    /// referenced function definition. Returned as a vec of owned
1394    /// `FunctionDef` so the row-write loop can fire them without
1395    /// holding a borrow on the catalog (which would conflict with
1396    /// the table.insert / update_row / delete mutable borrows).
1397    /// v7.16.2 — top-level DO block executor. Walks the
1398    /// PlPgSqlBlock via [`triggers::execute_do_block_top_level`],
1399    /// then runs each collected EmbeddedSql statement through
1400    /// the engine's regular execute path (NOT deferred — DO is
1401    /// outside any row-write borrow). Errors from any step
1402    /// abort the block and propagate verbatim.
1403    /// v7.16.2 — resolve every subquery inside a PlPgSqlBlock's
1404    /// expression slots so the downstream trigger-flavoured
1405    /// evaluator (which expects pre-resolved Expr::Literal /
1406    /// Binary chains) doesn't trip on raw Exists/ScalarSubquery
1407    /// nodes. Walks IF conditions, Assign values, RAISE args.
1408    /// EmbeddedSql statements re-enter the engine for execution
1409    /// later so their subqueries get the normal SELECT-side
1410    /// resolution.
1411    fn resolve_plpgsql_block_subqueries(
1412        &self,
1413        block: &mut spg_sql::ast::PlPgSqlBlock,
1414        cancel: CancelToken<'_>,
1415    ) -> Result<(), EngineError> {
1416        for d in &mut block.declarations {
1417            if let Some(e) = &mut d.default {
1418                self.resolve_expr_subqueries(e, cancel)?;
1419            }
1420        }
1421        self.resolve_plpgsql_stmts_subqueries(&mut block.statements, cancel)
1422    }
1423
1424    fn resolve_plpgsql_stmts_subqueries(
1425        &self,
1426        stmts: &mut [spg_sql::ast::PlPgSqlStmt],
1427        cancel: CancelToken<'_>,
1428    ) -> Result<(), EngineError> {
1429        use spg_sql::ast::PlPgSqlStmt;
1430        for stmt in stmts {
1431            match stmt {
1432                PlPgSqlStmt::Assign { value, .. } => {
1433                    self.resolve_expr_subqueries(value, cancel)?;
1434                }
1435                PlPgSqlStmt::Return(spg_sql::ast::ReturnTarget::Expr(e)) => {
1436                    self.resolve_expr_subqueries(e, cancel)?;
1437                }
1438                PlPgSqlStmt::Return(_) => {}
1439                PlPgSqlStmt::If {
1440                    branches,
1441                    else_branch,
1442                } => {
1443                    for (cond, body) in branches.iter_mut() {
1444                        self.resolve_expr_subqueries(cond, cancel)?;
1445                        self.resolve_plpgsql_stmts_subqueries(body, cancel)?;
1446                    }
1447                    self.resolve_plpgsql_stmts_subqueries(else_branch, cancel)?;
1448                }
1449                PlPgSqlStmt::Raise { args, .. } => {
1450                    for a in args {
1451                        self.resolve_expr_subqueries(a, cancel)?;
1452                    }
1453                }
1454                PlPgSqlStmt::EmbeddedSql(_) => {
1455                    // Embedded SQL goes back through execute_stmt
1456                    // _with_cancel which runs the SELECT-side
1457                    // resolver itself; nothing to do here.
1458                }
1459                PlPgSqlStmt::SelectInto { body, .. } => {
1460                    // SELECT INTO runs through Engine::execute
1461                    // when reached, so subquery resolution
1462                    // happens via the normal SELECT-side path.
1463                    // Still walk for nested subqueries inside
1464                    // the SELECT body so eval doesn't trip.
1465                    self.resolve_select_subqueries(body, cancel)?;
1466                }
1467            }
1468        }
1469        Ok(())
1470    }
1471
1472    fn exec_do_block(
1473        &mut self,
1474        body: spg_sql::ast::PlPgSqlBlock,
1475    ) -> Result<QueryResult, EngineError> {
1476        // v7.16.2 — pre-resolve every subquery the body's
1477        // expressions reach. `eval::eval_expr` errors on
1478        // unresolved Exists/ScalarSubquery/InSubquery; the
1479        // top-level SELECT path runs `resolve_select_subqueries`
1480        // for the caller — for plpgsql we have to do the
1481        // equivalent before the body walker runs. Catches the
1482        // mailrs idiom `IF EXISTS (SELECT 1 FROM
1483        // information_schema.columns WHERE …) THEN …`.
1484        let mut body = body;
1485        self.resolve_plpgsql_block_subqueries(&mut body, CancelToken::none())?;
1486        let dts = self
1487            .session_param("default_text_search_config")
1488            .map(String::from);
1489        // v7.16.2 — SELECT … INTO resolver. The walker calls
1490        // this synchronously when it hits a SelectInto stmt
1491        // so the IF / locals scope sees the result before the
1492        // next statement. Body walks for trigger paths (no
1493        // resolver) error loudly on SelectInto.
1494        // SAFETY: the closure shares this engine borrow with
1495        // the walker, but the walker only borrows for the
1496        // duration of `execute_do_block_top_level` and doesn't
1497        // reach back into the engine through any other path —
1498        // so the recursive `&mut` is sound. We use a `RefCell`
1499        // for interior mutability since the closure is
1500        // Fn-shaped.
1501        let engine_cell = core::cell::RefCell::new(&mut *self);
1502        let resolver_fn =
1503            |stmt: &spg_sql::ast::Statement| -> Result<Value, triggers::TriggerError> {
1504                let mut eng = engine_cell.borrow_mut();
1505                let r = eng
1506                    .execute_stmt_with_cancel(stmt.clone(), CancelToken::none())
1507                    .map_err(|e| triggers::TriggerError::EvalFailed {
1508                        function: "DO".into(),
1509                        cause: eval::EvalError::TypeMismatch {
1510                            detail: alloc::format!("SELECT … INTO failed: {e}"),
1511                        },
1512                    })?;
1513                match r {
1514                    QueryResult::Rows { rows, .. } => match rows.into_iter().next() {
1515                        Some(row) => Ok(row.values.into_iter().next().unwrap_or(Value::Null)),
1516                        None => Ok(Value::Null),
1517                    },
1518                    _ => Err(triggers::TriggerError::EvalFailed {
1519                        function: "DO".into(),
1520                        cause: eval::EvalError::TypeMismatch {
1521                            detail: "SELECT … INTO body must be a SELECT".into(),
1522                        },
1523                    }),
1524                }
1525            };
1526        let collected =
1527            triggers::execute_do_block_top_level(&body, dts.as_deref(), Some(&resolver_fn))
1528                .map_err(|e| {
1529                    EngineError::Storage(StorageError::Corrupt(alloc::format!("DO: {e}")))
1530                })?;
1531        // engine_cell goes out of scope here, releasing the &mut self borrow
1532        // Run each embedded statement against the engine. The
1533        // statements were already substitute-walked for NEW/OLD/
1534        // locals (those evaluate to engine literals before they
1535        // land here) so dispatch is plain execute_stmt_with_cancel.
1536        for stmt in collected {
1537            // v7.16.2 — preserve current_tx wrap so an outer
1538            // BEGIN/COMMIT around a DO block keeps the
1539            // EmbeddedSql writes inside that same tx slot.
1540            self.execute_stmt_with_cancel(stmt, CancelToken::none())?;
1541        }
1542        Ok(QueryResult::CommandOk {
1543            affected: 0,
1544            modified_catalog: !self.in_transaction(),
1545        })
1546    }
1547
1548    fn snapshot_row_triggers(
1549        &self,
1550        table: &str,
1551        event: &str,
1552        timing: &str,
1553    ) -> Vec<spg_storage::FunctionDef> {
1554        let cat = self.active_catalog();
1555        cat.triggers()
1556            .iter()
1557            .filter(|t| {
1558                // v7.16.1 — skip disabled triggers (mailrs
1559                // round-9 A.2.b — pg_dump --disable-triggers).
1560                t.enabled
1561                    && t.table == table
1562                    && t.timing.eq_ignore_ascii_case(timing)
1563                    && t.for_each.eq_ignore_ascii_case("row")
1564                    && t.events.iter().any(|e| e.eq_ignore_ascii_case(event))
1565            })
1566            .filter_map(|t| cat.functions().get(&t.function).cloned())
1567            .collect()
1568    }
1569
1570    /// v7.13.0 — UPDATE-side snapshot that pairs each trigger's
1571    /// function with its `UPDATE OF cols` filter (mailrs round-5
1572    /// G7). Empty filter Vec means "fire unconditionally", matching
1573    /// the v7.12 behaviour.
1574    fn snapshot_update_row_triggers(
1575        &self,
1576        table: &str,
1577        timing: &str,
1578    ) -> Vec<(spg_storage::FunctionDef, Vec<String>)> {
1579        let cat = self.active_catalog();
1580        cat.triggers()
1581            .iter()
1582            .filter(|t| {
1583                // v7.16.1 — skip disabled triggers.
1584                t.enabled
1585                    && t.table == table
1586                    && t.timing.eq_ignore_ascii_case(timing)
1587                    && t.for_each.eq_ignore_ascii_case("row")
1588                    && t.events.iter().any(|e| e.eq_ignore_ascii_case("UPDATE"))
1589            })
1590            .filter_map(|t| {
1591                cat.functions()
1592                    .get(&t.function)
1593                    .cloned()
1594                    .map(|fd| (fd, t.update_columns.clone()))
1595            })
1596            .collect()
1597    }
1598
1599    /// v7.12.7 — drain the trigger-emitted embedded SQL queue.
1600    /// Called by the INSERT / UPDATE / DELETE executors after
1601    /// their main row-write loop returns. Each statement runs
1602    /// inside the same cancel scope as the firing DML and bumps
1603    /// the recursion counter; nested embedded SQL beyond
1604    /// [`MAX_TRIGGER_RECURSION`] errors with a clear message so
1605    /// a trigger-graph cycle surfaces as a query failure instead
1606    /// of stack-blowing the engine.
1607    fn execute_deferred_trigger_stmts(
1608        &mut self,
1609        deferred: Vec<triggers::DeferredEmbeddedStmt>,
1610        cancel: CancelToken<'_>,
1611    ) -> Result<(), EngineError> {
1612        for d in deferred {
1613            if self.trigger_recursion_depth >= MAX_TRIGGER_RECURSION {
1614                return Err(EngineError::Storage(StorageError::Corrupt(alloc::format!(
1615                    "trigger embedded SQL recursion depth {} exceeded (trigger function \
1616                     {:?} would push past the {} cap — check for trigger cycles)",
1617                    self.trigger_recursion_depth,
1618                    d.function,
1619                    MAX_TRIGGER_RECURSION,
1620                ))));
1621            }
1622            self.trigger_recursion_depth += 1;
1623            let res = self.execute_stmt_with_cancel(d.stmt, cancel);
1624            self.trigger_recursion_depth -= 1;
1625            res?;
1626        }
1627        Ok(())
1628    }
1629
1630    fn active_catalog_mut(&mut self) -> &mut Catalog {
1631        let tx = self.current_tx;
1632        match tx {
1633            Some(t) => match self.tx_catalogs.get_mut(&t) {
1634                Some(s) => &mut s.catalog,
1635                None => &mut self.catalog,
1636            },
1637            None => &mut self.catalog,
1638        }
1639    }
1640
1641    /// Read-only execute path. Succeeds for `SELECT` / `SHOW TABLES`
1642    /// / `SHOW COLUMNS`; returns `EngineError::WriteRequired` for
1643    /// every other statement, so the caller can fall through to the
1644    /// `&mut self` `execute` path under a write lock. Engine state is
1645    /// not mutated even on the success path (`rewrite_clock_calls`
1646    /// and `resolve_order_by_position` both mutate the locally-owned
1647    /// AST, not `self`).
1648    ///
1649    /// **v4.0 concurrency**: this is the entry point the server takes
1650    /// under an `RwLock::read()` so multiple `SELECT` clients run in
1651    /// parallel without serialising on a single mutex.
1652    pub fn execute_readonly(&self, sql: &str) -> Result<QueryResult, EngineError> {
1653        self.execute_readonly_with_cancel(sql, CancelToken::none())
1654    }
1655
1656    /// v4.5 — read path with cooperative cancellation. Token's
1657    /// `is_cancelled` is checked at the start (so a watchdog that
1658    /// already fired returns Cancelled immediately) and at row-loop
1659    /// checkpoints inside `exec_select`. SHOW paths are O(small) and
1660    /// don't bother checking.
1661    pub fn execute_readonly_with_cancel(
1662        &self,
1663        sql: &str,
1664        cancel: CancelToken<'_>,
1665    ) -> Result<QueryResult, EngineError> {
1666        cancel.check()?;
1667        let mut stmt = parser::parse_statement_with(sql, self.backslash_escapes)?;
1668        let now_micros = self.clock.map(|f| f());
1669        rewrite_clock_calls(&mut stmt, now_micros);
1670        if let Statement::Select(s) = &mut stmt {
1671            resolve_order_by_position(s);
1672            // v6.2.3 — cost-based JOIN reorder (read path).
1673            reorder::reorder_joins(s, &self.catalog, &self.statistics);
1674        }
1675        self.execute_readonly_stmt_with_cancel(stmt, cancel)
1676    }
1677
1678    /// v7.18 — readonly dispatch on a pre-parsed `Statement`.
1679    /// Internal helper shared by the SQL-string path
1680    /// ([`Engine::execute_readonly_with_cancel`]) and the prepared-
1681    /// statement path ([`Engine::execute_readonly_prepared_on_snapshot_with_cancel`]).
1682    /// Statement-level transforms (clock rewrite, ORDER BY position,
1683    /// JOIN reorder, placeholder substitution) are the caller's
1684    /// responsibility — this helper assumes the AST is already
1685    /// execution-ready. Writes / DDL hit
1686    /// [`EngineError::WriteRequired`] the same way the SQL path does.
1687    fn execute_readonly_stmt_with_cancel(
1688        &self,
1689        stmt: Statement,
1690        cancel: CancelToken<'_>,
1691    ) -> Result<QueryResult, EngineError> {
1692        let result = match stmt {
1693            Statement::Select(s) => self.exec_select_cancel(&s, cancel),
1694            Statement::ShowTables => Ok(self.exec_show_tables()),
1695            Statement::ShowDatabases => Ok(self.exec_show_databases()),
1696            Statement::ShowCreateTable(name) => self.exec_show_create_table(&name),
1697            Statement::ShowIndexes(name) => self.exec_show_indexes(&name),
1698            Statement::ShowStatus => Ok(self.exec_show_status()),
1699            Statement::ShowVariables => Ok(self.exec_show_variables()),
1700            Statement::ShowProcesslist => Ok(self.exec_show_processlist()),
1701            Statement::ShowColumns(table) => self.exec_show_columns(&table),
1702            Statement::ShowUsers => Ok(self.exec_show_users()),
1703            Statement::ShowPublications => Ok(self.exec_show_publications()),
1704            Statement::ShowSubscriptions => Ok(self.exec_show_subscriptions()),
1705            Statement::WaitForWalPosition { .. } => Err(EngineError::Unsupported(
1706                "WAIT FOR WAL POSITION must be handled by the server layer".into(),
1707            )),
1708            Statement::Explain(e) => self.exec_explain(&e, cancel),
1709            _ => Err(EngineError::WriteRequired),
1710        };
1711        self.enforce_row_limit(result)
1712    }
1713
1714    /// v4.2: cap result-set size. Applied after the executor
1715    /// materialises rows but before they leave the engine — wrapping
1716    /// every Rows-returning exec_* function would scatter the check.
1717    fn enforce_row_limit(
1718        &self,
1719        result: Result<QueryResult, EngineError>,
1720    ) -> Result<QueryResult, EngineError> {
1721        if let (Ok(QueryResult::Rows { rows, .. }), Some(cap)) = (&result, self.max_query_rows)
1722            && rows.len() > cap
1723        {
1724            return Err(EngineError::RowLimitExceeded(cap));
1725        }
1726        result
1727    }
1728
1729    pub fn execute(&mut self, sql: &str) -> Result<QueryResult, EngineError> {
1730        self.execute_in_with_cancel(sql, IMPLICIT_TX, CancelToken::none())
1731    }
1732
1733    /// v4.5 — write path with cooperative cancellation. Same dispatch
1734    /// as `execute_in_with_cancel(sql, IMPLICIT_TX, cancel)`. Kept as
1735    /// a separate entry point for backward-compat with the v4.5
1736    /// public API.
1737    pub fn execute_with_cancel(
1738        &mut self,
1739        sql: &str,
1740        cancel: CancelToken<'_>,
1741    ) -> Result<QueryResult, EngineError> {
1742        self.execute_in_with_cancel(sql, IMPLICIT_TX, cancel)
1743    }
1744
1745    /// v4.41.1 multi-slot write entry. Routes `sql` through the TX
1746    /// slot identified by `tx_id` so spg-server dispatch can scope
1747    /// each implicit-wrap BEGIN..stmt..COMMIT to its own slot in
1748    /// `tx_catalogs`. `IMPLICIT_TX` is the legacy single-slot path
1749    /// every other caller (engine self-tests, replay, spg-embedded)
1750    /// implicitly takes via `execute()` / `execute_with_cancel()`.
1751    pub fn execute_in(&mut self, sql: &str, tx_id: TxId) -> Result<QueryResult, EngineError> {
1752        self.execute_in_with_cancel(sql, tx_id, CancelToken::none())
1753    }
1754
1755    /// v4.41.1 write path with cooperative cancellation + explicit TX
1756    /// scope. Sets `self.current_tx` for the duration of the call so
1757    /// every `exec_*` helper transparently sees its TX's shadow
1758    /// catalog and savepoint stack; restores on exit so the field is
1759    /// only valid mid-call (no leakage across calls).
1760    pub fn execute_in_with_cancel(
1761        &mut self,
1762        sql: &str,
1763        tx_id: TxId,
1764        cancel: CancelToken<'_>,
1765    ) -> Result<QueryResult, EngineError> {
1766        let saved = self.current_tx;
1767        self.current_tx = Some(tx_id);
1768        let result = self.execute_inner_with_cancel(sql, cancel);
1769        self.current_tx = saved;
1770        result
1771    }
1772
1773    /// v6.1.1 — parse and pre-process a SQL string ONCE so the
1774    /// resulting [`Statement`] can be cached and re-executed via
1775    /// [`Engine::execute_prepared`]. Returns the same `Statement`
1776    /// the simple-query path would synthesise internally (clock
1777    /// rewrites + ORDER BY position-ref resolution applied at
1778    /// prepare time, since both are session-independent). The
1779    /// `$N` placeholders in the SQL stay as `Expr::Placeholder(n)`
1780    /// nodes; they're resolved to concrete values per-call by
1781    /// `execute_prepared`'s substitution walk.
1782    ///
1783    /// Pgwire's `Parse` (P) message lands here.
1784    pub fn prepare(&self, sql: &str) -> Result<Statement, ParseError> {
1785        let mut stmt = parser::parse_statement_with(sql, self.backslash_escapes)?;
1786        let now_micros = self.clock.map(|f| f());
1787        rewrite_clock_calls(&mut stmt, now_micros);
1788        if let Statement::Select(s) = &mut stmt {
1789            // v6.4.1 — expand `GROUP BY ALL` to every non-aggregate
1790            // SELECT-list item BEFORE position / alias resolution so
1791            // downstream passes see the explicit list.
1792            expand_group_by_all(s);
1793            resolve_order_by_position(s);
1794            // v6.2.3 — cost-based JOIN reorder. No-op for
1795            // single-table FROMs or any non-INNER join shape.
1796            reorder::reorder_joins(s, &self.catalog, &self.statistics);
1797        }
1798        Ok(stmt)
1799    }
1800
1801    /// v6.3.0 — cached prepare. Returns a cloned `Statement` from
1802    /// the plan cache on hit, runs the full `prepare()` path on miss
1803    /// and inserts the resulting plan before returning. Skipping the
1804    /// parse + JOIN-reorder pipeline on hit is the dominant win for
1805    /// JDBC / sqlx / pgx clients that reuse the same SQL string.
1806    ///
1807    /// Returns a cloned `Statement` (not a borrow) because the
1808    /// pgwire layer owns its `PreparedStmt` map per-session and the
1809    /// engine-level cache must stay available for other sessions.
1810    /// Clone cost on a 5-table JOIN AST is well under the parse cost
1811    /// it replaces.
1812    pub fn prepare_cached(&mut self, sql: &str) -> Result<Statement, ParseError> {
1813        // v6.3.1 — version-aware lookup. If the cached plan was
1814        // prepared before the most recent ANALYZE, evict and replan.
1815        let current_version = self.statistics.version();
1816        if let Some(plan) = self.plan_cache.get(sql) {
1817            if plan.statistics_version == current_version {
1818                return Ok(plan.stmt.clone());
1819            }
1820            // Stale entry — fall through to evict + re-prepare.
1821        }
1822        self.plan_cache.evict(sql);
1823        let stmt = self.prepare(sql)?;
1824        let source_tables = plan_cache::collect_source_tables(&stmt);
1825        let plan = plan_cache::PreparedPlan {
1826            stmt: stmt.clone(),
1827            statistics_version: current_version,
1828            source_tables,
1829            describe_columns: alloc::vec::Vec::new(),
1830        };
1831        self.plan_cache.insert(String::from(sql), plan);
1832        Ok(stmt)
1833    }
1834
1835    /// v6.3.0 — read-only accessor for tests and v6.3.1 invalidation.
1836    pub fn plan_cache(&self) -> &plan_cache::PlanCache {
1837        &self.plan_cache
1838    }
1839
1840    /// v6.3.0 — mutable accessor for v6.3.1 invalidation hooks.
1841    pub fn plan_cache_mut(&mut self) -> &mut plan_cache::PlanCache {
1842        &mut self.plan_cache
1843    }
1844
1845    /// v6.3.3 — Describe a prepared `Statement` without executing.
1846    /// Returns `(parameter_oids, output_columns)`. Empty
1847    /// `output_columns` means the statement has no row-producing
1848    /// shape we could resolve here (JOIN, subquery, non-SELECT, …)
1849    /// — pgwire layer maps that to a `NoData` reply.
1850    pub fn describe_prepared(&self, stmt: &Statement) -> (Vec<u32>, Vec<ColumnSchema>) {
1851        describe::describe_prepared(stmt, self.active_catalog())
1852    }
1853
1854    /// v6.1.1 — execute a [`Statement`] previously returned by
1855    /// [`Engine::prepare`], substituting `Expr::Placeholder(n)`
1856    /// nodes for the corresponding [`Value`] in `params` (1-based
1857    /// per PG: `$1` → `params[0]`). Bind-time string parameters
1858    /// are decoded into typed `Value`s by the pgwire layer before
1859    /// this call so the resulting AST hits the same execution
1860    /// path as a simple query — no SQL re-parse.
1861    ///
1862    /// Pgwire's `Execute` (E) message after a `Bind` (B) lands here.
1863    pub fn execute_prepared(
1864        &mut self,
1865        stmt: Statement,
1866        params: &[Value],
1867    ) -> Result<QueryResult, EngineError> {
1868        self.execute_prepared_with_cancel(stmt, params, CancelToken::none())
1869    }
1870
1871    /// v7.17.0 Phase 2.3 — prepared-statement entry that honors a
1872    /// caller-supplied `CancelToken`. Mirrors `execute_prepared`'s
1873    /// `current_tx` save/restore so the extended-query path stays
1874    /// transactionally consistent with the simple-query path.
1875    pub fn execute_prepared_with_cancel(
1876        &mut self,
1877        mut stmt: Statement,
1878        params: &[Value],
1879        cancel: CancelToken<'_>,
1880    ) -> Result<QueryResult, EngineError> {
1881        substitute_placeholders(&mut stmt, params)?;
1882        // v7.16.0 — set `current_tx` for the duration of the
1883        // dispatch so the `exec_*` helpers see the right TX
1884        // slot (matches what `execute_in_with_cancel` does for
1885        // simple-query). Pre-v7.16 the simple-query path
1886        // worked because every public entry point routed
1887        // through `execute_in_with_cancel`; the prepared path
1888        // skipped the wrap and so its INSERTs/UPDATEs landed
1889        // in the no-tx default slot, silently invisible to a
1890        // BEGIN/COMMIT-bracketed flow. Caught by spg-sqlx's
1891        // first transaction-visibility test.
1892        let saved = self.current_tx;
1893        self.current_tx = Some(IMPLICIT_TX);
1894        let result = self.execute_stmt_with_cancel(stmt, cancel);
1895        self.current_tx = saved;
1896        result
1897    }
1898
1899    fn execute_inner_with_cancel(
1900        &mut self,
1901        sql: &str,
1902        cancel: CancelToken<'_>,
1903    ) -> Result<QueryResult, EngineError> {
1904        cancel.check()?;
1905        let stmt = self.prepare(sql)?;
1906        // v6.5.1 — wrap the executor with a wall-clock window so we
1907        // can record into spg_stat_query. Skip when the engine has
1908        // no clock attached (no_std embedded callers).
1909        let start_us = self.clock.map(|f| f());
1910        let result = self.execute_stmt_with_cancel(stmt, cancel);
1911        if let (Some(t0), Ok(_)) = (start_us, &result) {
1912            let now = self.clock.map_or(t0, |f| f());
1913            let elapsed = now.saturating_sub(t0).max(0) as u64;
1914            self.query_stats.record(sql, elapsed, now as u64);
1915            // v6.5.6 — slow-query log: fire callback when elapsed
1916            // exceeds the configured floor.
1917            if let (Some(threshold), Some(logger)) =
1918                (self.slow_query_threshold_us, self.slow_query_logger)
1919                && elapsed >= threshold
1920            {
1921                logger(sql, elapsed);
1922            }
1923        }
1924        result
1925    }
1926
1927    fn execute_stmt_with_cancel(
1928        &mut self,
1929        stmt: Statement,
1930        cancel: CancelToken<'_>,
1931    ) -> Result<QueryResult, EngineError> {
1932        cancel.check()?;
1933        // v7.17.0 Phase 1.1 — pre-resolve nextval / currval /
1934        // setval calls in the statement tree. Walks SELECT
1935        // projection, INSERT VALUES, UPDATE SET, DELETE WHERE,
1936        // and DEFAULT exprs; replaces sequence FunctionCall
1937        // nodes with concrete Literal values minted against the
1938        // catalog. This is the only place that mutates sequence
1939        // state from a SELECT-shaped path (exec_select_cancel is
1940        // `&self` and can't reach the catalog mutably).
1941        //
1942        // Fast-path: when no sequences exist anywhere in the
1943        // catalog (the typical hot-path INSERT load), skip the
1944        // walker entirely. Single map-emptiness check on the
1945        // catalog beats walking every expression on every call.
1946        let mut stmt = stmt;
1947        // v7.17 dump-compat — the fast-path check
1948        // `sequences().is_empty()` skips pre-resolve when no
1949        // sequence exists in the *currently active* catalog
1950        // snapshot. The committed catalog or the implicit-TX
1951        // catalog may legitimately disagree on this between
1952        // CREATE SEQUENCE and a later setval(): always run the
1953        // resolver — the walk is O(expr-count) and dwarfed by
1954        // the parse cost we just paid.
1955        self.pre_resolve_sequence_calls_in_statement(&mut stmt)?;
1956        let result = match stmt {
1957            Statement::CreateTable(s) => self.exec_create_table(s),
1958            // v7.9.15 — CREATE EXTENSION is a no-op on SPG. Returns
1959            // CommandOk with affected=0; modified_catalog=false so
1960            // the WAL doesn't grow a useless entry. mailrs F3.
1961            Statement::CreateExtension(_) => Ok(QueryResult::CommandOk {
1962                affected: 0,
1963                modified_catalog: false,
1964            }),
1965            // v7.16.2 — DO $$ ... $$ block. mailrs round-10 A.2
1966            // — the pre-v7.9.27 no-op SILENTLY swallowed every
1967            // mailrs migrate-038/-040/-042 idempotent rename
1968            // (the IF EXISTS … THEN ALTER … END block never
1969            // ran). v7.16.2 dispatches to exec_do_block which
1970            // runs the PlPgSqlBlock at top level via the same
1971            // execute_stmts machinery the trigger executor
1972            // uses (NEW=None, OLD=None — DO blocks have no
1973            // row context).
1974            Statement::DoBlock(body) => self.exec_do_block(body),
1975            // v7.14.0 — empty-statement no-op for pg_dump /
1976            // mysqldump preamble lines that collapse to nothing
1977            // after comment-stripping.
1978            Statement::Empty => Ok(QueryResult::CommandOk {
1979                affected: 0,
1980                modified_catalog: false,
1981            }),
1982            Statement::DropTable { names, if_exists } => self.exec_drop_table(names, if_exists),
1983            Statement::DropIndex { name, if_exists } => self.exec_drop_index(name, if_exists),
1984            Statement::CreateIndex(s) => self.exec_create_index(s),
1985            Statement::Insert(s) => self.exec_insert(s),
1986            Statement::Update(mut s) => {
1987                // Materialise uncorrelated subqueries in SET / WHERE
1988                // before the row walk — the SELECT path has done this
1989                // since v4.10; UPDATE gained it for mailrs's
1990                // `UPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP
1991                // LOCKED)` claim pattern (embed round-12).
1992                for (_, e) in &mut s.assignments {
1993                    self.resolve_expr_subqueries(e, cancel)?;
1994                }
1995                if let Some(w) = &mut s.where_ {
1996                    self.resolve_expr_subqueries(w, cancel)?;
1997                }
1998                self.exec_update_cancel(&s, cancel)
1999            }
2000            Statement::Delete(mut s) => {
2001                if let Some(w) = &mut s.where_ {
2002                    self.resolve_expr_subqueries(w, cancel)?;
2003                }
2004                self.exec_delete_cancel(&s, cancel)
2005            }
2006            Statement::Merge(s) => self.exec_merge_cancel(&s, cancel),
2007            Statement::Select(s) => self.exec_select_cancel(&s, cancel),
2008            Statement::Begin => self.exec_begin(),
2009            Statement::Commit => self.exec_commit(),
2010            Statement::Rollback => self.exec_rollback(),
2011            Statement::Savepoint(name) => self.exec_savepoint(name),
2012            Statement::RollbackToSavepoint(name) => self.exec_rollback_to_savepoint(&name),
2013            Statement::ReleaseSavepoint(name) => self.exec_release_savepoint(&name),
2014            Statement::ShowTables => Ok(self.exec_show_tables()),
2015            Statement::ShowDatabases => Ok(self.exec_show_databases()),
2016            Statement::ShowCreateTable(name) => self.exec_show_create_table(&name),
2017            Statement::ShowIndexes(name) => self.exec_show_indexes(&name),
2018            Statement::ShowStatus => Ok(self.exec_show_status()),
2019            Statement::ShowVariables => Ok(self.exec_show_variables()),
2020            Statement::ShowProcesslist => Ok(self.exec_show_processlist()),
2021            Statement::ShowColumns(table) => self.exec_show_columns(&table),
2022            Statement::ShowUsers => Ok(self.exec_show_users()),
2023            Statement::ShowPublications => Ok(self.exec_show_publications()),
2024            Statement::ShowSubscriptions => Ok(self.exec_show_subscriptions()),
2025            Statement::CreateUser(s) => self.exec_create_user(&s),
2026            Statement::DropUser(name) => self.exec_drop_user(&name),
2027            Statement::Explain(e) => self.exec_explain(&e, cancel),
2028            Statement::AlterIndex(s) => self.exec_alter_index(s),
2029            Statement::AlterTable(s) => self.exec_alter_table(s),
2030            Statement::CreatePublication(s) => self.exec_create_publication(s),
2031            Statement::DropPublication(name) => self.exec_drop_publication(&name),
2032            Statement::CreateSubscription(s) => self.exec_create_subscription(s),
2033            Statement::DropSubscription(name) => self.exec_drop_subscription(&name),
2034            // v6.1.7 — WAIT FOR WAL POSITION needs `lag_state`,
2035            // which lives in spg-server's ServerState. The engine
2036            // surfaces a clear error; the server-layer dispatch
2037            // intercepts the SQL before it reaches the engine on
2038            // a server build, so this arm only fires for
2039            // engine-only callers (spg-embedded, lib tests).
2040            Statement::WaitForWalPosition { .. } => Err(EngineError::Unsupported(
2041                "WAIT FOR WAL POSITION must be handled by the server layer".into(),
2042            )),
2043            // v6.2.0 — ANALYZE recomputes per-column histograms.
2044            Statement::Analyze(target) => self.exec_analyze(target.as_deref()),
2045            // v6.7.3 — COMPACT COLD SEGMENTS.
2046            Statement::CompactColdSegments => self.exec_compact_cold_segments(),
2047            // v7.12.1 — SET / RESET session parameter. Engine
2048            // tracks the value in `session_params`; FTS dispatcher
2049            // reads `default_text_search_config`. Everything else
2050            // is a recorded no-op (PG dump compat).
2051            Statement::SetParameter { name, value } => {
2052                self.set_session_param(name, value);
2053                Ok(QueryResult::CommandOk {
2054                    affected: 0,
2055                    modified_catalog: false,
2056                })
2057            }
2058            // v7.14.0 — MySQL multi-assignment SET. Each pair runs
2059            // through `set_session_param` so engine-known params
2060            // (FOREIGN_KEY_CHECKS, session_replication_role, …) take
2061            // effect; unknown pairs (including `@VAR` LHS from the
2062            // mysqldump preamble) are recorded then ignored.
2063            Statement::SetParameterList(pairs) => {
2064                for (name, value) in pairs {
2065                    self.set_session_param(name, value);
2066                }
2067                Ok(QueryResult::CommandOk {
2068                    affected: 0,
2069                    modified_catalog: false,
2070                })
2071            }
2072            // v7.12.4 — CREATE FUNCTION / CREATE TRIGGER / DROP …
2073            // for the PL/pgSQL trigger surface. exec_* methods are
2074            // defined alongside the existing CREATE handlers below.
2075            Statement::CreateFunction(s) => self.exec_create_function(s),
2076            Statement::CreateTrigger(s) => self.exec_create_trigger(s),
2077            Statement::DropTrigger {
2078                name,
2079                table,
2080                if_exists,
2081            } => self.exec_drop_trigger(&name, &table, if_exists),
2082            Statement::DropFunction { name, if_exists } => {
2083                self.exec_drop_function(&name, if_exists)
2084            }
2085            Statement::CreateSequence(s) => self.exec_create_sequence(s),
2086            Statement::AlterSequence(s) => self.exec_alter_sequence(s),
2087            Statement::DropSequence { names, if_exists } => {
2088                self.exec_drop_sequence(&names, if_exists)
2089            }
2090            Statement::CreateView(s) => self.exec_create_view(s),
2091            Statement::DropView { names, if_exists } => self.exec_drop_view(&names, if_exists),
2092            Statement::CreateMaterializedView(s) => self.exec_create_materialized_view(s),
2093            Statement::RefreshMaterializedView { name, with_data } => {
2094                self.exec_refresh_materialized_view(&name, with_data)
2095            }
2096            Statement::DropMaterializedView { names, if_exists } => {
2097                self.exec_drop_materialized_view(&names, if_exists)
2098            }
2099            Statement::CreateType(s) => self.exec_create_type(s),
2100            Statement::DropType { names, if_exists } => self.exec_drop_type(&names, if_exists),
2101            Statement::CreateDomain(s) => self.exec_create_domain(s),
2102            Statement::DropDomain { names, if_exists } => self.exec_drop_domain(&names, if_exists),
2103            Statement::CreateSchema {
2104                name,
2105                if_not_exists,
2106            } => self.exec_create_schema(name, if_not_exists),
2107            Statement::DropSchema { names, if_exists } => self.exec_drop_schema(&names, if_exists),
2108            Statement::ResetParameter(target) => {
2109                match target {
2110                    None => self.session_params.clear(),
2111                    Some(name) => {
2112                        self.session_params.remove(&name.to_ascii_lowercase());
2113                    }
2114                }
2115                Ok(QueryResult::CommandOk {
2116                    affected: 0,
2117                    modified_catalog: false,
2118                })
2119            }
2120        };
2121        self.enforce_row_limit(result)
2122    }
2123
2124    /// v6.1.2 — `CREATE PUBLICATION` runtime path. Duplicate names
2125    /// surface as `EngineError::Unsupported` so the existing PG-wire
2126    /// error mapping stays uniform; the message carries the name so
2127    /// operators can grep replication-log noise. Inside-transaction
2128    /// invocation is rejected (matches `CREATE USER` / `DROP USER`
2129    /// stance) — replication-catalog mutation is a connection-level
2130    /// administrative op, not a transactional one.
2131    fn exec_create_publication(
2132        &mut self,
2133        s: CreatePublicationStatement,
2134    ) -> Result<QueryResult, EngineError> {
2135        // v6.1.4 — the v6.1.2 "no DDL inside a transaction" guard
2136        // was over-cautious: it also blocked the auto-commit wrap
2137        // path (which begins an internal TX around every WAL-
2138        // logged statement). PG itself allows CREATE PUBLICATION
2139        // inside a transaction (it rolls back with the TX).
2140        self.publications
2141            .create(s.name, s.scope)
2142            .map_err(|e| EngineError::Unsupported(alloc::format!("CREATE PUBLICATION: {e:?}")))?;
2143        Ok(QueryResult::CommandOk {
2144            affected: 1,
2145            modified_catalog: true,
2146        })
2147    }
2148
2149    /// v6.1.2 — `DROP PUBLICATION` runtime path. PG-compatible silent
2150    /// no-op when the publication doesn't exist (returns `affected=0`
2151    /// in that case so the wire-level command tag distinguishes
2152    /// "dropped" from "no-op", though both succeed).
2153    fn exec_drop_publication(&mut self, name: &str) -> Result<QueryResult, EngineError> {
2154        let removed = self.publications.drop(name);
2155        Ok(QueryResult::CommandOk {
2156            affected: usize::from(removed),
2157            modified_catalog: removed,
2158        })
2159    }
2160
2161    /// v6.1.2 — read access to the publication catalog. Used by
2162    /// the v6.1.5 publisher-side WAL filter, by `SHOW PUBLICATIONS`
2163    /// (v6.1.3+), and by e2e tests that need to assert state without
2164    /// going through the wire.
2165    pub const fn publications(&self) -> &publications::Publications {
2166        &self.publications
2167    }
2168
2169    /// v6.1.4 — `CREATE SUBSCRIPTION` runtime path. Defaults
2170    /// `enabled = true` and `last_received_pos = 0` for a freshly-
2171    /// created subscription. The actual worker thread is spawned
2172    /// by spg-server once the engine returns success.
2173    fn exec_create_subscription(
2174        &mut self,
2175        s: CreateSubscriptionStatement,
2176    ) -> Result<QueryResult, EngineError> {
2177        // See exec_create_publication — the in_transaction gate
2178        // was over-cautious; the auto-commit wrap path holds an
2179        // internal TX that this check was incorrectly blocking.
2180        let sub = subscriptions::Subscription {
2181            conn_str: s.conn_str,
2182            publications: s.publications,
2183            enabled: true,
2184            last_received_pos: 0,
2185        };
2186        self.subscriptions
2187            .create(s.name, sub)
2188            .map_err(|e| EngineError::Unsupported(alloc::format!("CREATE SUBSCRIPTION: {e:?}")))?;
2189        Ok(QueryResult::CommandOk {
2190            affected: 1,
2191            modified_catalog: true,
2192        })
2193    }
2194
2195    /// v6.1.4 — `DROP SUBSCRIPTION`. Silent no-op when the name
2196    /// doesn't exist (PG-compatible). The associated worker is
2197    /// torn down by spg-server when it observes the catalog
2198    /// change at the next snapshot or via the engine's
2199    /// subscriptions accessor (the worker polls the catalog on
2200    /// reconnect; v6.1.5's filter-side will tighten this to an
2201    /// explicit signal).
2202    fn exec_drop_subscription(&mut self, name: &str) -> Result<QueryResult, EngineError> {
2203        let removed = self.subscriptions.drop(name);
2204        Ok(QueryResult::CommandOk {
2205            affected: usize::from(removed),
2206            modified_catalog: removed,
2207        })
2208    }
2209
2210    /// v6.1.4 — read access to the subscription catalog. Used by
2211    /// the subscription worker (read its own row to find its
2212    /// publications + last applied position), by SHOW SUBSCRIPTIONS,
2213    /// and by e2e tests asserting state directly.
2214    pub const fn subscriptions(&self) -> &subscriptions::Subscriptions {
2215        &self.subscriptions
2216    }
2217
2218    /// v6.1.4 — write access to `last_received_pos`. Worker
2219    /// calls this after each apply batch (under the engine's
2220    /// write-lock). Returns `false` when the subscription was
2221    /// dropped between when the worker received the record and
2222    /// when this call landed.
2223    pub fn subscription_advance(&mut self, name: &str, pos: u64) -> bool {
2224        self.subscriptions.update_last_received_pos(name, pos)
2225    }
2226
2227    /// v6.1.4 — `SHOW SUBSCRIPTIONS` row materialisation. Returns
2228    /// `(name, conn_str, publications, enabled, last_received_pos)`
2229    /// ordered by subscription name. The `publications` column is
2230    /// the comma-joined list ("p1, p2") for ergonomic SHOW output;
2231    /// callers wanting structured access read `Engine::subscriptions`.
2232    fn exec_show_subscriptions(&self) -> QueryResult {
2233        let columns = alloc::vec![
2234            ColumnSchema::new("name", DataType::Text, false),
2235            ColumnSchema::new("conn_str", DataType::Text, false),
2236            ColumnSchema::new("publications", DataType::Text, false),
2237            ColumnSchema::new("enabled", DataType::Bool, false),
2238            ColumnSchema::new("last_received_pos", DataType::BigInt, false),
2239        ];
2240        let rows: Vec<Row> = self
2241            .subscriptions
2242            .iter()
2243            .map(|(name, sub)| {
2244                Row::new(alloc::vec![
2245                    Value::Text(name.clone()),
2246                    Value::Text(sub.conn_str.clone()),
2247                    Value::Text(sub.publications.join(", ")),
2248                    Value::Bool(sub.enabled),
2249                    Value::BigInt(i64::try_from(sub.last_received_pos).unwrap_or(i64::MAX)),
2250                ])
2251            })
2252            .collect();
2253        QueryResult::Rows { columns, rows }
2254    }
2255
2256    /// v6.2.0 — materialise `spg_statistic` rows. One row per
2257    /// `(table, column)` pair tracked in `Statistics`, with
2258    /// `histogram_bounds` rendered as a `[v0, v1, ...]` string —
2259    /// the same canonical form vector literals use for round-trip.
2260    fn exec_spg_statistic(&self) -> QueryResult {
2261        let columns = alloc::vec![
2262            ColumnSchema::new("table_name", DataType::Text, false),
2263            ColumnSchema::new("column_name", DataType::Text, false),
2264            ColumnSchema::new("null_frac", DataType::Float, false),
2265            ColumnSchema::new("n_distinct", DataType::BigInt, false),
2266            ColumnSchema::new("histogram_bounds", DataType::Text, false),
2267            // v6.7.0 — appended column (v6.2.0 stability contract
2268            // allows APPEND to spg_statistic, not reorder/rename).
2269            // Reports the cached per-table cold-row count; same
2270            // value across every column row of the same table.
2271            ColumnSchema::new("cold_row_count", DataType::BigInt, false),
2272        ];
2273        let rows: Vec<Row> = self
2274            .statistics
2275            .iter()
2276            .map(|((t, c), s)| {
2277                let cold = self
2278                    .catalog
2279                    .get(t)
2280                    .map_or(0, |table| table.cold_row_count());
2281                Row::new(alloc::vec![
2282                    Value::Text(t.clone()),
2283                    Value::Text(c.clone()),
2284                    Value::Float(f64::from(s.null_frac)),
2285                    Value::BigInt(i64::try_from(s.n_distinct).unwrap_or(i64::MAX)),
2286                    Value::Text(render_histogram_bounds(&s.histogram_bounds)),
2287                    Value::BigInt(i64::try_from(cold).unwrap_or(i64::MAX)),
2288                ])
2289            })
2290            .collect();
2291        QueryResult::Rows { columns, rows }
2292    }
2293
2294    /// v6.5.0 — materialise `spg_stat_replication` rows. One row
2295    /// per subscription with `(name, conn_str, publications,
2296    /// last_received_pos, enabled)`. Surface mirrors
2297    /// `SHOW SUBSCRIPTIONS` but follows the virtual-table dispatch
2298    /// shape so it composes with SELECT clauses (WHERE, projection
2299    /// onto specific columns, etc).
2300    fn exec_spg_stat_replication(&self) -> QueryResult {
2301        let columns = alloc::vec![
2302            ColumnSchema::new("name", DataType::Text, false),
2303            ColumnSchema::new("conn_str", DataType::Text, false),
2304            ColumnSchema::new("publications", DataType::Text, false),
2305            ColumnSchema::new("last_received_pos", DataType::BigInt, false),
2306            ColumnSchema::new("enabled", DataType::Bool, false),
2307        ];
2308        let rows: Vec<Row> = self
2309            .subscriptions
2310            .iter()
2311            .map(|(name, sub)| {
2312                Row::new(alloc::vec![
2313                    Value::Text(name.clone()),
2314                    Value::Text(sub.conn_str.clone()),
2315                    Value::Text(sub.publications.join(",")),
2316                    Value::BigInt(i64::try_from(sub.last_received_pos).unwrap_or(i64::MAX)),
2317                    Value::Bool(sub.enabled),
2318                ])
2319            })
2320            .collect();
2321        QueryResult::Rows { columns, rows }
2322    }
2323
2324    /// v6.5.0 — materialise `spg_stat_segment` rows. One row per
2325    /// cold-tier segment with `(segment_id, num_rows, num_pages,
2326    /// total_bytes)`.
2327    ///
2328    /// v6.7.0 — appended `table_name` column resolves the v6.5.0
2329    /// carve-out. Walks every user table's BTree indices to find
2330    /// which table's Cold locators point at each segment. Empty
2331    /// string for orphan segments (loaded via SPG_PRELOAD_COLD_SEGMENT
2332    /// before any index registered a locator). The walk is
2333    /// O(tables × indices × keys); cached per call, not across
2334    /// calls — re-walked on every `SELECT * FROM spg_stat_segment`.
2335    fn exec_spg_stat_segment(&self) -> QueryResult {
2336        let columns = alloc::vec![
2337            ColumnSchema::new("segment_id", DataType::BigInt, false),
2338            ColumnSchema::new("table_name", DataType::Text, false),
2339            ColumnSchema::new("num_rows", DataType::BigInt, false),
2340            ColumnSchema::new("num_pages", DataType::BigInt, false),
2341            ColumnSchema::new("total_bytes", DataType::BigInt, false),
2342        ];
2343        // v6.7.0 — build a segment_id → table_name map by walking
2344        // every user table's BTree indices once. O(tables × indices
2345        // × keys) for the v6.5.0 carve-out resolution; acceptable
2346        // because spg_stat_segment is operator-facing (not on a
2347        // hot-loop path).
2348        let mut segment_owners: alloc::collections::BTreeMap<u32, String> = BTreeMap::new();
2349        for tname in self.catalog.table_names() {
2350            if is_internal_table_name(&tname) {
2351                continue;
2352            }
2353            let Some(t) = self.catalog.get(&tname) else {
2354                continue;
2355            };
2356            for idx in t.indices() {
2357                if let spg_storage::IndexKind::BTree(map) = &idx.kind {
2358                    for (_, locs) in map.iter() {
2359                        for loc in locs {
2360                            if let spg_storage::RowLocator::Cold { segment_id, .. } = loc {
2361                                segment_owners
2362                                    .entry(*segment_id)
2363                                    .or_insert_with(|| tname.clone());
2364                            }
2365                        }
2366                    }
2367                }
2368            }
2369        }
2370        let rows: Vec<Row> = self
2371            .catalog
2372            .cold_segment_ids_global()
2373            .iter()
2374            .filter_map(|&id| {
2375                let seg = self.catalog.cold_segment(id)?;
2376                let meta = seg.meta();
2377                let owner = segment_owners.get(&id).cloned().unwrap_or_default();
2378                Some(Row::new(alloc::vec![
2379                    Value::BigInt(i64::from(id)),
2380                    Value::Text(owner),
2381                    Value::BigInt(i64::try_from(meta.num_rows).unwrap_or(i64::MAX)),
2382                    Value::BigInt(i64::from(meta.num_pages)),
2383                    Value::BigInt(i64::try_from(meta.total_bytes).unwrap_or(i64::MAX)),
2384                ]))
2385            })
2386            .collect();
2387        QueryResult::Rows { columns, rows }
2388    }
2389
2390    /// v6.5.1 — materialise `spg_stat_query` rows. One row per
2391    /// distinct SQL text recorded since the engine booted, capped
2392    /// at `QUERY_STATS_MAX` (1024). Columns:
2393    ///   sql, exec_count, total_us, mean_us, max_us, last_seen_us
2394    /// mean_us = total_us / exec_count (saturating).
2395    fn exec_spg_stat_query(&self) -> QueryResult {
2396        let columns = alloc::vec![
2397            ColumnSchema::new("sql", DataType::Text, false),
2398            ColumnSchema::new("exec_count", DataType::BigInt, false),
2399            ColumnSchema::new("total_us", DataType::BigInt, false),
2400            ColumnSchema::new("mean_us", DataType::BigInt, false),
2401            ColumnSchema::new("max_us", DataType::BigInt, false),
2402            ColumnSchema::new("last_seen_us", DataType::BigInt, false),
2403        ];
2404        let rows: Vec<Row> = self
2405            .query_stats
2406            .snapshot()
2407            .into_iter()
2408            .map(|(sql, s)| {
2409                let mean = if s.exec_count == 0 {
2410                    0
2411                } else {
2412                    s.total_us / s.exec_count
2413                };
2414                Row::new(alloc::vec![
2415                    Value::Text(sql),
2416                    Value::BigInt(i64::try_from(s.exec_count).unwrap_or(i64::MAX)),
2417                    Value::BigInt(i64::try_from(s.total_us).unwrap_or(i64::MAX)),
2418                    Value::BigInt(i64::try_from(mean).unwrap_or(i64::MAX)),
2419                    Value::BigInt(i64::try_from(s.max_us).unwrap_or(i64::MAX)),
2420                    Value::BigInt(i64::try_from(s.last_seen_us).unwrap_or(i64::MAX)),
2421                ])
2422            })
2423            .collect();
2424        QueryResult::Rows { columns, rows }
2425    }
2426
2427    /// v6.5.2 — register a connection-state provider. spg-server
2428    /// calls this at startup with a function that snapshots its
2429    /// per-pgwire-connection registry. Engine reads through the
2430    /// callback on `SELECT * FROM spg_stat_activity`.
2431    #[must_use]
2432    pub const fn with_activity_provider(mut self, f: ActivityProvider) -> Self {
2433        self.activity_provider = Some(f);
2434        self
2435    }
2436
2437    /// v6.5.3 — register audit chain provider + verifier.
2438    #[must_use]
2439    pub const fn with_audit_providers(
2440        mut self,
2441        chain: AuditChainProvider,
2442        verify: AuditVerifier,
2443    ) -> Self {
2444        self.audit_chain_provider = Some(chain);
2445        self.audit_verifier = Some(verify);
2446        self
2447    }
2448
2449    /// v6.5.6 — register a slow-query log callback. `threshold_us`
2450    /// is the floor (in microseconds); only executes above the floor
2451    /// fire the callback. spg-server wires this from
2452    /// `SPG_SLOW_QUERY_THRESHOLD_MS` (default 100 ms).
2453    #[must_use]
2454    pub const fn with_slow_query_log(mut self, threshold_us: u64, logger: SlowQueryLogger) -> Self {
2455        self.slow_query_threshold_us = Some(threshold_us);
2456        self.slow_query_logger = Some(logger);
2457        self
2458    }
2459
2460    /// v6.5.6 — operator knob for plan cache cap. spg-server reads
2461    /// `SPG_PLAN_CACHE_MAX` env at startup; uses this to override
2462    /// the compile-time default of 256.
2463    pub fn set_plan_cache_max(&mut self, n: usize) {
2464        self.plan_cache.set_max_entries(n);
2465    }
2466
2467    /// v6.5.2 — materialise `spg_stat_activity` rows. Pulls a fresh
2468    /// snapshot from the registered `ActivityProvider`. Returns an
2469    /// empty result set when no provider is registered (the no_std
2470    /// embedded path with no pgwire layer).
2471    fn exec_spg_stat_activity(&self) -> QueryResult {
2472        let columns = alloc::vec![
2473            ColumnSchema::new("pid", DataType::Int, false),
2474            ColumnSchema::new("user", DataType::Text, false),
2475            ColumnSchema::new("started_at_us", DataType::BigInt, false),
2476            ColumnSchema::new("current_sql", DataType::Text, false),
2477            ColumnSchema::new("wait_event", DataType::Text, false),
2478            ColumnSchema::new("elapsed_us", DataType::BigInt, false),
2479            ColumnSchema::new("in_transaction", DataType::Bool, false),
2480            ColumnSchema::new("application_name", DataType::Text, false),
2481        ];
2482        let rows: Vec<Row> = self
2483            .activity_provider
2484            .map(|f| f())
2485            .unwrap_or_default()
2486            .into_iter()
2487            .map(|r| {
2488                Row::new(alloc::vec![
2489                    Value::Int(i32::try_from(r.pid).unwrap_or(i32::MAX)),
2490                    Value::Text(r.user),
2491                    Value::BigInt(r.started_at_us),
2492                    Value::Text(r.current_sql),
2493                    Value::Text(r.wait_event),
2494                    Value::BigInt(r.elapsed_us),
2495                    Value::Bool(r.in_transaction),
2496                    Value::Text(r.application_name),
2497                ])
2498            })
2499            .collect();
2500        QueryResult::Rows { columns, rows }
2501    }
2502
2503    /// v6.5.4 — materialise `spg_table_ddl` rows. One row per user
2504    /// table with `(table_name, ddl)`. Reconstructed from catalog
2505    /// state on demand.
2506    fn exec_spg_table_ddl(&self) -> QueryResult {
2507        let columns = alloc::vec![
2508            ColumnSchema::new("table_name", DataType::Text, false),
2509            ColumnSchema::new("ddl", DataType::Text, false),
2510        ];
2511        let rows: Vec<Row> = self
2512            .catalog
2513            .table_names()
2514            .into_iter()
2515            .filter(|n| !is_internal_table_name(n))
2516            .filter_map(|name| {
2517                let table = self.catalog.get(&name)?;
2518                let ddl = render_create_table(&name, &table.schema().columns);
2519                Some(Row::new(alloc::vec![Value::Text(name), Value::Text(ddl),]))
2520            })
2521            .collect();
2522        QueryResult::Rows { columns, rows }
2523    }
2524
2525    /// v6.5.4 — materialise `spg_role_ddl` rows. One row per user
2526    /// with `(role_name, ddl)`. Password is redacted (matches the
2527    /// `Statement::CreateUser` Display which prints `'<redacted>'`).
2528    fn exec_spg_role_ddl(&self) -> QueryResult {
2529        let columns = alloc::vec![
2530            ColumnSchema::new("role_name", DataType::Text, false),
2531            ColumnSchema::new("ddl", DataType::Text, false),
2532        ];
2533        let rows: Vec<Row> = self
2534            .users
2535            .iter()
2536            .map(|(name, rec)| {
2537                let ddl = alloc::format!(
2538                    "CREATE USER {name} WITH PASSWORD '<redacted>' ROLE '{}'",
2539                    rec.role.as_str(),
2540                );
2541                Row::new(alloc::vec![
2542                    Value::Text(String::from(name)),
2543                    Value::Text(ddl)
2544                ])
2545            })
2546            .collect();
2547        QueryResult::Rows { columns, rows }
2548    }
2549
2550    /// v6.5.4 — materialise `spg_database_ddl`: single row whose
2551    /// `ddl` column concatenates every user table's CREATE +
2552    /// every role's CREATE in deterministic catalog order. Suitable
2553    /// for piping back through `Engine::execute` to recreate a
2554    /// schema-equivalent database.
2555    fn exec_spg_database_ddl(&self) -> QueryResult {
2556        let columns = alloc::vec![ColumnSchema::new("ddl", DataType::Text, false)];
2557        let mut out = String::new();
2558        for (name, rec) in self.users.iter() {
2559            out.push_str(&alloc::format!(
2560                "CREATE USER {name} WITH PASSWORD '<redacted>' ROLE '{}';\n",
2561                rec.role.as_str(),
2562            ));
2563        }
2564        for name in self.catalog.table_names() {
2565            if is_internal_table_name(&name) {
2566                continue;
2567            }
2568            if let Some(table) = self.catalog.get(&name) {
2569                out.push_str(&render_create_table(&name, &table.schema().columns));
2570                out.push_str(";\n");
2571            }
2572        }
2573        QueryResult::Rows {
2574            columns,
2575            rows: alloc::vec![Row::new(alloc::vec![Value::Text(out)])],
2576        }
2577    }
2578
2579    /// v6.5.3 — materialise `spg_audit_chain` rows. Pulls a fresh
2580    /// snapshot from the registered provider; empty when no
2581    /// provider is set.
2582    fn exec_spg_audit_chain(&self) -> QueryResult {
2583        let columns = alloc::vec![
2584            ColumnSchema::new("seq", DataType::BigInt, false),
2585            ColumnSchema::new("ts_ms", DataType::BigInt, false),
2586            ColumnSchema::new("prev_hash", DataType::Text, false),
2587            ColumnSchema::new("entry_hash", DataType::Text, false),
2588            ColumnSchema::new("sql", DataType::Text, false),
2589        ];
2590        let rows: Vec<Row> = self
2591            .audit_chain_provider
2592            .map(|f| f())
2593            .unwrap_or_default()
2594            .into_iter()
2595            .map(|r| {
2596                Row::new(alloc::vec![
2597                    Value::BigInt(r.seq),
2598                    Value::BigInt(r.ts_ms),
2599                    Value::Text(r.prev_hash_hex),
2600                    Value::Text(r.entry_hash_hex),
2601                    Value::Text(r.sql),
2602                ])
2603            })
2604            .collect();
2605        QueryResult::Rows { columns, rows }
2606    }
2607
2608    /// v6.5.3 — materialise `spg_audit_verify` single-row result.
2609    /// `(verified_count, broken_at_seq)` — broken_at_seq is `-1`
2610    /// on a clean chain. Returns one row with both values 0 when
2611    /// no verifier is registered (no-data fallback for embedded
2612    /// callers).
2613    fn exec_spg_audit_verify(&self) -> QueryResult {
2614        let columns = alloc::vec![
2615            ColumnSchema::new("verified_count", DataType::BigInt, false),
2616            ColumnSchema::new("broken_at_seq", DataType::BigInt, false),
2617        ];
2618        let (verified, broken) = self.audit_verifier.map(|f| f()).unwrap_or((0, -1));
2619        let row = Row::new(alloc::vec![Value::BigInt(verified), Value::BigInt(broken),]);
2620        QueryResult::Rows {
2621            columns,
2622            rows: alloc::vec![row],
2623        }
2624    }
2625
2626    /// v6.5.1 — read-only accessor for tests + v6.5.6 ops resets.
2627    pub fn query_stats(&self) -> &query_stats::QueryStats {
2628        &self.query_stats
2629    }
2630
2631    /// v6.5.1 — mutable accessor (clear, etc).
2632    pub fn query_stats_mut(&mut self) -> &mut query_stats::QueryStats {
2633        &mut self.query_stats
2634    }
2635
2636    /// v6.2.0 — read access to the per-column statistics table.
2637    /// Used by the planner (v6.2.2 selectivity functions read this),
2638    /// by `SELECT * FROM spg_statistic`, and by e2e tests.
2639    pub const fn statistics(&self) -> &statistics::Statistics {
2640        &self.statistics
2641    }
2642
2643    /// v6.2.1 — return tables whose modified-row count crossed the
2644    /// auto-analyze threshold since the last ANALYZE on that table.
2645    /// The threshold is `0.1 × max(row_count, MIN_ROWS_FOR_AUTO_
2646    /// ANALYZE)` — combines PG-style fractional + absolute lower
2647    /// bound so a fresh / tiny table doesn't get hammered on every
2648    /// INSERT.
2649    ///
2650    /// Designed to be cheap: walks every user table's
2651    /// `Catalog::table_names()` + reads `statistics::modified_
2652    /// since_last_analyze()` (BTreeMap lookup). The background
2653    /// worker calls this under `engine.read()` then drops the lock
2654    /// before re-acquiring `engine.write()` for the actual ANALYZE.
2655    pub fn tables_needing_analyze(&self) -> Vec<String> {
2656        const MIN_ROWS: u64 = 100;
2657        let mut out = Vec::new();
2658        for name in self.catalog.table_names() {
2659            if is_internal_table_name(&name) {
2660                continue;
2661            }
2662            let Some(table) = self.catalog.get(&name) else {
2663                continue;
2664            };
2665            let row_count = table.rows().len() as u64;
2666            let modified = self.statistics.modified_since_last_analyze(&name);
2667            // Threshold: ceil(0.1 × max(row_count, MIN_ROWS)),
2668            // computed in integer arithmetic so spg-engine stays
2669            // no_std without pulling in libm. `(n + 9) / 10` is
2670            // `ceil(n / 10)` for non-negative `n`.
2671            let base = row_count.max(MIN_ROWS);
2672            let threshold = base.saturating_add(9) / 10;
2673            if modified >= threshold {
2674                out.push(name);
2675            }
2676        }
2677        out
2678    }
2679
2680    /// v6.2.0 — `ANALYZE [<table>]` runtime. Bare `ANALYZE` walks
2681    /// every user table; `ANALYZE <name>` re-stats one. For each
2682    /// target table, single-pass scan + per-column histogram +
2683    /// `null_frac` + `n_distinct`. Replaces the table's prior
2684    /// stats; resets the modified-row counter.
2685    ///
2686    /// v6.2.0 doesn't sample — it scans the full table. v6.2.x
2687    /// can add reservoir sampling at the > 100 K-row mark; not a
2688    /// scope blocker for the current commit since rows ≤ 100 K
2689    /// analyse in milliseconds.
2690    fn exec_analyze(&mut self, target: Option<&str>) -> Result<QueryResult, EngineError> {
2691        let names: Vec<String> = if let Some(name) = target {
2692            // Verify the table exists; surface a clear error if not.
2693            if self.catalog.get(name).is_none() {
2694                return Err(EngineError::Storage(StorageError::TableNotFound {
2695                    name: name.to_string(),
2696                }));
2697            }
2698            alloc::vec![name.to_string()]
2699        } else {
2700            self.catalog
2701                .table_names()
2702                .into_iter()
2703                .filter(|n| !is_internal_table_name(n))
2704                .collect()
2705        };
2706        let mut analysed = 0usize;
2707        for table_name in &names {
2708            self.analyze_one_table(table_name)?;
2709            analysed += 1;
2710        }
2711        // v6.3.1 — plan cache invalidation. Bump stats version so
2712        // future lookups see the new generation, and selectively
2713        // evict every plan whose `source_tables` overlap with the
2714        // ANALYZE target set. Bare ANALYZE (all tables) clears the
2715        // whole cache.
2716        if analysed > 0 {
2717            self.statistics.bump_version();
2718            if target.is_some() {
2719                for t in &names {
2720                    self.plan_cache.evict_referencing(t);
2721                }
2722            } else {
2723                self.plan_cache.clear();
2724            }
2725        }
2726        Ok(QueryResult::CommandOk {
2727            affected: analysed,
2728            modified_catalog: true,
2729        })
2730    }
2731
2732    /// v6.7.3 — `COMPACT COLD SEGMENTS` runtime path. Drives the
2733    /// engine-layer compaction shim with the default
2734    /// 4 MiB segment-size threshold. spg-server intercepts the
2735    /// SQL before it reaches the engine on a server build —
2736    /// it reads `SPG_COMPACTION_TARGET_SEGMENT_BYTES`, calls
2737    /// `Engine::compact_cold_segments_with_target` directly with
2738    /// the env value, and persists every merged segment to
2739    /// v7.12.1 — record a `SET <name> = <value>` parameter. Names
2740    /// are case-folded to lowercase to match PG; values keep their
2741    /// caller-supplied form so observability paths see what was
2742    /// requested. Only `default_text_search_config` is consulted by
2743    /// the engine today.
2744    fn set_session_param(&mut self, name: String, value: spg_sql::ast::SetValue) {
2745        let normalised = match value {
2746            spg_sql::ast::SetValue::String(s) => s,
2747            spg_sql::ast::SetValue::Ident(s) => s,
2748            spg_sql::ast::SetValue::Number(s) => s,
2749            spg_sql::ast::SetValue::Default => String::new(),
2750        };
2751        let key = name.to_ascii_lowercase();
2752        // v7.14.0 — mysqldump preamble emits
2753        // `SET FOREIGN_KEY_CHECKS=0` so it can CREATE TABLE in any
2754        // order despite cross-table FK references; the closing
2755        // section emits `SET FOREIGN_KEY_CHECKS=1` (or
2756        // `=@OLD_FOREIGN_KEY_CHECKS` which resolves to "ON" in our
2757        // session-variable-aware path). Match both shapes.
2758        // Also accept PG's `session_replication_role = 'replica'`
2759        // which suppresses trigger + FK enforcement during a
2760        // logical replication apply (pg_dump preserves this for
2761        // schema-only mode but it shows up in some restores).
2762        let value_off = matches!(
2763            normalised.to_ascii_lowercase().as_str(),
2764            "0" | "off" | "false"
2765        );
2766        let value_on = matches!(
2767            normalised.to_ascii_lowercase().as_str(),
2768            "1" | "on" | "true"
2769        );
2770        if key == "foreign_key_checks"
2771            || key == "session_replication_role" && normalised.eq_ignore_ascii_case("replica")
2772        {
2773            if value_off || key == "session_replication_role" {
2774                self.foreign_key_checks = false;
2775            } else if value_on
2776                || (key == "session_replication_role" && normalised.eq_ignore_ascii_case("origin"))
2777            {
2778                self.foreign_key_checks = true;
2779                // Drain pending FK queue against the now-complete
2780                // catalog. Errors here surface as the SET reply —
2781                // caller knows enabling checks revealed orphans.
2782                let _ = self.drain_pending_foreign_keys();
2783            }
2784        }
2785        // v7.22 (round-13 T3) — string-literal dialect signals.
2786        // `SET sql_mode = …` is something only MySQL clients and
2787        // mysqldump preambles emit → MySQL escape semantics.
2788        // `SET standard_conforming_strings = on|off` is PG's own
2789        // switch for exactly this behaviour (every pg_dump preamble
2790        // sets it to on). The same SQL text lexes differently per
2791        // dialect, so a flip invalidates the plan cache.
2792        let new_escapes = if key == "sql_mode" {
2793            Some(true)
2794        } else if key == "standard_conforming_strings" {
2795            Some(value_off)
2796        } else {
2797            None
2798        };
2799        if let Some(flag) = new_escapes
2800            && flag != self.backslash_escapes
2801        {
2802            self.backslash_escapes = flag;
2803            self.plan_cache.clear();
2804        }
2805        self.session_params.insert(key, normalised);
2806    }
2807
2808    /// v7.14.0 — resolve every queued FK whose installation was
2809    /// deferred (`SET FOREIGN_KEY_CHECKS=0` window). Called by
2810    /// `set_session_param` when checks flip back on and by the
2811    /// drop-import release gate. Each FK is resolved against the
2812    /// current catalog; remaining missing-parent errors propagate
2813    /// up so the caller knows the import was incomplete.
2814    fn drain_pending_foreign_keys(&mut self) -> Result<(), EngineError> {
2815        let pending = core::mem::take(&mut self.pending_foreign_keys);
2816        for (child, fk) in pending {
2817            // Resolve against the current catalog. Skip silently
2818            // when the child table itself was dropped between
2819            // queue + drain.
2820            let cols_snapshot = match self.active_catalog().get(&child) {
2821                Some(t) => t.schema().columns.clone(),
2822                None => continue,
2823            };
2824            let storage_fk =
2825                resolve_foreign_key(&child, &cols_snapshot, fk, self.active_catalog())?;
2826            let table = self
2827                .active_catalog_mut()
2828                .get_mut(&child)
2829                .expect("checked above");
2830            table.schema_mut().foreign_keys.push(storage_fk);
2831        }
2832        Ok(())
2833    }
2834
2835    /// v7.12.1 — read a session parameter set via `SET`. Used by
2836    /// the FTS function dispatcher to resolve the default config
2837    /// for `to_tsvector(text)` / `plainto_tsquery(text)` etc.
2838    #[must_use]
2839    pub fn session_param(&self, name: &str) -> Option<&str> {
2840        self.session_params
2841            .get(&name.to_ascii_lowercase())
2842            .map(String::as_str)
2843    }
2844
2845    /// v7.12.1 — build an `EvalContext` chained with the session's
2846    /// `default_text_search_config`. Engine-internal callers use
2847    /// this instead of `EvalContext::new` so the FTS function
2848    /// dispatcher sees the SET configuration.
2849    fn ev_ctx<'a>(
2850        &'a self,
2851        columns: &'a [ColumnSchema],
2852        alias: Option<&'a str>,
2853    ) -> EvalContext<'a> {
2854        EvalContext::new(columns, alias)
2855            .with_default_text_search_config(self.session_param("default_text_search_config"))
2856    }
2857
2858    /// `<db>.spg/segments/`. This arm only fires for engine-only
2859    /// callers (spg-embedded, lib tests); in that mode merged
2860    /// segments live in memory and are dropped at process exit.
2861    fn exec_compact_cold_segments(&mut self) -> Result<QueryResult, EngineError> {
2862        let target = COMPACTION_TARGET_DEFAULT_BYTES;
2863        let reports = self.compact_cold_segments_with_target(target)?;
2864        let columns = alloc::vec![
2865            ColumnSchema::new("table_name", DataType::Text, false),
2866            ColumnSchema::new("index_name", DataType::Text, false),
2867            ColumnSchema::new("sources_merged", DataType::BigInt, false),
2868            ColumnSchema::new("merged_segment_id", DataType::BigInt, false),
2869            ColumnSchema::new("merged_rows", DataType::BigInt, false),
2870            ColumnSchema::new("deleted_rows_pruned", DataType::BigInt, false),
2871            ColumnSchema::new("bytes_reclaimed_estimate", DataType::BigInt, false),
2872        ];
2873        let rows: Vec<Row> = reports
2874            .into_iter()
2875            .map(|(tname, iname, report)| {
2876                Row::new(alloc::vec![
2877                    Value::Text(tname),
2878                    Value::Text(iname),
2879                    Value::BigInt(i64::try_from(report.sources.len()).unwrap_or(i64::MAX)),
2880                    Value::BigInt(i64::from(report.merged_segment_id.unwrap_or(0))),
2881                    Value::BigInt(i64::try_from(report.merged_rows).unwrap_or(i64::MAX)),
2882                    Value::BigInt(i64::try_from(report.deleted_rows_pruned).unwrap_or(i64::MAX),),
2883                    Value::BigInt(
2884                        i64::try_from(report.bytes_reclaimed_estimate).unwrap_or(i64::MAX),
2885                    ),
2886                ])
2887            })
2888            .collect();
2889        Ok(QueryResult::Rows { columns, rows })
2890    }
2891
2892    /// Walk a single table's rows once and (re-)populate per-column
2893    /// stats. Drops the existing stats for `table` first so columns
2894    /// that have been DROP-ed between ANALYZEs don't leave stale
2895    /// rows.
2896    fn analyze_one_table(&mut self, table_name: &str) -> Result<(), EngineError> {
2897        let table = self.catalog.get(table_name).ok_or_else(|| {
2898            EngineError::Storage(StorageError::TableNotFound {
2899                name: table_name.to_string(),
2900            })
2901        })?;
2902        let schema = table.schema().clone();
2903        let row_count = table.rows().len();
2904        // For each column, collect (sorted) non-NULL textual values
2905        // + count NULLs; then ask `statistics::build_histogram` to
2906        // produce the 101 bounds and `estimate_n_distinct` the
2907        // distinct count.
2908        self.statistics.clear_table(table_name);
2909        for (col_pos, col_schema) in schema.columns.iter().enumerate() {
2910            // v6.2.0 skip: vector columns have their own stats
2911            // shape (HNSW graph topology). v6.2 deliberation #1.
2912            if matches!(col_schema.ty, DataType::Vector { .. }) {
2913                continue;
2914            }
2915            let mut non_null_values: Vec<Value> = Vec::with_capacity(row_count);
2916            let mut nulls: u64 = 0;
2917            for row in table.rows() {
2918                match row.values.get(col_pos) {
2919                    Some(Value::Null) | None => nulls += 1,
2920                    Some(v) => non_null_values.push(v.clone()),
2921                }
2922            }
2923            // Sort by type-aware ordering (Int as int, Text as
2924            // lex, etc.) so histogram bounds reflect the column's
2925            // natural order — not lexicographic on the string
2926            // representation, which would put "9" after "49".
2927            non_null_values.sort_by(|a, b| sort_values_for_histogram(a, b));
2928            let non_null: Vec<String> = non_null_values.iter().map(canonical_value_repr).collect();
2929            let null_frac = if row_count == 0 {
2930                0.0
2931            } else {
2932                #[allow(clippy::cast_precision_loss)]
2933                let f = nulls as f32 / row_count as f32;
2934                f
2935            };
2936            let n_distinct = statistics::estimate_n_distinct(&non_null);
2937            let histogram_bounds = statistics::build_histogram(&non_null);
2938            self.statistics.set(
2939                table_name.to_string(),
2940                col_schema.name.clone(),
2941                statistics::ColumnStats {
2942                    null_frac,
2943                    n_distinct,
2944                    histogram_bounds,
2945                },
2946            );
2947        }
2948        self.statistics.reset_modified(table_name);
2949        // v6.7.0 — refresh the per-table cold_rows cache. Walk the
2950        // BTree indices and count Cold locators (MAX across
2951        // indices); store the result on the table. Surfaced via
2952        // `spg_statistic.cold_row_count` (new column) and
2953        // `spg_stat_segment.table_name` (new column).
2954        let cold_count = {
2955            let table = self
2956                .active_catalog()
2957                .get(table_name)
2958                .expect("table still present");
2959            table.count_cold_locators()
2960        };
2961        let table_mut = self
2962            .active_catalog_mut()
2963            .get_mut(table_name)
2964            .expect("table still present");
2965        table_mut.set_cold_row_count(cold_count);
2966        Ok(())
2967    }
2968
2969    /// v6.1.3 — `SHOW PUBLICATIONS` row materialisation. Returns
2970    /// `(name, scope, table_count)` ordered by publication name.
2971    ///   - `scope` is the human-readable string:
2972    ///       `"FOR ALL TABLES"` /
2973    ///       `"FOR TABLE t1, t2"` /
2974    ///       `"FOR ALL TABLES EXCEPT t1, t2"`.
2975    ///   - `table_count` is NULL for `AllTables`, the list length
2976    ///     otherwise. NULLability lets clients distinguish "publish
2977    ///     everything" from "publish exactly 0 tables" (the v6.1.3
2978    ///     parser forbids the empty list, but the column shape is
2979    ///     ready for the v6.1.5 publisher-side semantics).
2980    fn exec_show_publications(&self) -> QueryResult {
2981        let columns = alloc::vec![
2982            ColumnSchema::new("name", DataType::Text, false),
2983            ColumnSchema::new("scope", DataType::Text, false),
2984            ColumnSchema::new("table_count", DataType::Int, true),
2985        ];
2986        let rows: Vec<Row> = self
2987            .publications
2988            .iter()
2989            .map(|(name, scope)| {
2990                let (scope_str, count_val) = match scope {
2991                    spg_sql::ast::PublicationScope::AllTables => {
2992                        ("FOR ALL TABLES".to_string(), Value::Null)
2993                    }
2994                    spg_sql::ast::PublicationScope::ForTables(ts) => (
2995                        alloc::format!("FOR TABLE {}", ts.join(", ")),
2996                        Value::Int(i32::try_from(ts.len()).unwrap_or(i32::MAX)),
2997                    ),
2998                    spg_sql::ast::PublicationScope::AllTablesExcept(ts) => (
2999                        alloc::format!("FOR ALL TABLES EXCEPT {}", ts.join(", ")),
3000                        Value::Int(i32::try_from(ts.len()).unwrap_or(i32::MAX)),
3001                    ),
3002                };
3003                Row::new(alloc::vec![
3004                    Value::Text(name.clone()),
3005                    Value::Text(scope_str),
3006                    count_val,
3007                ])
3008            })
3009            .collect();
3010        QueryResult::Rows { columns, rows }
3011    }
3012
3013    /// v4.1 `SHOW USERS` — `(name, role)` per row, ordered by name.
3014    fn exec_show_users(&self) -> QueryResult {
3015        let columns = alloc::vec![
3016            ColumnSchema::new("name", DataType::Text, false),
3017            ColumnSchema::new("role", DataType::Text, false),
3018        ];
3019        let rows: Vec<Row> = self
3020            .users
3021            .iter()
3022            .map(|(name, rec)| {
3023                Row::new(alloc::vec![
3024                    Value::Text(name.to_string()),
3025                    Value::Text(rec.role.as_str().to_string()),
3026                ])
3027            })
3028            .collect();
3029        QueryResult::Rows { columns, rows }
3030    }
3031
3032    fn exec_create_user(&mut self, s: &CreateUserStatement) -> Result<QueryResult, EngineError> {
3033        if self.in_transaction() {
3034            return Err(EngineError::Unsupported(
3035                "CREATE USER is not allowed inside a transaction".into(),
3036            ));
3037        }
3038        let role = users::Role::parse(&s.role).ok_or_else(|| {
3039            EngineError::Unsupported(alloc::format!("invalid role: {:?}", s.role))
3040        })?;
3041        // Prefer the host-injected RNG. Falls back to a deterministic
3042        // salt derived from the username only when no RNG is wired —
3043        // acceptable for tests; the server always installs one.
3044        let salt = self.salt_fn.map_or_else(
3045            || {
3046                let mut s_bytes = [0u8; 16];
3047                let digest = spg_crypto::hash(s.name.as_bytes());
3048                s_bytes.copy_from_slice(&digest[..16]);
3049                s_bytes
3050            },
3051            |f| f(),
3052        );
3053        self.users
3054            .create(&s.name, &s.password, role, salt)
3055            .map_err(|e| EngineError::Unsupported(alloc::format!("CREATE USER: {e}")))?;
3056        Ok(QueryResult::CommandOk {
3057            affected: 1,
3058            modified_catalog: true,
3059        })
3060    }
3061
3062    fn exec_drop_user(&mut self, name: &str) -> Result<QueryResult, EngineError> {
3063        if self.in_transaction() {
3064            return Err(EngineError::Unsupported(
3065                "DROP USER is not allowed inside a transaction".into(),
3066            ));
3067        }
3068        self.users
3069            .drop(name)
3070            .map_err(|e| EngineError::Unsupported(alloc::format!("DROP USER: {e}")))?;
3071        Ok(QueryResult::CommandOk {
3072            affected: 1,
3073            modified_catalog: true,
3074        })
3075    }
3076
3077    /// v7.12.4 — `CREATE [OR REPLACE] FUNCTION`. Stores the
3078    /// function metadata in the catalog. PL/pgSQL bodies are
3079    /// already parsed by the SQL parser; we re-canonicalise the
3080    /// body to source text for storage (the executor re-parses
3081    /// it at trigger fire time — see the trigger fire path).
3082    fn exec_create_function(
3083        &mut self,
3084        s: spg_sql::ast::CreateFunctionStatement,
3085    ) -> Result<QueryResult, EngineError> {
3086        let args_repr = render_function_args(&s.args);
3087        let returns = match &s.returns {
3088            spg_sql::ast::FunctionReturn::Trigger => alloc::string::String::from("TRIGGER"),
3089            spg_sql::ast::FunctionReturn::Void => alloc::string::String::from("VOID"),
3090            spg_sql::ast::FunctionReturn::Type(t) => alloc::format!("{t}"),
3091            spg_sql::ast::FunctionReturn::Other(s) => s.clone(),
3092        };
3093        let body_text = match &s.body {
3094            spg_sql::ast::FunctionBody::PlPgSql(b) => alloc::format!("{b}"),
3095            spg_sql::ast::FunctionBody::Raw(s) => s.clone(),
3096        };
3097        let def = spg_storage::FunctionDef {
3098            name: s.name.clone(),
3099            args_repr,
3100            returns,
3101            language: s.language.clone(),
3102            body: body_text,
3103        };
3104        self.active_catalog_mut()
3105            .create_function(def, s.or_replace)
3106            .map_err(EngineError::Storage)?;
3107        Ok(QueryResult::CommandOk {
3108            affected: 0,
3109            modified_catalog: true,
3110        })
3111    }
3112
3113    /// v7.12.4 — `CREATE [OR REPLACE] TRIGGER`. The referenced
3114    /// function must already exist in the catalog (forward
3115    /// references defer to a later release). Persists the
3116    /// trigger metadata for the row-write hooks below to consult.
3117    fn exec_create_trigger(
3118        &mut self,
3119        s: spg_sql::ast::CreateTriggerStatement,
3120    ) -> Result<QueryResult, EngineError> {
3121        let timing = match s.timing {
3122            spg_sql::ast::TriggerTiming::Before => "BEFORE",
3123            spg_sql::ast::TriggerTiming::After => "AFTER",
3124            spg_sql::ast::TriggerTiming::InsteadOf => "INSTEAD OF",
3125        };
3126        let events: Vec<alloc::string::String> = s
3127            .events
3128            .iter()
3129            .map(|e| match e {
3130                spg_sql::ast::TriggerEvent::Insert => alloc::string::String::from("INSERT"),
3131                spg_sql::ast::TriggerEvent::Update => alloc::string::String::from("UPDATE"),
3132                spg_sql::ast::TriggerEvent::Delete => alloc::string::String::from("DELETE"),
3133                spg_sql::ast::TriggerEvent::Truncate => alloc::string::String::from("TRUNCATE"),
3134            })
3135            .collect();
3136        let for_each = match s.for_each {
3137            spg_sql::ast::TriggerForEach::Row => "ROW",
3138            spg_sql::ast::TriggerForEach::Statement => "STATEMENT",
3139        };
3140        let def = spg_storage::TriggerDef {
3141            name: s.name.clone(),
3142            table: s.table.clone(),
3143            timing: alloc::string::String::from(timing),
3144            events,
3145            for_each: alloc::string::String::from(for_each),
3146            function: s.function.clone(),
3147            update_columns: s.update_columns.clone(),
3148            // v7.16.1 — every trigger is born enabled. Toggled
3149            // by ALTER TABLE … { ENABLE | DISABLE } TRIGGER.
3150            enabled: true,
3151        };
3152        self.active_catalog_mut()
3153            .create_trigger(def, s.or_replace)
3154            .map_err(EngineError::Storage)?;
3155        Ok(QueryResult::CommandOk {
3156            affected: 0,
3157            modified_catalog: true,
3158        })
3159    }
3160
3161    fn exec_drop_trigger(
3162        &mut self,
3163        name: &str,
3164        table: &str,
3165        if_exists: bool,
3166    ) -> Result<QueryResult, EngineError> {
3167        let removed = self.active_catalog_mut().drop_trigger(name, table);
3168        if !removed && !if_exists {
3169            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3170                alloc::format!("trigger {name:?} on {table:?} does not exist"),
3171            )));
3172        }
3173        Ok(QueryResult::CommandOk {
3174            affected: usize::from(removed),
3175            modified_catalog: removed,
3176        })
3177    }
3178
3179    fn exec_drop_function(
3180        &mut self,
3181        name: &str,
3182        if_exists: bool,
3183    ) -> Result<QueryResult, EngineError> {
3184        let removed = self.active_catalog_mut().drop_function(name);
3185        if !removed && !if_exists {
3186            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3187                alloc::format!("function {name:?} does not exist"),
3188            )));
3189        }
3190        Ok(QueryResult::CommandOk {
3191            affected: usize::from(removed),
3192            modified_catalog: removed,
3193        })
3194    }
3195
3196    /// v7.17.0 — `CREATE SEQUENCE` engine path. Resolves
3197    /// `min_value` / `max_value` / `start` against PG defaults
3198    /// when omitted, then installs the SequenceDef in the catalog.
3199    fn exec_create_sequence(
3200        &mut self,
3201        s: spg_sql::ast::CreateSequenceStatement,
3202    ) -> Result<QueryResult, EngineError> {
3203        use spg_sql::ast::{SeqBound, SequenceDataType as AstDt};
3204        use spg_storage::{SequenceDataType, SequenceDef};
3205        let dt = match s.data_type {
3206            None => SequenceDataType::BigInt,
3207            Some(AstDt::SmallInt) => SequenceDataType::SmallInt,
3208            Some(AstDt::Int) => SequenceDataType::Int,
3209            Some(AstDt::BigInt) => SequenceDataType::BigInt,
3210        };
3211        let increment = s.options.increment.unwrap_or(1);
3212        if increment == 0 {
3213            return Err(EngineError::Unsupported(
3214                "INCREMENT must not be zero".into(),
3215            ));
3216        }
3217        let (def_min, def_max) = dt.default_bounds(increment > 0);
3218        let min_value = match s.options.min_value {
3219            None | Some(SeqBound::NoBound) => def_min,
3220            Some(SeqBound::Value(n)) => n,
3221        };
3222        let max_value = match s.options.max_value {
3223            None | Some(SeqBound::NoBound) => def_max,
3224            Some(SeqBound::Value(n)) => n,
3225        };
3226        if min_value > max_value {
3227            return Err(EngineError::Unsupported(alloc::format!(
3228                "MINVALUE ({min_value}) must be <= MAXVALUE ({max_value})"
3229            )));
3230        }
3231        let start = s
3232            .options
3233            .start
3234            .unwrap_or(if increment > 0 { min_value } else { max_value });
3235        if start < min_value || start > max_value {
3236            return Err(EngineError::Unsupported(alloc::format!(
3237                "START WITH ({start}) is outside MINVALUE..MAXVALUE ({min_value}..{max_value})"
3238            )));
3239        }
3240        let cache = s.options.cache.unwrap_or(1);
3241        if cache < 1 {
3242            return Err(EngineError::Unsupported("CACHE must be >= 1".into()));
3243        }
3244        let cycle = s.options.cycle.unwrap_or(false);
3245        let owned_by = match s.options.owned_by {
3246            None | Some(spg_sql::ast::SequenceOwnedBy::None) => None,
3247            Some(spg_sql::ast::SequenceOwnedBy::Column { table, column }) => Some((table, column)),
3248        };
3249        let def = SequenceDef {
3250            name: s.name.clone(),
3251            data_type: dt,
3252            start,
3253            increment,
3254            min_value,
3255            max_value,
3256            cache,
3257            cycle,
3258            owned_by,
3259            last_value: start,
3260            is_called: false,
3261        };
3262        self.active_catalog_mut()
3263            .create_sequence(def, s.if_not_exists)
3264            .map_err(EngineError::Storage)?;
3265        Ok(QueryResult::CommandOk {
3266            affected: 0,
3267            modified_catalog: !self.in_transaction(),
3268        })
3269    }
3270
3271    /// v7.17.0 — `ALTER SEQUENCE` engine path. Re-uses the catalog
3272    /// `alter_sequence` merge helper.
3273    fn exec_alter_sequence(
3274        &mut self,
3275        s: spg_sql::ast::AlterSequenceStatement,
3276    ) -> Result<QueryResult, EngineError> {
3277        use spg_sql::ast::SeqBound;
3278        let cat = self.active_catalog_mut();
3279        if !cat.sequences().contains_key(&s.name) {
3280            if s.if_exists {
3281                return Ok(QueryResult::CommandOk {
3282                    affected: 0,
3283                    modified_catalog: false,
3284                });
3285            }
3286            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3287                alloc::format!("sequence {:?} does not exist", s.name),
3288            )));
3289        }
3290        let min_value = match s.options.min_value {
3291            None => None,
3292            Some(SeqBound::NoBound) => None, // NO MINVALUE → keep current
3293            Some(SeqBound::Value(n)) => Some(n),
3294        };
3295        let max_value = match s.options.max_value {
3296            None => None,
3297            Some(SeqBound::NoBound) => None,
3298            Some(SeqBound::Value(n)) => Some(n),
3299        };
3300        let owned_by = s.options.owned_by.map(|ob| match ob {
3301            spg_sql::ast::SequenceOwnedBy::None => None,
3302            spg_sql::ast::SequenceOwnedBy::Column { table, column } => Some((table, column)),
3303        });
3304        cat.alter_sequence(
3305            &s.name,
3306            s.options.increment,
3307            min_value,
3308            max_value,
3309            s.options.start,
3310            s.options.restart,
3311            s.options.cache,
3312            s.options.cycle,
3313            owned_by,
3314        )
3315        .map_err(EngineError::Storage)?;
3316        Ok(QueryResult::CommandOk {
3317            affected: 0,
3318            modified_catalog: !self.in_transaction(),
3319        })
3320    }
3321
3322    /// v7.17.0 Phase 1.1 — walk a Statement tree and pre-resolve
3323    /// any sequence FunctionCall nodes inside its Expr slots.
3324    /// Delegates per-statement-kind: SELECT projection +
3325    /// WHERE, INSERT VALUES, UPDATE SET, DELETE WHERE.
3326    fn pre_resolve_sequence_calls_in_statement(
3327        &mut self,
3328        stmt: &mut Statement,
3329    ) -> Result<(), EngineError> {
3330        match stmt {
3331            Statement::Select(s) => self.pre_resolve_sequence_calls_in_select(s),
3332            Statement::Insert(s) => {
3333                for tuple in &mut s.rows {
3334                    for cell in tuple.iter_mut() {
3335                        self.resolve_sequence_calls_in_expr(cell)?;
3336                    }
3337                }
3338                Ok(())
3339            }
3340            Statement::Update(s) => {
3341                for (_col, expr) in &mut s.assignments {
3342                    self.resolve_sequence_calls_in_expr(expr)?;
3343                }
3344                if let Some(w) = &mut s.where_ {
3345                    self.resolve_sequence_calls_in_expr(w)?;
3346                }
3347                Ok(())
3348            }
3349            Statement::Delete(s) => {
3350                if let Some(w) = &mut s.where_ {
3351                    self.resolve_sequence_calls_in_expr(w)?;
3352                }
3353                Ok(())
3354            }
3355            _ => Ok(()),
3356        }
3357    }
3358
3359    fn pre_resolve_sequence_calls_in_select(
3360        &mut self,
3361        s: &mut spg_sql::ast::SelectStatement,
3362    ) -> Result<(), EngineError> {
3363        for item in &mut s.items {
3364            match item {
3365                spg_sql::ast::SelectItem::Expr { expr, .. } => {
3366                    self.resolve_sequence_calls_in_expr(expr)?;
3367                }
3368                spg_sql::ast::SelectItem::Wildcard => {}
3369            }
3370        }
3371        if let Some(w) = &mut s.where_ {
3372            self.resolve_sequence_calls_in_expr(w)?;
3373        }
3374        Ok(())
3375    }
3376
3377    /// v7.17.0 Phase 1.1 — walk an Expr tree and pre-resolve any
3378    /// `nextval(name)` / `currval(name)` / `setval(name, value[,
3379    /// is_called])` FunctionCall nodes by calling the catalog and
3380    /// replacing the node with the resulting `Expr::Literal`.
3381    /// Used by INSERT VALUES / UPDATE SET / DEFAULT eval so the
3382    /// row-eval path sees pre-computed sequence values instead of
3383    /// needing mutable catalog access mid-eval.
3384    #[allow(clippy::too_many_lines)]
3385    fn resolve_sequence_calls_in_expr(&mut self, expr: &mut Expr) -> Result<(), EngineError> {
3386        match expr {
3387            Expr::Literal(_) | Expr::Column(_) | Expr::Placeholder(_) => Ok(()),
3388            Expr::FunctionCall { name, args } => {
3389                // Descend first so nested calls — e.g.
3390                // setval('seq', currval('other')) — resolve
3391                // innermost-first.
3392                for a in args.iter_mut() {
3393                    self.resolve_sequence_calls_in_expr(a)?;
3394                }
3395                let lc = name.to_ascii_lowercase();
3396                if lc == "nextval" || lc == "currval" || lc == "setval" {
3397                    let v = self.eval_sequence_call(&lc, args)?;
3398                    *expr = Expr::Literal(value_to_literal(v));
3399                }
3400                Ok(())
3401            }
3402            Expr::Binary { lhs, rhs, .. } => {
3403                self.resolve_sequence_calls_in_expr(lhs)?;
3404                self.resolve_sequence_calls_in_expr(rhs)
3405            }
3406            Expr::Unary { expr, .. } => self.resolve_sequence_calls_in_expr(expr),
3407            Expr::Cast { expr, .. } => self.resolve_sequence_calls_in_expr(expr),
3408            Expr::IsNull { expr, .. } => self.resolve_sequence_calls_in_expr(expr),
3409            Expr::Like { expr, pattern, .. } => {
3410                self.resolve_sequence_calls_in_expr(expr)?;
3411                self.resolve_sequence_calls_in_expr(pattern)
3412            }
3413            Expr::Extract { source, .. } => self.resolve_sequence_calls_in_expr(source),
3414            Expr::Array(items) => {
3415                for it in items.iter_mut() {
3416                    self.resolve_sequence_calls_in_expr(it)?;
3417                }
3418                Ok(())
3419            }
3420            // Window / subquery / etc — sequence calls inside these
3421            // are uncommon and require separate row-eval; leave
3422            // untouched for now and rely on the eval-time error
3423            // (no sequence_resolver attached).
3424            _ => Ok(()),
3425        }
3426    }
3427
3428    /// v7.17.0 Phase 1.1 — evaluate a single nextval/currval/
3429    /// setval call. `args` are already pre-resolved Expr nodes
3430    /// (literals) — we extract their constant values.
3431    fn eval_sequence_call(&mut self, op: &str, args: &[Expr]) -> Result<Value, EngineError> {
3432        if args.is_empty() {
3433            return Err(EngineError::Unsupported(alloc::format!(
3434                "{op}() takes at least one argument"
3435            )));
3436        }
3437        let seq_name = match &args[0] {
3438            Expr::Literal(spg_sql::ast::Literal::String(s)) => {
3439                // v7.17 dump-compat — pg_dump emits sequence
3440                // names schema-qualified (`'public.posts_id_seq'`).
3441                // SPG is single-schema; strip a leading
3442                // `public.` / `pg_catalog.` so the catalog lookup
3443                // matches the bare-name CREATE SEQUENCE used.
3444                let trimmed = s
3445                    .strip_prefix("public.")
3446                    .or_else(|| s.strip_prefix("pg_catalog."))
3447                    .unwrap_or(s);
3448                trimmed.to_string()
3449            }
3450            // v7.17 dump-compat — pg_dump also emits
3451            // `nextval('public.posts_id_seq'::regclass)`
3452            // where the cast wraps the literal. Peel the cast
3453            // and continue.
3454            Expr::Cast { expr, .. } => {
3455                if let Expr::Literal(spg_sql::ast::Literal::String(s)) = expr.as_ref() {
3456                    let trimmed = s
3457                        .strip_prefix("public.")
3458                        .or_else(|| s.strip_prefix("pg_catalog."))
3459                        .unwrap_or(s);
3460                    trimmed.to_string()
3461                } else {
3462                    return Err(EngineError::Unsupported(alloc::format!(
3463                        "{op}() first argument must be a literal sequence name"
3464                    )));
3465                }
3466            }
3467            other => {
3468                return Err(EngineError::Unsupported(alloc::format!(
3469                    "{op}() first argument must be a literal sequence name, got {other:?}"
3470                )));
3471            }
3472        };
3473        match op {
3474            "nextval" => {
3475                let v = self
3476                    .active_catalog_mut()
3477                    .sequence_next_value(&seq_name)
3478                    .map_err(EngineError::Storage)?;
3479                Ok(Value::BigInt(v))
3480            }
3481            "currval" => {
3482                let v = self
3483                    .active_catalog()
3484                    .sequence_current_value(&seq_name)
3485                    .map_err(EngineError::Storage)?;
3486                Ok(Value::BigInt(v))
3487            }
3488            "setval" => {
3489                if args.len() < 2 || args.len() > 3 {
3490                    return Err(EngineError::Unsupported(alloc::format!(
3491                        "setval() takes 2 or 3 arguments, got {}",
3492                        args.len()
3493                    )));
3494                }
3495                let value = match &args[1] {
3496                    Expr::Literal(spg_sql::ast::Literal::Integer(n)) => *n,
3497                    other => {
3498                        return Err(EngineError::Unsupported(alloc::format!(
3499                            "setval() value argument must be a literal integer, got {other:?}"
3500                        )));
3501                    }
3502                };
3503                let is_called = if args.len() == 3 {
3504                    match &args[2] {
3505                        Expr::Literal(spg_sql::ast::Literal::Bool(b)) => *b,
3506                        other => {
3507                            return Err(EngineError::Unsupported(alloc::format!(
3508                                "setval() is_called argument must be a literal BOOL, got {other:?}"
3509                            )));
3510                        }
3511                    }
3512                } else {
3513                    true
3514                };
3515                let v = self
3516                    .active_catalog_mut()
3517                    .sequence_set_value(&seq_name, value, is_called)
3518                    .map_err(EngineError::Storage)?;
3519                Ok(Value::BigInt(v))
3520            }
3521            other => Err(EngineError::Unsupported(alloc::format!(
3522                "unknown sequence op {other:?}"
3523            ))),
3524        }
3525    }
3526
3527    /// v7.17.0 Phase 1.2 — find every catalog VIEW referenced in
3528    /// the SELECT's FROM / JOIN graph, re-parse each view's body
3529    /// source, and prepend it as a synthetic CTE on the
3530    /// returned SelectStatement. Returns `None` when no view
3531    /// references are found (caller proceeds with the original
3532    /// statement); returns `Some(rewritten)` otherwise (caller
3533    /// re-runs exec_select_cancel on the rewritten form so the
3534    /// regular CTE materialiser handles it).
3535    fn expand_views_in_select(
3536        &self,
3537        stmt: &SelectStatement,
3538    ) -> Result<Option<SelectStatement>, EngineError> {
3539        let cat = self.active_catalog();
3540        let mut referenced: Vec<String> = Vec::new();
3541        if let Some(from) = &stmt.from {
3542            collect_view_refs(&from.primary, cat, &mut referenced);
3543            for j in &from.joins {
3544                collect_view_refs(&j.table, cat, &mut referenced);
3545            }
3546        }
3547        // Don't expand a view name that's already shadowed by a
3548        // CTE on the same SELECT — the CTE wins per PG.
3549        referenced.retain(|n| !stmt.ctes.iter().any(|c| c.name == *n));
3550        if referenced.is_empty() {
3551            return Ok(None);
3552        }
3553        let mut new_ctes: Vec<spg_sql::ast::Cte> = Vec::with_capacity(referenced.len());
3554        for name in &referenced {
3555            let view = cat.views().get(name).ok_or_else(|| {
3556                EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
3557                    "view {name:?} disappeared mid-expansion"
3558                )))
3559            })?;
3560            let parsed = spg_sql::parser::parse_statement(&view.body).map_err(|e| {
3561                EngineError::Unsupported(alloc::format!("view {name:?} body re-parse failed: {e}"))
3562            })?;
3563            let Statement::Select(body) = parsed else {
3564                return Err(EngineError::Unsupported(alloc::format!(
3565                    "view {name:?} body is not a SELECT (catalog corruption)"
3566                )));
3567            };
3568            new_ctes.push(spg_sql::ast::Cte {
3569                name: name.clone(),
3570                body,
3571                recursive: false,
3572                column_overrides: view.columns.clone(),
3573            });
3574        }
3575        let mut out = stmt.clone();
3576        // Prepend so view CTEs are visible to caller-supplied CTEs.
3577        new_ctes.extend(out.ctes);
3578        out.ctes = new_ctes;
3579        Ok(Some(out))
3580    }
3581
3582    /// v7.17.0 Phase 1.2 — `CREATE VIEW` engine path. Stores the
3583    /// Display-rendered body verbatim in the catalog; SELECT-from-
3584    /// view at exec time re-parses + prepends as a synthetic CTE.
3585    fn exec_create_view(
3586        &mut self,
3587        s: spg_sql::ast::CreateViewStatement,
3588    ) -> Result<QueryResult, EngineError> {
3589        // Render the SELECT body to canonical form so the catalog
3590        // round-trips a deterministic source (no whitespace /
3591        // comment surprises in the on-disk snapshot).
3592        let body_repr = alloc::format!("{}", spg_sql::ast::Statement::Select(s.body));
3593        let def = spg_storage::ViewDef {
3594            name: s.name.clone(),
3595            columns: s.columns,
3596            body: body_repr,
3597        };
3598        self.active_catalog_mut()
3599            .create_view(def, s.or_replace, s.if_not_exists)
3600            .map_err(EngineError::Storage)?;
3601        Ok(QueryResult::CommandOk {
3602            affected: 0,
3603            modified_catalog: !self.in_transaction(),
3604        })
3605    }
3606
3607    /// v7.17.0 Phase 1.4 — `CREATE TYPE name AS ENUM (…)` engine
3608    /// path. Registers the enum in the catalog with order-
3609    /// preserving labels. PG semantics: CREATE TYPE errors if the
3610    /// name is taken (no IF NOT EXISTS).
3611    fn exec_create_type(
3612        &mut self,
3613        s: spg_sql::ast::CreateTypeStatement,
3614    ) -> Result<QueryResult, EngineError> {
3615        // Name-collision check against tables / sequences / views /
3616        // materialized views.
3617        let cat = self.active_catalog();
3618        if cat.get(&s.name).is_some() {
3619            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3620                alloc::format!("type {:?} would shadow an existing table", s.name),
3621            )));
3622        }
3623        if cat.sequences().contains_key(&s.name) {
3624            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3625                alloc::format!("type {:?} would shadow an existing sequence", s.name),
3626            )));
3627        }
3628        if cat.views().contains_key(&s.name) {
3629            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3630                alloc::format!("type {:?} would shadow an existing view", s.name),
3631            )));
3632        }
3633        let def = match s.kind {
3634            spg_sql::ast::TypeKind::Enum { labels } => {
3635                if labels.is_empty() {
3636                    return Err(EngineError::Unsupported(
3637                        "CREATE TYPE … AS ENUM requires at least one label".into(),
3638                    ));
3639                }
3640                // Reject duplicate labels per PG.
3641                for i in 0..labels.len() {
3642                    for j in (i + 1)..labels.len() {
3643                        if labels[i] == labels[j] {
3644                            return Err(EngineError::Unsupported(alloc::format!(
3645                                "CREATE TYPE {:?}: duplicate ENUM label {:?}",
3646                                s.name,
3647                                labels[i]
3648                            )));
3649                        }
3650                    }
3651                }
3652                spg_storage::EnumDef {
3653                    name: s.name.clone(),
3654                    labels,
3655                }
3656            }
3657        };
3658        self.active_catalog_mut()
3659            .create_enum_type(def)
3660            .map_err(EngineError::Storage)?;
3661        Ok(QueryResult::CommandOk {
3662            affected: 0,
3663            modified_catalog: !self.in_transaction(),
3664        })
3665    }
3666
3667    /// v7.17.0 Phase 1.5 — `CREATE DOMAIN name AS base [DEFAULT
3668    /// expr] [NOT NULL] [CHECK (expr)]*` engine path. Stores the
3669    /// base type + Display-rendered CHECK / DEFAULT sources so
3670    /// INSERT/UPDATE on bound columns can re-eval the checks.
3671    fn exec_create_domain(
3672        &mut self,
3673        s: spg_sql::ast::CreateDomainStatement,
3674    ) -> Result<QueryResult, EngineError> {
3675        let cat = self.active_catalog();
3676        if cat.domain_types().contains_key(&s.name) {
3677            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3678                alloc::format!("domain {:?} already exists", s.name),
3679            )));
3680        }
3681        if cat.get(&s.name).is_some()
3682            || cat.sequences().contains_key(&s.name)
3683            || cat.views().contains_key(&s.name)
3684            || cat.enum_types().contains_key(&s.name)
3685        {
3686            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3687                alloc::format!("domain {:?} would shadow an existing object", s.name),
3688            )));
3689        }
3690        let base_type = column_type_to_data_type(s.base_type);
3691        let default = s.default.as_ref().map(|e| alloc::format!("{e}"));
3692        let checks = s
3693            .checks
3694            .iter()
3695            .map(|e| alloc::format!("{e}"))
3696            .collect::<Vec<_>>();
3697        let def = spg_storage::DomainDef {
3698            name: s.name.clone(),
3699            base_type,
3700            nullable: !s.not_null,
3701            default,
3702            checks,
3703        };
3704        self.active_catalog_mut()
3705            .create_domain_type(def)
3706            .map_err(EngineError::Storage)?;
3707        Ok(QueryResult::CommandOk {
3708            affected: 0,
3709            modified_catalog: !self.in_transaction(),
3710        })
3711    }
3712
3713    /// v7.17.0 Phase 1.5 — `DROP DOMAIN [IF EXISTS] names`.
3714    fn exec_drop_domain(
3715        &mut self,
3716        names: &[String],
3717        if_exists: bool,
3718    ) -> Result<QueryResult, EngineError> {
3719        let mut removed = 0usize;
3720        for name in names {
3721            let was_present = self.active_catalog_mut().drop_domain_type(name);
3722            if was_present {
3723                removed += 1;
3724            } else if !if_exists {
3725                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3726                    alloc::format!("domain {name:?} does not exist"),
3727                )));
3728            }
3729        }
3730        Ok(QueryResult::CommandOk {
3731            affected: removed,
3732            modified_catalog: removed > 0 && !self.in_transaction(),
3733        })
3734    }
3735
3736    /// v7.17.0 Phase 1.6 — `CREATE SCHEMA [IF NOT EXISTS] name`.
3737    /// Registers the schema in the catalog. Schema-qualified
3738    /// table references continue to strip the prefix at lookup
3739    /// time (prefix routing, not isolation — see project-next-
3740    /// docket for the v7.18+ real-isolation tracking).
3741    fn exec_create_schema(
3742        &mut self,
3743        name: String,
3744        if_not_exists: bool,
3745    ) -> Result<QueryResult, EngineError> {
3746        self.active_catalog_mut()
3747            .create_schema(name, if_not_exists)
3748            .map_err(EngineError::Storage)?;
3749        Ok(QueryResult::CommandOk {
3750            affected: 0,
3751            modified_catalog: !self.in_transaction(),
3752        })
3753    }
3754
3755    /// v7.17.0 Phase 1.6 — `DROP SCHEMA [IF EXISTS] names`.
3756    /// Built-in schemas always reject the drop with a clear
3757    /// error.
3758    fn exec_drop_schema(
3759        &mut self,
3760        names: &[String],
3761        if_exists: bool,
3762    ) -> Result<QueryResult, EngineError> {
3763        let mut removed = 0usize;
3764        for name in names {
3765            let was_present = self
3766                .active_catalog_mut()
3767                .drop_schema(name)
3768                .map_err(EngineError::Storage)?;
3769            if was_present {
3770                removed += 1;
3771            } else if !if_exists {
3772                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3773                    alloc::format!("schema {name:?} does not exist"),
3774                )));
3775            }
3776        }
3777        Ok(QueryResult::CommandOk {
3778            affected: removed,
3779            modified_catalog: removed > 0 && !self.in_transaction(),
3780        })
3781    }
3782
3783    /// v7.17.0 Phase 1.4 — `DROP TYPE [IF EXISTS] names`. Only
3784    /// ENUM types are catalogued today; other types silently
3785    /// no-op even outside IF EXISTS to mirror the prior
3786    /// "everything's text" lax stance.
3787    fn exec_drop_type(
3788        &mut self,
3789        names: &[String],
3790        if_exists: bool,
3791    ) -> Result<QueryResult, EngineError> {
3792        let mut removed = 0usize;
3793        for name in names {
3794            let was_present = self.active_catalog_mut().drop_enum_type(name);
3795            if was_present {
3796                removed += 1;
3797            } else if !if_exists {
3798                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3799                    alloc::format!("type {name:?} does not exist"),
3800                )));
3801            }
3802        }
3803        Ok(QueryResult::CommandOk {
3804            affected: removed,
3805            modified_catalog: removed > 0 && !self.in_transaction(),
3806        })
3807    }
3808
3809    /// v7.17.0 Phase 1.3 — `CREATE MATERIALIZED VIEW` engine path.
3810    /// Materialises the body at CREATE time (unless WITH NO DATA),
3811    /// stores the result as a regular `Table`, and registers the
3812    /// body source in the catalog so REFRESH can re-run it.
3813    fn exec_create_materialized_view(
3814        &mut self,
3815        s: spg_sql::ast::CreateMaterializedViewStatement,
3816    ) -> Result<QueryResult, EngineError> {
3817        // Name-collision check (table / view / sequence / mat-view).
3818        let cat = self.active_catalog();
3819        if cat.materialized_views().contains_key(&s.name) || cat.get(&s.name).is_some() {
3820            if s.if_not_exists {
3821                return Ok(QueryResult::CommandOk {
3822                    affected: 0,
3823                    modified_catalog: false,
3824                });
3825            }
3826            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3827                alloc::format!("materialized view {:?} already exists", s.name),
3828            )));
3829        }
3830        if cat.views().contains_key(&s.name) {
3831            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3832                alloc::format!(
3833                    "materialized view {:?} would shadow an existing view",
3834                    s.name
3835                ),
3836            )));
3837        }
3838        if cat.sequences().contains_key(&s.name) {
3839            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3840                alloc::format!(
3841                    "materialized view {:?} would shadow an existing sequence",
3842                    s.name
3843                ),
3844            )));
3845        }
3846        // Render the body to canonical form for the registry.
3847        let body_repr = alloc::format!("{}", spg_sql::ast::Statement::Select(s.body.clone()));
3848        // Execute the body to learn the columns. With WITH DATA we
3849        // also materialise the rows; with WITH NO DATA we only need
3850        // the schema, so re-use a LIMIT 0 wrap to keep the column
3851        // inference path uniform without paying for the rows.
3852        let result = self.exec_select_cancel(&s.body, CancelToken::none())?;
3853        let (mut cols, rows) = match result {
3854            QueryResult::Rows { columns, rows } => (columns, rows),
3855            other => {
3856                return Err(EngineError::Unsupported(alloc::format!(
3857                    "CREATE MATERIALIZED VIEW body did not return rows: {other:?}"
3858                )));
3859            }
3860        };
3861        // Apply the column-rename list per PG semantics.
3862        if !s.columns.is_empty() {
3863            if s.columns.len() != cols.len() {
3864                return Err(EngineError::Unsupported(alloc::format!(
3865                    "CREATE MATERIALIZED VIEW {:?}: column list has {} names but body returns {}",
3866                    s.name,
3867                    s.columns.len(),
3868                    cols.len()
3869                )));
3870            }
3871            for (c, name) in cols.iter_mut().zip(s.columns.iter()) {
3872                c.name.clone_from(name);
3873            }
3874        }
3875        // Promote any synthetic-Text projections to their actual
3876        // observed types so the backing table accepts the rows.
3877        cols = infer_column_types(&cols, &rows);
3878        let schema = spg_storage::TableSchema::new(s.name.clone(), cols);
3879        let cat = self.active_catalog_mut();
3880        cat.create_table(schema).map_err(EngineError::Storage)?;
3881        if s.with_data {
3882            let table = cat
3883                .get_mut(&s.name)
3884                .expect("just-created materialized-view backing table must exist");
3885            for row in rows {
3886                table.insert(row).map_err(EngineError::Storage)?;
3887            }
3888        }
3889        cat.register_materialized_view(s.name.clone(), body_repr);
3890        Ok(QueryResult::CommandOk {
3891            affected: 0,
3892            modified_catalog: !self.in_transaction(),
3893        })
3894    }
3895
3896    /// v7.17.0 Phase 1.3 — `REFRESH MATERIALIZED VIEW name [WITH
3897    /// [NO] DATA]`. Looks up the source, re-runs it, replaces the
3898    /// backing table's rows.
3899    fn exec_refresh_materialized_view(
3900        &mut self,
3901        name: &str,
3902        with_data: bool,
3903    ) -> Result<QueryResult, EngineError> {
3904        let source = self
3905            .active_catalog()
3906            .materialized_views()
3907            .get(name)
3908            .cloned()
3909            .ok_or_else(|| {
3910                EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
3911                    "materialized view {name:?} does not exist"
3912                )))
3913            })?;
3914        // Wipe the existing rows first (PG truncates the matview
3915        // and rebuilds; we approximate with an empty INSERT loop).
3916        {
3917            let cat = self.active_catalog_mut();
3918            let table = cat.get_mut(name).ok_or_else(|| {
3919                EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
3920                    "materialized view {name:?} backing table missing"
3921                )))
3922            })?;
3923            table.truncate();
3924        }
3925        if !with_data {
3926            return Ok(QueryResult::CommandOk {
3927                affected: 0,
3928                modified_catalog: !self.in_transaction(),
3929            });
3930        }
3931        let parsed = spg_sql::parser::parse_statement(&source).map_err(|e| {
3932            EngineError::Unsupported(alloc::format!(
3933                "materialized view {name:?} body re-parse failed: {e}"
3934            ))
3935        })?;
3936        let Statement::Select(body) = parsed else {
3937            return Err(EngineError::Unsupported(alloc::format!(
3938                "materialized view {name:?} body is not a SELECT (catalog corruption)"
3939            )));
3940        };
3941        let rows = match self.exec_select_cancel(&body, CancelToken::none())? {
3942            QueryResult::Rows { rows, .. } => rows,
3943            other => {
3944                return Err(EngineError::Unsupported(alloc::format!(
3945                    "REFRESH MATERIALIZED VIEW {name:?} body did not return rows: {other:?}"
3946                )));
3947            }
3948        };
3949        let cat = self.active_catalog_mut();
3950        let table = cat.get_mut(name).expect("backing table verified above");
3951        let affected = rows.len();
3952        for row in rows {
3953            table.insert(row).map_err(EngineError::Storage)?;
3954        }
3955        Ok(QueryResult::CommandOk {
3956            affected,
3957            modified_catalog: !self.in_transaction(),
3958        })
3959    }
3960
3961    /// v7.17.0 Phase 1.3 — `DROP MATERIALIZED VIEW [IF EXISTS]
3962    /// names`. Drops the backing table + unregisters the source.
3963    fn exec_drop_materialized_view(
3964        &mut self,
3965        names: &[String],
3966        if_exists: bool,
3967    ) -> Result<QueryResult, EngineError> {
3968        let mut removed = 0usize;
3969        for name in names {
3970            let was_present = self
3971                .active_catalog_mut()
3972                .drop_materialized_view_source(name);
3973            if was_present {
3974                // Drop the backing table too.
3975                self.active_catalog_mut().drop_table(name);
3976                removed += 1;
3977            } else if !if_exists {
3978                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3979                    alloc::format!("materialized view {name:?} does not exist"),
3980                )));
3981            }
3982        }
3983        Ok(QueryResult::CommandOk {
3984            affected: removed,
3985            modified_catalog: removed > 0 && !self.in_transaction(),
3986        })
3987    }
3988
3989    /// v7.17.0 Phase 1.2 — `DROP VIEW [IF EXISTS] name [, name…]`.
3990    fn exec_drop_view(
3991        &mut self,
3992        names: &[String],
3993        if_exists: bool,
3994    ) -> Result<QueryResult, EngineError> {
3995        let mut removed = 0usize;
3996        for name in names {
3997            let was_present = self.active_catalog_mut().drop_view(name);
3998            if !was_present && !if_exists {
3999                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
4000                    alloc::format!("view {name:?} does not exist"),
4001                )));
4002            }
4003            if was_present {
4004                removed += 1;
4005            }
4006        }
4007        Ok(QueryResult::CommandOk {
4008            affected: removed,
4009            modified_catalog: removed > 0 && !self.in_transaction(),
4010        })
4011    }
4012
4013    /// v7.17.0 — `DROP SEQUENCE [IF EXISTS] name [, name…]`.
4014    fn exec_drop_sequence(
4015        &mut self,
4016        names: &[String],
4017        if_exists: bool,
4018    ) -> Result<QueryResult, EngineError> {
4019        let mut removed = 0usize;
4020        for name in names {
4021            let was_present = self.active_catalog_mut().drop_sequence(name);
4022            if !was_present && !if_exists {
4023                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
4024                    alloc::format!("sequence {name:?} does not exist"),
4025                )));
4026            }
4027            if was_present {
4028                removed += 1;
4029            }
4030        }
4031        Ok(QueryResult::CommandOk {
4032            affected: removed,
4033            modified_catalog: removed > 0 && !self.in_transaction(),
4034        })
4035    }
4036
4037    /// v4.4 `UPDATE <table> SET col = expr [, ...] [WHERE cond]`.
4038    /// Filter pass uses the same WHERE eval as `exec_select`. Per
4039    /// matched row, evaluate each RHS expression against the *old*
4040    /// row, then call `Table::update_row` which rebuilds indices.
4041    /// Indexed columns are correctly reflected because rebuild
4042    /// happens after the cell rewrite.
4043    fn exec_update_cancel(
4044        &mut self,
4045        stmt: &spg_sql::ast::UpdateStatement,
4046        cancel: CancelToken<'_>,
4047    ) -> Result<QueryResult, EngineError> {
4048        // v7.12.5 — snapshot BEFORE/AFTER UPDATE row triggers + the
4049        // session FTS config before the table mut-borrow opens (the
4050        // INSERT path uses the same pattern). Empty vecs are the
4051        // common "no triggers on this table" fast path.
4052        // v7.13.0 — UPDATE triggers carry an optional `UPDATE OF
4053        // cols` filter. The filter is paired with each function so
4054        // the per-row fire loop can skip when no listed column
4055        // actually differs between OLD and NEW.
4056        let before_update_triggers = self.snapshot_update_row_triggers(&stmt.table, "BEFORE");
4057        let after_update_triggers = self.snapshot_update_row_triggers(&stmt.table, "AFTER");
4058        let trigger_session_cfg: Option<String> = self
4059            .session_params
4060            .get("default_text_search_config")
4061            .cloned();
4062        // v5.2.3: if the WHERE is a PK equality and matches a cold-
4063        // tier row, promote it back to the hot tier *before* the
4064        // hot-row walk. The promote pushes the row to the end of
4065        // `table.rows`, where the upcoming SET-evaluation loop will
4066        // pick it up and apply the assignments. Lookups for the key
4067        // never observe a gap because `promote_cold_row` inserts the
4068        // hot row before retiring the cold locator.
4069        if let Some(w) = &stmt.where_ {
4070            let schema_cols = self
4071                .active_catalog()
4072                .get(&stmt.table)
4073                .ok_or_else(|| {
4074                    EngineError::Storage(StorageError::TableNotFound {
4075                        name: stmt.table.clone(),
4076                    })
4077                })?
4078                .schema()
4079                .columns
4080                .clone();
4081            if let Some((col_pos, key)) = try_pk_predicate(w, &schema_cols, stmt.table.as_str())
4082                && let Some(idx_name) = self
4083                    .active_catalog()
4084                    .get(&stmt.table)
4085                    .and_then(|t| t.index_on(col_pos).map(|i| i.name.clone()))
4086            {
4087                // Promote may be a no-op (key is hot-only or absent);
4088                // we don't care about the return value here — the
4089                // subsequent hot walk will either match or not.
4090                let _ = self
4091                    .active_catalog_mut()
4092                    .promote_cold_row(&stmt.table, &idx_name, &key);
4093            }
4094        }
4095
4096        // v7.12.1 — cache session FTS config before the table
4097        // mut-borrow (same reason as exec_delete).
4098        let ts_cfg: Option<String> = self
4099            .session_param("default_text_search_config")
4100            .map(String::from);
4101        // v7.17.0 Phase 2.1 — snapshot the clock pointer before
4102        // we hold the catalog mutably so ON UPDATE runtime
4103        // overrides see the engine wall clock.
4104        let clock_for_on_update = self.clock;
4105        let table = self
4106            .active_catalog_mut()
4107            .get_mut(&stmt.table)
4108            .ok_or_else(|| {
4109                EngineError::Storage(StorageError::TableNotFound {
4110                    name: stmt.table.clone(),
4111                })
4112            })?;
4113        let schema_cols: Vec<ColumnSchema> = table.schema().columns.clone();
4114        // Resolve each SET target to a column position once, validate
4115        // up front so a typo'd column doesn't leave a partial mutation
4116        // behind.
4117        let mut targets: Vec<(usize, &Expr)> = Vec::with_capacity(stmt.assignments.len());
4118        for (col, expr) in &stmt.assignments {
4119            let pos = schema_cols
4120                .iter()
4121                .position(|c| c.name == *col)
4122                .ok_or_else(|| {
4123                    EngineError::Eval(EvalError::ColumnNotFound { name: col.clone() })
4124                })?;
4125            targets.push((pos, expr));
4126        }
4127        // v7.17.0 Phase 2.1 — for every column with an
4128        // `ON UPDATE CURRENT_TIMESTAMP` binding that the caller
4129        // did NOT explicitly set, schedule an automatic override.
4130        // Reuses `eval_runtime_default_free` so the same
4131        // canonical runtime-expression whitelist (now /
4132        // current_timestamp / current_date / …) governs both
4133        // DEFAULT and ON UPDATE.
4134        let mut on_update_overrides: Vec<(usize, String)> = Vec::new();
4135        for (i, col) in schema_cols.iter().enumerate() {
4136            if targets.iter().any(|(p, _)| *p == i) {
4137                continue;
4138            }
4139            if let Some(src) = &col.on_update_runtime {
4140                on_update_overrides.push((i, src.clone()));
4141            }
4142        }
4143        let ctx = EvalContext::new(&schema_cols, Some(stmt.table.as_str()))
4144            .with_default_text_search_config(ts_cfg.as_deref());
4145        // Walk candidate rows, evaluate WHERE then SET
4146        // expressions. We gather (position, new_values) tuples
4147        // first and apply them afterwards so the WHERE/RHS
4148        // evaluation reads the original row state — matches PG
4149        // semantics (UPDATE doesn't see its own writes).
4150        //
4151        // v7.20 P4 — index seek: a single-column equality WHERE
4152        // on an indexed column narrows the walk from
4153        // O(table.rows()) to O(matches). The full WHERE still
4154        // re-evaluates per candidate (the seek may be an
4155        // over-approximation under AND-composites), so semantics
4156        // are unchanged. profile: the bench's `UPDATE … WHERE
4157        // id = $1` on a 5 000-row table was a ~1.3 ms full scan
4158        // per statement; with the seek it's ~2 µs.
4159        let seek_positions: Option<Vec<usize>> = stmt
4160            .where_
4161            .as_ref()
4162            .and_then(|w| try_index_seek_positions(w, &schema_cols, table, stmt.table.as_str()));
4163        let mut planned: Vec<(usize, Vec<Value>)> = Vec::new();
4164        let candidate_positions: Vec<usize> = match &seek_positions {
4165            Some(list) => list.clone(),
4166            None => (0..table.row_count()).collect(),
4167        };
4168        for (loop_n, &i) in candidate_positions.iter().enumerate() {
4169            // v4.5: cooperative cancel checkpoint every 256 rows so
4170            // a runaway UPDATE without WHERE doesn't drag past the
4171            // server's query-timeout watchdog.
4172            if loop_n.is_multiple_of(256) {
4173                cancel.check()?;
4174            }
4175            let Some(row) = table.rows().get(i) else {
4176                continue;
4177            };
4178            if let Some(w) = &stmt.where_ {
4179                let cond = eval::eval_expr(w, row, &ctx)?;
4180                if !matches!(cond, Value::Bool(true)) {
4181                    continue;
4182                }
4183            }
4184            let mut new_vals = row.values.clone();
4185            for (pos, expr) in &targets {
4186                let v = eval::eval_expr(expr, row, &ctx)?;
4187                let coerced = coerce_value(v, schema_cols[*pos].ty, &schema_cols[*pos].name, *pos)?;
4188                check_unsigned_range(&coerced, &schema_cols[*pos], *pos)?;
4189                new_vals[*pos] = coerced;
4190            }
4191            // v7.17.0 Phase 2.1 — apply ON UPDATE overrides for
4192            // any column the SET clause didn't touch.
4193            for (pos, src) in &on_update_overrides {
4194                let v = eval_runtime_default_free(src, schema_cols[*pos].ty, clock_for_on_update)?;
4195                new_vals[*pos] = v;
4196            }
4197            planned.push((i, new_vals));
4198        }
4199        // planned must stay position-sorted: downstream passes
4200        // (FK pairing, trigger walks, the apply loop) iterate it
4201        // assuming ascending row order, which the full-scan path
4202        // guaranteed implicitly.
4203        planned.sort_by_key(|(i, _)| *i);
4204        // v7.6.6 — capture pre-update row values for the FK
4205        // enforcement passes below. `planned` carries new values
4206        // only; pair them with the old row.
4207        let plan_with_old: Vec<(usize, Vec<Value>, Vec<Value>)> = planned
4208            .iter()
4209            .map(|(pos, new_vals)| (*pos, table.rows()[*pos].values.clone(), new_vals.clone()))
4210            .collect();
4211        let self_fks = table.schema().foreign_keys.clone();
4212        // v7.12.5 — `affected` is computed post-BEFORE-trigger
4213        // below (triggers may RETURN NULL to skip individual
4214        // rows). The pre-trigger len shape is no longer accurate.
4215        // Release mutable borrow on `table` for the FK passes.
4216        let _ = table;
4217        // v7.6.6 — Stage 2a: outbound FK check. For every row whose
4218        // local FK columns changed, the new value must exist in the
4219        // parent.
4220        if !self_fks.is_empty() {
4221            let new_rows: Vec<Vec<Value>> = planned
4222                .iter()
4223                .map(|(_pos, new_vals)| new_vals.clone())
4224                .collect();
4225            enforce_fk_inserts(self.active_catalog(), &stmt.table, &self_fks, &new_rows)?;
4226        }
4227        // v7.13.0 — CHECK constraint enforcement on UPDATE
4228        // (mailrs round-5 G3). Predicates evaluated against the
4229        // candidate post-UPDATE row; false rejects the UPDATE.
4230        {
4231            let new_rows: Vec<Vec<Value>> = planned
4232                .iter()
4233                .map(|(_pos, new_vals)| new_vals.clone())
4234                .collect();
4235            enforce_check_constraints(self.active_catalog(), &stmt.table, &new_rows)?;
4236        }
4237        // v7.6.6 — Stage 2b: inbound FK check. For every row that
4238        // changed value in a column that *some other table* uses as
4239        // a FK parent column, react per `on_update` action.
4240        let child_plan =
4241            plan_fk_parent_updates(self.active_catalog(), &stmt.table, &plan_with_old)?;
4242        // Stage 3a — apply each child-side action.
4243        for step in &child_plan {
4244            apply_fk_child_step(self.active_catalog_mut(), step)?;
4245        }
4246        // Stage 3b — apply the original UPDATE.
4247        let table = self
4248            .active_catalog_mut()
4249            .get_mut(&stmt.table)
4250            .ok_or_else(|| {
4251                EngineError::Storage(StorageError::TableNotFound {
4252                    name: stmt.table.clone(),
4253                })
4254            })?;
4255        // v7.12.5 — fire BEFORE/AFTER UPDATE row-level triggers
4256        // around the apply loop. BEFORE sees NEW=candidate +
4257        // OLD=current; may rewrite NEW or RETURN NULL to skip.
4258        // AFTER sees NEW=post-write + OLD=pre-write (both read-
4259        // only).
4260        //
4261        // Filter `planned` through the BEFORE pass first so the
4262        // RETURNING snapshot reflects what actually got written
4263        // (triggers may rewrite cells, including a cancellation).
4264        let mut applied_after_before: Vec<(usize, Row, Row)> = Vec::with_capacity(planned.len());
4265        // v7.12.7 — embedded SQL queue.
4266        let mut deferred_embedded: Vec<triggers::DeferredEmbeddedStmt> = Vec::new();
4267        for (pos, new_vals) in &planned {
4268            let old_row = table.rows()[*pos].clone();
4269            let mut new_row = Row::new(new_vals.clone());
4270            let mut skip = false;
4271            for (fd, filter) in &before_update_triggers {
4272                // v7.13.0 — `UPDATE OF cols` filter (mailrs round-5
4273                // G7). Skip this trigger when the filter is set and
4274                // no listed column actually differs between OLD and
4275                // NEW for this row.
4276                if !filter.is_empty()
4277                    && !any_column_changed(filter, &schema_cols, &old_row, &new_row)
4278                {
4279                    continue;
4280                }
4281                let (outcome, deferred) = triggers::fire_row_trigger(
4282                    fd,
4283                    Some(new_row.clone()),
4284                    Some(&old_row),
4285                    &stmt.table,
4286                    &schema_cols,
4287                    &[],
4288                    trigger_session_cfg.as_deref(),
4289                    false,
4290                )
4291                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
4292                deferred_embedded.extend(deferred);
4293                match outcome {
4294                    triggers::TriggerOutcome::Row(r) => new_row = r,
4295                    triggers::TriggerOutcome::Skip => {
4296                        skip = true;
4297                        break;
4298                    }
4299                }
4300            }
4301            if !skip {
4302                applied_after_before.push((*pos, new_row, old_row));
4303            }
4304        }
4305        // v7.9.4 — snapshot post-update values for RETURNING (post-
4306        // BEFORE-trigger because triggers can rewrite cells).
4307        let updated_for_returning: Vec<Vec<Value>> = if stmt.returning.is_some() {
4308            applied_after_before
4309                .iter()
4310                .map(|(_pos, new_row, _old)| new_row.values.clone())
4311                .collect()
4312        } else {
4313            Vec::new()
4314        };
4315        let affected = applied_after_before.len();
4316        // Apply, then fire AFTER triggers per row. AFTER runs read-
4317        // only against the freshly-written row; v7.12.4-shape
4318        // assignment errors with a clear message.
4319        for (pos, new_row, old_row) in applied_after_before {
4320            table.update_row(pos, new_row.values.clone())?;
4321            for (fd, filter) in &after_update_triggers {
4322                if !filter.is_empty()
4323                    && !any_column_changed(filter, &schema_cols, &old_row, &new_row)
4324                {
4325                    continue;
4326                }
4327                let (_outcome, deferred) = triggers::fire_row_trigger(
4328                    fd,
4329                    Some(new_row.clone()),
4330                    Some(&old_row),
4331                    &stmt.table,
4332                    &schema_cols,
4333                    &[],
4334                    trigger_session_cfg.as_deref(),
4335                    true,
4336                )
4337                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
4338                deferred_embedded.extend(deferred);
4339            }
4340        }
4341        let _ = table;
4342        // v7.12.7 — drain trigger-emitted embedded SQL for this UPDATE.
4343        self.execute_deferred_trigger_stmts(deferred_embedded, cancel)?;
4344        // v6.2.1 — auto-analyze modified-row tracking for UPDATE.
4345        if !self.in_transaction() && affected > 0 {
4346            self.statistics
4347                .record_modifications(&stmt.table, affected as u64);
4348        }
4349        // v7.9.4 — RETURNING projection.
4350        if let Some(items) = &stmt.returning {
4351            return self.build_returning_rows(&stmt.table, items, updated_for_returning);
4352        }
4353        Ok(QueryResult::CommandOk {
4354            affected,
4355            modified_catalog: !self.in_transaction(),
4356        })
4357    }
4358
4359    /// v4.4 `DELETE FROM <table> [WHERE cond]`. Collects matching
4360    /// positions then delegates to `Table::delete_rows` (single index
4361    /// rebuild for the batch).
4362    /// v7.17.0 Phase 3.P0-42 — SQL:2003 / PG 15+ `MERGE` execution.
4363    ///
4364    /// Semantics:
4365    ///   * Resolve `target` and `source` tables (catalog reads).
4366    ///   * Build a combined `(target_alias.col, source_alias.col)`
4367    ///     schema so the ON / WHEN AND / SET / VALUES expressions
4368    ///     resolve through the standard qualifier-aware resolver.
4369    ///   * Pass 1: walk every source row × every target hot row,
4370    ///     evaluate ON, then pick the first WHEN clause that fits
4371    ///     (`Matched` if any target row matched, `NotMatched`
4372    ///     otherwise; AND-condition must hold). Collect the action
4373    ///     plan as `(deletes, updates, inserts)` so the apply pass
4374    ///     reads the original target row state.
4375    ///   * Pass 2: apply the plan against the target's mutable row
4376    ///     vector. Deletes execute by index in descending order so
4377    ///     earlier indices remain stable; updates next; inserts
4378    ///     last (matching PG's "INSERT branch sees the post-delete
4379    ///     state" behaviour for the common upsert shape).
4380    ///
4381    /// v7.17 simplifications (documented limitations):
4382    ///   * No triggers / WAL plumbing (MVP); MERGE rows don't fire
4383    ///     INSERT / UPDATE / DELETE row triggers in v7.17.
4384    ///   * No cardinality check (PG-canonical: "MERGE command
4385    ///     cannot affect row a second time" — SPG silently applies
4386    ///     the last action for a target row covered twice).
4387    ///   * Source must be a catalog-resolvable table (no subquery
4388    ///     source); RETURNING / BY SOURCE / BY TARGET unsupported.
4389    fn exec_merge_cancel(
4390        &mut self,
4391        stmt: &spg_sql::ast::MergeStatement,
4392        cancel: CancelToken<'_>,
4393    ) -> Result<QueryResult, EngineError> {
4394        let target_alias = stmt
4395            .target_alias
4396            .clone()
4397            .unwrap_or_else(|| stmt.target.clone());
4398        let source_alias = stmt
4399            .source_alias
4400            .clone()
4401            .unwrap_or_else(|| stmt.source.clone());
4402        let (target_cols, target_rows_snapshot) = {
4403            let t = self.active_catalog().get(&stmt.target).ok_or_else(|| {
4404                EngineError::Storage(StorageError::TableNotFound {
4405                    name: stmt.target.clone(),
4406                })
4407            })?;
4408            (
4409                t.schema().columns.clone(),
4410                t.rows().iter().cloned().collect::<Vec<Row>>(),
4411            )
4412        };
4413        let (source_cols, source_rows) = {
4414            let s = self.active_catalog().get(&stmt.source).ok_or_else(|| {
4415                EngineError::Storage(StorageError::TableNotFound {
4416                    name: stmt.source.clone(),
4417                })
4418            })?;
4419            (
4420                s.schema().columns.clone(),
4421                s.rows().iter().cloned().collect::<Vec<Row>>(),
4422            )
4423        };
4424        // Composite schema: target_alias.col ... source_alias.col ...
4425        let mut combined_schema: Vec<ColumnSchema> = Vec::new();
4426        for col in &target_cols {
4427            combined_schema.push(ColumnSchema::new(
4428                alloc::format!("{target_alias}.{}", col.name),
4429                col.ty,
4430                col.nullable,
4431            ));
4432        }
4433        for col in &source_cols {
4434            combined_schema.push(ColumnSchema::new(
4435                alloc::format!("{source_alias}.{}", col.name),
4436                col.ty,
4437                col.nullable,
4438            ));
4439        }
4440        let combined_ctx = EvalContext::new(&combined_schema, None);
4441        // Source-only context for WHEN NOT MATCHED actions (no
4442        // matched target row exists — the source-side qualified
4443        // columns must still resolve).
4444        let mut source_only_schema: Vec<ColumnSchema> = Vec::new();
4445        for col in &target_cols {
4446            source_only_schema.push(ColumnSchema::new(
4447                alloc::format!("{target_alias}.{}", col.name),
4448                col.ty,
4449                col.nullable,
4450            ));
4451        }
4452        for col in &source_cols {
4453            source_only_schema.push(ColumnSchema::new(
4454                alloc::format!("{source_alias}.{}", col.name),
4455                col.ty,
4456                col.nullable,
4457            ));
4458        }
4459        let source_only_ctx = EvalContext::new(&source_only_schema, None);
4460        let target_arity = target_cols.len();
4461        let source_arity = source_cols.len();
4462
4463        // Resolve INSERT column positions once (validate names).
4464        // For each clause that's an INSERT, map column names → target positions.
4465        let mut delete_indices: Vec<usize> = Vec::new();
4466        let mut updates: Vec<(usize, Vec<Value>)> = Vec::new();
4467        let mut inserts: Vec<Vec<Value>> = Vec::new();
4468        let mut affected: usize = 0;
4469
4470        for (src_idx, src_row) in source_rows.iter().enumerate() {
4471            if src_idx.is_multiple_of(256) {
4472                cancel.check()?;
4473            }
4474            // Find every matched target index (per the ON predicate).
4475            let mut matched_targets: Vec<usize> = Vec::new();
4476            for (t_idx, t_row) in target_rows_snapshot.iter().enumerate() {
4477                let mut combined_vals = t_row.values.clone();
4478                combined_vals.extend(src_row.values.iter().cloned());
4479                let combined_row = Row::new(combined_vals);
4480                let cond = eval::eval_expr(&stmt.on, &combined_row, &combined_ctx)?;
4481                if matches!(cond, Value::Bool(true)) {
4482                    matched_targets.push(t_idx);
4483                }
4484            }
4485            let is_matched = !matched_targets.is_empty();
4486            // Pick the first WHEN clause whose kind agrees with
4487            // `is_matched` and whose AND condition (if any) holds.
4488            // AND condition for MATCHED: evaluated against the
4489            // first matched target row × source. For NOT MATCHED:
4490            // evaluated with target side NULL-padded.
4491            let fired_clause = stmt.clauses.iter().find(|c| {
4492                let kind_ok = match c.matched {
4493                    spg_sql::ast::MergeMatched::Matched => is_matched,
4494                    spg_sql::ast::MergeMatched::NotMatched => !is_matched,
4495                };
4496                if !kind_ok {
4497                    return false;
4498                }
4499                let Some(cond_expr) = &c.condition else {
4500                    return true;
4501                };
4502                let row = if is_matched {
4503                    let t = &target_rows_snapshot[matched_targets[0]];
4504                    let mut vals = t.values.clone();
4505                    vals.extend(src_row.values.iter().cloned());
4506                    Row::new(vals)
4507                } else {
4508                    let mut vals: Vec<Value> = (0..target_arity).map(|_| Value::Null).collect();
4509                    vals.extend(src_row.values.iter().cloned());
4510                    Row::new(vals)
4511                };
4512                let ctx_ref = if is_matched {
4513                    &combined_ctx
4514                } else {
4515                    &source_only_ctx
4516                };
4517                matches!(
4518                    eval::eval_expr(cond_expr, &row, ctx_ref),
4519                    Ok(Value::Bool(true))
4520                )
4521            });
4522            let Some(clause) = fired_clause else { continue };
4523            match &clause.action {
4524                spg_sql::ast::MergeAction::DoNothing => {}
4525                spg_sql::ast::MergeAction::Delete => {
4526                    for &t_idx in &matched_targets {
4527                        if !delete_indices.contains(&t_idx) {
4528                            delete_indices.push(t_idx);
4529                            affected += 1;
4530                        }
4531                    }
4532                }
4533                spg_sql::ast::MergeAction::Update { assignments } => {
4534                    // Pre-resolve SET targets to target column positions.
4535                    let mut planned_sets: Vec<(usize, &Expr)> =
4536                        Vec::with_capacity(assignments.len());
4537                    for (col, expr) in assignments {
4538                        let pos =
4539                            target_cols
4540                                .iter()
4541                                .position(|c| c.name == *col)
4542                                .ok_or_else(|| {
4543                                    EngineError::Eval(EvalError::ColumnNotFound {
4544                                        name: col.clone(),
4545                                    })
4546                                })?;
4547                        planned_sets.push((pos, expr));
4548                    }
4549                    for &t_idx in &matched_targets {
4550                        let t_row = &target_rows_snapshot[t_idx];
4551                        let mut new_values = t_row.values.clone();
4552                        let mut combined_vals = t_row.values.clone();
4553                        combined_vals.extend(src_row.values.iter().cloned());
4554                        let combined_row = Row::new(combined_vals);
4555                        for (pos, expr) in &planned_sets {
4556                            let raw = eval::eval_expr(expr, &combined_row, &combined_ctx)?;
4557                            let coerced = coerce_value(
4558                                raw,
4559                                target_cols[*pos].ty,
4560                                &target_cols[*pos].name,
4561                                *pos,
4562                            )?;
4563                            new_values[*pos] = coerced;
4564                        }
4565                        updates.push((t_idx, new_values));
4566                        affected += 1;
4567                    }
4568                }
4569                spg_sql::ast::MergeAction::Insert { columns, values } => {
4570                    // For INSERT NOT MATCHED, target side is NULL-padded.
4571                    let mut vals: Vec<Value> = (0..target_arity).map(|_| Value::Null).collect();
4572                    vals.extend(src_row.values.iter().cloned());
4573                    let synth_row = Row::new(vals);
4574                    let mut new_row_values: Vec<Value> =
4575                        (0..target_arity).map(|_| Value::Null).collect();
4576                    for (col, expr) in columns.iter().zip(values.iter()) {
4577                        let pos =
4578                            target_cols
4579                                .iter()
4580                                .position(|c| c.name == *col)
4581                                .ok_or_else(|| {
4582                                    EngineError::Eval(EvalError::ColumnNotFound {
4583                                        name: col.clone(),
4584                                    })
4585                                })?;
4586                        let raw = eval::eval_expr(expr, &synth_row, &source_only_ctx)?;
4587                        let coerced =
4588                            coerce_value(raw, target_cols[pos].ty, &target_cols[pos].name, pos)?;
4589                        new_row_values[pos] = coerced;
4590                    }
4591                    inserts.push(new_row_values);
4592                    affected += 1;
4593                }
4594            }
4595        }
4596        let _ = source_arity; // captured for symmetry; cancellation cost negligible.
4597
4598        // Apply the plan to the target table.
4599        let table = self
4600            .active_catalog_mut()
4601            .get_mut(&stmt.target)
4602            .ok_or_else(|| {
4603                EngineError::Storage(StorageError::TableNotFound {
4604                    name: stmt.target.clone(),
4605                })
4606            })?;
4607        // Apply updates first (in-place), then deletes (one batch),
4608        // then inserts. The storage API uses `update_row(pos,
4609        // new_values)`, `delete_rows(&[positions])`, and `insert(row)`.
4610        for (idx, new_vals) in &updates {
4611            table
4612                .update_row(*idx, new_vals.clone())
4613                .map_err(EngineError::Storage)?;
4614        }
4615        if !delete_indices.is_empty() {
4616            table.delete_rows(&delete_indices);
4617        }
4618        for vals in inserts {
4619            table.insert(Row::new(vals)).map_err(EngineError::Storage)?;
4620        }
4621        Ok(QueryResult::CommandOk {
4622            affected,
4623            modified_catalog: affected > 0,
4624        })
4625    }
4626
4627    fn exec_delete_cancel(
4628        &mut self,
4629        stmt: &spg_sql::ast::DeleteStatement,
4630        cancel: CancelToken<'_>,
4631    ) -> Result<QueryResult, EngineError> {
4632        // v7.12.5 — snapshot BEFORE/AFTER DELETE row triggers + the
4633        // session FTS config before the mut borrow (same shape as
4634        // INSERT / UPDATE).
4635        let before_delete_triggers = self.snapshot_row_triggers(&stmt.table, "DELETE", "BEFORE");
4636        let after_delete_triggers = self.snapshot_row_triggers(&stmt.table, "DELETE", "AFTER");
4637        let trigger_session_cfg: Option<String> = self
4638            .session_params
4639            .get("default_text_search_config")
4640            .cloned();
4641        // v5.2.3: PK-targeted DELETE → first retire any cold-tier
4642        // locator for the key. The cold row body stays in the
4643        // segment (becoming shadowed garbage that a future
4644        // compaction pass reclaims) but the index no longer
4645        // resolves it. The shadow count contributes to the
4646        // affected total; the subsequent hot walk handles any hot
4647        // rows for the same key.
4648        let mut cold_shadow_count: usize = 0;
4649        if let Some(w) = &stmt.where_ {
4650            let schema_cols = self
4651                .active_catalog()
4652                .get(&stmt.table)
4653                .ok_or_else(|| {
4654                    EngineError::Storage(StorageError::TableNotFound {
4655                        name: stmt.table.clone(),
4656                    })
4657                })?
4658                .schema()
4659                .columns
4660                .clone();
4661            if let Some((col_pos, key)) = try_pk_predicate(w, &schema_cols, stmt.table.as_str())
4662                && let Some(idx_name) = self
4663                    .active_catalog()
4664                    .get(&stmt.table)
4665                    .and_then(|t| t.index_on(col_pos).map(|i| i.name.clone()))
4666            {
4667                cold_shadow_count = self
4668                    .active_catalog_mut()
4669                    .shadow_cold_row(&stmt.table, &idx_name, &key)
4670                    .unwrap_or(0);
4671            }
4672        }
4673
4674        // v7.12.1 — cache the session FTS config as an owned
4675        // String before the mutable table borrow below; the
4676        // ctx-builder then references it via `as_deref` so the
4677        // immutable read of `session_params` doesn't conflict
4678        // with the mut borrow chain.
4679        let ts_cfg: Option<String> = self
4680            .session_param("default_text_search_config")
4681            .map(String::from);
4682        let table = self
4683            .active_catalog_mut()
4684            .get_mut(&stmt.table)
4685            .ok_or_else(|| {
4686                EngineError::Storage(StorageError::TableNotFound {
4687                    name: stmt.table.clone(),
4688                })
4689            })?;
4690        let schema_cols: Vec<ColumnSchema> = table.schema().columns.clone();
4691        let ctx = EvalContext::new(&schema_cols, Some(stmt.table.as_str()))
4692            .with_default_text_search_config(ts_cfg.as_deref());
4693        let mut positions: Vec<usize> = Vec::new();
4694        // v7.6.3 — collect every to-delete row's full Value tuple
4695        // alongside its position, so the FK enforcement pass can
4696        // run after the mut borrow drops.
4697        let mut to_delete_rows: Vec<Vec<Value>> = Vec::new();
4698        // v7.20 P4 — index seek (same shape as exec_update_cancel):
4699        // an equality WHERE on an indexed column narrows the walk
4700        // to the matching hot positions; the full WHERE still
4701        // re-evaluates per candidate. Downstream passes assume
4702        // ascending position order, so the seek result is sorted.
4703        let seek_positions: Option<Vec<usize>> = stmt
4704            .where_
4705            .as_ref()
4706            .and_then(|w| try_index_seek_positions(w, &schema_cols, table, stmt.table.as_str()));
4707        let candidate_positions: Vec<usize> = match seek_positions {
4708            Some(mut list) => {
4709                list.sort_unstable();
4710                list
4711            }
4712            None => (0..table.row_count()).collect(),
4713        };
4714        for (loop_n, &i) in candidate_positions.iter().enumerate() {
4715            if loop_n.is_multiple_of(256) {
4716                cancel.check()?;
4717            }
4718            let Some(row) = table.rows().get(i) else {
4719                continue;
4720            };
4721            let keep = if let Some(w) = &stmt.where_ {
4722                let cond = eval::eval_expr(w, row, &ctx)?;
4723                !matches!(cond, Value::Bool(true))
4724            } else {
4725                false
4726            };
4727            if !keep {
4728                positions.push(i);
4729                to_delete_rows.push(row.values.clone());
4730            }
4731        }
4732        // v7.6.3 / v7.6.4 — Stage 2: FK enforcement on the immutable
4733        // catalog. Release the mut borrow and run reverse-scan
4734        // against every child table whose FK targets this table.
4735        // RESTRICT / NoAction raise an error; CASCADE returns a
4736        // cascade plan that stage 3 applies after the primary delete.
4737        // SET NULL / SET DEFAULT remain Unsupported until v7.6.5.
4738        let _ = table;
4739        // v7.12.5 — BEFORE DELETE row-level triggers. Each fires
4740        // with NEW=None / OLD=pre-delete row; RETURN OLD (or NEW)
4741        // = proceed, RETURN NULL = skip the row entirely. The
4742        // filter must run BEFORE the FK cascade plan so cascaded
4743        // child rows track the trigger's skip-decision on the
4744        // parent.
4745        // v7.12.7 — embedded SQL queue.
4746        let mut deferred_embedded: Vec<triggers::DeferredEmbeddedStmt> = Vec::new();
4747        if !before_delete_triggers.is_empty() {
4748            let mut filtered_positions: Vec<usize> = Vec::with_capacity(positions.len());
4749            let mut filtered_old_rows: Vec<Vec<Value>> = Vec::with_capacity(to_delete_rows.len());
4750            for (pos, old_vals) in positions.iter().zip(to_delete_rows.iter()) {
4751                let old_row = Row::new(old_vals.clone());
4752                let mut cancel_this = false;
4753                for fd in &before_delete_triggers {
4754                    let (outcome, deferred) = triggers::fire_row_trigger(
4755                        fd,
4756                        None,
4757                        Some(&old_row),
4758                        &stmt.table,
4759                        &schema_cols,
4760                        &[],
4761                        trigger_session_cfg.as_deref(),
4762                        false,
4763                    )
4764                    .map_err(|e| {
4765                        EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}")))
4766                    })?;
4767                    deferred_embedded.extend(deferred);
4768                    if matches!(outcome, triggers::TriggerOutcome::Skip) {
4769                        cancel_this = true;
4770                        break;
4771                    }
4772                }
4773                if !cancel_this {
4774                    filtered_positions.push(*pos);
4775                    filtered_old_rows.push(old_vals.clone());
4776                }
4777            }
4778            positions = filtered_positions;
4779            to_delete_rows = filtered_old_rows;
4780        }
4781        let cascade_plan = plan_fk_parent_deletions(
4782            self.active_catalog(),
4783            &stmt.table,
4784            &positions,
4785            &to_delete_rows,
4786        )?;
4787        // Stage 3a — apply each FK child step (SET NULL / SET
4788        // DEFAULT / CASCADE delete) before deleting the parent.
4789        // The plan is already ordered: nulls/defaults first, then
4790        // cascade deletes (so a row mutated and later deleted
4791        // surfaces as deleted — though v7.6.5 doesn't produce
4792        // that overlap today).
4793        for step in &cascade_plan {
4794            apply_fk_child_step(self.active_catalog_mut(), step)?;
4795        }
4796        // Stage 3b — actually delete the original target rows.
4797        let table = self
4798            .active_catalog_mut()
4799            .get_mut(&stmt.table)
4800            .ok_or_else(|| {
4801                EngineError::Storage(StorageError::TableNotFound {
4802                    name: stmt.table.clone(),
4803                })
4804            })?;
4805        let affected = table.delete_rows(&positions) + cold_shadow_count;
4806        let _ = table;
4807        // v7.12.5 — AFTER DELETE row-level triggers fire post-write
4808        // with NEW=None / OLD=pre-delete row (each from the
4809        // already-snapshotted to_delete_rows). Return value is
4810        // ignored (matches PG AFTER semantics).
4811        if !after_delete_triggers.is_empty() {
4812            for old_vals in &to_delete_rows {
4813                let old_row = Row::new(old_vals.clone());
4814                for fd in &after_delete_triggers {
4815                    let (_outcome, deferred) = triggers::fire_row_trigger(
4816                        fd,
4817                        None,
4818                        Some(&old_row),
4819                        &stmt.table,
4820                        &schema_cols,
4821                        &[],
4822                        trigger_session_cfg.as_deref(),
4823                        true,
4824                    )
4825                    .map_err(|e| {
4826                        EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}")))
4827                    })?;
4828                    deferred_embedded.extend(deferred);
4829                }
4830            }
4831        }
4832        // v7.12.7 — drain trigger-emitted embedded SQL for this DELETE.
4833        self.execute_deferred_trigger_stmts(deferred_embedded, cancel)?;
4834        // v6.2.1 — auto-analyze modified-row tracking for DELETE.
4835        if !self.in_transaction() && affected > 0 {
4836            self.statistics
4837                .record_modifications(&stmt.table, affected as u64);
4838        }
4839        // v7.9.4 — RETURNING projection over the soon-to-be-gone
4840        // rows. `to_delete_rows` was snapshotted in stage 1 before
4841        // mutation, so the projection sees the pre-delete state
4842        // (matches PG semantics: DELETE RETURNING returns the row
4843        // as it was just before removal).
4844        if let Some(items) = &stmt.returning {
4845            return self.build_returning_rows(&stmt.table, items, to_delete_rows);
4846        }
4847        Ok(QueryResult::CommandOk {
4848            affected,
4849            modified_catalog: !self.in_transaction(),
4850        })
4851    }
4852
4853    /// `SHOW TABLES` — one row per table in the active catalog.
4854    /// Column name is `name` so result-set consumers can downstream
4855    /// `SELECT name FROM ...` style logic if needed.
4856    /// v4.26: `EXPLAIN [ANALYZE] <select>`. Returns a single-column
4857    /// `QUERY PLAN` text table — first line names the top operator
4858    /// (Scan / Aggregate / Window / etc.), indented children list
4859    /// FROM joins, WHERE filters, ORDER BY / LIMIT, projection
4860    /// shape, and any active index hits. `ANALYZE` execs the inner
4861    /// SELECT and appends actual-row + elapsed-micros annotations.
4862    #[allow(clippy::format_push_string)]
4863    fn exec_explain(
4864        &self,
4865        e: &spg_sql::ast::ExplainStatement,
4866        cancel: CancelToken<'_>,
4867    ) -> Result<QueryResult, EngineError> {
4868        let mut lines = Vec::<String>::new();
4869        explain_select(&e.inner, self, 0, &mut lines);
4870        if e.suggest {
4871            // v6.8.3 — index advisor. Walks the SELECT's FROM
4872            // tables + WHERE column refs; for each (table, column)
4873            // pair that lacks an index, append a SUGGEST line with
4874            // a copy-pastable `CREATE INDEX` statement. This is a
4875            // pure-syntax heuristic — no cardinality estimation —
4876            // matching the v6.8.3 design intent of "tell the
4877            // operator where indexes are missing", not "give the
4878            // mathematically optimal index set".
4879            let suggestions = build_index_suggestions(&e.inner, self);
4880            for s in suggestions {
4881                lines.push(s);
4882            }
4883        } else if e.analyze {
4884            // v6.2.4 — EXPLAIN ANALYZE annotates each operator line
4885            // with `(rows=N)` where the row count is computable
4886            // without re-executing the full query:
4887            //   - Top-level operator (first non-indented line):
4888            //     rows = final result.len()
4889            //   - "From: <table> [full scan]" lines: rows =
4890            //     table.rows().len() (catalog read; no execution)
4891            //   - "From: <table> [index seek]": indeterminate —
4892            //     the index step would need re-execution; v6.2.5
4893            //     adds per-operator wall-clock + hot/cold rows
4894            //     instrumentation that makes this concrete.
4895            //   - Everything else: marked `(—)` so the surface
4896            //     stays well-defined without silently dropping
4897            //     stats. v6.2.5 fills in via inline executor
4898            //     instrumentation.
4899            // Total elapsed lands on a trailing `Total: …` line.
4900            let started = self.clock.map(|f| f());
4901            let exec = self.exec_select_cancel(&e.inner, cancel)?;
4902            let elapsed_micros = match (self.clock, started) {
4903                (Some(f), Some(s)) => Some(f().saturating_sub(s)),
4904                _ => None,
4905            };
4906            let row_count = if let QueryResult::Rows { rows, .. } = &exec {
4907                rows.len()
4908            } else {
4909                0
4910            };
4911            annotate_explain_lines(&mut lines, row_count, self);
4912            let mut total = alloc::format!("Total: rows={row_count}");
4913            if let Some(us) = elapsed_micros {
4914                total.push_str(&alloc::format!(" elapsed={us}us"));
4915            }
4916            lines.push(total);
4917        }
4918        let columns = alloc::vec![ColumnSchema::new("QUERY PLAN", DataType::Text, false)];
4919        let rows: Vec<Row> = lines
4920            .into_iter()
4921            .map(|l| Row::new(alloc::vec![Value::Text(l)]))
4922            .collect();
4923        Ok(QueryResult::Rows { columns, rows })
4924    }
4925
4926    fn exec_show_tables(&self) -> QueryResult {
4927        let columns = alloc::vec![ColumnSchema::new("name", DataType::Text, false)];
4928        let rows: Vec<Row> = self
4929            .active_catalog()
4930            .table_names()
4931            .into_iter()
4932            .map(|n| Row::new(alloc::vec![Value::Text(n)]))
4933            .collect();
4934        QueryResult::Rows { columns, rows }
4935    }
4936
4937    /// v7.17.0 Phase 3.P0-59 — `SHOW CREATE TABLE <t>`. Synthesise
4938    /// a minimal MySQL-flavoured CREATE TABLE DDL from the
4939    /// catalog's TableSchema so mysqldump round-trips load against
4940    /// SPG without splitting init scripts.
4941    fn exec_show_create_table(&self, name: &str) -> Result<QueryResult, EngineError> {
4942        let t = self.active_catalog().get(name).ok_or_else(|| {
4943            EngineError::Storage(StorageError::TableNotFound { name: name.into() })
4944        })?;
4945        let cols: Vec<String> = t
4946            .schema()
4947            .columns
4948            .iter()
4949            .map(|c| {
4950                let ty = render_data_type(c.ty);
4951                let nullable = if c.nullable { "" } else { " NOT NULL" };
4952                alloc::format!("  `{}` {}{}", c.name, ty, nullable)
4953            })
4954            .collect();
4955        let mut body = cols.join(",\n");
4956        // Append UNIQUE / PRIMARY KEY clauses.
4957        for uc in &t.schema().uniqueness_constraints {
4958            let col_names: Vec<String> = uc
4959                .columns
4960                .iter()
4961                .map(|&p| {
4962                    t.schema().columns.get(p).map_or_else(
4963                        || alloc::format!("col{p}"),
4964                        |c| alloc::format!("`{}`", c.name),
4965                    )
4966                })
4967                .collect();
4968            let kw = if uc.is_primary_key {
4969                "PRIMARY KEY"
4970            } else {
4971                "UNIQUE KEY"
4972            };
4973            body.push_str(",\n  ");
4974            body.push_str(&alloc::format!("{kw} ({})", col_names.join(", ")));
4975        }
4976        // Foreign keys.
4977        for fk in &t.schema().foreign_keys {
4978            let local: Vec<String> = fk
4979                .local_columns
4980                .iter()
4981                .map(|&p| {
4982                    t.schema().columns.get(p).map_or_else(
4983                        || alloc::format!("col{p}"),
4984                        |c| alloc::format!("`{}`", c.name),
4985                    )
4986                })
4987                .collect();
4988            let parent_cols: Vec<String> =
4989                if let Some(parent) = self.active_catalog().get(&fk.parent_table) {
4990                    fk.parent_columns
4991                        .iter()
4992                        .map(|&p| {
4993                            parent.schema().columns.get(p).map_or_else(
4994                                || alloc::format!("col{p}"),
4995                                |c| alloc::format!("`{}`", c.name),
4996                            )
4997                        })
4998                        .collect()
4999                } else {
5000                    fk.parent_columns
5001                        .iter()
5002                        .map(|p| alloc::format!("col{p}"))
5003                        .collect()
5004                };
5005            body.push_str(",\n  ");
5006            body.push_str(&alloc::format!(
5007                "FOREIGN KEY ({}) REFERENCES `{}` ({})",
5008                local.join(", "),
5009                fk.parent_table,
5010                parent_cols.join(", ")
5011            ));
5012        }
5013        let ddl = alloc::format!(
5014            "CREATE TABLE `{}` (\n{}\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
5015            name,
5016            body
5017        );
5018        let columns = alloc::vec![
5019            ColumnSchema::new("Table", DataType::Text, false),
5020            ColumnSchema::new("Create Table", DataType::Text, false),
5021        ];
5022        let rows = alloc::vec![Row::new(alloc::vec![
5023            Value::Text(name.into()),
5024            Value::Text(ddl),
5025        ])];
5026        Ok(QueryResult::Rows { columns, rows })
5027    }
5028
5029    /// v7.17.0 Phase 3.P0-60 — `SHOW INDEXES FROM <t>`. MySQL
5030    /// surface returns one row per (index × column) with 14
5031    /// columns; v7.17 ships the columns admin probes actually
5032    /// filter on: Table, Non_unique, Key_name, Seq_in_index,
5033    /// Column_name, Null, Index_type.
5034    fn exec_show_indexes(&self, name: &str) -> Result<QueryResult, EngineError> {
5035        let t = self.active_catalog().get(name).ok_or_else(|| {
5036            EngineError::Storage(StorageError::TableNotFound { name: name.into() })
5037        })?;
5038        let columns = alloc::vec![
5039            ColumnSchema::new("Table", DataType::Text, false),
5040            ColumnSchema::new("Non_unique", DataType::Int, false),
5041            ColumnSchema::new("Key_name", DataType::Text, false),
5042            ColumnSchema::new("Seq_in_index", DataType::Int, false),
5043            ColumnSchema::new("Column_name", DataType::Text, false),
5044            ColumnSchema::new("Null", DataType::Text, false),
5045            ColumnSchema::new("Index_type", DataType::Text, false),
5046        ];
5047        let mut rows: Vec<Row> = Vec::new();
5048        for idx in t.indices() {
5049            let col = t
5050                .schema()
5051                .columns
5052                .get(idx.column_position)
5053                .map_or("?".into(), |c| c.name.clone());
5054            let nullable = t
5055                .schema()
5056                .columns
5057                .get(idx.column_position)
5058                .map_or(true, |c| c.nullable);
5059            rows.push(Row::new(alloc::vec![
5060                Value::Text(name.into()),
5061                Value::Int(i32::from(!idx.is_unique)),
5062                Value::Text(idx.name.clone()),
5063                Value::Int(1),
5064                Value::Text(col),
5065                Value::Text(if nullable {
5066                    "YES".into()
5067                } else {
5068                    String::new()
5069                }),
5070                Value::Text("BTREE".into()),
5071            ]));
5072        }
5073        Ok(QueryResult::Rows { columns, rows })
5074    }
5075
5076    /// v7.17.0 Phase 3.P0-61 — `SHOW STATUS`. Returns canonical
5077    /// MySQL server-status counters (2-column `(Variable_name,
5078    /// Value)`).
5079    fn exec_show_status(&self) -> QueryResult {
5080        let columns = alloc::vec![
5081            ColumnSchema::new("Variable_name", DataType::Text, false),
5082            ColumnSchema::new("Value", DataType::Text, false),
5083        ];
5084        let pairs: &[(&str, &str)] = &[
5085            ("Uptime", "0"),
5086            ("Threads_connected", "1"),
5087            ("Threads_running", "1"),
5088            ("Questions", "0"),
5089            ("Slow_queries", "0"),
5090            ("Opened_tables", "0"),
5091            ("Innodb_buffer_pool_pages_total", "0"),
5092        ];
5093        let rows: Vec<Row> = pairs
5094            .iter()
5095            .map(|(k, v)| {
5096                Row::new(alloc::vec![
5097                    Value::Text((*k).into()),
5098                    Value::Text((*v).into())
5099                ])
5100            })
5101            .collect();
5102        QueryResult::Rows { columns, rows }
5103    }
5104
5105    /// v7.17.0 Phase 3.P0-61 — `SHOW VARIABLES`. Returns server-side
5106    /// variables MySQL/MariaDB clients probe at connect time.
5107    fn exec_show_variables(&self) -> QueryResult {
5108        let columns = alloc::vec![
5109            ColumnSchema::new("Variable_name", DataType::Text, false),
5110            ColumnSchema::new("Value", DataType::Text, false),
5111        ];
5112        let mut rows: Vec<Row> = Vec::new();
5113        let canonical: &[(&str, &str)] = &[
5114            ("version", "8.0.35-spg"),
5115            ("version_comment", "SPG dual-stack engine"),
5116            ("character_set_server", "utf8mb4"),
5117            ("collation_server", "utf8mb4_0900_ai_ci"),
5118            ("max_allowed_packet", "67108864"),
5119            ("autocommit", "ON"),
5120            ("sql_mode", "STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION"),
5121            ("time_zone", "SYSTEM"),
5122            ("transaction_isolation", "REPEATABLE-READ"),
5123        ];
5124        for &(k, v) in canonical {
5125            rows.push(Row::new(alloc::vec![
5126                Value::Text(k.into()),
5127                Value::Text(v.into()),
5128            ]));
5129        }
5130        // Session-set parameters surface here too.
5131        for (k, v) in &self.session_params {
5132            if !canonical.iter().any(|(n, _)| (*n).eq_ignore_ascii_case(k)) {
5133                rows.push(Row::new(alloc::vec![
5134                    Value::Text(k.clone()),
5135                    Value::Text(v.clone()),
5136                ]));
5137            }
5138        }
5139        QueryResult::Rows { columns, rows }
5140    }
5141
5142    /// v7.17.0 Phase 3.P0-62 — `SHOW PROCESSLIST`. SPG is
5143    /// single-process so the surface returns one synthetic row
5144    /// describing the current connection (Id, User, Host, db,
5145    /// Command, Time, State, Info).
5146    fn exec_show_processlist(&self) -> QueryResult {
5147        let columns = alloc::vec![
5148            ColumnSchema::new("Id", DataType::Int, false),
5149            ColumnSchema::new("User", DataType::Text, false),
5150            ColumnSchema::new("Host", DataType::Text, false),
5151            ColumnSchema::new("db", DataType::Text, true),
5152            ColumnSchema::new("Command", DataType::Text, false),
5153            ColumnSchema::new("Time", DataType::Int, false),
5154            ColumnSchema::new("State", DataType::Text, true),
5155            ColumnSchema::new("Info", DataType::Text, true),
5156        ];
5157        let rows = alloc::vec![Row::new(alloc::vec![
5158            Value::Int(1),
5159            Value::Text("postgres".into()),
5160            Value::Text("localhost".into()),
5161            Value::Text("postgres".into()),
5162            Value::Text("Query".into()),
5163            Value::Int(0),
5164            Value::Text("executing".into()),
5165            Value::Text("SHOW PROCESSLIST".into()),
5166        ])];
5167        QueryResult::Rows { columns, rows }
5168    }
5169
5170    /// v7.17.0 Phase 3.P0-58 — `SHOW DATABASES` / `SHOW SCHEMAS`.
5171    /// SPG is single-database so the result is the canonical MySQL
5172    /// set every mysql/MariaDB client expects at connect time:
5173    /// `information_schema`, `mysql`, `performance_schema`, `sys`,
5174    /// plus a `postgres` slot so dual-stack callers find their
5175    /// PG-compatible database too.
5176    fn exec_show_databases(&self) -> QueryResult {
5177        let columns = alloc::vec![ColumnSchema::new("Database", DataType::Text, false)];
5178        let names = [
5179            "information_schema",
5180            "mysql",
5181            "performance_schema",
5182            "sys",
5183            "postgres",
5184        ];
5185        let rows: Vec<Row> = names
5186            .iter()
5187            .map(|n| Row::new(alloc::vec![Value::Text((*n).into())]))
5188            .collect();
5189        QueryResult::Rows { columns, rows }
5190    }
5191
5192    /// `SHOW COLUMNS FROM <table>` — one row per column with the
5193    /// declared name, SQL type rendering, and nullability flag.
5194    fn exec_show_columns(&self, table_name: &str) -> Result<QueryResult, EngineError> {
5195        let table =
5196            self.active_catalog()
5197                .get(table_name)
5198                .ok_or_else(|| StorageError::TableNotFound {
5199                    name: table_name.into(),
5200                })?;
5201        let columns = alloc::vec![
5202            ColumnSchema::new("name", DataType::Text, false),
5203            ColumnSchema::new("type", DataType::Text, false),
5204            ColumnSchema::new("nullable", DataType::Bool, false),
5205        ];
5206        let rows: Vec<Row> = table
5207            .schema()
5208            .columns
5209            .iter()
5210            .map(|c| {
5211                Row::new(alloc::vec![
5212                    Value::Text(c.name.clone()),
5213                    Value::Text(alloc::format!("{}", c.ty)),
5214                    Value::Bool(c.nullable),
5215                ])
5216            })
5217            .collect();
5218        Ok(QueryResult::Rows { columns, rows })
5219    }
5220
5221    fn exec_begin(&mut self) -> Result<QueryResult, EngineError> {
5222        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5223        if self.tx_catalogs.contains_key(&tx_id) {
5224            return Err(EngineError::TransactionAlreadyOpen);
5225        }
5226        self.tx_catalogs.insert(
5227            tx_id,
5228            TxState {
5229                catalog: self.catalog.clone(),
5230                savepoints: Vec::new(),
5231            },
5232        );
5233        Ok(QueryResult::CommandOk {
5234            affected: 0,
5235            modified_catalog: false,
5236        })
5237    }
5238
5239    fn exec_commit(&mut self) -> Result<QueryResult, EngineError> {
5240        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5241        let state = self
5242            .tx_catalogs
5243            .remove(&tx_id)
5244            .ok_or(EngineError::NoActiveTransaction)?;
5245        self.catalog = state.catalog;
5246        // All savepoints become permanent at COMMIT and the stack
5247        // resets for the next TX (`state.savepoints` is discarded with
5248        // `state`).
5249        Ok(QueryResult::CommandOk {
5250            affected: 0,
5251            modified_catalog: true,
5252        })
5253    }
5254
5255    fn exec_rollback(&mut self) -> Result<QueryResult, EngineError> {
5256        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5257        if self.tx_catalogs.remove(&tx_id).is_none() {
5258            return Err(EngineError::NoActiveTransaction);
5259        }
5260        // savepoints discarded with the TxState
5261        Ok(QueryResult::CommandOk {
5262            affected: 0,
5263            modified_catalog: false,
5264        })
5265    }
5266
5267    fn exec_savepoint(&mut self, name: String) -> Result<QueryResult, EngineError> {
5268        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5269        let state = self
5270            .tx_catalogs
5271            .get_mut(&tx_id)
5272            .ok_or(EngineError::NoActiveTransaction)?;
5273        // PG re-uses an existing savepoint name by dropping the older
5274        // entry and pushing a fresh one — match that behaviour so
5275        // application code can `SAVEPOINT sp; ...; SAVEPOINT sp` freely.
5276        state.savepoints.retain(|(n, _)| n != &name);
5277        let snapshot = state.catalog.clone();
5278        state.savepoints.push((name, snapshot));
5279        Ok(QueryResult::CommandOk {
5280            affected: 0,
5281            modified_catalog: false,
5282        })
5283    }
5284
5285    fn exec_rollback_to_savepoint(&mut self, name: &str) -> Result<QueryResult, EngineError> {
5286        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5287        let state = self
5288            .tx_catalogs
5289            .get_mut(&tx_id)
5290            .ok_or(EngineError::NoActiveTransaction)?;
5291        let pos = state
5292            .savepoints
5293            .iter()
5294            .rposition(|(n, _)| n == name)
5295            .ok_or_else(|| {
5296                EngineError::Unsupported(alloc::format!("savepoint not found: {name}"))
5297            })?;
5298        // The savepoint stays on the stack (PG semantics): a later
5299        // `RELEASE` or further `ROLLBACK TO` is still allowed. Everything
5300        // after it is discarded.
5301        let snapshot = state.savepoints[pos].1.clone();
5302        state.savepoints.truncate(pos + 1);
5303        state.catalog = snapshot;
5304        Ok(QueryResult::CommandOk {
5305            affected: 0,
5306            modified_catalog: false,
5307        })
5308    }
5309
5310    fn exec_release_savepoint(&mut self, name: &str) -> Result<QueryResult, EngineError> {
5311        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5312        let state = self
5313            .tx_catalogs
5314            .get_mut(&tx_id)
5315            .ok_or(EngineError::NoActiveTransaction)?;
5316        let pos = state
5317            .savepoints
5318            .iter()
5319            .rposition(|(n, _)| n == name)
5320            .ok_or_else(|| {
5321                EngineError::Unsupported(alloc::format!("savepoint not found: {name}"))
5322            })?;
5323        // RELEASE keeps the work since the savepoint, just discards the
5324        // bookmark plus everything nested under it.
5325        state.savepoints.truncate(pos);
5326        Ok(QueryResult::CommandOk {
5327            affected: 0,
5328            modified_catalog: false,
5329        })
5330    }
5331
5332    /// v6.0.4 — synchronous `ALTER INDEX <name> REBUILD [WITH
5333    /// (encoding = …)]`. Walks every table in the active catalog
5334    /// looking for an index matching `stmt.name`, then delegates the
5335    /// rebuild (including any encoding switch) to
5336    /// `Table::rebuild_nsw_index`. The "live" non-blocking
5337    /// optimisation is v6.0.4.1 / v6.1.x territory.
5338    /// v6.7.2 — `ALTER TABLE t SET hot_tier_bytes = X`. Dispatch
5339    /// arm. Currently the only setting is `hot_tier_bytes`; later
5340    /// v6.7.x can extend `AlterTableTarget` without touching this
5341    /// arm structure.
5342    fn exec_alter_table(
5343        &mut self,
5344        s: spg_sql::ast::AlterTableStatement,
5345    ) -> Result<QueryResult, EngineError> {
5346        // v7.13.2 — mailrs round-6 S1: apply each subaction in order.
5347        // On first error the statement aborts; subactions already
5348        // applied stay (no transactional rollback in v7.13 — wrap in
5349        // BEGIN/COMMIT if atomicity matters).
5350        let table_name = s.name.clone();
5351        for target in s.targets {
5352            self.exec_alter_table_subaction(&table_name, target)?;
5353        }
5354        Ok(QueryResult::CommandOk {
5355            affected: 0,
5356            modified_catalog: !self.in_transaction(),
5357        })
5358    }
5359
5360    fn exec_alter_table_subaction(
5361        &mut self,
5362        table_name_outer: &str,
5363        target: spg_sql::ast::AlterTableTarget,
5364    ) -> Result<(), EngineError> {
5365        // Inner helper retains the s.name closure shape; alias to `s`
5366        // for minimal diff against the v7.13.0 body.
5367        struct S<'a> {
5368            name: &'a str,
5369        }
5370        let s = S {
5371            name: table_name_outer,
5372        };
5373        match target {
5374            spg_sql::ast::AlterTableTarget::SetHotTierBytes(n) => {
5375                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5376                    EngineError::Storage(StorageError::TableNotFound {
5377                        name: s.name.into(),
5378                    })
5379                })?;
5380                table.schema_mut().hot_tier_bytes = Some(n);
5381            }
5382            spg_sql::ast::AlterTableTarget::AddForeignKey(fk) => {
5383                // v7.6.8 — resolve FK against the live catalog first
5384                // (validates parent table, columns, indices). Then
5385                // verify every existing row in the child table
5386                // satisfies the new constraint. Then install it.
5387                let cols_snapshot = self
5388                    .active_catalog()
5389                    .get(s.name)
5390                    .ok_or_else(|| {
5391                        EngineError::Storage(StorageError::TableNotFound {
5392                            name: s.name.into(),
5393                        })
5394                    })?
5395                    .schema()
5396                    .columns
5397                    .clone();
5398                let storage_fk =
5399                    resolve_foreign_key(s.name, &cols_snapshot, fk, self.active_catalog())?;
5400                // Verify existing rows. Treat them as a virtual
5401                // INSERT batch — reusing the v7.6.2 enforce helper.
5402                let existing_rows: Vec<Vec<Value>> = self
5403                    .active_catalog()
5404                    .get(s.name)
5405                    .expect("checked above")
5406                    .rows()
5407                    .iter()
5408                    .map(|r| r.values.clone())
5409                    .collect();
5410                enforce_fk_inserts(
5411                    self.active_catalog(),
5412                    s.name,
5413                    core::slice::from_ref(&storage_fk),
5414                    &existing_rows,
5415                )?;
5416                // Reject duplicate constraint name.
5417                let table = self
5418                    .active_catalog_mut()
5419                    .get_mut(s.name)
5420                    .expect("checked above");
5421                if let Some(name) = &storage_fk.name
5422                    && table
5423                        .schema()
5424                        .foreign_keys
5425                        .iter()
5426                        .any(|f| f.name.as_ref() == Some(name))
5427                {
5428                    return Err(EngineError::Unsupported(alloc::format!(
5429                        "ALTER TABLE ADD CONSTRAINT: a constraint named {name:?} already exists"
5430                    )));
5431                }
5432                table.schema_mut().foreign_keys.push(storage_fk);
5433            }
5434            spg_sql::ast::AlterTableTarget::DropForeignKey { name, if_exists } => {
5435                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5436                    EngineError::Storage(StorageError::TableNotFound {
5437                        name: s.name.into(),
5438                    })
5439                })?;
5440                let fks = &mut table.schema_mut().foreign_keys;
5441                let before = fks.len();
5442                fks.retain(|f| f.name.as_ref() != Some(&name));
5443                if fks.len() == before && !if_exists {
5444                    return Err(EngineError::Unsupported(alloc::format!(
5445                        "ALTER TABLE DROP CONSTRAINT: no FK named {name:?} on {:?}",
5446                        s.name
5447                    )));
5448                }
5449                // v7.13.2 mailrs round-6 S7: IF EXISTS silences the miss.
5450            }
5451            spg_sql::ast::AlterTableTarget::AddColumn {
5452                column,
5453                if_not_exists,
5454            } => {
5455                // v7.13.0 — mailrs round-5 G1. Append-only column add
5456                // with back-fill of the DEFAULT (or NULL) into every
5457                // existing row. Column positions don't shift, so we
5458                // skip index rebuild.
5459                let clock = self.clock;
5460                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5461                    EngineError::Storage(StorageError::TableNotFound {
5462                        name: s.name.into(),
5463                    })
5464                })?;
5465                if table
5466                    .schema()
5467                    .columns
5468                    .iter()
5469                    .any(|c| c.name.eq_ignore_ascii_case(&column.name))
5470                {
5471                    if if_not_exists {
5472                        return Ok(());
5473                    }
5474                    return Err(EngineError::Unsupported(alloc::format!(
5475                        "ALTER TABLE ADD COLUMN: column {:?} already exists on {:?}",
5476                        column.name,
5477                        s.name
5478                    )));
5479                }
5480                let col_name = column.name.clone();
5481                let nullable = column.nullable;
5482                let has_default = column.default.is_some() || column.auto_increment;
5483                let col_schema = column_def_to_schema(column)?;
5484                let row_count = table.row_count();
5485                // Compute the back-fill value. Literal / runtime DEFAULT
5486                // funnels through the same resolver that INSERT uses
5487                // (v7.9.21 `resolve_column_default_free`). NULL when
5488                // the column is nullable and has no DEFAULT. NOT NULL
5489                // without DEFAULT errors when the table has existing
5490                // rows — same as PG.
5491                let fill_value: Value = if has_default || col_schema.runtime_default.is_some() {
5492                    resolve_column_default_free(&col_schema, clock)?
5493                } else if nullable || row_count == 0 {
5494                    Value::Null
5495                } else {
5496                    return Err(EngineError::Unsupported(alloc::format!(
5497                        "ALTER TABLE ADD COLUMN {col_name:?}: NOT NULL column requires DEFAULT \
5498                         when the table has existing rows"
5499                    )));
5500                };
5501                table.add_column(col_schema, fill_value);
5502            }
5503            spg_sql::ast::AlterTableTarget::AlterColumnType {
5504                column,
5505                new_type,
5506                using,
5507            } => {
5508                // v7.13.0 — mailrs round-5 G8. Re-evaluate each
5509                // row's column value (either through the USING
5510                // expression if supplied, or as a direct CAST of
5511                // the existing value) and re-coerce to the new
5512                // type. Indices on the column get rebuilt.
5513                let new_data_type = column_type_to_data_type(new_type);
5514                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5515                    EngineError::Storage(StorageError::TableNotFound {
5516                        name: s.name.into(),
5517                    })
5518                })?;
5519                let col_pos = table
5520                    .schema()
5521                    .columns
5522                    .iter()
5523                    .position(|c| c.name.eq_ignore_ascii_case(&column))
5524                    .ok_or_else(|| {
5525                        EngineError::Unsupported(alloc::format!(
5526                            "ALTER COLUMN TYPE: column {column:?} not found on {:?}",
5527                            s.name
5528                        ))
5529                    })?;
5530                let schema_cols = table.schema().columns.clone();
5531                let ctx = eval::EvalContext::new(&schema_cols, None);
5532                let mut new_values: alloc::vec::Vec<Value> =
5533                    alloc::vec::Vec::with_capacity(table.row_count());
5534                for row in table.rows().iter() {
5535                    let raw = match &using {
5536                        Some(expr) => eval::eval_expr(expr, row, &ctx).map_err(|e| {
5537                            EngineError::Unsupported(alloc::format!(
5538                                "ALTER COLUMN TYPE: USING expression failed: {e:?}"
5539                            ))
5540                        })?,
5541                        None => row.values.get(col_pos).cloned().unwrap_or(Value::Null),
5542                    };
5543                    let coerced = coerce_value(raw, new_data_type, &column, col_pos)?;
5544                    new_values.push(coerced);
5545                }
5546                table.schema_mut().columns[col_pos].ty = new_data_type;
5547                for (i, v) in new_values.into_iter().enumerate() {
5548                    let mut row_values = table
5549                        .rows()
5550                        .get(i)
5551                        .expect("bounds-checked above")
5552                        .values
5553                        .clone();
5554                    row_values[col_pos] = v;
5555                    table.update_row(i, row_values)?;
5556                }
5557            }
5558            spg_sql::ast::AlterTableTarget::AddTableConstraint(tc) => {
5559                // v7.14.0 — pg_dump emits PKs as a separate
5560                // ALTER TABLE ADD CONSTRAINT post-CREATE-TABLE.
5561                // For PRIMARY KEY / UNIQUE, install a UC entry
5562                // and the implicit BTree index on the leading
5563                // column. CHECK: append predicate to schema.
5564                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5565                    EngineError::Storage(StorageError::TableNotFound {
5566                        name: s.name.into(),
5567                    })
5568                })?;
5569                let is_pk = matches!(tc, spg_sql::ast::TableConstraint::PrimaryKey { .. });
5570                // v7.22 (mailrs round-13 gap 6) — carry the parsed
5571                // NULLS NOT DISTINCT flag through the ALTER path;
5572                // it was hardcoded false here while the CREATE
5573                // TABLE path honoured it since v7.13.
5574                let nnd = matches!(
5575                    tc,
5576                    spg_sql::ast::TableConstraint::Unique {
5577                        nulls_not_distinct: true,
5578                        ..
5579                    }
5580                );
5581                match tc {
5582                    spg_sql::ast::TableConstraint::PrimaryKey { columns, .. }
5583                    | spg_sql::ast::TableConstraint::Unique { columns, .. } => {
5584                        let positions: Vec<usize> = columns
5585                            .iter()
5586                            .map(|c| {
5587                                table
5588                                    .schema()
5589                                    .columns
5590                                    .iter()
5591                                    .position(|sc| sc.name.eq_ignore_ascii_case(c))
5592                                    .ok_or_else(|| {
5593                                        EngineError::Unsupported(alloc::format!(
5594                                            "ALTER TABLE ADD CONSTRAINT: column {c:?} not found on {:?}",
5595                                            s.name
5596                                        ))
5597                                    })
5598                            })
5599                            .collect::<Result<Vec<_>, _>>()?;
5600                        // Skip if an equivalent UC is already there
5601                        // (idempotent — pg_dump's PK + a prior inline
5602                        // PK shouldn't double-install).
5603                        let already = table
5604                            .schema()
5605                            .uniqueness_constraints
5606                            .iter()
5607                            .any(|u| u.columns == positions);
5608                        if !already {
5609                            table.schema_mut().uniqueness_constraints.push(
5610                                spg_storage::UniquenessConstraint {
5611                                    is_primary_key: is_pk,
5612                                    columns: positions.clone(),
5613                                    nulls_not_distinct: nnd,
5614                                },
5615                            );
5616                            // PK implies NOT NULL on referenced cols.
5617                            if is_pk {
5618                                for p in &positions {
5619                                    if let Some(c) = table.schema_mut().columns.get_mut(*p) {
5620                                        c.nullable = false;
5621                                    }
5622                                }
5623                            }
5624                            // Add a BTree index on the leading
5625                            // column for INSERT-side enforcement.
5626                            let leading = &columns[0];
5627                            let already_idx = table.indices().iter().any(|idx| {
5628                                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
5629                                    && table.schema().columns[idx.column_position].name == *leading
5630                            });
5631                            if !already_idx {
5632                                let suffix = if is_pk { "pkey" } else { "key" };
5633                                let idx_name = alloc::format!("{}_{leading}_{suffix}", s.name);
5634                                let _ = table.add_index(idx_name, leading);
5635                            }
5636                        }
5637                    }
5638                    spg_sql::ast::TableConstraint::Check { expr, .. } => {
5639                        table.schema_mut().checks.push(alloc::format!("{expr}"));
5640                    }
5641                    spg_sql::ast::TableConstraint::Index { name, columns } => {
5642                        // v7.15.0 — ALTER TABLE ADD KEY (cols).
5643                        // mysqldump occasionally emits this
5644                        // post-CREATE-TABLE shape; build a BTree
5645                        // on the leading column using the
5646                        // user-supplied or synthesised name.
5647                        let leading = &columns[0];
5648                        let already_idx = table.indices().iter().any(|idx| {
5649                            matches!(idx.kind, spg_storage::IndexKind::BTree(_))
5650                                && table.schema().columns[idx.column_position].name == *leading
5651                        });
5652                        if !already_idx {
5653                            let idx_name = name
5654                                .clone()
5655                                .unwrap_or_else(|| alloc::format!("{}_{leading}_idx", s.name));
5656                            let _ = table.add_index(idx_name, leading);
5657                        }
5658                    }
5659                    spg_sql::ast::TableConstraint::FulltextIndex { name, columns } => {
5660                        // v7.17.0 Phase 2.2 — ALTER TABLE ADD
5661                        // FULLTEXT KEY (cols). Builds one
5662                        // fulltext-GIN per named column so MATCH
5663                        // AGAINST gets a real inverted index.
5664                        // Multi-column declarations expand to
5665                        // per-column GINs (the leading column
5666                        // drives MATCH AGAINST planning).
5667                        for (k, col) in columns.iter().enumerate() {
5668                            let already_idx = table.indices().iter().any(|idx| {
5669                                matches!(idx.kind, spg_storage::IndexKind::GinFulltext(_))
5670                                    && table.schema().columns[idx.column_position].name == *col
5671                            });
5672                            if already_idx {
5673                                continue;
5674                            }
5675                            let idx_name = match (&name, columns.len(), k) {
5676                                (Some(n), 1, _) => n.clone(),
5677                                (Some(n), _, k) => alloc::format!("{n}_{k}"),
5678                                (None, _, _) => {
5679                                    alloc::format!("{}_{col}_ftidx", s.name)
5680                                }
5681                            };
5682                            let _ = table.add_gin_fulltext_index(idx_name, col);
5683                        }
5684                    }
5685                }
5686            }
5687            spg_sql::ast::AlterTableTarget::DropColumn {
5688                column,
5689                if_exists,
5690                cascade,
5691            } => {
5692                // v7.13.3 — mailrs round-7 S8. Remove the column +
5693                // every row's value at that position; drop any index
5694                // on the column. RESTRICT (default) rejects when an
5695                // FK on this table or partial-index predicate
5696                // references the column; CASCADE removes those
5697                // dependents first.
5698                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5699                    EngineError::Storage(StorageError::TableNotFound {
5700                        name: s.name.into(),
5701                    })
5702                })?;
5703                let col_pos = match table
5704                    .schema()
5705                    .columns
5706                    .iter()
5707                    .position(|c| c.name.eq_ignore_ascii_case(&column))
5708                {
5709                    Some(p) => p,
5710                    None => {
5711                        if if_exists {
5712                            return Ok(());
5713                        }
5714                        return Err(EngineError::Unsupported(alloc::format!(
5715                            "ALTER TABLE DROP COLUMN: column {column:?} not found on {:?}",
5716                            s.name
5717                        )));
5718                    }
5719                };
5720                // Dependent check: FKs whose local columns include
5721                // col_pos. CASCADE drops them; otherwise reject.
5722                let dependent_fks: Vec<usize> = table
5723                    .schema()
5724                    .foreign_keys
5725                    .iter()
5726                    .enumerate()
5727                    .filter_map(|(i, fk)| {
5728                        if fk.local_columns.contains(&col_pos) {
5729                            Some(i)
5730                        } else {
5731                            None
5732                        }
5733                    })
5734                    .collect();
5735                if !dependent_fks.is_empty() && !cascade {
5736                    return Err(EngineError::Unsupported(alloc::format!(
5737                        "ALTER TABLE DROP COLUMN {column:?}: column has FK dependents; \
5738                         use DROP COLUMN ... CASCADE to remove them"
5739                    )));
5740                }
5741                // CASCADE the FK removals first.
5742                if cascade {
5743                    // Drop in reverse so indices stay valid.
5744                    let mut sorted = dependent_fks.clone();
5745                    sorted.sort();
5746                    sorted.reverse();
5747                    let fks = &mut table.schema_mut().foreign_keys;
5748                    for i in sorted {
5749                        fks.remove(i);
5750                    }
5751                }
5752                // Drop the column. New helper on Table does the
5753                // row + schema + index shift atomically.
5754                table.drop_column(col_pos);
5755            }
5756            spg_sql::ast::AlterTableTarget::SetTriggerEnabled { which, enabled } => {
5757                // v7.16.1 — mailrs round-9 A.2.b. pg_dump
5758                // --disable-triggers wraps each table's data
5759                // block with `ALTER TABLE … DISABLE TRIGGER ALL`
5760                // / `… ENABLE TRIGGER ALL`. Toggle the enabled
5761                // flag on every matching trigger so the row-
5762                // write paths skip them; the catalog snapshot
5763                // persists the new state across restarts.
5764                let table_name = s.name.to_string();
5765                let trigs = self.active_catalog_mut().triggers_mut();
5766                let mut touched = false;
5767                for t in trigs.iter_mut() {
5768                    if !t.table.eq_ignore_ascii_case(&table_name) {
5769                        continue;
5770                    }
5771                    match &which {
5772                        spg_sql::ast::TriggerSelector::All => {
5773                            t.enabled = enabled;
5774                            touched = true;
5775                        }
5776                        spg_sql::ast::TriggerSelector::Named(name) => {
5777                            if t.name.eq_ignore_ascii_case(name) {
5778                                t.enabled = enabled;
5779                                touched = true;
5780                            }
5781                        }
5782                    }
5783                }
5784                // PG semantics: `ALL` on a table with no
5785                // triggers is a no-op (no error). A `Named`
5786                // form pointing at a non-existent trigger
5787                // raises in PG; v7.16.1 also raises so we
5788                // don't silently lose state.
5789                if !touched {
5790                    if let spg_sql::ast::TriggerSelector::Named(name) = &which {
5791                        return Err(EngineError::Unsupported(alloc::format!(
5792                            "ALTER TABLE {table_name:?} {} TRIGGER {name:?}: no such trigger on table",
5793                            if enabled { "ENABLE" } else { "DISABLE" },
5794                        )));
5795                    }
5796                }
5797            }
5798            spg_sql::ast::AlterTableTarget::SetColumnAutoIncrement { column, seq_name } => {
5799                // pg_dump's identity form names an IMPLICIT sequence
5800                // (`… AS IDENTITY ( SEQUENCE NAME s … )`) that never
5801                // gets its own CREATE SEQUENCE statement, while the
5802                // data section still calls `setval(s, …)`. Make the
5803                // sequence exist (idempotent) so those calls land.
5804                if let Some(seq) = seq_name {
5805                    let _ = self.exec_create_sequence(spg_sql::ast::CreateSequenceStatement {
5806                        name: seq,
5807                        if_not_exists: true,
5808                        temporary: false,
5809                        data_type: None,
5810                        options: spg_sql::ast::SequenceOptions::default(),
5811                    })?;
5812                }
5813                // v7.22 (round-13 T2) — pg_dump's serial/identity
5814                // spellings (`SET DEFAULT nextval(…)` / `ADD
5815                // GENERATED … AS IDENTITY`) lower here: flip the
5816                // column's auto-increment flag so post-import
5817                // INSERTs without an explicit value keep numbering
5818                // (max+1 semantics; the dump's setval() calls are
5819                // no-ops by construction).
5820                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5821                    EngineError::Storage(StorageError::TableNotFound {
5822                        name: s.name.into(),
5823                    })
5824                })?;
5825                let pos = table
5826                    .schema()
5827                    .columns
5828                    .iter()
5829                    .position(|c| c.name.eq_ignore_ascii_case(&column))
5830                    .ok_or_else(|| {
5831                        EngineError::Unsupported(alloc::format!(
5832                            "ALTER COLUMN {column:?}: no such column on {:?}",
5833                            s.name
5834                        ))
5835                    })?;
5836                let col = &table.schema().columns[pos];
5837                if !matches!(
5838                    col.ty,
5839                    spg_storage::DataType::SmallInt
5840                        | spg_storage::DataType::Int
5841                        | spg_storage::DataType::BigInt
5842                ) {
5843                    return Err(EngineError::Unsupported(alloc::format!(
5844                        "auto-increment applies to integer columns only ({column:?} is {:?})",
5845                        col.ty
5846                    )));
5847                }
5848                table.schema_mut().columns[pos].auto_increment = true;
5849            }
5850            spg_sql::ast::AlterTableTarget::RenameTable { new } => {
5851                // v7.16.2 — table-level rename (mailrs round-10
5852                // A.5 — used by migrate-042's `ALTER TABLE
5853                // contacts RENAME TO email_contacts`). Storage
5854                // helper updates the schema + by_name index +
5855                // dangling FK / trigger references in one
5856                // atomic step.
5857                let old = s.name.to_string();
5858                self.active_catalog_mut()
5859                    .rename_table(&old, &new)
5860                    .map_err(EngineError::Storage)?;
5861            }
5862            spg_sql::ast::AlterTableTarget::RenameColumn { old, new } => {
5863                // v7.15.0 — `ALTER TABLE t RENAME [COLUMN] old TO
5864                // new`. Rename the column in the schema; rewrite
5865                // every stored source string on this table that
5866                // references it as a (potentially-qualified)
5867                // column identifier: CHECK predicates, partial-
5868                // index predicates, runtime DEFAULT expressions.
5869                // Then walk catalog triggers on this table and
5870                // patch any `UPDATE OF` column list. Function and
5871                // trigger bodies are NOT auto-rewritten — that
5872                // surface is dynamic SQL territory; users update
5873                // those separately (matches PG plpgsql behavior:
5874                // a column rename invalidates name-referencing
5875                // plpgsql at call time, not rename time).
5876                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5877                    EngineError::Storage(StorageError::TableNotFound {
5878                        name: s.name.into(),
5879                    })
5880                })?;
5881                let col_pos = table
5882                    .schema()
5883                    .columns
5884                    .iter()
5885                    .position(|c| c.name.eq_ignore_ascii_case(&old))
5886                    .ok_or_else(|| {
5887                        EngineError::Unsupported(alloc::format!(
5888                            "ALTER TABLE RENAME COLUMN: column {old:?} not found on {:?}",
5889                            s.name
5890                        ))
5891                    })?;
5892                // Reject same-name (case-insensitive) collision.
5893                if table
5894                    .schema()
5895                    .columns
5896                    .iter()
5897                    .enumerate()
5898                    .any(|(i, c)| i != col_pos && c.name.eq_ignore_ascii_case(&new))
5899                {
5900                    return Err(EngineError::Unsupported(alloc::format!(
5901                        "ALTER TABLE RENAME COLUMN: column {new:?} already exists on {:?}",
5902                        s.name
5903                    )));
5904                }
5905                // Schema rename first — even idempotent same-name
5906                // rename (`ALTER TABLE t RENAME a TO a`) needs to
5907                // be a no-op, not an error.
5908                if old.eq_ignore_ascii_case(&new) {
5909                    return Ok(());
5910                }
5911                table.rename_column(col_pos, &new);
5912                // Rewrite per-column runtime_default sources on
5913                // every column of this table — a DEFAULT expression
5914                // on column X may reference column Y by name (rare,
5915                // but legal in PG when the value is supplied via a
5916                // function that takes the row).
5917                let n_cols = table.schema().columns.len();
5918                for i in 0..n_cols {
5919                    let rt = table.schema().columns[i].runtime_default.clone();
5920                    if let Some(src) = rt {
5921                        let rewritten = rewrite_column_in_source(&src, &old, &new)?;
5922                        table.schema_mut().columns[i].runtime_default = Some(rewritten);
5923                    }
5924                }
5925                // Rewrite table-level CHECK predicates.
5926                let checks = table.schema().checks.clone();
5927                let mut new_checks = Vec::with_capacity(checks.len());
5928                for chk in checks {
5929                    new_checks.push(rewrite_column_in_source(&chk, &old, &new)?);
5930                }
5931                table.schema_mut().checks = new_checks;
5932                // Rewrite per-index partial_predicate sources.
5933                let n_idx = table.indices().len();
5934                for i in 0..n_idx {
5935                    let pred = table.indices()[i].partial_predicate.clone();
5936                    if let Some(src) = pred {
5937                        let rewritten = rewrite_column_in_source(&src, &old, &new)?;
5938                        // SAFETY: indices_mut would be cleanest, but
5939                        // partial_predicate is the only mutable field
5940                        // here; reach in via the public mut accessor.
5941                        table.set_partial_predicate(i, Some(rewritten));
5942                    }
5943                }
5944                // Walk catalog triggers; patch `update_columns` on
5945                // triggers attached to this table.
5946                let table_name = s.name.to_string();
5947                for trig in self.active_catalog_mut().triggers_mut() {
5948                    if !trig.table.eq_ignore_ascii_case(&table_name) {
5949                        continue;
5950                    }
5951                    for c in &mut trig.update_columns {
5952                        if c.eq_ignore_ascii_case(&old) {
5953                            *c = new.clone();
5954                        }
5955                    }
5956                }
5957            }
5958        }
5959        Ok(())
5960    }
5961
5962    fn exec_alter_index(
5963        &mut self,
5964        stmt: spg_sql::ast::AlterIndexStatement,
5965    ) -> Result<QueryResult, EngineError> {
5966        // Translate the optional SQL-side encoding choice into the
5967        // storage-side enum; the same SqlVecEncoding -> VecEncoding
5968        // bridge `column_type_to_data_type` uses.
5969        let spg_sql::ast::AlterIndexStatement {
5970            name: idx_name,
5971            target,
5972        } = stmt;
5973        // v7.16.2 — RENAME TO branch (mailrs round-10 migrate-042).
5974        // IF EXISTS makes a missing index a no-op rather than an
5975        // error, mirroring PG semantics.
5976        if let spg_sql::ast::AlterIndexTarget::Rename { new, if_exists } = target {
5977            let renamed = self.active_catalog_mut().rename_index(&idx_name, &new);
5978            return match renamed {
5979                Ok(()) => Ok(QueryResult::CommandOk {
5980                    affected: 0,
5981                    modified_catalog: !self.in_transaction(),
5982                }),
5983                Err(StorageError::IndexNotFound { .. }) if if_exists => {
5984                    Ok(QueryResult::CommandOk {
5985                        affected: 0,
5986                        modified_catalog: false,
5987                    })
5988                }
5989                Err(e) => Err(EngineError::Storage(e)),
5990            };
5991        }
5992        let spg_sql::ast::AlterIndexTarget::Rebuild { encoding } = target else {
5993            unreachable!("Rename branch returned above");
5994        };
5995        let target = encoding.map(|e| match e {
5996            SqlVecEncoding::F32 => VecEncoding::F32,
5997            SqlVecEncoding::Sq8 => VecEncoding::Sq8,
5998            SqlVecEncoding::F16 => VecEncoding::F16,
5999        });
6000        // Linear scan: index names are globally unique within a
6001        // catalog (enforced by add_nsw_index_inner) so the first
6002        // match is the only one. Save the table name to avoid
6003        // borrowing while we then take a mut borrow.
6004        let table_name = {
6005            let cat = self.active_catalog();
6006            let mut found: Option<String> = None;
6007            for tname in cat.table_names() {
6008                if let Some(t) = cat.get(&tname)
6009                    && t.indices().iter().any(|i| i.name == idx_name)
6010                {
6011                    found = Some(tname);
6012                    break;
6013                }
6014            }
6015            found.ok_or_else(|| {
6016                EngineError::Storage(StorageError::IndexNotFound {
6017                    name: idx_name.clone(),
6018                })
6019            })?
6020        };
6021        let table = self
6022            .active_catalog_mut()
6023            .get_mut(&table_name)
6024            .expect("table found above");
6025        table.rebuild_nsw_index(&idx_name, target)?;
6026        // v6.3.1 — ALTER INDEX REBUILD potentially with new encoding
6027        // changes cost characteristics; evict any cached plans.
6028        self.plan_cache.evict_referencing(&table_name);
6029        Ok(QueryResult::CommandOk {
6030            affected: 0,
6031            modified_catalog: !self.in_transaction(),
6032        })
6033    }
6034
6035    fn exec_create_index(
6036        &mut self,
6037        stmt: CreateIndexStatement,
6038    ) -> Result<QueryResult, EngineError> {
6039        let table = self
6040            .active_catalog_mut()
6041            .get_mut(&stmt.table)
6042            .ok_or_else(|| {
6043                EngineError::Storage(StorageError::TableNotFound {
6044                    name: stmt.table.clone(),
6045                })
6046            })?;
6047        // `IF NOT EXISTS` reduces DuplicateIndex to a no-op CommandOk.
6048        if stmt.if_not_exists && table.indices().iter().any(|i| i.name == stmt.name) {
6049            return Ok(QueryResult::CommandOk {
6050                affected: 0,
6051                modified_catalog: false,
6052            });
6053        }
6054        // v7.9.14 — multi-column index parses through; engine
6055        // builds a single-column BTree on the leading column only.
6056        // The extras live on the AST so spg-server's dispatcher
6057        // can emit a PG-wire NoticeResponse / log line. Composite
6058        // BTree keys land in v7.10.
6059        let _ = &stmt.extra_columns; // intentional drop on engine side
6060        let table_name = stmt.table.clone();
6061        // v6.8.0 — resolve INCLUDE column names to positions. Done
6062        // before `add_index` so a typo error surfaces before any
6063        // catalog mutation lands.
6064        let included_positions: Vec<usize> = if stmt.included_columns.is_empty() {
6065            Vec::new()
6066        } else {
6067            let schema = table.schema();
6068            stmt.included_columns
6069                .iter()
6070                .map(|c| {
6071                    schema.column_position(c).ok_or_else(|| {
6072                        EngineError::Storage(StorageError::ColumnNotFound { column: c.clone() })
6073                    })
6074                })
6075                .collect::<Result<Vec<_>, _>>()?
6076        };
6077        match stmt.method {
6078            IndexMethod::BTree => table.add_index(stmt.name.clone(), &stmt.column)?,
6079            IndexMethod::Hnsw => {
6080                if !included_positions.is_empty() {
6081                    return Err(EngineError::Unsupported(
6082                        "INCLUDE columns are not supported on HNSW indexes".into(),
6083                    ));
6084                }
6085                table.add_nsw_index(stmt.name.clone(), &stmt.column, spg_storage::NSW_DEFAULT_M)?;
6086            }
6087            // v6.7.1 — BRIN. Pure metadata; no in-memory data.
6088            IndexMethod::Brin => {
6089                if !included_positions.is_empty() {
6090                    return Err(EngineError::Unsupported(
6091                        "INCLUDE columns are not supported on BRIN indexes".into(),
6092                    ));
6093                }
6094                table.add_brin_index(stmt.name.clone(), &stmt.column)?;
6095            }
6096            // v7.12.3 — GIN inverted index. Real posting-list-backed
6097            // GIN when the indexed column is `tsvector`; falls back
6098            // to a BTree on the leading column for any other column
6099            // type so v7.9.26b's `pg_dump` compatibility (GIN on
6100            // JSONB etc. silently loading as BTree) is preserved.
6101            // Operators see the real GIN only where it matters; old
6102            // schemas keep loading.
6103            IndexMethod::Gin => {
6104                if !included_positions.is_empty() {
6105                    return Err(EngineError::Unsupported(
6106                        "INCLUDE columns are not supported on GIN indexes".into(),
6107                    ));
6108                }
6109                let col_pos = table
6110                    .schema()
6111                    .column_position(&stmt.column)
6112                    .ok_or_else(|| {
6113                        EngineError::Storage(StorageError::ColumnNotFound {
6114                            column: stmt.column.clone(),
6115                        })
6116                    })?;
6117                let col_ty = table.schema().columns[col_pos].ty;
6118                // v7.15.0 — `gin_trgm_ops` on a TEXT/VARCHAR
6119                // column dispatches to the real trigram-shingle
6120                // GIN build (LIKE / similarity acceleration).
6121                // Other GIN opclasses fall through to the regular
6122                // tsvector-vs-BTree split below.
6123                let is_trgm = stmt
6124                    .opclass
6125                    .as_deref()
6126                    .is_some_and(|op| op.eq_ignore_ascii_case("gin_trgm_ops"));
6127                if is_trgm
6128                    && matches!(
6129                        col_ty,
6130                        spg_storage::DataType::Text | spg_storage::DataType::Varchar(_)
6131                    )
6132                {
6133                    table
6134                        .add_gin_trgm_index(stmt.name.clone(), &stmt.column)
6135                        .map_err(EngineError::Storage)?;
6136                } else if col_ty == spg_storage::DataType::TsVector {
6137                    table
6138                        .add_gin_index(stmt.name.clone(), &stmt.column)
6139                        .map_err(EngineError::Storage)?;
6140                } else {
6141                    // v7.9.26b BTree fallback — the catalog still
6142                    // gets an index entry on the leading column so
6143                    // pg_dump scripts that name GIN on JSONB / etc.
6144                    // load clean; query-time gain stays opt-in for
6145                    // tsvector callers.
6146                    table.add_index(stmt.name.clone(), &stmt.column)?;
6147                }
6148            }
6149        }
6150        if !included_positions.is_empty()
6151            && let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name)
6152        {
6153            idx.included_columns = included_positions;
6154        }
6155        // v6.8.1 — persist partial-index predicate. Stored as the
6156        // expression's Display form so the catalog snapshot stays
6157        // pure (storage has no spg-sql dependency). The runtime
6158        // maintenance path treats partial indexes identically to
6159        // full indexes for v6.8.1 (over-maintenance is safe; the
6160        // planner-side "use partial when query WHERE implies the
6161        // predicate" pass is STABILITY carve-out).
6162        if let Some(pred_expr) = &stmt.partial_predicate {
6163            let canonical = pred_expr.to_string();
6164            // v7.13.2 — mailrs round-6 S2. PG's `pg_trgm` uses
6165            // `CREATE INDEX … USING gin(col gin_trgm_ops) WHERE …`
6166            // routinely to slim trigram indexes. SPG now persists
6167            // the predicate for GIN / BRIN / HNSW the same way it
6168            // already does for BTree — same v6.8.1 "over-maintain
6169            // is safe; planner-side partial routing is STABILITY
6170            // carve-out" semantics. HNSW carries an additional
6171            // caveat: the predicate isn't applied at index build
6172            // time (would require per-row eval inside the NSW
6173            // construction loop), so the index oversamples; query
6174            // time the WHERE clause still filters correctly.
6175            if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
6176                idx.partial_predicate = Some(canonical);
6177            }
6178        }
6179        // v6.8.2 — persist expression index key. Same Display-form
6180        // storage; the runtime maintenance pass evaluates each
6181        // row's expression to derive the index key, but for v6.8.2
6182        // the engine falls through to the bare-column-reference
6183        // path and the expression is preserved for format-layer
6184        // round-trip + future planner work. Carved-out in
6185        // STABILITY § "Out of v6.8".
6186        if let Some(key_expr) = &stmt.expression {
6187            if matches!(
6188                stmt.method,
6189                IndexMethod::Hnsw | IndexMethod::Brin | IndexMethod::Gin
6190            ) {
6191                return Err(EngineError::Unsupported(
6192                    "Expression keys are not supported on HNSW or BRIN indexes".into(),
6193                ));
6194            }
6195            let canonical = key_expr.to_string();
6196            if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
6197                idx.expression = Some(canonical);
6198            }
6199        }
6200        // v7.9.29 — persist `is_unique` flag on the storage Index.
6201        // Combined with `partial_predicate`, INSERT enforcement
6202        // checks that no other row whose predicate evaluates true
6203        // shares the same indexed key. Parser already rejected
6204        // `UNIQUE` on HNSW / BRIN, so plain BTree here.
6205        // For multi-column UNIQUE INDEX the extras matter (the
6206        // full tuple is the uniqueness key), so resolve them to
6207        // column positions and persist on the index too.
6208        if stmt.is_unique {
6209            let mut extra_positions: alloc::vec::Vec<usize> = alloc::vec::Vec::new();
6210            for col_name in &stmt.extra_columns {
6211                let pos = table
6212                    .schema()
6213                    .columns
6214                    .iter()
6215                    .position(|c| c.name.eq_ignore_ascii_case(col_name))
6216                    .ok_or_else(|| {
6217                        EngineError::Unsupported(alloc::format!(
6218                            "UNIQUE INDEX {:?}: extra column {col_name:?} not in table {:?}",
6219                            stmt.name,
6220                            stmt.table
6221                        ))
6222                    })?;
6223                extra_positions.push(pos);
6224            }
6225            if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
6226                idx.is_unique = true;
6227                idx.extra_column_positions = extra_positions;
6228            }
6229            // At index-creation time, check the existing rows for
6230            // pre-existing duplicates that would have violated the
6231            // new constraint — otherwise CREATE UNIQUE INDEX would
6232            // silently leave duplicates in place.
6233            let snapshot_indices = table.indices().to_vec();
6234            let snapshot_rows: alloc::vec::Vec<spg_storage::Row> =
6235                table.rows().iter().cloned().collect();
6236            let snapshot_schema = table.schema().clone();
6237            let idx_ref = snapshot_indices
6238                .iter()
6239                .find(|i| i.name == stmt.name)
6240                .expect("just-added index");
6241            check_existing_unique_violation(idx_ref, &snapshot_schema, &snapshot_rows)?;
6242        }
6243        // v6.3.1 — adding an index can change the optimal plan for
6244        // any cached query that references this table.
6245        self.plan_cache.evict_referencing(&table_name);
6246        Ok(QueryResult::CommandOk {
6247            affected: 0,
6248            modified_catalog: !self.in_transaction(),
6249        })
6250    }
6251
6252    /// v7.13.3 — mailrs round-7 S9. SPG-specific reconciliation
6253    /// for `CREATE TABLE IF NOT EXISTS` when the table already
6254    /// exists. Adds missing columns + inline FKs from the new
6255    /// definition; existing columns / constraints stay untouched.
6256    /// New columns with a `NOT NULL` declaration without a
6257    /// `DEFAULT` are reported as a clear error rather than
6258    /// silently dropped — this is the "fail loud on real
6259    /// incompatibility, fail silent on schema-superset" tradeoff.
6260    fn reconcile_table_if_not_exists(
6261        &mut self,
6262        stmt: CreateTableStatement,
6263    ) -> Result<QueryResult, EngineError> {
6264        let table_name = stmt.name.clone();
6265        let clock = self.clock;
6266        let existing_col_names: alloc::collections::BTreeSet<String> = self
6267            .active_catalog()
6268            .get(&table_name)
6269            .expect("checked above")
6270            .schema()
6271            .columns
6272            .iter()
6273            .map(|c| c.name.to_ascii_lowercase())
6274            .collect();
6275        let row_count = self
6276            .active_catalog()
6277            .get(&table_name)
6278            .expect("checked above")
6279            .row_count();
6280        // Collect missing column defs in source order.
6281        let new_columns: alloc::vec::Vec<spg_sql::ast::ColumnDef> = stmt
6282            .columns
6283            .iter()
6284            .filter(|c| !existing_col_names.contains(&c.name.to_ascii_lowercase()))
6285            .cloned()
6286            .collect();
6287        for col_def in new_columns {
6288            let col_name = col_def.name.clone();
6289            let nullable = col_def.nullable;
6290            let has_default = col_def.default.is_some() || col_def.auto_increment;
6291            let col_schema = column_def_to_schema(col_def)?;
6292            let fill_value: Value = if has_default || col_schema.runtime_default.is_some() {
6293                resolve_column_default_free(&col_schema, clock)?
6294            } else if nullable || row_count == 0 {
6295                Value::Null
6296            } else {
6297                return Err(EngineError::Unsupported(alloc::format!(
6298                    "CREATE TABLE IF NOT EXISTS {table_name:?}: reconciling \
6299                     column {col_name:?} requires DEFAULT (existing rows would violate NOT NULL)"
6300                )));
6301            };
6302            let table = self
6303                .active_catalog_mut()
6304                .get_mut(&table_name)
6305                .expect("checked above");
6306            table.add_column(col_schema, fill_value);
6307        }
6308        // Resolve any newly-added inline FKs (column-level
6309        // REFERENCES forms) and install. Skip FKs whose local
6310        // columns we didn't have in the existing table.
6311        let table_cols_now = self
6312            .active_catalog()
6313            .get(&table_name)
6314            .expect("checked above")
6315            .schema()
6316            .columns
6317            .clone();
6318        for fk in stmt.foreign_keys {
6319            // Only install FKs whose every local column resolves
6320            // — older catalogs may have a column the new FK
6321            // references but not the column the new FK declares.
6322            let all_resolved = fk.columns.iter().all(|c| {
6323                table_cols_now
6324                    .iter()
6325                    .any(|sc| sc.name.eq_ignore_ascii_case(c))
6326            });
6327            if !all_resolved {
6328                continue;
6329            }
6330            let already_present = {
6331                let table = self
6332                    .active_catalog()
6333                    .get(&table_name)
6334                    .expect("checked above");
6335                table.schema().foreign_keys.iter().any(|f| {
6336                    f.parent_table.eq_ignore_ascii_case(&fk.parent_table)
6337                        && f.local_columns.len() == fk.columns.len()
6338                })
6339            };
6340            if already_present {
6341                continue;
6342            }
6343            let storage_fk =
6344                resolve_foreign_key(&table_name, &table_cols_now, fk, self.active_catalog())?;
6345            let table = self
6346                .active_catalog_mut()
6347                .get_mut(&table_name)
6348                .expect("checked above");
6349            table.schema_mut().foreign_keys.push(storage_fk);
6350        }
6351        Ok(QueryResult::CommandOk {
6352            affected: 0,
6353            modified_catalog: !self.in_transaction(),
6354        })
6355    }
6356
6357    /// v7.14.0 — DROP TABLE handler (pg_dump / mysqldump preamble).
6358    fn exec_drop_table(
6359        &mut self,
6360        names: Vec<String>,
6361        if_exists: bool,
6362    ) -> Result<QueryResult, EngineError> {
6363        for name in names {
6364            let dropped = self.active_catalog_mut().drop_table(&name);
6365            if !dropped && !if_exists {
6366                return Err(EngineError::Storage(StorageError::TableNotFound { name }));
6367            }
6368        }
6369        Ok(QueryResult::CommandOk {
6370            affected: 0,
6371            modified_catalog: !self.in_transaction(),
6372        })
6373    }
6374
6375    /// v7.14.0 — DROP INDEX handler.
6376    fn exec_drop_index(
6377        &mut self,
6378        name: String,
6379        if_exists: bool,
6380    ) -> Result<QueryResult, EngineError> {
6381        let dropped = self.active_catalog_mut().drop_named_index(&name);
6382        if !dropped && !if_exists {
6383            return Err(EngineError::Storage(StorageError::IndexNotFound { name }));
6384        }
6385        Ok(QueryResult::CommandOk {
6386            affected: 0,
6387            modified_catalog: !self.in_transaction(),
6388        })
6389    }
6390
6391    fn exec_create_table(
6392        &mut self,
6393        stmt: CreateTableStatement,
6394    ) -> Result<QueryResult, EngineError> {
6395        if stmt.if_not_exists && self.active_catalog().get(&stmt.name).is_some() {
6396            // v7.16.2 — PG-strict silent no-op (mailrs round-10
6397            // surfaced this). v7.13.3's "reconcile by adding
6398            // missing columns" was friendly for mailrs round-7
6399            // where init-schema's `contacts` and migrate-023's
6400            // CardDAV `contacts` collided; but it ALSO silently
6401            // added columns to existing tables when later
6402            // migrations had a duplicate `CREATE TABLE IF NOT
6403            // EXISTS <t> (different-shape-cols)` shape. mailrs's
6404            // migrate-030 has exactly that — re-declares
6405            // system_config with `key` even though init-schema
6406            // already created it with `config_key`. PG's silent
6407            // no-op leaves system_config at `config_key`;
6408            // v7.13.3 added a phantom `key` column that then
6409            // tripped migrate-040's idempotent rename guard.
6410            // mailrs v1.7.106 ships the proper PG-style
6411            // contacts rename via DO + IF EXISTS, so SPG can
6412            // revert to PG-strict here without re-breaking the
6413            // round-7 case.
6414            return Ok(QueryResult::CommandOk {
6415                affected: 0,
6416                modified_catalog: false,
6417            });
6418        }
6419        let table_name = stmt.name.clone();
6420        // v7.9.13 — pluck the names of any columns marked
6421        // `PRIMARY KEY` inline so the post-create-table pass can
6422        // build an implicit BTree index. mailrs F1.
6423        let inline_pk_columns: Vec<String> = stmt
6424            .columns
6425            .iter()
6426            .filter(|c| c.is_primary_key)
6427            .map(|c| c.name.clone())
6428            .collect();
6429        // v7.9.19 — table-level constraints: PRIMARY KEY (a, b, ...)
6430        // and UNIQUE (a, b, ...). Each builds a BTree index on the
6431        // leading column (the existing single-column storage tier)
6432        // and registers a UniquenessConstraint on the schema for
6433        // INSERT-time enforcement of the full tuple. mailrs G1/G6.
6434        let cols = stmt
6435            .columns
6436            .into_iter()
6437            .map(column_def_to_schema)
6438            .collect::<Result<Vec<_>, _>>()?;
6439        // v7.17.0 Phase 1.4 + 1.5 — classify every raw
6440        // user_type_ref (parked as user_enum_type by
6441        // column_def_to_schema) into either an enum binding or a
6442        // domain binding. For domains, also rewrite the column's
6443        // base DataType from the placeholder Text to the domain's
6444        // declared base. Unknown idents are still a hard error
6445        // here (same as Phase 1.4) so silent acceptance never
6446        // happens.
6447        let mut cols = cols;
6448        for col in cols.iter_mut() {
6449            let Some(name) = col.user_enum_type.take() else {
6450                continue;
6451            };
6452            let cat = self.active_catalog();
6453            if cat.enum_types().contains_key(&name) {
6454                col.user_enum_type = Some(name);
6455                continue;
6456            }
6457            if let Some(dom) = cat.domain_types().get(&name) {
6458                col.ty = dom.base_type;
6459                col.user_domain_type = Some(name);
6460                if !dom.nullable {
6461                    col.nullable = false;
6462                }
6463                continue;
6464            }
6465            return Err(EngineError::Unsupported(alloc::format!(
6466                "column {:?}: unknown column type {:?} (not a built-in, ENUM, or DOMAIN)",
6467                col.name,
6468                name
6469            )));
6470        }
6471        for tc in &stmt.table_constraints {
6472            if let spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } = tc {
6473                for col_name in columns {
6474                    if let Some(col) = cols.iter_mut().find(|c| c.name == *col_name) {
6475                        col.nullable = false;
6476                    }
6477                }
6478            }
6479        }
6480        // v7.6.1 — resolve every FK in the statement against the
6481        // already-known catalog. Validates: parent table exists,
6482        // parent column names exist, arity matches, parent columns
6483        // have a PK / UNIQUE index. Self-referencing FKs (parent
6484        // table == this table) resolve against the column list we
6485        // just built — they don't need the catalog yet.
6486        let mut fks: Vec<spg_storage::ForeignKeyConstraint> =
6487            Vec::with_capacity(stmt.foreign_keys.len());
6488        for fk in stmt.foreign_keys {
6489            // v7.14.0 — when SET FOREIGN_KEY_CHECKS=0 is in effect
6490            // (mysqldump preamble + bulk imports), defer FK
6491            // resolution if the parent table isn't in the catalog
6492            // yet. The FK is queued and resolved when checks flip
6493            // back on. Self-references stay in-band (the parent is
6494            // the same as the child we're building).
6495            let needs_parent = !fk.parent_table.eq_ignore_ascii_case(&table_name);
6496            if !self.foreign_key_checks
6497                && needs_parent
6498                && self.active_catalog().get(&fk.parent_table).is_none()
6499            {
6500                self.pending_foreign_keys.push((table_name.clone(), fk));
6501                continue;
6502            }
6503            fks.push(resolve_foreign_key(
6504                &table_name,
6505                &cols,
6506                fk,
6507                self.active_catalog(),
6508            )?);
6509        }
6510        let mut schema = TableSchema::new(table_name.clone(), cols);
6511        schema.foreign_keys = fks;
6512        // v7.9.19 — translate AST table_constraints to storage
6513        // UniquenessConstraints (column name → position) so the
6514        // INSERT enforcement helper sees positions directly.
6515        let mut uc_storage: Vec<spg_storage::UniquenessConstraint> = Vec::new();
6516        let mut check_exprs: Vec<String> = Vec::new();
6517        for tc in &stmt.table_constraints {
6518            let (is_pk, names, nnd) = match tc {
6519                spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } => {
6520                    (true, columns.clone(), false)
6521                }
6522                spg_sql::ast::TableConstraint::Unique {
6523                    columns,
6524                    nulls_not_distinct,
6525                    ..
6526                } => (false, columns.clone(), *nulls_not_distinct),
6527                spg_sql::ast::TableConstraint::Check { expr, .. } => {
6528                    // v7.13.0 — collect CHECK predicate sources;
6529                    // they get attached to the schema below.
6530                    check_exprs.push(alloc::format!("{expr}"));
6531                    continue;
6532                }
6533                // v7.15.0 — plain `KEY (cols)` from MySQL inline
6534                // is NOT a uniqueness constraint; skip the UC
6535                // build path entirely. The BTree index lands in
6536                // the post-create loop below alongside the PK/UQ
6537                // implicit indexes.
6538                spg_sql::ast::TableConstraint::Index { .. } => continue,
6539                // v7.17.0 Phase 2.2 — MySQL FULLTEXT KEY is not
6540                // a uniqueness constraint either; its GIN gets
6541                // built in the post-create loop below.
6542                spg_sql::ast::TableConstraint::FulltextIndex { .. } => continue,
6543            };
6544            let mut positions = Vec::with_capacity(names.len());
6545            for n in &names {
6546                let pos = schema
6547                    .columns
6548                    .iter()
6549                    .position(|c| c.name == *n)
6550                    .ok_or_else(|| {
6551                        EngineError::Unsupported(alloc::format!(
6552                            "table constraint references unknown column {n:?}"
6553                        ))
6554                    })?;
6555                positions.push(pos);
6556            }
6557            uc_storage.push(spg_storage::UniquenessConstraint {
6558                is_primary_key: is_pk,
6559                columns: positions,
6560                nulls_not_distinct: nnd,
6561            });
6562        }
6563        // v7.24 (round-16 collateral) — inline `PRIMARY KEY` column
6564        // constraints used to build only the implicit BTree index;
6565        // uniqueness was NEVER registered, so duplicate keys were
6566        // silently accepted (table-level PRIMARY KEY did enforce).
6567        // Register the same UniquenessConstraint the table-level
6568        // form gets, unless one already covers the column set.
6569        if !inline_pk_columns.is_empty() {
6570            let mut positions = Vec::with_capacity(inline_pk_columns.len());
6571            for n in &inline_pk_columns {
6572                if let Some(pos) = schema.columns.iter().position(|c| c.name == *n) {
6573                    positions.push(pos);
6574                }
6575            }
6576            if !uc_storage
6577                .iter()
6578                .any(|uc| uc.is_primary_key || uc.columns == positions)
6579            {
6580                uc_storage.push(spg_storage::UniquenessConstraint {
6581                    is_primary_key: true,
6582                    columns: positions,
6583                    nulls_not_distinct: false,
6584                });
6585            }
6586        }
6587        schema.uniqueness_constraints = uc_storage.clone();
6588        schema.checks = check_exprs;
6589        self.active_catalog_mut().create_table(schema)?;
6590        // v7.9.13 — implicit BTree per inline PK column +
6591        // v7.9.19 — implicit BTree on the leading column of every
6592        // table-level PRIMARY KEY / UNIQUE constraint.
6593        let table = self
6594            .active_catalog_mut()
6595            .get_mut(&table_name)
6596            .expect("just created");
6597        for (i, col_name) in inline_pk_columns.iter().enumerate() {
6598            let idx_name = if inline_pk_columns.len() == 1 {
6599                alloc::format!("{table_name}_pkey")
6600            } else {
6601                alloc::format!("{table_name}_pkey_{i}")
6602            };
6603            if let Err(e) = table.add_index(idx_name, col_name) {
6604                return Err(EngineError::Storage(e));
6605            }
6606        }
6607        for (i, tc) in stmt.table_constraints.iter().enumerate() {
6608            // v7.17.0 Phase 2.2 — FULLTEXT KEY lands a real
6609            // tsvector-GIN per declared column instead of the
6610            // BTree the PK / UQ / KEY paths build. Branch early
6611            // so the BTree loop never sees the FULLTEXT shape.
6612            if let spg_sql::ast::TableConstraint::FulltextIndex { name, columns } = tc {
6613                for (k, col) in columns.iter().enumerate() {
6614                    let already = table.indices().iter().any(|idx| {
6615                        matches!(idx.kind, spg_storage::IndexKind::GinFulltext(_))
6616                            && table.schema().columns[idx.column_position].name == *col
6617                    });
6618                    if already {
6619                        continue;
6620                    }
6621                    let idx_name = match (name.as_ref(), columns.len(), k) {
6622                        (Some(n), 1, _) => n.clone(),
6623                        (Some(n), _, k) => alloc::format!("{n}_{k}"),
6624                        (None, _, _) => {
6625                            alloc::format!("{table_name}_{col}_ftidx")
6626                        }
6627                    };
6628                    if let Err(e) = table.add_gin_fulltext_index(idx_name, col) {
6629                        return Err(EngineError::Storage(e));
6630                    }
6631                }
6632                continue;
6633            }
6634            // v7.15.0 — plain KEY/INDEX rides this same loop so
6635            // the implicit BTree gets built. It carries its own
6636            // user-supplied name; PK/UQ still synthesise.
6637            let (suffix, names, explicit_name): (&str, &Vec<String>, Option<&String>) = match tc {
6638                spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } => {
6639                    ("pkey", columns, None)
6640                }
6641                spg_sql::ast::TableConstraint::Unique { columns, .. } => ("key", columns, None),
6642                spg_sql::ast::TableConstraint::Index { name, columns } => {
6643                    ("idx", columns, name.as_ref())
6644                }
6645                spg_sql::ast::TableConstraint::Check { .. } => continue,
6646                // Handled by the early-branch above.
6647                spg_sql::ast::TableConstraint::FulltextIndex { .. } => continue,
6648            };
6649            let leading = &names[0];
6650            // Skip if a same-column BTree already exists (e.g.
6651            // inline PK on the leading column).
6652            let already = table.indices().iter().any(|idx| {
6653                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
6654                    && table.schema().columns[idx.column_position].name == *leading
6655            });
6656            if already {
6657                continue;
6658            }
6659            let idx_name = if let Some(n) = explicit_name {
6660                n.clone()
6661            } else if names.len() == 1 {
6662                alloc::format!("{table_name}_{leading}_{suffix}")
6663            } else {
6664                alloc::format!("{table_name}_{leading}_{suffix}_{i}")
6665            };
6666            if let Err(e) = table.add_index(idx_name, leading) {
6667                return Err(EngineError::Storage(e));
6668            }
6669        }
6670        Ok(QueryResult::CommandOk {
6671            affected: 0,
6672            modified_catalog: !self.in_transaction(),
6673        })
6674    }
6675
6676    fn exec_insert(&mut self, mut stmt: InsertStatement) -> Result<QueryResult, EngineError> {
6677        // v7.17.0 Phase 1.1 — pre-resolve any nextval / currval /
6678        // setval calls against the catalog before the row loop. We
6679        // walk each tuple expression and replace matching
6680        // FunctionCall nodes with their concrete Literal. This
6681        // keeps `literal_expr_to_value` free of `&mut self` and
6682        // lets multi-row INSERT VALUES (… nextval('seq') …)
6683        // mint a separate sequence value per row.
6684        for tuple in &mut stmt.rows {
6685            for cell in tuple.iter_mut() {
6686                self.resolve_sequence_calls_in_expr(cell)?;
6687            }
6688        }
6689        // v7.13.0 — `INSERT INTO t [(cols)] SELECT …` (mailrs
6690        // round-5 G4). Execute the inner SELECT first, then route
6691        // back through the regular VALUES code path with the
6692        // materialised rows.
6693        if let Some(select) = stmt.select_source.clone() {
6694            let select_result = self.exec_select_cancel(&select, CancelToken::none())?;
6695            let rows = match select_result {
6696                QueryResult::Rows { rows, .. } => rows,
6697                other => {
6698                    return Err(EngineError::Unsupported(alloc::format!(
6699                        "INSERT … SELECT: inner statement produced {other:?} instead of a row set"
6700                    )));
6701                }
6702            };
6703            let mut materialised: Vec<Vec<Expr>> = Vec::with_capacity(rows.len());
6704            for row in rows {
6705                let mut tuple: Vec<Expr> = Vec::with_capacity(row.values.len());
6706                for v in row.values {
6707                    tuple.push(value_to_literal_expr_permissive(v)?);
6708                }
6709                materialised.push(tuple);
6710            }
6711            let recurse = InsertStatement {
6712                table: stmt.table,
6713                columns: stmt.columns,
6714                rows: materialised,
6715                select_source: None,
6716                on_conflict: stmt.on_conflict,
6717                returning: stmt.returning,
6718            };
6719            return self.exec_insert(recurse);
6720        }
6721        // v7.9.21 — snapshot the clock fn pointer before the mut
6722        // borrow on the catalog opens; runtime DEFAULT eval needs
6723        // it inside the row hot loop.
6724        let clock = self.clock;
6725        // v7.12.4 — snapshot row-level triggers + their referenced
6726        // functions before the mut borrow on the catalog opens.
6727        // Cloned out so the row hot loop can fire them without
6728        // re-borrowing the catalog (which would conflict with
6729        // table.insert's mutable borrow).
6730        let before_insert_triggers = self.snapshot_row_triggers(&stmt.table, "INSERT", "BEFORE");
6731        let after_insert_triggers = self.snapshot_row_triggers(&stmt.table, "INSERT", "AFTER");
6732        let trigger_session_cfg: Option<alloc::string::String> = self
6733            .session_params
6734            .get("default_text_search_config")
6735            .cloned();
6736        // v7.17.0 Phase 1.4 — snapshot the enum label lookup BEFORE
6737        // opening the mutable borrow on the table below. We need
6738        // catalog-level read access (enum_types lives at the
6739        // catalog level, not the table) and the upcoming mutable
6740        // borrow shadows it.
6741        let pre_borrow_column_meta: Vec<ColumnSchema> = {
6742            let preview_table = self.active_catalog().get(&stmt.table).ok_or_else(|| {
6743                EngineError::Storage(StorageError::TableNotFound {
6744                    name: stmt.table.clone(),
6745                })
6746            })?;
6747            preview_table.schema().columns.clone()
6748        };
6749        let enum_label_lookup: alloc::collections::BTreeMap<usize, Vec<String>> =
6750            pre_borrow_column_meta
6751                .iter()
6752                .enumerate()
6753                .filter_map(|(i, col)| {
6754                    // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM
6755                    // variant lists take priority over the PG
6756                    // catalog enum_types lookup (they're
6757                    // column-local and authoritative when set).
6758                    if let Some(inline) = &col.inline_enum_variants {
6759                        return Some((i, inline.clone()));
6760                    }
6761                    col.user_enum_type.as_ref().and_then(|ename| {
6762                        self.active_catalog()
6763                            .enum_types()
6764                            .get(ename)
6765                            .map(|e| (i, e.labels.clone()))
6766                    })
6767                })
6768                .collect();
6769        // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant lists.
6770        // Distinct from enum_label_lookup: SET validates that
6771        // every comma-separated token is in the variant list, and
6772        // canonicalises the cell to definition-order de-duped text.
6773        let set_variant_lookup: alloc::collections::BTreeMap<usize, Vec<String>> =
6774            pre_borrow_column_meta
6775                .iter()
6776                .enumerate()
6777                .filter_map(|(i, col)| col.inline_set_variants.as_ref().map(|vs| (i, vs.clone())))
6778                .collect();
6779        let table = self
6780            .active_catalog_mut()
6781            .get_mut(&stmt.table)
6782            .ok_or_else(|| {
6783                EngineError::Storage(StorageError::TableNotFound {
6784                    name: stmt.table.clone(),
6785                })
6786            })?;
6787        // v3.1.5: clone the columns vector only (not the whole
6788        // TableSchema — saves one String alloc for the table name).
6789        // We need an owned snapshot because we'll call `table.insert`
6790        // (mutable borrow on `table`) inside the row loop while
6791        // reading schema fields.
6792        let column_meta: Vec<ColumnSchema> = table.schema().columns.clone();
6793        let schema_cols_len = column_meta.len();
6794        // Build a permutation `tuple_pos[c] = Some(j)` meaning schema
6795        // column `c` is filled from the `j`-th tuple slot; `None` means
6796        // "fill with NULL". Validated once and reused for every row.
6797        let tuple_pos: Option<Vec<Option<usize>>> = match &stmt.columns {
6798            None => None, // 1-1 mapping, fast path
6799            Some(cols) => {
6800                let mut map = alloc::vec![None; schema_cols_len];
6801                for (j, name) in cols.iter().enumerate() {
6802                    let idx = column_meta
6803                        .iter()
6804                        .position(|c| c.name == *name)
6805                        .ok_or_else(|| {
6806                            EngineError::Eval(EvalError::ColumnNotFound { name: name.clone() })
6807                        })?;
6808                    if map[idx].is_some() {
6809                        return Err(EngineError::Storage(StorageError::ArityMismatch {
6810                            expected: schema_cols_len,
6811                            actual: cols.len(),
6812                        }));
6813                    }
6814                    map[idx] = Some(j);
6815                }
6816                // Omitted columns must either be nullable, carry a
6817                // DEFAULT, or be AUTO_INCREMENT. Catch NOT NULL
6818                // omissions up front so the WAL stays clean.
6819                for (i, col) in column_meta.iter().enumerate() {
6820                    if map[i].is_none()
6821                        && !col.nullable
6822                        && col.default.is_none()
6823                        && col.runtime_default.is_none()
6824                        && !col.auto_increment
6825                    {
6826                        return Err(EngineError::Storage(StorageError::NullInNotNull {
6827                            column: col.name.clone(),
6828                        }));
6829                    }
6830                }
6831                Some(map)
6832            }
6833        };
6834        let expected_tuple_len = stmt.columns.as_ref().map_or(schema_cols_len, Vec::len);
6835        // v7.6.2 — snapshot this table's FK list before the
6836        // mutable-borrow window so we can run parent lookups
6837        // against the immutable catalog after parsing. Empty vec is
6838        // the no-FK fast path; clone cost is O(fks * arity) which
6839        // is < 100 ns for typical schemas.
6840        let fks = table.schema().foreign_keys.clone();
6841        let mut affected = 0usize;
6842        // Stage 1 — parse + AUTO_INC + coerce all rows under the
6843        // single mutable borrow.
6844        let mut all_values: Vec<Vec<Value>> = Vec::with_capacity(stmt.rows.len());
6845        // v7.24 (round-16 collateral) — statement-scoped serial
6846        // cursors. next_auto_value() is a max+1 scan over COMMITTED
6847        // rows; multi-row `INSERT … VALUES (…),(…)` computed it per
6848        // tuple BEFORE any insertion, so every row drew the SAME id
6849        // (then sailed through, compounding with the inline-PK
6850        // enforcement gap). First use per column seeds from the
6851        // table; subsequent rows increment.
6852        let mut auto_cursors: alloc::collections::BTreeMap<usize, i64> =
6853            alloc::collections::BTreeMap::new();
6854        for tuple in stmt.rows {
6855            if tuple.len() != expected_tuple_len {
6856                return Err(EngineError::Storage(StorageError::ArityMismatch {
6857                    expected: expected_tuple_len,
6858                    actual: tuple.len(),
6859                }));
6860            }
6861            // Fast path: no column-list permutation → tuple slot j
6862            // maps to schema column j. We can zip schema with tuple
6863            // and skip the `raw_tuple` staging allocation entirely.
6864            let values: Vec<Value> = if let Some(map) = &tuple_pos {
6865                // Permuted path: still need raw_tuple to index by `map[i]`.
6866                let raw_tuple: Vec<Value> = tuple
6867                    .into_iter()
6868                    .map(literal_expr_to_value)
6869                    .collect::<Result<_, _>>()?;
6870                let mut out = Vec::with_capacity(schema_cols_len);
6871                for (i, col) in column_meta.iter().enumerate() {
6872                    let mut raw = match map[i] {
6873                        Some(j) => raw_tuple[j].clone(),
6874                        None => resolve_column_default_free(col, clock)?,
6875                    };
6876                    if col.auto_increment && raw.is_null() {
6877                        let next = match auto_cursors.get(&i) {
6878                            Some(n) => *n,
6879                            None => table.next_auto_value(i).ok_or_else(|| {
6880                                EngineError::Unsupported(alloc::format!(
6881                                    "AUTO_INCREMENT applies to integer columns only (column `{}`)",
6882                                    col.name
6883                                ))
6884                            })?,
6885                        };
6886                        auto_cursors.insert(i, next + 1);
6887                        raw = Value::BigInt(next);
6888                    }
6889                    let coerced = coerce_value(raw, col.ty, &col.name, i)?;
6890                    enforce_enum_label(&enum_label_lookup, i, &col.name, &coerced)?;
6891                    let coerced =
6892                        canonicalize_set_value(&set_variant_lookup, i, &col.name, coerced)?;
6893                    check_unsigned_range(&coerced, col, i)?;
6894                    out.push(coerced);
6895                }
6896                out
6897            } else {
6898                // 1-1 mapping fast path: single Vec alloc, no raw_tuple.
6899                let mut out = Vec::with_capacity(schema_cols_len);
6900                for (i, (col, expr)) in column_meta.iter().zip(tuple).enumerate() {
6901                    let mut raw = literal_expr_to_value(expr)?;
6902                    if col.auto_increment && raw.is_null() {
6903                        let next = match auto_cursors.get(&i) {
6904                            Some(n) => *n,
6905                            None => table.next_auto_value(i).ok_or_else(|| {
6906                                EngineError::Unsupported(alloc::format!(
6907                                    "AUTO_INCREMENT applies to integer columns only (column `{}`)",
6908                                    col.name
6909                                ))
6910                            })?,
6911                        };
6912                        auto_cursors.insert(i, next + 1);
6913                        raw = Value::BigInt(next);
6914                    }
6915                    let coerced = coerce_value(raw, col.ty, &col.name, i)?;
6916                    enforce_enum_label(&enum_label_lookup, i, &col.name, &coerced)?;
6917                    let coerced =
6918                        canonicalize_set_value(&set_variant_lookup, i, &col.name, coerced)?;
6919                    check_unsigned_range(&coerced, col, i)?;
6920                    out.push(coerced);
6921                }
6922                out
6923            };
6924            all_values.push(values);
6925        }
6926        // Stage 2 — FK enforcement on the immutable catalog.
6927        // Non-lexical lifetimes release the mutable borrow on
6928        // `table` here since stage 1 was the last use. The
6929        // parent-table lookup runs before any row is committed.
6930        let uniqueness = table.schema().uniqueness_constraints.clone();
6931        let _ = table;
6932        if !fks.is_empty() {
6933            enforce_fk_inserts(self.active_catalog(), &stmt.table, &fks, &all_values)?;
6934        }
6935        // v7.13.0 — CHECK constraint enforcement (mailrs round-5 G3).
6936        enforce_check_constraints(self.active_catalog(), &stmt.table, &all_values)?;
6937        // NOTE (mailrs embed round-12): UNIQUE / PRIMARY KEY and
6938        // UNIQUE INDEX enforcement moved BELOW the ON CONFLICT
6939        // resolution pass. Running them first made every
6940        // `ON CONFLICT … DO UPDATE` upsert fail with a uniqueness
6941        // violation before the conflict handler could route the row
6942        // to an UPDATE — PG resolves the conflict action first and
6943        // only errors on rows no arbiter matched.
6944        // v7.9.8 / v7.9.9 — ON CONFLICT handling.
6945        //   - `DO NOTHING` filters `all_values` to non-conflicting
6946        //     rows + drops within-batch duplicates.
6947        //   - `DO UPDATE SET …` ALSO filters, but for each
6948        //     conflicting row it queues an UPDATE on the existing
6949        //     row using the incoming row's values as `EXCLUDED.*`.
6950        let mut pending_updates: Vec<(usize, Vec<Value>)> = Vec::new();
6951        let mut skipped_count = 0usize;
6952        if let Some(clause) = &stmt.on_conflict {
6953            let conflict_cols = resolve_on_conflict_columns(
6954                self.active_catalog(),
6955                &stmt.table,
6956                clause.target_columns.as_slice(),
6957            )?;
6958            let mut kept: Vec<Vec<Value>> = Vec::with_capacity(all_values.len());
6959            let mut seen_keys: Vec<Vec<Value>> = Vec::new();
6960            for values in all_values {
6961                let key_tuple: Vec<&Value> = conflict_cols.iter().map(|&c| &values[c]).collect();
6962                // SQL spec: NULL in any conflict column means "no
6963                // conflict possible" (NULL ≠ NULL for uniqueness).
6964                let has_null_key = key_tuple.iter().any(|v| matches!(v, Value::Null));
6965                let collides_with_table = !has_null_key
6966                    && on_conflict_keys_exist(
6967                        self.active_catalog(),
6968                        &stmt.table,
6969                        &conflict_cols,
6970                        &key_tuple,
6971                    );
6972                let key_tuple_owned: Vec<Value> = key_tuple.iter().map(|v| (*v).clone()).collect();
6973                let collides_with_batch =
6974                    !has_null_key && seen_keys.iter().any(|k| k == &key_tuple_owned);
6975                let collides = collides_with_table || collides_with_batch;
6976                match (&clause.action, collides) {
6977                    (_, false) => {
6978                        seen_keys.push(key_tuple_owned);
6979                        kept.push(values);
6980                    }
6981                    (spg_sql::ast::OnConflictAction::Nothing, true) => {
6982                        skipped_count += 1;
6983                    }
6984                    (
6985                        spg_sql::ast::OnConflictAction::Update {
6986                            assignments,
6987                            where_,
6988                        },
6989                        true,
6990                    ) => {
6991                        if !collides_with_table {
6992                            skipped_count += 1;
6993                            continue;
6994                        }
6995                        let target_pos = lookup_row_position_by_keys(
6996                            self.active_catalog(),
6997                            &stmt.table,
6998                            &conflict_cols,
6999                            &key_tuple,
7000                        )
7001                        .ok_or_else(|| {
7002                            EngineError::Unsupported(
7003                                "ON CONFLICT DO UPDATE: conflict detected but row \
7004                                 position could not be resolved (cold-tier row?)"
7005                                    .into(),
7006                            )
7007                        })?;
7008                        let updated = apply_on_conflict_assignments(
7009                            self.active_catalog(),
7010                            &stmt.table,
7011                            target_pos,
7012                            &values,
7013                            assignments,
7014                            where_.as_ref(),
7015                        )?;
7016                        if let Some(new_row) = updated {
7017                            pending_updates.push((target_pos, new_row));
7018                        } else {
7019                            skipped_count += 1;
7020                        }
7021                    }
7022                }
7023            }
7024            all_values = kept;
7025        }
7026        // v7.9.19 — composite UNIQUE / PRIMARY KEY enforcement.
7027        // v7.9.29 — CREATE UNIQUE INDEX [WHERE pred] enforcement.
7028        // Both run on the post-ON-CONFLICT row set: conflicting rows
7029        // already left `all_values` (DO NOTHING drop / DO UPDATE
7030        // reroute), so what remains must be genuinely unique.
7031        enforce_uniqueness_inserts(self.active_catalog(), &stmt.table, &uniqueness, &all_values)?;
7032        enforce_unique_index_inserts(self.active_catalog(), &stmt.table, &all_values)?;
7033        // Stage 3 — insert all rows under a fresh mutable borrow.
7034        let table = self
7035            .active_catalog_mut()
7036            .get_mut(&stmt.table)
7037            .ok_or_else(|| {
7038                EngineError::Storage(StorageError::TableNotFound {
7039                    name: stmt.table.clone(),
7040                })
7041            })?;
7042        // v7.9.4 — keep RETURNING projection rows separate per
7043        // INSERT and per UPDATE branch so DO UPDATE pushes the new
7044        // post-update state, not the incoming-only values.
7045        let mut returning_rows: Vec<Vec<Value>> = Vec::new();
7046        // v7.12.7 — collect embedded SQL emitted by any trigger
7047        // fire across the row loop; engine drains the queue after
7048        // the table mut borrow drops.
7049        let mut deferred_embedded: Vec<triggers::DeferredEmbeddedStmt> = Vec::new();
7050        'rowloop: for values in all_values {
7051            let mut row = Row::new(values);
7052            // v7.12.4 — BEFORE INSERT row-level triggers. Each
7053            // trigger may rewrite NEW cells (e.g. populate
7054            // `search_vector := to_tsvector(...)`) and may return
7055            // NULL to skip the row entirely.
7056            for fd in &before_insert_triggers {
7057                let (outcome, deferred) = triggers::fire_row_trigger(
7058                    fd,
7059                    Some(row.clone()),
7060                    None,
7061                    &stmt.table,
7062                    &column_meta,
7063                    &[],
7064                    trigger_session_cfg.as_deref(),
7065                    false,
7066                )
7067                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
7068                deferred_embedded.extend(deferred);
7069                match outcome {
7070                    triggers::TriggerOutcome::Row(r) => row = r,
7071                    triggers::TriggerOutcome::Skip => continue 'rowloop,
7072                }
7073            }
7074            if stmt.returning.is_some() {
7075                returning_rows.push(row.values.clone());
7076            }
7077            // v7.12.4 — clone for the AFTER trigger view; insert
7078            // moves the row into the table.
7079            let inserted = row.clone();
7080            table.insert(row)?;
7081            affected += 1;
7082            // v7.12.4 — AFTER INSERT row-level triggers fire post-
7083            // write. Return value is ignored (PG semantics); we
7084            // surface any error from the body up to the caller.
7085            for fd in &after_insert_triggers {
7086                let (_outcome, deferred) = triggers::fire_row_trigger(
7087                    fd,
7088                    Some(inserted.clone()),
7089                    None,
7090                    &stmt.table,
7091                    &column_meta,
7092                    &[],
7093                    trigger_session_cfg.as_deref(),
7094                    true,
7095                )
7096                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
7097                deferred_embedded.extend(deferred);
7098            }
7099        }
7100        // v7.9.9 — apply ON CONFLICT DO UPDATE rewrites collected
7101        // in the conflict-resolution pass. update_row handles
7102        // index maintenance + body re-encoding.
7103        for (pos, new_row) in pending_updates {
7104            if stmt.returning.is_some() {
7105                returning_rows.push(new_row.clone());
7106            }
7107            table.update_row(pos, new_row)?;
7108            affected += 1;
7109        }
7110        let _ = skipped_count;
7111        // v7.12.7 — drop the table mut borrow and drain any
7112        // trigger-emitted embedded SQL queued during this INSERT.
7113        // The borrow has to release first because each deferred
7114        // stmt may UPDATE / INSERT / DELETE the same (or another)
7115        // table — including, in principle, this one.
7116        let _ = table;
7117        self.execute_deferred_trigger_stmts(deferred_embedded, CancelToken::none())?;
7118        // v7.9.4/v7.9.9 — RETURNING streams the rows that ended
7119        // up in the table after this statement (insert or
7120        // post-update on conflict).
7121        if let Some(items) = &stmt.returning {
7122            return self.build_returning_rows(&stmt.table, items, returning_rows);
7123        }
7124        // v6.2.1 — auto-analyze: track per-table modified-row
7125        // counter so the background sweep can decide when to
7126        // re-ANALYZE. Cheap path on the autocommit-wrap hot loop
7127        // — one BTreeMap entry update per INSERT batch.
7128        if !self.in_transaction() && affected > 0 {
7129            self.statistics
7130                .record_modifications(&stmt.table, affected as u64);
7131        }
7132        Ok(QueryResult::CommandOk {
7133            affected,
7134            modified_catalog: !self.in_transaction(),
7135        })
7136    }
7137
7138    /// v4.5: SELECT with cooperative cancellation. The token is
7139    /// honoured between UNION peers and inside the bare-SELECT row
7140    /// loop; HNSW kNN graph walks and the aggregate executor don't
7141    /// honour it yet (deferred — those paths bound their work
7142    /// internally by `LIMIT k` and `GROUP BY` cardinality).
7143    /// v6.10.2 — cold-tier time-travel scan. Resolves the segment
7144    /// by id, decodes each row body against the table's current
7145    /// schema, applies the SELECT's projection + optional WHERE +
7146    /// optional LIMIT, returns a `Rows` result. JOINs / aggregates
7147    /// / ORDER BY are unsupported on this path (STABILITY carve-
7148    /// out); operators wanting them should restore the segment
7149    /// into a regular table first.
7150    fn exec_select_as_of_segment(
7151        &self,
7152        stmt: &SelectStatement,
7153        from: &spg_sql::ast::FromClause,
7154        segment_id: u32,
7155    ) -> Result<QueryResult, EngineError> {
7156        // v6.10.2 scope: no joins, no aggregates, no ORDER BY,
7157        // no GROUP BY / HAVING / UNION / OFFSET / DISTINCT.
7158        if !from.joins.is_empty()
7159            || stmt.group_by.is_some()
7160            || stmt.having.is_some()
7161            || !stmt.unions.is_empty()
7162            || !stmt.order_by.is_empty()
7163            || stmt.offset.is_some()
7164            || stmt.distinct
7165            || aggregate::uses_aggregate(stmt)
7166        {
7167            return Err(EngineError::Unsupported(
7168                "AS OF SEGMENT supports SELECT projection + WHERE + LIMIT only \
7169                 (joins / aggregates / ORDER BY are STABILITY § \"Out of v6.10\")"
7170                    .into(),
7171            ));
7172        }
7173        let table = self
7174            .active_catalog()
7175            .get(&from.primary.name)
7176            .ok_or_else(|| StorageError::TableNotFound {
7177                name: from.primary.name.clone(),
7178            })?;
7179        let schema = table.schema().clone();
7180        let schema_cols = &schema.columns;
7181        let alias = from
7182            .primary
7183            .alias
7184            .as_deref()
7185            .unwrap_or(from.primary.name.as_str());
7186        let ctx = EvalContext::new(schema_cols, Some(alias));
7187        let seg = self
7188            .active_catalog()
7189            .cold_segment(segment_id)
7190            .ok_or_else(|| {
7191                EngineError::Unsupported(alloc::format!(
7192                    "AS OF SEGMENT: cold segment {segment_id} not registered"
7193                ))
7194            })?;
7195        let mut out_rows: Vec<Row> = Vec::new();
7196        let mut limit_remaining: Option<usize> =
7197            stmt.limit_literal().and_then(|n| usize::try_from(n).ok());
7198        for (_key, body) in seg.scan() {
7199            let (row, _consumed) =
7200                spg_storage::decode_row_body_dense(&body, &schema, seg.long_strings())
7201                    .map_err(EngineError::Storage)?;
7202            if let Some(where_expr) = &stmt.where_ {
7203                let cond = self.eval_expr_simple(where_expr, &row, &ctx)?;
7204                if !matches!(cond, Value::Bool(true)) {
7205                    continue;
7206                }
7207            }
7208            // Projection.
7209            let projected = self.project_row_simple(&row, &stmt.items, schema_cols, alias)?;
7210            out_rows.push(projected);
7211            if let Some(rem) = limit_remaining.as_mut() {
7212                if *rem == 0 {
7213                    out_rows.pop();
7214                    break;
7215                }
7216                *rem -= 1;
7217            }
7218        }
7219        // Output column schema: derive from SELECT items.
7220        let columns = self.derive_output_columns(&stmt.items, schema_cols, alias);
7221        Ok(QueryResult::Rows {
7222            columns,
7223            rows: out_rows,
7224        })
7225    }
7226
7227    /// v6.10.2 — simple-path WHERE eval that doesn't go through
7228    /// the correlated-subquery / Memoize machinery. AS OF SEGMENT
7229    /// scan paths predicate against a snapshot frozen segment, no
7230    /// cross-row state.
7231    fn eval_expr_simple(
7232        &self,
7233        expr: &Expr,
7234        row: &Row,
7235        ctx: &EvalContext,
7236    ) -> Result<Value, EngineError> {
7237        let cancel = CancelToken::none();
7238        self.eval_expr_with_correlated(expr, row, ctx, cancel, None)
7239    }
7240
7241    /// v7.9.4 — INSERT / UPDATE / DELETE RETURNING projector.
7242    /// Given the table name, the user-supplied projection items,
7243    /// and the mutated rows (post-insert / post-update values, or
7244    /// pre-delete snapshot), build a `QueryResult::Rows` whose
7245    /// schema describes the projected columns. Mailrs migration
7246    /// blocker #1.
7247    fn build_returning_rows(
7248        &self,
7249        table_name: &str,
7250        items: &[SelectItem],
7251        mutated_rows: Vec<Vec<Value>>,
7252    ) -> Result<QueryResult, EngineError> {
7253        let table = self.active_catalog().get(table_name).ok_or_else(|| {
7254            EngineError::Storage(StorageError::TableNotFound {
7255                name: table_name.into(),
7256            })
7257        })?;
7258        let schema_cols = table.schema().columns.clone();
7259        let columns = self.derive_output_columns(items, &schema_cols, table_name);
7260        let mut out_rows: Vec<Row> = Vec::with_capacity(mutated_rows.len());
7261        for values in mutated_rows {
7262            let row = Row::new(values);
7263            let projected = self.project_row_simple(&row, items, &schema_cols, table_name)?;
7264            out_rows.push(projected);
7265        }
7266        Ok(QueryResult::Rows {
7267            columns,
7268            rows: out_rows,
7269        })
7270    }
7271
7272    /// v6.10.2 — projection for AS OF SEGMENT. Resolves
7273    /// `SelectItem::Wildcard` to all schema columns and
7274    /// `SelectItem::Expr` via the regular eval path.
7275    fn project_row_simple(
7276        &self,
7277        row: &Row,
7278        items: &[SelectItem],
7279        schema_cols: &[ColumnSchema],
7280        alias: &str,
7281    ) -> Result<Row, EngineError> {
7282        let ctx = EvalContext::new(schema_cols, Some(alias));
7283        let cancel = CancelToken::none();
7284        let mut out_vals = Vec::new();
7285        for item in items {
7286            match item {
7287                SelectItem::Wildcard => {
7288                    out_vals.extend(row.values.iter().cloned());
7289                }
7290                SelectItem::Expr { expr, .. } => {
7291                    let v = self.eval_expr_with_correlated(expr, row, &ctx, cancel, None)?;
7292                    out_vals.push(v);
7293                }
7294            }
7295        }
7296        Ok(Row::new(out_vals))
7297    }
7298
7299    /// v6.10.2 — derive the output `ColumnSchema` list for an
7300    /// AS OF SEGMENT projection. Wildcards take the full schema;
7301    /// expressions take the alias if present or a synthetic
7302    /// `?column?` (PG convention) otherwise.
7303    fn derive_output_columns(
7304        &self,
7305        items: &[SelectItem],
7306        schema_cols: &[ColumnSchema],
7307        _alias: &str,
7308    ) -> Vec<ColumnSchema> {
7309        let mut out = Vec::new();
7310        for item in items {
7311            match item {
7312                SelectItem::Wildcard => {
7313                    out.extend(schema_cols.iter().cloned());
7314                }
7315                SelectItem::Expr { expr, alias } => {
7316                    // Bare column references inherit the schema
7317                    // column's name + type — PG names `RETURNING id`
7318                    // "id" and types it BIGINT, and the sqlx embed
7319                    // path type-checks RowDescription against the
7320                    // Rust target (mailrs embed round-12).
7321                    if let Expr::Column(col) = expr
7322                        && let Some(sc) = schema_cols.iter().find(|c| c.name == col.name)
7323                    {
7324                        let name = alias.clone().unwrap_or_else(|| sc.name.clone());
7325                        out.push(ColumnSchema::new(name, sc.ty, sc.nullable));
7326                        continue;
7327                    }
7328                    let name = alias.clone().unwrap_or_else(|| "?column?".to_string());
7329                    // Default to Text; the caller's row values
7330                    // carry the actual type. v6.10.2 scope.
7331                    out.push(ColumnSchema::new(name, DataType::Text, true));
7332                }
7333            }
7334        }
7335        out
7336    }
7337
7338    fn exec_select_cancel(
7339        &self,
7340        stmt: &SelectStatement,
7341        cancel: CancelToken<'_>,
7342    ) -> Result<QueryResult, EngineError> {
7343        cancel.check()?;
7344        // v7.17.0 Phase 1.2 — user-defined VIEW expansion. If the
7345        // FROM / JOIN graph references any catalogued view name,
7346        // re-parse the view body and prepend it as a synthetic
7347        // CTE. Recurses on views-in-views via the regular CTE
7348        // dispatch below. Fast-path: skip the walker entirely when
7349        // the catalog has no views (the typical OLTP load).
7350        if !self.active_catalog().views().is_empty() {
7351            if let Some(rewritten) = self.expand_views_in_select(stmt)? {
7352                return self.exec_select_cancel(&rewritten, cancel);
7353            }
7354        }
7355        // v7.16.2 — information_schema / pg_catalog virtual
7356        // views (mailrs round-10 A.3). If the SELECT touches a
7357        // synthetic meta-table name (`__spg_info_*` /
7358        // `__spg_pg_*` — produced by the parser for
7359        // `information_schema.X` / `pg_catalog.X`), clone the
7360        // catalog, materialise the requested view as a real
7361        // temporary table, and re-execute against an enriched
7362        // engine. Same pattern as `exec_with_ctes` for CTEs.
7363        if !self.meta_views_materialised && select_references_meta_view(stmt) {
7364            return self.exec_select_with_meta_views(stmt, cancel);
7365        }
7366        // v6.10.2 — cold-tier time-travel short-circuit. When the
7367        // primary TableRef carries `AS OF SEGMENT '<id>'`, run a
7368        // dedicated cold-segment scan instead of the regular
7369        // hot+index path. The scope is intentionally narrow for
7370        // v6.10.2 — bare `SELECT * FROM <t> AS OF SEGMENT 'id'`,
7371        // optionally with a single-column-equality WHERE. JOINs /
7372        // aggregates / ORDER BY / subqueries on top of a time-
7373        // travelled scan are STABILITY § "Out of v6.10".
7374        if let Some(from) = &stmt.from
7375            && let Some(seg_id) = from.primary.as_of_segment
7376        {
7377            return self.exec_select_as_of_segment(stmt, from, seg_id);
7378        }
7379        // v6.2.0 / v6.5.0 — virtual-table short-circuits. Detected
7380        // pre-CTE because they don't read from the catalog and
7381        // shouldn't participate in regular FROM resolution.
7382        if let Some(from) = &stmt.from
7383            && from.joins.is_empty()
7384            && stmt.where_.is_none()
7385            && stmt.group_by.is_none()
7386            && stmt.having.is_none()
7387            && stmt.unions.is_empty()
7388            && stmt.order_by.is_empty()
7389            && stmt.limit.is_none()
7390            && stmt.offset.is_none()
7391            && !stmt.distinct
7392            && stmt.items.iter().all(|i| matches!(i, SelectItem::Wildcard))
7393        {
7394            let lower = from.primary.name.to_ascii_lowercase();
7395            match lower.as_str() {
7396                "spg_statistic" => return Ok(self.exec_spg_statistic()),
7397                // v6.5.0 — observability v2 virtual tables.
7398                "spg_stat_replication" => return Ok(self.exec_spg_stat_replication()),
7399                "spg_stat_segment" => return Ok(self.exec_spg_stat_segment()),
7400                "spg_stat_query" => return Ok(self.exec_spg_stat_query()),
7401                "spg_stat_activity" => return Ok(self.exec_spg_stat_activity()),
7402                "spg_audit_chain" => return Ok(self.exec_spg_audit_chain()),
7403                "spg_audit_verify" => return Ok(self.exec_spg_audit_verify()),
7404                "spg_table_ddl" => return Ok(self.exec_spg_table_ddl()),
7405                "spg_role_ddl" => return Ok(self.exec_spg_role_ddl()),
7406                "spg_database_ddl" => return Ok(self.exec_spg_database_ddl()),
7407                _ => {}
7408            }
7409        }
7410        // v4.11: CTEs materialise into a temporary enriched catalog
7411        // *before* anything else — the body SELECT can then refer
7412        // to CTE names via the regular FROM-clause resolution.
7413        // Uncorrelated only: each CTE body runs once against the
7414        // current catalog, not against later CTEs' results (left-
7415        // to-right materialisation would relax this, but we keep
7416        // it simple for v4.11 MVP).
7417        if !stmt.ctes.is_empty() {
7418            return self.exec_with_ctes(stmt, cancel);
7419        }
7420        // v4.10: subqueries (uncorrelated) are resolved here, before
7421        // the executor sees the row loop. We clone the statement so
7422        // we can mutate without disturbing the caller's AST — most
7423        // queries pass through with no subquery nodes and the clone
7424        // is cheap; with subqueries the materialisation cost
7425        // dominates anyway.
7426        let mut stmt_owned;
7427        let stmt_ref: &SelectStatement = if expr_tree_has_subquery(stmt) {
7428            stmt_owned = stmt.clone();
7429            self.resolve_select_subqueries(&mut stmt_owned, cancel)?;
7430            &stmt_owned
7431        } else {
7432            stmt
7433        };
7434        if stmt_ref.unions.is_empty() {
7435            return self.exec_bare_select_cancel(stmt_ref, cancel);
7436        }
7437        // UNION path: clone-strip the head into a bare block (its own
7438        // DISTINCT and any inner ORDER BY are dropped by parser rule —
7439        // the wrapper SelectStatement carries them), execute, then chain
7440        // peers with left-associative dedup semantics.
7441        let mut head = stmt_ref.clone();
7442        head.unions = Vec::new();
7443        head.order_by = Vec::new();
7444        head.limit = None;
7445        let QueryResult::Rows { columns, mut rows } =
7446            self.exec_bare_select_cancel(&head, cancel)?
7447        else {
7448            unreachable!("bare SELECT cannot return CommandOk")
7449        };
7450        for (kind, peer) in &stmt_ref.unions {
7451            let QueryResult::Rows {
7452                columns: peer_cols,
7453                rows: peer_rows,
7454            } = self.exec_bare_select_cancel(peer, cancel)?
7455            else {
7456                unreachable!("bare SELECT cannot return CommandOk")
7457            };
7458            if peer_cols.len() != columns.len() {
7459                return Err(EngineError::Unsupported(alloc::format!(
7460                    "UNION arity mismatch: head has {} columns, peer has {}",
7461                    columns.len(),
7462                    peer_cols.len()
7463                )));
7464            }
7465            rows.extend(peer_rows);
7466            if matches!(kind, UnionKind::Distinct) {
7467                rows = dedup_rows(rows);
7468            }
7469        }
7470        // ORDER BY at the top of a UNION applies to the combined result.
7471        // Eval against the projected schema (NOT the source table).
7472        if !stmt.order_by.is_empty() {
7473            let synth_ctx = EvalContext::new(&columns, None);
7474            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
7475            let mut tagged: Vec<(Vec<f64>, Row)> = Vec::with_capacity(rows.len());
7476            for r in rows {
7477                let keys = build_order_keys(&stmt.order_by, &r, &synth_ctx)?;
7478                tagged.push((keys, r));
7479            }
7480            sort_by_keys(&mut tagged, &descs);
7481            rows = tagged.into_iter().map(|(_, r)| r).collect();
7482        }
7483        apply_offset_and_limit(&mut rows, stmt.offset_literal(), stmt.limit_literal());
7484        Ok(QueryResult::Rows { columns, rows })
7485    }
7486
7487    #[allow(clippy::too_many_lines)]
7488    #[allow(clippy::too_many_lines)] // huge match — splitting fragments the planner
7489    /// v7.11.7 — execute `SELECT … FROM unnest(expr) [AS] alias …`.
7490    /// Synthesises a single-column virtual table whose column type
7491    /// is TEXT and whose rows are the array elements. Routes
7492    /// through the regular projection / WHERE / ORDER BY / LIMIT
7493    /// machinery so set-returning UNNEST composes naturally with
7494    /// the rest of the SELECT surface.
7495    fn exec_select_unnest(
7496        &self,
7497        stmt: &SelectStatement,
7498        primary: &TableRef,
7499        cancel: CancelToken<'_>,
7500    ) -> Result<QueryResult, EngineError> {
7501        let expr = primary
7502            .unnest_expr
7503            .as_deref()
7504            .expect("caller guards unnest_expr.is_some()");
7505        // Evaluate the array expression once. Empty schema / empty
7506        // row — uncorrelated UNNEST cannot reference outer columns.
7507        let empty_schema: alloc::vec::Vec<ColumnSchema> = alloc::vec::Vec::new();
7508        let ctx = EvalContext::new(&empty_schema, None);
7509        let dummy_row = Row::new(alloc::vec::Vec::new());
7510        // v7.11.13 — unnest dispatches per array element type so
7511        // INT[] / BIGINT[] surface their PG types in projection.
7512        let (elem_dtype, rows): (DataType, alloc::vec::Vec<Row>) =
7513            match eval::eval_expr(expr, &dummy_row, &ctx).map_err(EngineError::Eval)? {
7514                Value::Null => (DataType::Text, alloc::vec::Vec::new()),
7515                Value::TextArray(items) => {
7516                    let rows = items
7517                        .into_iter()
7518                        .map(|item| {
7519                            Row::new(alloc::vec![match item {
7520                                Some(s) => Value::Text(s),
7521                                None => Value::Null,
7522                            }])
7523                        })
7524                        .collect();
7525                    (DataType::Text, rows)
7526                }
7527                Value::IntArray(items) => {
7528                    let rows = items
7529                        .into_iter()
7530                        .map(|item| {
7531                            Row::new(alloc::vec![match item {
7532                                Some(n) => Value::Int(n),
7533                                None => Value::Null,
7534                            }])
7535                        })
7536                        .collect();
7537                    (DataType::Int, rows)
7538                }
7539                Value::BigIntArray(items) => {
7540                    let rows = items
7541                        .into_iter()
7542                        .map(|item| {
7543                            Row::new(alloc::vec![match item {
7544                                Some(n) => Value::BigInt(n),
7545                                None => Value::Null,
7546                            }])
7547                        })
7548                        .collect();
7549                    (DataType::BigInt, rows)
7550                }
7551                other => {
7552                    return Err(EngineError::Unsupported(alloc::format!(
7553                        "unnest() expects an array argument, got {:?}",
7554                        other.data_type()
7555                    )));
7556                }
7557            };
7558        let alias = primary
7559            .alias
7560            .clone()
7561            .unwrap_or_else(|| "unnest".to_string());
7562        // v7.13.2 — mailrs round-6 S5. Honour PG-standard
7563        // `UNNEST(arr) AS p(col_name)` column-list aliasing: the
7564        // first entry overrides the projected column's name.
7565        // Without the column list, fall back to the table alias
7566        // (pre-v7.13.2 behaviour).
7567        let col_name = primary
7568            .unnest_column_aliases
7569            .first()
7570            .cloned()
7571            .unwrap_or_else(|| alias.clone());
7572        let col_schema = ColumnSchema::new(col_name, elem_dtype, true);
7573        let schema_cols = alloc::vec![col_schema.clone()];
7574        let scan_ctx = EvalContext::new(&schema_cols, Some(&alias));
7575        // Apply WHERE.
7576        let filtered: alloc::vec::Vec<Row> = if let Some(w) = &stmt.where_ {
7577            let mut out = alloc::vec::Vec::with_capacity(rows.len());
7578            for row in rows {
7579                cancel.check()?;
7580                let v = eval::eval_expr(w, &row, &scan_ctx).map_err(EngineError::Eval)?;
7581                if matches!(v, Value::Bool(true)) {
7582                    out.push(row);
7583                }
7584            }
7585            out
7586        } else {
7587            rows
7588        };
7589        // v7.17.0 Phase 3.P0-48 — aggregate dispatch over the
7590        // unnest source. Same routing the relational scan path
7591        // already takes — without it `SELECT COUNT(*) FROM
7592        // unnest(ARRAY[…])` either errored at projection time or
7593        // returned the wrong shape.
7594        if aggregate::uses_aggregate(stmt) {
7595            let agg_correlated = |e: &Expr, r: &Row, c: &EvalContext<'_>| {
7596                self.eval_expr_with_correlated(e, r, c, cancel, None)
7597                    .map_err(|err| match err {
7598                        EngineError::Eval(ev) => ev,
7599                        other => eval::EvalError::TypeMismatch {
7600                            detail: alloc::format!("{other}"),
7601                        },
7602                    })
7603            };
7604            let filtered_refs: alloc::vec::Vec<&Row> = filtered.iter().collect();
7605            let mut agg = aggregate::run(
7606                stmt,
7607                &filtered_refs,
7608                &schema_cols,
7609                Some(&alias),
7610                Some(&agg_correlated),
7611            )?;
7612            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
7613            return Ok(QueryResult::Rows {
7614                columns: agg.columns,
7615                rows: agg.rows,
7616            });
7617        }
7618        // Projection.
7619        let projection = build_projection(&stmt.items, &schema_cols, &alias)?;
7620        let mut projected_rows: alloc::vec::Vec<Row> =
7621            alloc::vec::Vec::with_capacity(filtered.len());
7622        // v7.19 P5 — Set-Returning-Function in projection
7623        // position (PG `SELECT unnest(arr) FROM t` shape). When a
7624        // SELECT item evaluates to a top-level unnest(arr) call,
7625        // expand it: for each input row, evaluate the array, emit
7626        // one output row per element, broadcasting non-SRF
7627        // projections from the same input row. Multi-SRF + LCM
7628        // padding stays a documented carve-out; mailrs uses
7629        // single-SRF for redirect_uris.
7630        let srf_position = projection.iter().position(|p| is_top_level_unnest(&p.expr));
7631        if let Some(srf_idx) = srf_position {
7632            let srf_arg = top_level_unnest_arg(&projection[srf_idx].expr)
7633                .expect("checked by is_top_level_unnest above");
7634            for row in &filtered {
7635                let arr_val =
7636                    eval::eval_expr(srf_arg, row, &scan_ctx).map_err(EngineError::Eval)?;
7637                let elements = array_value_to_elements(&arr_val)?;
7638                // Empty array → zero rows for this input row (PG
7639                // semantics: `SELECT unnest('{}'::int[])` returns
7640                // 0 rows, not a single NULL row).
7641                for elem in elements {
7642                    let mut vals = alloc::vec::Vec::with_capacity(projection.len());
7643                    for (i, p) in projection.iter().enumerate() {
7644                        if i == srf_idx {
7645                            vals.push(elem.clone());
7646                        } else {
7647                            vals.push(
7648                                eval::eval_expr(&p.expr, row, &scan_ctx)
7649                                    .map_err(EngineError::Eval)?,
7650                            );
7651                        }
7652                    }
7653                    projected_rows.push(Row::new(vals));
7654                }
7655            }
7656        } else {
7657            // v7.24 (round-16 B) — select-list subqueries resolve
7658            // per row (correlated-aware; plain exprs take the fast
7659            // path inside).
7660            let mut proj_memo = memoize::MemoizeCache::default();
7661            for row in &filtered {
7662                let mut vals = alloc::vec::Vec::with_capacity(projection.len());
7663                for p in &projection {
7664                    vals.push(self.eval_expr_with_correlated(
7665                        &p.expr,
7666                        row,
7667                        &scan_ctx,
7668                        cancel,
7669                        Some(&mut proj_memo),
7670                    )?);
7671                }
7672                projected_rows.push(Row::new(vals));
7673            }
7674        }
7675        // ORDER BY / LIMIT — apply on the projected rows (cheap;
7676        // unnest result sets are small by design).
7677        let columns: alloc::vec::Vec<ColumnSchema> = projection
7678            .iter()
7679            .map(|p| ColumnSchema::new(p.output_name.clone(), p.ty, p.nullable))
7680            .collect();
7681        // Re-evaluate ORDER BY against the source schema (pre-projection
7682        // so col refs by name still resolve through `scan_ctx`).
7683        if !stmt.order_by.is_empty() {
7684            let mut indexed: alloc::vec::Vec<(usize, Vec<Value>)> = filtered
7685                .iter()
7686                .enumerate()
7687                .map(|(i, r)| -> Result<_, EngineError> {
7688                    let keys: Result<Vec<Value>, EngineError> = stmt
7689                        .order_by
7690                        .iter()
7691                        .map(|ob| {
7692                            eval::eval_expr(&ob.expr, r, &scan_ctx).map_err(EngineError::Eval)
7693                        })
7694                        .collect();
7695                    Ok((i, keys?))
7696                })
7697                .collect::<Result<_, _>>()?;
7698            indexed.sort_by(|a, b| {
7699                for (idx, (ka, kb)) in a.1.iter().zip(b.1.iter()).enumerate() {
7700                    let o = &stmt.order_by[idx];
7701                    let cmp = order_by_value_cmp(o.desc, o.nulls_first, ka, kb);
7702                    if cmp != core::cmp::Ordering::Equal {
7703                        return cmp;
7704                    }
7705                }
7706                core::cmp::Ordering::Equal
7707            });
7708            projected_rows = indexed
7709                .into_iter()
7710                .map(|(i, _)| projected_rows[i].clone())
7711                .collect();
7712        }
7713        // LIMIT / OFFSET — apply at the tail.
7714        if let Some(offset) = stmt.offset_literal() {
7715            let off = (offset as usize).min(projected_rows.len());
7716            projected_rows.drain(..off);
7717        }
7718        if let Some(limit) = stmt.limit_literal() {
7719            projected_rows.truncate(limit as usize);
7720        }
7721        Ok(QueryResult::Rows {
7722            columns,
7723            rows: projected_rows,
7724        })
7725    }
7726
7727    /// v7.17.0 Phase 3.10 — `FROM generate_series(start, stop [,
7728    /// step])` set-returning source. Mirrors `exec_select_unnest`'s
7729    /// shape: evaluate the arg list once against an empty row,
7730    /// materialise the row stream by stepping start → stop, then
7731    /// route through the standard WHERE / projection / ORDER BY /
7732    /// LIMIT pipeline. Two arg-type combos in v7.17:
7733    ///   * integer / integer [/ integer] — SmallInt, Int, BigInt
7734    ///     (widened to BigInt internally; step defaults to 1)
7735    ///   * timestamp / timestamp / interval — date-range
7736    ///     iteration (mailrs's daily-report pattern)
7737    fn exec_select_generate_series(
7738        &self,
7739        stmt: &SelectStatement,
7740        primary: &TableRef,
7741        cancel: CancelToken<'_>,
7742    ) -> Result<QueryResult, EngineError> {
7743        let args = primary
7744            .generate_series_args
7745            .as_ref()
7746            .expect("caller guards generate_series_args.is_some()");
7747        let empty_schema: alloc::vec::Vec<ColumnSchema> = alloc::vec::Vec::new();
7748        let ctx = EvalContext::new(&empty_schema, None);
7749        let dummy_row = Row::new(alloc::vec::Vec::new());
7750        let mut arg_values: alloc::vec::Vec<Value> = alloc::vec::Vec::with_capacity(args.len());
7751        for a in args {
7752            arg_values.push(eval::eval_expr(a, &dummy_row, &ctx).map_err(EngineError::Eval)?);
7753        }
7754        // Dispatch on the start value's shape. Reject mixed-shape
7755        // calls early (e.g. start = timestamp, stop = integer) so
7756        // the caller gets a clean error rather than a panic.
7757        let (elem_dtype, rows) = match arg_values.as_slice() {
7758            [Value::Timestamp(start), Value::Timestamp(stop), step] => {
7759                let interval_step = match step {
7760                    Value::Interval { .. } => step.clone(),
7761                    other => {
7762                        return Err(EngineError::Unsupported(alloc::format!(
7763                            "generate_series(timestamp, timestamp, …): \
7764                             step must be INTERVAL, got {:?}",
7765                            other.data_type()
7766                        )));
7767                    }
7768                };
7769                let rows = generate_series_timestamps(*start, *stop, interval_step, &cancel)?;
7770                (DataType::Timestamp, rows)
7771            }
7772            [start, stop, step]
7773                if value_is_integer(start) && value_is_integer(stop) && value_is_integer(step) =>
7774            {
7775                let s = value_to_i64(start);
7776                let e = value_to_i64(stop);
7777                let st = value_to_i64(step);
7778                let rows = generate_series_integers(s, e, st, &cancel)?;
7779                (DataType::BigInt, rows)
7780            }
7781            [start, stop] if value_is_integer(start) && value_is_integer(stop) => {
7782                let s = value_to_i64(start);
7783                let e = value_to_i64(stop);
7784                let rows = generate_series_integers(s, e, 1, &cancel)?;
7785                (DataType::BigInt, rows)
7786            }
7787            _ => {
7788                return Err(EngineError::Unsupported(alloc::format!(
7789                    "generate_series(): v7.17 supports integer or (timestamp, timestamp, interval) \
7790                     argument shapes; got {:?}",
7791                    arg_values
7792                        .iter()
7793                        .map(|v| v.data_type())
7794                        .collect::<alloc::vec::Vec<_>>()
7795                )));
7796            }
7797        };
7798        let alias = primary
7799            .alias
7800            .clone()
7801            .unwrap_or_else(|| "generate_series".to_string());
7802        let col_name = alias.clone();
7803        let col_schema = ColumnSchema::new(col_name, elem_dtype, true);
7804        let schema_cols = alloc::vec![col_schema.clone()];
7805        let scan_ctx = EvalContext::new(&schema_cols, Some(&alias));
7806        // WHERE.
7807        let filtered: alloc::vec::Vec<Row> = if let Some(w) = &stmt.where_ {
7808            let mut out = alloc::vec::Vec::with_capacity(rows.len());
7809            for row in rows {
7810                cancel.check()?;
7811                let v = eval::eval_expr(w, &row, &scan_ctx).map_err(EngineError::Eval)?;
7812                if matches!(v, Value::Bool(true)) {
7813                    out.push(row);
7814                }
7815            }
7816            out
7817        } else {
7818            rows
7819        };
7820        // v7.17.0 Phase 3.P0-48 — aggregate dispatch for set-
7821        // returning sources. When the SELECT projection contains
7822        // aggregate functions (COUNT/SUM/MIN/MAX/AVG/string_agg/
7823        // …) we route the filtered row stream through the same
7824        // aggregate executor the relational scan path uses, so
7825        // `SELECT COUNT(*) FROM generate_series(1, 100)` returns
7826        // a single 100 row instead of erroring at projection
7827        // time. GROUP BY / HAVING / ORDER BY over the aggregate
7828        // output all ride through `aggregate::run`.
7829        if aggregate::uses_aggregate(stmt) {
7830            let agg_correlated = |e: &Expr, r: &Row, c: &EvalContext<'_>| {
7831                self.eval_expr_with_correlated(e, r, c, cancel, None)
7832                    .map_err(|err| match err {
7833                        EngineError::Eval(ev) => ev,
7834                        other => eval::EvalError::TypeMismatch {
7835                            detail: alloc::format!("{other}"),
7836                        },
7837                    })
7838            };
7839            let filtered_refs: alloc::vec::Vec<&Row> = filtered.iter().collect();
7840            let mut agg = aggregate::run(
7841                stmt,
7842                &filtered_refs,
7843                &schema_cols,
7844                Some(&alias),
7845                Some(&agg_correlated),
7846            )?;
7847            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
7848            return Ok(QueryResult::Rows {
7849                columns: agg.columns,
7850                rows: agg.rows,
7851            });
7852        }
7853        // Projection.
7854        let projection = build_projection(&stmt.items, &schema_cols, &alias)?;
7855        let mut projected_rows: alloc::vec::Vec<Row> =
7856            alloc::vec::Vec::with_capacity(filtered.len());
7857        let mut proj_memo = memoize::MemoizeCache::default();
7858        for row in &filtered {
7859            let mut vals = alloc::vec::Vec::with_capacity(projection.len());
7860            for p in &projection {
7861                // v7.24 (round-16 B) — correlated-aware.
7862                vals.push(self.eval_expr_with_correlated(
7863                    &p.expr,
7864                    row,
7865                    &scan_ctx,
7866                    cancel,
7867                    Some(&mut proj_memo),
7868                )?);
7869            }
7870            projected_rows.push(Row::new(vals));
7871        }
7872        let columns: alloc::vec::Vec<ColumnSchema> = projection
7873            .iter()
7874            .map(|p| ColumnSchema::new(p.output_name.clone(), p.ty, p.nullable))
7875            .collect();
7876        // ORDER BY against the source schema.
7877        if !stmt.order_by.is_empty() {
7878            let mut indexed: alloc::vec::Vec<(usize, Vec<Value>)> = filtered
7879                .iter()
7880                .enumerate()
7881                .map(|(i, r)| -> Result<_, EngineError> {
7882                    let keys: Result<Vec<Value>, EngineError> = stmt
7883                        .order_by
7884                        .iter()
7885                        .map(|ob| {
7886                            eval::eval_expr(&ob.expr, r, &scan_ctx).map_err(EngineError::Eval)
7887                        })
7888                        .collect();
7889                    Ok((i, keys?))
7890                })
7891                .collect::<Result<_, _>>()?;
7892            indexed.sort_by(|a, b| {
7893                for (idx, (ka, kb)) in a.1.iter().zip(b.1.iter()).enumerate() {
7894                    let o = &stmt.order_by[idx];
7895                    let cmp = order_by_value_cmp(o.desc, o.nulls_first, ka, kb);
7896                    if cmp != core::cmp::Ordering::Equal {
7897                        return cmp;
7898                    }
7899                }
7900                core::cmp::Ordering::Equal
7901            });
7902            projected_rows = indexed
7903                .into_iter()
7904                .map(|(i, _)| projected_rows[i].clone())
7905                .collect();
7906        }
7907        if let Some(offset) = stmt.offset_literal() {
7908            let off = (offset as usize).min(projected_rows.len());
7909            projected_rows.drain(..off);
7910        }
7911        if let Some(limit) = stmt.limit_literal() {
7912            projected_rows.truncate(limit as usize);
7913        }
7914        Ok(QueryResult::Rows {
7915            columns,
7916            rows: projected_rows,
7917        })
7918    }
7919
7920    fn exec_bare_select_cancel(
7921        &self,
7922        stmt: &SelectStatement,
7923        cancel: CancelToken<'_>,
7924    ) -> Result<QueryResult, EngineError> {
7925        // v7.17.0 Phase 3.P0-49 — `FETCH FIRST N ROWS WITH TIES`
7926        // is meaningless without an ORDER BY; PG raises a hard
7927        // error and SPG mirrors the surface so the same DDL/app
7928        // path behaves identically on cutover.
7929        check_with_ties_requires_order_by(stmt)?;
7930        // v7.16.2 — same meta-view dispatch as
7931        // `exec_select_cancel`, applied here too because
7932        // `subquery_replacement` enters this function directly
7933        // for Exists / ScalarSubquery / InSubquery resolution
7934        // (bypassing the top-level entry to avoid double
7935        // subquery walking). Without this dispatch the subquery
7936        // hits `__spg_info_columns` and reports TableNotFound.
7937        if !self.meta_views_materialised && select_references_meta_view(stmt) {
7938            return self.exec_select_with_meta_views(stmt, cancel);
7939        }
7940        // v4.12: window-function path. When the projection contains
7941        // any `name(args) OVER (...)` we route to the dedicated
7942        // executor — partition + sort + per-row window value before
7943        // the regular projection.
7944        if select_has_window(stmt) {
7945            return self.exec_select_with_window(stmt, cancel);
7946        }
7947        // Constant SELECT (no FROM) — evaluate each item once against an
7948        // empty dummy row. Useful for `SELECT 1`, `SELECT coalesce(...)`,
7949        // `SELECT '7'::INT`. Column references will surface as
7950        // ColumnNotFound on eval since the schema is empty.
7951        let Some(from) = &stmt.from else {
7952            let empty_schema: Vec<ColumnSchema> = Vec::new();
7953            let ctx = self.ev_ctx(&empty_schema, None);
7954            let projection = build_projection(&stmt.items, &empty_schema, "")?;
7955            let dummy_row = Row::new(Vec::new());
7956            let mut values = Vec::with_capacity(projection.len());
7957            for p in &projection {
7958                values.push(eval::eval_expr(&p.expr, &dummy_row, &ctx)?);
7959            }
7960            let columns: Vec<ColumnSchema> = projection
7961                .into_iter()
7962                .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
7963                .collect();
7964            return Ok(QueryResult::Rows {
7965                columns,
7966                rows: alloc::vec![Row::new(values)],
7967            });
7968        };
7969        // Multi-table FROM (one or more joined peers) goes through the
7970        // nested-loop join executor. Single-table FROM stays on the
7971        // existing scan + index-seek path.
7972        if !from.joins.is_empty() {
7973            return self.exec_joined_select(stmt, from, cancel);
7974        }
7975        // v7.11.7 — `FROM unnest(<expr>) [AS] <alias>`. Synthesise a
7976        // single-column table at SELECT entry by evaluating the
7977        // expression once against the empty row (UNNEST is
7978        // uncorrelated in v7.11; correlated / LATERAL unnest is a
7979        // v7.12 carve-out). Build a virtual `Table` in a heap-only
7980        // catalog, then route to the regular scan path.
7981        if from.primary.unnest_expr.is_some() {
7982            return self.exec_select_unnest(stmt, &from.primary, cancel);
7983        }
7984        // v7.17.0 Phase 3.10 — `FROM generate_series(start, stop
7985        // [, step])` set-returning source. Dispatch mirrors UNNEST:
7986        // materialise the row stream from a single eval pass, then
7987        // run the regular projection / WHERE / ORDER BY / LIMIT
7988        // pipeline over the synthetic single-column table.
7989        if from.primary.generate_series_args.is_some() {
7990            return self.exec_select_generate_series(stmt, &from.primary, cancel);
7991        }
7992        let primary = &from.primary;
7993        let table = self.active_catalog().get(&primary.name).ok_or_else(|| {
7994            StorageError::TableNotFound {
7995                name: primary.name.clone(),
7996            }
7997        })?;
7998        let schema_cols = &table.schema().columns;
7999        // The qualifier accepted on column refs is the alias (if any) else the
8000        // bare table name.
8001        let alias = primary.alias.as_deref().unwrap_or(primary.name.as_str());
8002        let ctx = self.ev_ctx(schema_cols, Some(alias));
8003
8004        // NSW kNN planner: `ORDER BY col <-> literal LIMIT k` with no
8005        // WHERE and an NSW index on `col` skips the full scan. The
8006        // walk returns rows already in ascending-distance order, so
8007        // ORDER BY / LIMIT are honoured implicitly.
8008        if let Some(nsw_rows) = try_nsw_knn(stmt, table, schema_cols, alias) {
8009            return materialise_in_order(stmt, table, schema_cols, alias, &nsw_rows);
8010        }
8011
8012        // Index seek: if WHERE is `col = literal` (or commuted) and the
8013        // referenced column has an index, dispatch each locator through
8014        // the catalog (hot tier → borrow, cold tier → page-read +
8015        // decode) and iterate just those rows. Otherwise fall back to a
8016        // full scan over the hot tier (cold-tier rows are only reached
8017        // via index seek in v5.1 — full table scans against cold-tier
8018        // data ship in v5.2 with the freezer's per-segment scan API).
8019        let indexed_rows: Option<Vec<Cow<'_, Row>>> = stmt.where_.as_ref().and_then(|w| {
8020            // BTree / col=literal seek first — covers the v7.11.3 multi-
8021            // column AND case and the leading-column equality lookup.
8022            try_index_seek(w, schema_cols, self.active_catalog(), table, alias)
8023                .or_else(|| {
8024                    // v7.12.3 — GIN-accelerated `WHERE col @@
8025                    // tsquery` when the column has a `USING gin`
8026                    // index. Returns an over-approximate candidate
8027                    // set; the WHERE re-eval loop below verifies
8028                    // the full `@@` predicate per row.
8029                    try_gin_seek(w, schema_cols, self.active_catalog(), table, alias, &ctx)
8030                })
8031                .or_else(|| {
8032                    // v7.15.0 — trigram-GIN-accelerated
8033                    // `WHERE col LIKE / ILIKE '<pat>'` when the
8034                    // column has a `gin_trgm_ops` GIN index.
8035                    // Over-approximate candidate set; the WHERE
8036                    // re-eval verifies the LIKE per row.
8037                    try_trgm_seek(w, schema_cols, table, alias)
8038                })
8039        });
8040
8041        // Aggregate path: filter rows first, then hand off to the
8042        // aggregate executor which does its own projection + ORDER BY.
8043        if aggregate::uses_aggregate(stmt) {
8044            let mut filtered: Vec<&Row> = Vec::new();
8045            // v6.2.6 — Memoize: per-query LRU cache for correlated
8046            // scalar subqueries. Fresh per row-loop entry so each
8047            // SELECT execution gets an isolated cache.
8048            let mut memo = memoize::MemoizeCache::new();
8049            if let Some(rows) = &indexed_rows {
8050                for cow in rows {
8051                    let row = cow.as_ref();
8052                    if let Some(where_expr) = &stmt.where_ {
8053                        let cond = self.eval_expr_with_correlated(
8054                            where_expr,
8055                            row,
8056                            &ctx,
8057                            cancel,
8058                            Some(&mut memo),
8059                        )?;
8060                        if !matches!(cond, Value::Bool(true)) {
8061                            continue;
8062                        }
8063                    }
8064                    filtered.push(row);
8065                }
8066            } else {
8067                for i in 0..table.row_count() {
8068                    let row = &table.rows()[i];
8069                    if let Some(where_expr) = &stmt.where_ {
8070                        let cond = self.eval_expr_with_correlated(
8071                            where_expr,
8072                            row,
8073                            &ctx,
8074                            cancel,
8075                            Some(&mut memo),
8076                        )?;
8077                        if !matches!(cond, Value::Bool(true)) {
8078                            continue;
8079                        }
8080                    }
8081                    filtered.push(row);
8082                }
8083            }
8084            let agg_correlated = |e: &Expr, r: &Row, c: &EvalContext<'_>| {
8085                self.eval_expr_with_correlated(e, r, c, cancel, None)
8086                    .map_err(|err| match err {
8087                        EngineError::Eval(ev) => ev,
8088                        other => eval::EvalError::TypeMismatch {
8089                            detail: alloc::format!("{other}"),
8090                        },
8091                    })
8092            };
8093            let mut agg = aggregate::run(
8094                stmt,
8095                &filtered,
8096                schema_cols,
8097                Some(alias),
8098                Some(&agg_correlated),
8099            )?;
8100            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
8101            return Ok(QueryResult::Rows {
8102                columns: agg.columns,
8103                rows: agg.rows,
8104            });
8105        }
8106
8107        let projection = build_projection(&stmt.items, schema_cols, alias)?;
8108        // v7.19 P5 — single-table SELECT path for SRF
8109        // `SELECT unnest(arr) FROM t` shape. Detect a top-level
8110        // unnest in the projection list. When present, the
8111        // per-row processor emits one output row per array
8112        // element (broadcasting non-SRF projections from the
8113        // same input row). Empty / NULL arrays emit zero rows
8114        // for that input — PG semantics.
8115        let srf_position = projection.iter().position(|p| is_top_level_unnest(&p.expr));
8116
8117        // Materialise the filter pass into `(order_key, projected_row)`
8118        // tuples. The order key is `None` when there's no ORDER BY clause.
8119        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::new();
8120        // v6.2.6 — Memoize per-row WHERE eval shares one cache.
8121        let mut memo = memoize::MemoizeCache::new();
8122        // Inline the per-row work in a closure so the indexed and full-
8123        // scan branches share the body.
8124        let mut process_row = |row: &Row, loop_idx: usize| -> Result<(), EngineError> {
8125            if loop_idx.is_multiple_of(256) {
8126                cancel.check()?;
8127            }
8128            if let Some(where_expr) = &stmt.where_ {
8129                let cond =
8130                    self.eval_expr_with_correlated(where_expr, row, &ctx, cancel, Some(&mut memo))?;
8131                if !matches!(cond, Value::Bool(true)) {
8132                    return Ok(());
8133                }
8134            }
8135            let order_keys = if stmt.order_by.is_empty() {
8136                Vec::new()
8137            } else {
8138                build_order_keys(&stmt.order_by, row, &ctx)?
8139            };
8140            if let Some(srf_idx) = srf_position {
8141                let srf_arg = top_level_unnest_arg(&projection[srf_idx].expr)
8142                    .expect("checked by is_top_level_unnest above");
8143                let arr_val = eval::eval_expr(srf_arg, row, &ctx)?;
8144                let elements = array_value_to_elements(&arr_val)?;
8145                for elem in elements {
8146                    let mut values = Vec::with_capacity(projection.len());
8147                    for (i, p) in projection.iter().enumerate() {
8148                        if i == srf_idx {
8149                            values.push(elem.clone());
8150                        } else {
8151                            values.push(eval::eval_expr(&p.expr, row, &ctx)?);
8152                        }
8153                    }
8154                    tagged.push((order_keys.clone(), Row::new(values)));
8155                }
8156            } else {
8157                let mut values = Vec::with_capacity(projection.len());
8158                for p in &projection {
8159                    // v7.24 (round-16 B) — correlated-aware.
8160                    values.push(self.eval_expr_with_correlated(&p.expr, row, &ctx, cancel, None)?);
8161                }
8162                tagged.push((order_keys, Row::new(values)));
8163            }
8164            Ok(())
8165        };
8166        if let Some(rows) = &indexed_rows {
8167            for (loop_idx, cow) in rows.iter().enumerate() {
8168                process_row(cow.as_ref(), loop_idx)?;
8169            }
8170        } else {
8171            for i in 0..table.row_count() {
8172                process_row(&table.rows()[i], i)?;
8173            }
8174        }
8175
8176        if !stmt.order_by.is_empty() {
8177            // Partial-sort fast path: when LIMIT is small relative to
8178            // the row count, select_nth_unstable + sort just the
8179            // prefix is O(n + k log k) instead of O(n log n). DISTINCT
8180            // requires the full sort because de-dup happens after.
8181            // WITH TIES likewise needs the full sort so the tie
8182            // extension can scan past `limit` to find rows that
8183            // share the last-kept row's key.
8184            let keep = if stmt.distinct || stmt.limit_with_ties {
8185                None
8186            } else {
8187                stmt.limit_literal()
8188                    .map(|l| l as usize + stmt.offset_literal().map_or(0, |o| o as usize))
8189            };
8190            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
8191            partial_sort_tagged(&mut tagged, keep, &descs);
8192        }
8193
8194        // v7.17.0 Phase 3.P0-49 — `FETCH FIRST … WITH TIES` extends
8195        // past the truncated tail through every row that shares the
8196        // last-kept row's ORDER BY key. The tie check uses the
8197        // already-computed `(order_keys, row)` pairs so it matches
8198        // the sort comparator exactly. DISTINCT + WITH TIES falls
8199        // through to the no-ties path (PG also disallows their
8200        // combination; SPG silently drops the tie extension here so
8201        // the customer doesn't see a hard error mid-query — the
8202        // user-visible result is still correct, just narrower).
8203        let output_rows: Vec<Row> = if stmt.limit_with_ties && !stmt.distinct {
8204            apply_offset_and_limit_tagged(
8205                &mut tagged,
8206                stmt.offset_literal(),
8207                stmt.limit_literal(),
8208                true,
8209            );
8210            tagged.into_iter().map(|(_, r)| r).collect()
8211        } else {
8212            let mut output_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
8213            if stmt.distinct {
8214                output_rows = dedup_rows(output_rows);
8215            }
8216            apply_offset_and_limit(
8217                &mut output_rows,
8218                stmt.offset_literal(),
8219                stmt.limit_literal(),
8220            );
8221            output_rows
8222        };
8223
8224        let columns: Vec<ColumnSchema> = projection
8225            .into_iter()
8226            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
8227            .collect();
8228
8229        Ok(QueryResult::Rows {
8230            columns,
8231            rows: output_rows,
8232        })
8233    }
8234
8235    /// Multi-table SELECT executor (one or more JOIN peers).
8236    ///
8237    /// v1.10 builds the joined row set up-front via nested-loop joins,
8238    /// then runs WHERE + projection + ORDER BY against the combined
8239    /// rows. No index seek. Aggregates and DISTINCT still work because
8240    /// the executor delegates projection through the same shared paths.
8241    #[allow(clippy::too_many_lines)]
8242    /// v7.13.2 — mailrs round-6 S5. Resolve a TableRef into an
8243    /// owned (rows, schema) pair. Catalog tables clone their hot
8244    /// rows + schema; UNNEST table refs evaluate their array
8245    /// expression once and synthesise a single-column row set
8246    /// using the same dispatch as `exec_select_unnest`. Used by
8247    /// the joined-select path so UNNEST can appear in any FROM
8248    /// position, not just as the primary.
8249    fn materialise_table_ref(
8250        &self,
8251        tref: &TableRef,
8252    ) -> Result<(Vec<Row>, Vec<ColumnSchema>), EngineError> {
8253        if let Some(expr) = tref.unnest_expr.as_deref() {
8254            let empty_schema: Vec<ColumnSchema> = Vec::new();
8255            let ctx = EvalContext::new(&empty_schema, None);
8256            let dummy_row = Row::new(Vec::new());
8257            let (elem_dtype, rows) =
8258                match eval::eval_expr(expr, &dummy_row, &ctx).map_err(EngineError::Eval)? {
8259                    Value::Null => (DataType::Text, Vec::new()),
8260                    Value::TextArray(items) => (
8261                        DataType::Text,
8262                        items
8263                            .into_iter()
8264                            .map(|item| {
8265                                Row::new(alloc::vec![match item {
8266                                    Some(s) => Value::Text(s),
8267                                    None => Value::Null,
8268                                }])
8269                            })
8270                            .collect(),
8271                    ),
8272                    Value::IntArray(items) => (
8273                        DataType::Int,
8274                        items
8275                            .into_iter()
8276                            .map(|item| {
8277                                Row::new(alloc::vec![match item {
8278                                    Some(n) => Value::Int(n),
8279                                    None => Value::Null,
8280                                }])
8281                            })
8282                            .collect(),
8283                    ),
8284                    Value::BigIntArray(items) => (
8285                        DataType::BigInt,
8286                        items
8287                            .into_iter()
8288                            .map(|item| {
8289                                Row::new(alloc::vec![match item {
8290                                    Some(n) => Value::BigInt(n),
8291                                    None => Value::Null,
8292                                }])
8293                            })
8294                            .collect(),
8295                    ),
8296                    other => {
8297                        return Err(EngineError::Unsupported(alloc::format!(
8298                            "unnest() expects an array argument, got {:?}",
8299                            other.data_type()
8300                        )));
8301                    }
8302                };
8303            let alias = tref.alias.clone().unwrap_or_else(|| "unnest".to_string());
8304            let col_name = tref.unnest_column_aliases.first().cloned().unwrap_or(alias);
8305            return Ok((
8306                rows,
8307                alloc::vec![ColumnSchema::new(col_name, elem_dtype, true)],
8308            ));
8309        }
8310        let table =
8311            self.active_catalog()
8312                .get(&tref.name)
8313                .ok_or_else(|| StorageError::TableNotFound {
8314                    name: tref.name.clone(),
8315                })?;
8316        let rows: Vec<Row> = table.rows().iter().cloned().collect();
8317        let cols = table.schema().columns.clone();
8318        Ok((rows, cols))
8319    }
8320
8321    /// v7.17.0 Phase 3.P0-43 — materialise a `FROM` with one or more
8322    /// JOINs into `(combined_schema, filtered_rows)`. The combined
8323    /// schema uses composite `alias.col` column names so the
8324    /// qualifier-aware column resolver finds every join peer by
8325    /// exact match; the filtered rows are the join cross-product
8326    /// after the optional WHERE clause is applied.
8327    ///
8328    /// Shared by `exec_joined_select` and the JOIN branch of
8329    /// `exec_select_with_window`; both paths used to inline the
8330    /// same nested-loop logic and the window path rejected JOIN
8331    /// outright.
8332    fn build_joined_filtered_rows(
8333        &self,
8334        from: &FromClause,
8335        where_: Option<&Expr>,
8336        cancel: CancelToken<'_>,
8337    ) -> Result<(Vec<ColumnSchema>, Vec<Row>), EngineError> {
8338        let (primary_rows, primary_cols) = self.materialise_table_ref(&from.primary)?;
8339        let primary_alias = from
8340            .primary
8341            .alias
8342            .as_deref()
8343            .unwrap_or(from.primary.name.as_str())
8344            .to_string();
8345        // v7.17.0 Phase 3.P0-41 — LATERAL peers can't be
8346        // pre-materialised because their rows depend on outer
8347        // columns. For each peer, build either an eager
8348        // (rows, schema) pair or a "lateral" sentinel carrying
8349        // just the schema and the inner SELECT to re-run per
8350        // outer row.
8351        #[allow(clippy::type_complexity)]
8352        let mut joined: Vec<JoinedPeer<'_>> = Vec::new();
8353        for j in &from.joins {
8354            let a = j
8355                .table
8356                .alias
8357                .as_deref()
8358                .unwrap_or(j.table.name.as_str())
8359                .to_string();
8360            if let Some(inner_box) = &j.table.lateral_subquery {
8361                // Probe schema by running the inner SELECT against a
8362                // NULL-padded outer context. The probe gives us the
8363                // projection's column shape; rows materialise per
8364                // left-row below.
8365                let schema = self.lateral_probe_schema(inner_box)?;
8366                joined.push(JoinedPeer {
8367                    eager_rows: None,
8368                    cols: schema,
8369                    alias: a,
8370                    kind: j.kind,
8371                    on: j.on.as_ref(),
8372                    lateral: Some(inner_box.as_ref()),
8373                });
8374            } else {
8375                let (rows, cols) = self.materialise_table_ref(&j.table)?;
8376                joined.push(JoinedPeer {
8377                    eager_rows: Some(rows),
8378                    cols,
8379                    alias: a,
8380                    kind: j.kind,
8381                    on: j.on.as_ref(),
8382                    lateral: None,
8383                });
8384            }
8385        }
8386        let mut combined_schema: Vec<ColumnSchema> = Vec::new();
8387        for col in &primary_cols {
8388            combined_schema.push(ColumnSchema::new(
8389                alloc::format!("{primary_alias}.{}", col.name),
8390                col.ty,
8391                col.nullable,
8392            ));
8393        }
8394        for peer in &joined {
8395            for col in &peer.cols {
8396                combined_schema.push(ColumnSchema::new(
8397                    alloc::format!("{}.{}", peer.alias, col.name),
8398                    col.ty,
8399                    col.nullable,
8400                ));
8401            }
8402        }
8403        let ctx = EvalContext::new(&combined_schema, None);
8404        let mut working: Vec<Row> = primary_rows;
8405        // Track the per-row width consumed by the outer left side so
8406        // each lateral evaluation sees the correct schema slice.
8407        let mut consumed_cols = primary_cols.len();
8408        for peer in &joined {
8409            let right_arity = peer.cols.len();
8410            let mut next: Vec<Row> = Vec::new();
8411            for left in &working {
8412                let mut left_matched = false;
8413                let per_left_rrows: alloc::borrow::Cow<'_, [Row]> = match peer.lateral {
8414                    Some(inner) => {
8415                        // Substitute outer columns and run the inner
8416                        // SELECT against the current left row's slice
8417                        // of the combined schema.
8418                        let outer_schema = &combined_schema[..consumed_cols];
8419                        let rows = self.materialise_lateral_for_outer(inner, outer_schema, left)?;
8420                        alloc::borrow::Cow::Owned(rows)
8421                    }
8422                    None => {
8423                        let r = peer.eager_rows.as_ref().expect("non-lateral peer eager");
8424                        alloc::borrow::Cow::Borrowed(r.as_slice())
8425                    }
8426                };
8427                for right in per_left_rrows.as_ref() {
8428                    let mut combined_vals = left.values.clone();
8429                    combined_vals.extend(right.values.iter().cloned());
8430                    let combined = Row::new(combined_vals);
8431                    let keep = if let Some(on_expr) = peer.on {
8432                        // v7.24.1 — correlated-aware (subqueries in
8433                        // ON referencing earlier join columns).
8434                        let cond =
8435                            self.eval_expr_with_correlated(on_expr, &combined, &ctx, cancel, None)?;
8436                        matches!(cond, Value::Bool(true))
8437                    } else {
8438                        true
8439                    };
8440                    if keep {
8441                        next.push(combined);
8442                        left_matched = true;
8443                    }
8444                }
8445                if !left_matched && matches!(peer.kind, JoinKind::Left) {
8446                    let mut combined_vals = left.values.clone();
8447                    for _ in 0..right_arity {
8448                        combined_vals.push(Value::Null);
8449                    }
8450                    next.push(Row::new(combined_vals));
8451                }
8452            }
8453            working = next;
8454            consumed_cols += right_arity;
8455            debug_assert!(consumed_cols <= combined_schema.len());
8456        }
8457        let mut filtered: Vec<Row> = Vec::new();
8458        // v7.24 (round-16 B) — the joined WHERE filter ran the plain
8459        // row evaluator, so a correlated EXISTS/IN/scalar subquery
8460        // under a JOIN hit "subquery reached row eval". Route through
8461        // the correlated-aware evaluator (memoized, same as the
8462        // single-table path).
8463        let mut memo = memoize::MemoizeCache::default();
8464        for row in working {
8465            if let Some(where_expr) = where_ {
8466                let cond = self.eval_expr_with_correlated(
8467                    where_expr,
8468                    &row,
8469                    &ctx,
8470                    cancel,
8471                    Some(&mut memo),
8472                )?;
8473                if !matches!(cond, Value::Bool(true)) {
8474                    continue;
8475                }
8476            }
8477            filtered.push(row);
8478        }
8479        Ok((combined_schema, filtered))
8480    }
8481
8482    /// v7.17.0 Phase 3.P0-41 — probe a LATERAL subquery's projection
8483    /// schema by running it once with a NULL-padded outer context.
8484    /// The probe never materialises real outer rows; it just executes
8485    /// the inner SELECT with `outer_alias.col` references substituted
8486    /// to NULL so the projection's type inference is exercised.
8487    fn lateral_probe_schema(
8488        &self,
8489        inner: &SelectStatement,
8490    ) -> Result<Vec<ColumnSchema>, EngineError> {
8491        // Substitute every qualified column reference whose qualifier
8492        // does NOT match an in-subquery FROM alias with NULL. The
8493        // safest probe is to walk the inner SELECT and replace any
8494        // `<qual>.<col>` whose qual isn't bound inside the subquery
8495        // with a Null literal. For the v7.17 probe we just run the
8496        // unmodified subquery and surface the columns; if it fails
8497        // (e.g. references an outer column the probe can't resolve),
8498        // we synthesise a best-effort schema from the SELECT items
8499        // by inferring a single Text-typed column per projection.
8500        match self.execute_readonly_select_for_lateral_probe(inner) {
8501            Ok(QueryResult::Rows { columns, .. }) => Ok(columns),
8502            // Best-effort fallback: each SELECT item becomes a TEXT
8503            // column. Real schemas only differ when the inner SELECT
8504            // references outer columns at projection-time; those
8505            // queries surface via the substitution path during
8506            // per-row execution and still return the right values.
8507            _ => {
8508                let mut out: Vec<ColumnSchema> = Vec::new();
8509                for (i, item) in inner.items.iter().enumerate() {
8510                    let name = match item {
8511                        SelectItem::Expr { alias: Some(a), .. } => a.clone(),
8512                        SelectItem::Expr { expr, .. } => synth_lateral_col_name(expr, i),
8513                        SelectItem::Wildcard => alloc::format!("col{i}"),
8514                    };
8515                    out.push(ColumnSchema::new(name, DataType::Text, true));
8516                }
8517                Ok(out)
8518            }
8519        }
8520    }
8521
8522    /// v7.17.0 Phase 3.P0-41 — try the inner LATERAL subquery against
8523    /// the engine in read-only mode for schema-probe purposes. Failure
8524    /// is expected when the subquery references an outer column the
8525    /// probe can't resolve; the caller falls back to a best-effort
8526    /// schema based on the SELECT items.
8527    fn execute_readonly_select_for_lateral_probe(
8528        &self,
8529        inner: &SelectStatement,
8530    ) -> Result<QueryResult, EngineError> {
8531        self.exec_bare_select_cancel(inner, CancelToken::none())
8532    }
8533
8534    /// v7.17.0 Phase 3.P0-41 — materialise a LATERAL subquery's rows
8535    /// for one outer-row context. Walks the inner SELECT, replaces
8536    /// every `<outer_alias>.<col>` reference whose alias appears in
8537    /// the outer schema with the literal value from the outer row,
8538    /// then runs the rewritten SELECT against the engine.
8539    fn materialise_lateral_for_outer(
8540        &self,
8541        inner: &SelectStatement,
8542        outer_schema: &[ColumnSchema],
8543        outer_row: &Row,
8544    ) -> Result<Vec<Row>, EngineError> {
8545        let mut substituted = inner.clone();
8546        substitute_outer_columns_multi(&mut substituted, outer_row, outer_schema);
8547        let result = self.exec_bare_select_cancel(&substituted, CancelToken::none())?;
8548        match result {
8549            QueryResult::Rows { rows, .. } => Ok(rows),
8550            _ => Err(EngineError::Unsupported(
8551                "LATERAL subquery must be a SELECT (cannot be a write statement)".into(),
8552            )),
8553        }
8554    }
8555
8556    fn exec_joined_select(
8557        &self,
8558        stmt: &SelectStatement,
8559        from: &FromClause,
8560        cancel: CancelToken<'_>,
8561    ) -> Result<QueryResult, EngineError> {
8562        // v7.17.0 Phase 3.P0-43 + P0-41 — delegate the join +
8563        // WHERE materialisation to the shared helper so the LATERAL
8564        // / UNNEST / regular-catalog paths route through one place.
8565        // (`build_joined_filtered_rows` carries LATERAL support as
8566        // of Phase 3.P0-41.) Downstream we still handle aggregate /
8567        // projection / ORDER BY / DISTINCT / LIMIT inline because
8568        // those depend on the SelectStatement's items list.
8569        let (combined_schema, filtered) =
8570            self.build_joined_filtered_rows(from, stmt.where_.as_ref(), cancel)?;
8571        let ctx = EvalContext::new(&combined_schema, None);
8572        // Aggregate path: handle GROUP BY / aggregate calls over the
8573        // joined+filtered rows.
8574        if aggregate::uses_aggregate(stmt) {
8575            let refs: Vec<&Row> = filtered.iter().collect();
8576            let agg_correlated = |e: &Expr, r: &Row, c: &EvalContext<'_>| {
8577                self.eval_expr_with_correlated(e, r, c, cancel, None)
8578                    .map_err(|err| match err {
8579                        EngineError::Eval(ev) => ev,
8580                        other => eval::EvalError::TypeMismatch {
8581                            detail: alloc::format!("{other}"),
8582                        },
8583                    })
8584            };
8585            let mut agg =
8586                aggregate::run(stmt, &refs, &combined_schema, None, Some(&agg_correlated))?;
8587            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
8588            return Ok(QueryResult::Rows {
8589                columns: agg.columns,
8590                rows: agg.rows,
8591            });
8592        }
8593
8594        let projection = build_projection(&stmt.items, &combined_schema, "")?;
8595        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::new();
8596        let mut proj_memo = memoize::MemoizeCache::default();
8597        for row in &filtered {
8598            let mut values = Vec::with_capacity(projection.len());
8599            for p in &projection {
8600                // v7.24 (round-16 B) — select-list subqueries under a
8601                // JOIN go through the correlated-aware evaluator too.
8602                values.push(self.eval_expr_with_correlated(
8603                    &p.expr,
8604                    row,
8605                    &ctx,
8606                    cancel,
8607                    Some(&mut proj_memo),
8608                )?);
8609            }
8610            let order_keys = if stmt.order_by.is_empty() {
8611                Vec::new()
8612            } else {
8613                build_order_keys(&stmt.order_by, row, &ctx)?
8614            };
8615            tagged.push((order_keys, Row::new(values)));
8616        }
8617        if !stmt.order_by.is_empty() {
8618            let keep = if stmt.distinct {
8619                None
8620            } else {
8621                stmt.limit_literal()
8622                    .map(|l| l as usize + stmt.offset_literal().map_or(0, |o| o as usize))
8623            };
8624            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
8625            partial_sort_tagged(&mut tagged, keep, &descs);
8626        }
8627        let mut output_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
8628        if stmt.distinct {
8629            output_rows = dedup_rows(output_rows);
8630        }
8631        apply_offset_and_limit(
8632            &mut output_rows,
8633            stmt.offset_literal(),
8634            stmt.limit_literal(),
8635        );
8636        let columns: Vec<ColumnSchema> = projection
8637            .into_iter()
8638            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
8639            .collect();
8640        Ok(QueryResult::Rows {
8641            columns,
8642            rows: output_rows,
8643        })
8644    }
8645}
8646
8647/// One row-producing projection: an expression to evaluate, the resulting
8648/// column's user-visible name, its inferred type, and nullability.
8649#[derive(Debug, Clone)]
8650struct ProjectedItem {
8651    expr: Expr,
8652    output_name: String,
8653    ty: DataType,
8654    nullable: bool,
8655}
8656
8657/// Dedupe a row set, preserving first-seen order. `Row`'s `PartialEq` is
8658/// structural (`Vec<Value>` ⇒ pairwise `Value` equality), which gives SQL
8659/// `NULL = NULL → TRUE` and `NaN = NaN → FALSE`. The first agrees with
8660/// the spec's "two NULLs are not distinct"; the second is a tolerated
8661/// quirk for v1 (no NaN literals are reachable from the SQL surface).
8662fn dedup_rows(rows: Vec<Row>) -> Vec<Row> {
8663    let mut out: Vec<Row> = Vec::with_capacity(rows.len());
8664    for r in rows {
8665        if !out.iter().any(|seen| seen == &r) {
8666            out.push(r);
8667        }
8668    }
8669    out
8670}
8671
8672/// Coerce a `Value` to an `f64` sort key for ORDER BY. Numbers map directly;
8673/// NULL sorts last (treated as `+∞`); booleans are 0.0 / 1.0; text uses lex
8674/// order via the byte values; vectors are not sortable.
8675fn value_to_order_key(v: &Value) -> Result<f64, EngineError> {
8676    match v {
8677        Value::Null => Ok(f64::INFINITY),
8678        Value::SmallInt(n) => Ok(f64::from(*n)),
8679        Value::Int(n) => Ok(f64::from(*n)),
8680        Value::Date(d) => Ok(f64::from(*d)),
8681        #[allow(clippy::cast_precision_loss)]
8682        Value::Timestamp(t) => Ok(*t as f64),
8683        // v7.17.0 Phase 3.P0-32 — PG TIME ordered by underlying
8684        // i64 microseconds (matches wall-clock ordering).
8685        #[allow(clippy::cast_precision_loss)]
8686        Value::Time(us) => Ok(*us as f64),
8687        // v7.17.0 Phase 3.P0-33 — MySQL YEAR ordered by underlying
8688        // u16 (matches calendar ordering; zero-year sentinel
8689        // sorts before 1901).
8690        Value::Year(y) => Ok(f64::from(*y)),
8691        // v7.17.0 Phase 3.P0-34 — PG TIMETZ ordered by the
8692        // UTC-equivalent microseconds (local wall - offset). Two
8693        // values for the same physical instant in different zones
8694        // sort equal — matches PG TIMETZ index behaviour.
8695        #[allow(clippy::cast_precision_loss)]
8696        Value::TimeTz { us, offset_secs } => Ok((us - i64::from(*offset_secs) * 1_000_000) as f64),
8697        // v7.17.0 Phase 3.P0-35 — PG MONEY ordered by i64 cents.
8698        #[allow(clippy::cast_precision_loss)]
8699        Value::Money(c) => Ok(*c as f64),
8700        // v7.17.0 Phase 3.P0-38 — range ordering is not supported
8701        // in v7.17.0 (needs lex-then-inclusivity tiebreak).
8702        Value::Range { .. } => Err(EngineError::Unsupported(
8703            "ORDER BY of a range value is not supported in v7.17.0".into(),
8704        )),
8705        // v7.17.0 Phase 3.P0-39 — hstore is not orderable.
8706        Value::Hstore(_) => Err(EngineError::Unsupported(
8707            "ORDER BY of a hstore value is not supported".into(),
8708        )),
8709        // v7.17.0 Phase 3.P0-40 — 2D arrays not orderable.
8710        Value::IntArray2D(_) | Value::BigIntArray2D(_) | Value::TextArray2D(_) => Err(
8711            EngineError::Unsupported("ORDER BY of a 2D array is not supported in v7.17.0".into()),
8712        ),
8713        #[allow(clippy::cast_precision_loss)]
8714        Value::Numeric { scaled, scale } => {
8715            // Scaled integer / 10^scale, computed via f64 for sort
8716            // ordering only. Precision losses here only matter for
8717            // ORDER BY tie-breaks well past 15 significant digits.
8718            // `f64::powi` lives in std; we hand-roll the loop so the
8719            // no_std engine crate doesn't need it.
8720            let mut divisor = 1.0_f64;
8721            for _ in 0..*scale {
8722                divisor *= 10.0;
8723            }
8724            Ok((*scaled as f64) / divisor)
8725        }
8726        #[allow(clippy::cast_precision_loss)]
8727        Value::BigInt(n) => Ok(*n as f64),
8728        Value::Float(x) => Ok(*x),
8729        Value::Bool(b) => Ok(if *b { 1.0 } else { 0.0 }),
8730        Value::Text(s) => {
8731            // Lex order by codepoints — good enough for ORDER BY name.
8732            // Map first 8 bytes packed into u64 as a coarse key; ties fall to
8733            // partial_cmp Equal. v1.x can swap in a real string comparator.
8734            let mut key: u64 = 0;
8735            for &b in s.as_bytes().iter().take(8) {
8736                key = (key << 8) | u64::from(b);
8737            }
8738            #[allow(clippy::cast_precision_loss)]
8739            Ok(key as f64)
8740        }
8741        Value::Vector(_) | Value::Sq8Vector(_) | Value::HalfVector(_) => {
8742            Err(EngineError::Unsupported(
8743                "ORDER BY of a raw vector column is not meaningful — use `<->`".into(),
8744            ))
8745        }
8746        Value::Interval { .. } => Err(EngineError::Unsupported(
8747            "ORDER BY of an INTERVAL is not supported in v2.11 \
8748             (months vs micros has no single canonical ordering)"
8749                .into(),
8750        )),
8751        Value::Json(_) => Err(EngineError::Unsupported(
8752            "ORDER BY of a JSON value is not supported — cast the document to text first".into(),
8753        )),
8754        // v7.5.0 — Value is #[non_exhaustive]; future variants need
8755        // an explicit ORDER BY mapping. Surface as Unsupported until
8756        // engine support is added.
8757        _ => Err(EngineError::Unsupported(
8758            "ORDER BY of this value type is not supported".into(),
8759        )),
8760    }
8761}
8762
8763/// Try to plan a WHERE clause as an equality lookup against an existing
8764/// index. Returns the candidate row indices on success; `None` means the
8765/// caller should fall back to a full scan.
8766///
8767/// v0.8 recognises a single top-level `col = literal` (in either operand
8768/// order). AND chains and range scans land in later milestones.
8769/// Look for `ORDER BY col <dist-op> literal LIMIT k` against an
8770/// NSW-indexed vector column. Recognised distance ops: `<->` (L2),
8771/// `<#>` (inner product), `<=>` (cosine). When a WHERE clause is
8772/// present, the planner does an "over-fetch and filter" pass — it
8773/// asks the graph for `k * over_fetch` candidates, evaluates WHERE
8774/// against each, and trims back to `k`. Returns the row indices in
8775/// ascending-distance order when the plan applies.
8776fn try_nsw_knn(
8777    stmt: &SelectStatement,
8778    table: &Table,
8779    schema_cols: &[ColumnSchema],
8780    table_alias: &str,
8781) -> Option<Vec<usize>> {
8782    if stmt.distinct {
8783        return None;
8784    }
8785    let limit = usize::try_from(stmt.limit_literal()?).ok()?;
8786    if limit == 0 {
8787        return None;
8788    }
8789    // v6.4.0 — NSW kNN dispatch needs a single ORDER BY key on the
8790    // distance metric. Multi-key ORDER BY falls through to the
8791    // generic sort path.
8792    if stmt.order_by.len() != 1 {
8793        return None;
8794    }
8795    let order = &stmt.order_by[0];
8796    // NSW kNN returns rows ascending by distance — DESC inverts the
8797    // natural order, so the planner can't handle it without a sort
8798    // pass. Fall back to the generic ORDER BY path.
8799    if order.desc {
8800        return None;
8801    }
8802    let Expr::Binary { lhs, op, rhs } = &order.expr else {
8803        return None;
8804    };
8805    let metric = match op {
8806        BinOp::L2Distance => spg_storage::NswMetric::L2,
8807        BinOp::InnerProduct => spg_storage::NswMetric::InnerProduct,
8808        BinOp::CosineDistance => spg_storage::NswMetric::Cosine,
8809        _ => return None,
8810    };
8811    // Accept both `col <op> literal` and `literal <op> col`.
8812    let ((Expr::Column(col), literal) | (literal, Expr::Column(col))) =
8813        (lhs.as_ref(), rhs.as_ref())
8814    else {
8815        return None;
8816    };
8817    if let Some(q) = &col.qualifier
8818        && q != table_alias
8819    {
8820        return None;
8821    }
8822    let col_pos = schema_cols.iter().position(|s| s.name == col.name)?;
8823    let query = literal_to_vector(literal)?;
8824    let idx = spg_storage::nsw_index_on(table, col_pos)?;
8825    if let Some(where_expr) = &stmt.where_ {
8826        // Over-fetch and filter. The factor (10×) is a heuristic that
8827        // covers typical selectivity for the corpus tests; v2.x will
8828        // make it configurable.
8829        let over_fetch = limit.saturating_mul(10).max(NSW_OVER_FETCH_FLOOR);
8830        let candidates = spg_storage::nsw_query(table, &idx.name, &query, over_fetch, metric);
8831        let ctx = EvalContext::new(schema_cols, Some(table_alias));
8832        let mut kept: Vec<usize> = Vec::with_capacity(limit);
8833        for i in candidates {
8834            let row = &table.rows()[i];
8835            let cond = eval::eval_expr(where_expr, row, &ctx).ok()?;
8836            if matches!(cond, Value::Bool(true)) {
8837                kept.push(i);
8838                if kept.len() >= limit {
8839                    break;
8840                }
8841            }
8842        }
8843        Some(kept)
8844    } else {
8845        Some(spg_storage::nsw_query(
8846            table, &idx.name, &query, limit, metric,
8847        ))
8848    }
8849}
8850
8851/// Lower bound on the over-fetch pool when WHERE is present — even
8852/// for tiny `LIMIT 1` queries we keep enough candidates to absorb a
8853/// few WHERE rejections.
8854const NSW_OVER_FETCH_FLOOR: usize = 32;
8855
8856/// Pull a `Vec<f32>` out of a literal-or-cast expression. Returns
8857/// `None` for anything we can't fold at plan time.
8858fn literal_to_vector(e: &Expr) -> Option<Vec<f32>> {
8859    match e {
8860        Expr::Literal(Literal::Vector(v)) => Some(v.clone()),
8861        Expr::Cast { expr, .. } => literal_to_vector(expr),
8862        _ => None,
8863    }
8864}
8865
8866/// Materialise rows in a planner-supplied order (used by the NSW path)
8867/// without re-running ORDER BY. The projection + LIMIT slot mirror the
8868/// equivalent block in `exec_bare_select`.
8869fn materialise_in_order(
8870    stmt: &SelectStatement,
8871    table: &Table,
8872    schema_cols: &[ColumnSchema],
8873    table_alias: &str,
8874    ordered_rows: &[usize],
8875) -> Result<QueryResult, EngineError> {
8876    let ctx = EvalContext::new(schema_cols, Some(table_alias));
8877    let projection = build_projection(&stmt.items, schema_cols, table_alias)?;
8878    let mut output_rows: Vec<Row> = Vec::with_capacity(ordered_rows.len());
8879    for &i in ordered_rows {
8880        let row = &table.rows()[i];
8881        let mut values = Vec::with_capacity(projection.len());
8882        for p in &projection {
8883            values.push(eval::eval_expr(&p.expr, row, &ctx)?);
8884        }
8885        output_rows.push(Row::new(values));
8886    }
8887    apply_offset_and_limit(
8888        &mut output_rows,
8889        stmt.offset_literal(),
8890        stmt.limit_literal(),
8891    );
8892    let columns: Vec<ColumnSchema> = projection
8893        .into_iter()
8894        .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
8895        .collect();
8896    Ok(QueryResult::Rows {
8897        columns,
8898        rows: output_rows,
8899    })
8900}
8901
8902/// v7.20 P4 — hot-row POSITION seek for the mutation paths
8903/// (UPDATE / DELETE index their planned writes by position in
8904/// `table.rows()`, so the Cow-row shape `try_index_seek`
8905/// returns doesn't fit). Same top-level-AND recursion and
8906/// col=literal resolution; the caller re-applies the full WHERE
8907/// to every returned row so the index only narrows candidates.
8908///
8909/// Returns `None` (→ caller full-scans) when no equality leaf
8910/// hits an index OR any matching locator lives in the cold tier
8911/// — the mutation paths operate on hot rows, and the PK
8912/// promote-then-walk upstream already handles the
8913/// cold-single-row case.
8914fn try_index_seek_positions(
8915    where_expr: &Expr,
8916    schema_cols: &[ColumnSchema],
8917    table: &Table,
8918    table_alias: &str,
8919) -> Option<Vec<usize>> {
8920    if let Expr::Binary {
8921        lhs,
8922        op: BinOp::And,
8923        rhs,
8924    } = where_expr
8925    {
8926        if let Some(p) = try_index_seek_positions(lhs, schema_cols, table, table_alias) {
8927            return Some(p);
8928        }
8929        return try_index_seek_positions(rhs, schema_cols, table, table_alias);
8930    }
8931    let Expr::Binary {
8932        lhs,
8933        op: BinOp::Eq,
8934        rhs,
8935    } = where_expr
8936    else {
8937        return None;
8938    };
8939    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
8940        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
8941    let idx = table.index_on(col_pos)?;
8942    let key = IndexKey::from_value(&value)?;
8943    let locators = idx.lookup_eq(&key);
8944    let mut out = Vec::with_capacity(locators.len());
8945    for loc in locators {
8946        match *loc {
8947            spg_storage::RowLocator::Hot(i) => out.push(i),
8948            spg_storage::RowLocator::Cold { .. } => return None,
8949        }
8950    }
8951    Some(out)
8952}
8953
8954fn try_index_seek<'a>(
8955    where_expr: &Expr,
8956    schema_cols: &[ColumnSchema],
8957    catalog: &'a Catalog,
8958    table: &'a Table,
8959    table_alias: &str,
8960) -> Option<Vec<Cow<'a, Row>>> {
8961    // v7.11.3 — recurse through top-level `AND` so a PG-style
8962    // composite predicate like `WHERE id = 1 AND created_at > $1`
8963    // still hits the index on `id`. The caller re-applies the
8964    // full WHERE expression to each returned row, so dropping the
8965    // residual conjuncts here is correct — the index just narrows
8966    // the candidate set.
8967    if let Expr::Binary {
8968        lhs,
8969        op: BinOp::And,
8970        rhs,
8971    } = where_expr
8972    {
8973        // Try LHS first (typical convention: leading equality on
8974        // the indexed column comes first in user-written SQL).
8975        if let Some(rows) = try_index_seek(lhs, schema_cols, catalog, table, table_alias) {
8976            return Some(rows);
8977        }
8978        return try_index_seek(rhs, schema_cols, catalog, table, table_alias);
8979    }
8980    let Expr::Binary {
8981        lhs,
8982        op: BinOp::Eq,
8983        rhs,
8984    } = where_expr
8985    else {
8986        return None;
8987    };
8988    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
8989        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
8990    let idx = table.index_on(col_pos)?;
8991    let key = IndexKey::from_value(&value)?;
8992    let locators = idx.lookup_eq(&key);
8993    let table_name = table.schema().name.as_str();
8994    // v5.1: each locator dispatches to either the hot tier (zero-
8995    // copy borrow of `table.rows()[i]`) or a cold-tier segment
8996    // (one page read + dense row decode, ~µs scale). Cold rows are
8997    // returned as `Cow::Owned` so the caller's `&Row` iteration
8998    // doesn't see a tier distinction; pre-freezer (no cold
8999    // segments loaded) every locator is `Hot` and every entry is
9000    // `Cow::Borrowed` — identical cost to the pre-v5.1 path.
9001    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(locators.len());
9002    for loc in locators {
9003        match *loc {
9004            spg_storage::RowLocator::Hot(i) => {
9005                if let Some(row) = table.rows().get(i) {
9006                    out.push(Cow::Borrowed(row));
9007                }
9008            }
9009            spg_storage::RowLocator::Cold { segment_id, .. } => {
9010                if let Some(row) = catalog.resolve_cold_locator(table_name, segment_id, &key) {
9011                    out.push(Cow::Owned(row));
9012                }
9013            }
9014        }
9015    }
9016    Some(out)
9017}
9018
9019/// v7.12.3 — GIN-accelerated candidate seek for `WHERE col @@ <ts_query>`.
9020///
9021/// Recurses through top-level `AND` like [`try_index_seek`] so a
9022/// composite predicate `WHERE search_vector @@ q AND id > $1` still
9023/// hits the GIN index on `search_vector` — the caller re-applies the
9024/// full WHERE expression to each returned candidate, so dropping the
9025/// `id > $1` residual here stays semantically correct.
9026///
9027/// Returns `None` when:
9028///   - no leaf is a `col @@ <rhs>` shape on a GIN-indexed column;
9029///   - the RHS can't be const-evaluated to a `Value::TsQuery`
9030///     (typically because it references row columns);
9031///   - the resolved `TsQuery` uses query shapes the MVP doesn't
9032///     accelerate (`Not`, `Phrase` — those fall through to full scan).
9033///
9034/// On `Some(rows)` the caller iterates only `rows` and re-evaluates
9035/// the full `@@` predicate per row, so an over-approximate candidate
9036/// set is safe.
9037fn try_gin_seek<'a>(
9038    where_expr: &Expr,
9039    schema_cols: &[ColumnSchema],
9040    catalog: &'a Catalog,
9041    table: &'a Table,
9042    table_alias: &str,
9043    ctx: &eval::EvalContext<'_>,
9044) -> Option<Vec<Cow<'a, Row>>> {
9045    if let Expr::Binary {
9046        lhs,
9047        op: BinOp::And,
9048        rhs,
9049    } = where_expr
9050    {
9051        if let Some(rows) = try_gin_seek(lhs, schema_cols, catalog, table, table_alias, ctx) {
9052            return Some(rows);
9053        }
9054        return try_gin_seek(rhs, schema_cols, catalog, table, table_alias, ctx);
9055    }
9056    // v7.17.0 Phase 3.P0-44 — MySQL `MATCH(col1, col2) AGAINST (...)`
9057    // desugars into `(to_tsvector(col1) @@ q) OR (to_tsvector(col2) @@ q)`
9058    // in the parser. To accelerate the multi-column case, walk OR the same
9059    // way we walk AND: only emit a candidate set if BOTH sides can seek
9060    // (otherwise the OR result is unbounded and we must fall through to
9061    // the full scan). Candidates are union'd; the caller's WHERE re-eval
9062    // verifies the full predicate per row, so duplicates / supersets stay
9063    // semantically safe.
9064    if let Expr::Binary {
9065        lhs,
9066        op: BinOp::Or,
9067        rhs,
9068    } = where_expr
9069    {
9070        let left = try_gin_seek(lhs, schema_cols, catalog, table, table_alias, ctx)?;
9071        let right = try_gin_seek(rhs, schema_cols, catalog, table, table_alias, ctx)?;
9072        let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(left.len() + right.len());
9073        out.extend(left);
9074        out.extend(right);
9075        return Some(out);
9076    }
9077    let Expr::Binary {
9078        lhs,
9079        op: BinOp::TsMatch,
9080        rhs,
9081    } = where_expr
9082    else {
9083        return None;
9084    };
9085    // Either side can be the column; pgvector idiom (`vec @@ q`)
9086    // hits the first arm, FROM-clause-derived (`plainto_tsquery($1)
9087    // q ... WHERE search_vector @@ q`) the same. CROSS JOIN derived
9088    // tables resolve `q` to a Column too.
9089    let (col_pos, query) = resolve_gin_col_query(lhs, rhs, schema_cols, table_alias, ctx)
9090        .or_else(|| resolve_gin_col_query(rhs, lhs, schema_cols, table_alias, ctx))?;
9091    // v7.17.0 Phase 3.P0-44 — MySQL `FULLTEXT KEY` builds a
9092    // `IndexKind::GinFulltext` posting list (Phase 2.2). It shares
9093    // the same `gin_lookup_word` shape as the tsvector-typed GIN,
9094    // so the MATCH-AGAINST `@@` predicate (desugared by the parser
9095    // into `to_tsvector(col) @@ plainto_tsquery('term')`) routes
9096    // through the same candidate-set seek.
9097    let idx = table
9098        .indices()
9099        .iter()
9100        .find(|i| i.column_position == col_pos && (i.is_gin() || i.is_gin_fulltext()))?;
9101    let candidates = gin_query_candidates(idx, &query)?;
9102    let _ = catalog; // cold-tier row resolution unused in MVP; see below.
9103    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(candidates.len());
9104    for loc in candidates {
9105        match loc {
9106            spg_storage::RowLocator::Hot(i) => {
9107                if let Some(row) = table.rows().get(i) {
9108                    out.push(Cow::Borrowed(row));
9109                }
9110            }
9111            // GIN cold-tier rows in the MVP: skipped, matching the
9112            // full-scan `@@` path which itself only iterates
9113            // `table.rows()` (hot tier). When v7.13+ adds cold-tier
9114            // scan-time materialisation for `@@`, the parallel
9115            // resolution lands here; until then both paths see the
9116            // same hot-only candidate set so correctness is preserved.
9117            spg_storage::RowLocator::Cold { .. } => {}
9118        }
9119    }
9120    Some(out)
9121}
9122
9123/// v7.15.0 — trigram-GIN-accelerated candidate seek for
9124/// `WHERE col LIKE '<pat>'` and `WHERE col ILIKE '<pat>'` when
9125/// the column has a `gin_trgm_ops` GIN index.
9126///
9127/// Walks top-level `AND` so multi-predicate WHEREs (`col LIKE
9128/// 'foo%' AND id > 1`) still hit the trigram index; the caller
9129/// re-evaluates the full WHERE per candidate row, so dropping
9130/// non-LIKE conjuncts here stays semantically correct.
9131///
9132/// Returns `None` when:
9133///   - no leaf is `col LIKE/ILIKE <literal>` on a trigram-GIN-
9134///     indexed column;
9135///   - the pattern's literal runs are too short to constrain
9136///     (pattern decomposes into `< 3`-char runs, e.g. `%ab%`);
9137///   - the pattern doesn't const-evaluate to a TEXT.
9138fn try_trgm_seek<'a>(
9139    where_expr: &Expr,
9140    schema_cols: &[ColumnSchema],
9141    table: &'a Table,
9142    table_alias: &str,
9143) -> Option<Vec<Cow<'a, Row>>> {
9144    if let Expr::Binary {
9145        lhs,
9146        op: BinOp::And,
9147        rhs,
9148    } = where_expr
9149    {
9150        if let Some(rows) = try_trgm_seek(lhs, schema_cols, table, table_alias) {
9151            return Some(rows);
9152        }
9153        return try_trgm_seek(rhs, schema_cols, table, table_alias);
9154    }
9155    // LIKE node is what carries the column reference + pattern.
9156    // ILIKE is the same AST node — PG's LIKE/ILIKE both lower
9157    // through `Expr::Like { expr, pattern, negated }`. The trigram
9158    // index posting-list keys are already lower-cased and
9159    // case-folded, so we only need the pattern's literal runs.
9160    let Expr::Like { expr, pattern, .. } = where_expr else {
9161        return None;
9162    };
9163    // Column side.
9164    let Expr::Column(c) = expr.as_ref() else {
9165        return None;
9166    };
9167    if let Some(q) = &c.qualifier
9168        && q != table_alias
9169    {
9170        return None;
9171    }
9172    let col_pos = schema_cols
9173        .iter()
9174        .position(|s| s.name.eq_ignore_ascii_case(&c.name))?;
9175    // Index must exist on that column AND be a trigram-GIN.
9176    let idx = table
9177        .indices()
9178        .iter()
9179        .find(|i| i.column_position == col_pos && i.is_gin_trgm())?;
9180    // Pattern side must be a literal TEXT — anything else (column
9181    // ref, function call, parameter that hasn't been bound yet)
9182    // falls through to full scan.
9183    let Expr::Literal(spg_sql::ast::Literal::String(pat)) = pattern.as_ref() else {
9184        return None;
9185    };
9186    let trigrams = spg_storage::trgm::trigrams_from_like_pattern(pat)?;
9187    // Intersect every trigram's posting list. Empty intersection
9188    // → empty candidate set (caller short-circuits its row loop).
9189    let mut iter = trigrams.iter();
9190    let first = iter.next()?;
9191    let mut acc: Vec<spg_storage::RowLocator> = {
9192        let mut v = idx.gin_trgm_lookup(first).to_vec();
9193        v.sort_by_key(locator_sort_key);
9194        v.dedup_by_key(|l| locator_sort_key(l));
9195        v
9196    };
9197    for tri in iter {
9198        let mut next: Vec<spg_storage::RowLocator> = idx.gin_trgm_lookup(tri).to_vec();
9199        next.sort_by_key(locator_sort_key);
9200        next.dedup_by_key(|l| locator_sort_key(l));
9201        // Sorted-merge intersection.
9202        let mut merged: Vec<spg_storage::RowLocator> =
9203            Vec::with_capacity(acc.len().min(next.len()));
9204        let (mut i, mut j) = (0usize, 0usize);
9205        while i < acc.len() && j < next.len() {
9206            let lk = locator_sort_key(&acc[i]);
9207            let rk = locator_sort_key(&next[j]);
9208            match lk.cmp(&rk) {
9209                core::cmp::Ordering::Less => i += 1,
9210                core::cmp::Ordering::Greater => j += 1,
9211                core::cmp::Ordering::Equal => {
9212                    merged.push(acc[i]);
9213                    i += 1;
9214                    j += 1;
9215                }
9216            }
9217        }
9218        acc = merged;
9219        if acc.is_empty() {
9220            break;
9221        }
9222    }
9223    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(acc.len());
9224    for loc in acc {
9225        if let spg_storage::RowLocator::Hot(i) = loc
9226            && let Some(row) = table.rows().get(i)
9227        {
9228            out.push(Cow::Borrowed(row));
9229        }
9230        // Cold-tier rows: skipped in MVP (same as try_gin_seek).
9231    }
9232    Some(out)
9233}
9234
9235/// v7.12.3 — extract `(column_position, TsQueryAst)` when one side of
9236/// the binary is a column reference to a GIN-indexed tsvector column
9237/// and the other side const-evaluates to a `Value::TsQuery`. Returns
9238/// `None` if the column reference is for the wrong table alias, or if
9239/// the RHS expression depends on row data.
9240fn resolve_gin_col_query(
9241    col_side: &Expr,
9242    query_side: &Expr,
9243    schema_cols: &[ColumnSchema],
9244    table_alias: &str,
9245    ctx: &eval::EvalContext<'_>,
9246) -> Option<(usize, spg_storage::TsQueryAst)> {
9247    // v7.17.0 Phase 3.P0-44 — the MATCH AGAINST desugar wraps the
9248    // column in `to_tsvector('simple', col)`, so we peel that wrapper
9249    // before the column lookup. Direct `col @@ tsquery` paths (the
9250    // tsvector-typed v7.12 surface) skip the wrapper entirely.
9251    let column = match col_side {
9252        Expr::Column(c) => c,
9253        Expr::FunctionCall { name, args }
9254            if name.eq_ignore_ascii_case("to_tsvector") && !args.is_empty() =>
9255        {
9256            // PG `to_tsvector` accepts either `to_tsvector(col)` or
9257            // `to_tsvector(config, col)`. In both shapes the column
9258            // we care about is the final argument.
9259            if let Expr::Column(c) = args.last().unwrap() {
9260                c
9261            } else {
9262                return None;
9263            }
9264        }
9265        _ => return None,
9266    };
9267    let c = column;
9268    if let Some(q) = &c.qualifier
9269        && q != table_alias
9270    {
9271        return None;
9272    }
9273    let pos = schema_cols.iter().position(|s| s.name == c.name)?;
9274    // Const-evaluate the query side with an empty row — fails fast
9275    // (with a `ColumnNotFound` / similar) if the expression actually
9276    // depends on row data, which is exactly the bail signal we want.
9277    let empty_row = Row::new(Vec::new());
9278    let v = eval::eval_expr(query_side, &empty_row, ctx).ok()?;
9279    let Value::TsQuery(q) = v else { return None };
9280    Some((pos, q))
9281}
9282
9283/// v7.12.3 — walk a `TsQueryAst` against an [`IndexKind::Gin`] index
9284/// to produce a candidate row-locator set. Returns `None` for query
9285/// shapes the MVP doesn't accelerate (`Not` / `Phrase` — both bail to
9286/// full scan since their semantics need either complementation across
9287/// the whole row set or positional verification beyond what the
9288/// posting list carries).
9289///
9290/// Candidate sets are over-approximate — the caller re-applies the
9291/// full `@@` predicate per row, so reporting "row was in some
9292/// posting list" without verifying positions / weights stays correct.
9293fn gin_query_candidates(
9294    idx: &spg_storage::Index,
9295    query: &spg_storage::TsQueryAst,
9296) -> Option<Vec<spg_storage::RowLocator>> {
9297    use spg_storage::TsQueryAst;
9298    match query {
9299        TsQueryAst::Term { word, .. } => {
9300            let mut v: Vec<spg_storage::RowLocator> = idx.gin_lookup_word(word).to_vec();
9301            v.sort_by_key(locator_sort_key);
9302            v.dedup_by_key(|l| locator_sort_key(l));
9303            Some(v)
9304        }
9305        TsQueryAst::And(l, r) => {
9306            let mut left = gin_query_candidates(idx, l)?;
9307            let mut right = gin_query_candidates(idx, r)?;
9308            left.sort_by_key(locator_sort_key);
9309            right.sort_by_key(locator_sort_key);
9310            // Sorted-merge intersection.
9311            let mut out: Vec<spg_storage::RowLocator> = Vec::new();
9312            let (mut i, mut j) = (0usize, 0usize);
9313            while i < left.len() && j < right.len() {
9314                let lk = locator_sort_key(&left[i]);
9315                let rk = locator_sort_key(&right[j]);
9316                match lk.cmp(&rk) {
9317                    core::cmp::Ordering::Less => i += 1,
9318                    core::cmp::Ordering::Greater => j += 1,
9319                    core::cmp::Ordering::Equal => {
9320                        out.push(left[i]);
9321                        i += 1;
9322                        j += 1;
9323                    }
9324                }
9325            }
9326            Some(out)
9327        }
9328        TsQueryAst::Or(l, r) => {
9329            let mut out = gin_query_candidates(idx, l)?;
9330            out.extend(gin_query_candidates(idx, r)?);
9331            out.sort_by_key(locator_sort_key);
9332            out.dedup_by_key(|l| locator_sort_key(l));
9333            Some(out)
9334        }
9335        // Not / Phrase bail to full scan in the MVP. Not needs
9336        // complementation against the whole row set (not represented
9337        // in the posting-list view); Phrase needs positional
9338        // verification beyond what `word → rows` carries.
9339        TsQueryAst::Not(_) | TsQueryAst::Phrase { .. } => None,
9340    }
9341}
9342
9343/// v7.12.3 — total ordering on `RowLocator` for sort/dedup purposes
9344/// inside the GIN intersection / union loops. Hot rows order by their
9345/// row index; Cold rows order after all Hot rows, then by
9346/// `(segment_id, the cold sub-key)`.
9347fn locator_sort_key(l: &spg_storage::RowLocator) -> (u8, u64, u64) {
9348    match *l {
9349        spg_storage::RowLocator::Hot(i) => (0, i as u64, 0),
9350        spg_storage::RowLocator::Cold {
9351            segment_id,
9352            page_offset,
9353        } => (1, u64::from(segment_id), u64::from(page_offset)),
9354    }
9355}
9356
9357/// v5.2.3: extract `(column_position, IndexKey)` when `where_expr`
9358/// is a simple `col = literal` predicate suitable for a `BTree` index
9359/// seek. Used by `exec_update_cancel` / `exec_delete_cancel` to
9360/// decide whether a write touches a cold-tier row (which requires
9361/// promote-on-write / shadow-on-delete) before falling through to
9362/// the hot-tier row walk.
9363///
9364/// Returns `None` for any predicate shape the planner can't push
9365/// down to an index seek — complex WHERE clauses always take the
9366/// hot-only path (cold rows are immutable to non-indexed writes
9367/// until a future scan-fanout sub-version).
9368fn try_pk_predicate(
9369    where_expr: &Expr,
9370    schema_cols: &[ColumnSchema],
9371    table_alias: &str,
9372) -> Option<(usize, IndexKey)> {
9373    let Expr::Binary {
9374        lhs,
9375        op: BinOp::Eq,
9376        rhs,
9377    } = where_expr
9378    else {
9379        return None;
9380    };
9381    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
9382        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
9383    let key = IndexKey::from_value(&value)?;
9384    Some((col_pos, key))
9385}
9386
9387fn resolve_col_literal_pair(
9388    col_side: &Expr,
9389    lit_side: &Expr,
9390    schema_cols: &[ColumnSchema],
9391    table_alias: &str,
9392) -> Option<(usize, Value)> {
9393    let Expr::Column(c) = col_side else {
9394        return None;
9395    };
9396    if let Some(q) = &c.qualifier
9397        && q != table_alias
9398    {
9399        return None;
9400    }
9401    let pos = schema_cols.iter().position(|s| s.name == c.name)?;
9402    let Expr::Literal(l) = lit_side else {
9403        return None;
9404    };
9405    let v = match l {
9406        Literal::Integer(n) => {
9407            if let Ok(small) = i32::try_from(*n) {
9408                Value::Int(small)
9409            } else {
9410                Value::BigInt(*n)
9411            }
9412        }
9413        Literal::Float(x) => Value::Float(*x),
9414        Literal::String(s) => Value::Text(s.clone()),
9415        Literal::Bool(b) => Value::Bool(*b),
9416        Literal::Null => Value::Null,
9417        // Vector, array and Interval literals can't be used as B-tree
9418        // index keys. Tell the planner to fall back to full-scan.
9419        Literal::Vector(_)
9420        | Literal::Interval { .. }
9421        | Literal::TextArray(_)
9422        | Literal::IntArray(_)
9423        | Literal::BigIntArray(_) => return None,
9424    };
9425    Some((pos, v))
9426}
9427
9428/// Find the schema entry that a SELECT-list `Expr::Column` refers to.
9429/// Mirrors `resolve_column` in `eval.rs`, but returns a proper
9430/// `EngineError` so the projection-build path keeps `UnknownQualifier`
9431/// vs `ColumnNotFound` distinct.
9432fn resolve_projection_column<'a>(
9433    c: &ColumnName,
9434    schema_cols: &'a [ColumnSchema],
9435    table_alias: &str,
9436) -> Result<&'a ColumnSchema, EngineError> {
9437    if let Some(q) = &c.qualifier {
9438        let composite = alloc::format!("{q}.{name}", name = c.name);
9439        if let Some(s) = schema_cols.iter().find(|s| s.name == composite) {
9440            return Ok(s);
9441        }
9442        // Single-table case: the qualifier may equal the active alias —
9443        // then look for the bare column name.
9444        if q == table_alias
9445            && let Some(s) = schema_cols.iter().find(|s| s.name == c.name)
9446        {
9447            return Ok(s);
9448        }
9449        // For multi-table schemas the qualifier is unknown only if no
9450        // column bears the "<q>." prefix. For single-table, the alias
9451        // mismatch alone is enough.
9452        let prefix = alloc::format!("{q}.");
9453        let qualifier_known =
9454            q == table_alias || schema_cols.iter().any(|s| s.name.starts_with(&prefix));
9455        if !qualifier_known {
9456            return Err(EngineError::Eval(EvalError::UnknownQualifier {
9457                qualifier: q.clone(),
9458            }));
9459        }
9460        return Err(EngineError::Eval(EvalError::ColumnNotFound {
9461            name: c.name.clone(),
9462        }));
9463    }
9464    if let Some(s) = schema_cols.iter().find(|s| s.name == c.name) {
9465        return Ok(s);
9466    }
9467    let suffix = alloc::format!(".{name}", name = c.name);
9468    let mut matches = schema_cols.iter().filter(|s| s.name.ends_with(&suffix));
9469    let first = matches.next();
9470    let extra = matches.next();
9471    match (first, extra) {
9472        (Some(s), None) => Ok(s),
9473        (Some(_), Some(_)) => Err(EngineError::Eval(EvalError::TypeMismatch {
9474            detail: alloc::format!("ambiguous column reference: {}", c.name),
9475        })),
9476        _ => Err(EngineError::Eval(EvalError::ColumnNotFound {
9477            name: c.name.clone(),
9478        })),
9479    }
9480}
9481
9482fn build_projection(
9483    items: &[SelectItem],
9484    schema_cols: &[ColumnSchema],
9485    table_alias: &str,
9486) -> Result<Vec<ProjectedItem>, EngineError> {
9487    let mut out = Vec::new();
9488    for item in items {
9489        match item {
9490            SelectItem::Wildcard => {
9491                for col in schema_cols {
9492                    out.push(ProjectedItem {
9493                        expr: Expr::Column(ColumnName {
9494                            qualifier: None,
9495                            name: col.name.clone(),
9496                        }),
9497                        output_name: col.name.clone(),
9498                        ty: col.ty,
9499                        nullable: col.nullable,
9500                    });
9501                }
9502            }
9503            SelectItem::Expr { expr, alias } => {
9504                // Plain column ref keeps full schema info (real type +
9505                // nullability). For compound expressions try the
9506                // describe-side function-return-type table first
9507                // (e.g. `SELECT now()` → Timestamptz, `SELECT
9508                // concat(…)` → Text). Falls back to nullable Text
9509                // for shapes the describe path can't resolve.
9510                if let Expr::Column(c) = expr {
9511                    let sch = resolve_projection_column(c, schema_cols, table_alias)?;
9512                    let output_name = alias.clone().unwrap_or_else(|| c.name.clone());
9513                    out.push(ProjectedItem {
9514                        expr: expr.clone(),
9515                        output_name,
9516                        ty: sch.ty,
9517                        nullable: sch.nullable,
9518                    });
9519                } else if let Some(shape) = describe::describe_expr(expr, schema_cols) {
9520                    let output_name = alias.clone().unwrap_or_else(|| expr.to_string());
9521                    out.push(ProjectedItem {
9522                        expr: expr.clone(),
9523                        output_name,
9524                        ty: shape.ty,
9525                        nullable: shape.nullable,
9526                    });
9527                } else {
9528                    let output_name = alias.clone().unwrap_or_else(|| expr.to_string());
9529                    out.push(ProjectedItem {
9530                        expr: expr.clone(),
9531                        output_name,
9532                        ty: DataType::Text,
9533                        nullable: true,
9534                    });
9535                }
9536            }
9537        }
9538    }
9539    Ok(out)
9540}
9541
9542/// Promote an integer to a NUMERIC value at the requested scale.
9543/// Rejects values that, after scaling, would overflow the column's
9544/// precision budget.
9545fn numeric_from_integer(
9546    n: i128,
9547    precision: u8,
9548    scale: u8,
9549    col_name: &str,
9550) -> Result<Value, EngineError> {
9551    let factor = pow10_i128(scale);
9552    let scaled = n.checked_mul(factor).ok_or_else(|| {
9553        EngineError::Unsupported(alloc::format!(
9554            "integer overflow scaling value for column `{col_name}` to scale {scale}"
9555        ))
9556    })?;
9557    check_precision(scaled, precision, col_name)?;
9558    Ok(Value::Numeric { scaled, scale })
9559}
9560
9561/// Float → NUMERIC. Uses round-half-away-from-zero on `x * 10^scale`,
9562/// then verifies the result fits the column's precision.
9563#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
9564fn numeric_from_float(
9565    x: f64,
9566    precision: u8,
9567    scale: u8,
9568    col_name: &str,
9569) -> Result<Value, EngineError> {
9570    if !x.is_finite() {
9571        return Err(EngineError::Unsupported(alloc::format!(
9572            "cannot store non-finite float in NUMERIC column `{col_name}`"
9573        )));
9574    }
9575    let mut factor = 1.0_f64;
9576    for _ in 0..scale {
9577        factor *= 10.0;
9578    }
9579    // Round half-away-from-zero by biasing then casting (`as i128`
9580    // truncates toward zero, so the bias + truncation gives the
9581    // desired rounding). `f64::floor` / `ceil` live in std; we don't
9582    // need them — the cast handles the truncation step.
9583    let shifted = x * factor;
9584    let biased = if shifted >= 0.0 {
9585        shifted + 0.5
9586    } else {
9587        shifted - 0.5
9588    };
9589    // Range-check before casting back to i128 — the cast itself is
9590    // saturating in Rust, which would silently truncate huge inputs.
9591    if !(-1e38..=1e38).contains(&biased) {
9592        return Err(EngineError::Unsupported(alloc::format!(
9593            "value {x} overflows NUMERIC range for column `{col_name}`"
9594        )));
9595    }
9596    let scaled = biased as i128;
9597    check_precision(scaled, precision, col_name)?;
9598    Ok(Value::Numeric { scaled, scale })
9599}
9600
9601/// v7.17.0 Phase 3.P0-67 — parse PG-canonical decimal text into
9602/// `(mantissa: i128, source_scale: u8)`. Accepts optional sign,
9603/// optional integer part, optional fractional part. Rejects
9604/// scientific notation, embedded spaces, locale-specific
9605/// thousand separators. Returns None on bad input — coerce_value
9606/// turns that into a TypeMismatch error.
9607fn parse_numeric_text(s: &str) -> Option<(i128, u8)> {
9608    let s = s.trim();
9609    if s.is_empty() {
9610        return None;
9611    }
9612    let (negative, rest) = match s.as_bytes()[0] {
9613        b'-' => (true, &s[1..]),
9614        b'+' => (false, &s[1..]),
9615        _ => (false, s),
9616    };
9617    if rest.is_empty() {
9618        return None;
9619    }
9620    // Reject scientific notation — bigdecimal collapses it before
9621    // hitting the wire, and we want a clear error if a stray `e`
9622    // sneaks in.
9623    if rest.bytes().any(|b| b == b'e' || b == b'E') {
9624        return None;
9625    }
9626    let (int_part, frac_part) = match rest.find('.') {
9627        Some(idx) => (&rest[..idx], &rest[idx + 1..]),
9628        None => (rest, ""),
9629    };
9630    if int_part.is_empty() && frac_part.is_empty() {
9631        return None;
9632    }
9633    if int_part.bytes().any(|b| !b.is_ascii_digit()) {
9634        return None;
9635    }
9636    if frac_part.bytes().any(|b| !b.is_ascii_digit()) {
9637        return None;
9638    }
9639    let scale_u32 = u32::try_from(frac_part.len()).ok()?;
9640    if scale_u32 > u32::from(u8::MAX) {
9641        return None;
9642    }
9643    let scale = scale_u32 as u8;
9644    let mut digits = alloc::string::String::with_capacity(int_part.len() + frac_part.len() + 1);
9645    if negative {
9646        digits.push('-');
9647    }
9648    digits.push_str(int_part);
9649    digits.push_str(frac_part);
9650    // Strip a leading "+0..0" so parse doesn't choke on "00" etc.
9651    let digits = if digits == "-" {
9652        return None;
9653    } else if digits.is_empty() {
9654        "0"
9655    } else {
9656        digits.as_str()
9657    };
9658    let mantissa: i128 = digits.parse().ok()?;
9659    Some((mantissa, scale))
9660}
9661
9662/// Move a Numeric value from `src_scale` to `dst_scale`. Going up
9663/// multiplies by 10; going down rounds half-away-from-zero.
9664fn numeric_rescale(
9665    scaled: i128,
9666    src_scale: u8,
9667    precision: u8,
9668    dst_scale: u8,
9669    col_name: &str,
9670) -> Result<Value, EngineError> {
9671    let new_scaled = if dst_scale >= src_scale {
9672        let bump = pow10_i128(dst_scale - src_scale);
9673        scaled.checked_mul(bump).ok_or_else(|| {
9674            EngineError::Unsupported(alloc::format!(
9675                "overflow rescaling NUMERIC for column `{col_name}`"
9676            ))
9677        })?
9678    } else {
9679        let drop = pow10_i128(src_scale - dst_scale);
9680        let half = drop / 2;
9681        if scaled >= 0 {
9682            (scaled + half) / drop
9683        } else {
9684            (scaled - half) / drop
9685        }
9686    };
9687    check_precision(new_scaled, precision, col_name)?;
9688    Ok(Value::Numeric {
9689        scaled: new_scaled,
9690        scale: dst_scale,
9691    })
9692}
9693
9694/// Drop the fractional part of a scaled integer, returning the integer
9695/// portion (toward zero). Used for NUMERIC → INT casts.
9696const fn numeric_truncate_to_integer(scaled: i128, scale: u8) -> i128 {
9697    if scale == 0 {
9698        return scaled;
9699    }
9700    let factor = pow10_i128_const(scale);
9701    scaled / factor
9702}
9703
9704/// Verify a scaled NUMERIC value fits the column's declared precision.
9705/// `precision == 0` is the "unconstrained" form (bare `NUMERIC`); we
9706/// skip the check there.
9707fn check_precision(scaled: i128, precision: u8, col_name: &str) -> Result<(), EngineError> {
9708    if precision == 0 {
9709        return Ok(());
9710    }
9711    let limit = pow10_i128(precision);
9712    if scaled.unsigned_abs() >= limit.unsigned_abs() {
9713        return Err(EngineError::Unsupported(alloc::format!(
9714            "NUMERIC value exceeds precision {precision} for column `{col_name}`"
9715        )));
9716    }
9717    Ok(())
9718}
9719
9720const fn pow10_i128_const(p: u8) -> i128 {
9721    let mut acc: i128 = 1;
9722    let mut i = 0;
9723    while i < p {
9724        acc *= 10;
9725        i += 1;
9726    }
9727    acc
9728}
9729
9730fn pow10_i128(p: u8) -> i128 {
9731    pow10_i128_const(p)
9732}
9733
9734/// Walk a parsed `Statement`, swapping any `NOW()` /
9735/// `CURRENT_TIMESTAMP()` / `CURRENT_DATE()` function calls for a
9736/// literal cast that wraps the engine's per-statement clock reading.
9737/// When `now_micros` is `None`, calls stay as-is and surface as
9738/// `unknown function` at eval time — keeps the error path explicit.
9739/// v4.10: pre-walk the WHERE / projection / etc. of a SELECT and
9740/// replace every subquery node with a materialised literal. SPG
9741/// only supports uncorrelated subqueries — the inner SELECT does
9742/// not see outer-row columns, so the result is the same for every
9743/// outer row and can be evaluated once.
9744///
9745/// Returns the rewritten statement; the caller passes this to the
9746/// regular row-loop executor which no longer sees Subquery nodes
9747/// in its tree.
9748impl Engine {
9749    /// v4.12 window executor. Implements `ROW_NUMBER` / `RANK` /
9750    /// `DENSE_RANK` and the partition-aware aggregates `SUM` /
9751    /// `AVG` / `COUNT` / `MIN` / `MAX`. The plan is:
9752    /// 1. Apply the WHERE filter.
9753    /// 2. For each unique `WindowFunction` node in the projection,
9754    ///    partition + sort, compute the per-row value.
9755    /// 3. Append the window values as synthetic columns (`__win_N`)
9756    ///    to the row schema.
9757    /// 4. Rewrite the projection to read those columns.
9758    /// 5. Hand off to the regular project / ORDER BY / LIMIT pipe.
9759    #[allow(
9760        clippy::too_many_lines,
9761        clippy::type_complexity,
9762        clippy::needless_range_loop
9763    )] // window-eval is one cohesive pipe; splitting fragments
9764    fn exec_select_with_window(
9765        &self,
9766        stmt: &SelectStatement,
9767        cancel: CancelToken<'_>,
9768    ) -> Result<QueryResult, EngineError> {
9769        let from = stmt.from.as_ref().ok_or_else(|| {
9770            EngineError::Unsupported("window functions require a FROM clause".into())
9771        })?;
9772        // v7.17.0 Phase 3.P0-43 — JOIN + window functions. Phase
9773        // 3.6 rejected this combination outright ("queued for
9774        // v5.x"); P0-43 materialises the join + WHERE through the
9775        // existing nested-loop helper and runs the window pipeline
9776        // on the joined row set with the combined `alias.col`
9777        // schema. The window expressions resolve through the
9778        // qualifier-aware column resolver same as the aggregate /
9779        // projection paths on JOIN.
9780        let (schema_cols_owned, alias_opt): (Vec<ColumnSchema>, Option<&str>);
9781        let filtered: Vec<Row>;
9782        if from.joins.is_empty() {
9783            let primary = &from.primary;
9784            let table = self.active_catalog().get(&primary.name).ok_or_else(|| {
9785                StorageError::TableNotFound {
9786                    name: primary.name.clone(),
9787                }
9788            })?;
9789            let alias = primary.alias.as_deref().unwrap_or(primary.name.as_str());
9790            schema_cols_owned = table.schema().columns.clone();
9791            alias_opt = Some(alias);
9792            // Materialise WHERE-filtered rows owned so the JOIN
9793            // and single-table paths share a single downstream
9794            // shape. The clone is cheap relative to the window
9795            // computation that follows.
9796            let ctx = self.ev_ctx(&schema_cols_owned, alias_opt);
9797            let mut owned: Vec<Row> = Vec::new();
9798            for (i, row) in table.rows().iter().enumerate() {
9799                if i.is_multiple_of(256) {
9800                    cancel.check()?;
9801                }
9802                if let Some(w) = &stmt.where_ {
9803                    let cond = eval::eval_expr(w, row, &ctx)?;
9804                    if !matches!(cond, Value::Bool(true)) {
9805                        continue;
9806                    }
9807                }
9808                owned.push(row.clone());
9809            }
9810            filtered = owned;
9811        } else {
9812            let (combined_schema, rows) =
9813                self.build_joined_filtered_rows(from, stmt.where_.as_ref(), cancel)?;
9814            schema_cols_owned = combined_schema;
9815            alias_opt = None;
9816            filtered = rows;
9817        }
9818        let schema_cols = &schema_cols_owned;
9819        let ctx = self.ev_ctx(schema_cols, alias_opt);
9820        let alias = alias_opt.unwrap_or("");
9821        let n_rows = filtered.len();
9822        // Borrow refs into the owned row vec once so the downstream
9823        // `compute_window_partition` call (which takes `&[&Row]`) and
9824        // the per-row eval loops share a single backing buffer.
9825        let filtered_refs: Vec<&Row> = filtered.iter().collect();
9826
9827        // 2) Collect unique window function nodes from projection.
9828        let mut window_nodes: Vec<Expr> = Vec::new();
9829        for item in &stmt.items {
9830            if let SelectItem::Expr { expr, .. } = item {
9831                collect_window_nodes(expr, &mut window_nodes);
9832            }
9833        }
9834
9835        // 3) For each window, compute per-row value.
9836        // Index: same order as window_nodes; for row i, win_vals[w][i].
9837        let mut win_vals: Vec<Vec<Value>> = Vec::with_capacity(window_nodes.len());
9838        for wnode in &window_nodes {
9839            let Expr::WindowFunction {
9840                name,
9841                args,
9842                partition_by,
9843                order_by,
9844                frame,
9845                null_treatment,
9846            } = wnode
9847            else {
9848                unreachable!("collect_window_nodes pushes only WindowFunction");
9849            };
9850            // Compute (partition_key, order_key, original_index) for each row.
9851            let mut indexed: Vec<(Vec<Value>, Vec<(Value, bool, Option<bool>)>, usize)> =
9852                Vec::with_capacity(n_rows);
9853            for (i, row) in filtered.iter().enumerate() {
9854                let pkey: Vec<Value> = partition_by
9855                    .iter()
9856                    .map(|p| eval::eval_expr(p, row, &ctx))
9857                    .collect::<Result<_, _>>()?;
9858                let okey: Vec<(Value, bool, Option<bool>)> = order_by
9859                    .iter()
9860                    .map(|(e, desc, nf)| eval::eval_expr(e, row, &ctx).map(|v| (v, *desc, *nf)))
9861                    .collect::<Result<_, _>>()?;
9862                indexed.push((pkey, okey, i));
9863            }
9864            // Sort by (partition_key, order_key). Partition key uses
9865            // a stable encoded form; order key respects ASC/DESC.
9866            indexed.sort_by(|a, b| {
9867                let p_cmp = partition_key_cmp(&a.0, &b.0);
9868                if p_cmp != core::cmp::Ordering::Equal {
9869                    return p_cmp;
9870                }
9871                order_key_cmp(&a.1, &b.1)
9872            });
9873            // Per-partition compute.
9874            let mut out_vals: Vec<Value> = alloc::vec![Value::Null; n_rows];
9875            let mut p_start = 0;
9876            while p_start < indexed.len() {
9877                let mut p_end = p_start + 1;
9878                while p_end < indexed.len()
9879                    && partition_key_cmp(&indexed[p_start].0, &indexed[p_end].0)
9880                        == core::cmp::Ordering::Equal
9881                {
9882                    p_end += 1;
9883                }
9884                // Compute the function within this partition slice.
9885                compute_window_partition(
9886                    name,
9887                    args,
9888                    !order_by.is_empty(),
9889                    frame.as_ref(),
9890                    *null_treatment,
9891                    &indexed[p_start..p_end],
9892                    &filtered_refs,
9893                    &ctx,
9894                    &mut out_vals,
9895                )?;
9896                p_start = p_end;
9897            }
9898            win_vals.push(out_vals);
9899        }
9900
9901        // 4) Build extended schema: original columns + synthetic.
9902        let mut ext_cols = schema_cols.clone();
9903        for i in 0..window_nodes.len() {
9904            ext_cols.push(ColumnSchema::new(
9905                alloc::format!("__win_{i}"),
9906                DataType::Text, // type doesn't matter for projection eval
9907                true,
9908            ));
9909        }
9910        // 5) Build extended rows: each row gets its window values appended.
9911        let mut ext_rows: Vec<Row> = Vec::with_capacity(n_rows);
9912        for i in 0..n_rows {
9913            let mut values = filtered[i].values.clone();
9914            for w in 0..window_nodes.len() {
9915                values.push(win_vals[w][i].clone());
9916            }
9917            ext_rows.push(Row::new(values));
9918        }
9919        // 6) Rewrite the projection: WindowFunction nodes → Column(__win_N).
9920        let mut rewritten_items: Vec<SelectItem> = Vec::with_capacity(stmt.items.len());
9921        for item in &stmt.items {
9922            let new_item = match item {
9923                SelectItem::Wildcard => SelectItem::Wildcard,
9924                SelectItem::Expr { expr, alias } => {
9925                    let mut e = expr.clone();
9926                    rewrite_window_to_columns(&mut e, &window_nodes);
9927                    SelectItem::Expr {
9928                        expr: e,
9929                        alias: alias.clone(),
9930                    }
9931                }
9932            };
9933            rewritten_items.push(new_item);
9934        }
9935
9936        // 7) Project into final rows. JOIN case uses None so the
9937        // qualifier check in `resolve_column` falls through to the
9938        // composite `alias.col` schema lookup; single-table case
9939        // keeps the bare alias so `bare_col` resolution still
9940        // works for the projection's per-row column references.
9941        let ext_ctx = EvalContext::new(&ext_cols, alias_opt);
9942        let projection = build_projection(&rewritten_items, &ext_cols, alias)?;
9943        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::with_capacity(n_rows);
9944        for (i, row) in ext_rows.iter().enumerate() {
9945            if i.is_multiple_of(256) {
9946                cancel.check()?;
9947            }
9948            let mut values = Vec::with_capacity(projection.len());
9949            for p in &projection {
9950                values.push(eval::eval_expr(&p.expr, row, &ext_ctx)?);
9951            }
9952            let order_keys = if stmt.order_by.is_empty() {
9953                Vec::new()
9954            } else {
9955                let mut keys = Vec::with_capacity(stmt.order_by.len());
9956                for o in &stmt.order_by {
9957                    let mut e = o.expr.clone();
9958                    rewrite_window_to_columns(&mut e, &window_nodes);
9959                    let key = eval::eval_expr(&e, row, &ext_ctx)?;
9960                    keys.push(value_to_order_key(&key)?);
9961                }
9962                keys
9963            };
9964            tagged.push((order_keys, Row::new(values)));
9965        }
9966        // ORDER BY + LIMIT/OFFSET on the projected rows.
9967        if !stmt.order_by.is_empty() {
9968            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
9969            sort_by_keys(&mut tagged, &descs);
9970        }
9971        let mut out_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
9972        apply_offset_and_limit(&mut out_rows, stmt.offset_literal(), stmt.limit_literal());
9973        let final_cols: Vec<ColumnSchema> = projection
9974            .into_iter()
9975            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
9976            .collect();
9977        Ok(QueryResult::Rows {
9978            columns: final_cols,
9979            rows: out_rows,
9980        })
9981    }
9982
9983    /// v4.11: materialise each CTE into a temp table inside a
9984    /// cloned catalog, then run the body SELECT against a fresh
9985    /// engine instance that owns the enriched catalog. The clone
9986    /// is moderately expensive — only paid by CTE-bearing queries.
9987    /// Subqueries inside CTE bodies / the main body resolve as
9988    /// usual; `clock_fn` is propagated so `NOW()` lines up.
9989    /// v7.16.2 — mailrs round-10 A.3. Materialise the
9990    /// `information_schema.*` / `pg_catalog.*` virtual views
9991    /// the SELECT references, then re-execute the SELECT
9992    /// against an enriched catalog where those views are real
9993    /// tables. Same pattern as `exec_with_ctes`. The temp
9994    /// engine carries `meta_views_materialised = true` so its
9995    /// own meta-dispatch short-circuits — without that we'd
9996    /// infinite-recurse since the temp catalog's view name
9997    /// still starts with `__spg_info_` and re-triggers the
9998    /// check.
9999    fn exec_select_with_meta_views(
10000        &self,
10001        stmt: &SelectStatement,
10002        cancel: CancelToken<'_>,
10003    ) -> Result<QueryResult, EngineError> {
10004        let mut needed: alloc::collections::BTreeSet<String> = alloc::collections::BTreeSet::new();
10005        collect_meta_view_names(stmt, &mut needed);
10006        let mut catalog = self.active_catalog().clone();
10007        for view in &needed {
10008            if catalog.get(view).is_some() {
10009                continue;
10010            }
10011            match view.as_str() {
10012                "__spg_info_columns" => {
10013                    let (schema, rows) = synth_information_schema_columns(self.active_catalog());
10014                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10015                }
10016                "__spg_info_tables" => {
10017                    let (schema, rows) = synth_information_schema_tables(self.active_catalog());
10018                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10019                }
10020                "__spg_pg_class" => {
10021                    let (schema, rows) = synth_pg_class(self.active_catalog());
10022                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10023                }
10024                "__spg_pg_attribute" => {
10025                    let (schema, rows) = synth_pg_attribute(self.active_catalog());
10026                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10027                }
10028                // v7.17.0 Phase 3.P0-50 — pg_catalog.pg_type for
10029                // sqlx / SQLAlchemy / Diesel / pgAdmin lookups.
10030                "__spg_pg_type" => {
10031                    let (schema, rows) = synth_pg_type(self.active_catalog());
10032                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10033                }
10034                // v7.17.0 Phase 3.P0-51 — pg_catalog.pg_proc for
10035                // function-name introspection (ORM / pgAdmin).
10036                "__spg_pg_proc" => {
10037                    let (schema, rows) = synth_pg_proc(self.active_catalog());
10038                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10039                }
10040                // v7.24 (round-16 D) — pg_catalog.pg_trigger. The
10041                // round-16 "why doesn't prod fire the trigger"
10042                // question was unanswerable because triggers had NO
10043                // introspection surface; tgname/tgenabled plus the
10044                // pragmatic relname/timing/events/function columns
10045                // make "is it registered and enabled" a one-liner.
10046                "__spg_pg_trigger" => {
10047                    let (schema, rows) = synth_pg_trigger(self.active_catalog());
10048                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10049                }
10050                // v7.17.0 Phase 3.P0-52 — pg_catalog.pg_namespace
10051                // (schema list for admin tools' tree views).
10052                "__spg_pg_namespace" => {
10053                    let (schema, rows) = synth_pg_namespace(self.active_catalog());
10054                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10055                }
10056                // v7.17.0 Phase 3.P0-53 — pg_catalog.pg_indexes view
10057                // for pgAdmin / DataGrip "indexes per table" listings.
10058                "__spg_pg_indexes" => {
10059                    let (schema, rows) = synth_pg_indexes(self.active_catalog());
10060                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10061                }
10062                // v7.17.0 Phase 3.P0-53 — pg_catalog.pg_index (raw)
10063                // for index introspection by ORM compilers.
10064                "__spg_pg_index" => {
10065                    let (schema, rows) = synth_pg_index_raw(self.active_catalog());
10066                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10067                }
10068                // v7.17.0 Phase 3.P0-54 — pg_catalog.pg_constraint
10069                // for FK / UNIQUE / PK / CHECK introspection.
10070                "__spg_pg_constraint" => {
10071                    let (schema, rows) = synth_pg_constraint(self.active_catalog());
10072                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10073                }
10074                // v7.17.0 Phase 3.P0-55 — pg_catalog.pg_database /
10075                // pg_roles / pg_user. SPG is single-database so
10076                // pg_database surfaces just `postgres`; pg_roles
10077                // / pg_user walk the engine's UserStore.
10078                "__spg_pg_database" => {
10079                    let (schema, rows) = synth_pg_database(self.active_catalog());
10080                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10081                }
10082                "__spg_pg_roles" | "__spg_pg_user" => {
10083                    let (schema, rows) = synth_pg_roles(self);
10084                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10085                }
10086                // v7.17.0 Phase 3.P0-56 — pg_catalog.pg_views. PG's
10087                // pg_views surfaces every CREATE VIEW result; SPG
10088                // ships one row per declared view from the catalog.
10089                "__spg_pg_views" => {
10090                    let (schema, rows) = synth_pg_views(self.active_catalog());
10091                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10092                }
10093                // v7.17.0 Phase 3.P0-56 — pg_catalog.pg_matviews.
10094                // SPG has no materialised view surface yet so the
10095                // table shares pg_views's schema but stays empty.
10096                "__spg_pg_matviews" => {
10097                    let (schema, _) = synth_pg_views(self.active_catalog());
10098                    materialise_meta_view(&mut catalog, view, schema, Vec::new())?;
10099                }
10100                // pg_catalog.pg_extension — native capability list
10101                // (mailrs embed round-12).
10102                "__spg_pg_extension" => {
10103                    let (schema, rows) = synth_pg_extension();
10104                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10105                }
10106                // v7.17.0 Phase 3.P0-57 — pg_catalog.pg_settings.
10107                "__spg_pg_settings" => {
10108                    let (schema, rows) = synth_pg_settings(self);
10109                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10110                }
10111                // v7.17.0 Phase 3.P0-63 — information_schema.KEY_COLUMN_USAGE.
10112                "__spg_info_key_column_usage" => {
10113                    let (schema, rows) = synth_info_key_column_usage(self.active_catalog());
10114                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10115                }
10116                // v7.17.0 Phase 3.P0-64 — information_schema.REFERENTIAL_CONSTRAINTS.
10117                "__spg_info_referential_constraints" => {
10118                    let (schema, rows) = synth_info_referential_constraints(self.active_catalog());
10119                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10120                }
10121                // v7.17.0 Phase 3.P0-64 — information_schema.STATISTICS.
10122                "__spg_info_statistics" => {
10123                    let (schema, rows) = synth_info_statistics(self.active_catalog());
10124                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10125                }
10126                // v7.17.0 Phase 3.P0-64 — information_schema.ROUTINES.
10127                "__spg_info_routines" => {
10128                    let (schema, rows) = synth_info_routines();
10129                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10130                }
10131                // v7.17.0 Phase 3.P0-65 — mysql.user / mysql.db.
10132                "__spg_mysql_user" => {
10133                    let (schema, rows) = synth_mysql_user(self);
10134                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10135                }
10136                "__spg_mysql_db" => {
10137                    let (schema, rows) = synth_mysql_db();
10138                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10139                }
10140                _ => {
10141                    return Err(EngineError::Unsupported(alloc::format!(
10142                        "meta view {view:?} is not yet materialisable; \
10143                         v7.16.2 covers information_schema.columns / .tables \
10144                         and pg_catalog.pg_class / pg_attribute; \
10145                         v7.17.0 P0-50..P0-57 add pg_type / pg_proc / pg_namespace / \
10146                         pg_indexes / pg_index / pg_constraint / pg_database / pg_roles / \
10147                         pg_user / pg_views / pg_matviews / pg_settings"
10148                    )));
10149                }
10150            }
10151        }
10152        let mut temp = Engine::restore(catalog);
10153        if let Some(c) = self.clock {
10154            temp = temp.with_clock(c);
10155        }
10156        if let Some(f) = self.salt_fn {
10157            temp = temp.with_salt_fn(f);
10158        }
10159        temp.meta_views_materialised = true;
10160        temp.exec_select_cancel(stmt, cancel)
10161    }
10162
10163    fn exec_with_ctes(
10164        &self,
10165        stmt: &SelectStatement,
10166        cancel: CancelToken<'_>,
10167    ) -> Result<QueryResult, EngineError> {
10168        cancel.check()?;
10169        let mut catalog = self.active_catalog().clone();
10170        for cte in &stmt.ctes {
10171            if catalog.get(&cte.name).is_some() {
10172                return Err(EngineError::Unsupported(alloc::format!(
10173                    "CTE name {:?} shadows an existing table; rename the CTE",
10174                    cte.name
10175                )));
10176            }
10177            let (columns, rows) = if cte.recursive {
10178                self.materialise_recursive_cte(cte, &catalog, cancel)?
10179            } else {
10180                // v7.25 (round-17) — run the body against the
10181                // ACCUMULATED catalog so a CTE can reference every
10182                // CTE declared before it (`WITH a AS (…), b AS
10183                // (SELECT … FROM a)`). Executing on `self` lost the
10184                // already-materialised CTE tables.
10185                let mut cte_engine = Engine::restore(catalog.clone());
10186                if let Some(c) = self.clock {
10187                    cte_engine = cte_engine.with_clock(c);
10188                }
10189                if let Some(f) = self.salt_fn {
10190                    cte_engine = cte_engine.with_salt_fn(f);
10191                }
10192                let body_result = cte_engine.exec_select_cancel(&cte.body, cancel)?;
10193                let QueryResult::Rows { columns, rows } = body_result else {
10194                    return Err(EngineError::Unsupported(alloc::format!(
10195                        "CTE {:?} body did not return rows",
10196                        cte.name
10197                    )));
10198                };
10199                (columns, rows)
10200            };
10201            // v4.22: the projection builder labels any non-column
10202            // expression as Text — including literal SELECT 1.
10203            // Promote each column's type to whatever the rows
10204            // actually carry so the CTE storage table accepts them.
10205            let inferred = infer_column_types(&columns, &rows);
10206            let mut columns = inferred;
10207            // v4.22: apply optional `WITH name(a, b, c)` overrides.
10208            if !cte.column_overrides.is_empty() {
10209                if cte.column_overrides.len() != columns.len() {
10210                    return Err(EngineError::Unsupported(alloc::format!(
10211                        "CTE {:?} column list has {} names but body returns {} columns",
10212                        cte.name,
10213                        cte.column_overrides.len(),
10214                        columns.len()
10215                    )));
10216                }
10217                for (col, name) in columns.iter_mut().zip(cte.column_overrides.iter()) {
10218                    col.name.clone_from(name);
10219                }
10220            }
10221            let schema = TableSchema::new(cte.name.clone(), columns);
10222            catalog.create_table(schema).map_err(EngineError::Storage)?;
10223            let table = catalog
10224                .get_mut(&cte.name)
10225                .expect("just-created CTE table must exist");
10226            for row in rows {
10227                table.insert(row).map_err(EngineError::Storage)?;
10228            }
10229        }
10230        // Strip CTEs from the body before running on the temp engine
10231        // so we don't recurse forever.
10232        let mut body = stmt.clone();
10233        body.ctes = Vec::new();
10234        let mut temp = Engine::restore(catalog);
10235        if let Some(c) = self.clock {
10236            temp = temp.with_clock(c);
10237        }
10238        if let Some(f) = self.salt_fn {
10239            temp = temp.with_salt_fn(f);
10240        }
10241        temp.exec_select_cancel(&body, cancel)
10242    }
10243
10244    /// v4.22: materialise a WITH RECURSIVE CTE. The body must be a
10245    /// UNION (or UNION ALL) of an anchor that does not reference
10246    /// the CTE name, and one or more recursive terms that do. The
10247    /// anchor runs first; each subsequent iteration runs the
10248    /// recursive term against a temp catalog where the CTE name is
10249    /// bound to the *previous* iteration's output. Iteration stops
10250    /// when the recursive term yields no rows; UNION (DISTINCT)
10251    /// deduplicates against the accumulated result, UNION ALL does
10252    /// not. A hard cap on total rows prevents runaway queries.
10253    #[allow(clippy::too_many_lines)]
10254    fn materialise_recursive_cte(
10255        &self,
10256        cte: &spg_sql::ast::Cte,
10257        base_catalog: &Catalog,
10258        cancel: CancelToken<'_>,
10259    ) -> Result<(Vec<ColumnSchema>, Vec<Row>), EngineError> {
10260        const MAX_TOTAL_ROWS: usize = 1_000_000;
10261        const MAX_ITERATIONS: usize = 100_000;
10262        cancel.check()?;
10263        if cte.body.unions.is_empty() {
10264            return Err(EngineError::Unsupported(alloc::format!(
10265                "WITH RECURSIVE {:?} body must be a UNION of an anchor and a recursive term",
10266                cte.name
10267            )));
10268        }
10269        // Anchor: the body's leading SELECT, with unions stripped.
10270        let mut anchor = cte.body.clone();
10271        let union_terms = core::mem::take(&mut anchor.unions);
10272        anchor.ctes = Vec::new();
10273        // Anchor must not reference the CTE name.
10274        if select_refers_to(&anchor, &cte.name) {
10275            return Err(EngineError::Unsupported(alloc::format!(
10276                "WITH RECURSIVE {:?}: the anchor must not reference the CTE itself",
10277                cte.name
10278            )));
10279        }
10280        let anchor_result = self.exec_select_cancel(&anchor, cancel)?;
10281        let QueryResult::Rows {
10282            columns: anchor_cols,
10283            rows: anchor_rows,
10284        } = anchor_result
10285        else {
10286            return Err(EngineError::Unsupported(alloc::format!(
10287                "WITH RECURSIVE {:?}: anchor did not return rows",
10288                cte.name
10289            )));
10290        };
10291        // The projection builder labels non-column expressions Text;
10292        // refine column types from the anchor's actual values so the
10293        // intermediate iter-catalog tables accept them.
10294        let mut columns = infer_column_types(&anchor_cols, &anchor_rows);
10295        if !cte.column_overrides.is_empty() {
10296            if cte.column_overrides.len() != columns.len() {
10297                return Err(EngineError::Unsupported(alloc::format!(
10298                    "CTE {:?} column list has {} names but anchor returns {} columns",
10299                    cte.name,
10300                    cte.column_overrides.len(),
10301                    columns.len()
10302                )));
10303            }
10304            for (col, name) in columns.iter_mut().zip(cte.column_overrides.iter()) {
10305                col.name.clone_from(name);
10306            }
10307        }
10308        let mut all_rows: Vec<Row> = anchor_rows.clone();
10309        let mut working_set: Vec<Row> = anchor_rows;
10310        let mut seen: alloc::collections::BTreeSet<Vec<u8>> = alloc::collections::BTreeSet::new();
10311        // Track at least one "all UNION ALL" flag — if every union
10312        // kind is ALL we skip the dedup step (faster + matches PG).
10313        let all_union_all = union_terms.iter().all(|(k, _)| matches!(k, UnionKind::All));
10314        if !all_union_all {
10315            for r in &all_rows {
10316                seen.insert(encode_row_key(r));
10317            }
10318        }
10319        for iter in 0..MAX_ITERATIONS {
10320            cancel.check()?;
10321            if working_set.is_empty() {
10322                break;
10323            }
10324            // Build a fresh catalog: base + CTE bound to working_set.
10325            let mut iter_catalog = base_catalog.clone();
10326            let schema = TableSchema::new(cte.name.clone(), columns.clone());
10327            iter_catalog
10328                .create_table(schema)
10329                .map_err(EngineError::Storage)?;
10330            {
10331                let table = iter_catalog.get_mut(&cte.name).expect("just-created");
10332                for row in &working_set {
10333                    table.insert(row.clone()).map_err(EngineError::Storage)?;
10334                }
10335            }
10336            let mut iter_engine = Engine::restore(iter_catalog);
10337            if let Some(c) = self.clock {
10338                iter_engine = iter_engine.with_clock(c);
10339            }
10340            if let Some(f) = self.salt_fn {
10341                iter_engine = iter_engine.with_salt_fn(f);
10342            }
10343            // Run each recursive term in sequence and collect new rows.
10344            let mut next_set: Vec<Row> = Vec::new();
10345            for (_, term) in &union_terms {
10346                let mut term = term.clone();
10347                term.ctes = Vec::new();
10348                let r = iter_engine.exec_select_cancel(&term, cancel)?;
10349                let QueryResult::Rows {
10350                    columns: rc,
10351                    rows: rs,
10352                } = r
10353                else {
10354                    return Err(EngineError::Unsupported(alloc::format!(
10355                        "WITH RECURSIVE {:?}: recursive term did not return rows",
10356                        cte.name
10357                    )));
10358                };
10359                if rc.len() != columns.len() {
10360                    return Err(EngineError::Unsupported(alloc::format!(
10361                        "WITH RECURSIVE {:?}: column count of recursive term ({}) does not match anchor ({})",
10362                        cte.name,
10363                        rc.len(),
10364                        columns.len()
10365                    )));
10366                }
10367                for row in rs {
10368                    if !all_union_all {
10369                        let key = encode_row_key(&row);
10370                        if !seen.insert(key) {
10371                            continue;
10372                        }
10373                    }
10374                    next_set.push(row);
10375                }
10376            }
10377            if next_set.is_empty() {
10378                break;
10379            }
10380            all_rows.extend(next_set.iter().cloned());
10381            working_set = next_set;
10382            if all_rows.len() > MAX_TOTAL_ROWS {
10383                return Err(EngineError::Unsupported(alloc::format!(
10384                    "WITH RECURSIVE {:?}: produced more than {MAX_TOTAL_ROWS} rows — likely runaway recursion",
10385                    cte.name
10386                )));
10387            }
10388            if iter + 1 == MAX_ITERATIONS {
10389                return Err(EngineError::Unsupported(alloc::format!(
10390                    "WITH RECURSIVE {:?}: exceeded {MAX_ITERATIONS} iterations",
10391                    cte.name
10392                )));
10393            }
10394        }
10395        Ok((columns, all_rows))
10396    }
10397
10398    fn resolve_select_subqueries(
10399        &self,
10400        stmt: &mut SelectStatement,
10401        cancel: CancelToken<'_>,
10402    ) -> Result<(), EngineError> {
10403        for item in &mut stmt.items {
10404            if let SelectItem::Expr { expr, .. } = item {
10405                self.resolve_expr_subqueries(expr, cancel)?;
10406            }
10407        }
10408        if let Some(w) = &mut stmt.where_ {
10409            self.resolve_expr_subqueries(w, cancel)?;
10410        }
10411        // v7.24.1 — JOIN ON conditions can carry subqueries too;
10412        // they were never walked, so even an UNCORRELATED subquery
10413        // in ON hit "subquery reached row eval".
10414        if let Some(from) = &mut stmt.from {
10415            for j in &mut from.joins {
10416                if let Some(on) = &mut j.on {
10417                    self.resolve_expr_subqueries(on, cancel)?;
10418                }
10419            }
10420        }
10421        if let Some(gs) = &mut stmt.group_by {
10422            for g in gs {
10423                self.resolve_expr_subqueries(g, cancel)?;
10424            }
10425        }
10426        if let Some(h) = &mut stmt.having {
10427            self.resolve_expr_subqueries(h, cancel)?;
10428        }
10429        for o in &mut stmt.order_by {
10430            self.resolve_expr_subqueries(&mut o.expr, cancel)?;
10431        }
10432        for (_, peer) in &mut stmt.unions {
10433            self.resolve_select_subqueries(peer, cancel)?;
10434        }
10435        Ok(())
10436    }
10437
10438    #[allow(clippy::only_used_in_recursion)] // engine handle reads aren't really pure
10439    fn resolve_expr_subqueries(
10440        &self,
10441        e: &mut Expr,
10442        cancel: CancelToken<'_>,
10443    ) -> Result<(), EngineError> {
10444        // Replace-on-this-node cases first.
10445        if let Some(replacement) = self.subquery_replacement(e, cancel)? {
10446            *e = replacement;
10447            return Ok(());
10448        }
10449        match e {
10450            Expr::AggregateOrdered { call, order_by, .. } => {
10451                self.resolve_expr_subqueries(call, cancel)?;
10452                for o in order_by.iter_mut() {
10453                    self.resolve_expr_subqueries(&mut o.expr, cancel)?;
10454                }
10455            }
10456            Expr::Binary { lhs, rhs, .. } => {
10457                self.resolve_expr_subqueries(lhs, cancel)?;
10458                self.resolve_expr_subqueries(rhs, cancel)?;
10459            }
10460            Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
10461                self.resolve_expr_subqueries(expr, cancel)?;
10462            }
10463            Expr::FunctionCall { args, .. } => {
10464                for a in args {
10465                    self.resolve_expr_subqueries(a, cancel)?;
10466                }
10467            }
10468            Expr::Like { expr, pattern, .. } => {
10469                self.resolve_expr_subqueries(expr, cancel)?;
10470                self.resolve_expr_subqueries(pattern, cancel)?;
10471            }
10472            Expr::Extract { source, .. } => self.resolve_expr_subqueries(source, cancel)?,
10473            // v4.12 window functions — recurse into args + ORDER BY
10474            // + PARTITION BY in case they carry inner subqueries.
10475            Expr::WindowFunction {
10476                args,
10477                partition_by,
10478                order_by,
10479                ..
10480            } => {
10481                for a in args {
10482                    self.resolve_expr_subqueries(a, cancel)?;
10483                }
10484                for p in partition_by {
10485                    self.resolve_expr_subqueries(p, cancel)?;
10486                }
10487                for (e, _, _) in order_by {
10488                    self.resolve_expr_subqueries(e, cancel)?;
10489                }
10490            }
10491            // Subquery nodes are handled in subquery_replacement
10492            // (which returned None — defensive no-op); Literal /
10493            // Column are leaves.
10494            Expr::ScalarSubquery(_)
10495            | Expr::Exists { .. }
10496            | Expr::InSubquery { .. }
10497            | Expr::Literal(_)
10498            | Expr::Placeholder(_)
10499            | Expr::Column(_) => {}
10500            // v7.10.10 — recurse children.
10501            Expr::Array(items) => {
10502                for elem in items {
10503                    self.resolve_expr_subqueries(elem, cancel)?;
10504                }
10505            }
10506            Expr::ArraySubscript { target, index } => {
10507                self.resolve_expr_subqueries(target, cancel)?;
10508                self.resolve_expr_subqueries(index, cancel)?;
10509            }
10510            Expr::AnyAll { expr, array, .. } => {
10511                self.resolve_expr_subqueries(expr, cancel)?;
10512                self.resolve_expr_subqueries(array, cancel)?;
10513            }
10514            Expr::Case {
10515                operand,
10516                branches,
10517                else_branch,
10518            } => {
10519                if let Some(o) = operand {
10520                    self.resolve_expr_subqueries(o, cancel)?;
10521                }
10522                for (w, t) in branches {
10523                    self.resolve_expr_subqueries(w, cancel)?;
10524                    self.resolve_expr_subqueries(t, cancel)?;
10525                }
10526                if let Some(e) = else_branch {
10527                    self.resolve_expr_subqueries(e, cancel)?;
10528                }
10529            }
10530        }
10531        Ok(())
10532    }
10533
10534    /// v4.23: per-row eval that handles correlated subqueries.
10535    /// Equivalent to `eval::eval_expr` when the expression has no
10536    /// subqueries; otherwise clones the expression, substitutes
10537    /// outer-row columns into each surviving subquery node, runs
10538    /// the inner SELECT, and replaces the node with the literal
10539    /// result. Only the WHERE-filter call sites use this path so
10540    /// the uncorrelated fast path is preserved everywhere else.
10541    fn eval_expr_with_correlated(
10542        &self,
10543        expr: &Expr,
10544        row: &Row,
10545        ctx: &EvalContext<'_>,
10546        cancel: CancelToken<'_>,
10547        memo: Option<&mut memoize::MemoizeCache>,
10548    ) -> Result<Value, EngineError> {
10549        if !expr_has_subquery(expr) {
10550            return eval::eval_expr(expr, row, ctx).map_err(EngineError::Eval);
10551        }
10552        let mut e = expr.clone();
10553        self.resolve_correlated_in_expr(&mut e, row, ctx, cancel, memo)?;
10554        eval::eval_expr(&e, row, ctx).map_err(EngineError::Eval)
10555    }
10556
10557    fn resolve_correlated_in_expr(
10558        &self,
10559        e: &mut Expr,
10560        row: &Row,
10561        ctx: &EvalContext<'_>,
10562        cancel: CancelToken<'_>,
10563        mut memo: Option<&mut memoize::MemoizeCache>,
10564    ) -> Result<(), EngineError> {
10565        match e {
10566            Expr::AggregateOrdered { call, order_by, .. } => {
10567                self.resolve_correlated_in_expr(call, row, ctx, cancel, memo.as_deref_mut())?;
10568                for o in order_by.iter_mut() {
10569                    self.resolve_correlated_in_expr(
10570                        &mut o.expr,
10571                        row,
10572                        ctx,
10573                        cancel,
10574                        memo.as_deref_mut(),
10575                    )?;
10576                }
10577            }
10578            Expr::ScalarSubquery(inner) => {
10579                // v6.2.6 — Memoize: build the cache key from the
10580                // pre-substitution subquery repr + the outer row's
10581                // values. Two outer rows with identical correlated
10582                // values hit the same entry.
10583                let cache_key = memo.as_ref().map(|_| memoize::CacheKey {
10584                    subquery_repr: alloc::format!("{}", **inner),
10585                    outer_values: row.values.clone(),
10586                });
10587                if let (Some(cache), Some(k)) = (memo.as_deref_mut(), cache_key.as_ref())
10588                    && let Some(cached) = cache.get(k)
10589                {
10590                    *e = value_to_literal_expr(cached)?;
10591                    return Ok(());
10592                }
10593                let mut s = (**inner).clone();
10594                substitute_outer_columns(&mut s, row, ctx);
10595                let r = self.exec_select_cancel(&s, cancel)?;
10596                let QueryResult::Rows { rows, .. } = r else {
10597                    return Err(EngineError::Unsupported(
10598                        "scalar subquery: inner did not return rows".into(),
10599                    ));
10600                };
10601                let value = match rows.as_slice() {
10602                    [] => Value::Null,
10603                    [r0] => r0.values.first().cloned().unwrap_or(Value::Null),
10604                    _ => {
10605                        return Err(EngineError::Unsupported(alloc::format!(
10606                            "scalar subquery returned {} rows; expected 0 or 1",
10607                            rows.len()
10608                        )));
10609                    }
10610                };
10611                if let (Some(cache), Some(k)) = (memo.as_deref_mut(), cache_key) {
10612                    cache.insert(k, value.clone());
10613                }
10614                *e = value_to_literal_expr(value)?;
10615            }
10616            Expr::Exists { subquery, negated } => {
10617                let mut s = (**subquery).clone();
10618                substitute_outer_columns(&mut s, row, ctx);
10619                let r = self.exec_select_cancel(&s, cancel)?;
10620                let exists = matches!(r, QueryResult::Rows { rows, .. } if !rows.is_empty());
10621                let bit = if *negated { !exists } else { exists };
10622                *e = Expr::Literal(Literal::Bool(bit));
10623            }
10624            Expr::InSubquery {
10625                expr: lhs,
10626                subquery,
10627                negated,
10628            } => {
10629                self.resolve_correlated_in_expr(lhs, row, ctx, cancel, memo.as_deref_mut())?;
10630                let lhs_val = eval::eval_expr(lhs, row, ctx).map_err(EngineError::Eval)?;
10631                let mut s = (**subquery).clone();
10632                substitute_outer_columns(&mut s, row, ctx);
10633                let r = self.exec_select_cancel(&s, cancel)?;
10634                let QueryResult::Rows { columns, rows, .. } = r else {
10635                    return Err(EngineError::Unsupported(
10636                        "IN-subquery: inner did not return rows".into(),
10637                    ));
10638                };
10639                if columns.len() != 1 {
10640                    return Err(EngineError::Unsupported(alloc::format!(
10641                        "IN-subquery must project exactly one column; got {}",
10642                        columns.len()
10643                    )));
10644                }
10645                let mut found = false;
10646                let mut any_null = false;
10647                for r0 in rows {
10648                    let v = r0.values.into_iter().next().unwrap_or(Value::Null);
10649                    if v.is_null() {
10650                        any_null = true;
10651                        continue;
10652                    }
10653                    if value_cmp(&v, &lhs_val) == core::cmp::Ordering::Equal {
10654                        found = true;
10655                        break;
10656                    }
10657                }
10658                let bit = if found {
10659                    !*negated
10660                } else if any_null {
10661                    return Err(EngineError::Unsupported(
10662                        "IN-subquery with NULL in result and no match: NULL semantics not yet implemented".into(),
10663                    ));
10664                } else {
10665                    *negated
10666                };
10667                *e = Expr::Literal(Literal::Bool(bit));
10668            }
10669            Expr::Binary { lhs, rhs, .. } => {
10670                self.resolve_correlated_in_expr(lhs, row, ctx, cancel, memo.as_deref_mut())?;
10671                self.resolve_correlated_in_expr(rhs, row, ctx, cancel, memo.as_deref_mut())?;
10672            }
10673            Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
10674                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
10675            }
10676            Expr::Like { expr, pattern, .. } => {
10677                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
10678                self.resolve_correlated_in_expr(pattern, row, ctx, cancel, memo.as_deref_mut())?;
10679            }
10680            Expr::FunctionCall { args, .. } => {
10681                for a in args {
10682                    self.resolve_correlated_in_expr(a, row, ctx, cancel, memo.as_deref_mut())?;
10683                }
10684            }
10685            Expr::Extract { source, .. } => {
10686                self.resolve_correlated_in_expr(source, row, ctx, cancel, memo.as_deref_mut())?;
10687            }
10688            Expr::WindowFunction { .. }
10689            | Expr::Literal(_)
10690            | Expr::Placeholder(_)
10691            | Expr::Column(_) => {}
10692            // v7.10.10 — recurse children.
10693            Expr::Array(items) => {
10694                for elem in items {
10695                    self.resolve_correlated_in_expr(elem, row, ctx, cancel, memo.as_deref_mut())?;
10696                }
10697            }
10698            Expr::ArraySubscript { target, index } => {
10699                self.resolve_correlated_in_expr(target, row, ctx, cancel, memo.as_deref_mut())?;
10700                self.resolve_correlated_in_expr(index, row, ctx, cancel, memo.as_deref_mut())?;
10701            }
10702            Expr::AnyAll { expr, array, .. } => {
10703                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
10704                self.resolve_correlated_in_expr(array, row, ctx, cancel, memo.as_deref_mut())?;
10705            }
10706            Expr::Case {
10707                operand,
10708                branches,
10709                else_branch,
10710            } => {
10711                if let Some(o) = operand {
10712                    self.resolve_correlated_in_expr(o, row, ctx, cancel, memo.as_deref_mut())?;
10713                }
10714                for (w, t) in branches {
10715                    self.resolve_correlated_in_expr(w, row, ctx, cancel, memo.as_deref_mut())?;
10716                    self.resolve_correlated_in_expr(t, row, ctx, cancel, memo.as_deref_mut())?;
10717                }
10718                if let Some(e) = else_branch {
10719                    self.resolve_correlated_in_expr(e, row, ctx, cancel, memo.as_deref_mut())?;
10720                }
10721            }
10722        }
10723        Ok(())
10724    }
10725
10726    fn subquery_replacement(
10727        &self,
10728        e: &Expr,
10729        cancel: CancelToken<'_>,
10730    ) -> Result<Option<Expr>, EngineError> {
10731        match e {
10732            Expr::ScalarSubquery(inner) => {
10733                let mut s = (**inner).clone();
10734                // Recurse into the inner SELECT first so nested
10735                // subqueries materialise bottom-up.
10736                self.resolve_select_subqueries(&mut s, cancel)?;
10737                let r = match self.exec_bare_select_cancel(&s, cancel) {
10738                    Ok(r) => r,
10739                    Err(e) if is_correlation_error(&e) => return Ok(None),
10740                    Err(e) => return Err(e),
10741                };
10742                let QueryResult::Rows { rows, .. } = r else {
10743                    return Err(EngineError::Unsupported(
10744                        "scalar subquery: inner statement did not return rows".into(),
10745                    ));
10746                };
10747                let value = match rows.as_slice() {
10748                    [] => Value::Null,
10749                    [row] => row.values.first().cloned().unwrap_or(Value::Null),
10750                    _ => {
10751                        return Err(EngineError::Unsupported(alloc::format!(
10752                            "scalar subquery returned {} rows; expected 0 or 1",
10753                            rows.len()
10754                        )));
10755                    }
10756                };
10757                Ok(Some(value_to_literal_expr(value)?))
10758            }
10759            Expr::Exists { subquery, negated } => {
10760                let mut s = (**subquery).clone();
10761                self.resolve_select_subqueries(&mut s, cancel)?;
10762                let r = match self.exec_bare_select_cancel(&s, cancel) {
10763                    Ok(r) => r,
10764                    Err(e) if is_correlation_error(&e) => return Ok(None),
10765                    Err(e) => return Err(e),
10766                };
10767                let exists = match r {
10768                    QueryResult::Rows { rows, .. } => !rows.is_empty(),
10769                    QueryResult::CommandOk { .. } => false,
10770                };
10771                let bit = if *negated { !exists } else { exists };
10772                Ok(Some(Expr::Literal(Literal::Bool(bit))))
10773            }
10774            Expr::InSubquery {
10775                expr,
10776                subquery,
10777                negated,
10778            } => {
10779                let mut s = (**subquery).clone();
10780                self.resolve_select_subqueries(&mut s, cancel)?;
10781                let r = match self.exec_bare_select_cancel(&s, cancel) {
10782                    Ok(r) => r,
10783                    Err(e) if is_correlation_error(&e) => return Ok(None),
10784                    Err(e) => return Err(e),
10785                };
10786                let QueryResult::Rows { columns, rows, .. } = r else {
10787                    return Err(EngineError::Unsupported(
10788                        "IN-subquery: inner statement did not return rows".into(),
10789                    ));
10790                };
10791                if columns.len() != 1 {
10792                    return Err(EngineError::Unsupported(alloc::format!(
10793                        "IN-subquery must project exactly one column; got {}",
10794                        columns.len()
10795                    )));
10796                }
10797                // Build the same OR-Eq chain the parse-time literal-list
10798                // path constructs, with each value lifted into a Literal.
10799                let mut acc: Option<Expr> = None;
10800                for row in rows {
10801                    let v = row.values.into_iter().next().unwrap_or(Value::Null);
10802                    let lit = value_to_literal_expr(v)?;
10803                    let cmp = Expr::Binary {
10804                        lhs: expr.clone(),
10805                        op: BinOp::Eq,
10806                        rhs: Box::new(lit),
10807                    };
10808                    acc = Some(match acc {
10809                        None => cmp,
10810                        Some(prev) => Expr::Binary {
10811                            lhs: Box::new(prev),
10812                            op: BinOp::Or,
10813                            rhs: Box::new(cmp),
10814                        },
10815                    });
10816                }
10817                let combined = acc.unwrap_or(Expr::Literal(Literal::Bool(false)));
10818                let final_expr = if *negated {
10819                    Expr::Unary {
10820                        op: UnOp::Not,
10821                        expr: Box::new(combined),
10822                    }
10823                } else {
10824                    combined
10825                };
10826                Ok(Some(final_expr))
10827            }
10828            _ => Ok(None),
10829        }
10830    }
10831}
10832
10833// ---- v4.12 window-function helpers ----
10834// The (partition-key, order-key, original-index) tuple shape used
10835// across these helpers is intrinsic to the planner. Factoring it
10836// into a typedef adds indirection without making the code clearer,
10837// so several lints are allowed inline on the affected functions
10838// rather than module-wide.
10839
10840/// v4.22: cheap structural scan for `FROM <name>` (qualified or
10841/// not) inside a SELECT — used to verify the anchor of a WITH
10842/// RECURSIVE CTE doesn't recurse into itself. Conservative: walks
10843/// FROM joins, subqueries, and unions.
10844fn select_refers_to(stmt: &SelectStatement, target: &str) -> bool {
10845    if let Some(from) = &stmt.from
10846        && from_refers_to(from, target)
10847    {
10848        return true;
10849    }
10850    for (_, peer) in &stmt.unions {
10851        if select_refers_to(peer, target) {
10852            return true;
10853        }
10854    }
10855    for item in &stmt.items {
10856        if let SelectItem::Expr { expr, .. } = item
10857            && expr_refers_to(expr, target)
10858        {
10859            return true;
10860        }
10861    }
10862    if let Some(w) = &stmt.where_
10863        && expr_refers_to(w, target)
10864    {
10865        return true;
10866    }
10867    false
10868}
10869
10870fn from_refers_to(from: &FromClause, target: &str) -> bool {
10871    if from.primary.name.eq_ignore_ascii_case(target) {
10872        return true;
10873    }
10874    from.joins
10875        .iter()
10876        .any(|j| j.table.name.eq_ignore_ascii_case(target))
10877}
10878
10879fn expr_refers_to(e: &Expr, target: &str) -> bool {
10880    match e {
10881        Expr::AggregateOrdered { call, order_by, .. } => {
10882            expr_refers_to(call, target) || order_by.iter().any(|o| expr_refers_to(&o.expr, target))
10883        }
10884        Expr::ScalarSubquery(s) => select_refers_to(s, target),
10885        Expr::Exists { subquery, .. } | Expr::InSubquery { subquery, .. } => {
10886            select_refers_to(subquery, target)
10887        }
10888        Expr::Binary { lhs, rhs, .. } => expr_refers_to(lhs, target) || expr_refers_to(rhs, target),
10889        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
10890            expr_refers_to(expr, target)
10891        }
10892        Expr::Like { expr, pattern, .. } => {
10893            expr_refers_to(expr, target) || expr_refers_to(pattern, target)
10894        }
10895        Expr::FunctionCall { args, .. } => args.iter().any(|a| expr_refers_to(a, target)),
10896        Expr::Extract { source, .. } => expr_refers_to(source, target),
10897        Expr::WindowFunction {
10898            args,
10899            partition_by,
10900            order_by,
10901            ..
10902        } => {
10903            args.iter().any(|a| expr_refers_to(a, target))
10904                || partition_by.iter().any(|p| expr_refers_to(p, target))
10905                || order_by.iter().any(|(o, _, _)| expr_refers_to(o, target))
10906        }
10907        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => false,
10908        Expr::Array(items) => items.iter().any(|e| expr_refers_to(e, target)),
10909        Expr::ArraySubscript { target: t, index } => {
10910            expr_refers_to(t, target) || expr_refers_to(index, target)
10911        }
10912        Expr::AnyAll { expr, array, .. } => {
10913            expr_refers_to(expr, target) || expr_refers_to(array, target)
10914        }
10915        Expr::Case {
10916            operand,
10917            branches,
10918            else_branch,
10919        } => {
10920            operand
10921                .as_deref()
10922                .is_some_and(|o| expr_refers_to(o, target))
10923                || branches
10924                    .iter()
10925                    .any(|(w, t)| expr_refers_to(w, target) || expr_refers_to(t, target))
10926                || else_branch
10927                    .as_deref()
10928                    .is_some_and(|e| expr_refers_to(e, target))
10929        }
10930    }
10931}
10932
10933/// v4.22: pick more specific column types from observed rows when
10934/// the projection builder defaulted to Text (the v1.x behavior for
10935/// non-column expressions). Lets `WITH t(n) AS (SELECT 1 ...)`
10936/// land an Int column in the CTE storage table rather than failing
10937/// the insert with "expected TEXT, got INT".
10938/// v7.16.2 — map an SPG [`DataType`] to the PG-canonical
10939/// `information_schema.columns.data_type` text. Covers the
10940/// values mailrs's migrations probe (`'ARRAY'`, `'integer'`,
10941/// `'text'`, …). Unknown variants fall back to the SPG name
10942/// downcased — better than panicking on a future DataType.
10943fn pg_data_type_text(ty: DataType) -> alloc::string::String {
10944    let s = match ty {
10945        DataType::Int => "integer",
10946        DataType::BigInt => "bigint",
10947        DataType::SmallInt => "smallint",
10948        DataType::Float => "double precision",
10949        DataType::Bool => "boolean",
10950        DataType::Text => "text",
10951        DataType::Varchar(_) => "character varying",
10952        DataType::Date => "date",
10953        DataType::Timestamp => "timestamp without time zone",
10954        DataType::Timestamptz => "timestamp with time zone",
10955        DataType::Json => "jsonb",
10956        DataType::Bytes => "bytea",
10957        DataType::TextArray | DataType::IntArray | DataType::BigIntArray => "ARRAY",
10958        DataType::TsVector => "tsvector",
10959        DataType::TsQuery => "tsquery",
10960        DataType::Vector { .. } => "USER-DEFINED",
10961        // Non-exhaustive — fall back to "USER-DEFINED" the way
10962        // PG labels any pg_type it doesn't recognise.
10963        _ => "USER-DEFINED",
10964    };
10965    alloc::string::String::from(s)
10966}
10967
10968/// v7.16.2 — synthesise `information_schema.columns`. mailrs
10969/// queries are of shape `SELECT 1 FROM information_schema.columns
10970/// WHERE table_name = … AND column_name = … AND data_type = …` —
10971/// the v7.16.2 view returns the columns mailrs probes; broader
10972/// PG-spec parity (ordinal_position, is_nullable, character_
10973/// maximum_length, udt_name, …) lands as needed.
10974fn synth_information_schema_columns(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10975    let schema = alloc::vec![
10976        ColumnSchema::new("table_catalog", DataType::Text, false),
10977        ColumnSchema::new("table_schema", DataType::Text, false),
10978        ColumnSchema::new("table_name", DataType::Text, false),
10979        ColumnSchema::new("column_name", DataType::Text, false),
10980        ColumnSchema::new("ordinal_position", DataType::Int, false),
10981        ColumnSchema::new("is_nullable", DataType::Text, false),
10982        ColumnSchema::new("data_type", DataType::Text, false),
10983    ];
10984    let mut rows: Vec<Row> = Vec::new();
10985    for tname in cat.table_names() {
10986        let Some(t) = cat.get(&tname) else { continue };
10987        for (i, col) in t.schema().columns.iter().enumerate() {
10988            #[allow(clippy::cast_possible_wrap)]
10989            let ordinal = (i + 1) as i32;
10990            rows.push(Row::new(alloc::vec![
10991                Value::Text("spg".into()),
10992                Value::Text("public".into()),
10993                Value::Text(tname.clone()),
10994                Value::Text(col.name.clone()),
10995                Value::Int(ordinal),
10996                Value::Text(if col.nullable {
10997                    "YES".into()
10998                } else {
10999                    "NO".into()
11000                }),
11001                Value::Text(pg_data_type_text(col.ty)),
11002            ]));
11003        }
11004    }
11005    (schema, rows)
11006}
11007
11008/// v7.16.2 — synthesise `information_schema.tables`.
11009fn synth_information_schema_tables(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11010    let schema = alloc::vec![
11011        ColumnSchema::new("table_catalog", DataType::Text, false),
11012        ColumnSchema::new("table_schema", DataType::Text, false),
11013        ColumnSchema::new("table_name", DataType::Text, false),
11014        ColumnSchema::new("table_type", DataType::Text, false),
11015    ];
11016    let mut rows: Vec<Row> = Vec::new();
11017    for tname in cat.table_names() {
11018        rows.push(Row::new(alloc::vec![
11019            Value::Text("spg".into()),
11020            Value::Text("public".into()),
11021            Value::Text(tname.clone()),
11022            Value::Text("BASE TABLE".into()),
11023        ]));
11024    }
11025    (schema, rows)
11026}
11027
11028/// v7.16.2 — synthesise `pg_catalog.pg_class`. Minimum shape
11029/// for psql `\d` / ORM probes: `relname` + `relkind`. Each
11030/// user table emits one row.
11031fn synth_pg_class(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11032    let schema = alloc::vec![
11033        ColumnSchema::new("relname", DataType::Text, false),
11034        ColumnSchema::new("relkind", DataType::Text, false),
11035        ColumnSchema::new("relnamespace", DataType::BigInt, false),
11036    ];
11037    let mut rows: Vec<Row> = Vec::new();
11038    for tname in cat.table_names() {
11039        rows.push(Row::new(alloc::vec![
11040            Value::Text(tname.clone()),
11041            Value::Text("r".into()),
11042            Value::BigInt(2200), // PG's `public` namespace OID
11043        ]));
11044    }
11045    (schema, rows)
11046}
11047
11048/// v7.16.2 — synthesise `pg_catalog.pg_attribute`. Minimum
11049/// shape: `attrelid` (text — SPG has no OID), `attname`,
11050/// `attnum`, `atttypid` (text), `attnotnull`.
11051fn synth_pg_attribute(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11052    let schema = alloc::vec![
11053        ColumnSchema::new("attrelid", DataType::Text, false),
11054        ColumnSchema::new("attname", DataType::Text, false),
11055        ColumnSchema::new("attnum", DataType::Int, false),
11056        ColumnSchema::new("atttypid", DataType::Text, false),
11057        ColumnSchema::new("attnotnull", DataType::Bool, false),
11058    ];
11059    let mut rows: Vec<Row> = Vec::new();
11060    for tname in cat.table_names() {
11061        let Some(t) = cat.get(&tname) else { continue };
11062        for (i, col) in t.schema().columns.iter().enumerate() {
11063            #[allow(clippy::cast_possible_wrap)]
11064            let ordinal = (i + 1) as i32;
11065            rows.push(Row::new(alloc::vec![
11066                Value::Text(tname.clone()),
11067                Value::Text(col.name.clone()),
11068                Value::Int(ordinal),
11069                Value::Text(pg_data_type_text(col.ty)),
11070                Value::Bool(!col.nullable),
11071            ]));
11072        }
11073    }
11074    (schema, rows)
11075}
11076
11077/// v7.17.0 Phase 3.P0-50 — synthesise `pg_catalog.pg_type`. The
11078/// returned rows cover every built-in scalar / array type sqlx,
11079/// SQLAlchemy, Diesel and pgAdmin look up at compile / connect
11080/// time. PG-canonical schema columns we expose:
11081///   * oid           — type OID (the lookup key sqlx uses)
11082///   * typname       — canonical type name (`int4`, `text`, …)
11083///   * typlen        — width in bytes (-1 for var-length)
11084///   * typtype       — `b`ase / `c`omposite / `e`num / etc.
11085///   * typcategory   — PG type category single-char
11086///   * typelem       — element OID for arrays (0 otherwise)
11087///   * typarray      — array-type OID (0 if no array type)
11088///   * typnamespace  — schema OID (always `public` = 2200)
11089///
11090/// Other pg_type columns (typowner, typinput/typoutput, etc.)
11091/// land in follow-up work — sqlx encoders don't query them at
11092/// connect time.
11093fn synth_pg_type(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11094    let schema = alloc::vec![
11095        ColumnSchema::new("oid", DataType::BigInt, false),
11096        ColumnSchema::new("typname", DataType::Text, false),
11097        ColumnSchema::new("typlen", DataType::SmallInt, false),
11098        ColumnSchema::new("typtype", DataType::Text, false),
11099        ColumnSchema::new("typcategory", DataType::Text, false),
11100        ColumnSchema::new("typelem", DataType::BigInt, false),
11101        ColumnSchema::new("typarray", DataType::BigInt, false),
11102        ColumnSchema::new("typnamespace", DataType::BigInt, false),
11103    ];
11104    // (oid, name, len, type, cat, elem, array_oid). PG OID
11105    // numbers come straight from `pg_type.dat`.
11106    let scalars: &[(i64, &str, i16, &str, &str, i64, i64)] = &[
11107        // bool
11108        (16, "bool", 1, "b", "B", 0, 1000),
11109        (17, "bytea", -1, "b", "U", 0, 1001),
11110        (18, "char", 1, "b", "S", 0, 1002),
11111        (19, "name", 64, "b", "S", 0, 1003),
11112        (20, "int8", 8, "b", "N", 0, 1016),
11113        (21, "int2", 2, "b", "N", 0, 1005),
11114        (23, "int4", 4, "b", "N", 0, 1007),
11115        (24, "regproc", 4, "b", "N", 0, 1008),
11116        (25, "text", -1, "b", "S", 0, 1009),
11117        (26, "oid", 4, "b", "N", 0, 1028),
11118        (114, "json", -1, "b", "U", 0, 199),
11119        (142, "xml", -1, "b", "U", 0, 143),
11120        (700, "float4", 4, "b", "N", 0, 1021),
11121        (701, "float8", 8, "b", "N", 0, 1022),
11122        (650, "cidr", -1, "b", "I", 0, 651),
11123        (869, "inet", -1, "b", "I", 0, 1041),
11124        (829, "macaddr", 6, "b", "U", 0, 1040),
11125        (1042, "bpchar", -1, "b", "S", 0, 1014),
11126        (1043, "varchar", -1, "b", "S", 0, 1015),
11127        (1082, "date", 4, "b", "D", 0, 1182),
11128        (1083, "time", 8, "b", "D", 0, 1183),
11129        (1114, "timestamp", 8, "b", "D", 0, 1115),
11130        (1184, "timestamptz", 8, "b", "D", 0, 1185),
11131        (1186, "interval", 16, "b", "T", 0, 1187),
11132        (1266, "timetz", 12, "b", "D", 0, 1270),
11133        (1700, "numeric", -1, "b", "N", 0, 1231),
11134        (790, "money", 8, "b", "N", 0, 791),
11135        (2950, "uuid", 16, "b", "U", 0, 2951),
11136        (3802, "jsonb", -1, "b", "U", 0, 3807),
11137        (3614, "tsvector", -1, "b", "U", 0, 3643),
11138        (3615, "tsquery", -1, "b", "U", 0, 3645),
11139        // hstore + range types — typcategory 'U' (user) / 'R' (range).
11140        (3908, "tstzrange", -1, "r", "R", 0, 3909),
11141        (3910, "tsrange", -1, "r", "R", 0, 3911),
11142        (3904, "int4range", -1, "r", "R", 0, 3905),
11143        (3926, "int8range", -1, "r", "R", 0, 3927),
11144        (3906, "numrange", -1, "r", "R", 0, 3907),
11145        (3912, "daterange", -1, "r", "R", 0, 3913),
11146    ];
11147    // Array companion types share the typelem / typcategory='A'.
11148    // We emit just the array OIDs the scalars reference.
11149    let arrays: &[(i64, &str, i64)] = &[
11150        (1000, "_bool", 16),
11151        (1001, "_bytea", 17),
11152        (1002, "_char", 18),
11153        (1003, "_name", 19),
11154        (1016, "_int8", 20),
11155        (1005, "_int2", 21),
11156        (1007, "_int4", 23),
11157        (1008, "_regproc", 24),
11158        (1009, "_text", 25),
11159        (1028, "_oid", 26),
11160        (199, "_json", 114),
11161        (143, "_xml", 142),
11162        (1021, "_float4", 700),
11163        (1022, "_float8", 701),
11164        (651, "_cidr", 650),
11165        (1041, "_inet", 869),
11166        (1040, "_macaddr", 829),
11167        (1014, "_bpchar", 1042),
11168        (1015, "_varchar", 1043),
11169        (1182, "_date", 1082),
11170        (1183, "_time", 1083),
11171        (1115, "_timestamp", 1114),
11172        (1185, "_timestamptz", 1184),
11173        (1187, "_interval", 1186),
11174        (1270, "_timetz", 1266),
11175        (1231, "_numeric", 1700),
11176        (791, "_money", 790),
11177        (2951, "_uuid", 2950),
11178        (3807, "_jsonb", 3802),
11179        (3643, "_tsvector", 3614),
11180        (3645, "_tsquery", 3615),
11181    ];
11182    let mut rows: Vec<Row> = Vec::with_capacity(scalars.len() + arrays.len());
11183    for &(oid, name, len, ty, cat, elem, arr) in scalars {
11184        rows.push(Row::new(alloc::vec![
11185            Value::BigInt(oid),
11186            Value::Text(name.into()),
11187            Value::SmallInt(len),
11188            Value::Text(ty.into()),
11189            Value::Text(cat.into()),
11190            Value::BigInt(elem),
11191            Value::BigInt(arr),
11192            Value::BigInt(2200),
11193        ]));
11194    }
11195    for &(oid, name, elem) in arrays {
11196        rows.push(Row::new(alloc::vec![
11197            Value::BigInt(oid),
11198            Value::Text(name.into()),
11199            Value::SmallInt(-1),
11200            Value::Text("b".into()),
11201            Value::Text("A".into()),
11202            Value::BigInt(elem),
11203            Value::BigInt(0),
11204            Value::BigInt(2200),
11205        ]));
11206    }
11207    (schema, rows)
11208}
11209
11210/// v7.17.0 Phase 3.P0-51 — synthesise `pg_catalog.pg_proc`. ORM /
11211/// pgAdmin probes look up functions by name; SPG synthesises rows
11212/// for the built-in scalar functions / aggregates / window funcs
11213/// the engine actually dispatches. SPG has no user-defined
11214/// functions yet so the table is a stable static list.
11215///
11216/// Schema columns exposed:
11217///   * oid (BigInt) — function OID from PG's pg_proc.dat
11218///   * proname (Text) — function name (lowercase)
11219///   * pronamespace (BigInt) — 11 (`pg_catalog`)
11220///   * prokind (Text) — 'f' function, 'a' aggregate, 'w' window
11221///   * pronargs (SmallInt) — declared arg count (-1 for variadic)
11222///   * prorettype (BigInt) — return type OID (matches synth_pg_type)
11223/// v7.24 (round-16 D) — synthesise `pg_catalog.pg_trigger` from the
11224/// live catalog. PG-shaped core columns (tgname, tgenabled with
11225/// 'O'/'D') plus pragmatic text columns PG keeps relational
11226/// (relname, timing, events, function) so health checks don't need
11227/// oid joins.
11228fn synth_pg_trigger(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11229    let schema = alloc::vec![
11230        ColumnSchema::new("tgname", DataType::Text, false),
11231        ColumnSchema::new("relname", DataType::Text, false),
11232        ColumnSchema::new("tgenabled", DataType::Text, false),
11233        ColumnSchema::new("timing", DataType::Text, false),
11234        ColumnSchema::new("events", DataType::Text, false),
11235        ColumnSchema::new("function", DataType::Text, false),
11236    ];
11237    let rows: Vec<Row> = cat
11238        .triggers()
11239        .iter()
11240        .map(|t| {
11241            Row::new(alloc::vec![
11242                Value::Text(t.name.clone()),
11243                Value::Text(t.table.clone()),
11244                Value::Text(if t.enabled { "O".into() } else { "D".into() }),
11245                Value::Text(t.timing.clone()),
11246                Value::Text(t.events.join(" OR ")),
11247                Value::Text(t.function.clone()),
11248            ])
11249        })
11250        .collect();
11251    (schema, rows)
11252}
11253
11254fn synth_pg_proc(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11255    let schema = alloc::vec![
11256        ColumnSchema::new("oid", DataType::BigInt, false),
11257        ColumnSchema::new("proname", DataType::Text, false),
11258        ColumnSchema::new("pronamespace", DataType::BigInt, false),
11259        ColumnSchema::new("prokind", DataType::Text, false),
11260        ColumnSchema::new("pronargs", DataType::Int, false),
11261        ColumnSchema::new("prorettype", DataType::BigInt, false),
11262    ];
11263    // (oid, name, kind, nargs, rettype). OIDs taken from PG's
11264    // pg_proc.dat for the common subset.
11265    let funcs: &[(i64, &str, &str, i32, i64)] = &[
11266        // Scalar functions.
11267        (1318, "length", "f", 1, 23),
11268        (871, "upper", "f", 1, 25),
11269        (870, "lower", "f", 1, 25),
11270        (936, "substring", "f", 3, 25),
11271        (937, "substring", "f", 2, 25),
11272        (3055, "btrim", "f", 1, 25),
11273        (885, "btrim", "f", 2, 25),
11274        (3056, "ltrim", "f", 1, 25),
11275        (875, "ltrim", "f", 2, 25),
11276        (3057, "rtrim", "f", 1, 25),
11277        (876, "rtrim", "f", 2, 25),
11278        (1397, "abs", "f", 1, 23),
11279        (1396, "abs", "f", 1, 20),
11280        (1606, "round", "f", 1, 1700),
11281        (1707, "round", "f", 2, 1700),
11282        (2308, "ceil", "f", 1, 701),
11283        (2309, "ceiling", "f", 1, 701),
11284        (2310, "floor", "f", 1, 701),
11285        (1376, "sqrt", "f", 1, 701),
11286        (1369, "ln", "f", 1, 701),
11287        (1373, "exp", "f", 1, 701),
11288        (1368, "power", "f", 2, 701),
11289        (2228, "random", "f", 0, 701),
11290        // Date / time.
11291        (1299, "now", "f", 0, 1184),
11292        (1274, "current_timestamp", "f", 0, 1184),
11293        (1140, "current_date", "f", 0, 1082),
11294        (2050, "current_time", "f", 0, 1083),
11295        (1158, "date_trunc", "f", 2, 1184),
11296        (1171, "date_part", "f", 2, 701),
11297        (1172, "age", "f", 1, 1186),
11298        (936, "to_char", "f", 2, 25),
11299        // Session / introspection.
11300        (861, "current_database", "f", 0, 19),
11301        (745, "current_user", "f", 0, 19),
11302        (745, "session_user", "f", 0, 19),
11303        (1402, "current_schema", "f", 0, 19),
11304        // String concat / format.
11305        (3058, "concat", "f", -1, 25),
11306        (3059, "concat_ws", "f", -1, 25),
11307        (3539, "format", "f", -1, 25),
11308        // Type introspection.
11309        (2877, "pg_typeof", "f", 1, 2206),
11310        // JSON.
11311        (3198, "json_build_object", "f", -1, 114),
11312        (3199, "jsonb_build_object", "f", -1, 3802),
11313        (3271, "json_build_array", "f", -1, 114),
11314        (3272, "jsonb_build_array", "f", -1, 3802),
11315        // UUID.
11316        (3253, "gen_random_uuid", "f", 0, 2950),
11317        (3252, "uuid_generate_v4", "f", 0, 2950),
11318        // Aggregates.
11319        (2147, "count", "a", 0, 20),
11320        (2803, "count", "a", -1, 20),
11321        (2116, "max", "a", 1, 23),
11322        (2132, "min", "a", 1, 23),
11323        (2108, "sum", "a", 1, 20),
11324        (2100, "avg", "a", 1, 1700),
11325        (2517, "string_agg", "a", 2, 25),
11326        (2747, "array_agg", "a", 1, 1009),
11327        (2517, "bool_and", "a", 1, 16),
11328        (2518, "bool_or", "a", 1, 16),
11329        (2519, "every", "a", 1, 16),
11330        // Window functions.
11331        (3100, "row_number", "w", 0, 20),
11332        (3101, "rank", "w", 0, 20),
11333        (3102, "dense_rank", "w", 0, 20),
11334        (3103, "percent_rank", "w", 0, 701),
11335        (3104, "cume_dist", "w", 0, 701),
11336        (3105, "lag", "w", -1, 2283),
11337        (3106, "lead", "w", -1, 2283),
11338        (3107, "first_value", "w", 1, 2283),
11339        (3108, "last_value", "w", 1, 2283),
11340        (3109, "nth_value", "w", 2, 2283),
11341    ];
11342    let mut rows: Vec<Row> = Vec::with_capacity(funcs.len());
11343    for &(oid, name, kind, nargs, rettype) in funcs {
11344        rows.push(Row::new(alloc::vec![
11345            Value::BigInt(oid),
11346            Value::Text(name.into()),
11347            Value::BigInt(11),
11348            Value::Text(kind.into()),
11349            Value::Int(nargs),
11350            Value::BigInt(rettype),
11351        ]));
11352    }
11353    (schema, rows)
11354}
11355
11356/// v7.17.0 Phase 3.P0-65 — synthesise `mysql.user`. MySQL admin
11357/// queries (`SELECT user, host FROM mysql.user`) probe this at
11358/// connect time to list accounts. SPG ships one row per
11359/// UserStore entry plus a synthetic `root` superuser row for
11360/// MySQL bootstrap compat.
11361fn synth_mysql_user(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
11362    let schema = alloc::vec![
11363        ColumnSchema::new("user", DataType::Text, false),
11364        ColumnSchema::new("host", DataType::Text, false),
11365        ColumnSchema::new("select_priv", DataType::Text, false),
11366    ];
11367    let mut rows: Vec<Row> = Vec::new();
11368    rows.push(Row::new(alloc::vec![
11369        Value::Text("root".into()),
11370        Value::Text("localhost".into()),
11371        Value::Text("Y".into()),
11372    ]));
11373    for (name, _) in engine.users.iter() {
11374        if name != "root" {
11375            rows.push(Row::new(alloc::vec![
11376                Value::Text(name.to_string()),
11377                Value::Text("%".into()),
11378                Value::Text("Y".into()),
11379            ]));
11380        }
11381    }
11382    (schema, rows)
11383}
11384
11385/// v7.17.0 Phase 3.P0-65 — synthesise `mysql.db`. The
11386/// per-database privileges table. SPG is single-database so the
11387/// table surfaces one row per declared user with full privileges
11388/// on the canonical `postgres` database.
11389fn synth_mysql_db() -> (Vec<ColumnSchema>, Vec<Row>) {
11390    let schema = alloc::vec![
11391        ColumnSchema::new("host", DataType::Text, false),
11392        ColumnSchema::new("db", DataType::Text, false),
11393        ColumnSchema::new("user", DataType::Text, false),
11394        ColumnSchema::new("select_priv", DataType::Text, false),
11395    ];
11396    let rows = alloc::vec![Row::new(alloc::vec![
11397        Value::Text("localhost".into()),
11398        Value::Text("postgres".into()),
11399        Value::Text("root".into()),
11400        Value::Text("Y".into()),
11401    ])];
11402    (schema, rows)
11403}
11404
11405/// v7.17.0 Phase 3.P0-63 — synthesise
11406/// `information_schema.KEY_COLUMN_USAGE`. ORM migration tools
11407/// (Alembic, Sequelize, TypeORM) walk this view to discover FK
11408/// relationships in MySQL-flavoured introspection queries.
11409///
11410/// Schema columns exposed:
11411///   * CONSTRAINT_NAME (Text)
11412///   * TABLE_NAME (Text)
11413///   * COLUMN_NAME (Text)
11414///   * ORDINAL_POSITION (Int)
11415///   * REFERENCED_TABLE_NAME (Text) — empty for non-FK rows
11416///   * REFERENCED_COLUMN_NAME (Text) — empty for non-FK rows
11417fn synth_info_key_column_usage(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11418    let schema = alloc::vec![
11419        ColumnSchema::new("constraint_name", DataType::Text, false),
11420        ColumnSchema::new("table_name", DataType::Text, false),
11421        ColumnSchema::new("column_name", DataType::Text, false),
11422        ColumnSchema::new("ordinal_position", DataType::Int, false),
11423        ColumnSchema::new("referenced_table_name", DataType::Text, false),
11424        ColumnSchema::new("referenced_column_name", DataType::Text, false),
11425    ];
11426    let mut rows: Vec<Row> = Vec::new();
11427    for tname in cat.table_names() {
11428        let Some(t) = cat.get(&tname) else { continue };
11429        let cols = &t.schema().columns;
11430        let col_name_at = |pos: usize| -> String {
11431            cols.get(pos)
11432                .map_or_else(|| alloc::format!("col{pos}"), |c| c.name.clone())
11433        };
11434        // FKs.
11435        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
11436            let conname = fk
11437                .name
11438                .clone()
11439                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
11440            for (i, (&local, &parent)) in fk
11441                .local_columns
11442                .iter()
11443                .zip(fk.parent_columns.iter())
11444                .enumerate()
11445            {
11446                let parent_name = cat
11447                    .get(&fk.parent_table)
11448                    .and_then(|pt| pt.schema().columns.get(parent).map(|c| c.name.clone()))
11449                    .unwrap_or_else(|| alloc::format!("col{parent}"));
11450                #[allow(clippy::cast_possible_wrap)]
11451                let ordinal = (i + 1) as i32;
11452                rows.push(Row::new(alloc::vec![
11453                    Value::Text(conname.clone()),
11454                    Value::Text(tname.clone()),
11455                    Value::Text(col_name_at(local)),
11456                    Value::Int(ordinal),
11457                    Value::Text(fk.parent_table.clone()),
11458                    Value::Text(parent_name),
11459                ]));
11460            }
11461        }
11462        // PK / composite UC entries.
11463        for (ci, uc) in t.schema().uniqueness_constraints.iter().enumerate() {
11464            let conname = if uc.is_primary_key {
11465                alloc::format!("{}_pkey", tname)
11466            } else {
11467                alloc::format!("{}_uniq{ci}", tname)
11468            };
11469            for (i, &local) in uc.columns.iter().enumerate() {
11470                #[allow(clippy::cast_possible_wrap)]
11471                let ordinal = (i + 1) as i32;
11472                rows.push(Row::new(alloc::vec![
11473                    Value::Text(conname.clone()),
11474                    Value::Text(tname.clone()),
11475                    Value::Text(col_name_at(local)),
11476                    Value::Int(ordinal),
11477                    Value::Text(String::new()),
11478                    Value::Text(String::new()),
11479                ]));
11480            }
11481        }
11482    }
11483    (schema, rows)
11484}
11485
11486/// v7.17.0 Phase 3.P0-64 — synthesise
11487/// `information_schema.REFERENTIAL_CONSTRAINTS`. One row per FK.
11488fn synth_info_referential_constraints(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11489    let schema = alloc::vec![
11490        ColumnSchema::new("constraint_name", DataType::Text, false),
11491        ColumnSchema::new("table_name", DataType::Text, false),
11492        ColumnSchema::new("referenced_table_name", DataType::Text, false),
11493        ColumnSchema::new("update_rule", DataType::Text, false),
11494        ColumnSchema::new("delete_rule", DataType::Text, false),
11495    ];
11496    fn rule_name(a: spg_storage::FkAction) -> &'static str {
11497        match a {
11498            spg_storage::FkAction::Cascade => "CASCADE",
11499            spg_storage::FkAction::SetNull => "SET NULL",
11500            spg_storage::FkAction::SetDefault => "SET DEFAULT",
11501            spg_storage::FkAction::Restrict => "RESTRICT",
11502            spg_storage::FkAction::NoAction => "NO ACTION",
11503        }
11504    }
11505    let mut rows: Vec<Row> = Vec::new();
11506    for tname in cat.table_names() {
11507        let Some(t) = cat.get(&tname) else { continue };
11508        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
11509            let conname = fk
11510                .name
11511                .clone()
11512                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
11513            rows.push(Row::new(alloc::vec![
11514                Value::Text(conname),
11515                Value::Text(tname.clone()),
11516                Value::Text(fk.parent_table.clone()),
11517                Value::Text(rule_name(fk.on_update).into()),
11518                Value::Text(rule_name(fk.on_delete).into()),
11519            ]));
11520        }
11521    }
11522    (schema, rows)
11523}
11524
11525/// v7.17.0 Phase 3.P0-64 — synthesise `information_schema.STATISTICS`.
11526/// One row per (index × column) — admin tools walk this to
11527/// surface index-cardinality estimates.
11528fn synth_info_statistics(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11529    let schema = alloc::vec![
11530        ColumnSchema::new("table_name", DataType::Text, false),
11531        ColumnSchema::new("index_name", DataType::Text, false),
11532        ColumnSchema::new("column_name", DataType::Text, false),
11533        ColumnSchema::new("seq_in_index", DataType::Int, false),
11534        ColumnSchema::new("non_unique", DataType::Int, false),
11535        ColumnSchema::new("index_type", DataType::Text, false),
11536    ];
11537    let mut rows: Vec<Row> = Vec::new();
11538    for tname in cat.table_names() {
11539        let Some(t) = cat.get(&tname) else { continue };
11540        for idx in t.indices() {
11541            let col = t
11542                .schema()
11543                .columns
11544                .get(idx.column_position)
11545                .map_or("?".into(), |c| c.name.clone());
11546            rows.push(Row::new(alloc::vec![
11547                Value::Text(tname.clone()),
11548                Value::Text(idx.name.clone()),
11549                Value::Text(col),
11550                Value::Int(1),
11551                Value::Int(i32::from(!idx.is_unique)),
11552                Value::Text("BTREE".into()),
11553            ]));
11554        }
11555    }
11556    (schema, rows)
11557}
11558
11559/// v7.17.0 Phase 3.P0-64 — synthesise `information_schema.ROUTINES`.
11560/// SPG has no user-defined functions in v7.17 so the surface is
11561/// always empty; admin tools just need the table to exist.
11562fn synth_info_routines() -> (Vec<ColumnSchema>, Vec<Row>) {
11563    let schema = alloc::vec![
11564        ColumnSchema::new("routine_name", DataType::Text, false),
11565        ColumnSchema::new("routine_type", DataType::Text, false),
11566        ColumnSchema::new("data_type", DataType::Text, false),
11567    ];
11568    (schema, Vec::new())
11569}
11570
11571/// v7.17.0 Phase 3.P0-54 — synthesise `pg_catalog.pg_constraint`.
11572/// ORM compilers (Diesel, sea-orm) and admin tools probe this for
11573/// FK / UNIQUE / PK / CHECK definitions to surface relationship
11574/// graphs and validation rules. SPG ships one row per
11575/// uniqueness constraint + foreign key declared in the catalog.
11576///
11577/// Schema columns exposed:
11578///   * conname (Text) — constraint name (synthetic when anonymous)
11579///   * contype (Text) — `p` PK, `u` UNIQUE, `f` FK, `c` CHECK
11580///   * conrelid (Text) — owner table name
11581///   * confrelid (Text) — referenced parent table (FK only;
11582///     empty string otherwise)
11583///   * conkey (Text) — comma-separated column names
11584///   * confkey (Text) — comma-separated parent column names (FK only)
11585fn synth_pg_constraint(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11586    let schema = alloc::vec![
11587        ColumnSchema::new("conname", DataType::Text, false),
11588        ColumnSchema::new("contype", DataType::Text, false),
11589        ColumnSchema::new("conrelid", DataType::Text, false),
11590        ColumnSchema::new("confrelid", DataType::Text, false),
11591        ColumnSchema::new("conkey", DataType::Text, false),
11592        ColumnSchema::new("confkey", DataType::Text, false),
11593    ];
11594    let mut rows: Vec<Row> = Vec::new();
11595    for tname in cat.table_names() {
11596        let Some(t) = cat.get(&tname) else { continue };
11597        let cols = &t.schema().columns;
11598        let col_name_at = |pos: usize| -> String {
11599            cols.get(pos)
11600                .map_or_else(|| alloc::format!("col{pos}"), |c| c.name.clone())
11601        };
11602        // Uniqueness constraints (composite UNIQUE / PRIMARY KEY).
11603        for (ci, uc) in t.schema().uniqueness_constraints.iter().enumerate() {
11604            let kind = if uc.is_primary_key { "p" } else { "u" };
11605            let conname = if uc.is_primary_key {
11606                alloc::format!("{}_pkey", tname)
11607            } else {
11608                alloc::format!("{}_uniq{ci}", tname)
11609            };
11610            let conkey: Vec<String> = uc.columns.iter().map(|&p| col_name_at(p)).collect();
11611            rows.push(Row::new(alloc::vec![
11612                Value::Text(conname),
11613                Value::Text(kind.into()),
11614                Value::Text(tname.clone()),
11615                Value::Text(String::new()),
11616                Value::Text(conkey.join(",")),
11617                Value::Text(String::new()),
11618            ]));
11619        }
11620        // Single-column PK / UNIQUE indexes that have no
11621        // matching entry in `uniqueness_constraints` (the engine
11622        // creates only the BTree index for the bare-column case;
11623        // composite forms ride the UC path above).
11624        for idx in t.indices() {
11625            if !idx.is_unique {
11626                continue;
11627            }
11628            let is_primary = idx.name.ends_with("_pkey");
11629            let conname = idx.name.clone();
11630            let kind = if is_primary { "p" } else { "u" };
11631            let col_name = col_name_at(idx.column_position);
11632            // Skip if already emitted via the UC loop above (same
11633            // tuple shape — single-column).
11634            let already = t
11635                .schema()
11636                .uniqueness_constraints
11637                .iter()
11638                .any(|uc| uc.columns.len() == 1 && uc.columns[0] == idx.column_position);
11639            if already {
11640                continue;
11641            }
11642            rows.push(Row::new(alloc::vec![
11643                Value::Text(conname),
11644                Value::Text(kind.into()),
11645                Value::Text(tname.clone()),
11646                Value::Text(String::new()),
11647                Value::Text(col_name),
11648                Value::Text(String::new()),
11649            ]));
11650        }
11651        // Foreign keys.
11652        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
11653            let conname = fk
11654                .name
11655                .clone()
11656                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
11657            let conkey: Vec<String> = fk.local_columns.iter().map(|&p| col_name_at(p)).collect();
11658            // Parent column names: look up the parent table's
11659            // schema if it exists; otherwise emit positions.
11660            let confkey: Vec<String> = if let Some(parent) = cat.get(&fk.parent_table) {
11661                fk.parent_columns
11662                    .iter()
11663                    .map(|&p| {
11664                        parent
11665                            .schema()
11666                            .columns
11667                            .get(p)
11668                            .map_or_else(|| alloc::format!("col{p}"), |c| c.name.clone())
11669                    })
11670                    .collect()
11671            } else {
11672                fk.parent_columns
11673                    .iter()
11674                    .map(|p| alloc::format!("col{p}"))
11675                    .collect()
11676            };
11677            rows.push(Row::new(alloc::vec![
11678                Value::Text(conname),
11679                Value::Text("f".into()),
11680                Value::Text(tname.clone()),
11681                Value::Text(fk.parent_table.clone()),
11682                Value::Text(conkey.join(",")),
11683                Value::Text(confkey.join(",")),
11684            ]));
11685        }
11686    }
11687    (schema, rows)
11688}
11689
11690/// v7.17.0 Phase 3.P0-55 — synthesise `pg_catalog.pg_database`.
11691/// SPG is single-database so we surface a single row keyed on the
11692/// canonical `postgres` database name (matching what every PG
11693/// admin tool's startup screen expects to find).
11694fn synth_pg_database(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11695    let schema = alloc::vec![
11696        ColumnSchema::new("oid", DataType::BigInt, false),
11697        ColumnSchema::new("datname", DataType::Text, false),
11698        ColumnSchema::new("datdba", DataType::BigInt, false),
11699        ColumnSchema::new("encoding", DataType::Int, false),
11700        ColumnSchema::new("datcollate", DataType::Text, false),
11701    ];
11702    let rows = alloc::vec![Row::new(alloc::vec![
11703        Value::BigInt(16384),
11704        Value::Text("postgres".into()),
11705        Value::BigInt(10),
11706        Value::Int(6), // UTF8
11707        Value::Text("en_US.UTF-8".into()),
11708    ])];
11709    (schema, rows)
11710}
11711
11712/// v7.17.0 Phase 3.P0-55 — synthesise `pg_catalog.pg_roles`. PG's
11713/// pg_roles is a view over pg_authid showing all roles. SPG ships
11714/// one row per declared user from the engine's UserStore so admin
11715/// tool startup screens can populate.
11716fn synth_pg_roles(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
11717    let schema = alloc::vec![
11718        ColumnSchema::new("oid", DataType::BigInt, false),
11719        ColumnSchema::new("rolname", DataType::Text, false),
11720        ColumnSchema::new("rolsuper", DataType::Bool, false),
11721        ColumnSchema::new("rolinherit", DataType::Bool, false),
11722        ColumnSchema::new("rolcanlogin", DataType::Bool, false),
11723    ];
11724    let mut rows: Vec<Row> = Vec::new();
11725    let oid: i64 = 10;
11726    for (i, (name, _)) in engine.users.iter().enumerate() {
11727        rows.push(Row::new(alloc::vec![
11728            Value::BigInt(oid + (i as i64) + 1),
11729            Value::Text(name.to_string()),
11730            Value::Bool(false),
11731            Value::Bool(true),
11732            Value::Bool(true),
11733        ]));
11734    }
11735    // Always include `postgres` as the bootstrap superuser if not
11736    // already present — admin tools probe for it.
11737    if !rows
11738        .iter()
11739        .any(|r| matches!(&r.values[1], Value::Text(s) if s == "postgres"))
11740    {
11741        rows.insert(
11742            0,
11743            Row::new(alloc::vec![
11744                Value::BigInt(10),
11745                Value::Text("postgres".into()),
11746                Value::Bool(true),
11747                Value::Bool(true),
11748                Value::Bool(true),
11749            ]),
11750        );
11751    }
11752    (schema, rows)
11753}
11754
11755/// v7.17.0 Phase 3.P0-56 — synthesise `pg_catalog.pg_views`. PG's
11756/// pg_views is a view listing every catalog view; SPG ships one
11757/// row per declared view + its definition text.
11758/// Synthesise `pg_catalog.pg_extension`. SPG ships its "extension"
11759/// surfaces natively (vector, pg_trgm, plpgsql-shaped DO blocks), so
11760/// the table lists those as installed — `SELECT … FROM pg_extension
11761/// WHERE extname = 'vector'` probes from PG clients (mailrs embed
11762/// round-12) answer truthfully about capability presence.
11763fn synth_pg_extension() -> (Vec<ColumnSchema>, Vec<Row>) {
11764    let schema = alloc::vec![
11765        ColumnSchema::new("oid", DataType::BigInt, false),
11766        ColumnSchema::new("extname", DataType::Text, false),
11767        ColumnSchema::new("extversion", DataType::Text, false),
11768        ColumnSchema::new("extnamespace", DataType::Text, false),
11769    ];
11770    let exts: &[(&str, &str)] = &[("plpgsql", "1.0"), ("vector", "0.8.0"), ("pg_trgm", "1.6")];
11771    let rows = exts
11772        .iter()
11773        .enumerate()
11774        .map(|(i, (name, ver))| {
11775            Row::new(alloc::vec![
11776                Value::BigInt(16384 + i as i64),
11777                Value::Text((*name).into()),
11778                Value::Text((*ver).into()),
11779                Value::Text("pg_catalog".into()),
11780            ])
11781        })
11782        .collect();
11783    (schema, rows)
11784}
11785
11786fn synth_pg_views(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11787    let schema = alloc::vec![
11788        ColumnSchema::new("schemaname", DataType::Text, false),
11789        ColumnSchema::new("viewname", DataType::Text, false),
11790        ColumnSchema::new("definition", DataType::Text, false),
11791    ];
11792    let mut rows: Vec<Row> = Vec::new();
11793    for (name, def) in cat.views() {
11794        rows.push(Row::new(alloc::vec![
11795            Value::Text("public".into()),
11796            Value::Text(name.clone()),
11797            Value::Text(def.body.clone()),
11798        ]));
11799    }
11800    (schema, rows)
11801}
11802
11803/// v7.17.0 Phase 3.P0-57 — synthesise `pg_catalog.pg_settings`. ORM
11804/// connection-checkers (sqlx pre-flight, Diesel migrator) and admin
11805/// tools read `pg_settings` to discover server-side configuration.
11806/// SPG surfaces every session_param + a small set of canonical PG
11807/// defaults so the pre-flight queries match.
11808fn synth_pg_settings(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
11809    let schema = alloc::vec![
11810        ColumnSchema::new("name", DataType::Text, false),
11811        ColumnSchema::new("setting", DataType::Text, false),
11812        ColumnSchema::new("category", DataType::Text, false),
11813    ];
11814    let mut rows: Vec<Row> = Vec::new();
11815    // Canonical defaults every admin tool expects to find.
11816    let defaults: &[(&str, &str, &str)] = &[
11817        ("server_version", "16.0 (spg)", "Preset Options"),
11818        ("server_encoding", "UTF8", "Client Connection Defaults"),
11819        ("client_encoding", "UTF8", "Client Connection Defaults"),
11820        ("DateStyle", "ISO, MDY", "Client Connection Defaults"),
11821        ("TimeZone", "UTC", "Client Connection Defaults"),
11822        ("standard_conforming_strings", "on", "Compatibility"),
11823        ("integer_datetimes", "on", "Compatibility"),
11824        ("max_connections", "100", "Connections and Authentication"),
11825    ];
11826    for &(name, val, cat) in defaults {
11827        rows.push(Row::new(alloc::vec![
11828            Value::Text(name.into()),
11829            Value::Text(val.into()),
11830            Value::Text(cat.into()),
11831        ]));
11832    }
11833    // Session-set params override the static defaults.
11834    for (k, v) in &engine.session_params {
11835        if !defaults
11836            .iter()
11837            .any(|(n, _, _)| (*n).eq_ignore_ascii_case(k))
11838        {
11839            rows.push(Row::new(alloc::vec![
11840                Value::Text(k.clone()),
11841                Value::Text(v.clone()),
11842                Value::Text("Session".into()),
11843            ]));
11844        }
11845    }
11846    (schema, rows)
11847}
11848
11849/// v7.17.0 Phase 3.P0-53 — synthesise `pg_catalog.pg_indexes`.
11850/// PG's pg_indexes is a real view on pg_index + pg_class + pg_attribute.
11851/// SPG ships it as a synthesised flat table so admin tools (pgAdmin,
11852/// DataGrip) can list indexes by tablename without joining four catalogs.
11853///
11854/// Schema columns exposed:
11855///   * schemaname (Text) — always `public`
11856///   * tablename (Text)
11857///   * indexname (Text)
11858///   * indexdef (Text) — best-effort CREATE INDEX DDL
11859fn synth_pg_indexes(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11860    let schema = alloc::vec![
11861        ColumnSchema::new("schemaname", DataType::Text, false),
11862        ColumnSchema::new("tablename", DataType::Text, false),
11863        ColumnSchema::new("indexname", DataType::Text, false),
11864        ColumnSchema::new("indexdef", DataType::Text, false),
11865    ];
11866    let mut rows: Vec<Row> = Vec::new();
11867    for tname in cat.table_names() {
11868        let Some(t) = cat.get(&tname) else { continue };
11869        for idx in t.indices() {
11870            let col_name = t
11871                .schema()
11872                .columns
11873                .get(idx.column_position)
11874                .map_or("?".into(), |c| c.name.clone());
11875            let unique_kw = if idx.is_unique { "UNIQUE " } else { "" };
11876            let indexdef = alloc::format!(
11877                "CREATE {unique_kw}INDEX {} ON public.{} ({})",
11878                idx.name,
11879                tname,
11880                col_name
11881            );
11882            rows.push(Row::new(alloc::vec![
11883                Value::Text("public".into()),
11884                Value::Text(tname.clone()),
11885                Value::Text(idx.name.clone()),
11886                Value::Text(indexdef),
11887            ]));
11888        }
11889    }
11890    (schema, rows)
11891}
11892
11893/// v7.17.0 Phase 3.P0-53 — synthesise `pg_catalog.pg_index`. The
11894/// "raw" pg_index catalog used by PG-internal tooling for index
11895/// flags and ordinal information. SPG ships the columns ORM probes
11896/// actually filter on.
11897///
11898/// Schema columns exposed:
11899///   * indexrelid (BigInt) — index OID (synthetic = position+1)
11900///   * indrelid (BigInt) — table OID (synthetic = position+1)
11901///   * indnatts (Int) — number of indexed columns
11902///   * indisunique (Bool)
11903///   * indisprimary (Bool)
11904fn synth_pg_index_raw(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11905    let schema = alloc::vec![
11906        ColumnSchema::new("indexrelid", DataType::BigInt, false),
11907        ColumnSchema::new("indrelid", DataType::BigInt, false),
11908        ColumnSchema::new("indnatts", DataType::Int, false),
11909        ColumnSchema::new("indisunique", DataType::Bool, false),
11910        ColumnSchema::new("indisprimary", DataType::Bool, false),
11911    ];
11912    let mut rows: Vec<Row> = Vec::new();
11913    let mut idx_oid: i64 = 100_000;
11914    for (table_idx, tname) in cat.table_names().iter().enumerate() {
11915        let Some(t) = cat.get(tname) else { continue };
11916        for idx in t.indices() {
11917            idx_oid += 1;
11918            #[allow(clippy::cast_possible_wrap)]
11919            let nattrs = (1 + idx.extra_column_positions.len()) as i32;
11920            // is_primary: SPG / PG flag the primary via the
11921            // index name convention `<table>_pkey`.
11922            let is_primary = idx.name.ends_with("_pkey");
11923            rows.push(Row::new(alloc::vec![
11924                Value::BigInt(idx_oid),
11925                Value::BigInt((table_idx + 1) as i64),
11926                Value::Int(nattrs),
11927                Value::Bool(idx.is_unique),
11928                Value::Bool(is_primary),
11929            ]));
11930        }
11931    }
11932    (schema, rows)
11933}
11934
11935/// v7.17.0 Phase 3.P0-52 — synthesise `pg_catalog.pg_namespace`.
11936/// SPG is single-schema so we expose the canonical PG schemas:
11937/// `public` (user-facing), `pg_catalog` (built-in), and
11938/// `information_schema` (PG meta).
11939fn synth_pg_namespace(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11940    let schema = alloc::vec![
11941        ColumnSchema::new("oid", DataType::BigInt, false),
11942        ColumnSchema::new("nspname", DataType::Text, false),
11943        ColumnSchema::new("nspowner", DataType::BigInt, false),
11944    ];
11945    let rows = alloc::vec![
11946        Row::new(alloc::vec![
11947            Value::BigInt(11),
11948            Value::Text("pg_catalog".into()),
11949            Value::BigInt(10),
11950        ]),
11951        Row::new(alloc::vec![
11952            Value::BigInt(2200),
11953            Value::Text("public".into()),
11954            Value::BigInt(10),
11955        ]),
11956        Row::new(alloc::vec![
11957            Value::BigInt(13000),
11958            Value::Text("information_schema".into()),
11959            Value::BigInt(10),
11960        ]),
11961    ];
11962    (schema, rows)
11963}
11964
11965/// v7.16.2 — drop the synthesised meta view into the enriched
11966/// catalog so the regular FROM-resolution path can see it.
11967fn materialise_meta_view(
11968    catalog: &mut Catalog,
11969    name: &str,
11970    columns: Vec<ColumnSchema>,
11971    rows: Vec<Row>,
11972) -> Result<(), EngineError> {
11973    let schema = TableSchema::new(name.to_string(), columns);
11974    catalog.create_table(schema).map_err(EngineError::Storage)?;
11975    let table = catalog
11976        .get_mut(name)
11977        .expect("just-created meta view must exist");
11978    for row in rows {
11979        table.insert(row).map_err(EngineError::Storage)?;
11980    }
11981    Ok(())
11982}
11983
11984/// v7.16.2 — true when the SELECT statement references any
11985/// `__spg_info_*` or `__spg_pg_*` synthetic table name (the
11986/// parser produces these for `information_schema.X` /
11987/// `pg_catalog.X`). Used by `exec_select_cancel` to short-
11988/// circuit into the meta-view materialisation path.
11989/// v7.17.0 Phase 1.2 — append the names of any catalog-known
11990/// views referenced by `tref` to `into`. Helper for
11991/// `Engine::expand_views_in_select`. A view that's been already
11992/// materialised as a table (e.g. via the synthetic CTE pass for
11993/// SELECT FROM v) is skipped — the table form wins so the
11994/// recursive exec_select_cancel call inside exec_with_ctes
11995/// doesn't re-expand and trigger the CTE-shadow guard.
11996fn collect_view_refs(
11997    tref: &spg_sql::ast::TableRef,
11998    cat: &spg_storage::Catalog,
11999    into: &mut Vec<String>,
12000) {
12001    if cat.views().contains_key(&tref.name)
12002        && cat.get(&tref.name).is_none()
12003        && !into.iter().any(|n| n == &tref.name)
12004    {
12005        into.push(tref.name.clone());
12006    }
12007}
12008
12009fn select_references_meta_view(stmt: &SelectStatement) -> bool {
12010    fn is_meta(name: &str) -> bool {
12011        name.starts_with("__spg_info_")
12012            || name.starts_with("__spg_pg_")
12013            || name.starts_with("__spg_mysql_")
12014    }
12015    if let Some(from) = &stmt.from {
12016        if is_meta(&from.primary.name) {
12017            return true;
12018        }
12019        for j in &from.joins {
12020            if is_meta(&j.table.name) {
12021                return true;
12022            }
12023        }
12024    }
12025    for cte in &stmt.ctes {
12026        if select_references_meta_view(&cte.body) {
12027            return true;
12028        }
12029    }
12030    false
12031}
12032
12033/// v7.16.2 — collect every meta-view name a SELECT touches.
12034/// Returns a deduplicated, sorted list. Caller materialises
12035/// each one into the enriched catalog before re-running the
12036/// SELECT. Walks JOINs, CTEs, and the primary FROM.
12037fn collect_meta_view_names(
12038    stmt: &SelectStatement,
12039    into: &mut alloc::collections::BTreeSet<String>,
12040) {
12041    fn is_meta(name: &str) -> bool {
12042        name.starts_with("__spg_info_")
12043            || name.starts_with("__spg_pg_")
12044            || name.starts_with("__spg_mysql_")
12045    }
12046    if let Some(from) = &stmt.from {
12047        if is_meta(&from.primary.name) {
12048            into.insert(from.primary.name.clone());
12049        }
12050        for j in &from.joins {
12051            if is_meta(&j.table.name) {
12052                into.insert(j.table.name.clone());
12053            }
12054        }
12055    }
12056    for cte in &stmt.ctes {
12057        collect_meta_view_names(&cte.body, into);
12058    }
12059}
12060
12061fn infer_column_types(columns: &[ColumnSchema], rows: &[Row]) -> Vec<ColumnSchema> {
12062    let mut out = columns.to_vec();
12063    for (col_idx, col) in out.iter_mut().enumerate() {
12064        if col.ty != DataType::Text {
12065            continue;
12066        }
12067        let mut inferred: Option<DataType> = None;
12068        let mut all_null = true;
12069        for row in rows {
12070            let Some(v) = row.values.get(col_idx) else {
12071                continue;
12072            };
12073            let ty = match v {
12074                Value::Null => continue,
12075                Value::SmallInt(_) => DataType::SmallInt,
12076                Value::Int(_) => DataType::Int,
12077                Value::BigInt(_) => DataType::BigInt,
12078                Value::Float(_) => DataType::Float,
12079                Value::Bool(_) => DataType::Bool,
12080                Value::Vector(_) => DataType::Vector {
12081                    dim: 0,
12082                    encoding: VecEncoding::F32,
12083                },
12084                _ => DataType::Text,
12085            };
12086            all_null = false;
12087            inferred = Some(match inferred {
12088                None => ty,
12089                Some(prev) if prev == ty => prev,
12090                Some(_) => DataType::Text,
12091            });
12092        }
12093        if let Some(t) = inferred {
12094            col.ty = t;
12095            col.nullable = true;
12096        } else if all_null {
12097            col.nullable = true;
12098        }
12099    }
12100    out
12101}
12102
12103/// v4.26: render a human-readable plan tree for `EXPLAIN <select>`.
12104/// Lines are pushed into `out`; `depth` controls indentation. We
12105/// describe the rewritten SELECT — what the executor *would* do —
12106/// using the engine handle to spot indexed lookups and table shapes.
12107#[allow(clippy::too_many_lines, clippy::format_push_string)]
12108/// v6.2.4 — Walk every line of the rendered plan tree and append
12109/// per-operator stats. Lines that name a known operator get
12110/// `(rows=N)` (`actual_rows` of the top-level operator equals the
12111/// final result row count; scans report their catalog row count
12112/// as the rows-considered metric). Other lines — Filter / Join /
12113/// GroupBy / OrderBy etc. — are marked `(—)` so the surface is
12114/// complete-by-construction; v6.2.5 fills these in via inline
12115/// executor counters.
12116/// v6.8.3 — surface "CREATE INDEX …" suggestions for every
12117/// `(table, column)` pair the query touches via WHERE / JOIN
12118/// that doesn't already have an index on the owning table.
12119/// Walks the SELECT's FROM clauses + WHERE expression tree;
12120/// returns one line per missing index. Deterministic order:
12121/// FROM-clause iteration order, then column-reference walk
12122/// order inside each WHERE. Each suggestion is a copy-pastable
12123/// DDL string.
12124fn build_index_suggestions(stmt: &SelectStatement, engine: &Engine) -> Vec<String> {
12125    use alloc::collections::BTreeSet;
12126    let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
12127    let mut out: Vec<String> = Vec::new();
12128    let cat = engine.active_catalog();
12129    // Build a (table, qualifier-or-alias) list from the FROM clause
12130    // so unqualified column refs in WHERE resolve to the correct
12131    // table.
12132    let Some(from) = &stmt.from else {
12133        return out;
12134    };
12135    let mut tables: Vec<String> = Vec::new();
12136    tables.push(from.primary.name.clone());
12137    for j in &from.joins {
12138        tables.push(j.table.name.clone());
12139    }
12140    // Collect column refs from the WHERE expression. JOIN ON
12141    // predicates also feed in.
12142    let mut col_refs: Vec<spg_sql::ast::ColumnName> = Vec::new();
12143    if let Some(w) = &stmt.where_ {
12144        collect_column_refs(w, &mut col_refs);
12145    }
12146    for j in &from.joins {
12147        if let Some(on) = &j.on {
12148            collect_column_refs(on, &mut col_refs);
12149        }
12150    }
12151    for cn in &col_refs {
12152        // Resolve owner table: explicit qualifier first, else
12153        // first table in FROM that has a column of this name.
12154        let owner: Option<String> = if let Some(q) = &cn.qualifier {
12155            tables.iter().find(|t| t == &q).cloned()
12156        } else {
12157            tables.iter().find_map(|t| {
12158                cat.get(t).and_then(|tbl| {
12159                    if tbl.schema().column_position(&cn.name).is_some() {
12160                        Some(t.clone())
12161                    } else {
12162                        None
12163                    }
12164                })
12165            })
12166        };
12167        let Some(owner) = owner else {
12168            continue;
12169        };
12170        let Some(tbl) = cat.get(&owner) else {
12171            continue;
12172        };
12173        let Some(col_pos) = tbl.schema().column_position(&cn.name) else {
12174            continue;
12175        };
12176        // Skip if any BTree index already covers this column as
12177        // its key.
12178        let already_indexed = tbl.indices().iter().any(|i| {
12179            matches!(i.kind, spg_storage::IndexKind::BTree(_))
12180                && i.column_position == col_pos
12181                && i.expression.is_none()
12182                && i.partial_predicate.is_none()
12183        });
12184        if already_indexed {
12185            continue;
12186        }
12187        if seen.insert((owner.clone(), cn.name.clone())) {
12188            out.push(alloc::format!(
12189                "SUGGEST: CREATE INDEX ix_{}_{} ON {} ({})",
12190                owner,
12191                cn.name,
12192                owner,
12193                cn.name
12194            ));
12195        }
12196    }
12197    out
12198}
12199
12200/// Walks an `Expr` and pushes every `ColumnName` it references.
12201/// Order is depth-first, left-to-right.
12202fn collect_column_refs(expr: &Expr, out: &mut Vec<spg_sql::ast::ColumnName>) {
12203    match expr {
12204        Expr::Column(cn) => out.push(cn.clone()),
12205        Expr::FunctionCall { args, .. } => {
12206            for a in args {
12207                collect_column_refs(a, out);
12208            }
12209        }
12210        Expr::Binary { lhs, rhs, .. } => {
12211            collect_column_refs(lhs, out);
12212            collect_column_refs(rhs, out);
12213        }
12214        Expr::Unary { expr: e, .. } => collect_column_refs(e, out),
12215        _ => {}
12216    }
12217}
12218
12219fn annotate_explain_lines(lines: &mut [String], total_rows: usize, engine: &Engine) {
12220    let catalog = engine.active_catalog();
12221    let cold_ids = catalog.cold_segment_ids_global();
12222    let any_cold = !cold_ids.is_empty();
12223    let cold_ids_repr = if any_cold {
12224        let mut s = alloc::string::String::from("[");
12225        for (i, id) in cold_ids.iter().enumerate() {
12226            if i > 0 {
12227                s.push(',');
12228            }
12229            s.push_str(&alloc::format!("{id}"));
12230        }
12231        s.push(']');
12232        s
12233    } else {
12234        alloc::string::String::new()
12235    };
12236    for (idx, line) in lines.iter_mut().enumerate() {
12237        let trimmed = line.trim_start();
12238        let is_top_level = idx == 0;
12239        if is_top_level {
12240            line.push_str(&alloc::format!(" (rows={total_rows})"));
12241            continue;
12242        }
12243        if let Some(rest) = trimmed.strip_prefix("From: ") {
12244            let (name, scan_kind) = match rest.split_once(" [") {
12245                Some((n, k)) => (n.trim(), k.trim_end_matches(']')),
12246                None => (rest.trim(), ""),
12247            };
12248            let bare = name.split_whitespace().next().unwrap_or(name);
12249            let hot = catalog.get(bare).map(|t| t.rows().len());
12250            // v6.2.7 — `cold_segments=[id0,id1,…]` enumerates every
12251            // cold-tier segment the scan COULD have walked. v6.2.x
12252            // can tighten to per-table by walking the table's
12253            // BTree-index cold locators.
12254            let annot = match (hot, scan_kind) {
12255                (Some(h), "full scan") => {
12256                    let mut s = alloc::format!(" (hot_rows={h}");
12257                    if any_cold {
12258                        s.push_str(&alloc::format!(
12259                            ", cold_tier=present, cold_segments={cold_ids_repr}"
12260                        ));
12261                    }
12262                    s.push(')');
12263                    s
12264                }
12265                (Some(h), "index seek") => {
12266                    let mut s = alloc::format!(" (hot_rows≤{h}");
12267                    if any_cold {
12268                        s.push_str(&alloc::format!(
12269                            ", cold_tier=present, cold_segments={cold_ids_repr}"
12270                        ));
12271                    }
12272                    s.push(')');
12273                    s
12274                }
12275                _ => " (rows=—)".to_string(),
12276            };
12277            line.push_str(&annot);
12278            continue;
12279        }
12280        // Filter / GroupBy / Having / OrderBy / Limit / Join etc.
12281        line.push_str(" (rows=—)");
12282    }
12283}
12284
12285fn explain_select(stmt: &SelectStatement, engine: &Engine, depth: usize, out: &mut Vec<String>) {
12286    let pad = "  ".repeat(depth);
12287    // 1) Top-level operator label.
12288    let top = if !stmt.ctes.is_empty() {
12289        if stmt.ctes.iter().any(|c| c.recursive) {
12290            "CTEScan (WITH RECURSIVE)"
12291        } else {
12292            "CTEScan (WITH)"
12293        }
12294    } else if !stmt.unions.is_empty() {
12295        "UnionScan"
12296    } else if select_has_window(stmt) {
12297        "WindowAgg"
12298    } else if aggregate::uses_aggregate(stmt) {
12299        "Aggregate"
12300    } else if stmt.distinct {
12301        "Distinct"
12302    } else if stmt.from.is_some() {
12303        "TableScan"
12304    } else {
12305        "Result"
12306    };
12307    out.push(alloc::format!("{pad}{top}"));
12308    let child = "  ".repeat(depth + 1);
12309    // 2) CTE bodies.
12310    for cte in &stmt.ctes {
12311        let head = if cte.recursive {
12312            alloc::format!("{child}CTE (recursive): {}", cte.name)
12313        } else {
12314            alloc::format!("{child}CTE: {}", cte.name)
12315        };
12316        out.push(head);
12317        explain_select(&cte.body, engine, depth + 2, out);
12318    }
12319    // 3) FROM details — primary table + joins, index hits.
12320    if let Some(from) = &stmt.from {
12321        let mut tag = alloc::format!("{child}From: {}", from.primary.name);
12322        if let Some(alias) = &from.primary.alias {
12323            tag.push_str(&alloc::format!(" AS {alias}"));
12324        }
12325        // Try to detect an index-seek opportunity on WHERE against
12326        // the primary table — same heuristic the executor uses.
12327        if let Some(w) = &stmt.where_
12328            && let Some(table) = engine.active_catalog().get(&from.primary.name)
12329        {
12330            let alias = from.primary.alias.as_deref().unwrap_or(&from.primary.name);
12331            let cols = &table.schema().columns;
12332            if try_index_seek(w, cols, engine.active_catalog(), table, alias).is_some() {
12333                tag.push_str(" [index seek]");
12334            } else {
12335                tag.push_str(" [full scan]");
12336            }
12337        } else {
12338            tag.push_str(" [full scan]");
12339        }
12340        out.push(tag);
12341        for j in &from.joins {
12342            let kind = match j.kind {
12343                spg_sql::ast::JoinKind::Inner => "INNER JOIN",
12344                spg_sql::ast::JoinKind::Left => "LEFT JOIN",
12345                spg_sql::ast::JoinKind::Cross => "CROSS JOIN",
12346            };
12347            let mut s = alloc::format!("{child}{kind}: {}", j.table.name);
12348            if let Some(alias) = &j.table.alias {
12349                s.push_str(&alloc::format!(" AS {alias}"));
12350            }
12351            if j.on.is_some() {
12352                s.push_str(" (ON …)");
12353            }
12354            out.push(s);
12355        }
12356    }
12357    // 4) WHERE / GROUP BY / HAVING / ORDER BY / LIMIT / OFFSET.
12358    if let Some(w) = &stmt.where_ {
12359        let mut s = alloc::format!("{child}Filter: {w}");
12360        if expr_has_subquery(w) {
12361            s.push_str(" [subquery]");
12362        }
12363        out.push(s);
12364    }
12365    if let Some(gs) = &stmt.group_by {
12366        let mut parts = Vec::new();
12367        for g in gs {
12368            parts.push(alloc::format!("{g}"));
12369        }
12370        out.push(alloc::format!("{child}GroupBy: {}", parts.join(", ")));
12371    }
12372    if let Some(h) = &stmt.having {
12373        out.push(alloc::format!("{child}Having: {h}"));
12374    }
12375    for o in &stmt.order_by {
12376        let dir = if o.desc { "DESC" } else { "ASC" };
12377        out.push(alloc::format!("{child}OrderBy: {} {dir}", o.expr));
12378    }
12379    if let Some(lim) = stmt.limit {
12380        out.push(alloc::format!("{child}Limit: {lim}"));
12381    }
12382    if let Some(off) = stmt.offset {
12383        out.push(alloc::format!("{child}Offset: {off}"));
12384    }
12385    // 5) Projection — collapse Wildcard or render N items.
12386    if stmt
12387        .items
12388        .iter()
12389        .any(|it| matches!(it, SelectItem::Wildcard))
12390    {
12391        out.push(alloc::format!("{child}Project: *"));
12392    } else {
12393        out.push(alloc::format!(
12394            "{child}Project: {} item(s)",
12395            stmt.items.len()
12396        ));
12397    }
12398    // 6) Recurse into UNION peers.
12399    for (kind, peer) in &stmt.unions {
12400        let label = match kind {
12401            UnionKind::All => "UNION ALL",
12402            UnionKind::Distinct => "UNION",
12403        };
12404        out.push(alloc::format!("{child}{label}"));
12405        explain_select(peer, engine, depth + 2, out);
12406    }
12407}
12408
12409/// v4.23: recognise the engine errors that indicate the inner
12410/// SELECT couldn't be evaluated in isolation because it references
12411/// an outer column — used by `subquery_replacement` to skip
12412/// materialisation and let row-eval handle it instead.
12413fn is_correlation_error(e: &EngineError) -> bool {
12414    matches!(
12415        e,
12416        EngineError::Eval(
12417            eval::EvalError::ColumnNotFound { .. } | eval::EvalError::UnknownQualifier { .. }
12418        )
12419    )
12420}
12421
12422/// v4.23: walk every Expr in `stmt` and replace each Column ref
12423/// that targets the outer scope (qualifier matches the outer
12424/// table alias) with a Literal carrying the outer row's value.
12425/// Conservative: only qualified refs are substituted, so the user
12426/// must write `outer_alias.col` to reference an outer column. This
12427/// matches PG's lexical scoping for correlated subqueries and
12428/// avoids accidentally rebinding inner columns of the same name.
12429/// v7.17.0 Phase 3.P0-41 — LATERAL peer descriptor. Either eagerly
12430/// materialised (every regular table / unnest / generate_series) or
12431/// lateral (subquery re-evaluated per outer row).
12432struct JoinedPeer<'a> {
12433    eager_rows: Option<Vec<Row>>,
12434    cols: Vec<ColumnSchema>,
12435    alias: String,
12436    kind: JoinKind,
12437    on: Option<&'a Expr>,
12438    lateral: Option<&'a SelectStatement>,
12439}
12440
12441/// v7.17.0 Phase 3.P0-41 — synthesise a column name for a LATERAL
12442/// projection item that has no explicit alias. PG names anonymous
12443/// projection items by the function call's name or by `column<i>`.
12444/// SPG mirrors the latter (lower-overhead than walking arbitrary
12445/// Expr shapes) so the probe-schema fallback path produces stable
12446/// names for the lateral peer's columns.
12447fn synth_lateral_col_name(expr: &Expr, idx: usize) -> String {
12448    match expr {
12449        // Bare column reference — use the column's own name.
12450        Expr::Column(c) => c.name.clone(),
12451        // Function call — use the function name (PG canonical:
12452        // `count` / `max` / `lower` …).
12453        Expr::FunctionCall { name, .. } => name.clone(),
12454        // Cast — drill into the inner expression.
12455        Expr::Cast { expr: inner, .. } => synth_lateral_col_name(inner, idx),
12456        // Everything else falls back to PG's `column<N>` placeholder.
12457        _ => alloc::format!("column{}", idx + 1),
12458    }
12459}
12460
12461/// v7.17.0 Phase 3.P0-41 — substitute every `<alias>.<col>` Expr
12462/// reference whose `<alias>.<col>` exists in the outer composite
12463/// schema with the matching value from the outer row. Walks the
12464/// entire SELECT body (items, WHERE, GROUP BY, HAVING, ORDER BY,
12465/// UNION peers) so any depth of outer reference inside the
12466/// LATERAL subquery resolves before execution.
12467fn substitute_outer_columns_multi(
12468    stmt: &mut SelectStatement,
12469    outer_row: &Row,
12470    outer_schema: &[ColumnSchema],
12471) {
12472    substitute_outer_in_select(stmt, outer_row, outer_schema);
12473}
12474
12475fn substitute_outer_in_select(
12476    stmt: &mut SelectStatement,
12477    outer_row: &Row,
12478    outer_schema: &[ColumnSchema],
12479) {
12480    for item in &mut stmt.items {
12481        if let SelectItem::Expr { expr, .. } = item {
12482            substitute_outer_in_expr(expr, outer_row, outer_schema);
12483        }
12484    }
12485    if let Some(w) = &mut stmt.where_ {
12486        substitute_outer_in_expr(w, outer_row, outer_schema);
12487    }
12488    if let Some(gs) = &mut stmt.group_by {
12489        for g in gs {
12490            substitute_outer_in_expr(g, outer_row, outer_schema);
12491        }
12492    }
12493    if let Some(h) = &mut stmt.having {
12494        substitute_outer_in_expr(h, outer_row, outer_schema);
12495    }
12496    for o in &mut stmt.order_by {
12497        substitute_outer_in_expr(&mut o.expr, outer_row, outer_schema);
12498    }
12499    for (_, peer) in &mut stmt.unions {
12500        substitute_outer_in_select(peer, outer_row, outer_schema);
12501    }
12502}
12503
12504fn substitute_outer_in_expr(e: &mut Expr, outer_row: &Row, outer_schema: &[ColumnSchema]) {
12505    if let Expr::Column(c) = e
12506        && let Some(qual) = &c.qualifier
12507    {
12508        let composite = alloc::format!("{qual}.{}", c.name);
12509        if let Some(idx) = outer_schema
12510            .iter()
12511            .position(|sc| sc.name.eq_ignore_ascii_case(&composite))
12512        {
12513            let v = outer_row.values.get(idx).cloned().unwrap_or(Value::Null);
12514            if let Ok(lit) = value_to_literal_expr(v) {
12515                *e = lit;
12516                return;
12517            }
12518        }
12519    }
12520    match e {
12521        Expr::Binary { lhs, rhs, .. } => {
12522            substitute_outer_in_expr(lhs, outer_row, outer_schema);
12523            substitute_outer_in_expr(rhs, outer_row, outer_schema);
12524        }
12525        Expr::Unary { expr: inner, .. } => {
12526            substitute_outer_in_expr(inner, outer_row, outer_schema);
12527        }
12528        Expr::FunctionCall { args, .. } => {
12529            for a in args {
12530                substitute_outer_in_expr(a, outer_row, outer_schema);
12531            }
12532        }
12533        Expr::Cast { expr: inner, .. } => {
12534            substitute_outer_in_expr(inner, outer_row, outer_schema);
12535        }
12536        Expr::Case {
12537            operand,
12538            branches,
12539            else_branch,
12540        } => {
12541            if let Some(op) = operand {
12542                substitute_outer_in_expr(op, outer_row, outer_schema);
12543            }
12544            for (cond, val) in branches {
12545                substitute_outer_in_expr(cond, outer_row, outer_schema);
12546                substitute_outer_in_expr(val, outer_row, outer_schema);
12547            }
12548            if let Some(e) = else_branch {
12549                substitute_outer_in_expr(e, outer_row, outer_schema);
12550            }
12551        }
12552        _ => {}
12553    }
12554}
12555
12556fn substitute_outer_columns(stmt: &mut SelectStatement, row: &Row, ctx: &EvalContext<'_>) {
12557    // v7.24 (round-16 B) — joined outer contexts carry no single
12558    // table alias; their schemas use composite "alias.column" names
12559    // instead. Pass an unmatchable alias and let the composite
12560    // lookup in substitute_in_expr do the work (a correlated EXISTS
12561    // under a JOIN previously skipped substitution entirely and
12562    // died with "unknown table qualifier").
12563    let outer_alias = ctx.table_alias.unwrap_or("");
12564    substitute_in_select(stmt, row, ctx, outer_alias);
12565}
12566
12567fn substitute_in_select(
12568    stmt: &mut SelectStatement,
12569    row: &Row,
12570    ctx: &EvalContext<'_>,
12571    outer_alias: &str,
12572) {
12573    for item in &mut stmt.items {
12574        if let SelectItem::Expr { expr, .. } = item {
12575            substitute_in_expr(expr, row, ctx, outer_alias);
12576        }
12577    }
12578    if let Some(w) = &mut stmt.where_ {
12579        substitute_in_expr(w, row, ctx, outer_alias);
12580    }
12581    if let Some(gs) = &mut stmt.group_by {
12582        for g in gs {
12583            substitute_in_expr(g, row, ctx, outer_alias);
12584        }
12585    }
12586    if let Some(h) = &mut stmt.having {
12587        substitute_in_expr(h, row, ctx, outer_alias);
12588    }
12589    for o in &mut stmt.order_by {
12590        substitute_in_expr(&mut o.expr, row, ctx, outer_alias);
12591    }
12592    for (_, peer) in &mut stmt.unions {
12593        substitute_in_select(peer, row, ctx, outer_alias);
12594    }
12595}
12596
12597fn substitute_in_expr(e: &mut Expr, row: &Row, ctx: &EvalContext<'_>, outer_alias: &str) {
12598    // v7.25.2 (round-19 A) — bare synthetic columns. The aggregate
12599    // rewriter replaces group-key references INSIDE subquery bodies
12600    // with `__grp_N` so a correlated subquery in a GROUP BY select
12601    // list can resolve against the synthesised group row. The names
12602    // are engine-generated, so they can't shadow user columns.
12603    if let Expr::Column(c) = e
12604        && c.qualifier.is_none()
12605        && (c.name.starts_with("__grp_") || c.name.starts_with("__agg_"))
12606        && let Some(idx) = ctx.columns.iter().position(|sc| sc.name == c.name)
12607    {
12608        let v = row.values.get(idx).cloned().unwrap_or(Value::Null);
12609        if let Ok(lit) = value_to_literal_expr(v) {
12610            *e = lit;
12611            return;
12612        }
12613    }
12614    if let Expr::Column(c) = e
12615        && let Some(qual) = &c.qualifier
12616    {
12617        // Look up the column's index in the outer schema: plain name
12618        // when the qualifier is the outer table's alias, composite
12619        // "alias.column" for joined outer schemas (v7.24).
12620        let idx = if !outer_alias.is_empty() && qual.eq_ignore_ascii_case(outer_alias) {
12621            ctx.columns
12622                .iter()
12623                .position(|sc| sc.name.eq_ignore_ascii_case(&c.name))
12624        } else {
12625            None
12626        }
12627        .or_else(|| {
12628            let composite = alloc::format!("{qual}.{name}", name = c.name);
12629            ctx.columns
12630                .iter()
12631                .position(|sc| sc.name.eq_ignore_ascii_case(&composite))
12632        });
12633        if let Some(idx) = idx {
12634            let v = row.values.get(idx).cloned().unwrap_or(Value::Null);
12635            if let Ok(lit) = value_to_literal_expr(v) {
12636                *e = lit;
12637                return;
12638            }
12639        }
12640    }
12641    match e {
12642        Expr::AggregateOrdered { call, order_by, .. } => {
12643            substitute_in_expr(call, row, ctx, outer_alias);
12644            for o in order_by.iter_mut() {
12645                substitute_in_expr(&mut o.expr, row, ctx, outer_alias);
12646            }
12647        }
12648        Expr::Binary { lhs, rhs, .. } => {
12649            substitute_in_expr(lhs, row, ctx, outer_alias);
12650            substitute_in_expr(rhs, row, ctx, outer_alias);
12651        }
12652        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12653            substitute_in_expr(expr, row, ctx, outer_alias);
12654        }
12655        Expr::Like { expr, pattern, .. } => {
12656            substitute_in_expr(expr, row, ctx, outer_alias);
12657            substitute_in_expr(pattern, row, ctx, outer_alias);
12658        }
12659        Expr::FunctionCall { args, .. } => {
12660            for a in args {
12661                substitute_in_expr(a, row, ctx, outer_alias);
12662            }
12663        }
12664        Expr::Extract { source, .. } => substitute_in_expr(source, row, ctx, outer_alias),
12665        Expr::WindowFunction {
12666            args,
12667            partition_by,
12668            order_by,
12669            ..
12670        } => {
12671            for a in args {
12672                substitute_in_expr(a, row, ctx, outer_alias);
12673            }
12674            for p in partition_by {
12675                substitute_in_expr(p, row, ctx, outer_alias);
12676            }
12677            for (o, _, _) in order_by {
12678                substitute_in_expr(o, row, ctx, outer_alias);
12679            }
12680        }
12681        Expr::ScalarSubquery(s) => substitute_in_select(s, row, ctx, outer_alias),
12682        Expr::Exists { subquery, .. } | Expr::InSubquery { subquery, .. } => {
12683            substitute_in_select(subquery, row, ctx, outer_alias);
12684        }
12685        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => {}
12686        Expr::Array(items) => {
12687            for elem in items {
12688                substitute_in_expr(elem, row, ctx, outer_alias);
12689            }
12690        }
12691        Expr::ArraySubscript { target, index } => {
12692            substitute_in_expr(target, row, ctx, outer_alias);
12693            substitute_in_expr(index, row, ctx, outer_alias);
12694        }
12695        Expr::AnyAll { expr, array, .. } => {
12696            substitute_in_expr(expr, row, ctx, outer_alias);
12697            substitute_in_expr(array, row, ctx, outer_alias);
12698        }
12699        Expr::Case {
12700            operand,
12701            branches,
12702            else_branch,
12703        } => {
12704            if let Some(o) = operand {
12705                substitute_in_expr(o, row, ctx, outer_alias);
12706            }
12707            for (w, t) in branches {
12708                substitute_in_expr(w, row, ctx, outer_alias);
12709                substitute_in_expr(t, row, ctx, outer_alias);
12710            }
12711            if let Some(e) = else_branch {
12712                substitute_in_expr(e, row, ctx, outer_alias);
12713            }
12714        }
12715    }
12716}
12717
12718/// v4.22: encode a Row to a comparable byte key for UNION-DISTINCT
12719/// dedup inside the recursive iteration. Crude but deterministic
12720/// — Debug prints embed type discriminants so NULL ≠ "" ≠ 0.
12721fn encode_row_key(row: &Row) -> Vec<u8> {
12722    let mut out = Vec::new();
12723    for v in &row.values {
12724        let s = alloc::format!("{v:?}|");
12725        out.extend_from_slice(s.as_bytes());
12726    }
12727    out
12728}
12729
12730fn select_has_window(stmt: &SelectStatement) -> bool {
12731    for item in &stmt.items {
12732        if let SelectItem::Expr { expr, .. } = item
12733            && expr_has_window(expr)
12734        {
12735            return true;
12736        }
12737    }
12738    false
12739}
12740
12741fn expr_has_window(e: &Expr) -> bool {
12742    match e {
12743        Expr::WindowFunction { .. } => true,
12744        Expr::AggregateOrdered { call, order_by, .. } => {
12745            expr_has_window(call) || order_by.iter().any(|o| expr_has_window(&o.expr))
12746        }
12747        Expr::Binary { lhs, rhs, .. } => expr_has_window(lhs) || expr_has_window(rhs),
12748        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12749            expr_has_window(expr)
12750        }
12751        Expr::FunctionCall { args, .. } => args.iter().any(expr_has_window),
12752        Expr::Like { expr, pattern, .. } => expr_has_window(expr) || expr_has_window(pattern),
12753        Expr::Extract { source, .. } => expr_has_window(source),
12754        Expr::ScalarSubquery(_)
12755        | Expr::Exists { .. }
12756        | Expr::InSubquery { .. }
12757        | Expr::Literal(_)
12758        | Expr::Placeholder(_)
12759        | Expr::Column(_) => false,
12760        Expr::Array(items) => items.iter().any(expr_has_window),
12761        Expr::ArraySubscript { target, index } => expr_has_window(target) || expr_has_window(index),
12762        Expr::AnyAll { expr, array, .. } => expr_has_window(expr) || expr_has_window(array),
12763        Expr::Case {
12764            operand,
12765            branches,
12766            else_branch,
12767        } => {
12768            operand.as_deref().is_some_and(expr_has_window)
12769                || branches
12770                    .iter()
12771                    .any(|(w, t)| expr_has_window(w) || expr_has_window(t))
12772                || else_branch.as_deref().is_some_and(expr_has_window)
12773        }
12774    }
12775}
12776
12777fn collect_window_nodes(e: &Expr, out: &mut Vec<Expr>) {
12778    if let Expr::WindowFunction { .. } = e {
12779        // Deduplicate by structural equality on the expression
12780        // (cheap because window args + partition + order are
12781        // small). Without dedup we'd recompute identical windows
12782        // once per occurrence in the projection.
12783        if !out.iter().any(|x| x == e) {
12784            out.push(e.clone());
12785        }
12786        return;
12787    }
12788    match e {
12789        // Already handled by the early-return at the top.
12790        Expr::WindowFunction { .. } => unreachable!(),
12791        Expr::Binary { lhs, rhs, .. } => {
12792            collect_window_nodes(lhs, out);
12793            collect_window_nodes(rhs, out);
12794        }
12795        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12796            collect_window_nodes(expr, out);
12797        }
12798        Expr::FunctionCall { args, .. } => {
12799            for a in args {
12800                collect_window_nodes(a, out);
12801            }
12802        }
12803        Expr::Like { expr, pattern, .. } => {
12804            collect_window_nodes(expr, out);
12805            collect_window_nodes(pattern, out);
12806        }
12807        Expr::Extract { source, .. } => collect_window_nodes(source, out),
12808        _ => {}
12809    }
12810}
12811
12812fn rewrite_window_to_columns(e: &mut Expr, window_nodes: &[Expr]) {
12813    if let Expr::WindowFunction { .. } = e
12814        && let Some(idx) = window_nodes.iter().position(|w| w == e)
12815    {
12816        *e = Expr::Column(spg_sql::ast::ColumnName {
12817            qualifier: None,
12818            name: alloc::format!("__win_{idx}"),
12819        });
12820        return;
12821    }
12822    match e {
12823        Expr::Binary { lhs, rhs, .. } => {
12824            rewrite_window_to_columns(lhs, window_nodes);
12825            rewrite_window_to_columns(rhs, window_nodes);
12826        }
12827        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12828            rewrite_window_to_columns(expr, window_nodes);
12829        }
12830        Expr::FunctionCall { args, .. } => {
12831            for a in args {
12832                rewrite_window_to_columns(a, window_nodes);
12833            }
12834        }
12835        Expr::Like { expr, pattern, .. } => {
12836            rewrite_window_to_columns(expr, window_nodes);
12837            rewrite_window_to_columns(pattern, window_nodes);
12838        }
12839        Expr::Extract { source, .. } => rewrite_window_to_columns(source, window_nodes),
12840        _ => {}
12841    }
12842}
12843
12844/// Total order over partition-key tuples. NULL sorts as the
12845/// lowest value (matches the `<` partial order's NULL-last
12846/// behaviour with `INFINITY` flipped).
12847fn partition_key_cmp(a: &[Value], b: &[Value]) -> core::cmp::Ordering {
12848    for (x, y) in a.iter().zip(b.iter()) {
12849        let c = value_cmp(x, y);
12850        if c != core::cmp::Ordering::Equal {
12851            return c;
12852        }
12853    }
12854    a.len().cmp(&b.len())
12855}
12856
12857fn order_key_cmp(
12858    a: &[(Value, bool, Option<bool>)],
12859    b: &[(Value, bool, Option<bool>)],
12860) -> core::cmp::Ordering {
12861    // v7.24.1 — per-key DESC + effective NULLS placement (shared
12862    // contract with order_by_value_cmp).
12863    for ((va, desc, nf), (vb, _, _)) in a.iter().zip(b.iter()) {
12864        let c = order_by_value_cmp(*desc, *nf, va, vb);
12865        if c != core::cmp::Ordering::Equal {
12866            return c;
12867        }
12868    }
12869    a.len().cmp(&b.len())
12870}
12871
12872/// v7.17.0 Phase 3.10 — true when the Value is one of the
12873/// integer-shaped variants `generate_series` accepts as a start
12874/// / stop / step component. Float / NUMERIC are rejected — PG's
12875/// `generate_series(numeric, numeric)` overload is out of v7.17
12876/// scope.
12877const fn value_is_integer(v: &Value) -> bool {
12878    matches!(v, Value::SmallInt(_) | Value::Int(_) | Value::BigInt(_))
12879}
12880
12881/// v7.17.0 Phase 3.10 — widen any integer-shaped Value to i64 for
12882/// the generate_series iteration loop. Non-integer inputs panic;
12883/// caller guards via `value_is_integer`.
12884const fn value_to_i64(v: &Value) -> i64 {
12885    match v {
12886        Value::SmallInt(n) => *n as i64,
12887        Value::Int(n) => *n as i64,
12888        Value::BigInt(n) => *n,
12889        _ => panic!("value_to_i64 called on non-integer Value"),
12890    }
12891}
12892
12893/// v7.17.0 Phase 3.10 — integer-mode generate_series materialiser.
12894/// Step direction follows the sign: positive step iterates upward
12895/// (stops when current > stop); negative iterates downward; zero
12896/// errors. Caller-facing row stream is `BigInt`-typed so a single
12897/// projection schema covers SmallInt / Int / BigInt callers.
12898fn generate_series_integers(
12899    start: i64,
12900    stop: i64,
12901    step: i64,
12902    cancel: &CancelToken<'_>,
12903) -> Result<alloc::vec::Vec<Row>, EngineError> {
12904    if step == 0 {
12905        return Err(EngineError::Unsupported(
12906            "generate_series(): step argument cannot be zero".into(),
12907        ));
12908    }
12909    let mut out = alloc::vec::Vec::new();
12910    let mut cur = start;
12911    // Hard cap to keep a runaway call from eating all memory. PG
12912    // has no such cap but does honour query timeout; SPG's cancel
12913    // token will fire too — this is a defense-in-depth backstop.
12914    const MAX_ROWS: usize = 10_000_000;
12915    loop {
12916        cancel.check()?;
12917        if step > 0 && cur > stop {
12918            break;
12919        }
12920        if step < 0 && cur < stop {
12921            break;
12922        }
12923        out.push(Row::new(alloc::vec![Value::BigInt(cur)]));
12924        if out.len() > MAX_ROWS {
12925            return Err(EngineError::Unsupported(alloc::format!(
12926                "generate_series(): exceeded {MAX_ROWS} rows; \
12927                 narrow start/stop or use a larger step"
12928            )));
12929        }
12930        cur = match cur.checked_add(step) {
12931            Some(n) => n,
12932            None => break,
12933        };
12934    }
12935    Ok(out)
12936}
12937
12938/// v7.17.0 Phase 3.10 — timestamp-mode generate_series. step is a
12939/// `Value::Interval { months, micros }` per the caller's guard;
12940/// each iteration adds the interval via `apply_binary_interval`
12941/// so month-shifting handles short-month rollover (PG semantics).
12942fn generate_series_timestamps(
12943    start: i64,
12944    stop: i64,
12945    step: Value,
12946    cancel: &CancelToken<'_>,
12947) -> Result<alloc::vec::Vec<Row>, EngineError> {
12948    let (months, micros) = match &step {
12949        Value::Interval { months, micros } => (*months, *micros),
12950        _ => unreachable!("caller guards step.is_interval"),
12951    };
12952    if months == 0 && micros == 0 {
12953        return Err(EngineError::Unsupported(
12954            "generate_series(): INTERVAL step cannot be zero".into(),
12955        ));
12956    }
12957    let ascending = months > 0 || micros > 0;
12958    let mut out = alloc::vec::Vec::new();
12959    let mut cur = Value::Timestamp(start);
12960    const MAX_ROWS: usize = 10_000_000;
12961    loop {
12962        cancel.check()?;
12963        let cur_t = match cur {
12964            Value::Timestamp(t) => t,
12965            _ => unreachable!("loop invariant: cur is Timestamp"),
12966        };
12967        if ascending && cur_t > stop {
12968            break;
12969        }
12970        if !ascending && cur_t < stop {
12971            break;
12972        }
12973        out.push(Row::new(alloc::vec![Value::Timestamp(cur_t)]));
12974        if out.len() > MAX_ROWS {
12975            return Err(EngineError::Unsupported(alloc::format!(
12976                "generate_series(): exceeded {MAX_ROWS} rows; \
12977                 narrow start/stop or use a larger step"
12978            )));
12979        }
12980        let next = eval::apply_binary_interval(
12981            spg_sql::ast::BinOp::Add,
12982            &cur,
12983            &Value::Interval { months, micros },
12984        )
12985        .map_err(EngineError::Eval)?;
12986        cur = match next {
12987            Some(v) => v,
12988            None => break,
12989        };
12990    }
12991    Ok(out)
12992}
12993
12994#[allow(clippy::match_same_arms)] // explicit arms per type document the supported pairs
12995/// v7.24 (round-16 A) — per-key ORDER BY comparator honouring DESC
12996/// and the effective NULLS placement (explicit NULLS FIRST/LAST,
12997/// else the PG default: NULLS LAST for ASC, NULLS FIRST for DESC).
12998/// NULL placement is absolute — it does not flip with DESC.
12999pub(crate) fn order_by_value_cmp(
13000    desc: bool,
13001    nulls_first: Option<bool>,
13002    a: &Value,
13003    b: &Value,
13004) -> core::cmp::Ordering {
13005    use core::cmp::Ordering;
13006    let nf = nulls_first.unwrap_or(desc);
13007    match (matches!(a, Value::Null), matches!(b, Value::Null)) {
13008        (true, true) => Ordering::Equal,
13009        (true, false) => {
13010            if nf {
13011                Ordering::Less
13012            } else {
13013                Ordering::Greater
13014            }
13015        }
13016        (false, true) => {
13017            if nf {
13018                Ordering::Greater
13019            } else {
13020                Ordering::Less
13021            }
13022        }
13023        (false, false) => {
13024            let c = value_cmp(a, b);
13025            if desc { c.reverse() } else { c }
13026        }
13027    }
13028}
13029
13030fn value_cmp(a: &Value, b: &Value) -> core::cmp::Ordering {
13031    use core::cmp::Ordering;
13032    match (a, b) {
13033        (Value::Null, Value::Null) => Ordering::Equal,
13034        (Value::Null, _) => Ordering::Less,
13035        (_, Value::Null) => Ordering::Greater,
13036        (Value::Int(x), Value::Int(y)) => x.cmp(y),
13037        (Value::BigInt(x), Value::BigInt(y)) => x.cmp(y),
13038        (Value::SmallInt(x), Value::SmallInt(y)) => x.cmp(y),
13039        (Value::Text(x), Value::Text(y)) => x.cmp(y),
13040        (Value::Bool(x), Value::Bool(y)) => x.cmp(y),
13041        (Value::Float(x), Value::Float(y)) => x.partial_cmp(y).unwrap_or(Ordering::Equal),
13042        (Value::Date(x), Value::Date(y)) => x.cmp(y),
13043        (Value::Timestamp(x), Value::Timestamp(y)) => x.cmp(y),
13044        // Cross-type compare: fall back to the debug rendering —
13045        // same-partition is the goal, exact order is irrelevant.
13046        _ => alloc::format!("{a:?}").cmp(&alloc::format!("{b:?}")),
13047    }
13048}
13049
13050/// Compute the window function's per-row output for one partition.
13051/// `slice` has (partition key, order key, original-row-index)
13052/// tuples already sorted by order key. `filtered_rows` is the
13053/// full row list indexed by original-row-index. `out_vals` is
13054/// the destination, also indexed by original-row-index.
13055#[allow(
13056    clippy::too_many_arguments,
13057    clippy::cast_possible_truncation,
13058    clippy::cast_possible_wrap,
13059    clippy::cast_precision_loss,
13060    clippy::cast_sign_loss,
13061    clippy::doc_markdown,
13062    clippy::too_many_lines,
13063    clippy::type_complexity,
13064    clippy::match_same_arms
13065)]
13066fn compute_window_partition(
13067    name: &str,
13068    args: &[Expr],
13069    ordered: bool,
13070    frame: Option<&WindowFrame>,
13071    null_treatment: spg_sql::ast::NullTreatment,
13072    slice: &[(Vec<Value>, Vec<(Value, bool, Option<bool>)>, usize)],
13073    filtered_rows: &[&Row],
13074    ctx: &EvalContext<'_>,
13075    out_vals: &mut [Value],
13076) -> Result<(), EngineError> {
13077    let ignore_nulls = matches!(null_treatment, spg_sql::ast::NullTreatment::Ignore);
13078    let lower = name.to_ascii_lowercase();
13079    match lower.as_str() {
13080        "row_number" => {
13081            for (rank, (_, _, idx)) in slice.iter().enumerate() {
13082                out_vals[*idx] = Value::BigInt((rank + 1) as i64);
13083            }
13084            Ok(())
13085        }
13086        "rank" => {
13087            let mut prev_key: Option<&[(Value, bool, Option<bool>)]> = None;
13088            let mut current_rank: i64 = 1;
13089            for (i, (_, okey, idx)) in slice.iter().enumerate() {
13090                if let Some(p) = prev_key
13091                    && order_key_cmp(p, okey) != core::cmp::Ordering::Equal
13092                {
13093                    current_rank = (i + 1) as i64;
13094                }
13095                if prev_key.is_none() {
13096                    current_rank = 1;
13097                }
13098                out_vals[*idx] = Value::BigInt(current_rank);
13099                prev_key = Some(okey.as_slice());
13100            }
13101            Ok(())
13102        }
13103        "dense_rank" => {
13104            let mut prev_key: Option<&[(Value, bool, Option<bool>)]> = None;
13105            let mut current_rank: i64 = 0;
13106            for (_, okey, idx) in slice {
13107                if prev_key.is_none_or(|p| order_key_cmp(p, okey) != core::cmp::Ordering::Equal) {
13108                    current_rank += 1;
13109                }
13110                out_vals[*idx] = Value::BigInt(current_rank);
13111                prev_key = Some(okey.as_slice());
13112            }
13113            Ok(())
13114        }
13115        "sum" | "avg" | "min" | "max" | "count" | "count_star" => {
13116            // Pre-evaluate the function arg per row in the slice
13117            // (count_star has no arg).
13118            let arg_values: Vec<Value> = if lower == "count_star" || args.is_empty() {
13119                slice.iter().map(|_| Value::Null).collect()
13120            } else {
13121                slice
13122                    .iter()
13123                    .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
13124                    .collect::<Result<_, _>>()
13125                    .map_err(EngineError::Eval)?
13126            };
13127            // v4.20: pick the effective frame. Explicit frame
13128            // overrides the implicit default (running for ordered,
13129            // whole-partition for unordered).
13130            let eff = effective_frame(frame, ordered)?;
13131            #[allow(clippy::needless_range_loop)]
13132            for i in 0..slice.len() {
13133                let (lo, hi) = frame_bounds_for_row(&eff, i, slice);
13134                let mut sum: f64 = 0.0;
13135                let mut count: i64 = 0;
13136                let mut min_v: Option<f64> = None;
13137                let mut max_v: Option<f64> = None;
13138                let mut row_count: i64 = 0;
13139                if lo <= hi {
13140                    for j in lo..=hi {
13141                        let v = &arg_values[j];
13142                        match lower.as_str() {
13143                            "count_star" => row_count += 1,
13144                            "count" => {
13145                                if !v.is_null() {
13146                                    count += 1;
13147                                }
13148                            }
13149                            _ => {
13150                                if let Some(x) = value_to_f64(v) {
13151                                    sum += x;
13152                                    count += 1;
13153                                    min_v = Some(min_v.map_or(x, |m| m.min(x)));
13154                                    max_v = Some(max_v.map_or(x, |m| m.max(x)));
13155                                }
13156                            }
13157                        }
13158                    }
13159                }
13160                let value = match lower.as_str() {
13161                    "count_star" => Value::BigInt(row_count),
13162                    "count" => Value::BigInt(count),
13163                    "sum" => Value::Float(sum),
13164                    "avg" => {
13165                        if count == 0 {
13166                            Value::Null
13167                        } else {
13168                            Value::Float(sum / count as f64)
13169                        }
13170                    }
13171                    "min" => min_v.map_or(Value::Null, Value::Float),
13172                    "max" => max_v.map_or(Value::Null, Value::Float),
13173                    _ => unreachable!(),
13174                };
13175                let (_, _, idx) = &slice[i];
13176                out_vals[*idx] = value;
13177            }
13178            Ok(())
13179        }
13180        "lag" | "lead" => {
13181            // lag(expr [, offset [, default]])
13182            // lead(expr [, offset [, default]])
13183            if args.is_empty() {
13184                return Err(EngineError::Unsupported(alloc::format!(
13185                    "{lower}() requires at least one argument"
13186                )));
13187            }
13188            let offset: i64 = if args.len() >= 2 {
13189                let v = eval::eval_expr(&args[1], filtered_rows[slice[0].2], ctx)
13190                    .map_err(EngineError::Eval)?;
13191                match v {
13192                    Value::SmallInt(n) => i64::from(n),
13193                    Value::Int(n) => i64::from(n),
13194                    Value::BigInt(n) => n,
13195                    _ => {
13196                        return Err(EngineError::Unsupported(alloc::format!(
13197                            "{lower}() offset must be integer"
13198                        )));
13199                    }
13200                }
13201            } else {
13202                1
13203            };
13204            let default: Value = if args.len() >= 3 {
13205                eval::eval_expr(&args[2], filtered_rows[slice[0].2], ctx)
13206                    .map_err(EngineError::Eval)?
13207            } else {
13208                Value::Null
13209            };
13210            let values: Vec<Value> = slice
13211                .iter()
13212                .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
13213                .collect::<Result<_, _>>()
13214                .map_err(EngineError::Eval)?;
13215            let n = slice.len();
13216            for (i, (_, _, idx)) in slice.iter().enumerate() {
13217                let signed_offset = if lower == "lag" { -offset } else { offset };
13218                let v = if ignore_nulls {
13219                    // v6.4.2 — IGNORE NULLS: walk in the offset direction
13220                    // skipping NULL values; the `offset`-th non-NULL
13221                    // encountered is the result.
13222                    let step: i64 = if signed_offset >= 0 { 1 } else { -1 };
13223                    let needed: i64 = signed_offset.abs();
13224                    if needed == 0 {
13225                        values[i].clone()
13226                    } else {
13227                        let mut j: i64 = i as i64;
13228                        let mut hits: i64 = 0;
13229                        let mut found: Option<Value> = None;
13230                        loop {
13231                            j += step;
13232                            if j < 0 || j >= n as i64 {
13233                                break;
13234                            }
13235                            #[allow(clippy::cast_sign_loss)]
13236                            let v = &values[j as usize];
13237                            if !v.is_null() {
13238                                hits += 1;
13239                                if hits == needed {
13240                                    found = Some(v.clone());
13241                                    break;
13242                                }
13243                            }
13244                        }
13245                        found.unwrap_or_else(|| default.clone())
13246                    }
13247                } else {
13248                    let target_signed = i64::try_from(i).unwrap_or(i64::MAX) + signed_offset;
13249                    if target_signed < 0 || target_signed >= i64::try_from(n).unwrap_or(i64::MAX) {
13250                        default.clone()
13251                    } else {
13252                        #[allow(clippy::cast_sign_loss)]
13253                        {
13254                            values[target_signed as usize].clone()
13255                        }
13256                    }
13257                };
13258                out_vals[*idx] = v;
13259            }
13260            Ok(())
13261        }
13262        "first_value" | "last_value" | "nth_value" => {
13263            if args.is_empty() {
13264                return Err(EngineError::Unsupported(alloc::format!(
13265                    "{lower}() requires at least one argument"
13266                )));
13267            }
13268            let values: Vec<Value> = slice
13269                .iter()
13270                .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
13271                .collect::<Result<_, _>>()
13272                .map_err(EngineError::Eval)?;
13273            let nth: usize = if lower == "nth_value" {
13274                if args.len() < 2 {
13275                    return Err(EngineError::Unsupported(
13276                        "nth_value() requires (expr, n)".into(),
13277                    ));
13278                }
13279                let v = eval::eval_expr(&args[1], filtered_rows[slice[0].2], ctx)
13280                    .map_err(EngineError::Eval)?;
13281                let raw = match v {
13282                    Value::SmallInt(n) => i64::from(n),
13283                    Value::Int(n) => i64::from(n),
13284                    Value::BigInt(n) => n,
13285                    _ => {
13286                        return Err(EngineError::Unsupported(
13287                            "nth_value() n must be integer".into(),
13288                        ));
13289                    }
13290                };
13291                if raw < 1 {
13292                    return Err(EngineError::Unsupported(
13293                        "nth_value() n must be >= 1".into(),
13294                    ));
13295                }
13296                #[allow(clippy::cast_sign_loss)]
13297                {
13298                    raw as usize
13299                }
13300            } else {
13301                0
13302            };
13303            let eff = effective_frame(frame, ordered)?;
13304            for i in 0..slice.len() {
13305                let (lo, hi) = frame_bounds_for_row(&eff, i, slice);
13306                let (_, _, idx) = &slice[i];
13307                let v = if lo > hi {
13308                    Value::Null
13309                } else if ignore_nulls && matches!(lower.as_str(), "first_value" | "last_value") {
13310                    // v6.4.2 — IGNORE NULLS: skip NULL cells when
13311                    // selecting the boundary value within the frame.
13312                    if lower == "first_value" {
13313                        (lo..=hi)
13314                            .find_map(|j| {
13315                                let v = &values[j];
13316                                (!v.is_null()).then(|| v.clone())
13317                            })
13318                            .unwrap_or(Value::Null)
13319                    } else {
13320                        (lo..=hi)
13321                            .rev()
13322                            .find_map(|j| {
13323                                let v = &values[j];
13324                                (!v.is_null()).then(|| v.clone())
13325                            })
13326                            .unwrap_or(Value::Null)
13327                    }
13328                } else {
13329                    match lower.as_str() {
13330                        "first_value" => values[lo].clone(),
13331                        "last_value" => values[hi].clone(),
13332                        "nth_value" => {
13333                            let pos = lo + nth - 1;
13334                            if pos > hi {
13335                                Value::Null
13336                            } else {
13337                                values[pos].clone()
13338                            }
13339                        }
13340                        _ => unreachable!(),
13341                    }
13342                };
13343                out_vals[*idx] = v;
13344            }
13345            Ok(())
13346        }
13347        "ntile" => {
13348            if args.is_empty() {
13349                return Err(EngineError::Unsupported(
13350                    "ntile(n) requires an integer argument".into(),
13351                ));
13352            }
13353            let v = eval::eval_expr(&args[0], filtered_rows[slice[0].2], ctx)
13354                .map_err(EngineError::Eval)?;
13355            let bucket_count: i64 = match v {
13356                Value::SmallInt(n) => i64::from(n),
13357                Value::Int(n) => i64::from(n),
13358                Value::BigInt(n) => n,
13359                _ => {
13360                    return Err(EngineError::Unsupported(
13361                        "ntile() argument must be integer".into(),
13362                    ));
13363                }
13364            };
13365            if bucket_count < 1 {
13366                return Err(EngineError::Unsupported(
13367                    "ntile() argument must be >= 1".into(),
13368                ));
13369            }
13370            #[allow(clippy::cast_sign_loss)]
13371            let buckets = bucket_count as usize;
13372            let n = slice.len();
13373            // Each bucket gets `base` rows; the first `extras` buckets
13374            // get one extra. PG semantics.
13375            let base = n / buckets;
13376            let extras = n % buckets;
13377            let mut bucket: usize = 1;
13378            let mut remaining_in_bucket = if extras > 0 { base + 1 } else { base };
13379            let mut buckets_with_extra_remaining = extras;
13380            for (_, _, idx) in slice {
13381                if remaining_in_bucket == 0 {
13382                    bucket += 1;
13383                    buckets_with_extra_remaining = buckets_with_extra_remaining.saturating_sub(1);
13384                    remaining_in_bucket = if buckets_with_extra_remaining > 0 {
13385                        base + 1
13386                    } else {
13387                        base
13388                    };
13389                    // Edge: if base==0 and extras==0, all rows fit;
13390                    // shouldn't reach here, but guard anyway.
13391                    if remaining_in_bucket == 0 {
13392                        remaining_in_bucket = 1;
13393                    }
13394                }
13395                out_vals[*idx] = Value::BigInt(i64::try_from(bucket).unwrap_or(i64::MAX));
13396                remaining_in_bucket -= 1;
13397            }
13398            Ok(())
13399        }
13400        "percent_rank" => {
13401            // (rank - 1) / (n - 1) where rank is the standard RANK().
13402            // Single-row partitions get 0.
13403            let n = slice.len();
13404            let mut prev_key: Option<&[(Value, bool, Option<bool>)]> = None;
13405            let mut current_rank: i64 = 1;
13406            for (i, (_, okey, idx)) in slice.iter().enumerate() {
13407                if let Some(p) = prev_key
13408                    && order_key_cmp(p, okey) != core::cmp::Ordering::Equal
13409                {
13410                    current_rank = i64::try_from(i + 1).unwrap_or(i64::MAX);
13411                }
13412                if prev_key.is_none() {
13413                    current_rank = 1;
13414                }
13415                #[allow(clippy::cast_precision_loss)]
13416                let pr = if n <= 1 {
13417                    0.0
13418                } else {
13419                    (current_rank - 1) as f64 / (n - 1) as f64
13420                };
13421                out_vals[*idx] = Value::Float(pr);
13422                prev_key = Some(okey.as_slice());
13423            }
13424            Ok(())
13425        }
13426        "cume_dist" => {
13427            // # rows up to and including this row's peer group / n.
13428            let n = slice.len();
13429            // First pass: find peer-group-end rank for each row.
13430            for i in 0..slice.len() {
13431                let peer_end = peer_group_end(slice, i);
13432                #[allow(clippy::cast_precision_loss)]
13433                let cd = (peer_end + 1) as f64 / n as f64;
13434                let (_, _, idx) = &slice[i];
13435                out_vals[*idx] = Value::Float(cd);
13436            }
13437            Ok(())
13438        }
13439        other => Err(EngineError::Unsupported(alloc::format!(
13440            "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)"
13441        ))),
13442    }
13443}
13444
13445/// v4.20: resolve the user-provided frame down to a normalised
13446/// `(kind, start, end)`. `None` means default — derive from
13447/// `ordered`: ordered ⇒ RANGE UNBOUNDED PRECEDING AND CURRENT ROW,
13448/// unordered ⇒ ROWS UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
13449/// Single-bound shorthand (e.g. `ROWS 5 PRECEDING`) normalises
13450/// end → CURRENT ROW per the PG spec.
13451fn effective_frame(
13452    frame: Option<&WindowFrame>,
13453    ordered: bool,
13454) -> Result<(FrameKind, FrameBound, FrameBound), EngineError> {
13455    match frame {
13456        None => {
13457            if ordered {
13458                Ok((
13459                    FrameKind::Range,
13460                    FrameBound::UnboundedPreceding,
13461                    FrameBound::CurrentRow,
13462                ))
13463            } else {
13464                Ok((
13465                    FrameKind::Rows,
13466                    FrameBound::UnboundedPreceding,
13467                    FrameBound::UnboundedFollowing,
13468                ))
13469            }
13470        }
13471        Some(fr) => {
13472            let end = fr.end.clone().unwrap_or(FrameBound::CurrentRow);
13473            // Reject start > end (a few impossible combinations).
13474            if matches!(fr.start, FrameBound::UnboundedFollowing)
13475                || matches!(end, FrameBound::UnboundedPreceding)
13476            {
13477                return Err(EngineError::Unsupported(alloc::format!(
13478                    "invalid frame: start={:?} end={:?}",
13479                    fr.start,
13480                    end
13481                )));
13482            }
13483            // RANGE OFFSET PRECEDING / FOLLOWING needs value-typed
13484            // arithmetic on the ORDER BY key (e.g. `RANGE BETWEEN
13485            // INTERVAL '1 day' PRECEDING AND CURRENT ROW`). Not
13486            // implemented in v4.20.
13487            if fr.kind == FrameKind::Range
13488                && (matches!(
13489                    fr.start,
13490                    FrameBound::OffsetPreceding(_) | FrameBound::OffsetFollowing(_)
13491                ) || matches!(
13492                    end,
13493                    FrameBound::OffsetPreceding(_) | FrameBound::OffsetFollowing(_)
13494                ))
13495            {
13496                return Err(EngineError::Unsupported(
13497                    "RANGE with explicit offset bounds is not supported (v4.20: only UNBOUNDED / CURRENT ROW for RANGE)".into(),
13498                ));
13499            }
13500            Ok((fr.kind, fr.start.clone(), end))
13501        }
13502    }
13503}
13504
13505/// Compute `(lo, hi)` row-index bounds inside the partition slice
13506/// for the row at position `i`. Inclusive, clamped to
13507/// `[0, slice.len()-1]`. Empty result if `lo > hi`.
13508#[allow(clippy::type_complexity)]
13509fn frame_bounds_for_row(
13510    eff: &(FrameKind, FrameBound, FrameBound),
13511    i: usize,
13512    slice: &[(Vec<Value>, Vec<(Value, bool, Option<bool>)>, usize)],
13513) -> (usize, usize) {
13514    let (kind, start, end) = eff;
13515    let n = slice.len();
13516    let last = n.saturating_sub(1);
13517    let (mut lo, mut hi) = match kind {
13518        FrameKind::Rows => {
13519            let lo = match start {
13520                FrameBound::UnboundedPreceding => 0,
13521                FrameBound::OffsetPreceding(k) => {
13522                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
13523                    i.saturating_sub(k)
13524                }
13525                FrameBound::CurrentRow => i,
13526                FrameBound::OffsetFollowing(k) => {
13527                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
13528                    i.saturating_add(k).min(last)
13529                }
13530                FrameBound::UnboundedFollowing => last,
13531            };
13532            let hi = match end {
13533                FrameBound::UnboundedPreceding => 0,
13534                FrameBound::OffsetPreceding(k) => {
13535                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
13536                    i.saturating_sub(k)
13537                }
13538                FrameBound::CurrentRow => i,
13539                FrameBound::OffsetFollowing(k) => {
13540                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
13541                    i.saturating_add(k).min(last)
13542                }
13543                FrameBound::UnboundedFollowing => last,
13544            };
13545            (lo, hi)
13546        }
13547        FrameKind::Range => {
13548            // RANGE bounds are peer-aware. With only UNBOUNDED and
13549            // CURRENT ROW supported (rejected at effective_frame for
13550            // explicit offsets), the start/end map to the
13551            // partition's full extent at the same-order-key peer
13552            // group boundary.
13553            let lo = match start {
13554                FrameBound::UnboundedPreceding => 0,
13555                FrameBound::CurrentRow => peer_group_start(slice, i),
13556                FrameBound::UnboundedFollowing => last,
13557                _ => unreachable!("offset bounds rejected for RANGE"),
13558            };
13559            let hi = match end {
13560                FrameBound::UnboundedPreceding => 0,
13561                FrameBound::CurrentRow => peer_group_end(slice, i),
13562                FrameBound::UnboundedFollowing => last,
13563                _ => unreachable!("offset bounds rejected for RANGE"),
13564            };
13565            (lo, hi)
13566        }
13567    };
13568    if hi >= n {
13569        hi = last;
13570    }
13571    if lo >= n {
13572        lo = last;
13573    }
13574    (lo, hi)
13575}
13576
13577/// Find the inclusive index of the first row with the same ORDER
13578/// BY key as `slice[i]`. Slice is already sorted by partition then
13579/// order, so peers are contiguous.
13580#[allow(clippy::type_complexity)]
13581fn peer_group_start(
13582    slice: &[(Vec<Value>, Vec<(Value, bool, Option<bool>)>, usize)],
13583    i: usize,
13584) -> usize {
13585    let key = &slice[i].1;
13586    let mut j = i;
13587    while j > 0 && order_key_cmp(&slice[j - 1].1, key) == core::cmp::Ordering::Equal {
13588        j -= 1;
13589    }
13590    j
13591}
13592
13593/// Find the inclusive index of the last row with the same ORDER
13594/// BY key as `slice[i]`.
13595#[allow(clippy::type_complexity)]
13596fn peer_group_end(
13597    slice: &[(Vec<Value>, Vec<(Value, bool, Option<bool>)>, usize)],
13598    i: usize,
13599) -> usize {
13600    let key = &slice[i].1;
13601    let mut j = i;
13602    while j + 1 < slice.len() && order_key_cmp(&slice[j + 1].1, key) == core::cmp::Ordering::Equal {
13603        j += 1;
13604    }
13605    j
13606}
13607
13608fn value_to_f64(v: &Value) -> Option<f64> {
13609    match v {
13610        Value::SmallInt(n) => Some(f64::from(*n)),
13611        Value::Int(n) => Some(f64::from(*n)),
13612        #[allow(clippy::cast_precision_loss)]
13613        Value::BigInt(n) => Some(*n as f64),
13614        Value::Float(x) => Some(*x),
13615        _ => None,
13616    }
13617}
13618
13619/// Quick scan for any subquery-bearing node in a SELECT's WHERE /
13620/// projection / `order_by` — saves cloning the AST when there are
13621/// none (the common case).
13622fn expr_tree_has_subquery(stmt: &SelectStatement) -> bool {
13623    let mut any = false;
13624    for item in &stmt.items {
13625        if let SelectItem::Expr { expr, .. } = item {
13626            any = any || expr_has_subquery(expr);
13627        }
13628    }
13629    if let Some(w) = &stmt.where_ {
13630        any = any || expr_has_subquery(w);
13631    }
13632    if let Some(h) = &stmt.having {
13633        any = any || expr_has_subquery(h);
13634    }
13635    for o in &stmt.order_by {
13636        any = any || expr_has_subquery(&o.expr);
13637    }
13638    for (_, peer) in &stmt.unions {
13639        any = any || expr_tree_has_subquery(peer);
13640    }
13641    any
13642}
13643
13644pub(crate) fn expr_has_subquery(e: &Expr) -> bool {
13645    match e {
13646        Expr::ScalarSubquery(_) | Expr::Exists { .. } | Expr::InSubquery { .. } => true,
13647        Expr::AggregateOrdered { call, order_by, .. } => {
13648            expr_has_subquery(call) || order_by.iter().any(|o| expr_has_subquery(&o.expr))
13649        }
13650        Expr::Binary { lhs, rhs, .. } => expr_has_subquery(lhs) || expr_has_subquery(rhs),
13651        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13652            expr_has_subquery(expr)
13653        }
13654        Expr::FunctionCall { args, .. } => args.iter().any(expr_has_subquery),
13655        Expr::Like { expr, pattern, .. } => expr_has_subquery(expr) || expr_has_subquery(pattern),
13656        Expr::Extract { source, .. } => expr_has_subquery(source),
13657        Expr::WindowFunction {
13658            args,
13659            partition_by,
13660            order_by,
13661            ..
13662        } => {
13663            args.iter().any(expr_has_subquery)
13664                || partition_by.iter().any(expr_has_subquery)
13665                || order_by.iter().any(|(e, _, _)| expr_has_subquery(e))
13666        }
13667        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => false,
13668        Expr::Array(items) => items.iter().any(expr_has_subquery),
13669        Expr::ArraySubscript { target, index } => {
13670            expr_has_subquery(target) || expr_has_subquery(index)
13671        }
13672        Expr::AnyAll { expr, array, .. } => expr_has_subquery(expr) || expr_has_subquery(array),
13673        Expr::Case {
13674            operand,
13675            branches,
13676            else_branch,
13677        } => {
13678            operand.as_deref().is_some_and(expr_has_subquery)
13679                || branches
13680                    .iter()
13681                    .any(|(w, t)| expr_has_subquery(w) || expr_has_subquery(t))
13682                || else_branch.as_deref().is_some_and(expr_has_subquery)
13683        }
13684    }
13685}
13686
13687/// v4.10 helper: materialise a runtime `Value` back into an AST
13688/// `Expr::Literal` for the subquery-rewrite path. Supports the
13689/// types `Literal` can represent (Integer / Float / Text / Bool /
13690/// Null). Date / Timestamp / Numeric / Vector / Interval / JSON
13691/// would lose precision through Literal and aren't supported in
13692/// uncorrelated-subquery results; they error with a clear hint.
13693fn value_to_literal_expr(v: Value) -> Result<Expr, EngineError> {
13694    let lit = match v {
13695        Value::Null => Literal::Null,
13696        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
13697        Value::Int(n) => Literal::Integer(i64::from(n)),
13698        Value::BigInt(n) => Literal::Integer(n),
13699        Value::Float(x) => Literal::Float(x),
13700        Value::Text(s) | Value::Json(s) => Literal::String(s),
13701        Value::Bool(b) => Literal::Bool(b),
13702        other => {
13703            return Err(EngineError::Unsupported(alloc::format!(
13704                "subquery result type {:?} not yet materialisable; cast to text or integer in the inner SELECT",
13705                other.data_type()
13706            )));
13707        }
13708    };
13709    Ok(Expr::Literal(lit))
13710}
13711
13712/// v7.13.0 — wider helper used by `INSERT … SELECT` (mailrs
13713/// round-5 G4). Covers the most common `Value` variants. Types
13714/// that need lossy textual round-trip (BYTEA, arrays, ts*)
13715/// surface as an Unsupported error so the caller can add a cast
13716/// in the inner SELECT.
13717fn value_to_literal_expr_permissive(v: Value) -> Result<Expr, EngineError> {
13718    let lit = match v {
13719        Value::Null => Literal::Null,
13720        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
13721        Value::Int(n) => Literal::Integer(i64::from(n)),
13722        Value::BigInt(n) => Literal::Integer(n),
13723        Value::Float(x) => Literal::Float(x),
13724        Value::Text(s) | Value::Json(s) => Literal::String(s),
13725        Value::Bool(b) => Literal::Bool(b),
13726        Value::Vector(xs) => Literal::Vector(xs),
13727        // Date / Timestamp / Timestamptz / Numeric round-trip
13728        // through a TEXT literal that `coerce_value` re-parses
13729        // against the target column type.
13730        Value::Date(days) => {
13731            let micros = (i64::from(days)) * 86_400_000_000;
13732            Literal::String(format_timestamp_micros_as_date(micros))
13733        }
13734        Value::Timestamp(us) => Literal::String(format_timestamp_micros(us)),
13735        Value::Numeric { scaled, scale } => Literal::String(format_numeric(scaled, scale)),
13736        other => {
13737            return Err(EngineError::Unsupported(alloc::format!(
13738                "INSERT … SELECT cannot materialise value of type {:?}; \
13739                 add an explicit CAST in the inner SELECT",
13740                other.data_type()
13741            )));
13742        }
13743    };
13744    Ok(Expr::Literal(lit))
13745}
13746
13747fn format_timestamp_micros(us: i64) -> String {
13748    // Same Y/M/D split used by the wire layer; epoch-relative.
13749    let days = us.div_euclid(86_400_000_000);
13750    let intra_day = us.rem_euclid(86_400_000_000);
13751    let date = format_timestamp_micros_as_date(days * 86_400_000_000);
13752    let secs = intra_day / 1_000_000;
13753    let us_rem = intra_day % 1_000_000;
13754    let h = (secs / 3600) % 24;
13755    let m = (secs / 60) % 60;
13756    let s = secs % 60;
13757    if us_rem == 0 {
13758        alloc::format!("{date} {h:02}:{m:02}:{s:02}")
13759    } else {
13760        alloc::format!("{date} {h:02}:{m:02}:{s:02}.{us_rem:06}")
13761    }
13762}
13763
13764fn format_timestamp_micros_as_date(us: i64) -> String {
13765    // Days since 1970-01-01 → calendar Y-M-D via the proleptic
13766    // Gregorian conversion used by spg-engine's date helpers.
13767    let days = us.div_euclid(86_400_000_000);
13768    // 1970-01-01 = JDN 2440588.
13769    let jdn = days + 2_440_588;
13770    let (y, mo, d) = jdn_to_ymd(jdn);
13771    alloc::format!("{y:04}-{mo:02}-{d:02}")
13772}
13773
13774fn jdn_to_ymd(jdn: i64) -> (i64, u32, u32) {
13775    // Fliegel & Van Flandern (1968) — works for all positive JDNs.
13776    let l = jdn + 68569;
13777    let n = (4 * l) / 146_097;
13778    let l = l - (146_097 * n + 3) / 4;
13779    let i = (4000 * (l + 1)) / 1_461_001;
13780    let l = l - (1461 * i) / 4 + 31;
13781    let j = (80 * l) / 2447;
13782    let day = (l - (2447 * j) / 80) as u32;
13783    let l = j / 11;
13784    let month = (j + 2 - 12 * l) as u32;
13785    let year = 100 * (n - 49) + i + l;
13786    (year, month, day)
13787}
13788
13789fn format_numeric(scaled: i128, scale: u8) -> String {
13790    if scale == 0 {
13791        return alloc::format!("{scaled}");
13792    }
13793    let abs = scaled.unsigned_abs();
13794    let divisor = 10u128.pow(u32::from(scale));
13795    let whole = abs / divisor;
13796    let frac = abs % divisor;
13797    let sign = if scaled < 0 { "-" } else { "" };
13798    alloc::format!("{sign}{whole}.{frac:0width$}", width = usize::from(scale))
13799}
13800
13801/// v6.1.1 — walk the prepared `Statement` AST and replace every
13802/// `Expr::Placeholder(n)` with `Expr::Literal(value_to_literal(
13803/// params[n-1]))`. The dispatch downstream sees a `Statement`
13804/// indistinguishable from a simple-query parse, so the exec path
13805/// stays unchanged.
13806///
13807/// Errors fall into one shape: a `$N` references past the bound
13808/// `params.len()`. Out-of-range happens when the Bind didn't
13809/// supply enough values; pgwire surfaces this as a protocol error
13810/// to the client.
13811/// v7.15.0 — rewrite every (potentially-qualified) column
13812/// identifier matching `old` to `new` in a stored SQL source
13813/// string. Used by `ALTER TABLE … RENAME COLUMN` to patch
13814/// CHECK predicate sources, partial-index predicate sources,
13815/// and runtime DEFAULT expression sources before they get
13816/// re-parsed on the next INSERT/UPDATE.
13817///
13818/// Round-trips through the parser, so the rewritten output is
13819/// the canonical Display form (matches what the engine stores
13820/// for fresh predicates). If the source doesn't parse, surfaces
13821/// the parse error — the invariant that stored predicates are
13822/// in canonical Display form means a parse failure here is a
13823/// real bug, not a user mistake to swallow.
13824fn rewrite_column_in_source(
13825    src: &str,
13826    old: &str,
13827    new: &str,
13828) -> Result<alloc::string::String, EngineError> {
13829    let mut expr = spg_sql::parser::parse_expression(src).map_err(|e| {
13830        EngineError::Unsupported(alloc::format!(
13831            "ALTER TABLE RENAME COLUMN: stored predicate source {src:?} \
13832             failed to parse for rewrite ({e})"
13833        ))
13834    })?;
13835    rewrite_column_in_expr(&mut expr, old, new);
13836    Ok(alloc::format!("{expr}"))
13837}
13838
13839/// v7.15.0 — Expr walker that swaps `Expr::Column { name: old, .. }`
13840/// for `Expr::Column { name: new, .. }`. Qualifier is preserved
13841/// (e.g. `t.old` → `t.new`); a foreign-table qualifier still
13842/// gets rewritten because the AST has no way to tell us this
13843/// predicate is on table T versus table T2 — predicate sources
13844/// in SPG are always scoped to the owning table, so any
13845/// qualifier present is either redundant or wrong.
13846fn rewrite_column_in_expr(e: &mut Expr, old: &str, new: &str) {
13847    match e {
13848        Expr::AggregateOrdered { call, order_by, .. } => {
13849            rewrite_column_in_expr(call, old, new);
13850            for o in order_by.iter_mut() {
13851                rewrite_column_in_expr(&mut o.expr, old, new);
13852            }
13853        }
13854        Expr::Column(c) => {
13855            if c.name.eq_ignore_ascii_case(old) {
13856                c.name = new.to_string();
13857            }
13858        }
13859        Expr::Binary { lhs, rhs, .. } => {
13860            rewrite_column_in_expr(lhs, old, new);
13861            rewrite_column_in_expr(rhs, old, new);
13862        }
13863        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13864            rewrite_column_in_expr(expr, old, new);
13865        }
13866        Expr::FunctionCall { args, .. } => {
13867            for a in args {
13868                rewrite_column_in_expr(a, old, new);
13869            }
13870        }
13871        Expr::Like { expr, pattern, .. } => {
13872            rewrite_column_in_expr(expr, old, new);
13873            rewrite_column_in_expr(pattern, old, new);
13874        }
13875        Expr::Extract { source, .. } => rewrite_column_in_expr(source, old, new),
13876        Expr::WindowFunction {
13877            args,
13878            partition_by,
13879            order_by,
13880            ..
13881        } => {
13882            for a in args {
13883                rewrite_column_in_expr(a, old, new);
13884            }
13885            for p in partition_by {
13886                rewrite_column_in_expr(p, old, new);
13887            }
13888            for (o, _, _) in order_by {
13889                rewrite_column_in_expr(o, old, new);
13890            }
13891        }
13892        Expr::Array(items) => {
13893            for elem in items {
13894                rewrite_column_in_expr(elem, old, new);
13895            }
13896        }
13897        Expr::ArraySubscript { target, index } => {
13898            rewrite_column_in_expr(target, old, new);
13899            rewrite_column_in_expr(index, old, new);
13900        }
13901        Expr::AnyAll { expr, array, .. } => {
13902            rewrite_column_in_expr(expr, old, new);
13903            rewrite_column_in_expr(array, old, new);
13904        }
13905        Expr::Case {
13906            operand,
13907            branches,
13908            else_branch,
13909        } => {
13910            if let Some(o) = operand {
13911                rewrite_column_in_expr(o, old, new);
13912            }
13913            for (w, t) in branches {
13914                rewrite_column_in_expr(w, old, new);
13915                rewrite_column_in_expr(t, old, new);
13916            }
13917            if let Some(e) = else_branch {
13918                rewrite_column_in_expr(e, old, new);
13919            }
13920        }
13921        // Stored predicate sources never contain subqueries —
13922        // CHECK / partial-index / runtime_default are all scalar.
13923        // If a future feature changes that, recurse here.
13924        Expr::ScalarSubquery(_) | Expr::Exists { .. } | Expr::InSubquery { .. } => {}
13925        Expr::Literal(_) | Expr::Placeholder(_) => {}
13926    }
13927}
13928
13929/// v7.16.0 — walks a parsed statement and replaces every
13930/// `Expr::Placeholder(N)` with the corresponding `params[N-1]`
13931/// re-encoded as an `Expr::Literal`. Used internally by
13932/// `Engine::execute_prepared` AND surfaced for the spg-embedded
13933/// WAL path (which needs the bind-final AST so replay sees a
13934/// simple-query-shaped statement, not a `$1`-shaped one). Errors
13935/// when a placeholder references an index past the params slice.
13936pub fn substitute_placeholders(stmt: &mut Statement, params: &[Value]) -> Result<(), EngineError> {
13937    match stmt {
13938        Statement::Select(s) => substitute_select(s, params)?,
13939        Statement::Insert(ins) => {
13940            for row in &mut ins.rows {
13941                for e in row {
13942                    substitute_expr(e, params)?;
13943                }
13944            }
13945            // ON CONFLICT DO UPDATE assignments / WHERE can carry
13946            // placeholders too (`… DO UPDATE SET reason = $2` —
13947            // mailrs embed round-12).
13948            if let Some(clause) = &mut ins.on_conflict
13949                && let spg_sql::ast::OnConflictAction::Update {
13950                    assignments,
13951                    where_,
13952                } = &mut clause.action
13953            {
13954                for (_, e) in assignments.iter_mut() {
13955                    substitute_expr(e, params)?;
13956                }
13957                if let Some(w) = where_ {
13958                    substitute_expr(w, params)?;
13959                }
13960            }
13961        }
13962        Statement::Update(u) => {
13963            for (_, e) in &mut u.assignments {
13964                substitute_expr(e, params)?;
13965            }
13966            if let Some(w) = &mut u.where_ {
13967                substitute_expr(w, params)?;
13968            }
13969        }
13970        Statement::Delete(d) => {
13971            if let Some(w) = &mut d.where_ {
13972                substitute_expr(w, params)?;
13973            }
13974        }
13975        Statement::Explain(e) => substitute_select(&mut e.inner, params)?,
13976        // Other statements (CREATE / BEGIN / SHOW / …) have no
13977        // expression slots; no walk needed.
13978        _ => {}
13979    }
13980    Ok(())
13981}
13982
13983/// v7.25.1 (mailrs round-18) — THE canonical mutable traversal of
13984/// every expression slot in a SelectStatement, including every
13985/// nested SelectStatement (CTE bodies, UNION peers, LATERAL derived
13986/// tables) and the JOIN ON conditions. Round-12 #7b and round-18
13987/// were both "a hand-rolled Select walker forgot one subtree";
13988/// every whole-statement rewrite pass (placeholders, clock) must go
13989/// through here so a new AST slot only needs adding once.
13990/// Expression-INTERNAL recursion (into subquery nodes inside an
13991/// Expr) stays the visitor's own responsibility.
13992pub(crate) fn walk_select_exprs_mut(
13993    s: &mut SelectStatement,
13994    f: &mut impl FnMut(&mut Expr) -> Result<(), EngineError>,
13995) -> Result<(), EngineError> {
13996    for cte in &mut s.ctes {
13997        walk_select_exprs_mut(&mut cte.body, f)?;
13998    }
13999    for item in &mut s.items {
14000        if let SelectItem::Expr { expr, .. } = item {
14001            f(expr)?;
14002        }
14003    }
14004    if let Some(from) = &mut s.from {
14005        if let Some(sub) = &mut from.primary.lateral_subquery {
14006            walk_select_exprs_mut(sub, f)?;
14007        }
14008        for j in &mut from.joins {
14009            if let Some(sub) = &mut j.table.lateral_subquery {
14010                walk_select_exprs_mut(sub, f)?;
14011            }
14012            if let Some(on) = &mut j.on {
14013                f(on)?;
14014            }
14015        }
14016    }
14017    if let Some(w) = &mut s.where_ {
14018        f(w)?;
14019    }
14020    if let Some(gs) = &mut s.group_by {
14021        for g in gs {
14022            f(g)?;
14023        }
14024    }
14025    if let Some(h) = &mut s.having {
14026        f(h)?;
14027    }
14028    for o in &mut s.order_by {
14029        f(&mut o.expr)?;
14030    }
14031    for (_, peer) in &mut s.unions {
14032        walk_select_exprs_mut(peer, f)?;
14033    }
14034    Ok(())
14035}
14036
14037fn substitute_select(s: &mut SelectStatement, params: &[Value]) -> Result<(), EngineError> {
14038    walk_select_exprs_mut(s, &mut |e| substitute_expr(e, params))?;
14039    // v7.25.1 — LIMIT/OFFSET placeholders inside CTE bodies and
14040    // UNION peers resolve through their own recursion (the walker
14041    // above only visits Expr slots), so handle them per nested
14042    // statement here.
14043    for cte in &mut s.ctes {
14044        resolve_limit_offset_placeholders(&mut cte.body, params)?;
14045    }
14046    for (_, peer) in &mut s.unions {
14047        resolve_limit_offset_placeholders(peer, params)?;
14048    }
14049    // v7.9.24 — LIMIT $N / OFFSET $N placeholder resolution.
14050    // mailrs H2. After this pass each LIMIT/OFFSET that was a
14051    // Placeholder is rewritten to Literal so the existing
14052    // `LimitExpr::as_literal` path consumes a concrete u32.
14053    if let Some(le) = s.limit {
14054        s.limit = Some(resolve_limit_placeholder(le, params)?);
14055    }
14056    if let Some(le) = s.offset {
14057        s.offset = Some(resolve_limit_placeholder(le, params)?);
14058    }
14059    Ok(())
14060}
14061
14062/// v7.25.1 — recursive LIMIT/OFFSET placeholder resolution for
14063/// nested statements (CTE bodies / UNION peers).
14064fn resolve_limit_offset_placeholders(
14065    s: &mut SelectStatement,
14066    params: &[Value],
14067) -> Result<(), EngineError> {
14068    if let Some(le) = s.limit {
14069        s.limit = Some(resolve_limit_placeholder(le, params)?);
14070    }
14071    if let Some(le) = s.offset {
14072        s.offset = Some(resolve_limit_placeholder(le, params)?);
14073    }
14074    for cte in &mut s.ctes {
14075        resolve_limit_offset_placeholders(&mut cte.body, params)?;
14076    }
14077    for (_, peer) in &mut s.unions {
14078        resolve_limit_offset_placeholders(peer, params)?;
14079    }
14080    Ok(())
14081}
14082
14083fn resolve_limit_placeholder(
14084    le: spg_sql::ast::LimitExpr,
14085    params: &[Value],
14086) -> Result<spg_sql::ast::LimitExpr, EngineError> {
14087    use spg_sql::ast::LimitExpr;
14088    match le {
14089        LimitExpr::Literal(_) => Ok(le),
14090        LimitExpr::Placeholder(n) => {
14091            let idx = usize::from(n).saturating_sub(1);
14092            let v = params.get(idx).ok_or_else(|| {
14093                EngineError::Eval(EvalError::PlaceholderOutOfRange {
14094                    n,
14095                    bound: u16::try_from(params.len()).unwrap_or(u16::MAX),
14096                })
14097            })?;
14098            let int = match v {
14099                Value::SmallInt(x) => Some(i64::from(*x)),
14100                Value::Int(x) => Some(i64::from(*x)),
14101                Value::BigInt(x) => Some(*x),
14102                _ => None,
14103            }
14104            .ok_or_else(|| {
14105                EngineError::Unsupported(alloc::format!(
14106                    "LIMIT/OFFSET ${n} bound to non-integer {v:?}"
14107                ))
14108            })?;
14109            if int < 0 {
14110                return Err(EngineError::Unsupported(alloc::format!(
14111                    "LIMIT/OFFSET ${n} bound to negative value {int}"
14112                )));
14113            }
14114            let bounded = u32::try_from(int).map_err(|_| {
14115                EngineError::Unsupported(alloc::format!(
14116                    "LIMIT/OFFSET ${n} value {int} exceeds u32 range"
14117                ))
14118            })?;
14119            Ok(LimitExpr::Literal(bounded))
14120        }
14121    }
14122}
14123
14124fn substitute_expr(e: &mut Expr, params: &[Value]) -> Result<(), EngineError> {
14125    if let Expr::Placeholder(n) = e {
14126        let idx = usize::from(*n).saturating_sub(1);
14127        let v = params.get(idx).ok_or_else(|| {
14128            EngineError::Eval(EvalError::PlaceholderOutOfRange {
14129                n: *n,
14130                bound: u16::try_from(params.len()).unwrap_or(u16::MAX),
14131            })
14132        })?;
14133        *e = Expr::Literal(value_to_literal(v.clone()));
14134        return Ok(());
14135    }
14136    match e {
14137        Expr::AggregateOrdered { call, order_by, .. } => {
14138            substitute_expr(call, params)?;
14139            for o in order_by.iter_mut() {
14140                substitute_expr(&mut o.expr, params)?;
14141            }
14142        }
14143        Expr::Binary { lhs, rhs, .. } => {
14144            substitute_expr(lhs, params)?;
14145            substitute_expr(rhs, params)?;
14146        }
14147        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
14148            substitute_expr(expr, params)?;
14149        }
14150        Expr::FunctionCall { args, .. } => {
14151            for a in args {
14152                substitute_expr(a, params)?;
14153            }
14154        }
14155        Expr::Like { expr, pattern, .. } => {
14156            substitute_expr(expr, params)?;
14157            substitute_expr(pattern, params)?;
14158        }
14159        Expr::Extract { source, .. } => substitute_expr(source, params)?,
14160        Expr::ScalarSubquery(s) => substitute_select(s, params)?,
14161        Expr::Exists { subquery, .. } => substitute_select(subquery, params)?,
14162        Expr::InSubquery { expr, subquery, .. } => {
14163            substitute_expr(expr, params)?;
14164            substitute_select(subquery, params)?;
14165        }
14166        Expr::WindowFunction {
14167            args,
14168            partition_by,
14169            order_by,
14170            ..
14171        } => {
14172            for a in args {
14173                substitute_expr(a, params)?;
14174            }
14175            for p in partition_by {
14176                substitute_expr(p, params)?;
14177            }
14178            for (e, _, _) in order_by {
14179                substitute_expr(e, params)?;
14180            }
14181        }
14182        Expr::Literal(_) | Expr::Column(_) => {}
14183        // Already handled above.
14184        Expr::Placeholder(_) => unreachable!("Placeholder handled at top of fn"),
14185        Expr::Array(items) => {
14186            for elem in items {
14187                substitute_expr(elem, params)?;
14188            }
14189        }
14190        Expr::ArraySubscript { target, index } => {
14191            substitute_expr(target, params)?;
14192            substitute_expr(index, params)?;
14193        }
14194        Expr::AnyAll { expr, array, .. } => {
14195            substitute_expr(expr, params)?;
14196            substitute_expr(array, params)?;
14197        }
14198        Expr::Case {
14199            operand,
14200            branches,
14201            else_branch,
14202        } => {
14203            if let Some(o) = operand {
14204                substitute_expr(o, params)?;
14205            }
14206            for (w, t) in branches {
14207                substitute_expr(w, params)?;
14208                substitute_expr(t, params)?;
14209            }
14210            if let Some(e) = else_branch {
14211                substitute_expr(e, params)?;
14212            }
14213        }
14214    }
14215    Ok(())
14216}
14217
14218/// v6.1.1 — convert a runtime `Value` into the closest matching
14219/// `Literal` for the substitute walker. Lossless for the simple
14220/// scalars (Int / Float / Text / Bool); Numeric / Date / Timestamp
14221/// / Json / Interval render as their canonical text form so the
14222/// downstream coerce_value can re-parse against the target column
14223/// type. SQ8 / HalfVector cells are NOT expected as bind params;
14224/// pgwire's Bind decodes vector params to the f32 representation
14225/// before they reach this helper.
14226/// v6.2.0 — total ordering on `Value`s used by ANALYZE to sort a
14227/// column's non-NULL sample before histogram building. Cross-type
14228/// pairs (Int vs Float, Date vs Timestamp, …) compare via the
14229/// same widening the eval-side `compare` operator uses; everything
14230/// else (the genuinely-incompatible pairs) falls back to ordering
14231/// by canonical string form so the sort is still total + stable.
14232/// Vector / SQ8 / Half / Json / Numeric / Interval values reach
14233/// here only via the string-fallback path because vector columns
14234/// are filtered out upstream.
14235fn sort_values_for_histogram(a: &Value, b: &Value) -> core::cmp::Ordering {
14236    use core::cmp::Ordering;
14237    match (a, b) {
14238        (Value::SmallInt(a), Value::SmallInt(b)) => a.cmp(b),
14239        (Value::Int(a), Value::Int(b)) => a.cmp(b),
14240        (Value::BigInt(a), Value::BigInt(b)) => a.cmp(b),
14241        (Value::SmallInt(a), Value::Int(b)) => i32::from(*a).cmp(b),
14242        (Value::Int(a), Value::SmallInt(b)) => a.cmp(&i32::from(*b)),
14243        (Value::Int(a), Value::BigInt(b)) => i64::from(*a).cmp(b),
14244        (Value::BigInt(a), Value::Int(b)) => a.cmp(&i64::from(*b)),
14245        (Value::SmallInt(a), Value::BigInt(b)) => i64::from(*a).cmp(b),
14246        (Value::BigInt(a), Value::SmallInt(b)) => a.cmp(&i64::from(*b)),
14247        (Value::Float(a), Value::Float(b)) => a.partial_cmp(b).unwrap_or(Ordering::Equal),
14248        (Value::Text(a), Value::Text(b)) | (Value::Json(a), Value::Json(b)) => a.cmp(b),
14249        (Value::Bool(a), Value::Bool(b)) => a.cmp(b),
14250        (Value::Date(a), Value::Date(b)) => a.cmp(b),
14251        (Value::Timestamp(a), Value::Timestamp(b)) => a.cmp(b),
14252        // Mixed numeric/float — widen to f64 and compare.
14253        (Value::SmallInt(n), Value::Float(x)) => {
14254            (f64::from(*n)).partial_cmp(x).unwrap_or(Ordering::Equal)
14255        }
14256        (Value::Float(x), Value::SmallInt(n)) => {
14257            x.partial_cmp(&f64::from(*n)).unwrap_or(Ordering::Equal)
14258        }
14259        (Value::Int(n), Value::Float(x)) => {
14260            (f64::from(*n)).partial_cmp(x).unwrap_or(Ordering::Equal)
14261        }
14262        (Value::Float(x), Value::Int(n)) => {
14263            x.partial_cmp(&f64::from(*n)).unwrap_or(Ordering::Equal)
14264        }
14265        (Value::BigInt(n), Value::Float(x)) => {
14266            #[allow(clippy::cast_precision_loss)]
14267            let nf = *n as f64;
14268            nf.partial_cmp(x).unwrap_or(Ordering::Equal)
14269        }
14270        (Value::Float(x), Value::BigInt(n)) => {
14271            #[allow(clippy::cast_precision_loss)]
14272            let nf = *n as f64;
14273            x.partial_cmp(&nf).unwrap_or(Ordering::Equal)
14274        }
14275        // Cross-type fallback: lexicographic on canonical form.
14276        // Total + stable so the sort is well-defined.
14277        _ => canonical_value_repr(a).cmp(&canonical_value_repr(b)),
14278    }
14279}
14280
14281/// v6.2.0 — render the histogram bounds list as a `[v0, v1, ...]`
14282/// string for the `spg_statistic.histogram_bounds` column. Values
14283/// containing `,` or `[` / `]` are JSON-style escaped so the
14284/// rendering round-trips through a future parser; v6.2.0 only
14285/// uses the rendered form for human consumption, so the escaping
14286/// is conservative.
14287fn render_histogram_bounds(bounds: &[alloc::string::String]) -> alloc::string::String {
14288    let mut out = alloc::string::String::with_capacity(bounds.len() * 8 + 2);
14289    out.push('[');
14290    for (i, b) in bounds.iter().enumerate() {
14291        if i > 0 {
14292            out.push_str(", ");
14293        }
14294        let needs_quote = b.contains([',', '[', ']', '"']) || b.is_empty();
14295        if needs_quote {
14296            out.push('"');
14297            for ch in b.chars() {
14298                if ch == '"' || ch == '\\' {
14299                    out.push('\\');
14300                }
14301                out.push(ch);
14302            }
14303            out.push('"');
14304        } else {
14305            out.push_str(b);
14306        }
14307    }
14308    out.push(']');
14309    out
14310}
14311
14312/// v6.2.0 — canonical textual form of a `Value` for histogram
14313/// bound storage. Strings used by ANALYZE for sort + bound output.
14314/// INT / BIGINT → decimal; FLOAT → shortest-round-trip via
14315/// `{:?}`; TEXT pass-through; BOOL → `t` / `f`; DATE / TIMESTAMP →
14316/// the same form `format_date` / `format_timestamp` produce for
14317/// SQL Display. Vector / SQ8 / Half / Json / Numeric / Interval
14318/// reach this only via a non-Vector column (vector columns are
14319/// skipped upstream); they fall back to a Debug-derived form so
14320/// stats still serialise without crashing.
14321pub(crate) fn canonical_value_repr(v: &Value) -> alloc::string::String {
14322    match v {
14323        Value::Null => "NULL".to_string(),
14324        Value::SmallInt(n) => alloc::format!("{n}"),
14325        Value::Int(n) => alloc::format!("{n}"),
14326        Value::BigInt(n) => alloc::format!("{n}"),
14327        Value::Float(x) => alloc::format!("{x:?}"),
14328        Value::Text(s) | Value::Json(s) => s.clone(),
14329        Value::Bool(b) => if *b { "t" } else { "f" }.to_string(),
14330        Value::Date(d) => eval::format_date(*d),
14331        Value::Timestamp(t) => eval::format_timestamp(*t),
14332        // v7.17.0 Phase 3.P0-32 — PG TIME canonical text form.
14333        Value::Time(us) => eval::format_time(*us),
14334        // v7.17.0 Phase 3.P0-33 — MySQL YEAR 4-digit zero-padded.
14335        Value::Year(y) => alloc::format!("{y:04}"),
14336        // v7.17.0 Phase 3.P0-34 — PG TIMETZ canonical text form.
14337        Value::TimeTz { us, offset_secs } => eval::format_timetz(*us, *offset_secs),
14338        // v7.17.0 Phase 3.P0-35 — PG MONEY canonical en_US text form.
14339        Value::Money(c) => eval::format_money(*c),
14340        // v7.17.0 Phase 3.P0-38 — PG range canonical text form.
14341        v @ Value::Range { .. } => format_range_str(v),
14342        // v7.17.0 Phase 3.P0-39 — PG hstore canonical text form.
14343        Value::Hstore(pairs) => format_hstore_str(pairs),
14344        // v7.17.0 Phase 3.P0-40 — 2D array canonical text form.
14345        Value::IntArray2D(rows) => format_int_2d_text(rows),
14346        Value::BigIntArray2D(rows) => format_bigint_2d_text(rows),
14347        Value::TextArray2D(rows) => format_text_2d_text(rows),
14348        Value::Interval { months, micros } => eval::format_interval(*months, *micros),
14349        Value::Numeric { scaled, scale } => eval::format_numeric(*scaled, *scale),
14350        Value::Vector(_) | Value::Sq8Vector(_) | Value::HalfVector(_) => {
14351            // Unreachable in practice (vector columns are filtered
14352            // out before this). Defensive fallback so a future
14353            // vector-stats path doesn't crash.
14354            alloc::format!("{v:?}")
14355        }
14356        // v7.5.0 — Value is #[non_exhaustive] for downstream
14357        // forward-compat. Future variants fall through to Debug
14358        // form here (same shape as the vector fallback above).
14359        _ => alloc::format!("{v:?}"),
14360    }
14361}
14362
14363/// v6.2.0 — true for engine-managed catalog tables that the bare
14364/// `ANALYZE` (no target) should skip. v6.2.0 has no internal
14365/// tables yet (publications / subscriptions / users / statistics
14366/// all live as engine fields, not catalog tables), so this is a
14367/// reserved future-proofing hook — every existing user table is
14368/// analysed.
14369const fn is_internal_table_name(_name: &str) -> bool {
14370    false
14371}
14372
14373fn value_to_literal(v: Value) -> Literal {
14374    match v {
14375        Value::Null => Literal::Null,
14376        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
14377        Value::Int(n) => Literal::Integer(i64::from(n)),
14378        Value::BigInt(n) => Literal::Integer(n),
14379        Value::Float(x) => Literal::Float(x),
14380        Value::Text(s) | Value::Json(s) => Literal::String(s),
14381        Value::Bool(b) => Literal::Bool(b),
14382        Value::Vector(v) => Literal::Vector(v),
14383        Value::Numeric { scaled, scale } => Literal::String(eval::format_numeric(scaled, scale)),
14384        Value::Date(d) => Literal::String(eval::format_date(d)),
14385        Value::Timestamp(t) => Literal::String(eval::format_timestamp(t)),
14386        // v7.17.0 Phase 3.P0-69 — UUID round-trips via canonical
14387        // hyphenated text. Without this arm the fallback below
14388        // renders `Debug` form ("Uuid([85, …])") which the
14389        // engine's Text → Uuid coerce can't parse, breaking
14390        // prepared-bind round-trip from the spg-sqlx adapter.
14391        Value::Uuid(b) => Literal::String(spg_storage::format_uuid(&b)),
14392        // v7.16.0 — BYTEA round-trip for the spg-sqlx Bind path.
14393        // PG-canonical text rep is `\x` + lowercase hex; the
14394        // engine's coerce_value already accepts that on the
14395        // text → bytea direction.
14396        Value::Bytes(b) => Literal::String(eval::format_bytea_hex(&b)),
14397        // Arrays ride the AST natively (mailrs embed round-12) —
14398        // the prior `{a,b,c}` text form only worked where a column
14399        // type drove the re-parse; `= ANY($1)` has no column
14400        // context and saw a bare Text value.
14401        Value::TextArray(items) => Literal::TextArray(items),
14402        Value::IntArray(items) => Literal::IntArray(items),
14403        Value::BigIntArray(items) => Literal::BigIntArray(items),
14404        Value::Interval { months, micros } => Literal::Interval {
14405            months,
14406            micros,
14407            text: eval::format_interval(months, micros),
14408        },
14409        // SQ8 / halfvec cells dequantise to f32 before reaching the
14410        // substitute walker; pgwire's Bind path handles that.
14411        Value::Sq8Vector(q) => Literal::Vector(spg_storage::quantize::dequantize(&q)),
14412        Value::HalfVector(h) => Literal::Vector(h.to_f32_vec()),
14413        // v7.5.0 — Value is #[non_exhaustive]; future variants
14414        // render as Debug-form String literal until explicit
14415        // mapping is added.
14416        v => Literal::String(alloc::format!("{v:?}")),
14417    }
14418}
14419
14420fn rewrite_clock_calls(stmt: &mut Statement, now_micros: Option<i64>) {
14421    let Some(now) = now_micros else {
14422        return;
14423    };
14424    match stmt {
14425        Statement::Select(s) => rewrite_select_clock(s, now),
14426        Statement::Insert(ins) => {
14427            for row in &mut ins.rows {
14428                for e in row {
14429                    rewrite_expr_clock(e, now);
14430                }
14431            }
14432            // `ON CONFLICT … DO UPDATE SET created_at = NOW()` —
14433            // the upsert assignments carry clock calls too (mailrs
14434            // embed round-12).
14435            if let Some(clause) = &mut ins.on_conflict
14436                && let spg_sql::ast::OnConflictAction::Update {
14437                    assignments,
14438                    where_,
14439                } = &mut clause.action
14440            {
14441                for (_, e) in assignments.iter_mut() {
14442                    rewrite_expr_clock(e, now);
14443                }
14444                if let Some(w) = where_ {
14445                    rewrite_expr_clock(w, now);
14446                }
14447            }
14448        }
14449        // `UPDATE … SET seen_at = NOW() WHERE …` / `DELETE … WHERE
14450        // ts < NOW()` (mailrs embed round-12 — previously only
14451        // SELECT / INSERT-rows were walked).
14452        Statement::Update(u) => {
14453            for (_, e) in &mut u.assignments {
14454                rewrite_expr_clock(e, now);
14455            }
14456            if let Some(w) = &mut u.where_ {
14457                rewrite_expr_clock(w, now);
14458            }
14459        }
14460        Statement::Delete(d) => {
14461            if let Some(w) = &mut d.where_ {
14462                rewrite_expr_clock(w, now);
14463            }
14464        }
14465        _ => {}
14466    }
14467}
14468
14469fn rewrite_select_clock(s: &mut SelectStatement, now: i64) {
14470    // v7.25.1 (round-18) — shared traversal: CTE bodies, LATERAL
14471    // subqueries, JOIN ON, and UNION peers all get the clock
14472    // rewrite (NOW() inside a CTE previously survived to eval as
14473    // "unknown function `now`").
14474    let _ = walk_select_exprs_mut(s, &mut |e| {
14475        rewrite_expr_clock(e, now);
14476        Ok(())
14477    });
14478}
14479
14480/// v3.0.3 hot path: every recursion lands in exactly one `match` arm.
14481/// Literal / Column-with-qualifier (the dominant cases on a typical
14482/// AST) take a single pattern dispatch and exit. The clock-rewrite
14483/// targets (zero-arg `NOW` / `CURRENT_TIMESTAMP` / `CURRENT_DATE`
14484/// functions, and bare `CURRENT_TIMESTAMP` / `CURRENT_DATE` column
14485/// refs) sit on their own arms with match guards so the fall-through
14486/// to the recursive arms is unambiguous.
14487fn rewrite_expr_clock(e: &mut Expr, now: i64) {
14488    // Fast-path test on the no-recursion shapes first. We can't fold
14489    // them into the big match below because they need to *replace* `e`
14490    // outright; the recursive arms below match on its sub-fields.
14491    if let Some(replacement) = clock_replacement_for(e, now) {
14492        *e = replacement;
14493        return;
14494    }
14495    match e {
14496        Expr::AggregateOrdered { call, order_by, .. } => {
14497            rewrite_expr_clock(call, now);
14498            for o in order_by.iter_mut() {
14499                rewrite_expr_clock(&mut o.expr, now);
14500            }
14501        }
14502        Expr::Binary { lhs, rhs, .. } => {
14503            rewrite_expr_clock(lhs, now);
14504            rewrite_expr_clock(rhs, now);
14505        }
14506        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
14507            rewrite_expr_clock(expr, now);
14508        }
14509        Expr::FunctionCall { args, .. } => {
14510            for a in args {
14511                rewrite_expr_clock(a, now);
14512            }
14513        }
14514        Expr::Like { expr, pattern, .. } => {
14515            rewrite_expr_clock(expr, now);
14516            rewrite_expr_clock(pattern, now);
14517        }
14518        Expr::Extract { source, .. } => rewrite_expr_clock(source, now),
14519        // v4.10 subquery nodes — recurse into the inner SELECT's
14520        // expression slots so e.g. SELECT NOW() in a scalar
14521        // subquery picks up the same instant as the outer query.
14522        Expr::ScalarSubquery(s) => rewrite_select_clock(s, now),
14523        Expr::Exists { subquery, .. } => rewrite_select_clock(subquery, now),
14524        Expr::InSubquery { expr, subquery, .. } => {
14525            rewrite_expr_clock(expr, now);
14526            rewrite_select_clock(subquery, now);
14527        }
14528        // v4.12 window functions — args + PARTITION BY + ORDER BY
14529        // may all reference clock literals.
14530        Expr::WindowFunction {
14531            args,
14532            partition_by,
14533            order_by,
14534            ..
14535        } => {
14536            for a in args {
14537                rewrite_expr_clock(a, now);
14538            }
14539            for p in partition_by {
14540                rewrite_expr_clock(p, now);
14541            }
14542            for (e, _, _) in order_by {
14543                rewrite_expr_clock(e, now);
14544            }
14545        }
14546        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => {}
14547        Expr::Array(items) => {
14548            for elem in items {
14549                rewrite_expr_clock(elem, now);
14550            }
14551        }
14552        Expr::ArraySubscript { target, index } => {
14553            rewrite_expr_clock(target, now);
14554            rewrite_expr_clock(index, now);
14555        }
14556        Expr::AnyAll { expr, array, .. } => {
14557            rewrite_expr_clock(expr, now);
14558            rewrite_expr_clock(array, now);
14559        }
14560        Expr::Case {
14561            operand,
14562            branches,
14563            else_branch,
14564        } => {
14565            if let Some(o) = operand {
14566                rewrite_expr_clock(o, now);
14567            }
14568            for (w, t) in branches {
14569                rewrite_expr_clock(w, now);
14570                rewrite_expr_clock(t, now);
14571            }
14572            if let Some(e) = else_branch {
14573                rewrite_expr_clock(e, now);
14574            }
14575        }
14576    }
14577}
14578
14579/// Returns `Some(Expr)` when `e` is one of the clock-call shapes that
14580/// must be rewritten; otherwise `None` so the caller falls through to
14581/// the recursive walk. Identifies both function-call forms (`NOW()` /
14582/// `CURRENT_TIMESTAMP()` / `CURRENT_DATE()`) and bare-identifier forms
14583/// (`CURRENT_TIMESTAMP` / `CURRENT_DATE` as unqualified column refs,
14584/// which is how PG accepts them without parens).
14585fn clock_replacement_for(e: &Expr, now: i64) -> Option<Expr> {
14586    let (kind, name) = match e {
14587        Expr::FunctionCall { name, args } if args.is_empty() => (ClockSite::Fn, name.as_str()),
14588        Expr::Column(c) if c.qualifier.is_none() => (ClockSite::BareIdent, c.name.as_str()),
14589        _ => return None,
14590    };
14591    // ASCII case-insensitive name match. Each entry decides what
14592    // synthetic literal the call expands to.
14593    //
14594    // v7.17.0 Phase 3.P0-29 — `unix_timestamp` (no args) joins this
14595    // table as MySQL's epoch-seconds equivalent of `now()`. Folded
14596    // to a BigInt literal here so apply_function never needs a
14597    // clock dependency.
14598    enum ClockShape {
14599        Timestamp,
14600        Date,
14601        UnixSeconds,
14602    }
14603    let shape = match name.len() {
14604        3 if kind == ClockSite::Fn && name.eq_ignore_ascii_case("now") => {
14605            Some(ClockShape::Timestamp)
14606        }
14607        12 if name.eq_ignore_ascii_case("current_date") => Some(ClockShape::Date),
14608        14 if kind == ClockSite::Fn && name.eq_ignore_ascii_case("unix_timestamp") => {
14609            Some(ClockShape::UnixSeconds)
14610        }
14611        17 if name.eq_ignore_ascii_case("current_timestamp") => Some(ClockShape::Timestamp),
14612        _ => None,
14613    };
14614    let shape = shape?;
14615    let payload = match shape {
14616        ClockShape::Timestamp => now,
14617        ClockShape::Date => now.div_euclid(86_400_000_000),
14618        ClockShape::UnixSeconds => now.div_euclid(1_000_000),
14619    };
14620    let target = match shape {
14621        ClockShape::Timestamp => spg_sql::ast::CastTarget::Timestamp,
14622        ClockShape::Date => spg_sql::ast::CastTarget::Date,
14623        ClockShape::UnixSeconds => spg_sql::ast::CastTarget::BigInt,
14624    };
14625    Some(Expr::Cast {
14626        expr: alloc::boxed::Box::new(Expr::Literal(spg_sql::ast::Literal::Integer(payload))),
14627        target,
14628    })
14629}
14630
14631#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14632enum ClockSite {
14633    Fn,
14634    BareIdent,
14635}
14636
14637/// `ORDER BY <integer>` references the N-th SELECT item (1-based).
14638/// Swap the integer literal for the matching item's expression so the
14639/// executor doesn't need a special-case branch. Recurses into UNION
14640/// peers because each peer keeps its own SELECT list.
14641/// v6.4.1 — expand `GROUP BY ALL` to every non-aggregate SELECT-list
14642/// item. Mirrors DuckDB / PG 19 semantics. Wildcards (`SELECT * …`)
14643/// are NOT expanded by GROUP BY ALL (PG 19 leaves the wildcard intact
14644/// and groups by whatever explicit non-aggregates remain — none in
14645/// the wildcard-only case, which still works for non-aggregate
14646/// queries).
14647fn expand_group_by_all(s: &mut SelectStatement) {
14648    if !s.group_by_all {
14649        for (_, peer) in &mut s.unions {
14650            expand_group_by_all(peer);
14651        }
14652        return;
14653    }
14654    let mut groups: Vec<Expr> = Vec::new();
14655    for item in &s.items {
14656        if let SelectItem::Expr { expr, .. } = item
14657            && !aggregate::contains_aggregate(expr)
14658        {
14659            groups.push(expr.clone());
14660        }
14661    }
14662    s.group_by = Some(groups);
14663    s.group_by_all = false;
14664    for (_, peer) in &mut s.unions {
14665        expand_group_by_all(peer);
14666    }
14667}
14668
14669fn resolve_order_by_position(s: &mut SelectStatement) {
14670    // v6.4.0 — iterate every ORDER BY key. Position references
14671    // (`ORDER BY 2`) bind to the 1-based projection index;
14672    // identifier references that match a SELECT-list alias bind to
14673    // the projected expression (Step 4 of L3a).
14674    for order in &mut s.order_by {
14675        match &order.expr {
14676            Expr::Literal(Literal::Integer(n)) if *n >= 1 => {
14677                if let Ok(idx_one_based) = usize::try_from(*n) {
14678                    let idx = idx_one_based - 1;
14679                    if idx < s.items.len()
14680                        && let SelectItem::Expr { expr, .. } = &s.items[idx]
14681                    {
14682                        order.expr = expr.clone();
14683                    }
14684                }
14685            }
14686            Expr::Column(c) if c.qualifier.is_none() => {
14687                // Alias-in-ORDER-BY lookup.
14688                for item in &s.items {
14689                    if let SelectItem::Expr {
14690                        expr,
14691                        alias: Some(a),
14692                    } = item
14693                        && a == &c.name
14694                    {
14695                        order.expr = expr.clone();
14696                        break;
14697                    }
14698                }
14699            }
14700            _ => {}
14701        }
14702    }
14703    for (_, peer) in &mut s.unions {
14704        resolve_order_by_position(peer);
14705    }
14706}
14707
14708/// Sort `tagged` by `f64` key, reversing the comparator under DESC.
14709/// Used by the UNION ORDER BY path; per-block paths inline the same
14710/// comparator because they already hold `&OrderBy` directly.
14711/// v3.1.1: partial-sort helper. When `keep` (= offset + limit) is
14712/// strictly less than `tagged.len()`, run `select_nth_unstable_by` to
14713/// partition the prefix in O(n), then sort just that prefix in O(k
14714/// log k). Total O(n + k log k), vs O(n log n) for a full sort. The
14715/// caller decides what `keep` is; passing `None` (no LIMIT) keeps the
14716/// full-sort behaviour.
14717///
14718/// `tagged` holds `(Option<f64>, Row)` (the SELECT path) — `None` keys
14719/// sort last in ascending order, mirroring NULL-sorts-last in SQL.
14720fn partial_sort_tagged(tagged: &mut Vec<(Vec<f64>, Row)>, keep: Option<usize>, descs: &[bool]) {
14721    let cmp = |a: &(Vec<f64>, Row), b: &(Vec<f64>, Row)| cmp_multi_key(&a.0, &b.0, descs);
14722    match keep {
14723        Some(k) if k < tagged.len() && k > 0 => {
14724            let pivot = k - 1;
14725            tagged.select_nth_unstable_by(pivot, cmp);
14726            tagged[..k].sort_by(cmp);
14727            tagged.truncate(k);
14728        }
14729        _ => {
14730            tagged.sort_by(cmp);
14731        }
14732    }
14733}
14734
14735fn sort_by_keys(tagged: &mut [(Vec<f64>, Row)], descs: &[bool]) {
14736    tagged.sort_by(|a, b| cmp_multi_key(&a.0, &b.0, descs));
14737}
14738
14739/// v6.4.0 — multi-key ORDER BY comparator. Each key's per-key DESC
14740/// flag is honored independently. NULL is encoded as `f64::INFINITY`
14741/// so it sorts last in ASC and first in DESC (matches PG default).
14742fn cmp_multi_key(a: &[f64], b: &[f64], descs: &[bool]) -> core::cmp::Ordering {
14743    use core::cmp::Ordering;
14744    for (i, (ka, kb)) in a.iter().zip(b.iter()).enumerate() {
14745        let ord = ka.partial_cmp(kb).unwrap_or(Ordering::Equal);
14746        let ord = if descs.get(i).copied().unwrap_or(false) {
14747            ord.reverse()
14748        } else {
14749            ord
14750        };
14751        if ord != Ordering::Equal {
14752            return ord;
14753        }
14754    }
14755    Ordering::Equal
14756}
14757
14758/// v6.4.0 — eval every ORDER BY expression for a row and pack the
14759/// resulting keys into a `Vec<f64>`. NULL → `f64::INFINITY`.
14760fn build_order_keys(
14761    order_by: &[OrderBy],
14762    row: &Row,
14763    ctx: &EvalContext,
14764) -> Result<Vec<f64>, EngineError> {
14765    let mut keys = Vec::with_capacity(order_by.len());
14766    for o in order_by {
14767        let v = eval::eval_expr(&o.expr, row, ctx)?;
14768        // v7.24 (round-16 A) — explicit NULLS FIRST/LAST. The f64
14769        // packing sorts ascending THEN applies the per-key DESC
14770        // reverse, so a NULL must land at +INF exactly when the
14771        // effective placement agrees with the reverse direction:
14772        // nf == desc → +INF (ASC default last / DESC default
14773        // first), nf != desc → -INF (the explicit flips).
14774        if matches!(v, Value::Null) {
14775            let nf = o.nulls_first.unwrap_or(o.desc);
14776            keys.push(if nf == o.desc {
14777                f64::INFINITY
14778            } else {
14779                f64::NEG_INFINITY
14780            });
14781        } else {
14782            keys.push(value_to_order_key(&v)?);
14783        }
14784    }
14785    Ok(keys)
14786}
14787
14788/// Drop the first `offset` rows then truncate to `limit`. PG / `MySQL`
14789/// agree: OFFSET applies *after* ORDER BY but *before* LIMIT (so
14790/// `LIMIT 10 OFFSET 5` keeps rows 6..=15).
14791fn apply_offset_and_limit(rows: &mut Vec<Row>, offset: Option<u32>, limit: Option<u32>) {
14792    if let Some(off) = offset {
14793        let off = off as usize;
14794        if off >= rows.len() {
14795            rows.clear();
14796        } else {
14797            rows.drain(..off);
14798        }
14799    }
14800    if let Some(n) = limit {
14801        rows.truncate(n as usize);
14802    }
14803}
14804
14805/// v7.17.0 Phase 3.P0-49 — offset + limit applied to a tagged
14806/// `(order_keys, row)` sequence, with optional SQL:2008 `WITH
14807/// TIES` extension. When `with_ties` is set, the truncated tail
14808/// is extended through every subsequent row whose order keys
14809/// equal the last-kept row's keys (so a "top 3 by score" with
14810/// WITH TIES emits row 4 too when row 4 ties row 3 on `score`).
14811///
14812/// The order-key vector is the per-row sort key the caller already
14813/// computed via `build_order_keys`; equal-key detection therefore
14814/// matches the sort comparator exactly.
14815fn apply_offset_and_limit_tagged(
14816    tagged: &mut Vec<(Vec<f64>, Row)>,
14817    offset: Option<u32>,
14818    limit: Option<u32>,
14819    with_ties: bool,
14820) {
14821    if let Some(off) = offset {
14822        let off = off as usize;
14823        if off >= tagged.len() {
14824            tagged.clear();
14825        } else {
14826            tagged.drain(..off);
14827        }
14828    }
14829    if let Some(n) = limit {
14830        let n = n as usize;
14831        if with_ties && n > 0 && n < tagged.len() {
14832            let cutoff_key = tagged[n - 1].0.clone();
14833            let mut end = n;
14834            while end < tagged.len() && tagged[end].0 == cutoff_key {
14835                end += 1;
14836            }
14837            tagged.truncate(end);
14838        } else {
14839            tagged.truncate(n);
14840        }
14841    }
14842}
14843
14844/// v7.17.0 Phase 3.P0-49 — PG-canonical: `FETCH FIRST <n> ROWS
14845/// WITH TIES` requires an `ORDER BY`. Without one, there's no
14846/// way to identify "ties" deterministically, so PG errors at
14847/// plan time. SPG mirrors that surface so the same DDL / app
14848/// behaviour holds on cutover.
14849fn check_with_ties_requires_order_by(stmt: &SelectStatement) -> Result<(), EngineError> {
14850    if stmt.limit_with_ties && stmt.order_by.is_empty() {
14851        return Err(EngineError::Unsupported(alloc::string::String::from(
14852            "FETCH FIRST … ROWS WITH TIES requires an ORDER BY clause",
14853        )));
14854    }
14855    Ok(())
14856}
14857
14858/// v7.6.1 — resolve a parser-level `ForeignKeyConstraint` (column
14859/// names + parent table name) into the storage-layer shape (column
14860/// indices + same parent table). Validates everything the engine
14861/// needs to know about the FK at CREATE TABLE time:
14862///
14863///   - parent table exists (catalog lookup, unless self-referencing)
14864///   - parent columns exist on the parent table
14865///   - parent column list matches the local arity (defaults to the
14866///     parent's primary index column when omitted)
14867///   - parent columns are covered by a `BTree` UNIQUE-class index
14868///     (SPG's stand-in for `PRIMARY KEY`/`UNIQUE`) — required so
14869///     the v7.6.2 INSERT path can do an O(log n) parent lookup
14870///   - local columns exist on the table being created
14871fn resolve_foreign_key(
14872    local_table_name: &str,
14873    local_cols: &[ColumnSchema],
14874    fk: spg_sql::ast::ForeignKeyConstraint,
14875    catalog: &Catalog,
14876) -> Result<spg_storage::ForeignKeyConstraint, EngineError> {
14877    // Resolve local columns.
14878    let mut local_columns = Vec::with_capacity(fk.columns.len());
14879    for name in &fk.columns {
14880        let pos = local_cols
14881            .iter()
14882            .position(|c| c.name == *name)
14883            .ok_or_else(|| {
14884                EngineError::Unsupported(alloc::format!(
14885                    "FOREIGN KEY references unknown local column {name:?}"
14886                ))
14887            })?;
14888        local_columns.push(pos);
14889    }
14890    // Self-referencing FK: parent table is the one we're creating.
14891    // The parent column resolution uses the local column list since
14892    // the catalog doesn't have this table yet.
14893    let is_self_ref = fk.parent_table == local_table_name;
14894    let (parent_cols_for_lookup, parent_table_str): (&[ColumnSchema], &str) = if is_self_ref {
14895        (local_cols, local_table_name)
14896    } else {
14897        let parent_table = catalog.get(&fk.parent_table).ok_or_else(|| {
14898            EngineError::Storage(StorageError::TableNotFound {
14899                name: fk.parent_table.clone(),
14900            })
14901        })?;
14902        (
14903            parent_table.schema().columns.as_slice(),
14904            fk.parent_table.as_str(),
14905        )
14906    };
14907    // Resolve parent column names → positions. If the FK omitted the
14908    // parent column list, fall back to the parent's primary index
14909    // column (single-column only — composite default is rejected
14910    // because there's no unambiguous "PK" in SPG's index list).
14911    let parent_columns: Vec<usize> = if fk.parent_columns.is_empty() {
14912        if fk.columns.len() != 1 {
14913            return Err(EngineError::Unsupported(
14914                "composite FOREIGN KEY without explicit parent column list is not supported \
14915                 — list the parent columns explicitly"
14916                    .into(),
14917            ));
14918        }
14919        // Find a single BTree index on the parent and use its column.
14920        let pos = pick_pk_index_column(catalog, parent_table_str, is_self_ref, local_cols)
14921            .ok_or_else(|| {
14922                EngineError::Unsupported(alloc::format!(
14923                    "parent table {parent_table_str:?} has no PRIMARY-key / UNIQUE BTree index \
14924                     to default the FOREIGN KEY against"
14925                ))
14926            })?;
14927        alloc::vec![pos]
14928    } else {
14929        let mut out = Vec::with_capacity(fk.parent_columns.len());
14930        for name in &fk.parent_columns {
14931            let pos = parent_cols_for_lookup
14932                .iter()
14933                .position(|c| c.name == *name)
14934                .ok_or_else(|| {
14935                    EngineError::Unsupported(alloc::format!(
14936                        "FOREIGN KEY references unknown parent column \
14937                         {name:?} on table {parent_table_str:?}"
14938                    ))
14939                })?;
14940            out.push(pos);
14941        }
14942        out
14943    };
14944    if parent_columns.len() != local_columns.len() {
14945        return Err(EngineError::Unsupported(alloc::format!(
14946            "FOREIGN KEY arity mismatch: {} local columns vs {} parent columns",
14947            local_columns.len(),
14948            parent_columns.len()
14949        )));
14950    }
14951    // For non-self-referencing FKs, verify the parent column set is
14952    // covered by a BTree index. SPG doesn't have a `PRIMARY KEY`
14953    // declaration; the convention is "the parent column for FK
14954    // purposes must have a BTree index" — which the user creates via
14955    // `CREATE INDEX ... USING btree (col)` (the default). We accept
14956    // any single-column BTree index that covers a parent column;
14957    // composite parent column lists require an index whose `column_position`
14958    // matches the first parent column (multi-column BTree indices
14959    // are not in the v7.x roadmap).
14960    if !is_self_ref {
14961        let parent_table = catalog.get(&fk.parent_table).expect("checked above");
14962        let primary_parent_col = parent_columns[0];
14963        let has_btree = parent_table
14964            .schema()
14965            .columns
14966            .get(primary_parent_col)
14967            .is_some()
14968            && parent_table.indices().iter().any(|idx| {
14969                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14970                    && idx.column_position == primary_parent_col
14971                    && idx.partial_predicate.is_none()
14972            });
14973        if !has_btree {
14974            return Err(EngineError::Unsupported(alloc::format!(
14975                "FOREIGN KEY parent column on {:?} is not covered by an unconditional BTree \
14976                 index — create one with `CREATE INDEX ... ON {} ({})` first",
14977                parent_table_str,
14978                parent_table_str,
14979                parent_table.schema().columns[primary_parent_col].name,
14980            )));
14981        }
14982    }
14983    let on_delete = fk_action_sql_to_storage(fk.on_delete);
14984    let on_update = fk_action_sql_to_storage(fk.on_update);
14985    Ok(spg_storage::ForeignKeyConstraint {
14986        name: fk.name,
14987        local_columns,
14988        parent_table: fk.parent_table,
14989        parent_columns,
14990        on_delete,
14991        on_update,
14992    })
14993}
14994
14995/// v7.6.1 — pick a sentinel "primary key" column from the parent
14996/// table when the FK didn't name parent columns. Picks the first
14997/// single-column unconditional BTree index — that's the closest
14998/// thing SPG has to a PRIMARY KEY today. Self-referencing FKs use
14999/// `local_cols` as the column source.
15000fn pick_pk_index_column(
15001    catalog: &Catalog,
15002    parent_name: &str,
15003    is_self_ref: bool,
15004    local_cols: &[ColumnSchema],
15005) -> Option<usize> {
15006    if is_self_ref {
15007        // Self-ref FK omitted parent columns: pick column 0 by
15008        // convention (no catalog entry yet). Engine will widen this
15009        // when v7.6.7 lands; v7.6.1 only handles the explicit form.
15010        let _ = local_cols;
15011        return Some(0);
15012    }
15013    let parent = catalog.get(parent_name)?;
15014    parent.indices().iter().find_map(|idx| {
15015        if matches!(idx.kind, spg_storage::IndexKind::BTree(_))
15016            && idx.partial_predicate.is_none()
15017            && idx.included_columns.is_empty()
15018            && idx.expression.is_none()
15019        {
15020            Some(idx.column_position)
15021        } else {
15022            None
15023        }
15024    })
15025}
15026
15027/// v7.9.8 / v7.9.10 — resolve the column positions that
15028/// identify a conflict for ON CONFLICT. Returns a Vec of
15029/// column positions (1 element for single-column form, N for
15030/// composite). When the user wrote bare `ON CONFLICT DO …`,
15031/// falls back to the table's first unconditional BTree index
15032/// (always single-column today).
15033fn resolve_on_conflict_columns(
15034    catalog: &Catalog,
15035    table_name: &str,
15036    target: &[String],
15037) -> Result<Vec<usize>, EngineError> {
15038    let table = catalog.get(table_name).ok_or_else(|| {
15039        EngineError::Storage(StorageError::TableNotFound {
15040            name: table_name.into(),
15041        })
15042    })?;
15043    if target.is_empty() {
15044        // v7.13.2 — mailrs round-6 S5 follow-up. Composite UNIQUE
15045        // constraints carry a multi-column tuple; the prior code
15046        // path picked only the leading column of the first BTree
15047        // index, which caused `ON CONFLICT DO NOTHING` to dedup
15048        // by leading column alone (3 rows with same group_id but
15049        // different permission collapsed to 1). PG semantics use
15050        // the full tuple. Prefer a UniquenessConstraint's full
15051        // column list when one exists; fall back to the leading
15052        // BTree column for legacy single-column UNIQUE.
15053        if let Some(uc) = table.schema().uniqueness_constraints.first() {
15054            return Ok(uc.columns.clone());
15055        }
15056        let pos = table
15057            .indices()
15058            .iter()
15059            .find_map(|idx| {
15060                if matches!(idx.kind, spg_storage::IndexKind::BTree(_))
15061                    && idx.partial_predicate.is_none()
15062                    && idx.included_columns.is_empty()
15063                    && idx.expression.is_none()
15064                {
15065                    Some(idx.column_position)
15066                } else {
15067                    None
15068                }
15069            })
15070            .ok_or_else(|| {
15071                EngineError::Unsupported(alloc::format!(
15072                    "ON CONFLICT without target requires a UNIQUE BTree index on {table_name:?}"
15073                ))
15074            })?;
15075        return Ok(alloc::vec![pos]);
15076    }
15077    let mut out = Vec::with_capacity(target.len());
15078    for name in target {
15079        let pos = table
15080            .schema()
15081            .columns
15082            .iter()
15083            .position(|c| c.name == *name)
15084            .ok_or_else(|| {
15085                EngineError::Unsupported(alloc::format!(
15086                    "ON CONFLICT target column {name:?} not found on {table_name:?}"
15087                ))
15088            })?;
15089        out.push(pos);
15090    }
15091    Ok(out)
15092}
15093
15094/// v7.9.8 — check whether the BTree index on `column_pos` of
15095/// `table_name` already has a row with this key.
15096fn on_conflict_key_exists(
15097    catalog: &Catalog,
15098    table_name: &str,
15099    column_pos: usize,
15100    key: &Value,
15101) -> bool {
15102    let Some(table) = catalog.get(table_name) else {
15103        return false;
15104    };
15105    let Some(idx_key) = spg_storage::IndexKey::from_value(key) else {
15106        return false;
15107    };
15108    table.indices().iter().any(|idx| {
15109        matches!(idx.kind, spg_storage::IndexKind::BTree(_))
15110            && idx.column_position == column_pos
15111            && idx.partial_predicate.is_none()
15112            && !idx.lookup_eq(&idx_key).is_empty()
15113    })
15114}
15115
15116/// v7.9.9 / v7.9.10 — look up an existing row's position by
15117/// matching all `column_positions` against the incoming `key`
15118/// tuple. Single-column shape (one column) reduces to the
15119/// canonical PK lookup; composite shapes scan linearly until
15120/// every position matches.
15121fn lookup_row_position_by_keys(
15122    catalog: &Catalog,
15123    table_name: &str,
15124    column_positions: &[usize],
15125    key: &[&Value],
15126) -> Option<usize> {
15127    let table = catalog.get(table_name)?;
15128    table.rows().iter().position(|r| {
15129        column_positions
15130            .iter()
15131            .enumerate()
15132            .all(|(i, &pos)| r.values.get(pos) == Some(key[i]))
15133    })
15134}
15135
15136/// v7.9.10 — does the table already contain a row whose
15137/// `column_positions` tuple equals `key`? Single-column shape
15138/// uses the existing BTree fast path; composite shapes fall
15139/// back to a row scan.
15140fn on_conflict_keys_exist(
15141    catalog: &Catalog,
15142    table_name: &str,
15143    column_positions: &[usize],
15144    key: &[&Value],
15145) -> bool {
15146    if column_positions.len() == 1 {
15147        return on_conflict_key_exists(catalog, table_name, column_positions[0], key[0]);
15148    }
15149    let Some(table) = catalog.get(table_name) else {
15150        return false;
15151    };
15152    table.rows().iter().any(|r| {
15153        column_positions
15154            .iter()
15155            .enumerate()
15156            .all(|(i, &pos)| r.values.get(pos) == Some(key[i]))
15157    })
15158}
15159
15160/// v7.9.9 — apply ON CONFLICT DO UPDATE SET assignments to an
15161/// existing row.
15162///
15163/// `incoming` is the rejected INSERT row (used to resolve
15164/// `EXCLUDED.col` references in the assignment exprs);
15165/// `target_pos` is the position of the existing row in the table.
15166/// Each assignment substitutes `EXCLUDED.col` with the matching
15167/// incoming value, evaluates the resulting expression against
15168/// the existing row, and writes the new value into the
15169/// corresponding column of the returned `Vec<Value>`. If
15170/// `where_` evaluates falsy, returns Ok(None) — PG behaviour:
15171/// the conflicting row is silently kept unchanged.
15172fn apply_on_conflict_assignments(
15173    catalog: &Catalog,
15174    table_name: &str,
15175    target_pos: usize,
15176    incoming: &[Value],
15177    assignments: &[(String, Expr)],
15178    where_: Option<&Expr>,
15179) -> Result<Option<Vec<Value>>, EngineError> {
15180    let table = catalog.get(table_name).ok_or_else(|| {
15181        EngineError::Storage(StorageError::TableNotFound {
15182            name: table_name.into(),
15183        })
15184    })?;
15185    let schema_cols = table.schema().columns.clone();
15186    let existing = table
15187        .rows()
15188        .get(target_pos)
15189        .ok_or_else(|| {
15190            EngineError::Unsupported(alloc::format!(
15191                "ON CONFLICT DO UPDATE: row position {target_pos} out of bounds on {table_name:?}"
15192            ))
15193        })?
15194        .clone();
15195    let ctx = eval::EvalContext::new(&schema_cols, Some(table_name));
15196    // Optional WHERE filter on the conflict row.
15197    if let Some(w) = where_ {
15198        let pred = w.clone();
15199        let pred = substitute_excluded_refs(pred, &schema_cols, incoming);
15200        let v = eval::eval_expr(&pred, &existing, &ctx)?;
15201        if !matches!(v, Value::Bool(true)) {
15202            return Ok(None);
15203        }
15204    }
15205    let mut new_values = existing.values.clone();
15206    for (col_name, expr) in assignments {
15207        let target_idx = schema_cols
15208            .iter()
15209            .position(|c| c.name == *col_name)
15210            .ok_or_else(|| {
15211                EngineError::Eval(EvalError::ColumnNotFound {
15212                    name: col_name.clone(),
15213                })
15214            })?;
15215        let sub = substitute_excluded_refs(expr.clone(), &schema_cols, incoming);
15216        let v = eval::eval_expr(&sub, &existing, &ctx)?;
15217        let coerced = coerce_value(v, schema_cols[target_idx].ty, col_name, target_idx)?;
15218        check_unsigned_range(&coerced, &schema_cols[target_idx], target_idx)?;
15219        new_values[target_idx] = coerced;
15220    }
15221    Ok(Some(new_values))
15222}
15223
15224/// v7.9.9 — walk an `Expr` tree replacing any `Column { qualifier:
15225/// "EXCLUDED", name }` reference with a `Literal` of the matching
15226/// value from the incoming-row vec. Resolution against the
15227/// child-table column list (by name).
15228fn substitute_excluded_refs(expr: Expr, schema_cols: &[ColumnSchema], incoming: &[Value]) -> Expr {
15229    use spg_sql::ast::ColumnName;
15230    match expr {
15231        Expr::Column(ColumnName { qualifier, name })
15232            if qualifier
15233                .as_deref()
15234                .is_some_and(|q| q.eq_ignore_ascii_case("excluded")) =>
15235        {
15236            let pos = schema_cols.iter().position(|c| c.name == name);
15237            match pos {
15238                Some(p) => {
15239                    let v = incoming.get(p).cloned().unwrap_or(Value::Null);
15240                    value_to_literal_expr(v)
15241                        .unwrap_or_else(|_| Expr::Literal(spg_sql::ast::Literal::Null))
15242                }
15243                None => Expr::Column(ColumnName { qualifier, name }),
15244            }
15245        }
15246        Expr::Binary { op, lhs, rhs } => Expr::Binary {
15247            op,
15248            lhs: Box::new(substitute_excluded_refs(*lhs, schema_cols, incoming)),
15249            rhs: Box::new(substitute_excluded_refs(*rhs, schema_cols, incoming)),
15250        },
15251        Expr::Unary { op, expr } => Expr::Unary {
15252            op,
15253            expr: Box::new(substitute_excluded_refs(*expr, schema_cols, incoming)),
15254        },
15255        Expr::FunctionCall { name, args } => Expr::FunctionCall {
15256            name,
15257            args: args
15258                .into_iter()
15259                .map(|a| substitute_excluded_refs(a, schema_cols, incoming))
15260                .collect(),
15261        },
15262        other => other,
15263    }
15264}
15265
15266/// v7.6.2 / v7.6.7 — INSERT-side FK enforcement. For every row
15267/// about to be inserted into `child_table`, every FK declared on
15268/// that table is checked: the row's FK columns must either be
15269/// NULL (SQL spec skip) or match an existing parent row via the
15270/// parent's BTree PK / UNIQUE index.
15271///
15272/// Returns `EngineError::Unsupported` with a `FOREIGN KEY violation`
15273/// payload on first failure.
15274///
15275/// **Self-referencing FKs (v7.6.7 widening):** when `fk.parent_table
15276/// == child_table`, the parent rows visible to this check are
15277///  (a) rows already committed to the table, plus
15278///  (b) earlier rows from the *same* `rows` batch.
15279/// This makes `INSERT INTO tree VALUES (1, NULL), (2, 1), (3, 2)`
15280/// work in a single statement — common pattern for bulk-loading
15281/// hierarchies.
15282/// v7.9.19 — enforce table-level UNIQUE / PRIMARY KEY tuple
15283/// constraints at INSERT time. For each constraint declared on
15284/// the target table, check that no existing row + no earlier row
15285/// in the same batch has the same full-column tuple. NULL in
15286/// any column lifts the row out of the check (SQL spec: NULL
15287/// ≠ NULL for uniqueness). mailrs G1 + G6.
15288fn enforce_uniqueness_inserts(
15289    catalog: &Catalog,
15290    child_table: &str,
15291    constraints: &[spg_storage::UniquenessConstraint],
15292    rows: &[Vec<Value>],
15293) -> Result<(), EngineError> {
15294    if constraints.is_empty() {
15295        return Ok(());
15296    }
15297    let table = catalog.get(child_table).ok_or_else(|| {
15298        EngineError::Storage(StorageError::TableNotFound {
15299            name: child_table.into(),
15300        })
15301    })?;
15302    let schema = table.schema();
15303    for uc in constraints {
15304        for (batch_idx, row_values) in rows.iter().enumerate() {
15305            // v7.17.0 Phase 3.P0-45 — fold each key cell by its
15306            // column's declared Collation before comparing. Phase
15307            // 2.5b wired Collation into GROUP BY / ORDER BY / `=`
15308            // but the UNIQUE-constraint enforcement still compared
15309            // Text byte-wise; a `*_ci` column would let
15310            // `('Foo')` and `('FOO')` coexist when MySQL would
15311            // reject the second. Owned Values so the fold and
15312            // the borrow live in the same scope.
15313            let key: Vec<Value> = uc
15314                .columns
15315                .iter()
15316                .map(|&i| collated_key_cell(&row_values[i], i, schema))
15317                .collect();
15318            let has_null = key.iter().any(|v| matches!(v, Value::Null));
15319            // v7.13.0 — `NULLS NOT DISTINCT` (mailrs round-5 G10,
15320            // PG 15+): two rows whose constrained columns are all
15321            // NULL collide. SQL-standard `NULLS DISTINCT` lets any
15322            // NULL skip the check.
15323            if has_null && !uc.nulls_not_distinct {
15324                continue;
15325            }
15326            // Table-side collision: scan existing rows.
15327            let collides_in_table = table.rows().iter().any(|prow| {
15328                uc.columns.iter().enumerate().all(|(i, &p)| {
15329                    prow.values
15330                        .get(p)
15331                        .is_some_and(|v| collated_key_cell(v, p, schema) == key[i])
15332                })
15333            });
15334            // Batch-side collision: earlier rows in the same INSERT.
15335            let collides_in_batch = rows[..batch_idx].iter().any(|earlier| {
15336                uc.columns.iter().enumerate().all(|(i, &p)| {
15337                    earlier
15338                        .get(p)
15339                        .is_some_and(|v| collated_key_cell(v, p, schema) == key[i])
15340                })
15341            });
15342            if collides_in_table || collides_in_batch {
15343                let kind = if uc.is_primary_key {
15344                    "PRIMARY KEY"
15345                } else {
15346                    "UNIQUE"
15347                };
15348                let col_names: Vec<String> = uc
15349                    .columns
15350                    .iter()
15351                    .map(|&i| table.schema().columns[i].name.clone())
15352                    .collect();
15353                return Err(EngineError::Unsupported(alloc::format!(
15354                    "{kind} violation on {child_table:?} columns {col_names:?}: \
15355                     row #{batch_idx} duplicates an existing key"
15356                )));
15357            }
15358        }
15359    }
15360    Ok(())
15361}
15362
15363/// v7.17.0 Phase 3.P0-45 — return a key cell folded by its column's
15364/// declared `Collation`. For `CaseInsensitive`, fold Text payloads to
15365/// ASCII lowercase (matches Phase 2.5's `*_ci` semantics: ASCII case-
15366/// fold only, non-ASCII bytes stay byte-wise). For `Binary` or non-Text
15367/// values, the cell passes through unchanged. The caller compares the
15368/// folded values with `==`.
15369fn collated_key_cell(
15370    v: &spg_storage::Value,
15371    column_position: usize,
15372    schema: &spg_storage::TableSchema,
15373) -> spg_storage::Value {
15374    match (v, schema.columns.get(column_position).map(|c| c.collation)) {
15375        (spg_storage::Value::Text(s), Some(spg_storage::Collation::CaseInsensitive)) => {
15376            spg_storage::Value::Text(s.to_ascii_lowercase())
15377        }
15378        _ => v.clone(),
15379    }
15380}
15381
15382/// v7.9.29 — `true` iff `v` counts as a truthy SQL value for a
15383/// WHERE-style predicate. NULL → false (three-valued logic
15384/// collapses to "skip this row" for index inclusion). Numeric
15385/// non-zero, BIGINT non-zero, TINYINT non-zero, BOOLEAN true → true.
15386/// Everything else (strings, vectors, JSON, …) is not a valid
15387/// predicate result and surfaces as `false` so a malformed
15388/// predicate degrades to "row not in index" rather than panicking.
15389fn predicate_truthy(v: &spg_storage::Value) -> bool {
15390    use spg_storage::Value as V;
15391    match v {
15392        V::Bool(b) => *b,
15393        V::Int(n) => *n != 0,
15394        V::BigInt(n) => *n != 0,
15395        V::SmallInt(n) => *n != 0,
15396        _ => false,
15397    }
15398}
15399
15400/// v7.9.29 — at CREATE UNIQUE INDEX time, scan the table's
15401/// committed rows for pre-existing duplicates. If any pair of rows
15402/// matches the predicate AND has the same index key, refuse to
15403/// create the index so the user fixes the data before retrying.
15404fn check_existing_unique_violation(
15405    idx: &spg_storage::Index,
15406    schema: &spg_storage::TableSchema,
15407    rows: &[spg_storage::Row],
15408) -> Result<(), EngineError> {
15409    let predicate_expr = match idx.partial_predicate.as_deref() {
15410        Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
15411            EngineError::Unsupported(alloc::format!(
15412                "stored partial predicate {s:?} failed to re-parse: {e:?}"
15413            ))
15414        })?),
15415        None => None,
15416    };
15417    let ctx = eval::EvalContext::new(&schema.columns, None);
15418    let key_positions = unique_key_positions(idx);
15419    let mut seen: alloc::vec::Vec<alloc::vec::Vec<spg_storage::Value>> = alloc::vec::Vec::new();
15420    for row in rows {
15421        if let Some(expr) = &predicate_expr {
15422            let v = eval::eval_expr(expr, row, &ctx).map_err(|e| {
15423                EngineError::Unsupported(alloc::format!(
15424                    "evaluating UNIQUE INDEX predicate against existing row: {e:?}"
15425                ))
15426            })?;
15427            if !predicate_truthy(&v) {
15428                continue;
15429            }
15430        }
15431        let key: alloc::vec::Vec<spg_storage::Value> = key_positions
15432            .iter()
15433            .map(|&p| {
15434                let v = row
15435                    .values
15436                    .get(p)
15437                    .cloned()
15438                    .unwrap_or(spg_storage::Value::Null);
15439                collated_key_cell(&v, p, schema)
15440            })
15441            .collect();
15442        if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
15443            continue;
15444        }
15445        if seen.iter().any(|other| *other == key) {
15446            return Err(EngineError::Unsupported(alloc::format!(
15447                "CREATE UNIQUE INDEX {:?}: existing rows already violate the constraint",
15448                idx.name
15449            )));
15450        }
15451        seen.push(key);
15452    }
15453    Ok(())
15454}
15455
15456/// v7.9.29 — full key tuple for a UNIQUE INDEX (leading +
15457/// extra positions). For single-column indexes this is just
15458/// `[column_position]`.
15459fn unique_key_positions(idx: &spg_storage::Index) -> alloc::vec::Vec<usize> {
15460    let mut out = alloc::vec::Vec::with_capacity(1 + idx.extra_column_positions.len());
15461    out.push(idx.column_position);
15462    out.extend_from_slice(&idx.extra_column_positions);
15463    out
15464}
15465
15466/// v7.9.29 — at INSERT time, walk every `is_unique` index on the
15467/// target table. For each, eval the index's optional predicate
15468/// against (a) the candidate row and (b) every committed row plus
15469/// earlier batch rows; only rows where the predicate is truthy
15470/// participate. A duplicate key among predicate-matching rows is a
15471/// uniqueness violation. NULL keys lift the row out of the check
15472/// (matching PG's "UNIQUE allows multiple NULLs" semantics).
15473fn enforce_unique_index_inserts(
15474    catalog: &Catalog,
15475    table_name: &str,
15476    rows: &[alloc::vec::Vec<spg_storage::Value>],
15477) -> Result<(), EngineError> {
15478    let table = catalog.get(table_name).ok_or_else(|| {
15479        EngineError::Storage(StorageError::TableNotFound {
15480            name: table_name.into(),
15481        })
15482    })?;
15483    let schema = table.schema();
15484    let ctx = eval::EvalContext::new(&schema.columns, None);
15485    for idx in table.indices() {
15486        if !idx.is_unique {
15487            continue;
15488        }
15489        // Re-parse the predicate once per index per batch.
15490        let predicate_expr = match idx.partial_predicate.as_deref() {
15491            Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
15492                EngineError::Unsupported(alloc::format!(
15493                    "UNIQUE INDEX {:?} predicate {s:?} failed to re-parse: {e:?}",
15494                    idx.name
15495                ))
15496            })?),
15497            None => None,
15498        };
15499        let key_positions = unique_key_positions(idx);
15500        let key_of = |values: &[spg_storage::Value]| -> alloc::vec::Vec<spg_storage::Value> {
15501            // v7.17.0 Phase 3.P0-45 — fold per-column collation
15502            // before building the comparison key so a `*_ci`
15503            // column treats `'Foo'` and `'FOO'` as equal.
15504            key_positions
15505                .iter()
15506                .map(|&p| {
15507                    let v = values.get(p).cloned().unwrap_or(spg_storage::Value::Null);
15508                    collated_key_cell(&v, p, schema)
15509                })
15510                .collect()
15511        };
15512        // Helper: does `values` participate in this index? (predicate
15513        // truthy when present.) Wraps `values` into a transient Row
15514        // because eval_expr requires &Row.
15515        let participates = |values: &[spg_storage::Value]| -> Result<bool, EngineError> {
15516            let Some(expr) = &predicate_expr else {
15517                return Ok(true);
15518            };
15519            let tmp_row = spg_storage::Row {
15520                values: values.to_vec(),
15521            };
15522            let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
15523                EngineError::Unsupported(alloc::format!(
15524                    "UNIQUE INDEX {:?} predicate eval: {e:?}",
15525                    idx.name
15526                ))
15527            })?;
15528            Ok(predicate_truthy(&v))
15529        };
15530        for (batch_idx, row_values) in rows.iter().enumerate() {
15531            if !participates(row_values)? {
15532                continue;
15533            }
15534            let key = key_of(row_values);
15535            if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
15536                continue;
15537            }
15538            // Committed-table collision.
15539            for prow in table.rows() {
15540                if !participates(&prow.values)? {
15541                    continue;
15542                }
15543                if key_of(&prow.values) == key {
15544                    return Err(EngineError::Unsupported(alloc::format!(
15545                        "UNIQUE INDEX {:?} violation on {table_name:?}: \
15546                         row #{batch_idx} duplicates an existing key",
15547                        idx.name
15548                    )));
15549                }
15550            }
15551            // Within-batch collision: earlier rows in the same INSERT.
15552            for earlier in &rows[..batch_idx] {
15553                if !participates(earlier)? {
15554                    continue;
15555                }
15556                if key_of(earlier) == key {
15557                    return Err(EngineError::Unsupported(alloc::format!(
15558                        "UNIQUE INDEX {:?} violation on {table_name:?}: \
15559                         row #{batch_idx} duplicates an earlier row in the same batch",
15560                        idx.name
15561                    )));
15562                }
15563            }
15564        }
15565    }
15566    Ok(())
15567}
15568
15569/// v7.13.0 — `UPDATE OF cols` filter helper (mailrs round-5 G7).
15570/// Returns `true` when at least one of `filter_cols` has a
15571/// different value in `new_row` vs `old_row`. Column lookup is
15572/// case-insensitive against `schema_cols`; unknown filter columns
15573/// are treated as "not changed" (the trigger therefore won't
15574/// fire on them — surfacing a parse-time error would be too
15575/// strict for catalog reloads where the schema may have drifted).
15576fn any_column_changed(
15577    filter_cols: &[String],
15578    schema_cols: &[ColumnSchema],
15579    old_row: &Row,
15580    new_row: &Row,
15581) -> bool {
15582    for col_name in filter_cols {
15583        let Some(pos) = schema_cols
15584            .iter()
15585            .position(|c| c.name.eq_ignore_ascii_case(col_name))
15586        else {
15587            continue;
15588        };
15589        let old_v = old_row.values.get(pos);
15590        let new_v = new_row.values.get(pos);
15591        if old_v != new_v {
15592            return true;
15593        }
15594    }
15595    false
15596}
15597
15598/// v7.13.0 — evaluate every CHECK predicate on the schema against
15599/// each candidate row. Mirrors PG semantics: a `false` result
15600/// rejects the mutation; a NULL result *passes* (CHECK rejects
15601/// only on definite-false, not on unknown). mailrs round-5 G3.
15602fn enforce_check_constraints(
15603    catalog: &Catalog,
15604    table_name: &str,
15605    rows: &[alloc::vec::Vec<spg_storage::Value>],
15606) -> Result<(), EngineError> {
15607    let table = catalog.get(table_name).ok_or_else(|| {
15608        EngineError::Storage(StorageError::TableNotFound {
15609            name: table_name.into(),
15610        })
15611    })?;
15612    let schema = table.schema();
15613    // v7.17.0 Phase 1.5 — domain-level CHECKs are enforced in
15614    // parallel with table-level CHECKs. Collect both lists up
15615    // front; if neither exists we early-out.
15616    let mut domain_checks_per_col: alloc::vec::Vec<(usize, alloc::vec::Vec<Expr>)> =
15617        alloc::vec::Vec::new();
15618    for (idx, col) in schema.columns.iter().enumerate() {
15619        let Some(dname) = &col.user_domain_type else {
15620            continue;
15621        };
15622        let Some(dom) = catalog.domain_types().get(dname) else {
15623            continue;
15624        };
15625        let mut parsed_for_col: alloc::vec::Vec<Expr> =
15626            alloc::vec::Vec::with_capacity(dom.checks.len());
15627        for src in &dom.checks {
15628            let expr = spg_sql::parser::parse_expression(src).map_err(|e| {
15629                EngineError::Unsupported(alloc::format!(
15630                    "DOMAIN {dname:?} CHECK ({src:?}) on column {:?}: re-parse failed: {e:?}",
15631                    col.name
15632                ))
15633            })?;
15634            parsed_for_col.push(expr);
15635        }
15636        if !parsed_for_col.is_empty() {
15637            domain_checks_per_col.push((idx, parsed_for_col));
15638        }
15639    }
15640    if schema.checks.is_empty() && domain_checks_per_col.is_empty() {
15641        return Ok(());
15642    }
15643    let ctx = eval::EvalContext::new(&schema.columns, None);
15644    let mut parsed: alloc::vec::Vec<(usize, Expr)> = alloc::vec::Vec::new();
15645    for (i, src) in schema.checks.iter().enumerate() {
15646        let expr = spg_sql::parser::parse_expression(src).map_err(|e| {
15647            EngineError::Unsupported(alloc::format!(
15648                "CHECK constraint #{i} on {table_name:?} ({src:?}) failed to re-parse: {e:?}"
15649            ))
15650        })?;
15651        parsed.push((i, expr));
15652    }
15653    for (batch_idx, row_values) in rows.iter().enumerate() {
15654        let tmp_row = spg_storage::Row {
15655            values: row_values.clone(),
15656        };
15657        for (i, expr) in &parsed {
15658            let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
15659                EngineError::Unsupported(alloc::format!(
15660                    "CHECK constraint #{i} on {table_name:?} eval at row #{batch_idx}: {e:?}"
15661                ))
15662            })?;
15663            // PG: NULL passes (CHECK rejects on definite-false only).
15664            if matches!(v, spg_storage::Value::Bool(false)) {
15665                return Err(EngineError::Unsupported(alloc::format!(
15666                    "CHECK constraint violation on {table_name:?} (row #{batch_idx}): {:?}",
15667                    schema.checks[*i]
15668                )));
15669            }
15670        }
15671        // v7.17.0 Phase 1.5 — domain-level CHECKs. Each CHECK
15672        // expression references VALUE as a column-name; we
15673        // substitute the per-row cell into the eval context by
15674        // synthesising a single-column row of just that value
15675        // under a temporary `value` column schema.
15676        for (col_idx, checks) in &domain_checks_per_col {
15677            let cell = row_values
15678                .get(*col_idx)
15679                .cloned()
15680                .unwrap_or(spg_storage::Value::Null);
15681            let synth_cols = alloc::vec![spg_storage::ColumnSchema::new(
15682                "value",
15683                schema.columns[*col_idx].ty,
15684                schema.columns[*col_idx].nullable,
15685            )];
15686            let synth_ctx = eval::EvalContext::new(&synth_cols, None);
15687            let synth_row = spg_storage::Row {
15688                values: alloc::vec![cell],
15689            };
15690            for (ci, expr) in checks.iter().enumerate() {
15691                let v = eval::eval_expr(expr, &synth_row, &synth_ctx).map_err(|e| {
15692                    EngineError::Unsupported(alloc::format!(
15693                        "DOMAIN CHECK #{ci} on column {:?} eval at row #{batch_idx}: {e:?}",
15694                        schema.columns[*col_idx].name
15695                    ))
15696                })?;
15697                if matches!(v, spg_storage::Value::Bool(false)) {
15698                    return Err(EngineError::Unsupported(alloc::format!(
15699                        "DOMAIN CHECK violation on column {:?} (row #{batch_idx})",
15700                        schema.columns[*col_idx].name
15701                    )));
15702                }
15703            }
15704        }
15705    }
15706    Ok(())
15707}
15708
15709fn enforce_fk_inserts(
15710    catalog: &Catalog,
15711    child_table: &str,
15712    fks: &[spg_storage::ForeignKeyConstraint],
15713    rows: &[Vec<Value>],
15714) -> Result<(), EngineError> {
15715    for fk in fks {
15716        let parent_is_self = fk.parent_table == child_table;
15717        let parent = if parent_is_self {
15718            // Self-ref: read the current state of the same table.
15719            // The mut borrow on child has been dropped by the caller.
15720            catalog.get(child_table).ok_or_else(|| {
15721                EngineError::Storage(StorageError::TableNotFound {
15722                    name: child_table.into(),
15723                })
15724            })?
15725        } else {
15726            catalog.get(&fk.parent_table).ok_or_else(|| {
15727                EngineError::Storage(StorageError::TableNotFound {
15728                    name: fk.parent_table.clone(),
15729                })
15730            })?
15731        };
15732        for (batch_idx, row_values) in rows.iter().enumerate() {
15733            // Single-column FK fast path: try the parent's BTree
15734            // index for an O(log n) lookup. Composite FKs fall back
15735            // to a parent-row scan.
15736            if fk.local_columns.len() == 1 {
15737                let v = &row_values[fk.local_columns[0]];
15738                if matches!(v, Value::Null) {
15739                    continue;
15740                }
15741                let parent_col = fk.parent_columns[0];
15742                let key = spg_storage::IndexKey::from_value(v).ok_or_else(|| {
15743                    EngineError::Unsupported(alloc::format!(
15744                        "FOREIGN KEY column value of type {:?} is not index-eligible",
15745                        v.data_type()
15746                    ))
15747                })?;
15748                let present_committed = parent.indices().iter().any(|idx| {
15749                    matches!(idx.kind, spg_storage::IndexKind::BTree(_))
15750                        && idx.column_position == parent_col
15751                        && idx.partial_predicate.is_none()
15752                        && !idx.lookup_eq(&key).is_empty()
15753                });
15754                // v7.6.7 self-ref widening: also accept a match
15755                // against earlier rows in this same batch when the
15756                // FK points at the table being inserted into.
15757                let present_in_batch = parent_is_self
15758                    && rows[..batch_idx]
15759                        .iter()
15760                        .any(|earlier| earlier.get(parent_col) == Some(v));
15761                if !(present_committed || present_in_batch) {
15762                    return Err(EngineError::Unsupported(alloc::format!(
15763                        "FOREIGN KEY violation: no parent row in {:?} where {} = {:?}",
15764                        fk.parent_table,
15765                        parent
15766                            .schema()
15767                            .columns
15768                            .get(parent_col)
15769                            .map_or("?", |c| c.name.as_str()),
15770                        v,
15771                    )));
15772                }
15773            } else {
15774                // Composite FK: scan parent rows. v7.6.7 also
15775                // accepts a match against earlier rows in the same
15776                // batch (self-ref bulk-loading of hierarchies).
15777                if fk
15778                    .local_columns
15779                    .iter()
15780                    .all(|&i| matches!(row_values.get(i), Some(Value::Null)))
15781                {
15782                    continue;
15783                }
15784                let local: Vec<&Value> = fk.local_columns.iter().map(|&i| &row_values[i]).collect();
15785                let parent_match_committed = parent.rows().iter().any(|prow| {
15786                    fk.parent_columns
15787                        .iter()
15788                        .enumerate()
15789                        .all(|(i, &pi)| prow.values.get(pi) == Some(local[i]))
15790                });
15791                let parent_match_in_batch = parent_is_self
15792                    && rows[..batch_idx].iter().any(|earlier| {
15793                        fk.parent_columns
15794                            .iter()
15795                            .enumerate()
15796                            .all(|(i, &pi)| earlier.get(pi) == Some(local[i]))
15797                    });
15798                if !(parent_match_committed || parent_match_in_batch) {
15799                    return Err(EngineError::Unsupported(alloc::format!(
15800                        "FOREIGN KEY violation: no parent row in {:?} matching composite key",
15801                        fk.parent_table,
15802                    )));
15803                }
15804            }
15805        }
15806    }
15807    Ok(())
15808}
15809
15810/// v7.6.4 / v7.6.5 — one step of the FK action plan computed for a
15811/// DELETE on a parent. The plan is a list of these steps, stacked
15812/// across the FK graph by `plan_fk_parent_deletions`.
15813#[derive(Debug, Clone)]
15814struct FkChildStep {
15815    child_table: String,
15816    action: FkChildAction,
15817}
15818
15819#[derive(Debug, Clone)]
15820enum FkChildAction {
15821    /// CASCADE — remove these rows. Sorted, deduplicated positions.
15822    Delete { positions: Vec<usize> },
15823    /// SET NULL — for each (row, column) in the flat list, write
15824    /// NULL into that child cell. Multiple FKs on the same row may
15825    /// produce overlapping entries (deduped at plan time).
15826    SetNull {
15827        positions: Vec<usize>,
15828        columns: Vec<usize>,
15829    },
15830    /// SET DEFAULT — same shape as SetNull but writes the column's
15831    /// declared DEFAULT value (resolved at plan time). Columns
15832    /// without a DEFAULT raise an error during planning.
15833    SetDefault {
15834        positions: Vec<usize>,
15835        columns: Vec<usize>,
15836        defaults: Vec<Value>,
15837    },
15838}
15839
15840/// v7.6.3 → v7.6.5 — plan FK fallout for a DELETE on a parent table.
15841///
15842/// Walks every table in the catalog looking for FKs whose
15843/// `parent_table` is `parent_table_name`. For each such FK + each
15844/// to-be-deleted parent row:
15845///
15846///   - RESTRICT / NoAction → error, no plan returned
15847///   - CASCADE → child rows get scheduled for deletion; recursive
15848///   - SetNull → child FK column(s) scheduled to be NULL-ed.
15849///     Verified NULL-able at plan time.
15850///   - SetDefault → child FK column(s) scheduled to be reset to
15851///     their declared DEFAULT. Columns without a DEFAULT raise.
15852///
15853/// SET NULL / SET DEFAULT do NOT cascade further — the child row
15854/// stays; only one of its columns mutates.
15855fn plan_fk_parent_deletions(
15856    catalog: &Catalog,
15857    parent_table_name: &str,
15858    to_delete_positions: &[usize],
15859    to_delete_rows: &[Vec<Value>],
15860) -> Result<Vec<FkChildStep>, EngineError> {
15861    use alloc::collections::{BTreeMap, BTreeSet};
15862    if to_delete_rows.is_empty() {
15863        return Ok(Vec::new());
15864    }
15865    let mut delete_plan: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
15866    // setnull / setdefault keyed by child_table → (row_idx, col_idx) → optional default
15867    let mut setnull_plan: BTreeMap<String, BTreeSet<(usize, usize)>> = BTreeMap::new();
15868    let mut setdefault_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
15869    let mut visited: BTreeSet<(String, usize)> = BTreeSet::new();
15870    for &p in to_delete_positions {
15871        visited.insert((parent_table_name.to_string(), p));
15872    }
15873    let mut work: Vec<(String, Vec<Value>)> = to_delete_rows
15874        .iter()
15875        .map(|r| (parent_table_name.to_string(), r.clone()))
15876        .collect();
15877    while let Some((cur_parent, parent_row)) = work.pop() {
15878        for child_name in catalog.table_names() {
15879            let child = catalog
15880                .get(&child_name)
15881                .expect("table_names → catalog.get round-trip is total");
15882            for fk in &child.schema().foreign_keys {
15883                if fk.parent_table != cur_parent {
15884                    continue;
15885                }
15886                let parent_key: Vec<&Value> = fk
15887                    .parent_columns
15888                    .iter()
15889                    .map(|&pi| &parent_row[pi])
15890                    .collect();
15891                if parent_key.iter().any(|v| matches!(v, Value::Null)) {
15892                    continue;
15893                }
15894                for (child_row_idx, child_row) in child.rows().iter().enumerate() {
15895                    if child_name == cur_parent
15896                        && visited.contains(&(child_name.clone(), child_row_idx))
15897                    {
15898                        continue;
15899                    }
15900                    let matches_key = fk
15901                        .local_columns
15902                        .iter()
15903                        .enumerate()
15904                        .all(|(i, &li)| child_row.values.get(li) == Some(parent_key[i]));
15905                    if !matches_key {
15906                        continue;
15907                    }
15908                    match fk.on_delete {
15909                        spg_storage::FkAction::Restrict | spg_storage::FkAction::NoAction => {
15910                            return Err(EngineError::Unsupported(alloc::format!(
15911                                "FOREIGN KEY violation: DELETE on {cur_parent:?} is \
15912                                 restricted by FK from {child_name:?}.{:?}",
15913                                fk.local_columns,
15914                            )));
15915                        }
15916                        spg_storage::FkAction::Cascade => {
15917                            if visited.insert((child_name.clone(), child_row_idx)) {
15918                                delete_plan
15919                                    .entry(child_name.clone())
15920                                    .or_default()
15921                                    .insert(child_row_idx);
15922                                work.push((child_name.clone(), child_row.values.clone()));
15923                            }
15924                        }
15925                        spg_storage::FkAction::SetNull => {
15926                            // Verify every local FK column is NULL-able.
15927                            for &li in &fk.local_columns {
15928                                let col = child.schema().columns.get(li).ok_or_else(|| {
15929                                    EngineError::Unsupported(alloc::format!(
15930                                        "FK local column {li} missing in {child_name:?}"
15931                                    ))
15932                                })?;
15933                                if !col.nullable {
15934                                    return Err(EngineError::Unsupported(alloc::format!(
15935                                        "FOREIGN KEY ON DELETE SET NULL: column \
15936                                         {child_name:?}.{:?} is NOT NULL — cannot SET NULL",
15937                                        col.name,
15938                                    )));
15939                                }
15940                            }
15941                            let entry = setnull_plan.entry(child_name.clone()).or_default();
15942                            for &li in &fk.local_columns {
15943                                entry.insert((child_row_idx, li));
15944                            }
15945                        }
15946                        spg_storage::FkAction::SetDefault => {
15947                            // Resolve the DEFAULT for every local FK col.
15948                            let entry = setdefault_plan.entry(child_name.clone()).or_default();
15949                            for &li in &fk.local_columns {
15950                                let col = child.schema().columns.get(li).ok_or_else(|| {
15951                                    EngineError::Unsupported(alloc::format!(
15952                                        "FK local column {li} missing in {child_name:?}"
15953                                    ))
15954                                })?;
15955                                let default = col.default.clone().ok_or_else(|| {
15956                                    EngineError::Unsupported(alloc::format!(
15957                                        "FOREIGN KEY ON DELETE SET DEFAULT: column \
15958                                         {child_name:?}.{:?} has no DEFAULT declared",
15959                                        col.name,
15960                                    ))
15961                                })?;
15962                                entry.insert((child_row_idx, li), default);
15963                            }
15964                        }
15965                    }
15966                }
15967            }
15968        }
15969    }
15970    // Flatten the three plans into the ordered `FkChildStep` list.
15971    // Deletes are applied last per child (after any null/default
15972    // re-writes on the same child) so a child row that's both
15973    // re-written and then cascade-deleted only ends up deleted —
15974    // but in v7.6.5 SetNull/Cascade never overlap on the same row
15975    // (a single FK chooses exactly one action), so the order is
15976    // mostly a precaution.
15977    let mut steps: Vec<FkChildStep> = Vec::new();
15978    for (child_table, entries) in setnull_plan {
15979        let (positions, columns): (Vec<usize>, Vec<usize>) = entries.into_iter().unzip();
15980        steps.push(FkChildStep {
15981            child_table,
15982            action: FkChildAction::SetNull { positions, columns },
15983        });
15984    }
15985    for (child_table, entries) in setdefault_plan {
15986        let mut positions = Vec::with_capacity(entries.len());
15987        let mut columns = Vec::with_capacity(entries.len());
15988        let mut defaults = Vec::with_capacity(entries.len());
15989        for ((p, c), v) in entries {
15990            positions.push(p);
15991            columns.push(c);
15992            defaults.push(v);
15993        }
15994        steps.push(FkChildStep {
15995            child_table,
15996            action: FkChildAction::SetDefault {
15997                positions,
15998                columns,
15999                defaults,
16000            },
16001        });
16002    }
16003    for (child_table, positions) in delete_plan {
16004        steps.push(FkChildStep {
16005            child_table,
16006            action: FkChildAction::Delete {
16007                positions: positions.into_iter().collect(),
16008            },
16009        });
16010    }
16011    Ok(steps)
16012}
16013
16014/// v7.6.6 — plan FK fallout for an UPDATE that mutates parent-side
16015/// PK/UNIQUE columns. Walks every other table whose FK references
16016/// `parent_table_name`; for each FK whose parent_columns overlap a
16017/// mutated column, decides the action by `fk.on_update`.
16018///
16019///   - RESTRICT / NoAction → error if any child references the OLD
16020///     value
16021///   - CASCADE → child FK columns get rewritten to the NEW parent
16022///     value (a SetNull-style update step with the new value)
16023///   - SetNull → child FK columns set to NULL
16024///   - SetDefault → child FK columns set to declared default
16025///
16026/// `plan_with_old` is `(row_position, old_values, new_values)` so
16027/// the planner can detect "did this row's parent key actually
16028/// change?" — only rows where at least one referenced parent
16029/// column moved trigger inbound work.
16030fn plan_fk_parent_updates(
16031    catalog: &Catalog,
16032    parent_table_name: &str,
16033    plan_with_old: &[(usize, Vec<Value>, Vec<Value>)],
16034) -> Result<Vec<FkChildStep>, EngineError> {
16035    use alloc::collections::BTreeMap;
16036    if plan_with_old.is_empty() {
16037        return Ok(Vec::new());
16038    }
16039    // For each child table we may touch, build per-child step
16040    // lists. UPDATE never deletes children — `delete_plan` stays
16041    // empty here but is kept structurally aligned with
16042    // `plan_fk_parent_deletions` for future use.
16043    let delete_plan: BTreeMap<String, alloc::collections::BTreeSet<usize>> = BTreeMap::new();
16044    let mut setnull_plan: BTreeMap<String, alloc::collections::BTreeSet<(usize, usize)>> =
16045        BTreeMap::new();
16046    let mut setdefault_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
16047    // Cascade-update plan: child_table → row_idx → col_idx → new_value
16048    let mut cascade_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
16049
16050    for child_name in catalog.table_names() {
16051        let child = catalog
16052            .get(&child_name)
16053            .expect("table_names → catalog.get total");
16054        for fk in &child.schema().foreign_keys {
16055            if fk.parent_table != parent_table_name {
16056                continue;
16057            }
16058            for (_pos, old_row, new_row) in plan_with_old {
16059                // Did any parent FK column change?
16060                let key_changed = fk
16061                    .parent_columns
16062                    .iter()
16063                    .any(|&pi| old_row.get(pi) != new_row.get(pi));
16064                if !key_changed {
16065                    continue;
16066                }
16067                // The OLD parent key — used to find referring children.
16068                let old_key: Vec<&Value> =
16069                    fk.parent_columns.iter().map(|&pi| &old_row[pi]).collect();
16070                if old_key.iter().any(|v| matches!(v, Value::Null)) {
16071                    // NULL parent has no children — skip.
16072                    continue;
16073                }
16074                let new_key: Vec<&Value> =
16075                    fk.parent_columns.iter().map(|&pi| &new_row[pi]).collect();
16076                for (child_row_idx, child_row) in child.rows().iter().enumerate() {
16077                    // Self-ref same-row updates: a row updating its
16078                    // own PK doesn't restrict itself.
16079                    if child_name == parent_table_name
16080                        && plan_with_old.iter().any(|(p, _, _)| *p == child_row_idx)
16081                    {
16082                        continue;
16083                    }
16084                    let matches_key = fk
16085                        .local_columns
16086                        .iter()
16087                        .enumerate()
16088                        .all(|(i, &li)| child_row.values.get(li) == Some(old_key[i]));
16089                    if !matches_key {
16090                        continue;
16091                    }
16092                    match fk.on_update {
16093                        spg_storage::FkAction::Restrict | spg_storage::FkAction::NoAction => {
16094                            return Err(EngineError::Unsupported(alloc::format!(
16095                                "FOREIGN KEY violation: UPDATE on {parent_table_name:?} PK is \
16096                                 restricted by FK from {child_name:?}.{:?}",
16097                                fk.local_columns,
16098                            )));
16099                        }
16100                        spg_storage::FkAction::Cascade => {
16101                            // Rewrite child FK columns to new key.
16102                            let entry = cascade_plan.entry(child_name.clone()).or_default();
16103                            for (i, &li) in fk.local_columns.iter().enumerate() {
16104                                entry.insert((child_row_idx, li), new_key[i].clone());
16105                            }
16106                        }
16107                        spg_storage::FkAction::SetNull => {
16108                            for &li in &fk.local_columns {
16109                                let col = child.schema().columns.get(li).ok_or_else(|| {
16110                                    EngineError::Unsupported(alloc::format!(
16111                                        "FK local column {li} missing in {child_name:?}"
16112                                    ))
16113                                })?;
16114                                if !col.nullable {
16115                                    return Err(EngineError::Unsupported(alloc::format!(
16116                                        "FOREIGN KEY ON UPDATE SET NULL: column \
16117                                         {child_name:?}.{:?} is NOT NULL",
16118                                        col.name,
16119                                    )));
16120                                }
16121                            }
16122                            let entry = setnull_plan.entry(child_name.clone()).or_default();
16123                            for &li in &fk.local_columns {
16124                                entry.insert((child_row_idx, li));
16125                            }
16126                        }
16127                        spg_storage::FkAction::SetDefault => {
16128                            let entry = setdefault_plan.entry(child_name.clone()).or_default();
16129                            for &li in &fk.local_columns {
16130                                let col = child.schema().columns.get(li).ok_or_else(|| {
16131                                    EngineError::Unsupported(alloc::format!(
16132                                        "FK local column {li} missing in {child_name:?}"
16133                                    ))
16134                                })?;
16135                                let default = col.default.clone().ok_or_else(|| {
16136                                    EngineError::Unsupported(alloc::format!(
16137                                        "FOREIGN KEY ON UPDATE SET DEFAULT: column \
16138                                         {child_name:?}.{:?} has no DEFAULT",
16139                                        col.name,
16140                                    ))
16141                                })?;
16142                                entry.insert((child_row_idx, li), default);
16143                            }
16144                        }
16145                    }
16146                }
16147            }
16148        }
16149    }
16150    // Flatten into FkChildStep list. UPDATE doesn't produce
16151    // DeleteSteps (CASCADE on UPDATE just rewrites FK values).
16152    let mut steps: Vec<FkChildStep> = Vec::new();
16153    for (child_table, entries) in cascade_plan {
16154        let mut positions = Vec::with_capacity(entries.len());
16155        let mut columns = Vec::with_capacity(entries.len());
16156        let mut defaults = Vec::with_capacity(entries.len());
16157        for ((p, c), v) in entries {
16158            positions.push(p);
16159            columns.push(c);
16160            defaults.push(v);
16161        }
16162        // We reuse `FkChildAction::SetDefault` for cascade-update:
16163        // both shapes are "write a known value into specific cells"
16164        // — `apply_per_cell_writes` doesn't care whether the value
16165        // came from a DEFAULT declaration or a new parent key.
16166        steps.push(FkChildStep {
16167            child_table,
16168            action: FkChildAction::SetDefault {
16169                positions,
16170                columns,
16171                defaults,
16172            },
16173        });
16174    }
16175    for (child_table, entries) in setnull_plan {
16176        let (positions, columns): (Vec<usize>, Vec<usize>) = entries.into_iter().unzip();
16177        steps.push(FkChildStep {
16178            child_table,
16179            action: FkChildAction::SetNull { positions, columns },
16180        });
16181    }
16182    for (child_table, entries) in setdefault_plan {
16183        let mut positions = Vec::with_capacity(entries.len());
16184        let mut columns = Vec::with_capacity(entries.len());
16185        let mut defaults = Vec::with_capacity(entries.len());
16186        for ((p, c), v) in entries {
16187            positions.push(p);
16188            columns.push(c);
16189            defaults.push(v);
16190        }
16191        steps.push(FkChildStep {
16192            child_table,
16193            action: FkChildAction::SetDefault {
16194                positions,
16195                columns,
16196                defaults,
16197            },
16198        });
16199    }
16200    let _ = delete_plan; // UPDATE never deletes children.
16201    Ok(steps)
16202}
16203
16204/// v7.6.5 — apply one FK child step to the catalog. Encapsulates
16205/// the three action variants so the DELETE executor stays a
16206/// simple loop over the planned steps.
16207fn apply_fk_child_step(catalog: &mut Catalog, step: &FkChildStep) -> Result<(), EngineError> {
16208    let child = catalog.get_mut(&step.child_table).ok_or_else(|| {
16209        EngineError::Storage(StorageError::TableNotFound {
16210            name: step.child_table.clone(),
16211        })
16212    })?;
16213    match &step.action {
16214        FkChildAction::Delete { positions } => {
16215            let _ = child.delete_rows(positions);
16216        }
16217        FkChildAction::SetNull { positions, columns } => {
16218            apply_per_cell_writes(child, positions, columns, |_| Value::Null)?;
16219        }
16220        FkChildAction::SetDefault {
16221            positions,
16222            columns,
16223            defaults,
16224        } => {
16225            apply_per_cell_writes(child, positions, columns, |i| defaults[i].clone())?;
16226        }
16227    }
16228    Ok(())
16229}
16230
16231/// v7.6.5 — write new values into selected child cells via
16232/// `Table::update_row` (the catalog's existing UPDATE entry).
16233/// Groups writes by row position so multi-column updates on the
16234/// same row only call `update_row` once. `value_for(i)` produces
16235/// the new value for the i-th (position, column) entry.
16236fn apply_per_cell_writes(
16237    child: &mut spg_storage::Table,
16238    positions: &[usize],
16239    columns: &[usize],
16240    mut value_for: impl FnMut(usize) -> Value,
16241) -> Result<(), EngineError> {
16242    use alloc::collections::BTreeMap;
16243    let mut by_row: BTreeMap<usize, Vec<(usize, Value)>> = BTreeMap::new();
16244    for i in 0..positions.len() {
16245        by_row
16246            .entry(positions[i])
16247            .or_default()
16248            .push((columns[i], value_for(i)));
16249    }
16250    for (pos, mutations) in by_row {
16251        let mut new_values = child.rows()[pos].values.clone();
16252        for (col, v) in mutations {
16253            if let Some(slot) = new_values.get_mut(col) {
16254                *slot = v;
16255            }
16256        }
16257        child
16258            .update_row(pos, new_values)
16259            .map_err(EngineError::Storage)?;
16260    }
16261    Ok(())
16262}
16263
16264fn fk_action_sql_to_storage(a: spg_sql::ast::FkAction) -> spg_storage::FkAction {
16265    match a {
16266        spg_sql::ast::FkAction::Restrict => spg_storage::FkAction::Restrict,
16267        spg_sql::ast::FkAction::Cascade => spg_storage::FkAction::Cascade,
16268        spg_sql::ast::FkAction::SetNull => spg_storage::FkAction::SetNull,
16269        spg_sql::ast::FkAction::SetDefault => spg_storage::FkAction::SetDefault,
16270        spg_sql::ast::FkAction::NoAction => spg_storage::FkAction::NoAction,
16271    }
16272}
16273
16274/// v7.9.21 — resolve a column's DEFAULT for INSERT-time
16275/// default-fill. Free fn (rather than `&self`) so callers
16276/// with an active `&mut Table` borrow can still use it.
16277/// Literal defaults take the cached path (`col.default`);
16278/// runtime defaults hit `clock_fn` at each call. mailrs G4.
16279fn resolve_column_default_free(
16280    col: &ColumnSchema,
16281    clock_fn: Option<ClockFn>,
16282) -> Result<Value, EngineError> {
16283    if let Some(rt) = &col.runtime_default {
16284        return eval_runtime_default_free(rt, col.ty, clock_fn);
16285    }
16286    Ok(col.default.clone().unwrap_or(Value::Null))
16287}
16288
16289fn eval_runtime_default_free(
16290    rt: &str,
16291    ty: DataType,
16292    clock_fn: Option<ClockFn>,
16293) -> Result<Value, EngineError> {
16294    let s = rt.trim().to_ascii_lowercase();
16295    // v7.17.0 Phase 2.1 — also strip `(N)` precision suffix
16296    // so MySQL `CURRENT_TIMESTAMP(6)` resolves the same as
16297    // bare `CURRENT_TIMESTAMP`. SPG stores TIMESTAMP at fixed
16298    // microsecond resolution; the precision modifier is
16299    // parser-only.
16300    let with_no_parens = s.trim_end_matches("()");
16301    let canonical: &str = if let Some(open_idx) = with_no_parens.find('(') {
16302        if with_no_parens.ends_with(')') {
16303            &with_no_parens[..open_idx]
16304        } else {
16305            with_no_parens
16306        }
16307    } else {
16308        with_no_parens
16309    };
16310    let now_us = match clock_fn {
16311        Some(f) => f(),
16312        None => 0,
16313    };
16314    let v = match canonical {
16315        "now" | "current_timestamp" | "localtimestamp" => Value::Timestamp(now_us),
16316        "current_date" => Value::Date((now_us / 86_400_000_000) as i32),
16317        "current_time" | "localtime" => Value::Timestamp(now_us),
16318        // v7.17.0 — UUID generators in DEFAULT clauses. Required
16319        // for the canonical Django / Rails / Hibernate `id UUID
16320        // PRIMARY KEY DEFAULT gen_random_uuid()` pattern. Each
16321        // INSERT evaluates the function fresh; the per-row UUID
16322        // is the storage value, not a cached literal.
16323        "gen_random_uuid" | "uuid_generate_v4" => Value::Uuid(eval::gen_random_uuid_bytes()),
16324        other => {
16325            return Err(EngineError::Unsupported(alloc::format!(
16326                "runtime DEFAULT expression {other:?} not supported \
16327                 (v7.17.0 whitelist: now() / current_timestamp / \
16328                 current_date / current_time / localtimestamp / \
16329                 localtime / gen_random_uuid() / \
16330                 uuid_generate_v4())"
16331            )));
16332        }
16333    };
16334    coerce_value(v, ty, "DEFAULT", 0)
16335}
16336
16337/// v7.9.21 — true when a DEFAULT expression needs INSERT-time
16338/// evaluation rather than being cacheable as a literal Value.
16339/// FunctionCall is the immediate case (`now()`,
16340/// `current_timestamp`). Literal expressions and simple sign-
16341/// flipped numerics still take the static-cache path.
16342fn is_runtime_default_expr(expr: &Expr) -> bool {
16343    match expr {
16344        Expr::FunctionCall { .. } => true,
16345        Expr::Unary { expr, .. } => is_runtime_default_expr(expr),
16346        _ => false,
16347    }
16348}
16349
16350/// v7.17.0 Phase 1.4 — INSERT/UPDATE-time enum label check. When
16351/// `col_idx` has a registered label list, the cell value must be
16352/// NULL or one of the labels (case-sensitive per PG).
16353/// v7.17.0 Phase 3.P0-37 — validate + canonicalise a MySQL inline
16354/// SET cell. For non-SET columns this is a no-op pass-through.
16355///
16356/// Semantics:
16357///   * NULL preserved.
16358///   * Empty string → `''` (zero flags).
16359///   * Otherwise split on ',', trim each token, validate every
16360///     token against the column's variant list (error on miss),
16361///     de-dup, then re-emit in DEFINITION order joined by ','.
16362fn canonicalize_set_value(
16363    lookup: &alloc::collections::BTreeMap<usize, Vec<String>>,
16364    col_idx: usize,
16365    col_name: &str,
16366    value: Value,
16367) -> Result<Value, EngineError> {
16368    let Some(variants) = lookup.get(&col_idx) else {
16369        return Ok(value);
16370    };
16371    match value {
16372        Value::Null => Ok(Value::Null),
16373        Value::Text(s) => {
16374            if s.is_empty() {
16375                return Ok(Value::Text(alloc::string::String::new()));
16376            }
16377            // Collect a presence-set of variant indices to keep
16378            // definition order + handle de-dup in one pass.
16379            let mut present = alloc::vec![false; variants.len()];
16380            for raw in s.split(',') {
16381                let tok = raw.trim();
16382                if tok.is_empty() {
16383                    continue;
16384                }
16385                let idx = variants.iter().position(|v| v == tok).ok_or_else(|| {
16386                    EngineError::Unsupported(alloc::format!(
16387                        "column {col_name:?}: invalid SET token {tok:?}; \
16388                         allowed: {variants:?}"
16389                    ))
16390                })?;
16391                present[idx] = true;
16392            }
16393            // Re-emit in definition order.
16394            let mut out = alloc::string::String::new();
16395            let mut first = true;
16396            for (i, keep) in present.iter().enumerate() {
16397                if !keep {
16398                    continue;
16399                }
16400                if !first {
16401                    out.push(',');
16402                }
16403                first = false;
16404                out.push_str(&variants[i]);
16405            }
16406            Ok(Value::Text(out))
16407        }
16408        other => Err(EngineError::Unsupported(alloc::format!(
16409            "column {col_name:?}: SET-typed column expects TEXT, got {:?}",
16410            other.data_type()
16411        ))),
16412    }
16413}
16414
16415fn enforce_enum_label(
16416    lookup: &alloc::collections::BTreeMap<usize, Vec<String>>,
16417    col_idx: usize,
16418    col_name: &str,
16419    value: &Value,
16420) -> Result<(), EngineError> {
16421    if let Some(labels) = lookup.get(&col_idx) {
16422        match value {
16423            Value::Null => Ok(()),
16424            Value::Text(s) => {
16425                if labels.iter().any(|l| l == s) {
16426                    Ok(())
16427                } else {
16428                    Err(EngineError::Unsupported(alloc::format!(
16429                        "column {col_name:?}: invalid enum label {s:?}; allowed: {labels:?}"
16430                    )))
16431                }
16432            }
16433            other => Err(EngineError::Unsupported(alloc::format!(
16434                "column {col_name:?}: enum-typed column expects TEXT, got {:?}",
16435                other.data_type()
16436            ))),
16437        }
16438    } else {
16439        Ok(())
16440    }
16441}
16442
16443fn column_def_to_schema(c: ColumnDef) -> Result<ColumnSchema, EngineError> {
16444    let ty = column_type_to_data_type(c.ty);
16445    let mut schema = ColumnSchema::new(c.name.clone(), ty, c.nullable);
16446    // user_type_ref is the raw ident the parser couldn't resolve
16447    // to a built-in; classification into enum vs domain happens
16448    // at exec_create_table where we have catalog access. We
16449    // park it temporarily as user_enum_type and the engine
16450    // promotes domain bindings to user_domain_type before the
16451    // table is stored.
16452    if let Some(name) = c.user_type_ref {
16453        schema.user_enum_type = Some(name);
16454    }
16455    // v7.17.0 Phase 2.1 — render the ON UPDATE expression to
16456    // canonical text (the engine re-parses at UPDATE time).
16457    if let Some(expr) = c.on_update_runtime {
16458        schema.on_update_runtime = Some(alloc::format!("{expr}"));
16459    }
16460    // v7.17.0 Phase 2.5 — bridge the AST `Collation` enum to the
16461    // storage one. Same variants, different crates (spg-storage
16462    // owns no dep on spg-sql).
16463    schema.collation = match c.collation {
16464        spg_sql::ast::Collation::Binary => spg_storage::Collation::Binary,
16465        spg_sql::ast::Collation::CaseInsensitive => spg_storage::Collation::CaseInsensitive,
16466    };
16467    // v7.17.0 Phase 4.4 — MySQL `UNSIGNED` flag propagates to
16468    // storage so engine INSERT / UPDATE can range-check.
16469    schema.is_unsigned = c.is_unsigned;
16470    // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM variant list.
16471    // INSERT validation lives in coerce_value (Text → Text path
16472    // with the column's variant list as the accept-set).
16473    schema.inline_enum_variants = c.inline_enum_variants;
16474    // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant list.
16475    // INSERT canonicalisation (de-dup + sort by definition order)
16476    // lives in the exec_insert path next to the ENUM check.
16477    schema.inline_set_variants = c.inline_set_variants;
16478    if let Some(default_expr) = c.default {
16479        // v7.9.21 — distinguish literal defaults (evaluated once
16480        // at CREATE TABLE) from expression defaults (deferred to
16481        // INSERT). Function calls (`now()`, `current_timestamp`
16482        // — see v7.9.20 keyword promotion) take the runtime path.
16483        // Literals continue to cache. mailrs G4.
16484        if is_runtime_default_expr(&default_expr) {
16485            let display = alloc::format!("{default_expr}");
16486            schema = schema.with_runtime_default(display);
16487        } else {
16488            let raw = literal_expr_to_value(default_expr)?;
16489            let coerced = coerce_value(raw, ty, &c.name, 0)?;
16490            schema = schema.with_default(coerced);
16491        }
16492    }
16493    if c.auto_increment {
16494        // AUTO_INCREMENT only makes sense on integer-shaped columns.
16495        if !matches!(ty, DataType::SmallInt | DataType::Int | DataType::BigInt) {
16496            return Err(EngineError::Unsupported(alloc::format!(
16497                "AUTO_INCREMENT requires an integer column type, got {ty:?}"
16498            )));
16499        }
16500        schema = schema.with_auto_increment();
16501    }
16502    Ok(schema)
16503}
16504
16505/// v7.10.4 — decode a BYTEA literal. Accepts:
16506///   * `\xDEADBEEF` (case-insensitive hex; whitespace stripped)
16507///   * `Hello\000world` (backslash escape form; `\\` for literal backslash)
16508///   * Anything else → raw UTF-8 bytes of the input (PG accepts this too).
16509fn decode_bytea_literal(s: &str) -> Result<alloc::vec::Vec<u8>, &'static str> {
16510    let s = s.trim();
16511    if let Some(hex) = s.strip_prefix("\\x").or_else(|| s.strip_prefix("\\X")) {
16512        // Hex form. Each pair of hex digits → one byte.
16513        let cleaned: alloc::string::String = hex.chars().filter(|c| !c.is_whitespace()).collect();
16514        if cleaned.len() % 2 != 0 {
16515            return Err("odd-length hex literal");
16516        }
16517        let mut out = alloc::vec::Vec::with_capacity(cleaned.len() / 2);
16518        let cleaned_bytes = cleaned.as_bytes();
16519        for i in (0..cleaned_bytes.len()).step_by(2) {
16520            let hi = hex_nibble(cleaned_bytes[i])?;
16521            let lo = hex_nibble(cleaned_bytes[i + 1])?;
16522            out.push((hi << 4) | lo);
16523        }
16524        return Ok(out);
16525    }
16526    // Escape form or raw. Walk char-by-char; `\\` and `\NNN` octal
16527    // sequences decode; anything else is a literal byte.
16528    let bytes = s.as_bytes();
16529    let mut out = alloc::vec::Vec::with_capacity(bytes.len());
16530    let mut i = 0;
16531    while i < bytes.len() {
16532        let b = bytes[i];
16533        if b == b'\\' && i + 1 < bytes.len() {
16534            let n = bytes[i + 1];
16535            if n == b'\\' {
16536                out.push(b'\\');
16537                i += 2;
16538                continue;
16539            }
16540            if n.is_ascii_digit()
16541                && i + 3 < bytes.len()
16542                && bytes[i + 2].is_ascii_digit()
16543                && bytes[i + 3].is_ascii_digit()
16544            {
16545                let oct = |x: u8| (x - b'0') as u32;
16546                let v = oct(n) * 64 + oct(bytes[i + 2]) * 8 + oct(bytes[i + 3]);
16547                if v <= 0xFF {
16548                    out.push(v as u8);
16549                    i += 4;
16550                    continue;
16551                }
16552            }
16553        }
16554        out.push(b);
16555        i += 1;
16556    }
16557    Ok(out)
16558}
16559
16560fn hex_nibble(b: u8) -> Result<u8, &'static str> {
16561    match b {
16562        b'0'..=b'9' => Ok(b - b'0'),
16563        b'a'..=b'f' => Ok(b - b'a' + 10),
16564        b'A'..=b'F' => Ok(b - b'A' + 10),
16565        _ => Err("invalid hex digit"),
16566    }
16567}
16568
16569/// v7.10.11 — decode a PG TEXT[] external array form
16570/// (`{a,b,NULL}` with optional double-quoted elements). The
16571/// engine takes a leading/trailing `{`/`}` and splits at commas.
16572/// Quoted elements (`"hello, world"`) preserve embedded commas;
16573/// `\\` and `\"` decode to literal backslash / quote. Plain
16574/// unquoted `NULL` (case-insensitive) maps to `None`.
16575/// v7.11.13 — pick the array type for `ARRAY[lit, …]` from the
16576/// element values. Single-element-type rules:
16577///   - all NULL / all Text → TextArray
16578///   - all Int (or Int+NULL) → IntArray
16579///   - any BigInt without Text → BigIntArray (widening)
16580///   - any Text → TextArray (fallback; non-string elements
16581///     render as text)
16582fn array_literal_widen(items: alloc::vec::Vec<Value>) -> Value {
16583    let mut has_text = false;
16584    let mut has_bigint = false;
16585    let mut has_int = false;
16586    for v in &items {
16587        match v {
16588            Value::Null => {}
16589            Value::Text(_) | Value::Json(_) => has_text = true,
16590            Value::BigInt(_) => has_bigint = true,
16591            Value::Int(_) | Value::SmallInt(_) => has_int = true,
16592            _ => has_text = true,
16593        }
16594    }
16595    if has_text || (!has_bigint && !has_int) {
16596        let out: alloc::vec::Vec<Option<alloc::string::String>> = items
16597            .into_iter()
16598            .map(|v| match v {
16599                Value::Null => None,
16600                Value::Text(s) | Value::Json(s) => Some(s),
16601                other => Some(alloc::format!("{other:?}")),
16602            })
16603            .collect();
16604        return Value::TextArray(out);
16605    }
16606    if has_bigint {
16607        let out: alloc::vec::Vec<Option<i64>> = items
16608            .into_iter()
16609            .map(|v| match v {
16610                Value::Null => None,
16611                Value::Int(n) => Some(i64::from(n)),
16612                Value::SmallInt(n) => Some(i64::from(n)),
16613                Value::BigInt(n) => Some(n),
16614                _ => unreachable!("widen: unexpected non-integer in BigInt path"),
16615            })
16616            .collect();
16617        return Value::BigIntArray(out);
16618    }
16619    let out: alloc::vec::Vec<Option<i32>> = items
16620        .into_iter()
16621        .map(|v| match v {
16622            Value::Null => None,
16623            Value::Int(n) => Some(n),
16624            Value::SmallInt(n) => Some(i32::from(n)),
16625            _ => unreachable!("widen: unexpected non-i32-compatible in Int path"),
16626        })
16627        .collect();
16628    Value::IntArray(out)
16629}
16630
16631fn decode_text_array_literal(
16632    s: &str,
16633) -> Result<alloc::vec::Vec<Option<alloc::string::String>>, &'static str> {
16634    let trimmed = s.trim();
16635    let inner = trimmed
16636        .strip_prefix('{')
16637        .and_then(|x| x.strip_suffix('}'))
16638        .ok_or("TEXT[] literal must be enclosed in '{...}'")?;
16639    let mut out: alloc::vec::Vec<Option<alloc::string::String>> = alloc::vec::Vec::new();
16640    if inner.trim().is_empty() {
16641        return Ok(out);
16642    }
16643    let bytes = inner.as_bytes();
16644    let mut i = 0;
16645    while i <= bytes.len() {
16646        // Skip leading whitespace.
16647        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
16648            i += 1;
16649        }
16650        // Quoted element.
16651        if i < bytes.len() && bytes[i] == b'"' {
16652            i += 1; // open quote
16653            let mut buf = alloc::string::String::new();
16654            while i < bytes.len() && bytes[i] != b'"' {
16655                if bytes[i] == b'\\' && i + 1 < bytes.len() {
16656                    buf.push(bytes[i + 1] as char);
16657                    i += 2;
16658                } else {
16659                    buf.push(bytes[i] as char);
16660                    i += 1;
16661                }
16662            }
16663            if i >= bytes.len() {
16664                return Err("unterminated quoted element");
16665            }
16666            i += 1; // close quote
16667            out.push(Some(buf));
16668        } else {
16669            // Unquoted element — read until next comma or end.
16670            let start = i;
16671            while i < bytes.len() && bytes[i] != b',' {
16672                i += 1;
16673            }
16674            let raw = inner[start..i].trim();
16675            if raw.eq_ignore_ascii_case("NULL") {
16676                out.push(None);
16677            } else {
16678                out.push(Some(alloc::string::ToString::to_string(raw)));
16679            }
16680        }
16681        // Skip whitespace, expect comma or end.
16682        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
16683            i += 1;
16684        }
16685        if i >= bytes.len() {
16686            break;
16687        }
16688        if bytes[i] != b',' {
16689            return Err("expected ',' between TEXT[] elements");
16690        }
16691        i += 1;
16692    }
16693    Ok(out)
16694}
16695
16696/// v7.10.11 — encode a TEXT[] back into the PG external array
16697/// form. NULL elements become the literal `NULL`; elements
16698/// containing commas, quotes, backslashes, or braces are
16699/// double-quoted with `\\` / `\"` escapes.
16700fn encode_text_array(items: &[Option<alloc::string::String>]) -> alloc::string::String {
16701    let mut out = alloc::string::String::with_capacity(2 + items.len() * 8);
16702    out.push('{');
16703    for (i, item) in items.iter().enumerate() {
16704        if i > 0 {
16705            out.push(',');
16706        }
16707        match item {
16708            None => out.push_str("NULL"),
16709            Some(s) => {
16710                let needs_quote = s.is_empty()
16711                    || s.eq_ignore_ascii_case("NULL")
16712                    || s.chars()
16713                        .any(|c| matches!(c, ',' | '{' | '}' | '"' | '\\' | ' ' | '\t'));
16714                if needs_quote {
16715                    out.push('"');
16716                    for c in s.chars() {
16717                        if c == '"' || c == '\\' {
16718                            out.push('\\');
16719                        }
16720                        out.push(c);
16721                    }
16722                    out.push('"');
16723                } else {
16724                    out.push_str(s);
16725                }
16726            }
16727        }
16728    }
16729    out.push('}');
16730    out
16731}
16732
16733/// v7.10.4 — encode BYTEA bytes in PG hex output format
16734/// (`\x` prefix, lowercase hex pairs). Used by Text-side
16735/// round-trip + the wire layer's text-mode encoder.
16736fn encode_bytea_hex(b: &[u8]) -> alloc::string::String {
16737    let mut out = alloc::string::String::with_capacity(2 + 2 * b.len());
16738    out.push_str("\\x");
16739    for byte in b {
16740        let hi = byte >> 4;
16741        let lo = byte & 0x0F;
16742        out.push(hex_digit(hi));
16743        out.push(hex_digit(lo));
16744    }
16745    out
16746}
16747
16748const fn hex_digit(n: u8) -> char {
16749    match n {
16750        0..=9 => (b'0' + n) as char,
16751        10..=15 => (b'a' + n - 10) as char,
16752        _ => '?',
16753    }
16754}
16755
16756/// v7.17.0 Phase 3.P0-39 — parse a PG `hstore` text literal into
16757/// a flat key→value map. Empty string → empty map. Duplicate
16758/// keys take last-write-wins (matches PG `hstore_in`).
16759///
16760/// Accepted shapes (minimal subset):
16761///   * `'a=>1, b=>2'`            — bareword keys/values
16762///   * `'"a"=>"1", "b"=>"2"'`    — quoted keys/values
16763///   * `'a=>NULL'`               — case-insensitive NULL token
16764///     surfaces as `None` (no quotes around NULL)
16765///
16766/// Returns None on parse failure → caller surfaces as hard error.
16767fn parse_hstore_str(
16768    s: &str,
16769) -> Option<Vec<(alloc::string::String, Option<alloc::string::String>)>> {
16770    let bytes = s.as_bytes();
16771    let mut i = 0;
16772    let mut out: Vec<(alloc::string::String, Option<alloc::string::String>)> = Vec::new();
16773    let skip_ws = |bytes: &[u8], i: &mut usize| {
16774        while *i < bytes.len() && matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r') {
16775            *i += 1;
16776        }
16777    };
16778    let parse_token = |bytes: &[u8], i: &mut usize| -> Option<alloc::string::String> {
16779        if *i >= bytes.len() {
16780            return None;
16781        }
16782        if bytes[*i] == b'"' {
16783            *i += 1;
16784            let mut out = alloc::string::String::new();
16785            while *i < bytes.len() {
16786                match bytes[*i] {
16787                    b'"' => {
16788                        *i += 1;
16789                        return Some(out);
16790                    }
16791                    b'\\' if *i + 1 < bytes.len() => {
16792                        out.push(bytes[*i + 1] as char);
16793                        *i += 2;
16794                    }
16795                    c => {
16796                        out.push(c as char);
16797                        *i += 1;
16798                    }
16799                }
16800            }
16801            None
16802        } else {
16803            let start = *i;
16804            while *i < bytes.len()
16805                && !matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r' | b',' | b'=')
16806            {
16807                *i += 1;
16808            }
16809            if *i == start {
16810                return None;
16811            }
16812            Some(alloc::str::from_utf8(&bytes[start..*i]).ok()?.to_string())
16813        }
16814    };
16815    skip_ws(bytes, &mut i);
16816    while i < bytes.len() {
16817        let key = parse_token(bytes, &mut i)?;
16818        skip_ws(bytes, &mut i);
16819        if i + 1 >= bytes.len() || bytes[i] != b'=' || bytes[i + 1] != b'>' {
16820            return None;
16821        }
16822        i += 2;
16823        skip_ws(bytes, &mut i);
16824        // Check for unquoted NULL token (case-insensitive).
16825        let val_token = if i + 4 <= bytes.len()
16826            && bytes[i..i + 4].eq_ignore_ascii_case(b"NULL")
16827            && (i + 4 == bytes.len() || matches!(bytes[i + 4], b' ' | b'\t' | b',' | b'\n' | b'\r'))
16828        {
16829            i += 4;
16830            None
16831        } else {
16832            Some(parse_token(bytes, &mut i)?)
16833        };
16834        // Replace any existing entry with the same key (last-wins).
16835        if let Some(pos) = out.iter().position(|(k, _)| k == &key) {
16836            out[pos] = (key, val_token);
16837        } else {
16838            out.push((key, val_token));
16839        }
16840        skip_ws(bytes, &mut i);
16841        if i >= bytes.len() {
16842            break;
16843        }
16844        if bytes[i] == b',' {
16845            i += 1;
16846            skip_ws(bytes, &mut i);
16847            continue;
16848        }
16849        return None;
16850    }
16851    Some(out)
16852}
16853
16854/// v7.17.0 Phase 3.P0-39 — render a hstore as canonical PG text
16855/// form `"k"=>"v"` (keys and non-NULL values always quoted;
16856/// NULL token is bare).
16857fn format_hstore_str(
16858    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
16859) -> alloc::string::String {
16860    let mut out = alloc::string::String::new();
16861    for (i, (k, v)) in pairs.iter().enumerate() {
16862        if i > 0 {
16863            out.push_str(", ");
16864        }
16865        out.push('"');
16866        out.push_str(k);
16867        out.push_str("\"=>");
16868        match v {
16869            None => out.push_str("NULL"),
16870            Some(val) => {
16871                out.push('"');
16872                out.push_str(val);
16873                out.push('"');
16874            }
16875        }
16876    }
16877    out
16878}
16879
16880/// v7.17.0 Phase 3.P0-39 — pub re-export so pgwire + sqllogictest
16881/// share the single hstore renderer.
16882pub fn format_hstore_text(
16883    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
16884) -> alloc::string::String {
16885    format_hstore_str(pairs)
16886}
16887
16888// ─── v7.17.0 Phase 3.P0-40 — 2D array parse + display ─────────
16889
16890/// Split a PG external 2D-array literal `'{{a,b},{c,d}}'` into
16891/// per-row token lists. Returns Err on shape mismatch.
16892fn split_2d_literal(s: &str) -> Result<Vec<Vec<alloc::string::String>>, &'static str> {
16893    let s = s.trim();
16894    let outer = s
16895        .strip_prefix('{')
16896        .and_then(|x| x.strip_suffix('}'))
16897        .ok_or("missing outer '{...}' braces")?;
16898    let trimmed = outer.trim();
16899    if trimmed.is_empty() {
16900        return Ok(Vec::new());
16901    }
16902    let mut rows: Vec<Vec<alloc::string::String>> = Vec::new();
16903    let mut i = 0;
16904    let bytes = trimmed.as_bytes();
16905    while i < bytes.len() {
16906        while i < bytes.len() && matches!(bytes[i], b' ' | b'\t' | b'\n' | b'\r' | b',') {
16907            i += 1;
16908        }
16909        if i >= bytes.len() {
16910            break;
16911        }
16912        if bytes[i] != b'{' {
16913            return Err("expected '{' opening a row");
16914        }
16915        i += 1;
16916        let row_start = i;
16917        let mut depth = 1;
16918        while i < bytes.len() && depth > 0 {
16919            match bytes[i] {
16920                b'{' => depth += 1,
16921                b'}' => depth -= 1,
16922                _ => {}
16923            }
16924            if depth > 0 {
16925                i += 1;
16926            }
16927        }
16928        if depth != 0 {
16929            return Err("unbalanced '{...}' in row");
16930        }
16931        let row_text = &trimmed[row_start..i];
16932        i += 1;
16933        let cells: Vec<alloc::string::String> = if row_text.trim().is_empty() {
16934            Vec::new()
16935        } else {
16936            row_text.split(',').map(|t| t.trim().to_string()).collect()
16937        };
16938        rows.push(cells);
16939    }
16940    if let Some(first) = rows.first() {
16941        let cols = first.len();
16942        for r in &rows {
16943            if r.len() != cols {
16944                return Err("ragged 2D array (rows have different column counts)");
16945            }
16946        }
16947    }
16948    Ok(rows)
16949}
16950
16951fn parse_int_2d_literal(s: &str) -> Result<Vec<Vec<Option<i32>>>, &'static str> {
16952    let raw = split_2d_literal(s)?;
16953    raw.into_iter()
16954        .map(|row| {
16955            row.into_iter()
16956                .map(|cell| {
16957                    if cell.eq_ignore_ascii_case("NULL") {
16958                        Ok(None)
16959                    } else {
16960                        cell.parse::<i32>()
16961                            .map(Some)
16962                            .map_err(|_| "invalid int element")
16963                    }
16964                })
16965                .collect()
16966        })
16967        .collect()
16968}
16969
16970fn parse_bigint_2d_literal(s: &str) -> Result<Vec<Vec<Option<i64>>>, &'static str> {
16971    let raw = split_2d_literal(s)?;
16972    raw.into_iter()
16973        .map(|row| {
16974            row.into_iter()
16975                .map(|cell| {
16976                    if cell.eq_ignore_ascii_case("NULL") {
16977                        Ok(None)
16978                    } else {
16979                        cell.parse::<i64>()
16980                            .map(Some)
16981                            .map_err(|_| "invalid bigint element")
16982                    }
16983                })
16984                .collect()
16985        })
16986        .collect()
16987}
16988
16989fn parse_text_2d_literal(s: &str) -> Result<Vec<Vec<Option<alloc::string::String>>>, &'static str> {
16990    let raw = split_2d_literal(s)?;
16991    Ok(raw
16992        .into_iter()
16993        .map(|row| {
16994            row.into_iter()
16995                .map(|cell| {
16996                    if cell.eq_ignore_ascii_case("NULL") {
16997                        None
16998                    } else {
16999                        Some(cell.trim_matches('"').to_string())
17000                    }
17001                })
17002                .collect()
17003        })
17004        .collect())
17005}
17006
17007fn format_int_2d_text(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
17008    let mut out = alloc::string::String::from("{");
17009    for (i, row) in rows.iter().enumerate() {
17010        if i > 0 {
17011            out.push(',');
17012        }
17013        out.push('{');
17014        for (j, cell) in row.iter().enumerate() {
17015            if j > 0 {
17016                out.push(',');
17017            }
17018            match cell {
17019                None => out.push_str("NULL"),
17020                Some(n) => out.push_str(&alloc::format!("{n}")),
17021            }
17022        }
17023        out.push('}');
17024    }
17025    out.push('}');
17026    out
17027}
17028
17029fn format_bigint_2d_text(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
17030    let mut out = alloc::string::String::from("{");
17031    for (i, row) in rows.iter().enumerate() {
17032        if i > 0 {
17033            out.push(',');
17034        }
17035        out.push('{');
17036        for (j, cell) in row.iter().enumerate() {
17037            if j > 0 {
17038                out.push(',');
17039            }
17040            match cell {
17041                None => out.push_str("NULL"),
17042                Some(n) => out.push_str(&alloc::format!("{n}")),
17043            }
17044        }
17045        out.push('}');
17046    }
17047    out.push('}');
17048    out
17049}
17050
17051fn format_text_2d_text(rows: &[Vec<Option<alloc::string::String>>]) -> alloc::string::String {
17052    let mut out = alloc::string::String::from("{");
17053    for (i, row) in rows.iter().enumerate() {
17054        if i > 0 {
17055            out.push(',');
17056        }
17057        out.push('{');
17058        for (j, cell) in row.iter().enumerate() {
17059            if j > 0 {
17060                out.push(',');
17061            }
17062            match cell {
17063                None => out.push_str("NULL"),
17064                Some(s) => out.push_str(s),
17065            }
17066        }
17067        out.push('}');
17068    }
17069    out.push('}');
17070    out
17071}
17072
17073/// v7.17.0 Phase 3.P0-40 — pub re-exports so pgwire + sqllogictest
17074/// share the single 2D-array renderer.
17075pub fn format_int_2d_text_pub(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
17076    format_int_2d_text(rows)
17077}
17078pub fn format_bigint_2d_text_pub(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
17079    format_bigint_2d_text(rows)
17080}
17081pub fn format_text_2d_text_pub(
17082    rows: &[Vec<Option<alloc::string::String>>],
17083) -> alloc::string::String {
17084    format_text_2d_text(rows)
17085}
17086
17087/// v7.17.0 Phase 3.P0-38 — parse a PG range literal of the form
17088/// `'[lo,up)'` / `'(lo,up]'` / `'[lo,up]'` / `'(lo,up)'` /
17089/// `'empty'`. Lower / upper may be empty (unbounded). Returns
17090/// `None` on any parse failure; caller surfaces as hard error.
17091fn parse_range_str(s: &str, kind: spg_storage::RangeKind) -> Option<Value> {
17092    let s = s.trim();
17093    if s.eq_ignore_ascii_case("empty") {
17094        return Some(Value::Range {
17095            kind,
17096            lower: None,
17097            upper: None,
17098            lower_inc: false,
17099            upper_inc: false,
17100            empty: true,
17101        });
17102    }
17103    let bytes = s.as_bytes();
17104    if bytes.len() < 3 {
17105        return None;
17106    }
17107    let lower_inc = match bytes[0] {
17108        b'[' => true,
17109        b'(' => false,
17110        _ => return None,
17111    };
17112    let upper_inc = match bytes[bytes.len() - 1] {
17113        b']' => true,
17114        b')' => false,
17115        _ => return None,
17116    };
17117    let inner = &s[1..s.len() - 1];
17118    let (lo_text, up_text) = inner.split_once(',')?;
17119    let lower = if lo_text.is_empty() {
17120        None
17121    } else {
17122        Some(alloc::boxed::Box::new(parse_range_element(lo_text, kind)?))
17123    };
17124    let upper = if up_text.is_empty() {
17125        None
17126    } else {
17127        Some(alloc::boxed::Box::new(parse_range_element(up_text, kind)?))
17128    };
17129    Some(Value::Range {
17130        kind,
17131        lower,
17132        upper,
17133        lower_inc,
17134        upper_inc,
17135        empty: false,
17136    })
17137}
17138
17139/// v7.17.0 Phase 3.P0-38 — parse a single range bound text into
17140/// the matching element Value for the RangeKind.
17141fn parse_range_element(text: &str, kind: spg_storage::RangeKind) -> Option<Value> {
17142    let text = text.trim().trim_matches('"');
17143    use spg_storage::RangeKind as K;
17144    match kind {
17145        K::Int4 => text.parse::<i32>().ok().map(Value::Int),
17146        K::Int8 => text.parse::<i64>().ok().map(Value::BigInt),
17147        K::Num => {
17148            // Reuse the Numeric parse via the engine's text-coercion
17149            // path; bail to None on failure.
17150            let dot = text.find('.');
17151            let scale: u8 = dot.map_or(0, |p| (text.len() - p - 1) as u8);
17152            let digits: alloc::string::String = text
17153                .chars()
17154                .filter(|c| *c == '-' || c.is_ascii_digit())
17155                .collect();
17156            let scaled: i128 = digits.parse().ok()?;
17157            Some(Value::Numeric { scaled, scale })
17158        }
17159        K::Ts | K::TsTz => {
17160            // Reuse the existing timestamp parse path. v7.17.0
17161            // expects `'YYYY-MM-DD HH:MM:SS[.ffffff]'` in range
17162            // bounds (TZ offset on TsTz is OOS for the initial
17163            // P0-38; ship plain Timestamp shape).
17164            crate::eval::parse_timestamp_literal(text).map(Value::Timestamp)
17165        }
17166        K::Date => crate::eval::parse_date_literal(text).map(Value::Date),
17167    }
17168}
17169
17170/// v7.17.0 Phase 3.P0-38 — render a Range value as its canonical
17171/// PG text form. Re-exported via [`format_range_text`] for use
17172/// from spg-server's pgwire layer.
17173pub fn format_range_text(v: &Value) -> alloc::string::String {
17174    format_range_str(v)
17175}
17176
17177fn format_range_str(v: &Value) -> alloc::string::String {
17178    let Value::Range {
17179        lower,
17180        upper,
17181        lower_inc,
17182        upper_inc,
17183        empty,
17184        ..
17185    } = v
17186    else {
17187        return alloc::string::String::new();
17188    };
17189    if *empty {
17190        return "empty".into();
17191    }
17192    let mut out = alloc::string::String::new();
17193    out.push(if *lower_inc { '[' } else { '(' });
17194    if let Some(l) = lower {
17195        out.push_str(&format_range_element(l));
17196    }
17197    out.push(',');
17198    if let Some(u) = upper {
17199        out.push_str(&format_range_element(u));
17200    }
17201    out.push(if *upper_inc { ']' } else { ')' });
17202    out
17203}
17204
17205fn format_range_element(v: &Value) -> alloc::string::String {
17206    match v {
17207        Value::Int(n) => alloc::format!("{n}"),
17208        Value::BigInt(n) => alloc::format!("{n}"),
17209        Value::Date(d) => crate::eval::format_date(*d),
17210        Value::Timestamp(t) => crate::eval::format_timestamp(*t),
17211        Value::Numeric { scaled, scale } => crate::eval::format_numeric(*scaled, *scale),
17212        other => alloc::format!("{other:?}"),
17213    }
17214}
17215
17216/// v7.17.0 Phase 3.P0-35 — parse a PG `money` literal into i64
17217/// cents. Accepts:
17218///   * Optional leading `-` (negative)
17219///   * Optional `$` prefix
17220///   * Integer portion with optional `,` thousands separators
17221///   * Optional `.` followed by 1-2 digits (cents); 1 digit
17222///     auto-pads to 2 (`.5` → 50 cents).
17223///
17224/// Returns None on any parse failure — caller surfaces as hard
17225/// SQL error.
17226fn parse_money_str(s: &str) -> Option<i64> {
17227    let s = s.trim();
17228    let (neg, rest) = match s.strip_prefix('-') {
17229        Some(r) => (true, r.trim_start()),
17230        None => (false, s),
17231    };
17232    let rest = rest.strip_prefix('$').unwrap_or(rest).trim_start();
17233    let (int_part, frac_part) = match rest.split_once('.') {
17234        Some((i, f)) => (i, Some(f)),
17235        None => (rest, None),
17236    };
17237    if int_part.is_empty() {
17238        return None;
17239    }
17240    // Validate + strip commas from the integer portion.
17241    let mut int_digits = alloc::string::String::with_capacity(int_part.len());
17242    for b in int_part.bytes() {
17243        match b {
17244            b',' => {}
17245            b'0'..=b'9' => int_digits.push(b as char),
17246            _ => return None,
17247        }
17248    }
17249    if int_digits.is_empty() {
17250        return None;
17251    }
17252    let dollars: i64 = int_digits.parse().ok()?;
17253    let cents: i64 = match frac_part {
17254        None => 0,
17255        Some(f) => {
17256            if f.is_empty() || f.len() > 2 || !f.bytes().all(|b| b.is_ascii_digit()) {
17257                return None;
17258            }
17259            let padded = if f.len() == 1 {
17260                alloc::format!("{f}0")
17261            } else {
17262                f.to_string()
17263            };
17264            padded.parse().ok()?
17265        }
17266    };
17267    let total = dollars.checked_mul(100)?.checked_add(cents)?;
17268    Some(if neg { -total } else { total })
17269}
17270
17271/// v7.17.0 Phase 3.P0-34 — parse a PG `timetz` literal
17272/// `HH:MM:SS[.fraction]±HH[:MM]` into (us, offset_secs).
17273///
17274/// The offset suffix is MANDATORY: SPG doesn't have a session TZ
17275/// wired into eval, so a bare `HH:MM:SS` literal would be
17276/// ambiguous. Returns None for any parse failure or out-of-range
17277/// component — caller surfaces as a hard SQL error.
17278///
17279/// Offset range: ±14 hours (±50400 seconds), matching PG's
17280/// internal limit.
17281fn parse_timetz_str(s: &str) -> Option<(i64, i32)> {
17282    let s = s.trim();
17283    // Find the offset sign — scan from right since the time part
17284    // never contains '+' / '-' (after the optional fractional dot
17285    // it's all digits and ':').
17286    let bytes = s.as_bytes();
17287    let sign_pos = bytes
17288        .iter()
17289        .enumerate()
17290        .rev()
17291        .find(|&(_, &b)| b == b'+' || b == b'-')
17292        .map(|(i, _)| i)?;
17293    if sign_pos == 0 {
17294        return None; // bare sign — no time component
17295    }
17296    let time_part = &s[..sign_pos];
17297    let offset_part = &s[sign_pos..];
17298    let us = parse_time_str(time_part)?;
17299    let sign: i32 = if offset_part.starts_with('+') { 1 } else { -1 };
17300    let offset_body = &offset_part[1..];
17301    let (hh_str, mm_str) = match offset_body.split_once(':') {
17302        Some((h, m)) => (h, m),
17303        None => (offset_body, "0"),
17304    };
17305    let hh: i32 = hh_str.parse().ok()?;
17306    let mm: i32 = mm_str.parse().ok()?;
17307    if !(0..=14).contains(&hh) || !(0..=59).contains(&mm) {
17308        return None;
17309    }
17310    let total = sign * (hh * 3600 + mm * 60);
17311    if total.abs() > 50_400 {
17312        return None;
17313    }
17314    Some((us, total))
17315}
17316
17317/// v7.17.0 Phase 3.P0-33 — funnel an integer literal through MySQL
17318/// YEAR range validation: 0 sentinel or 1901..=2155. Out-of-range
17319/// surfaces as a hard SQL error (no silent truncation, mirrors PG
17320/// `time_in` / `uuid_in` discipline).
17321fn coerce_int_to_year(n: i64, col_name: &str) -> Result<Value, EngineError> {
17322    if n == 0 || (1901..=2155).contains(&n) {
17323        // u16::try_from cannot fail in this range; the cast also
17324        // covers the 0 sentinel.
17325        return Ok(Value::Year(n as u16));
17326    }
17327    Err(EngineError::Eval(EvalError::TypeMismatch {
17328        detail: alloc::format!(
17329            "year value out of range: {n} (column `{col_name}`; \
17330             MySQL accepts 0 or 1901..=2155)"
17331        ),
17332    }))
17333}
17334
17335/// v7.17.0 Phase 3.P0-32 — parse a PG `time` literal
17336/// `HH:MM:SS[.fraction]` into microseconds since 00:00:00.
17337///
17338/// Accepts:
17339///   * `HH:MM:SS`            — exact-second precision
17340///   * `HH:MM:SS.f` .. `.ffffff` — 1-6 fractional digits, right-padded
17341///     with zeros to microseconds
17342///
17343/// Range: hour 0..=23, minute 0..=59, second 0..=59. Anything else
17344/// returns None — caller surfaces as a hard SQL error (no silent
17345/// truncation, matches PG's `time_in` behaviour).
17346fn parse_time_str(s: &str) -> Option<i64> {
17347    let s = s.trim();
17348    let (hms, frac) = match s.split_once('.') {
17349        Some((h, f)) => (h, Some(f)),
17350        None => (s, None),
17351    };
17352    let mut parts = hms.split(':');
17353    let hh: u32 = parts.next()?.parse().ok()?;
17354    let mm: u32 = parts.next()?.parse().ok()?;
17355    let ss: u32 = parts.next()?.parse().ok()?;
17356    if parts.next().is_some() {
17357        return None;
17358    }
17359    if hh > 23 || mm > 59 || ss > 59 {
17360        return None;
17361    }
17362    let frac_us: i64 = match frac {
17363        None => 0,
17364        Some(f) => {
17365            if f.is_empty() || f.len() > 6 || !f.bytes().all(|b| b.is_ascii_digit()) {
17366                return None;
17367            }
17368            // Right-pad with zeros so '.5' = 500000 µsec.
17369            let mut padded = alloc::string::String::with_capacity(6);
17370            padded.push_str(f);
17371            while padded.len() < 6 {
17372                padded.push('0');
17373            }
17374            padded.parse().ok()?
17375        }
17376    };
17377    Some(
17378        i64::from(hh) * 3_600_000_000
17379            + i64::from(mm) * 60_000_000
17380            + i64::from(ss) * 1_000_000
17381            + frac_us,
17382    )
17383}
17384
17385const fn column_type_to_data_type(t: ColumnTypeName) -> DataType {
17386    match t {
17387        ColumnTypeName::SmallInt => DataType::SmallInt,
17388        ColumnTypeName::Int => DataType::Int,
17389        ColumnTypeName::BigInt => DataType::BigInt,
17390        ColumnTypeName::Float => DataType::Float,
17391        ColumnTypeName::Text => DataType::Text,
17392        ColumnTypeName::Varchar(n) => DataType::Varchar(n),
17393        ColumnTypeName::Char(n) => DataType::Char(n),
17394        ColumnTypeName::Bool => DataType::Bool,
17395        ColumnTypeName::Vector { dim, encoding } => DataType::Vector {
17396            dim,
17397            encoding: match encoding {
17398                SqlVecEncoding::F32 => VecEncoding::F32,
17399                SqlVecEncoding::Sq8 => VecEncoding::Sq8,
17400                SqlVecEncoding::F16 => VecEncoding::F16,
17401            },
17402        },
17403        ColumnTypeName::Numeric(precision, scale) => DataType::Numeric { precision, scale },
17404        ColumnTypeName::Date => DataType::Date,
17405        ColumnTypeName::Timestamp => DataType::Timestamp,
17406        ColumnTypeName::Timestamptz => DataType::Timestamptz,
17407        ColumnTypeName::Json => DataType::Json,
17408        ColumnTypeName::Jsonb => DataType::Jsonb,
17409        ColumnTypeName::Bytes => DataType::Bytes,
17410        ColumnTypeName::TextArray => DataType::TextArray,
17411        ColumnTypeName::IntArray => DataType::IntArray,
17412        ColumnTypeName::BigIntArray => DataType::BigIntArray,
17413        ColumnTypeName::TsVector => DataType::TsVector,
17414        ColumnTypeName::TsQuery => DataType::TsQuery,
17415        ColumnTypeName::Uuid => DataType::Uuid,
17416        ColumnTypeName::Time => DataType::Time,
17417        ColumnTypeName::Year => DataType::Year,
17418        ColumnTypeName::TimeTz => DataType::TimeTz,
17419        ColumnTypeName::Money => DataType::Money,
17420        ColumnTypeName::Range(k) => DataType::Range(match k {
17421            spg_sql::ast::RangeKindAst::Int4 => spg_storage::RangeKind::Int4,
17422            spg_sql::ast::RangeKindAst::Int8 => spg_storage::RangeKind::Int8,
17423            spg_sql::ast::RangeKindAst::Num => spg_storage::RangeKind::Num,
17424            spg_sql::ast::RangeKindAst::Ts => spg_storage::RangeKind::Ts,
17425            spg_sql::ast::RangeKindAst::TsTz => spg_storage::RangeKind::TsTz,
17426            spg_sql::ast::RangeKindAst::Date => spg_storage::RangeKind::Date,
17427        }),
17428        ColumnTypeName::Hstore => DataType::Hstore,
17429        ColumnTypeName::IntArray2D => DataType::IntArray2D,
17430        ColumnTypeName::BigIntArray2D => DataType::BigIntArray2D,
17431        ColumnTypeName::TextArray2D => DataType::TextArray2D,
17432    }
17433}
17434
17435/// Convert an INSERT VALUES expression to a storage Value. Supports literal
17436/// expressions, unary-minus over numeric literals, and pgvector-style
17437/// `'[..]'::vector` cast (v1.2). Anything more complex returns `Unsupported`.
17438fn literal_expr_to_value(expr: Expr) -> Result<Value, EngineError> {
17439    match expr {
17440        Expr::Literal(l) => Ok(literal_to_value(l)),
17441        Expr::Cast { expr, target } => {
17442            let inner_value = literal_expr_to_value(*expr)?;
17443            crate::eval::cast_value(inner_value, target).map_err(EngineError::Eval)
17444        }
17445        Expr::Unary {
17446            op: UnOp::Neg,
17447            expr,
17448        } => match *expr {
17449            Expr::Literal(Literal::Integer(n)) => {
17450                // Fold to i32 if it fits, else BigInt. Parser emits Integer(i64)
17451                // — overflow on negate of i64::MIN is the one edge case.
17452                let neg = n.checked_neg().ok_or_else(|| {
17453                    EngineError::Unsupported("integer literal overflow on negation".into())
17454                })?;
17455                Ok(int_value_for(neg))
17456            }
17457            Expr::Literal(Literal::Float(x)) => Ok(Value::Float(-x)),
17458            other => Err(EngineError::Unsupported(alloc::format!(
17459                "unary minus over non-literal expression: {other:?}"
17460            ))),
17461        },
17462        // v7.10.10 — `ARRAY[lit, lit, …]` constructor accepted at
17463        // INSERT-time. Each element must reduce to a Value through
17464        // `literal_expr_to_value`; NULL elements become `None`.
17465        // v7.11.13 — deduce shape from element values: all Int →
17466        // IntArray; any BigInt → BigIntArray (widening); any Text
17467        // → TextArray. Cast targets (`ARRAY[]::INT[]`) flow through
17468        // the outer Cast arm before reaching here and re-coerce.
17469        Expr::Array(items) => {
17470            let mut materialised: alloc::vec::Vec<Value> =
17471                alloc::vec::Vec::with_capacity(items.len());
17472            for elem in items {
17473                materialised.push(literal_expr_to_value(elem)?);
17474            }
17475            Ok(array_literal_widen(materialised))
17476        }
17477        // Any other Expr shape — fall back to a general evaluation
17478        // against an empty row + empty schema. This unblocks the
17479        // app-common patterns where INSERT VALUES carries a
17480        // non-correlated function call:
17481        //   INSERT INTO t VALUES (concat('U-', 42))
17482        //   INSERT INTO t VALUES (now())
17483        //   INSERT INTO t VALUES (format('%s-%s', 'a', 'b'))
17484        // Any expression that references a column or `$N`
17485        // placeholder fails cleanly inside `eval_expr` with a
17486        // descriptive error; literals + casts + ARRAY[…] continue
17487        // to take the fast paths above so the hot INSERT path is
17488        // unchanged on the common case.
17489        other => {
17490            let empty_schema: alloc::vec::Vec<spg_storage::ColumnSchema> = alloc::vec::Vec::new();
17491            let ctx = EvalContext::new(&empty_schema, None);
17492            let empty_row = spg_storage::Row::new(alloc::vec::Vec::new());
17493            crate::eval::eval_expr(&other, &empty_row, &ctx).map_err(EngineError::Eval)
17494        }
17495    }
17496}
17497
17498fn literal_to_value(l: Literal) -> Value {
17499    match l {
17500        Literal::Integer(n) => int_value_for(n),
17501        Literal::Float(x) => Value::Float(x),
17502        Literal::String(s) => Value::Text(s),
17503        Literal::Bool(b) => Value::Bool(b),
17504        Literal::Null => Value::Null,
17505        Literal::Vector(v) => Value::Vector(v),
17506        Literal::TextArray(items) => Value::TextArray(items),
17507        Literal::IntArray(items) => Value::IntArray(items),
17508        Literal::BigIntArray(items) => Value::BigIntArray(items),
17509        Literal::Interval { months, micros, .. } => Value::Interval { months, micros },
17510    }
17511}
17512
17513/// Pick `Int` (`i32`) when the literal fits, else `BigInt`. `INT` vs `BIGINT`
17514/// columns will still enforce the right tag downstream — this is just the
17515/// default we synthesise from an unannotated integer literal.
17516fn int_value_for(n: i64) -> Value {
17517    if let Ok(small) = i32::try_from(n) {
17518        Value::Int(small)
17519    } else {
17520        Value::BigInt(n)
17521    }
17522}
17523
17524/// Widen / narrow `v` to fit `expected`. Numerics permit safe widening
17525/// (`Int → BigInt`, `Int/BigInt → Float`) and best-effort narrowing
17526/// (`BigInt → Int` succeeds only when the value fits in `i32`). Everything
17527/// else returns `TypeMismatch` carrying the column name for caller diagnostics.
17528/// `NULL` is always permitted; the nullability check happens later in storage.
17529#[allow(clippy::too_many_lines)]
17530/// v7.17.0 Phase 4.4 — reject negative integer values on UNSIGNED
17531/// columns. Called after `coerce_value` at each INSERT / UPDATE
17532/// site that has ColumnSchema context. NULL passes through (a
17533/// nullable UNSIGNED column can legitimately hold NULL).
17534fn check_unsigned_range(
17535    v: &Value,
17536    schema: &ColumnSchema,
17537    position: usize,
17538) -> Result<(), EngineError> {
17539    if !schema.is_unsigned {
17540        return Ok(());
17541    }
17542    let n = match v {
17543        Value::SmallInt(x) => i64::from(*x),
17544        Value::Int(x) => i64::from(*x),
17545        Value::BigInt(x) => *x,
17546        _ => return Ok(()), // non-integer cells (NULL, default) skip
17547    };
17548    if n < 0 {
17549        return Err(EngineError::Unsupported(alloc::format!(
17550            "column {:?} is UNSIGNED but got negative value {n} at position {position}",
17551            schema.name
17552        )));
17553    }
17554    Ok(())
17555}
17556
17557fn coerce_value(
17558    v: Value,
17559    expected: DataType,
17560    col_name: &str,
17561    position: usize,
17562) -> Result<Value, EngineError> {
17563    if v.is_null() {
17564        return Ok(Value::Null);
17565    }
17566    let actual = v.data_type().expect("non-null");
17567    if actual == expected {
17568        return Ok(v);
17569    }
17570    let coerced = match (v, expected) {
17571        (Value::Int(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
17572        (Value::Int(n), DataType::Float) => Some(Value::Float(f64::from(n))),
17573        (Value::Int(n), DataType::SmallInt) => i16::try_from(n).ok().map(Value::SmallInt),
17574        (Value::Int(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
17575            i128::from(n),
17576            precision,
17577            scale,
17578            col_name,
17579        )?),
17580        (Value::SmallInt(n), DataType::Int) => Some(Value::Int(i32::from(n))),
17581        (Value::SmallInt(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
17582        (Value::SmallInt(n), DataType::Float) => Some(Value::Float(f64::from(n))),
17583        (Value::SmallInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
17584            i128::from(n),
17585            precision,
17586            scale,
17587            col_name,
17588        )?),
17589        (Value::BigInt(n), DataType::Int) => i32::try_from(n).ok().map(Value::Int),
17590        (Value::BigInt(n), DataType::SmallInt) => i16::try_from(n).ok().map(Value::SmallInt),
17591        #[allow(clippy::cast_precision_loss)]
17592        (Value::BigInt(n), DataType::Float) => Some(Value::Float(n as f64)),
17593        (Value::BigInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
17594            i128::from(n),
17595            precision,
17596            scale,
17597            col_name,
17598        )?),
17599        (Value::Float(x), DataType::Numeric { precision, scale }) => {
17600            Some(numeric_from_float(x, precision, scale, col_name)?)
17601        }
17602        // v7.17.0 Phase 3.P0-67 — Text → NUMERIC. Parse a
17603        // canonical decimal text (`"-1234.56"` / `"42"` /
17604        // `"0.0001"`) into `(mantissa, source_scale)` and rescale
17605        // to the column's declared scale. Required for prepared
17606        // binds: `value_to_literal` flattens a Value::Numeric
17607        // into a TEXT literal because Literal carries no native
17608        // Numeric variant, so the placeholder substitution path
17609        // reaches coerce_value as Text → Numeric. Without this
17610        // arm the round-trip surfaces a TypeMismatch even though
17611        // the cell already left the engine as a valid Numeric.
17612        (Value::Text(s), DataType::Numeric { precision, scale }) => {
17613            let Some((mantissa, src_scale)) = parse_numeric_text(&s) else {
17614                return Err(EngineError::Eval(EvalError::TypeMismatch {
17615                    detail: alloc::format!("cannot parse {s:?} as NUMERIC for column `{col_name}`"),
17616                }));
17617            };
17618            Some(numeric_rescale(
17619                mantissa, src_scale, precision, scale, col_name,
17620            )?)
17621        }
17622        // Text → DATE / TIMESTAMP: parse canonical text forms.
17623        (Value::Text(s), DataType::Date) => {
17624            let d = eval::parse_date_literal(&s).ok_or_else(|| {
17625                EngineError::Eval(EvalError::TypeMismatch {
17626                    detail: alloc::format!("cannot parse {s:?} as DATE for column `{col_name}`"),
17627                })
17628            })?;
17629            Some(Value::Date(d))
17630        }
17631        // v7.14.0 — MySQL DEFAULT clauses quote integer / float
17632        // / boolean literals (`DEFAULT '0'`, `DEFAULT '1'`,
17633        // `DEFAULT '3.14'`, `DEFAULT 'true'`). Coerce the text
17634        // form to the column's numeric / bool type at DEFAULT-
17635        // installation time so the storage check sees a typed
17636        // value. Parse failures fall through to TypeMismatch.
17637        (Value::Text(s), DataType::SmallInt) => s.parse::<i16>().ok().map(Value::SmallInt),
17638        (Value::Text(s), DataType::Int) => s.parse::<i32>().ok().map(Value::Int),
17639        (Value::Text(s), DataType::BigInt) => s.parse::<i64>().ok().map(Value::BigInt),
17640        (Value::Text(s), DataType::Float) => s.parse::<f64>().ok().map(Value::Float),
17641        (Value::Text(s), DataType::Bool) => match s.to_ascii_lowercase().as_str() {
17642            "0" | "false" | "f" | "no" | "off" => Some(Value::Bool(false)),
17643            "1" | "true" | "t" | "yes" | "on" => Some(Value::Bool(true)),
17644            _ => None,
17645        },
17646        // v7.17.0 Phase 3.P0-46 — MySQL TINYINT(1) (which Phase 4.3
17647        // classifies as DataType::Bool) is the storage shape every
17648        // mysqldump-restored boolean column lands in. mysqldump emits
17649        // the values as integer `0` / `1` literals, so int → bool
17650        // coerce on INSERT is required for a 0-change cutover. MySQL's
17651        // rule is "any non-zero is truthy"; we follow that for all
17652        // signed int widths so the same coerce path serves an
17653        // explicit `BOOLEAN` column too.
17654        (Value::Int(n), DataType::Bool) => Some(Value::Bool(n != 0)),
17655        (Value::SmallInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
17656        (Value::BigInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
17657        // v4.9: Text ↔ JSON coercion. No structural validation —
17658        // any text literal is accepted; the responsibility for
17659        // valid JSON lies with the producer.
17660        (Value::Text(s), DataType::Json | DataType::Jsonb) => Some(Value::Json(s)),
17661        (Value::Json(s), DataType::Text) => Some(Value::Text(s)),
17662        // v7.13.3 — mailrs round-7 S10. SPG's storage represents
17663        // both JSON and JSONB on-disk as `Value::Json(String)` —
17664        // they share the underlying text payload. The cast
17665        // `'<text>'::jsonb` produces a Value::Json that needs to
17666        // satisfy a DataType::Jsonb column. Identity coerce in
17667        // both directions so JSON ↔ JSONB assignments work at all
17668        // INSERT / ALTER COLUMN TYPE / DEFAULT contexts.
17669        (Value::Json(s), DataType::Jsonb | DataType::Json) => Some(Value::Json(s)),
17670        // v7.10.4 — Text → BYTEA. Decode PG-style literal forms:
17671        //   - Hex:    `\x48656c6c6f`  (case-insensitive hex pairs)
17672        //   - Escape: `Hello\\000world`  (backslash + octal triples)
17673        //   - Plain:  any string → raw UTF-8 bytes (PG also accepts)
17674        // Errors surface as TypeMismatch so the operator gets a
17675        // clear "this literal isn't a bytea literal" hint.
17676        (Value::Text(s), DataType::Bytes) => {
17677            let bytes = decode_bytea_literal(&s).map_err(|e| {
17678                EngineError::Eval(EvalError::TypeMismatch {
17679                    detail: alloc::format!(
17680                        "cannot parse {s:?} as BYTEA for column `{col_name}`: {e}"
17681                    ),
17682                })
17683            })?;
17684            Some(Value::Bytes(bytes))
17685        }
17686        // v7.10.4 — BYTEA → Text round-trip uses the PG hex
17687        // output (lowercase, `\x` prefix). Important when a
17688        // SELECT pulls a bytea cell through a Text column path.
17689        (Value::Bytes(b), DataType::Text) => Some(Value::Text(encode_bytea_hex(&b))),
17690        // v7.17.0 — Text → UUID. PG accepts canonical hyphenated,
17691        // unhyphenated, uppercase, and `{...}`-braced forms; we
17692        // funnel all four through `spg_storage::parse_uuid_str`.
17693        // A malformed literal surfaces as a SQL TypeMismatch
17694        // rather than silently inserting garbage — `0-change
17695        // cutover` requires that an app inserting bad UUID text
17696        // sees the same hard error PG would raise.
17697        (Value::Text(s), DataType::Uuid) => match spg_storage::parse_uuid_str(&s) {
17698            Some(b) => Some(Value::Uuid(b)),
17699            None => {
17700                return Err(EngineError::Eval(EvalError::TypeMismatch {
17701                    detail: alloc::format!(
17702                        "invalid input syntax for type uuid: {s:?} (column `{col_name}`)"
17703                    ),
17704                }));
17705            }
17706        },
17707        // v7.17.0 — UUID → Text canonical 8-4-4-4-12 lowercase.
17708        // Surfaces when a SELECT plucks a uuid cell through a
17709        // Text column path (e.g. INSERT INTO log SELECT id::text
17710        // FROM other_table).
17711        (Value::Uuid(b), DataType::Text) => Some(Value::Text(spg_storage::format_uuid(&b))),
17712        // v7.17.0 Phase 3.P0-32 — Text → TIME. Accepts
17713        // `HH:MM:SS` and `HH:MM:SS.ffffff` (1-6 fractional digits).
17714        // Out-of-range hour/min/sec is a hard SQL error (no
17715        // silent truncation — same 0-change-cutover discipline
17716        // we apply to UUID).
17717        (Value::Text(s), DataType::Time) => match parse_time_str(&s) {
17718            Some(us) => Some(Value::Time(us)),
17719            None => {
17720                return Err(EngineError::Eval(EvalError::TypeMismatch {
17721                    detail: alloc::format!(
17722                        "invalid input syntax for type time: {s:?} (column `{col_name}`)"
17723                    ),
17724                }));
17725            }
17726        },
17727        // v7.17.0 Phase 3.P0-32 — TIME → Text canonical `HH:MM:SS[.ffffff]`.
17728        (Value::Time(us), DataType::Text) => Some(Value::Text(eval::format_time(us))),
17729        // v7.17.0 Phase 3.P0-33 — int / bigint → YEAR. Range
17730        // check enforces the MySQL canonical 1901..=2155 + 0
17731        // sentinel; out-of-range is a hard SQL error (no silent
17732        // truncation, mirrors P0-32 / P0-25 discipline).
17733        (Value::SmallInt(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
17734        (Value::Int(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
17735        (Value::BigInt(n), DataType::Year) => Some(coerce_int_to_year(n, col_name)?),
17736        // Text → YEAR. Accepts the 4-digit decimal form only;
17737        // two-digit YEAR (`'99'` → 1999) was deprecated in MySQL
17738        // 5.7 and is out of scope for v7.17.0.
17739        (Value::Text(s), DataType::Year) => match s.trim().parse::<i64>() {
17740            Ok(n) => Some(coerce_int_to_year(n, col_name)?),
17741            Err(_) => {
17742                return Err(EngineError::Eval(EvalError::TypeMismatch {
17743                    detail: alloc::format!(
17744                        "invalid input syntax for type year: {s:?} (column `{col_name}`)"
17745                    ),
17746                }));
17747            }
17748        },
17749        // YEAR → Text 4-digit zero-padded.
17750        (Value::Year(y), DataType::Text) => Some(Value::Text(alloc::format!("{y:04}"))),
17751        // v7.17.0 Phase 3.P0-34 — Text → TIMETZ. Mandatory
17752        // signed offset suffix; missing offset is a hard error
17753        // (SPG has no session TZ wired into eval, unlike PG).
17754        (Value::Text(s), DataType::TimeTz) => match parse_timetz_str(&s) {
17755            Some((us, offset_secs)) => Some(Value::TimeTz { us, offset_secs }),
17756            None => {
17757                return Err(EngineError::Eval(EvalError::TypeMismatch {
17758                    detail: alloc::format!(
17759                        "invalid input syntax for type time with time zone: \
17760                         {s:?} (column `{col_name}`)"
17761                    ),
17762                }));
17763            }
17764        },
17765        // TIMETZ → Text canonical `HH:MM:SS[.ffffff]±HH[:MM]`.
17766        (Value::TimeTz { us, offset_secs }, DataType::Text) => {
17767            Some(Value::Text(eval::format_timetz(us, offset_secs)))
17768        }
17769        // v7.17.0 Phase 3.P0-35 — Text → MONEY. Accepts `$N.NN`,
17770        // `$N,NNN.NN`, optional leading `-`. Bare numeric literals
17771        // arrive via the Int/BigInt/Float/Numeric arms below.
17772        (Value::Text(s), DataType::Money) => match parse_money_str(&s) {
17773            Some(c) => Some(Value::Money(c)),
17774            None => {
17775                return Err(EngineError::Eval(EvalError::TypeMismatch {
17776                    detail: alloc::format!(
17777                        "invalid input syntax for type money: {s:?} (column `{col_name}`)"
17778                    ),
17779                }));
17780            }
17781        },
17782        // Int / BigInt / SmallInt / Float / Numeric → MONEY.
17783        // Bare numeric literal is interpreted as a major-unit
17784        // amount (matches PG: `100`::money → $100.00 = 10000 cents).
17785        (Value::SmallInt(n), DataType::Money) => {
17786            Some(Value::Money(i64::from(n).saturating_mul(100)))
17787        }
17788        (Value::Int(n), DataType::Money) => Some(Value::Money(i64::from(n).saturating_mul(100))),
17789        (Value::BigInt(n), DataType::Money) => Some(Value::Money(n.saturating_mul(100))),
17790        (Value::Float(x), DataType::Money) => {
17791            // Round half-away-from-zero to cents (no_std — no
17792            // `f64::round`, so hand-roll via biased truncation).
17793            let scaled = x * 100.0;
17794            let cents = if scaled >= 0.0 {
17795                (scaled + 0.5) as i64
17796            } else {
17797                (scaled - 0.5) as i64
17798            };
17799            Some(Value::Money(cents))
17800        }
17801        (Value::Numeric { scaled, scale }, DataType::Money) => {
17802            // Convert exact decimal to cents (scale 2). If scale > 2,
17803            // round half-away-from-zero. If scale < 2, multiply up.
17804            let cents = if scale == 2 {
17805                scaled
17806            } else if scale < 2 {
17807                let mult = 10_i128.pow(u32::from(2 - scale));
17808                scaled.saturating_mul(mult)
17809            } else {
17810                let div = 10_i128.pow(u32::from(scale - 2));
17811                let half = div / 2;
17812                let bias = if scaled >= 0 { half } else { -half };
17813                (scaled + bias) / div
17814            };
17815            Some(Value::Money(i64::try_from(cents).unwrap_or(i64::MAX)))
17816        }
17817        // MONEY → Text canonical `$N,NNN.CC`.
17818        (Value::Money(c), DataType::Text) => Some(Value::Text(eval::format_money(c))),
17819        // v7.17.0 Phase 3.P0-38 — Text → Range. Accepts canonical
17820        // PG forms: `'empty'`, `'[a,b)'`, `'(a,b]'`, `'[a,b]'`,
17821        // `'(a,b)'`, with empty lower or upper for unbounded.
17822        (Value::Text(s), DataType::Range(kind)) => match parse_range_str(&s, kind) {
17823            Some(v) => Some(v),
17824            None => {
17825                return Err(EngineError::Eval(EvalError::TypeMismatch {
17826                    detail: alloc::format!(
17827                        "invalid input syntax for range type: {s:?} (column `{col_name}`)"
17828                    ),
17829                }));
17830            }
17831        },
17832        // Range → Text canonical form (`[a,b)`, `'empty'`, etc).
17833        (v @ Value::Range { .. }, DataType::Text) => Some(Value::Text(format_range_str(&v))),
17834        // v7.17.0 Phase 3.P0-39 — Text → Hstore.
17835        (Value::Text(s), DataType::Hstore) => match parse_hstore_str(&s) {
17836            Some(pairs) => Some(Value::Hstore(pairs)),
17837            None => {
17838                return Err(EngineError::Eval(EvalError::TypeMismatch {
17839                    detail: alloc::format!(
17840                        "invalid input syntax for type hstore: {s:?} (column `{col_name}`)"
17841                    ),
17842                }));
17843            }
17844        },
17845        // Hstore → Text canonical `"k"=>"v"` form.
17846        (Value::Hstore(pairs), DataType::Text) => Some(Value::Text(format_hstore_str(&pairs))),
17847        // v7.17.0 Phase 3.P0-40 — Text → 2D arrays via PG
17848        // external `'{{a,b},{c,d}}'` literal.
17849        (Value::Text(s), DataType::IntArray2D) => match parse_int_2d_literal(&s) {
17850            Ok(m) => Some(Value::IntArray2D(m)),
17851            Err(e) => {
17852                return Err(EngineError::Eval(EvalError::TypeMismatch {
17853                    detail: alloc::format!(
17854                        "invalid input syntax for INT[][]: {s:?} (column `{col_name}`): {e}"
17855                    ),
17856                }));
17857            }
17858        },
17859        (Value::Text(s), DataType::BigIntArray2D) => match parse_bigint_2d_literal(&s) {
17860            Ok(m) => Some(Value::BigIntArray2D(m)),
17861            Err(e) => {
17862                return Err(EngineError::Eval(EvalError::TypeMismatch {
17863                    detail: alloc::format!(
17864                        "invalid input syntax for BIGINT[][]: {s:?} (column `{col_name}`): {e}"
17865                    ),
17866                }));
17867            }
17868        },
17869        (Value::Text(s), DataType::TextArray2D) => match parse_text_2d_literal(&s) {
17870            Ok(m) => Some(Value::TextArray2D(m)),
17871            Err(e) => {
17872                return Err(EngineError::Eval(EvalError::TypeMismatch {
17873                    detail: alloc::format!(
17874                        "invalid input syntax for TEXT[][]: {s:?} (column `{col_name}`): {e}"
17875                    ),
17876                }));
17877            }
17878        },
17879        // 2D arrays → Text canonical nested form.
17880        (Value::IntArray2D(rows), DataType::Text) => Some(Value::Text(format_int_2d_text(&rows))),
17881        (Value::BigIntArray2D(rows), DataType::Text) => {
17882            Some(Value::Text(format_bigint_2d_text(&rows)))
17883        }
17884        (Value::TextArray2D(rows), DataType::Text) => Some(Value::Text(format_text_2d_text(&rows))),
17885        // v7.10.11 — Text → TEXT[]. Decode PG's external array
17886        // form `'{a,b,NULL}'`. NULL element token (case-insensitive)
17887        // is the literal `NULL`; everything else is a quoted or
17888        // unquoted text element. mailrs `'{label1,label2}'::TEXT[]`.
17889        (Value::Text(s), DataType::TextArray) => {
17890            let arr = decode_text_array_literal(&s).map_err(|e| {
17891                EngineError::Eval(EvalError::TypeMismatch {
17892                    detail: alloc::format!(
17893                        "cannot parse {s:?} as TEXT[] for column `{col_name}`: {e}"
17894                    ),
17895                })
17896            })?;
17897            Some(Value::TextArray(arr))
17898        }
17899        // v7.16.0 — Text → IntArray / BigIntArray for the
17900        // spg-sqlx Bind path. Decode the PG external form
17901        // `{1,2,3}` as a TEXT array first, then parse each
17902        // element as int. Same shape as the TextArray decode
17903        // above with an element-wise narrow.
17904        (Value::Text(s), DataType::IntArray) => {
17905            let arr = decode_text_array_literal(&s).map_err(|e| {
17906                EngineError::Eval(EvalError::TypeMismatch {
17907                    detail: alloc::format!(
17908                        "cannot parse {s:?} as INT[] for column `{col_name}`: {e}"
17909                    ),
17910                })
17911            })?;
17912            let mut out: Vec<Option<i32>> = Vec::with_capacity(arr.len());
17913            for elem in arr {
17914                match elem {
17915                    None => out.push(None),
17916                    Some(t) => {
17917                        let n: i32 = t.parse().map_err(|_| {
17918                            EngineError::Eval(EvalError::TypeMismatch {
17919                                detail: alloc::format!(
17920                                    "cannot parse {t:?} as INT element for `{col_name}`"
17921                                ),
17922                            })
17923                        })?;
17924                        out.push(Some(n));
17925                    }
17926                }
17927            }
17928            Some(Value::IntArray(out))
17929        }
17930        (Value::Text(s), DataType::BigIntArray) => {
17931            let arr = decode_text_array_literal(&s).map_err(|e| {
17932                EngineError::Eval(EvalError::TypeMismatch {
17933                    detail: alloc::format!(
17934                        "cannot parse {s:?} as BIGINT[] for column `{col_name}`: {e}"
17935                    ),
17936                })
17937            })?;
17938            let mut out: Vec<Option<i64>> = Vec::with_capacity(arr.len());
17939            for elem in arr {
17940                match elem {
17941                    None => out.push(None),
17942                    Some(t) => {
17943                        let n: i64 = t.parse().map_err(|_| {
17944                            EngineError::Eval(EvalError::TypeMismatch {
17945                                detail: alloc::format!(
17946                                    "cannot parse {t:?} as BIGINT element for `{col_name}`"
17947                                ),
17948                            })
17949                        })?;
17950                        out.push(Some(n));
17951                    }
17952                }
17953            }
17954            Some(Value::BigIntArray(out))
17955        }
17956        // v7.10.11 — TEXT[] → Text round-trip uses PG's
17957        // external array form (`{a,b,NULL}`). Lets a SELECT
17958        // pull an array column through any Text-side codepath.
17959        (Value::TextArray(items), DataType::Text) => Some(Value::Text(encode_text_array(&items))),
17960        // v7.17.0 Phase 3.P0-68 — Text → VECTOR auto-coerce.
17961        // Matches the existing Text → TsVector arm and the
17962        // `::vector` cast: PG-canonical pgvector external form
17963        // (`'[1, 2, -3]'`) becomes a typed Vector value at the
17964        // column boundary. Dim mismatch surfaces as TypeMismatch.
17965        // For SQ8 / HALF encodings we chain through the standard
17966        // quantise helpers so the storage shape matches the
17967        // declared encoding without a second coerce pass.
17968        (Value::Text(s), DataType::Vector { dim, encoding }) => {
17969            let parsed = eval::parse_vector_text(&s).ok_or_else(|| {
17970                EngineError::Eval(EvalError::TypeMismatch {
17971                    detail: alloc::format!("cannot parse {s:?} as VECTOR for column `{col_name}`"),
17972                })
17973            })?;
17974            if parsed.len() != dim as usize {
17975                return Err(EngineError::Eval(EvalError::TypeMismatch {
17976                    detail: alloc::format!(
17977                        "VECTOR({dim}) column `{col_name}` rejects literal of length {}",
17978                        parsed.len()
17979                    ),
17980                }));
17981            }
17982            Some(match encoding {
17983                VecEncoding::F32 => Value::Vector(parsed),
17984                VecEncoding::Sq8 => Value::Sq8Vector(spg_storage::quantize::quantize(&parsed)),
17985                VecEncoding::F16 => {
17986                    Value::HalfVector(spg_storage::halfvec::HalfVector::from_f32_slice(&parsed))
17987                }
17988            })
17989        }
17990        // v7.16.1 — Text → TSVECTOR auto-coerce for the
17991        // INSERT-side wire path (mailrs round-9 A.2.a). PG
17992        // implicitly promotes the TEXT literal at INSERT into a
17993        // TSVECTOR column; SPG previously rejected with a hard
17994        // type mismatch, blocking 23,276 pg_dump rows into
17995        // `messages.search_vector`. We route through the same
17996        // `decode_tsvector_external` the `::tsvector` cast
17997        // already uses, so PG-canonical forms (`'word'`,
17998        // `'word:1A,2B'`, multi-lexeme, empty `''`) all parse.
17999        (Value::Text(s), DataType::TsVector) => {
18000            let lexs = eval::decode_tsvector_external(&s).map_err(|e| {
18001                EngineError::Eval(EvalError::TypeMismatch {
18002                    detail: alloc::format!(
18003                        "cannot parse {s:?} as TSVECTOR for column `{col_name}`: {e}"
18004                    ),
18005                })
18006            })?;
18007            Some(Value::TsVector(lexs))
18008        }
18009        (Value::Text(s), DataType::Timestamp | DataType::Timestamptz) => {
18010            let t = eval::parse_timestamp_literal(&s).ok_or_else(|| {
18011                EngineError::Eval(EvalError::TypeMismatch {
18012                    detail: alloc::format!(
18013                        "cannot parse {s:?} as TIMESTAMP for column `{col_name}`"
18014                    ),
18015                })
18016            })?;
18017            Some(Value::Timestamp(t))
18018        }
18019        // DATE ↔ TIMESTAMP convertibility (DATE → midnight,
18020        // TIMESTAMP → day truncation).
18021        (Value::Date(d), DataType::Timestamp | DataType::Timestamptz) => {
18022            Some(Value::Timestamp(i64::from(d) * 86_400_000_000))
18023        }
18024        // v7.9.21 — Value::Timestamp lands in either Timestamp
18025        // or Timestamptz columns; the on-disk layout is the
18026        // same i64 microseconds UTC.
18027        (Value::Timestamp(t), DataType::Timestamptz) => Some(Value::Timestamp(t)),
18028        (Value::Timestamp(t), DataType::Date) => {
18029            let days = t.div_euclid(86_400_000_000);
18030            i32::try_from(days).ok().map(Value::Date)
18031        }
18032        (
18033            Value::Numeric {
18034                scaled,
18035                scale: src_scale,
18036            },
18037            DataType::Numeric { precision, scale },
18038        ) => Some(numeric_rescale(
18039            scaled, src_scale, precision, scale, col_name,
18040        )?),
18041        #[allow(clippy::cast_precision_loss)]
18042        (Value::Numeric { scaled, scale }, DataType::Float) => {
18043            let mut div = 1.0_f64;
18044            for _ in 0..scale {
18045                div *= 10.0;
18046            }
18047            Some(Value::Float((scaled as f64) / div))
18048        }
18049        (Value::Numeric { scaled, scale }, DataType::Int) => {
18050            let truncated = numeric_truncate_to_integer(scaled, scale);
18051            i32::try_from(truncated).ok().map(Value::Int)
18052        }
18053        (Value::Numeric { scaled, scale }, DataType::BigInt) => {
18054            let truncated = numeric_truncate_to_integer(scaled, scale);
18055            i64::try_from(truncated).ok().map(Value::BigInt)
18056        }
18057        (Value::Numeric { scaled, scale }, DataType::SmallInt) => {
18058            let truncated = numeric_truncate_to_integer(scaled, scale);
18059            i16::try_from(truncated).ok().map(Value::SmallInt)
18060        }
18061        // VARCHAR(n) enforces an upper bound on character count.
18062        (Value::Text(s), DataType::Varchar(max)) => {
18063            if u32::try_from(s.chars().count()).unwrap_or(u32::MAX) <= max {
18064                Some(Value::Text(s))
18065            } else {
18066                return Err(EngineError::Unsupported(alloc::format!(
18067                    "value for VARCHAR({max}) column `{col_name}` exceeds length: \
18068                     {} chars",
18069                    s.chars().count()
18070                )));
18071            }
18072        }
18073        // v6.0.1: f32 → SQ8 INSERT-time quantisation. Triggered
18074        // when the column declares `VECTOR(N) USING SQ8` and
18075        // the INSERT VALUES expression yields a raw f32 vector
18076        // (the normal pgvector-shape literal). Dim mismatch
18077        // falls through the `_ => None` arm and surfaces as
18078        // `TypeMismatch` with the expected SQ8 column type —
18079        // matching the F32 path's existing error.
18080        (
18081            Value::Vector(v),
18082            DataType::Vector {
18083                dim,
18084                encoding: VecEncoding::Sq8,
18085            },
18086        ) if v.len() == dim as usize => Some(Value::Sq8Vector(spg_storage::quantize::quantize(&v))),
18087        // v6.0.3: f32 → f16 INSERT-time conversion for HALF
18088        // columns. Bit-exact at the storage layer (modulo
18089        // half-precision rounding); no rerank pass needed at
18090        // search time.
18091        (
18092            Value::Vector(v),
18093            DataType::Vector {
18094                dim,
18095                encoding: VecEncoding::F16,
18096            },
18097        ) if v.len() == dim as usize => Some(Value::HalfVector(
18098            spg_storage::halfvec::HalfVector::from_f32_slice(&v),
18099        )),
18100        // CHAR(n) right-pads with U+0020 to exactly n chars; if the input
18101        // is already longer we reject (PG truncates trailing-space-only;
18102        // staying strict for v1).
18103        (Value::Text(s), DataType::Char(size)) => {
18104            let len = u32::try_from(s.chars().count()).unwrap_or(u32::MAX);
18105            if len > size {
18106                return Err(EngineError::Unsupported(alloc::format!(
18107                    "value for CHAR({size}) column `{col_name}` exceeds length: \
18108                     {len} chars"
18109                )));
18110            }
18111            let need = (size - len) as usize;
18112            let mut padded = s;
18113            padded.reserve(need);
18114            for _ in 0..need {
18115                padded.push(' ');
18116            }
18117            Some(Value::Text(padded))
18118        }
18119        _ => None,
18120    };
18121    coerced.ok_or(EngineError::Storage(StorageError::TypeMismatch {
18122        column: col_name.into(),
18123        expected,
18124        actual,
18125        position,
18126    }))
18127}
18128
18129/// v7.12.4 — render a function arg list into the
18130/// canonical form the storage layer caches as
18131/// [`spg_storage::FunctionDef::args_repr`]. The catalogue uses
18132/// this string for both display + as a coarse signature key
18133/// for the (deferred) overload resolution v7.12.5+ adds.
18134fn render_function_args(args: &[spg_sql::ast::FunctionArg]) -> alloc::string::String {
18135    use core::fmt::Write;
18136    let mut out = alloc::string::String::from("(");
18137    for (i, a) in args.iter().enumerate() {
18138        if i > 0 {
18139            out.push_str(", ");
18140        }
18141        match a.mode {
18142            spg_sql::ast::FunctionArgMode::In => {}
18143            spg_sql::ast::FunctionArgMode::Out => out.push_str("OUT "),
18144            spg_sql::ast::FunctionArgMode::InOut => out.push_str("INOUT "),
18145        }
18146        if let Some(n) = &a.name {
18147            out.push_str(n);
18148            out.push(' ');
18149        }
18150        match &a.ty {
18151            spg_sql::ast::FunctionArgType::Typed(t) => {
18152                let _ = write!(out, "{t}");
18153            }
18154            spg_sql::ast::FunctionArgType::Raw(s) => out.push_str(s),
18155        }
18156    }
18157    out.push(')');
18158    out
18159}
18160
18161/// v7.19 P5 — true iff `expr` is `unnest(arg)` at the top level
18162/// (case-insensitive). Used by `exec_select_cancel`'s
18163/// projection loop to detect Set-Returning-Function rows that
18164/// need per-row expansion. Only the top-level call counts —
18165/// `coalesce(unnest(arr), 'x')` is NOT a SRF row from the
18166/// projection's perspective; it would surface as an "unknown
18167/// function" mismatch downstream, which is what we want
18168/// (multi-SRF / nested SRF is documented carve-out for v7.19).
18169fn is_top_level_unnest(expr: &spg_sql::ast::Expr) -> bool {
18170    match expr {
18171        spg_sql::ast::Expr::FunctionCall { name, args } => {
18172            name.eq_ignore_ascii_case("unnest") && args.len() == 1
18173        }
18174        _ => false,
18175    }
18176}
18177
18178/// v7.19 P5 — extract the array argument out of a top-level
18179/// `unnest(arg)` call. `None` if `expr` isn't a `unnest` call
18180/// of arity 1 (mirrors `is_top_level_unnest`).
18181fn top_level_unnest_arg(expr: &spg_sql::ast::Expr) -> Option<&spg_sql::ast::Expr> {
18182    match expr {
18183        spg_sql::ast::Expr::FunctionCall { name, args }
18184            if name.eq_ignore_ascii_case("unnest") && args.len() == 1 =>
18185        {
18186            Some(&args[0])
18187        }
18188        _ => None,
18189    }
18190}
18191
18192/// v7.19 P5 — turn an array-typed `Value` into the element list
18193/// `unnest()` projection emits. NULL → empty list (PG: `unnest(NULL)
18194/// = (no rows)`). Non-array values fall through to a type-mismatch
18195/// error.
18196fn array_value_to_elements(v: &Value) -> Result<Vec<Value>, EngineError> {
18197    match v {
18198        Value::Null => Ok(Vec::new()),
18199        Value::TextArray(items) => Ok(items
18200            .iter()
18201            .map(|opt| {
18202                opt.as_ref()
18203                    .map(|s| Value::Text(s.clone()))
18204                    .unwrap_or(Value::Null)
18205            })
18206            .collect()),
18207        Value::IntArray(items) => Ok(items
18208            .iter()
18209            .map(|opt| opt.map(Value::Int).unwrap_or(Value::Null))
18210            .collect()),
18211        Value::BigIntArray(items) => Ok(items
18212            .iter()
18213            .map(|opt| opt.map(Value::BigInt).unwrap_or(Value::Null))
18214            .collect()),
18215        other => Err(EngineError::Eval(EvalError::TypeMismatch {
18216            detail: alloc::format!(
18217                "unnest() expects an array argument, got {:?}",
18218                other.data_type()
18219            ),
18220        })),
18221    }
18222}
18223
18224#[cfg(test)]
18225mod tests {
18226    use super::*;
18227    use alloc::vec;
18228
18229    fn unwrap_command_ok(r: &QueryResult) -> usize {
18230        match r {
18231            QueryResult::CommandOk { affected, .. } => *affected,
18232            QueryResult::Rows { .. } => panic!("expected CommandOk, got Rows"),
18233        }
18234    }
18235
18236    #[test]
18237    fn update_seek_positions_engages_on_indexed_eq() {
18238        let mut e = Engine::new();
18239        e.execute("CREATE TABLE b (id INT NOT NULL, v INT NOT NULL)")
18240            .unwrap();
18241        e.execute("CREATE INDEX b_id ON b (id)").unwrap();
18242        for i in 0..100 {
18243            e.execute(&alloc::format!("INSERT INTO b VALUES ({i}, {i})"))
18244                .unwrap();
18245        }
18246        let stmt = spg_sql::parser::parse_statement("UPDATE b SET v = v + 1 WHERE id = 42")
18247            .expect("parse");
18248        let Statement::Update(u) = stmt else {
18249            panic!("expected Update, got {stmt:?}");
18250        };
18251        let w = u.where_.as_ref().expect("where");
18252        let table = e.catalog().get("b").unwrap();
18253        let schema_cols = table.schema().columns.clone();
18254        // step-by-step: each sub-resolution must succeed.
18255        let Expr::Binary { lhs, op, rhs } = w else {
18256            panic!("WHERE not Binary: {w:?}");
18257        };
18258        assert_eq!(*op, BinOp::Eq, "op not Eq");
18259        let pair = resolve_col_literal_pair(lhs, rhs, &schema_cols, "b");
18260        assert!(
18261            pair.is_some(),
18262            "resolve_col_literal_pair None: lhs={lhs:?} rhs={rhs:?}"
18263        );
18264        let (col_pos, value) = pair.unwrap();
18265        assert!(
18266            table.index_on(col_pos).is_some(),
18267            "no index on col {col_pos}"
18268        );
18269        assert!(
18270            IndexKey::from_value(&value).is_some(),
18271            "IndexKey::from_value None for {value:?}"
18272        );
18273        let positions = try_index_seek_positions(w, &schema_cols, table, "b");
18274        assert_eq!(positions, Some(vec![42]), "seek did not engage");
18275    }
18276
18277    #[test]
18278    fn create_table_registers_schema() {
18279        let mut e = Engine::new();
18280        e.execute("CREATE TABLE foo (a INT NOT NULL, b TEXT)")
18281            .unwrap();
18282        assert_eq!(e.catalog().table_count(), 1);
18283        let t = e.catalog().get("foo").unwrap();
18284        assert_eq!(t.schema().columns.len(), 2);
18285        assert_eq!(t.schema().columns[0].ty, DataType::Int);
18286        assert!(!t.schema().columns[0].nullable);
18287        assert_eq!(t.schema().columns[1].ty, DataType::Text);
18288    }
18289
18290    #[test]
18291    fn create_table_vector_default_is_f32_encoded() {
18292        let mut e = Engine::new();
18293        e.execute("CREATE TABLE t (v VECTOR(8))").unwrap();
18294        let t = e.catalog().get("t").unwrap();
18295        assert_eq!(
18296            t.schema().columns[0].ty,
18297            DataType::Vector {
18298                dim: 8,
18299                encoding: VecEncoding::F32,
18300            },
18301        );
18302    }
18303
18304    #[test]
18305    fn create_table_vector_using_sq8_succeeds() {
18306        // v6.0.1 step 3: the step-1 fence in `column_def_to_schema`
18307        // is lifted. CREATE TABLE persists an SQ8 column type in
18308        // the catalog; INSERT (next test) quantises raw f32 input.
18309        let mut e = Engine::new();
18310        e.execute("CREATE TABLE t (v VECTOR(8) USING SQ8)").unwrap();
18311        let t = e.catalog().get("t").unwrap();
18312        assert_eq!(
18313            t.schema().columns[0].ty,
18314            DataType::Vector {
18315                dim: 8,
18316                encoding: VecEncoding::Sq8,
18317            },
18318        );
18319    }
18320
18321    #[test]
18322    fn insert_into_sq8_column_quantises_f32_payload() {
18323        // v6.0.1 step 3: INSERT-time `coerce_value` rewrites a raw
18324        // `Value::Vector(Vec<f32>)` literal into the column's
18325        // quantised representation. The row that lands in the
18326        // catalog must therefore hold a `Value::Sq8Vector`, not the
18327        // original f32 buffer — that's the bit that delivers the
18328        // 4× compression target.
18329        let mut e = Engine::new();
18330        e.execute("CREATE TABLE t (v VECTOR(4) USING SQ8)").unwrap();
18331        e.execute("INSERT INTO t VALUES ([0.0, 0.25, 0.5, 1.0])")
18332            .unwrap();
18333        let t = e.catalog().get("t").unwrap();
18334        assert_eq!(t.rows().len(), 1);
18335        match &t.rows()[0].values[0] {
18336            Value::Sq8Vector(q) => {
18337                assert_eq!(q.bytes.len(), 4);
18338                // min/max are derived from the payload: min=0.0, max=1.0.
18339                assert!((q.min - 0.0).abs() < 1e-6);
18340                assert!((q.max - 1.0).abs() < 1e-6);
18341            }
18342            other => panic!("expected Sq8Vector cell, got {other:?}"),
18343        }
18344    }
18345
18346    #[test]
18347    fn create_table_vector_using_half_succeeds_and_insert_converts_to_f16() {
18348        // v6.0.3: CREATE TABLE accepts USING HALF; INSERT path
18349        // converts the incoming `Value::Vector(Vec<f32>)` cell
18350        // into `Value::HalfVector(HalfVector)` via the new
18351        // `coerce_value` arm. The dequantised round-trip is
18352        // bit-exact for f16-representable values, so 0.0 / 0.25
18353        // / 0.5 / 1.0 hit their grid points exactly.
18354        let mut e = Engine::new();
18355        e.execute("CREATE TABLE t (v VECTOR(4) USING HALF)")
18356            .unwrap();
18357        e.execute("INSERT INTO t VALUES ([0.0, 0.25, 0.5, 1.0])")
18358            .unwrap();
18359        let t = e.catalog().get("t").unwrap();
18360        assert_eq!(t.rows().len(), 1);
18361        match &t.rows()[0].values[0] {
18362            Value::HalfVector(h) => {
18363                assert_eq!(h.dim(), 4);
18364                let back = h.to_f32_vec();
18365                let expected = alloc::vec![0.0_f32, 0.25, 0.5, 1.0];
18366                for (g, e) in back.iter().zip(expected.iter()) {
18367                    assert!(
18368                        (g - e).abs() < 1e-6,
18369                        "{g} vs {e} should be exact on f16 grid"
18370                    );
18371                }
18372            }
18373            other => panic!("expected HalfVector cell, got {other:?}"),
18374        }
18375    }
18376
18377    #[test]
18378    fn alter_index_rebuild_in_place_succeeds() {
18379        // v6.0.4: bare REBUILD (no encoding switch) walks every
18380        // row again to rebuild the NSW graph. Verifies the engine
18381        // dispatch + storage helper plumbing without changing any
18382        // cell encoding.
18383        let mut e = Engine::new();
18384        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(3) NOT NULL)")
18385            .unwrap();
18386        for i in 0..8_i32 {
18387            #[allow(clippy::cast_precision_loss)]
18388            let base = (i as f32) * 0.1;
18389            e.execute(&alloc::format!(
18390                "INSERT INTO t VALUES ({i}, [{base}, {b1}, {b2}])",
18391                b1 = base + 0.01,
18392                b2 = base + 0.02,
18393            ))
18394            .unwrap();
18395        }
18396        e.execute("CREATE INDEX t_idx ON t USING hnsw (v)").unwrap();
18397        e.execute("ALTER INDEX t_idx REBUILD").unwrap();
18398        // Schema encoding stays F32 (no encoding clause).
18399        assert_eq!(
18400            e.catalog().get("t").unwrap().schema().columns[1].ty,
18401            DataType::Vector {
18402                dim: 3,
18403                encoding: VecEncoding::F32,
18404            },
18405        );
18406    }
18407
18408    #[test]
18409    fn alter_index_rebuild_with_encoding_switches_cell_type() {
18410        // v6.0.4: REBUILD WITH (encoding = SQ8) recodes every
18411        // stored cell from F32 → SQ8 + rebuilds the graph atop the
18412        // new encoding. Post-rebuild, cells must be Sq8Vector and
18413        // the schema must report encoding = Sq8.
18414        let mut e = Engine::new();
18415        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(4) NOT NULL)")
18416            .unwrap();
18417        e.execute("INSERT INTO t VALUES (1, [0.0, 0.25, 0.5, 1.0])")
18418            .unwrap();
18419        e.execute("CREATE INDEX t_idx ON t USING hnsw (v)").unwrap();
18420        e.execute("ALTER INDEX t_idx REBUILD WITH (encoding = SQ8)")
18421            .unwrap();
18422        let t = e.catalog().get("t").unwrap();
18423        assert_eq!(
18424            t.schema().columns[1].ty,
18425            DataType::Vector {
18426                dim: 4,
18427                encoding: VecEncoding::Sq8,
18428            },
18429        );
18430        assert!(matches!(t.rows()[0].values[1], Value::Sq8Vector(_)));
18431    }
18432
18433    #[test]
18434    fn alter_index_rebuild_unknown_index_errors() {
18435        let mut e = Engine::new();
18436        let err = e.execute("ALTER INDEX nope REBUILD").unwrap_err();
18437        assert!(
18438            matches!(
18439                &err,
18440                EngineError::Storage(StorageError::IndexNotFound { name }) if name == "nope"
18441            ),
18442            "got: {err}"
18443        );
18444    }
18445
18446    #[test]
18447    fn alter_index_rebuild_on_btree_index_errors() {
18448        // REBUILD on a B-tree index has no semantic meaning in
18449        // v6.0.4 — rejected at the storage layer with `Unsupported`.
18450        let mut e = Engine::new();
18451        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18452        e.execute("INSERT INTO t VALUES (1)").unwrap();
18453        e.execute("CREATE INDEX t_idx ON t (id)").unwrap();
18454        let err = e.execute("ALTER INDEX t_idx REBUILD").unwrap_err();
18455        assert!(
18456            matches!(&err, EngineError::Storage(StorageError::Unsupported(_))),
18457            "got: {err}"
18458        );
18459    }
18460
18461    #[test]
18462    fn prepared_insert_substitutes_placeholders() {
18463        // v6.1.1: prepare() parses once; execute_prepared() walks the
18464        // AST and replaces $1/$2 with the param Values BEFORE the
18465        // dispatch sees them. Same logical result as a simple-query
18466        // INSERT, but parse happens once per *statement*, not per
18467        // execution.
18468        let mut e = Engine::new();
18469        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)")
18470            .unwrap();
18471        let stmt = e.prepare("INSERT INTO t VALUES ($1, $2)").unwrap();
18472        for (id, name) in [(1, "alice"), (2, "bob"), (3, "carol")] {
18473            e.execute_prepared(stmt.clone(), &[Value::Int(id), Value::Text(name.into())])
18474                .unwrap();
18475        }
18476        // Read back via simple-query SELECT.
18477        let rows_result = e.execute("SELECT id, name FROM t").unwrap();
18478        let QueryResult::Rows { rows, .. } = rows_result else {
18479            panic!("expected Rows")
18480        };
18481        assert_eq!(rows.len(), 3);
18482    }
18483
18484    #[test]
18485    fn prepared_select_with_placeholder_filters_rows() {
18486        let mut e = Engine::new();
18487        e.execute("CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)")
18488            .unwrap();
18489        for i in 0..10_i32 {
18490            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, {})", i * 7))
18491                .unwrap();
18492        }
18493        let stmt = e.prepare("SELECT id FROM t WHERE v = $1").unwrap();
18494        let QueryResult::Rows { rows, .. } = e.execute_prepared(stmt, &[Value::Int(35)]).unwrap()
18495        else {
18496            panic!("expected Rows")
18497        };
18498        // v = 35 means i*7 = 35 → i = 5.
18499        assert_eq!(rows.len(), 1);
18500        assert_eq!(rows[0].values[0], Value::Int(5));
18501    }
18502
18503    #[test]
18504    fn prepared_too_few_params_errors() {
18505        let mut e = Engine::new();
18506        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18507        let stmt = e.prepare("INSERT INTO t VALUES ($1)").unwrap();
18508        let err = e.execute_prepared(stmt, &[]).unwrap_err();
18509        assert!(
18510            matches!(
18511                &err,
18512                EngineError::Eval(EvalError::PlaceholderOutOfRange { n: 1, bound: 0 })
18513            ),
18514            "got: {err}"
18515        );
18516    }
18517
18518    #[test]
18519    fn bytea_cast_round_trips_text_input() {
18520        // v7.18 — `'hello'::bytea` produces the raw bytes. Closes
18521        // the mailrs D-pre #3 reverse-acceptance gap.
18522        let e = Engine::new();
18523        let r = e.execute_readonly("SELECT 'hello'::bytea").unwrap();
18524        let QueryResult::Rows { rows, .. } = r else {
18525            panic!("expected Rows")
18526        };
18527        assert_eq!(rows.len(), 1);
18528        assert_eq!(rows[0].values[0], Value::Bytes(b"hello".to_vec()));
18529    }
18530
18531    #[test]
18532    fn bytea_cast_pg_escape_hex_form() {
18533        // E'\\xdeadbeef'::bytea — E-string decodes to `\xdeadbeef`
18534        // (literal 10 chars), then ::bytea reads it as PG hex
18535        // form bytea literal → 4 bytes.
18536        let e = Engine::new();
18537        let r = e.execute_readonly(r"SELECT E'\\xdeadbeef'::bytea").unwrap();
18538        let QueryResult::Rows { rows, .. } = r else {
18539            panic!("expected Rows")
18540        };
18541        assert_eq!(
18542            rows[0].values[0],
18543            Value::Bytes(vec![0xde, 0xad, 0xbe, 0xef])
18544        );
18545    }
18546
18547    #[test]
18548    fn bytea_cast_chains_through_octet_length() {
18549        // octet_length('hello'::bytea) → 5. Confirms the cast
18550        // composes inside larger expressions, not just at top
18551        // level.
18552        let e = Engine::new();
18553        let r = e
18554            .execute_readonly("SELECT octet_length('hello'::bytea)")
18555            .unwrap();
18556        let QueryResult::Rows { rows, .. } = r else {
18557            panic!("expected Rows")
18558        };
18559        match &rows[0].values[0] {
18560            Value::Int(n) => assert_eq!(*n, 5),
18561            Value::BigInt(n) => assert_eq!(*n, 5),
18562            other => panic!("expected integer length, got {other:?}"),
18563        }
18564    }
18565
18566    #[test]
18567    fn readonly_prepared_on_snapshot_select_with_placeholder() {
18568        // v7.18 — sqlx Pool fan-out relies on running prepared
18569        // SELECTs against a frozen snapshot without re-entering
18570        // the writer engine. Mirrors the simple-query SELECT path
18571        // in `execute_readonly_on_snapshot` but takes a Statement
18572        // + bound params (the shape sqlx's Execute path produces).
18573        let mut e = Engine::new();
18574        e.execute("CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)")
18575            .unwrap();
18576        for i in 0..10_i32 {
18577            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, {})", i * 7))
18578                .unwrap();
18579        }
18580        let snapshot = e.clone_snapshot();
18581        let stmt = e.prepare("SELECT id FROM t WHERE v = $1").unwrap();
18582        let QueryResult::Rows { rows, .. } =
18583            Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(35)])
18584                .unwrap()
18585        else {
18586            panic!("expected Rows")
18587        };
18588        assert_eq!(rows.len(), 1);
18589        assert_eq!(rows[0].values[0], Value::Int(5));
18590    }
18591
18592    #[test]
18593    fn readonly_prepared_on_snapshot_rejects_writes() {
18594        // DDL / DML prepared statements on the readonly path must
18595        // surface `WriteRequired` so the spg-sqlx connection layer
18596        // routes them to the writer mutex instead of the snapshot.
18597        let mut e = Engine::new();
18598        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18599        let snapshot = e.clone_snapshot();
18600        let stmt = e.prepare("INSERT INTO t VALUES ($1)").unwrap();
18601        let err = Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(1)])
18602            .unwrap_err();
18603        assert!(matches!(&err, EngineError::WriteRequired), "got: {err}");
18604    }
18605
18606    #[test]
18607    fn readonly_prepared_on_snapshot_frozen_view() {
18608        // The snapshot reflects engine state at clone_snapshot()
18609        // time. Writes after the snapshot are NOT visible — caller
18610        // takes a fresh snapshot (or `AsyncReadHandle::refresh()`)
18611        // to see them. This is the contract the per-statement
18612        // refresh in spg-sqlx relies on.
18613        let mut e = Engine::new();
18614        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18615        e.execute("INSERT INTO t VALUES (1)").unwrap();
18616        let snapshot = e.clone_snapshot();
18617        e.execute("INSERT INTO t VALUES (2)").unwrap();
18618        let stmt = e.prepare("SELECT id FROM t WHERE id = $1").unwrap();
18619        let QueryResult::Rows { rows, .. } =
18620            Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(2)])
18621                .unwrap()
18622        else {
18623            panic!("expected Rows")
18624        };
18625        assert!(rows.is_empty(), "id=2 was inserted after snapshot");
18626    }
18627
18628    #[test]
18629    fn describe_prepared_on_snapshot_resolves_columns() {
18630        // v7.18 — sqlx's Executor::describe path on the readonly
18631        // fan-out needs to resolve column names + types against
18632        // the snapshot's catalog (not the live engine's catalog,
18633        // which may have moved on).
18634        let mut e = Engine::new();
18635        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)")
18636            .unwrap();
18637        let snapshot = e.clone_snapshot();
18638        let stmt = e.prepare("SELECT id, name FROM t WHERE id = $1").unwrap();
18639        let (_params, cols) = Engine::describe_prepared_on_snapshot(&snapshot, &stmt);
18640        assert_eq!(cols.len(), 2);
18641        assert_eq!(cols[0].name, "id");
18642        assert_eq!(cols[0].ty, DataType::Int);
18643        assert_eq!(cols[1].name, "name");
18644        assert_eq!(cols[1].ty, DataType::Text);
18645    }
18646
18647    #[test]
18648    fn insert_into_half_column_dim_mismatch_errors() {
18649        let mut e = Engine::new();
18650        e.execute("CREATE TABLE t (v VECTOR(4) USING HALF)")
18651            .unwrap();
18652        let err = e.execute("INSERT INTO t VALUES ([1.0, 2.0])").unwrap_err();
18653        assert!(matches!(
18654            &err,
18655            EngineError::Storage(StorageError::TypeMismatch { .. })
18656        ));
18657    }
18658
18659    #[test]
18660    fn insert_into_sq8_column_dim_mismatch_errors() {
18661        // Dim mismatch falls through the `coerce_value` Vector→Sq8
18662        // arm's guard and surfaces as `TypeMismatch` — the same
18663        // error the F32 path produces today, so client error
18664        // handling stays uniform across encodings.
18665        let mut e = Engine::new();
18666        e.execute("CREATE TABLE t (v VECTOR(4) USING SQ8)").unwrap();
18667        let err = e.execute("INSERT INTO t VALUES ([1.0, 2.0])").unwrap_err();
18668        assert!(
18669            matches!(
18670                &err,
18671                EngineError::Storage(StorageError::TypeMismatch { .. })
18672            ),
18673            "got: {err}",
18674        );
18675    }
18676
18677    #[test]
18678    fn create_table_duplicate_errors() {
18679        let mut e = Engine::new();
18680        e.execute("CREATE TABLE foo (a INT)").unwrap();
18681        let err = e.execute("CREATE TABLE foo (a INT)").unwrap_err();
18682        assert!(matches!(
18683            err,
18684            EngineError::Storage(StorageError::DuplicateTable { ref name }) if name == "foo"
18685        ));
18686    }
18687
18688    #[test]
18689    fn insert_into_unknown_table_errors() {
18690        let mut e = Engine::new();
18691        let err = e.execute("INSERT INTO ghost VALUES (1)").unwrap_err();
18692        assert!(matches!(
18693            err,
18694            EngineError::Storage(StorageError::TableNotFound { ref name }) if name == "ghost"
18695        ));
18696    }
18697
18698    #[test]
18699    fn insert_happy_path_reports_one_affected() {
18700        let mut e = Engine::new();
18701        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
18702        let r = e.execute("INSERT INTO foo VALUES (42)").unwrap();
18703        assert_eq!(unwrap_command_ok(&r), 1);
18704        assert_eq!(e.catalog().get("foo").unwrap().row_count(), 1);
18705    }
18706
18707    #[test]
18708    fn insert_arity_mismatch_propagates() {
18709        let mut e = Engine::new();
18710        e.execute("CREATE TABLE foo (a INT, b TEXT)").unwrap();
18711        let err = e.execute("INSERT INTO foo VALUES (1)").unwrap_err();
18712        assert!(matches!(
18713            err,
18714            EngineError::Storage(StorageError::ArityMismatch { .. })
18715        ));
18716    }
18717
18718    #[test]
18719    fn insert_negative_integer_via_unary_minus() {
18720        let mut e = Engine::new();
18721        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
18722        e.execute("INSERT INTO foo VALUES (-7)").unwrap();
18723        let rows = e.catalog().get("foo").unwrap().rows();
18724        assert_eq!(rows[0].values[0], Value::Int(-7));
18725    }
18726
18727    #[test]
18728    fn insert_expression_evaluated_against_empty_context() {
18729        // PG-canonical: INSERT VALUES accepts an arbitrary scalar
18730        // expression. The engine evaluates against an empty row
18731        // context — column references would error, but pure
18732        // arithmetic / function calls are fine.
18733        let mut e = Engine::new();
18734        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
18735        e.execute("INSERT INTO foo VALUES (1 + 2)").unwrap();
18736        let rows = e.catalog().get("foo").unwrap().rows();
18737        assert_eq!(rows[0].values[0], Value::Int(3));
18738    }
18739
18740    #[test]
18741    fn select_star_returns_all_rows_in_insertion_order() {
18742        let mut e = Engine::new();
18743        e.execute("CREATE TABLE foo (a INT NOT NULL, b TEXT NOT NULL)")
18744            .unwrap();
18745        e.execute("INSERT INTO foo VALUES (1, 'one')").unwrap();
18746        e.execute("INSERT INTO foo VALUES (2, 'two')").unwrap();
18747        e.execute("INSERT INTO foo VALUES (3, 'three')").unwrap();
18748
18749        let r = e.execute("SELECT * FROM foo").unwrap();
18750        let QueryResult::Rows { columns, rows } = r else {
18751            panic!("expected Rows")
18752        };
18753        assert_eq!(columns.len(), 2);
18754        assert_eq!(columns[0].name, "a");
18755        assert_eq!(rows.len(), 3);
18756        assert_eq!(
18757            rows[1].values,
18758            vec![Value::Int(2), Value::Text("two".into())]
18759        );
18760    }
18761
18762    #[test]
18763    fn select_star_on_empty_table_returns_zero_rows() {
18764        let mut e = Engine::new();
18765        e.execute("CREATE TABLE foo (a INT)").unwrap();
18766        let r = e.execute("SELECT * FROM foo").unwrap();
18767        match r {
18768            QueryResult::Rows { rows, .. } => assert!(rows.is_empty()),
18769            QueryResult::CommandOk { .. } => panic!("expected Rows"),
18770        }
18771    }
18772
18773    // --- v0.4: WHERE + projection ------------------------------------------
18774
18775    fn make_three_row_users(e: &mut Engine) {
18776        e.execute("CREATE TABLE users (id INT NOT NULL, name TEXT NOT NULL, score INT)")
18777            .unwrap();
18778        e.execute("INSERT INTO users VALUES (1, 'alice', 90)")
18779            .unwrap();
18780        e.execute("INSERT INTO users VALUES (2, 'bob', NULL)")
18781            .unwrap();
18782        e.execute("INSERT INTO users VALUES (3, 'cara', 70)")
18783            .unwrap();
18784    }
18785
18786    fn unwrap_rows(r: QueryResult) -> (Vec<ColumnSchema>, Vec<Row>) {
18787        match r {
18788            QueryResult::Rows { columns, rows } => (columns, rows),
18789            QueryResult::CommandOk { .. } => panic!("expected Rows"),
18790        }
18791    }
18792
18793    #[test]
18794    fn where_filter_passes_only_true_rows() {
18795        let mut e = Engine::new();
18796        make_three_row_users(&mut e);
18797        let r = e.execute("SELECT * FROM users WHERE id > 1").unwrap();
18798        let (_, rows) = unwrap_rows(r);
18799        assert_eq!(rows.len(), 2);
18800        assert_eq!(rows[0].values[0], Value::Int(2));
18801        assert_eq!(rows[1].values[0], Value::Int(3));
18802    }
18803
18804    #[test]
18805    fn where_with_null_result_filters_out_row() {
18806        let mut e = Engine::new();
18807        make_three_row_users(&mut e);
18808        // score is NULL for bob → score > 80 is NULL → row excluded
18809        let r = e.execute("SELECT * FROM users WHERE score > 80").unwrap();
18810        let (_, rows) = unwrap_rows(r);
18811        assert_eq!(rows.len(), 1);
18812        assert_eq!(rows[0].values[1], Value::Text("alice".into()));
18813    }
18814
18815    #[test]
18816    fn projection_named_columns() {
18817        let mut e = Engine::new();
18818        make_three_row_users(&mut e);
18819        let r = e.execute("SELECT name, score FROM users").unwrap();
18820        let (cols, rows) = unwrap_rows(r);
18821        assert_eq!(cols.len(), 2);
18822        assert_eq!(cols[0].name, "name");
18823        assert_eq!(cols[1].name, "score");
18824        assert_eq!(rows.len(), 3);
18825        assert_eq!(
18826            rows[0].values,
18827            vec![Value::Text("alice".into()), Value::Int(90)]
18828        );
18829    }
18830
18831    #[test]
18832    fn projection_with_column_alias() {
18833        let mut e = Engine::new();
18834        make_three_row_users(&mut e);
18835        let r = e
18836            .execute("SELECT name AS who FROM users WHERE id = 1")
18837            .unwrap();
18838        let (cols, rows) = unwrap_rows(r);
18839        assert_eq!(cols[0].name, "who");
18840        assert_eq!(rows.len(), 1);
18841        assert_eq!(rows[0].values[0], Value::Text("alice".into()));
18842    }
18843
18844    #[test]
18845    fn qualified_column_with_table_alias_resolves() {
18846        let mut e = Engine::new();
18847        make_three_row_users(&mut e);
18848        let r = e
18849            .execute("SELECT u.id, u.name FROM users AS u WHERE u.id < 3")
18850            .unwrap();
18851        let (cols, rows) = unwrap_rows(r);
18852        assert_eq!(cols.len(), 2);
18853        assert_eq!(rows.len(), 2);
18854    }
18855
18856    #[test]
18857    fn qualified_column_with_wrong_alias_errors() {
18858        let mut e = Engine::new();
18859        make_three_row_users(&mut e);
18860        let err = e.execute("SELECT x.id FROM users AS u").unwrap_err();
18861        assert!(matches!(
18862            err,
18863            EngineError::Eval(EvalError::UnknownQualifier { ref qualifier }) if qualifier == "x"
18864        ));
18865    }
18866
18867    #[test]
18868    fn select_unknown_column_errors_in_projection() {
18869        let mut e = Engine::new();
18870        make_three_row_users(&mut e);
18871        let err = e.execute("SELECT ghost FROM users").unwrap_err();
18872        assert!(matches!(
18873            err,
18874            EngineError::Eval(EvalError::ColumnNotFound { ref name }) if name == "ghost"
18875        ));
18876    }
18877
18878    #[test]
18879    fn where_unknown_column_errors() {
18880        let mut e = Engine::new();
18881        make_three_row_users(&mut e);
18882        let err = e
18883            .execute("SELECT * FROM users WHERE ghost = 1")
18884            .unwrap_err();
18885        assert!(matches!(
18886            err,
18887            EngineError::Eval(EvalError::ColumnNotFound { .. })
18888        ));
18889    }
18890
18891    #[test]
18892    fn expression_projection_evaluates_and_renders() {
18893        // Compound expressions in the SELECT list are evaluated per row;
18894        // the output column is typed TEXT, name defaults to the expression.
18895        let mut e = Engine::new();
18896        e.execute("CREATE TABLE t (a INT NOT NULL)").unwrap();
18897        e.execute("INSERT INTO t VALUES (3)").unwrap();
18898        let (_, rows) = unwrap_rows(e.execute("SELECT 1 + 2 FROM t").unwrap());
18899        assert_eq!(rows.len(), 1);
18900        // The expression evaluates to integer 3; rendered as the cell value
18901        // (storage::Value::Int(3) since arithmetic kept ints).
18902        assert_eq!(rows[0].values[0], Value::Int(3));
18903    }
18904
18905    #[test]
18906    fn select_unknown_table_errors() {
18907        let mut e = Engine::new();
18908        let err = e.execute("SELECT * FROM ghost").unwrap_err();
18909        assert!(matches!(
18910            err,
18911            EngineError::Storage(StorageError::TableNotFound { .. })
18912        ));
18913    }
18914
18915    #[test]
18916    fn invalid_sql_returns_parse_error() {
18917        // v4.4: UPDATE is now real SQL, so use a true syntactic
18918        // garbage payload for the parse-error path.
18919        let mut e = Engine::new();
18920        let err = e.execute("THIS_IS_NOT_A_KEYWORD foo bar baz").unwrap_err();
18921        assert!(matches!(err, EngineError::Parse(_)));
18922    }
18923
18924    // --- v0.8 CREATE INDEX + index seek ------------------------------------
18925
18926    #[test]
18927    fn create_index_registers_on_table() {
18928        let mut e = Engine::new();
18929        make_three_row_users(&mut e);
18930        e.execute("CREATE INDEX by_name ON users (name)").unwrap();
18931        let t = e.catalog().get("users").unwrap();
18932        assert_eq!(t.indices().len(), 1);
18933        assert_eq!(t.indices()[0].name, "by_name");
18934    }
18935
18936    #[test]
18937    fn create_index_on_unknown_table_errors() {
18938        let mut e = Engine::new();
18939        let err = e.execute("CREATE INDEX i ON ghost (a)").unwrap_err();
18940        assert!(matches!(
18941            err,
18942            EngineError::Storage(StorageError::TableNotFound { .. })
18943        ));
18944    }
18945
18946    #[test]
18947    fn create_index_on_unknown_column_errors() {
18948        let mut e = Engine::new();
18949        make_three_row_users(&mut e);
18950        let err = e.execute("CREATE INDEX i ON users (ghost)").unwrap_err();
18951        assert!(matches!(
18952            err,
18953            EngineError::Storage(StorageError::ColumnNotFound { .. })
18954        ));
18955    }
18956
18957    #[test]
18958    fn select_eq_uses_index_returns_same_rows_as_scan() {
18959        // Build two engines: one with an index, one without. Same query →
18960        // same row set (index is a planner optimisation, not a semantic
18961        // change).
18962        let mut without = Engine::new();
18963        make_three_row_users(&mut without);
18964        let mut with = Engine::new();
18965        make_three_row_users(&mut with);
18966        with.execute("CREATE INDEX by_id ON users (id)").unwrap();
18967
18968        let q = "SELECT * FROM users WHERE id = 2";
18969        let (_, no_idx_rows) = unwrap_rows(without.execute(q).unwrap());
18970        let (_, idx_rows) = unwrap_rows(with.execute(q).unwrap());
18971        assert_eq!(no_idx_rows, idx_rows);
18972        assert_eq!(idx_rows.len(), 1);
18973    }
18974
18975    #[test]
18976    fn select_eq_with_no_matching_index_value_returns_empty() {
18977        let mut e = Engine::new();
18978        make_three_row_users(&mut e);
18979        e.execute("CREATE INDEX by_id ON users (id)").unwrap();
18980        let (_, rows) = unwrap_rows(e.execute("SELECT * FROM users WHERE id = 999").unwrap());
18981        assert_eq!(rows.len(), 0);
18982    }
18983
18984    // --- v0.9 transactions -------------------------------------------------
18985
18986    #[test]
18987    fn begin_sets_in_transaction_flag() {
18988        let mut e = Engine::new();
18989        assert!(!e.in_transaction());
18990        e.execute("BEGIN").unwrap();
18991        assert!(e.in_transaction());
18992    }
18993
18994    #[test]
18995    fn double_begin_errors() {
18996        let mut e = Engine::new();
18997        e.execute("BEGIN").unwrap();
18998        let err = e.execute("BEGIN").unwrap_err();
18999        assert_eq!(err, EngineError::TransactionAlreadyOpen);
19000    }
19001
19002    #[test]
19003    fn commit_without_begin_errors() {
19004        let mut e = Engine::new();
19005        let err = e.execute("COMMIT").unwrap_err();
19006        assert_eq!(err, EngineError::NoActiveTransaction);
19007    }
19008
19009    #[test]
19010    fn rollback_without_begin_errors() {
19011        let mut e = Engine::new();
19012        let err = e.execute("ROLLBACK").unwrap_err();
19013        assert_eq!(err, EngineError::NoActiveTransaction);
19014    }
19015
19016    #[test]
19017    fn commit_applies_shadow_to_committed_catalog() {
19018        let mut e = Engine::new();
19019        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
19020        e.execute("BEGIN").unwrap();
19021        e.execute("INSERT INTO t VALUES (1)").unwrap();
19022        e.execute("INSERT INTO t VALUES (2)").unwrap();
19023        e.execute("COMMIT").unwrap();
19024        assert!(!e.in_transaction());
19025        assert_eq!(e.catalog().get("t").unwrap().row_count(), 2);
19026    }
19027
19028    #[test]
19029    fn rollback_discards_shadow() {
19030        let mut e = Engine::new();
19031        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
19032        e.execute("BEGIN").unwrap();
19033        e.execute("INSERT INTO t VALUES (1)").unwrap();
19034        e.execute("INSERT INTO t VALUES (2)").unwrap();
19035        e.execute("ROLLBACK").unwrap();
19036        assert!(!e.in_transaction());
19037        assert_eq!(e.catalog().get("t").unwrap().row_count(), 0);
19038    }
19039
19040    #[test]
19041    fn select_during_tx_sees_uncommitted_writes_own_session() {
19042        // The shadow catalog is read by SELECTs while a TX is open — the
19043        // session can see its own pending writes.
19044        let mut e = Engine::new();
19045        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
19046        e.execute("BEGIN").unwrap();
19047        e.execute("INSERT INTO t VALUES (42)").unwrap();
19048        let (_, rows) = unwrap_rows(e.execute("SELECT * FROM t").unwrap());
19049        assert_eq!(rows.len(), 1);
19050        assert_eq!(rows[0].values[0], Value::Int(42));
19051    }
19052
19053    #[test]
19054    fn snapshot_with_no_users_is_bare_catalog_format() {
19055        let mut e = Engine::new();
19056        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19057        let bytes = e.snapshot();
19058        assert_eq!(
19059            &bytes[..8],
19060            b"SPGDB001",
19061            "must be the bare v3.x catalog magic"
19062        );
19063        let e2 = Engine::restore_envelope(&bytes).unwrap();
19064        assert!(e2.users().is_empty());
19065        assert_eq!(e2.catalog().table_count(), 1);
19066    }
19067
19068    #[test]
19069    fn snapshot_with_users_round_trips_both_via_envelope() {
19070        let mut e = Engine::new();
19071        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19072        e.create_user("alice", "pw1", Role::Admin, [9; 16]).unwrap();
19073        e.create_user("bob", "pw2", Role::ReadOnly, [5; 16])
19074            .unwrap();
19075        let bytes = e.snapshot();
19076        assert_eq!(&bytes[..8], b"SPGENV01", "must be the v4.1 envelope magic");
19077        let e2 = Engine::restore_envelope(&bytes).unwrap();
19078        assert_eq!(e2.users().len(), 2);
19079        assert_eq!(e2.verify_user("alice", "pw1"), Some(Role::Admin));
19080        assert_eq!(e2.verify_user("bob", "pw2"), Some(Role::ReadOnly));
19081        assert_eq!(e2.verify_user("alice", "wrong"), None);
19082        assert_eq!(e2.catalog().table_count(), 1);
19083    }
19084
19085    #[test]
19086    fn ddl_inside_tx_also_rolled_back() {
19087        let mut e = Engine::new();
19088        e.execute("BEGIN").unwrap();
19089        e.execute("CREATE TABLE t (v INT)").unwrap();
19090        // Visible inside the TX.
19091        e.execute("SELECT * FROM t").unwrap();
19092        e.execute("ROLLBACK").unwrap();
19093        // Gone after rollback.
19094        let err = e.execute("SELECT * FROM t").unwrap_err();
19095        assert!(matches!(
19096            err,
19097            EngineError::Storage(StorageError::TableNotFound { .. })
19098        ));
19099    }
19100
19101    // ── v6.1.2: CREATE / DROP PUBLICATION (engine-side) ──────
19102
19103    #[test]
19104    fn create_publication_lands_in_catalog() {
19105        let mut e = Engine::new();
19106        assert!(e.publications().is_empty());
19107        e.execute("CREATE PUBLICATION pub_a").unwrap();
19108        assert_eq!(e.publications().len(), 1);
19109        assert!(e.publications().contains("pub_a"));
19110    }
19111
19112    #[test]
19113    fn create_publication_duplicate_errors() {
19114        let mut e = Engine::new();
19115        e.execute("CREATE PUBLICATION pub_a").unwrap();
19116        let err = e.execute("CREATE PUBLICATION pub_a").unwrap_err();
19117        assert!(
19118            alloc::format!("{err:?}").contains("DuplicateName"),
19119            "got {err:?}"
19120        );
19121    }
19122
19123    #[test]
19124    fn drop_publication_silent_when_absent() {
19125        let mut e = Engine::new();
19126        // PG-compatible: DROP a publication that doesn't exist
19127        // succeeds (no-op) but reports zero affected.
19128        let r = e.execute("DROP PUBLICATION nope").unwrap();
19129        match r {
19130            QueryResult::CommandOk { affected, .. } => assert_eq!(affected, 0),
19131            other => panic!("expected CommandOk, got {other:?}"),
19132        }
19133    }
19134
19135    #[test]
19136    fn drop_publication_present_reports_one_affected() {
19137        let mut e = Engine::new();
19138        e.execute("CREATE PUBLICATION pub_a").unwrap();
19139        let r = e.execute("DROP PUBLICATION pub_a").unwrap();
19140        match r {
19141            QueryResult::CommandOk {
19142                affected,
19143                modified_catalog,
19144            } => {
19145                assert_eq!(affected, 1);
19146                assert!(modified_catalog);
19147            }
19148            other => panic!("expected CommandOk, got {other:?}"),
19149        }
19150        assert!(e.publications().is_empty());
19151    }
19152
19153    #[test]
19154    fn publications_persist_across_snapshot_restore() {
19155        // The persist-across-restart ship-gate at the engine layer —
19156        // snapshot → restore_envelope round trip must preserve the
19157        // publication catalog. The spg-server e2e covers the
19158        // process-restart variant.
19159        let mut e = Engine::new();
19160        e.execute("CREATE PUBLICATION pub_a").unwrap();
19161        e.execute("CREATE PUBLICATION pub_b FOR ALL TABLES")
19162            .unwrap();
19163        let snap = e.snapshot();
19164        let e2 = Engine::restore_envelope(&snap).unwrap();
19165        assert_eq!(e2.publications().len(), 2);
19166        assert!(e2.publications().contains("pub_a"));
19167        assert!(e2.publications().contains("pub_b"));
19168    }
19169
19170    #[test]
19171    fn create_publication_allowed_inside_transaction() {
19172        // v6.1.4 dropped the v6.1.2 in-TX guard — PG allows
19173        // CREATE PUBLICATION inside a TX and the auto-commit
19174        // wrap path needs the same allowance.
19175        let mut e = Engine::new();
19176        e.execute("BEGIN").unwrap();
19177        e.execute("CREATE PUBLICATION pub_a").unwrap();
19178        e.execute("COMMIT").unwrap();
19179        assert!(e.publications().contains("pub_a"));
19180    }
19181
19182    // ── v6.1.3: SHOW PUBLICATIONS + FOR-list variants ───────
19183
19184    #[test]
19185    fn create_publication_for_table_list_lands_with_scope() {
19186        let mut e = Engine::new();
19187        e.execute("CREATE TABLE t1 (id INT NOT NULL)").unwrap();
19188        e.execute("CREATE TABLE t2 (id INT NOT NULL)").unwrap();
19189        e.execute("CREATE PUBLICATION pub_a FOR TABLE t1, t2")
19190            .unwrap();
19191        let scope = e.publications().get("pub_a").cloned();
19192        let Some(spg_sql::ast::PublicationScope::ForTables(ts)) = scope else {
19193            panic!("expected ForTables scope, got {scope:?}")
19194        };
19195        assert_eq!(ts, alloc::vec!["t1".to_string(), "t2".to_string()]);
19196    }
19197
19198    #[test]
19199    fn create_publication_all_tables_except_lands_with_scope() {
19200        let mut e = Engine::new();
19201        e.execute("CREATE PUBLICATION pub_a FOR ALL TABLES EXCEPT t3")
19202            .unwrap();
19203        let scope = e.publications().get("pub_a").cloned();
19204        let Some(spg_sql::ast::PublicationScope::AllTablesExcept(ts)) = scope else {
19205            panic!("expected AllTablesExcept scope, got {scope:?}")
19206        };
19207        assert_eq!(ts, alloc::vec!["t3".to_string()]);
19208    }
19209
19210    #[test]
19211    fn show_publications_empty_returns_zero_rows() {
19212        let e = Engine::new();
19213        let r = e.execute_readonly("SHOW PUBLICATIONS").unwrap();
19214        let QueryResult::Rows { rows, columns } = r else {
19215            panic!()
19216        };
19217        assert!(rows.is_empty());
19218        assert_eq!(columns.len(), 3);
19219        assert_eq!(columns[0].name, "name");
19220        assert_eq!(columns[1].name, "scope");
19221        assert_eq!(columns[2].name, "table_count");
19222    }
19223
19224    #[test]
19225    fn show_publications_returns_one_row_per_publication_ordered_by_name() {
19226        let mut e = Engine::new();
19227        e.execute("CREATE PUBLICATION z_pub").unwrap();
19228        e.execute("CREATE PUBLICATION a_pub FOR TABLE t1, t2")
19229            .unwrap();
19230        e.execute("CREATE PUBLICATION m_pub FOR ALL TABLES EXCEPT bad")
19231            .unwrap();
19232        let r = e.execute_readonly("SHOW PUBLICATIONS").unwrap();
19233        let QueryResult::Rows { rows, .. } = r else {
19234            panic!()
19235        };
19236        assert_eq!(rows.len(), 3);
19237        // Alphabetical order: a_pub, m_pub, z_pub.
19238        let names: Vec<&str> = rows
19239            .iter()
19240            .map(|r| {
19241                if let Value::Text(s) = &r.values[0] {
19242                    s.as_str()
19243                } else {
19244                    panic!()
19245                }
19246            })
19247            .collect();
19248        assert_eq!(names, alloc::vec!["a_pub", "m_pub", "z_pub"]);
19249        // Row 0 — a_pub scope summary + table_count = 2.
19250        match &rows[0].values[1] {
19251            Value::Text(s) => assert_eq!(s, "FOR TABLE t1, t2"),
19252            other => panic!("expected Text, got {other:?}"),
19253        }
19254        assert_eq!(rows[0].values[2], Value::Int(2));
19255        // Row 1 — m_pub.
19256        match &rows[1].values[1] {
19257            Value::Text(s) => assert_eq!(s, "FOR ALL TABLES EXCEPT bad"),
19258            other => panic!("expected Text, got {other:?}"),
19259        }
19260        assert_eq!(rows[1].values[2], Value::Int(1));
19261        // Row 2 — z_pub (AllTables → NULL count).
19262        match &rows[2].values[1] {
19263            Value::Text(s) => assert_eq!(s, "FOR ALL TABLES"),
19264            other => panic!("expected Text, got {other:?}"),
19265        }
19266        assert_eq!(rows[2].values[2], Value::Null);
19267    }
19268
19269    #[test]
19270    fn for_list_scopes_persist_across_snapshot() {
19271        // The v6.1.2 envelope-v3 round-trip exercised AllTables;
19272        // v6.1.3 needs the scope-1 / scope-2 tags to survive too.
19273        let mut e = Engine::new();
19274        e.execute("CREATE PUBLICATION p1 FOR TABLE t1, t2").unwrap();
19275        e.execute("CREATE PUBLICATION p2 FOR ALL TABLES EXCEPT bad, worse")
19276            .unwrap();
19277        let snap = e.snapshot();
19278        let e2 = Engine::restore_envelope(&snap).unwrap();
19279        assert_eq!(e2.publications().len(), 2);
19280        let p1 = e2.publications().get("p1").cloned();
19281        let Some(spg_sql::ast::PublicationScope::ForTables(ts)) = p1 else {
19282            panic!("p1 scope lost: {p1:?}")
19283        };
19284        assert_eq!(ts, alloc::vec!["t1".to_string(), "t2".to_string()]);
19285        let p2 = e2.publications().get("p2").cloned();
19286        let Some(spg_sql::ast::PublicationScope::AllTablesExcept(ts)) = p2 else {
19287            panic!("p2 scope lost: {p2:?}")
19288        };
19289        assert_eq!(ts, alloc::vec!["bad".to_string(), "worse".to_string()]);
19290    }
19291
19292    // ── v6.1.4: CREATE / DROP SUBSCRIPTION + SHOW + envelope v4 ─
19293
19294    #[test]
19295    fn create_subscription_lands_in_catalog_with_defaults() {
19296        let mut e = Engine::new();
19297        e.execute(
19298            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=127.0.0.1 port=20002' PUBLICATION pub_a",
19299        )
19300        .unwrap();
19301        let s = e.subscriptions().get("sub_a").cloned().expect("present");
19302        assert_eq!(s.conn_str, "host=127.0.0.1 port=20002");
19303        assert_eq!(s.publications, alloc::vec!["pub_a".to_string()]);
19304        assert!(s.enabled);
19305        assert_eq!(s.last_received_pos, 0);
19306    }
19307
19308    #[test]
19309    fn create_subscription_duplicate_name_errors() {
19310        let mut e = Engine::new();
19311        e.execute("CREATE SUBSCRIPTION s CONNECTION 'host=x' PUBLICATION p")
19312            .unwrap();
19313        let err = e
19314            .execute("CREATE SUBSCRIPTION s CONNECTION 'host=y' PUBLICATION p")
19315            .unwrap_err();
19316        assert!(
19317            alloc::format!("{err:?}").contains("DuplicateName"),
19318            "got {err:?}"
19319        );
19320    }
19321
19322    #[test]
19323    fn drop_subscription_silent_when_absent() {
19324        let mut e = Engine::new();
19325        let r = e.execute("DROP SUBSCRIPTION never").unwrap();
19326        match r {
19327            QueryResult::CommandOk { affected, .. } => assert_eq!(affected, 0),
19328            other => panic!("expected CommandOk, got {other:?}"),
19329        }
19330    }
19331
19332    #[test]
19333    fn subscription_advance_updates_last_pos_monotone() {
19334        let mut e = Engine::new();
19335        e.execute("CREATE SUBSCRIPTION s CONNECTION 'h=x' PUBLICATION p")
19336            .unwrap();
19337        assert!(e.subscription_advance("s", 100));
19338        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 100);
19339        assert!(e.subscription_advance("s", 50)); // stale → ignored
19340        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 100);
19341        assert!(e.subscription_advance("s", 200));
19342        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 200);
19343        assert!(!e.subscription_advance("missing", 1));
19344    }
19345
19346    #[test]
19347    fn show_subscriptions_returns_rows_ordered_by_name() {
19348        let mut e = Engine::new();
19349        e.execute("CREATE SUBSCRIPTION z_sub CONNECTION 'h=x' PUBLICATION p1, p2")
19350            .unwrap();
19351        e.execute("CREATE SUBSCRIPTION a_sub CONNECTION 'h=y' PUBLICATION p3")
19352            .unwrap();
19353        let r = e.execute_readonly("SHOW SUBSCRIPTIONS").unwrap();
19354        let QueryResult::Rows { rows, columns } = r else {
19355            panic!()
19356        };
19357        assert_eq!(rows.len(), 2);
19358        assert_eq!(columns.len(), 5);
19359        assert_eq!(columns[0].name, "name");
19360        assert_eq!(columns[4].name, "last_received_pos");
19361        // Alphabetical: a_sub, z_sub.
19362        let names: Vec<&str> = rows
19363            .iter()
19364            .map(|r| {
19365                if let Value::Text(s) = &r.values[0] {
19366                    s.as_str()
19367                } else {
19368                    panic!()
19369                }
19370            })
19371            .collect();
19372        assert_eq!(names, alloc::vec!["a_sub", "z_sub"]);
19373        // Row 0: a_sub
19374        assert_eq!(rows[0].values[1], Value::Text("h=y".to_string()));
19375        assert_eq!(rows[0].values[2], Value::Text("p3".to_string()));
19376        assert_eq!(rows[0].values[3], Value::Bool(true));
19377        assert_eq!(rows[0].values[4], Value::BigInt(0));
19378        // Row 1: z_sub — publications join with ", "
19379        assert_eq!(rows[1].values[2], Value::Text("p1, p2".to_string()));
19380    }
19381
19382    #[test]
19383    fn subscriptions_persist_across_snapshot_envelope_v4() {
19384        let mut e = Engine::new();
19385        e.execute("CREATE SUBSCRIPTION s1 CONNECTION 'h=A' PUBLICATION p1, p2")
19386            .unwrap();
19387        e.execute("CREATE SUBSCRIPTION s2 CONNECTION 'h=B' PUBLICATION p3")
19388            .unwrap();
19389        e.subscription_advance("s2", 42);
19390        let snap = e.snapshot();
19391        let e2 = Engine::restore_envelope(&snap).unwrap();
19392        assert_eq!(e2.subscriptions().len(), 2);
19393        let s1 = e2.subscriptions().get("s1").unwrap();
19394        assert_eq!(s1.conn_str, "h=A");
19395        assert_eq!(
19396            s1.publications,
19397            alloc::vec!["p1".to_string(), "p2".to_string()]
19398        );
19399        assert_eq!(s1.last_received_pos, 0);
19400        let s2 = e2.subscriptions().get("s2").unwrap();
19401        assert_eq!(s2.last_received_pos, 42);
19402    }
19403
19404    #[test]
19405    fn v3_envelope_loads_with_empty_subscriptions() {
19406        // v3 snapshot (publications-only). Forge it by hand so we
19407        // verify v6.1.4 readers don't panic — they must surface
19408        // empty subscriptions and a populated publication table.
19409        let mut e = Engine::new();
19410        e.execute("CREATE PUBLICATION pub_legacy").unwrap();
19411        let catalog = e.catalog.serialize();
19412        let users = crate::users::serialize_users(&e.users);
19413        let pubs = e.publications.serialize();
19414        let mut buf = Vec::new();
19415        buf.extend_from_slice(b"SPGENV01");
19416        buf.push(3u8); // v3
19417        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
19418        buf.extend_from_slice(&catalog);
19419        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
19420        buf.extend_from_slice(&users);
19421        buf.extend_from_slice(&u32::try_from(pubs.len()).unwrap().to_le_bytes());
19422        buf.extend_from_slice(&pubs);
19423        let crc = spg_crypto::crc32::crc32(&buf);
19424        buf.extend_from_slice(&crc.to_le_bytes());
19425
19426        let e2 = Engine::restore_envelope(&buf).expect("v3 envelope restores under v4 reader");
19427        assert!(e2.subscriptions().is_empty());
19428        assert!(e2.publications().contains("pub_legacy"));
19429    }
19430
19431    #[test]
19432    fn create_subscription_allowed_inside_transaction() {
19433        let mut e = Engine::new();
19434        e.execute("BEGIN").unwrap();
19435        e.execute("CREATE SUBSCRIPTION s CONNECTION 'h=x' PUBLICATION p")
19436            .unwrap();
19437        e.execute("COMMIT").unwrap();
19438        assert!(e.subscriptions().contains("s"));
19439    }
19440
19441    // ── v6.2.0: ANALYZE + spg_statistic + envelope v5 ──────────
19442    #[test]
19443    fn analyze_populates_histogram_bounds() {
19444        let mut e = Engine::new();
19445        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT)")
19446            .unwrap();
19447        for i in 0..50 {
19448            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, 'name{i}')"))
19449                .unwrap();
19450        }
19451        e.execute("ANALYZE t").unwrap();
19452        let stats = e.statistics();
19453        let id_stats = stats.get("t", "id").unwrap();
19454        assert!(id_stats.histogram_bounds.len() >= 2);
19455        assert_eq!(id_stats.histogram_bounds.first().unwrap(), "0");
19456        assert_eq!(id_stats.histogram_bounds.last().unwrap(), "49");
19457        assert!((id_stats.null_frac - 0.0).abs() < 1e-6);
19458        assert_eq!(id_stats.n_distinct, 50);
19459    }
19460
19461    #[test]
19462    fn reanalyze_overwrites_prior_stats() {
19463        let mut e = Engine::new();
19464        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19465        for i in 0..10 {
19466            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19467                .unwrap();
19468        }
19469        e.execute("ANALYZE t").unwrap();
19470        let n1 = e.statistics().get("t", "id").unwrap().n_distinct;
19471        assert_eq!(n1, 10);
19472        for i in 10..30 {
19473            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19474                .unwrap();
19475        }
19476        e.execute("ANALYZE t").unwrap();
19477        let n2 = e.statistics().get("t", "id").unwrap().n_distinct;
19478        assert_eq!(n2, 30);
19479    }
19480
19481    #[test]
19482    fn analyze_unknown_table_errors() {
19483        let mut e = Engine::new();
19484        let err = e.execute("ANALYZE nonexistent").unwrap_err();
19485        assert!(matches!(
19486            err,
19487            EngineError::Storage(StorageError::TableNotFound { .. })
19488        ));
19489    }
19490
19491    #[test]
19492    fn bare_analyze_covers_all_user_tables() {
19493        let mut e = Engine::new();
19494        e.execute("CREATE TABLE t1 (id INT NOT NULL)").unwrap();
19495        e.execute("CREATE TABLE t2 (name TEXT NOT NULL)").unwrap();
19496        e.execute("INSERT INTO t1 VALUES (1)").unwrap();
19497        e.execute("INSERT INTO t2 VALUES ('alice')").unwrap();
19498        let r = e.execute("ANALYZE").unwrap();
19499        match r {
19500            QueryResult::CommandOk {
19501                affected,
19502                modified_catalog,
19503            } => {
19504                assert_eq!(affected, 2);
19505                assert!(modified_catalog);
19506            }
19507            other => panic!("expected CommandOk, got {other:?}"),
19508        }
19509        assert!(e.statistics().get("t1", "id").is_some());
19510        assert!(e.statistics().get("t2", "name").is_some());
19511    }
19512
19513    #[test]
19514    fn select_from_spg_statistic_returns_rows_per_column() {
19515        let mut e = Engine::new();
19516        e.execute("CREATE TABLE t (id INT NOT NULL, label TEXT)")
19517            .unwrap();
19518        e.execute("INSERT INTO t VALUES (1, 'a')").unwrap();
19519        e.execute("INSERT INTO t VALUES (2, 'b')").unwrap();
19520        e.execute("ANALYZE t").unwrap();
19521        let r = e.execute_readonly("SELECT * FROM spg_statistic").unwrap();
19522        let QueryResult::Rows { rows, columns } = r else {
19523            panic!()
19524        };
19525        // v6.7.0 — spg_statistic gained a `cold_row_count` column.
19526        assert_eq!(columns.len(), 6);
19527        assert_eq!(columns[0].name, "table_name");
19528        assert_eq!(columns[4].name, "histogram_bounds");
19529        assert_eq!(columns[5].name, "cold_row_count");
19530        assert_eq!(rows.len(), 2, "one row per column of t");
19531        // Sorted by (table_name, column_name).
19532        match (&rows[0].values[0], &rows[0].values[1]) {
19533            (Value::Text(t), Value::Text(c)) => {
19534                assert_eq!(t, "t");
19535                // BTreeMap orders (table, column); columns "id" < "label".
19536                assert_eq!(c, "id");
19537            }
19538            _ => panic!(),
19539        }
19540    }
19541
19542    #[test]
19543    fn analyze_skips_vector_columns() {
19544        // Vector columns have their own stats shape (HNSW graph);
19545        // ANALYZE leaves them out of spg_statistic.
19546        let mut e = Engine::new();
19547        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(3) NOT NULL)")
19548            .unwrap();
19549        e.execute("INSERT INTO t VALUES (1, [1, 2, 3])").unwrap();
19550        e.execute("ANALYZE t").unwrap();
19551        assert!(e.statistics().get("t", "id").is_some());
19552        assert!(e.statistics().get("t", "v").is_none());
19553    }
19554
19555    #[test]
19556    fn statistics_persist_across_envelope_v5_round_trip() {
19557        let mut e = Engine::new();
19558        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19559        for i in 0..20 {
19560            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19561                .unwrap();
19562        }
19563        e.execute("ANALYZE").unwrap();
19564        let snap = e.snapshot();
19565        let e2 = Engine::restore_envelope(&snap).unwrap();
19566        let s = e2.statistics().get("t", "id").unwrap();
19567        assert_eq!(s.n_distinct, 20);
19568    }
19569
19570    // ── v6.2.1 auto-analyze threshold ───────────────────────────
19571
19572    #[test]
19573    fn auto_analyze_threshold_fires_after_10pct_of_min_rows_on_small_table() {
19574        // For a table with 0 rows then 10 inserts → modified=10,
19575        // row_count=10. Threshold = 0.1 × max(10, 100) = 10. So
19576        // after the 10th INSERT the threshold is met.
19577        let mut e = Engine::new();
19578        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19579        for i in 0..9 {
19580            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19581                .unwrap();
19582        }
19583        assert!(e.tables_needing_analyze().is_empty(), "9 < threshold");
19584        e.execute("INSERT INTO t VALUES (9)").unwrap();
19585        let needs = e.tables_needing_analyze();
19586        assert_eq!(needs, alloc::vec!["t".to_string()]);
19587    }
19588
19589    #[test]
19590    fn auto_analyze_threshold_uses_10pct_of_row_count_for_large_tables() {
19591        // After ANALYZE on 1000 rows, threshold = 0.1 × row_count.
19592        // Each new INSERT bumps both modified and row_count, so to
19593        // trigger from N=1000 we need modifications ≥ 0.1 × (1000+M),
19594        // i.e. M ≥ 112. The test inserts 50 (no fire), then 150
19595        // more (200 total mods, row_count=1200, threshold=120 → fire).
19596        let mut e = Engine::new();
19597        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19598        for i in 0..1000 {
19599            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19600                .unwrap();
19601        }
19602        e.execute("ANALYZE t").unwrap();
19603        assert!(e.tables_needing_analyze().is_empty(), "fresh ANALYZE");
19604        for i in 1000..1050 {
19605            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19606                .unwrap();
19607        }
19608        assert!(
19609            e.tables_needing_analyze().is_empty(),
19610            "50 inserts < threshold of ~105"
19611        );
19612        for i in 1050..1200 {
19613            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19614                .unwrap();
19615        }
19616        assert_eq!(
19617            e.tables_needing_analyze(),
19618            alloc::vec!["t".to_string()],
19619            "200 inserts > 0.1 × 1200 threshold"
19620        );
19621    }
19622
19623    #[test]
19624    fn auto_analyze_threshold_resets_after_analyze() {
19625        let mut e = Engine::new();
19626        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19627        for i in 0..200 {
19628            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19629                .unwrap();
19630        }
19631        assert!(!e.tables_needing_analyze().is_empty());
19632        e.execute("ANALYZE").unwrap();
19633        assert!(
19634            e.tables_needing_analyze().is_empty(),
19635            "ANALYZE must reset the counter"
19636        );
19637    }
19638
19639    #[test]
19640    fn auto_analyze_threshold_tracks_updates_and_deletes() {
19641        let mut e = Engine::new();
19642        e.execute("CREATE TABLE t (id INT NOT NULL, label TEXT)")
19643            .unwrap();
19644        for i in 0..50 {
19645            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, 'x')"))
19646                .unwrap();
19647        }
19648        e.execute("ANALYZE t").unwrap();
19649        // UPDATE 20 rows + DELETE 5 → modified=25. Threshold = 0.1
19650        // × max(50, 100) = 10. So 25 >= 10 → trigger.
19651        e.execute("UPDATE t SET label = 'y' WHERE id < 20").unwrap();
19652        e.execute("DELETE FROM t WHERE id >= 45").unwrap();
19653        assert_eq!(e.tables_needing_analyze(), alloc::vec!["t".to_string()]);
19654    }
19655
19656    #[test]
19657    fn v4_envelope_loads_with_empty_statistics() {
19658        // Forge a v4 envelope by hand: catalog + users + pubs +
19659        // subs trailer, no statistics. A v6.2.0 reader must accept
19660        // it and surface an empty Statistics.
19661        let mut e = Engine::new();
19662        e.create_user("alice", "secret", crate::users::Role::ReadOnly, [0u8; 16])
19663            .unwrap();
19664        let catalog = e.catalog.serialize();
19665        let users = crate::users::serialize_users(&e.users);
19666        let pubs = e.publications.serialize();
19667        let subs = e.subscriptions.serialize();
19668        let mut buf = Vec::new();
19669        buf.extend_from_slice(b"SPGENV01");
19670        buf.push(4u8);
19671        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
19672        buf.extend_from_slice(&catalog);
19673        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
19674        buf.extend_from_slice(&users);
19675        buf.extend_from_slice(&u32::try_from(pubs.len()).unwrap().to_le_bytes());
19676        buf.extend_from_slice(&pubs);
19677        buf.extend_from_slice(&u32::try_from(subs.len()).unwrap().to_le_bytes());
19678        buf.extend_from_slice(&subs);
19679        let crc = spg_crypto::crc32::crc32(&buf);
19680        buf.extend_from_slice(&crc.to_le_bytes());
19681        let e2 = Engine::restore_envelope(&buf).expect("v4 envelope restores");
19682        assert!(e2.statistics().is_empty());
19683    }
19684
19685    #[test]
19686    fn v1_v2_envelope_loads_with_empty_publications() {
19687        // A snapshot taken before v6.1.2 (no publication trailer,
19688        // envelope v2) must still deserialise — and the resulting
19689        // engine must report zero publications. Use the engine's own
19690        // round-trip with no publications: that emits v3 but with an
19691        // empty pubs block. Then forge a v2 envelope by hand to lock
19692        // the back-compat path.
19693        let mut e = Engine::new();
19694        // Force users to be non-empty so the snapshot takes the
19695        // envelope path rather than the bare-catalog fallback.
19696        e.create_user("alice", "secret", crate::users::Role::ReadOnly, [0u8; 16])
19697            .unwrap();
19698
19699        // Forge an envelope v2: same shape as v3 but no pubs trailer.
19700        let catalog = e.catalog.serialize();
19701        let users = crate::users::serialize_users(&e.users);
19702        let mut buf = Vec::new();
19703        buf.extend_from_slice(b"SPGENV01");
19704        buf.push(2u8); // v2
19705        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
19706        buf.extend_from_slice(&catalog);
19707        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
19708        buf.extend_from_slice(&users);
19709        let crc = spg_crypto::crc32::crc32(&buf);
19710        buf.extend_from_slice(&crc.to_le_bytes());
19711
19712        let e2 = Engine::restore_envelope(&buf).expect("v2 envelope restores");
19713        assert!(e2.publications().is_empty());
19714    }
19715}