Skip to main content

spg_engine/
lib.rs

1//! SPG execution engine — v0.3 wires the SQL front-end to the in-memory
2//! storage layer. Implements `CREATE TABLE`, single-row `INSERT VALUES`, and
3//! `SELECT * FROM <table>` (no WHERE yet — that lands in v0.4 alongside
4//! expression evaluation against rows).
5#![no_std]
6
7extern crate alloc;
8
9pub mod aggregate;
10pub mod copy;
11pub mod describe;
12pub mod eval;
13pub mod fts;
14pub mod json;
15pub mod memoize;
16pub mod plan_cache;
17pub mod publications;
18pub mod query_stats;
19pub mod reorder;
20pub mod selectivity;
21pub mod statistics;
22pub mod subscriptions;
23pub mod triggers;
24pub mod users;
25
26pub use crate::users::{Role, ScramSecrets, UserError, UserStore};
27
28use alloc::borrow::Cow;
29use alloc::boxed::Box;
30use alloc::collections::BTreeMap;
31use alloc::string::{String, ToString};
32use alloc::vec::Vec;
33use core::fmt;
34
35use spg_sql::ast::{
36    BinOp, ColumnDef, ColumnName, ColumnTypeName, CreateIndexStatement, CreatePublicationStatement,
37    CreateSubscriptionStatement, CreateTableStatement, CreateUserStatement, Expr, FrameBound,
38    FrameKind, FromClause, IndexMethod, InsertStatement, JoinKind, Literal, OrderBy, SelectItem,
39    SelectStatement, Statement, TableRef, UnOp, UnionKind, VecEncoding as SqlVecEncoding,
40    WindowFrame,
41};
42// v7.16.0 — re-export the parsed-statement AST so downstream
43// crates (spg-embedded → spg-sqlx) don't need a direct dep on
44// spg-sql for the prepare/bind handle.
45pub use spg_sql::ast::Statement as ParsedStatement;
46use spg_sql::parser::{self, ParseError};
47use spg_storage::{
48    Catalog, ColumnSchema, CompactReport, DataType, IndexKey, IndexKind, Row, StorageError, Table,
49    TableSchema, Value, VecEncoding,
50};
51
52use crate::eval::{EvalContext, EvalError};
53
54/// Result of executing one statement.
55#[derive(Debug, Clone, PartialEq)]
56#[non_exhaustive]
57pub enum QueryResult {
58    /// DDL or DML succeeded.
59    ///
60    /// `affected` is the row count for `INSERT` and 0 elsewhere.
61    /// `modified_catalog` tells the server whether this statement
62    /// caused the *committed* catalog to change — it's the signal to
63    /// snapshot/audit. False for `BEGIN`/`ROLLBACK`, false for writeful
64    /// statements executed inside a transaction (those only touch the
65    /// shadow), and true for `COMMIT` and for writes outside a TX.
66    CommandOk {
67        affected: usize,
68        modified_catalog: bool,
69    },
70    /// `SELECT` returned a (possibly empty) row set.
71    Rows {
72        columns: Vec<ColumnSchema>,
73        rows: Vec<Row>,
74    },
75}
76
77/// All errors the engine can return.
78///
79/// Marked `#[non_exhaustive]` from v7.5.0 onward: external `match`
80/// must include a `_` arm so new variants in subsequent v7.x releases
81/// are not breaking changes.
82#[derive(Debug, Clone, PartialEq)]
83#[non_exhaustive]
84pub enum EngineError {
85    Parse(ParseError),
86    Storage(StorageError),
87    Eval(EvalError),
88    /// Front-end accepted a construct that the v0.x executor doesn't support.
89    Unsupported(String),
90    /// `BEGIN` while another transaction is already open.
91    TransactionAlreadyOpen,
92    /// `COMMIT` / `ROLLBACK` with no active transaction.
93    NoActiveTransaction,
94    /// v4.0 sentinel: `execute_readonly` got a statement that
95    /// mutates engine state (INSERT / CREATE / BEGIN / COMMIT / …).
96    /// The caller should retake the write lock and dispatch through
97    /// `execute(&mut self)` instead.
98    WriteRequired,
99    /// v4.2: a SELECT would have returned more rows than the
100    /// configured `max_query_rows` cap. Carries the cap.
101    RowLimitExceeded(usize),
102    /// v4.5: cooperative cancellation — the host (server's
103    /// per-query watchdog) set the cancel flag while a long-running
104    /// SELECT / UPDATE / DELETE was scanning rows. The partial work
105    /// is discarded; the caller should surface this as a timeout
106    /// to the client.
107    Cancelled,
108}
109
110impl fmt::Display for EngineError {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        match self {
113            Self::Parse(e) => write!(f, "parse: {e}"),
114            Self::Storage(e) => write!(f, "storage: {e}"),
115            Self::Eval(e) => write!(f, "eval: {e}"),
116            Self::Unsupported(s) => write!(f, "unsupported: {s}"),
117            Self::TransactionAlreadyOpen => f.write_str("a transaction is already open"),
118            Self::NoActiveTransaction => f.write_str("no active transaction"),
119            Self::WriteRequired => {
120                f.write_str("statement requires a write lock (use execute, not execute_readonly)")
121            }
122            Self::RowLimitExceeded(n) => {
123                write!(f, "query exceeded max_query_rows={n}")
124            }
125            Self::Cancelled => f.write_str("query cancelled (timeout or client request)"),
126        }
127    }
128}
129
130impl From<ParseError> for EngineError {
131    fn from(e: ParseError) -> Self {
132        Self::Parse(e)
133    }
134}
135impl From<StorageError> for EngineError {
136    fn from(e: StorageError) -> Self {
137        Self::Storage(e)
138    }
139}
140impl From<EvalError> for EngineError {
141    fn from(e: EvalError) -> Self {
142        Self::Eval(e)
143    }
144}
145
146/// The execution engine. Holds the catalog and (later) other server-scope
147/// state. `Engine::new()` is intentionally cheap so callers can construct one
148/// per database, per test.
149/// Function pointer that returns "now" as microseconds since Unix
150/// epoch. The engine is `no_std`, so it can't reach for `std::time`
151/// itself — callers (`spg-server`, the sqllogictest runner) inject a
152/// concrete implementation. `None` means `NOW()` / `CURRENT_*` raise
153/// `Unsupported`.
154pub type ClockFn = fn() -> i64;
155
156/// Function pointer that produces 16 cryptographically random bytes.
157/// Like `ClockFn`, the engine is `no_std` and can't reach for /dev/urandom
158/// itself — host (`spg-server`) injects an OS-backed source. `None`
159/// means SQL-driven `CREATE USER` falls back to a deterministic salt
160/// derived from the username (acceptable in tests; the server always
161/// installs a real RNG so production paths never see this).
162pub type SaltFn = fn() -> [u8; 16];
163
164/// v4.5 cooperative cancellation token. A long-running SELECT /
165/// UPDATE / DELETE checks `is_cancelled` at row-loop checkpoints
166/// and bails with `EngineError::Cancelled`. The host
167/// (`spg-server`) creates an `AtomicBool` per query, spawns a
168/// watchdog thread that sets it after `SPG_QUERY_TIMEOUT_MS`,
169/// and passes it via `execute_with_cancel` / `execute_readonly_with_cancel`.
170///
171/// `CancelToken::none()` is a no-op — used by the legacy `execute`
172/// and `execute_readonly` entry points so existing callers don't
173/// change.
174/// v7.17.0 Phase 2.3 — monotonic time source for deadline-aware
175/// cancellation (PG `statement_timeout`). Returns microseconds
176/// since some host-stable monotonic origin (typically the first
177/// call into `Instant::now()` on the server). The engine never
178/// calls `Instant::now()` directly so the crate stays `#![no_std]`.
179pub type MonotonicNowFn = fn() -> u64;
180
181#[derive(Debug, Clone, Copy)]
182struct Deadline {
183    now_fn: MonotonicNowFn,
184    /// Absolute deadline in `now_fn()` units (microseconds).
185    deadline_us: u64,
186}
187
188#[derive(Debug, Clone, Copy)]
189pub struct CancelToken<'a> {
190    flag: Option<&'a core::sync::atomic::AtomicBool>,
191    // v7.17.0 Phase 2.3 — when set, every existing `cancel.check()`
192    // checkpoint also fires `EngineError::Cancelled` once
193    // `(now_fn)() >= deadline_us`. No new check sites, no thread
194    // spawn per query — the monotonic now-fn read is a vDSO
195    // `clock_gettime(CLOCK_MONOTONIC)` (~20ns) and only runs when
196    // the host actually wired a deadline (statement_timeout > 0).
197    deadline: Option<Deadline>,
198}
199
200impl<'a> CancelToken<'a> {
201    #[must_use]
202    pub const fn none() -> Self {
203        Self {
204            flag: None,
205            deadline: None,
206        }
207    }
208
209    #[must_use]
210    pub const fn from_flag(f: &'a core::sync::atomic::AtomicBool) -> Self {
211        Self {
212            flag: Some(f),
213            deadline: None,
214        }
215    }
216
217    /// v7.17.0 Phase 2.3 — attach a monotonic deadline. `now_fn`
218    /// must return microseconds since a stable origin; the token
219    /// trips when `now_fn() >= deadline_us`. Compose with
220    /// `from_flag(...)` when both a watchdog flag and a per-statement
221    /// timeout are in play (e.g. server-wide `SPG_QUERY_TIMEOUT_MS`
222    /// plus session `statement_timeout`); the tighter of the two
223    /// wins by virtue of either signaling first.
224    #[must_use]
225    pub const fn with_deadline(mut self, now_fn: MonotonicNowFn, deadline_us: u64) -> Self {
226        self.deadline = Some(Deadline {
227            now_fn,
228            deadline_us,
229        });
230        self
231    }
232
233    #[must_use]
234    pub fn is_cancelled(self) -> bool {
235        if self
236            .flag
237            .is_some_and(|f| f.load(core::sync::atomic::Ordering::Relaxed))
238        {
239            return true;
240        }
241        // Deadline check is the second branch so the "no timeout"
242        // hot path (`deadline: None`) elides the now-fn call —
243        // predicted-not-taken on the SLO INSERT loop.
244        if let Some(d) = self.deadline
245            && (d.now_fn)() >= d.deadline_us
246        {
247            return true;
248        }
249        false
250    }
251
252    /// Returns `Err(Cancelled)` if the token has been tripped.
253    /// Used at row-loop checkpoints to bail cooperatively without
254    /// scattering raw `is_cancelled` checks across the executor.
255    #[inline]
256    pub fn check(self) -> Result<(), EngineError> {
257        if self.is_cancelled() {
258            Err(EngineError::Cancelled)
259        } else {
260            Ok(())
261        }
262    }
263}
264
265// ---- snapshot envelope (v4.1, extended with CRC32 in v4.37,  ----
266// ----   publications in v6.1.2 v3, subscriptions in v6.1.4 v4) ----
267//
268// Wraps a catalog blob + a user blob behind a small header so the
269// server can persist both atomically without inventing a new file.
270// Bare catalog blobs (v3.x) still load via `restore_envelope` since
271// the magic check fails fast and the function falls back to
272// `Catalog::deserialize`.
273//
274// Layout — v1 (v4.1, no CRC):
275//   [8 bytes magic "SPGENV01"]
276//   [u8 version = 1]
277//   [u32 catalog_len][catalog bytes]
278//   [u32 users_len][users bytes]
279//
280// Layout — v2 (v4.37, CRC32 of body):
281//   [8 bytes magic "SPGENV01"]
282//   [u8 version = 2]
283//   [u32 catalog_len][catalog bytes]
284//   [u32 users_len][users bytes]
285//   [u32 crc32]                      ← CRC32 of every byte before it.
286//
287// Layout — v3 (v6.1.2, publications trailer):
288//   [8 bytes magic "SPGENV01"]
289//   [u8 version = 3]
290//   [u32 catalog_len][catalog bytes]
291//   [u32 users_len][users bytes]
292//   [u32 pubs_len][publications bytes]
293//   [u32 crc32]
294//
295// Layout — v4 (v6.1.4, subscriptions trailer):
296//   [8 bytes magic "SPGENV01"]
297//   [u8 version = 4]
298//   [u32 catalog_len][catalog bytes]
299//   [u32 users_len][users bytes]
300//   [u32 pubs_len][publications bytes]
301//   [u32 subs_len][subscriptions bytes]
302//   [u32 crc32]
303//
304// Layout — v5 (v6.2.0, statistics trailer):
305//   [8 bytes magic "SPGENV01"]
306//   [u8 version = 5]
307//   [u32 catalog_len][catalog bytes]
308//   [u32 users_len][users bytes]
309//   [u32 pubs_len][publications bytes]
310//   [u32 subs_len][subscriptions bytes]
311//   [u32 stats_len][statistics bytes]      ← NEW
312//   [u32 crc32]
313//
314// Writers emit v5 from v6.2.0 on. Readers accept all of {v1, v2,
315// v3, v4, v5}: v1/v2 load with empty publications / subscriptions /
316// statistics; v3 loads with empty subscriptions + statistics; v4
317// loads with empty statistics; v5 deserialises all three. Older
318// SPG versions reading a v5 envelope fall through the version
319// match to `EnvelopeParse::Bare` — pre-v6.2.0 binaries cannot
320// open v6.2.0+ snapshots (matches the v6.1.2 / v6.1.4 breaks).
321
322const ENVELOPE_MAGIC: &[u8; 8] = b"SPGENV01";
323const ENVELOPE_VERSION_V1: u8 = 1;
324const ENVELOPE_VERSION_V2: u8 = 2;
325const ENVELOPE_VERSION_V3: u8 = 3;
326const ENVELOPE_VERSION_V4: u8 = 4;
327const ENVELOPE_VERSION_V5: u8 = 5;
328
329fn build_envelope(catalog: &[u8], users: &[u8], pubs: &[u8], subs: &[u8], stats: &[u8]) -> Vec<u8> {
330    let mut out = Vec::with_capacity(
331        8 + 1
332            + 4
333            + catalog.len()
334            + 4
335            + users.len()
336            + 4
337            + pubs.len()
338            + 4
339            + subs.len()
340            + 4
341            + stats.len()
342            + 4,
343    );
344    out.extend_from_slice(ENVELOPE_MAGIC);
345    out.push(ENVELOPE_VERSION_V5);
346    out.extend_from_slice(
347        &u32::try_from(catalog.len())
348            .expect("≤ 4G catalog")
349            .to_le_bytes(),
350    );
351    out.extend_from_slice(catalog);
352    out.extend_from_slice(
353        &u32::try_from(users.len())
354            .expect("≤ 4G users")
355            .to_le_bytes(),
356    );
357    out.extend_from_slice(users);
358    out.extend_from_slice(
359        &u32::try_from(pubs.len())
360            .expect("≤ 4G publications")
361            .to_le_bytes(),
362    );
363    out.extend_from_slice(pubs);
364    out.extend_from_slice(
365        &u32::try_from(subs.len())
366            .expect("≤ 4G subscriptions")
367            .to_le_bytes(),
368    );
369    out.extend_from_slice(subs);
370    out.extend_from_slice(
371        &u32::try_from(stats.len())
372            .expect("≤ 4G statistics")
373            .to_le_bytes(),
374    );
375    out.extend_from_slice(stats);
376    let crc = spg_crypto::crc32::crc32(&out);
377    out.extend_from_slice(&crc.to_le_bytes());
378    out
379}
380
381/// Outcome of envelope parsing: either bare-catalog fallback, a
382/// successfully split section trio from a v1/v2/v3 envelope, or an
383/// explicit corruption error from a v2/v3 CRC mismatch. `Bare`
384/// (catalog-only fallback) preserves v3.x readability. v1/v2
385/// envelopes set `publications` to `None`; v3 sets it to the
386/// publications byte slice.
387enum EnvelopeParse<'a> {
388    Bare,
389    Pair {
390        catalog: &'a [u8],
391        users: &'a [u8],
392        publications: Option<&'a [u8]>,
393        subscriptions: Option<&'a [u8]>,
394        statistics: Option<&'a [u8]>,
395    },
396    CrcMismatch {
397        expected: u32,
398        computed: u32,
399    },
400}
401
402/// Returns `EnvelopeParse::Pair` for a valid v1 / v2 / v3 envelope,
403/// `Bare` for a buffer that doesn't look like an envelope (v3.x
404/// bare catalog fallback), and `CrcMismatch` for a v2/v3 envelope
405/// whose trailing CRC32 doesn't match the body.
406fn split_envelope(buf: &[u8]) -> EnvelopeParse<'_> {
407    if buf.len() < 8 + 1 + 4 || &buf[..8] != ENVELOPE_MAGIC {
408        return EnvelopeParse::Bare;
409    }
410    let version = buf[8];
411    if !matches!(
412        version,
413        ENVELOPE_VERSION_V1
414            | ENVELOPE_VERSION_V2
415            | ENVELOPE_VERSION_V3
416            | ENVELOPE_VERSION_V4
417            | ENVELOPE_VERSION_V5
418    ) {
419        return EnvelopeParse::Bare;
420    }
421    let mut p = 9usize;
422    let Some(cat_len_bytes) = buf.get(p..p + 4) else {
423        return EnvelopeParse::Bare;
424    };
425    let Ok(cat_len_arr) = cat_len_bytes.try_into() else {
426        return EnvelopeParse::Bare;
427    };
428    let cat_len = u32::from_le_bytes(cat_len_arr) as usize;
429    p += 4;
430    if p + cat_len + 4 > buf.len() {
431        return EnvelopeParse::Bare;
432    }
433    let catalog = &buf[p..p + cat_len];
434    p += cat_len;
435    let Some(user_len_bytes) = buf.get(p..p + 4) else {
436        return EnvelopeParse::Bare;
437    };
438    let Ok(user_len_arr) = user_len_bytes.try_into() else {
439        return EnvelopeParse::Bare;
440    };
441    let user_len = u32::from_le_bytes(user_len_arr) as usize;
442    p += 4;
443    if p + user_len > buf.len() {
444        return EnvelopeParse::Bare;
445    }
446    let users = &buf[p..p + user_len];
447    p += user_len;
448    let publications = if matches!(
449        version,
450        ENVELOPE_VERSION_V3 | ENVELOPE_VERSION_V4 | ENVELOPE_VERSION_V5
451    ) {
452        // [u32 pubs_len][publications bytes]
453        let Some(pubs_len_bytes) = buf.get(p..p + 4) else {
454            return EnvelopeParse::Bare;
455        };
456        let Ok(pubs_len_arr) = pubs_len_bytes.try_into() else {
457            return EnvelopeParse::Bare;
458        };
459        let pubs_len = u32::from_le_bytes(pubs_len_arr) as usize;
460        p += 4;
461        if p + pubs_len > buf.len() {
462            return EnvelopeParse::Bare;
463        }
464        let pubs_slice = &buf[p..p + pubs_len];
465        p += pubs_len;
466        Some(pubs_slice)
467    } else {
468        None
469    };
470    let subscriptions = if matches!(version, ENVELOPE_VERSION_V4 | ENVELOPE_VERSION_V5) {
471        // [u32 subs_len][subscriptions bytes]
472        let Some(subs_len_bytes) = buf.get(p..p + 4) else {
473            return EnvelopeParse::Bare;
474        };
475        let Ok(subs_len_arr) = subs_len_bytes.try_into() else {
476            return EnvelopeParse::Bare;
477        };
478        let subs_len = u32::from_le_bytes(subs_len_arr) as usize;
479        p += 4;
480        if p + subs_len > buf.len() {
481            return EnvelopeParse::Bare;
482        }
483        let subs_slice = &buf[p..p + subs_len];
484        p += subs_len;
485        Some(subs_slice)
486    } else {
487        None
488    };
489    let statistics = if version == ENVELOPE_VERSION_V5 {
490        // [u32 stats_len][statistics bytes]
491        let Some(stats_len_bytes) = buf.get(p..p + 4) else {
492            return EnvelopeParse::Bare;
493        };
494        let Ok(stats_len_arr) = stats_len_bytes.try_into() else {
495            return EnvelopeParse::Bare;
496        };
497        let stats_len = u32::from_le_bytes(stats_len_arr) as usize;
498        p += 4;
499        if p + stats_len > buf.len() {
500            return EnvelopeParse::Bare;
501        }
502        let stats_slice = &buf[p..p + stats_len];
503        p += stats_len;
504        Some(stats_slice)
505    } else {
506        None
507    };
508    if matches!(
509        version,
510        ENVELOPE_VERSION_V2 | ENVELOPE_VERSION_V3 | ENVELOPE_VERSION_V4 | ENVELOPE_VERSION_V5
511    ) {
512        if p + 4 != buf.len() {
513            return EnvelopeParse::Bare;
514        }
515        let Ok(crc_arr) = buf[p..p + 4].try_into() else {
516            return EnvelopeParse::Bare;
517        };
518        let expected = u32::from_le_bytes(crc_arr);
519        let computed = spg_crypto::crc32::crc32(&buf[..p]);
520        if expected != computed {
521            return EnvelopeParse::CrcMismatch { expected, computed };
522        }
523    } else if p != buf.len() {
524        // v1: must end exactly at the users section.
525        return EnvelopeParse::Bare;
526    }
527    EnvelopeParse::Pair {
528        catalog,
529        users,
530        publications,
531        subscriptions,
532        statistics,
533    }
534}
535
536/// v4.41.1 opaque transaction handle. Returned by `Engine::alloc_tx_id`,
537/// threaded through `Engine::execute_in` so dispatch can identify which
538/// in-flight TX a statement belongs to. `IMPLICIT_TX` is the reserved
539/// slot every legacy caller — engine self-tests, spg-cli, spg-embedded,
540/// startup replay — implicitly uses through the unchanged
541/// `Engine::execute(sql)` API. v4.41.1 keeps at most one active slot at
542/// runtime (dispatch holds `engine.write()` across the wrap, same as
543/// v4.34); the map shape is here to let v4.42 turn on N in-flight
544/// implicit TXs without reshuffling the engine internals.
545#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
546pub struct TxId(pub u64);
547
548/// Reserved slot used by `Engine::execute(sql)` — the legacy single-
549/// global-shadow path. New `alloc_tx_id` handles start at 1.
550pub const IMPLICIT_TX: TxId = TxId(0);
551
552/// v6.7.3 — default segment-size threshold used by `COMPACT COLD
553/// SEGMENTS` when no explicit target is supplied. Segments whose
554/// `OwnedSegment::bytes().len()` is **strictly** less than this
555/// value are eligible to merge. spg-server reads
556/// `SPG_COMPACTION_TARGET_SEGMENT_BYTES` to override.
557pub const COMPACTION_TARGET_DEFAULT_BYTES: u64 = 4 * 1024 * 1024;
558
559/// Per-slot transaction state. Held inside `tx_catalogs[tx_id]` for the
560/// lifetime of a BEGIN..COMMIT (or BEGIN..ROLLBACK) window. Drops when
561/// the TX commits (its `catalog` is moved over `Engine.catalog`) or
562/// rolls back (slot removed, catalog discarded).
563#[derive(Debug, Default, Clone)]
564struct TxState {
565    /// The TX's shadow copy of the catalog. Started as a clone of
566    /// `Engine.catalog` at BEGIN time; writes flow into it; COMMIT
567    /// installs it over `Engine.catalog`. `Catalog::clone()` is O(1)
568    /// since v4.40 (`PersistentVec` rows + `PersistentBTreeMap` indices).
569    catalog: Catalog,
570    /// Per-TX savepoint stack. Each entry pairs the savepoint name with
571    /// a clone of `catalog` at the moment `SAVEPOINT <name>` fired.
572    /// `ROLLBACK TO <name>` restores from the entry and pops everything
573    /// after it; `RELEASE <name>` discards the entry and everything
574    /// after; COMMIT/ROLLBACK clears the whole stack.
575    savepoints: Vec<(String, Catalog)>,
576}
577
578/// v7.11.0 — frozen read-only view of the engine's committed state.
579/// Constructed via [`Engine::clone_snapshot`]. Holds clones of the
580/// catalog, statistics, clock function, and row-cap config — the
581/// four fields the `execute_readonly` path actually reads. Cheap to
582/// `Clone` (each clone shares the underlying `PersistentVec` row
583/// storage; only the trie root pointers copy). Send + Sync so a
584/// snapshot can be moved across `tokio::task::spawn_blocking`
585/// boundaries without coordination.
586///
587/// The contract: a snapshot reflects the engine's state at the
588/// moment `clone_snapshot()` returned. Subsequent writes to the
589/// engine are NOT visible. Callers who need fresher data take a
590/// new snapshot.
591#[derive(Debug, Clone)]
592pub struct CatalogSnapshot {
593    catalog: Catalog,
594    statistics: statistics::Statistics,
595    clock: Option<ClockFn>,
596    max_query_rows: Option<usize>,
597}
598
599#[derive(Debug, Default)]
600pub struct Engine {
601    /// Committed catalog — what survives `Engine::snapshot()` and what
602    /// outside-TX `SELECT`s read.
603    catalog: Catalog,
604    /// Active TX slots, keyed by `TxId`. Empty when no TX is in flight.
605    /// v4.41.1 runtime invariant: at most one entry (single-writer
606    /// model unchanged). v4.42 will let dispatch hold multiple entries
607    /// concurrently for group commit + engine MVCC.
608    tx_catalogs: BTreeMap<TxId, TxState>,
609    /// Which slot the next exec_* call should mutate. Set by
610    /// `execute_in(sql, tx_id)` at the entry point; legacy `execute(sql)`
611    /// sets it to `IMPLICIT_TX`. None when no TX is in flight (read /
612    /// write goes straight against `catalog`).
613    current_tx: Option<TxId>,
614    /// Monotonic counter for `alloc_tx_id`. Starts at 1 — slot 0 is
615    /// reserved for `IMPLICIT_TX`.
616    next_tx_id: u64,
617    /// v7.22 (round-13 T3) — session string-literal dialect. `false`
618    /// (default) = PG semantics (backslash literal, `''` escape);
619    /// `true` = MySQL semantics (`\'` etc.). Flipped by the
620    /// deterministic session signals each dump emits: `SET sql_mode`
621    /// (only MySQL clients/dumps send it) turns it on,
622    /// `SET standard_conforming_strings = on` (every pg_dump
623    /// preamble) turns it off. The plan cache is cleared on every
624    /// flip — the same SQL text lexes differently per dialect.
625    backslash_escapes: bool,
626    /// Optional wall clock used to satisfy `NOW()` / `CURRENT_TIMESTAMP`
627    /// / `CURRENT_DATE`. Set by the host environment.
628    clock: Option<ClockFn>,
629    /// v4.1 cryptographic RNG for per-user password salt. Set by the
630    /// host. `None` means SQL-driven `CREATE USER` uses a
631    /// deterministic fallback — see `SaltFn`.
632    salt_fn: Option<SaltFn>,
633    /// v4.2 per-query row cap. `None` = unlimited. When set, a
634    /// SELECT that materialises more than `n` rows returns
635    /// `EngineError::RowLimitExceeded`. Enforced before the result
636    /// is shaped into wire frames so a runaway scan can't blow the
637    /// server's heap.
638    max_query_rows: Option<usize>,
639    /// v4.1 RBAC user table. Empty means "no RBAC configured yet" —
640    /// the server decides what that means at the auth boundary
641    /// (open mode vs legacy single-password mode). User CRUD goes
642    /// through `create_user`/`drop_user`/`verify_user`; persistence
643    /// rides the snapshot envelope alongside the catalog.
644    users: UserStore,
645    /// v6.1.2 logical-replication publication catalog. Empty until
646    /// `CREATE PUBLICATION` runs. Persistence rides the v3 envelope
647    /// trailer (see `build_envelope`).
648    publications: publications::Publications,
649    /// v6.1.4 logical-replication subscription catalog. Empty until
650    /// `CREATE SUBSCRIPTION` runs. Persistence rides the v4 envelope
651    /// trailer.
652    subscriptions: subscriptions::Subscriptions,
653    /// v6.2.0 — per-column statistics for the cost-based optimizer.
654    /// Populated by `ANALYZE`; queried via `spg_statistic` virtual
655    /// table. Persistence rides the v5 envelope trailer.
656    statistics: statistics::Statistics,
657    /// v6.3.0 — engine-level plan cache. Caches the post-`prepare()`
658    /// `Statement` keyed on SQL text. In-memory only — does NOT ride
659    /// the snapshot envelope (rebuilt on demand after restart).
660    plan_cache: plan_cache::PlanCache,
661    /// v6.5.1 — per-distinct-SQL execution stats. In-memory only,
662    /// surfaced via `spg_stat_query` virtual table. Updated by the
663    /// `execute_*` paths after a successful execute.
664    query_stats: query_stats::QueryStats,
665    /// v6.5.2 — connection-state provider callback. spg-server
666    /// registers a function at startup that snapshots its
667    /// per-pgwire-connection registry into `ActivityRow`s; engine
668    /// reads through it on every `SELECT * FROM spg_stat_activity`.
669    /// `None` ⇒ no-data (returns empty rows; matches the no_std
670    /// embedded callers that don't run pgwire).
671    activity_provider: Option<ActivityProvider>,
672    /// v6.5.3 — audit-chain provider + verifier. Same pattern as
673    /// activity_provider: spg-server registers both at startup;
674    /// engine reads through on `SELECT * FROM spg_audit_chain` and
675    /// `SELECT * FROM spg_audit_verify`. `None` ⇒ no-data.
676    audit_chain_provider: Option<AuditChainProvider>,
677    audit_verifier: Option<AuditVerifier>,
678    /// v6.5.6 — slow-query log threshold in microseconds. When set,
679    /// every successful execute whose elapsed exceeds the threshold
680    /// gets fed to the registered slow-query log callback (so
681    /// spg-server can emit a structured log line). Default `None`
682    /// = no slow-query logging.
683    slow_query_threshold_us: Option<u64>,
684    slow_query_logger: Option<SlowQueryLogger>,
685    /// v7.12.1 — session parameters set via `SET <name> = <value>`.
686    /// Only `default_text_search_config` is consumed by the engine
687    /// today (the FTS function dispatcher reads it when
688    /// `to_tsvector(text)` is called without an explicit config).
689    /// All other names are accepted + recorded so PG-dump output
690    /// loads, but have no behavioural effect.
691    session_params: BTreeMap<String, String>,
692    /// v7.12.7 — depth counter for trigger-emitted embedded SQL.
693    /// Each time the engine executes a `DeferredEmbeddedStmt` it
694    /// increments this; the recursive `execute_stmt_with_cancel`
695    /// inside that path checks against [`MAX_TRIGGER_RECURSION`]
696    /// to bound runaway cascades (trigger A's UPDATE on table B
697    /// fires trigger B which UPDATEs table A which fires trigger
698    /// A again…). Reset to 0 once the original DML returns.
699    trigger_recursion_depth: u32,
700    /// v7.14.0 — when `SET FOREIGN_KEY_CHECKS=0` is in effect
701    /// (mysqldump preamble), the FK existence + arity check at
702    /// CREATE TABLE time is deferred. FKs referencing a
703    /// not-yet-existing parent land in `pending_foreign_keys`
704    /// keyed by child table; `SET FOREIGN_KEY_CHECKS=1` drains
705    /// the queue and resolves each FK against the now-complete
706    /// catalog. Empty by default; the queue is drained on every
707    /// `RESET ALL` too.
708    foreign_key_checks: bool,
709    /// v7.16.2 — true on the temp Engine an outer
710    /// `exec_select_with_meta_views` builds, telling that
711    /// temp engine "stop short-circuiting into the meta-view
712    /// path — your catalog already has the materialised
713    /// tables; just run the regular SELECT." Without this we'd
714    /// infinite-loop since the meta-view name (e.g.
715    /// `__spg_info_columns`) still triggers
716    /// `select_references_meta_view`.
717    meta_views_materialised: bool,
718    pending_foreign_keys: Vec<(alloc::string::String, spg_sql::ast::ForeignKeyConstraint)>,
719}
720
721/// v7.12.7 — hard cap on nested trigger-emitted embedded SQL
722/// fires. 16 deep is well past anything a normal trigger graph
723/// uses while still preventing infinite-loop wedging.
724const MAX_TRIGGER_RECURSION: u32 = 16;
725
726/// v6.5.6 — callback signature for slow-query log emission. Called
727/// with `(sql, elapsed_us)` once per successful execute that crosses
728/// the threshold.
729pub type SlowQueryLogger = fn(&str, u64);
730
731/// v6.5.4 — synthesise a `CREATE TABLE` statement from catalog
732/// state. Round-trips through `Engine::execute` to recreate the
733/// same schema (sans data + indexes — indexes are emitted as a
734/// separate `CREATE INDEX` chain in `spg_database_ddl`).
735fn render_create_table(name: &str, columns: &[ColumnSchema]) -> String {
736    let mut out = alloc::format!("CREATE TABLE {name} (");
737    for (i, col) in columns.iter().enumerate() {
738        if i > 0 {
739            out.push_str(", ");
740        }
741        out.push_str(&col.name);
742        out.push(' ');
743        out.push_str(&render_data_type(col.ty));
744        if !col.nullable {
745            out.push_str(" NOT NULL");
746        }
747        if col.auto_increment {
748            out.push_str(" AUTO_INCREMENT");
749        }
750    }
751    out.push(')');
752    out
753}
754
755fn render_data_type(ty: DataType) -> String {
756    match ty {
757        DataType::SmallInt => "SMALLINT".into(),
758        DataType::Int => "INT".into(),
759        DataType::BigInt => "BIGINT".into(),
760        DataType::Float => "FLOAT".into(),
761        DataType::Text => "TEXT".into(),
762        DataType::Varchar(n) => alloc::format!("VARCHAR({n})"),
763        DataType::Char(n) => alloc::format!("CHAR({n})"),
764        DataType::Bool => "BOOL".into(),
765        DataType::Vector { dim, encoding } => match encoding {
766            spg_storage::VecEncoding::F32 => alloc::format!("VECTOR({dim})"),
767            spg_storage::VecEncoding::Sq8 => alloc::format!("VECTOR({dim}) USING SQ8"),
768            spg_storage::VecEncoding::F16 => alloc::format!("VECTOR({dim}) USING HALF"),
769        },
770        DataType::Numeric { precision, scale } => {
771            alloc::format!("NUMERIC({precision},{scale})")
772        }
773        DataType::Date => "DATE".into(),
774        DataType::Timestamp => "TIMESTAMP".into(),
775        DataType::Interval => "INTERVAL".into(),
776        DataType::Json => "JSON".into(),
777        DataType::Jsonb => "JSONB".into(),
778        DataType::Timestamptz => "TIMESTAMPTZ".into(),
779        DataType::Bytes => "BYTEA".into(),
780        DataType::TextArray => "TEXT[]".into(),
781        DataType::IntArray => "INT[]".into(),
782        DataType::BigIntArray => "BIGINT[]".into(),
783        DataType::TsVector => "TSVECTOR".into(),
784        DataType::TsQuery => "TSQUERY".into(),
785        DataType::Uuid => "UUID".into(),
786        DataType::Time => "TIME".into(),
787        DataType::Year => "YEAR".into(),
788        DataType::TimeTz => "TIMETZ".into(),
789        DataType::Money => "MONEY".into(),
790        DataType::Range(k) => k.keyword().into(),
791        DataType::Hstore => "HSTORE".into(),
792        DataType::IntArray2D => "INT[][]".into(),
793        DataType::BigIntArray2D => "BIGINT[][]".into(),
794        DataType::TextArray2D => "TEXT[][]".into(),
795    }
796}
797
798/// v6.5.2 — one row of `spg_stat_activity`. Engine-public so
799/// spg-server can construct rows without re-exporting internal
800/// dispatch types.
801#[derive(Debug, Clone)]
802pub struct ActivityRow {
803    pub pid: u32,
804    pub user: String,
805    pub started_at_us: i64,
806    pub current_sql: String,
807    pub wait_event: String,
808    pub elapsed_us: i64,
809    pub in_transaction: bool,
810    /// v7.17 Phase 2.4 — startup-param `application_name` (or the
811    /// last value the client sent via `SET application_name = '...'`).
812    /// Empty when the client never declared one.
813    pub application_name: String,
814}
815
816/// v6.5.2 — provider callback type. Fresh snapshot returned each
817/// call; engine doesn't cache the slice.
818pub type ActivityProvider = fn() -> Vec<ActivityRow>;
819
820/// v6.5.3 — one row of `spg_audit_chain`. Engine-public so
821/// spg-server can construct rows directly from `AuditEntry`.
822#[derive(Debug, Clone)]
823pub struct AuditRow {
824    pub seq: i64,
825    pub ts_ms: i64,
826    pub prev_hash_hex: String,
827    pub entry_hash_hex: String,
828    pub sql: String,
829}
830
831/// v6.5.3 — chain-table provider + verifier. spg-server registers
832/// fn pointers that snapshot / verify the audit log. `verify`
833/// returns `(verified_count, broken_at_seq)` — `broken_at_seq` is
834/// `-1` on a clean chain.
835pub type AuditChainProvider = fn() -> Vec<AuditRow>;
836pub type AuditVerifier = fn() -> (i64, i64);
837
838impl Engine {
839    pub fn new() -> Self {
840        Self {
841            catalog: Catalog::new(),
842            tx_catalogs: BTreeMap::new(),
843            current_tx: None,
844            backslash_escapes: false,
845            next_tx_id: 1,
846            clock: None,
847            salt_fn: None,
848            max_query_rows: None,
849            users: UserStore::new(),
850            publications: publications::Publications::new(),
851            subscriptions: subscriptions::Subscriptions::new(),
852            statistics: statistics::Statistics::new(),
853            plan_cache: plan_cache::PlanCache::new(),
854            query_stats: query_stats::QueryStats::new(),
855            activity_provider: None,
856            audit_chain_provider: None,
857            audit_verifier: None,
858            slow_query_threshold_us: None,
859            slow_query_logger: None,
860            session_params: BTreeMap::new(),
861            trigger_recursion_depth: 0,
862            foreign_key_checks: true,
863            meta_views_materialised: false,
864            pending_foreign_keys: Vec::new(),
865        }
866    }
867
868    /// v7.11.0 — clone the engine's committed catalog + read-time
869    /// state into a frozen `CatalogSnapshot`. Cheap (`Catalog` is
870    /// backed by `PersistentVec`; cloning is O(log n) per table).
871    /// Subsequent writes to this engine are invisible to the
872    /// snapshot; the snapshot is self-contained and can be moved
873    /// to another thread for concurrent `execute_readonly_on_snapshot`
874    /// calls. The basis for [`AsyncReadHandle`] in spg-embedded-tokio
875    /// and any other read-fanout pattern.
876    #[must_use]
877    pub fn clone_snapshot(&self) -> CatalogSnapshot {
878        CatalogSnapshot {
879            catalog: self.active_catalog().clone(),
880            statistics: self.statistics.clone(),
881            clock: self.clock,
882            max_query_rows: self.max_query_rows,
883        }
884    }
885
886    /// v7.11.1 — execute a read-only SQL statement against a
887    /// `CatalogSnapshot` without touching this engine. Same
888    /// semantics as `execute_readonly` but parameterised on the
889    /// snapshot's catalog. Reject DDL/DML the same way
890    /// `execute_readonly` does. Static-on-Self so the caller can
891    /// dispatch without holding an `Engine` borrow alongside the
892    /// snapshot.
893    pub fn execute_readonly_on_snapshot(
894        snapshot: &CatalogSnapshot,
895        sql: &str,
896    ) -> Result<QueryResult, EngineError> {
897        Self::execute_readonly_on_snapshot_with_cancel(snapshot, sql, CancelToken::none())
898    }
899
900    /// v7.11.1 — `execute_readonly_on_snapshot` with cooperative
901    /// cancellation. Builds a transient `Engine` over the snapshot
902    /// state, runs `execute_readonly_with_cancel`, drops. The
903    /// transient engine is cheap to construct (no I/O; everything
904    /// is just struct moves) and lets the existing read path stay
905    /// untouched.
906    pub fn execute_readonly_on_snapshot_with_cancel(
907        snapshot: &CatalogSnapshot,
908        sql: &str,
909        cancel: CancelToken<'_>,
910    ) -> Result<QueryResult, EngineError> {
911        let transient = Engine {
912            catalog: snapshot.catalog.clone(),
913            statistics: snapshot.statistics.clone(),
914            clock: snapshot.clock,
915            max_query_rows: snapshot.max_query_rows,
916            ..Engine::default()
917        };
918        transient.execute_readonly_with_cancel(sql, cancel)
919    }
920
921    /// v7.18 — execute a previously-prepared `Statement` against a
922    /// `CatalogSnapshot` in read-only mode. Mirror of
923    /// [`Engine::execute_prepared`] for the fan-out read path:
924    /// substitutes `Expr::Placeholder(n)` nodes from `params`, then
925    /// dispatches through [`Engine::execute_readonly_stmt_with_cancel`]
926    /// (writes / DDL hit `EngineError::WriteRequired`). Static-on-Self
927    /// so multiple readonly threads can dispatch against the same
928    /// snapshot concurrently without an `Engine` borrow.
929    ///
930    /// **Schema drift contract**. The `Statement` was prepared against
931    /// some prior catalog. If the snapshot's catalog has since
932    /// diverged (DDL renamed / dropped a referenced column / table),
933    /// execution surfaces the normal `EngineError` — same shape as
934    /// PG's "cached plan must not change result type". Caller decides
935    /// whether to re-prepare; engine does NOT auto-retry.
936    pub fn execute_readonly_prepared_on_snapshot(
937        snapshot: &CatalogSnapshot,
938        stmt: Statement,
939        params: &[Value],
940    ) -> Result<QueryResult, EngineError> {
941        Self::execute_readonly_prepared_on_snapshot_with_cancel(
942            snapshot,
943            stmt,
944            params,
945            CancelToken::none(),
946        )
947    }
948
949    /// v7.18 — cancellable variant of
950    /// [`Engine::execute_readonly_prepared_on_snapshot`].
951    pub fn execute_readonly_prepared_on_snapshot_with_cancel(
952        snapshot: &CatalogSnapshot,
953        mut stmt: Statement,
954        params: &[Value],
955        cancel: CancelToken<'_>,
956    ) -> Result<QueryResult, EngineError> {
957        cancel.check()?;
958        substitute_placeholders(&mut stmt, params)?;
959        let transient = Engine {
960            catalog: snapshot.catalog.clone(),
961            statistics: snapshot.statistics.clone(),
962            clock: snapshot.clock,
963            max_query_rows: snapshot.max_query_rows,
964            ..Engine::default()
965        };
966        transient.execute_readonly_stmt_with_cancel(stmt, cancel)
967    }
968
969    /// v7.18 — describe a prepared `Statement` against a
970    /// `CatalogSnapshot`. Same `(parameter_oids, output_columns)`
971    /// shape as [`Engine::describe_prepared`]; resolves names
972    /// against the snapshot's catalog instead of `self`. Pure
973    /// function — no engine state read.
974    pub fn describe_prepared_on_snapshot(
975        snapshot: &CatalogSnapshot,
976        stmt: &Statement,
977    ) -> (Vec<u32>, Vec<ColumnSchema>) {
978        describe::describe_prepared(stmt, &snapshot.catalog)
979    }
980
981    /// v7.18 — does this SQL string classify as read-only? Parses
982    /// `sql` with the engine parser and consults
983    /// `Statement::is_readonly()`. A parse error returns `false`
984    /// (route to the writer path so the user sees the canonical
985    /// parse error from the writer's simple-query dispatch).
986    /// Static-on-Self so the spg-sqlx connection layer can ask
987    /// without an `Engine` borrow.
988    #[must_use]
989    pub fn is_readonly_sql(sql: &str) -> bool {
990        parser::parse_statement(sql)
991            .as_ref()
992            .map(spg_sql::ast::Statement::is_readonly)
993            .unwrap_or(false)
994    }
995
996    /// v7.18 — parse + plan a SQL string against a
997    /// `CatalogSnapshot`. Mirror of [`Engine::prepare`] for the
998    /// readonly fan-out path: applies the same prepare-time
999    /// transforms (clock rewrite, `GROUP BY ALL` expansion, ORDER
1000    /// BY position resolve, cost-based JOIN reorder) but resolves
1001    /// catalog + statistics against the snapshot, not a live
1002    /// engine. Static-on-Self — `AsyncReadHandle::prepare` calls
1003    /// this without taking the writer lock so multiple read
1004    /// handles can prepare concurrently against frozen views.
1005    ///
1006    /// # Errors
1007    /// Propagates [`ParseError`] from the parser. Schema
1008    /// validation deferred to execute time, same as
1009    /// [`Engine::prepare`].
1010    pub fn prepare_on_snapshot(
1011        snapshot: &CatalogSnapshot,
1012        sql: &str,
1013    ) -> Result<Statement, ParseError> {
1014        let mut stmt = parser::parse_statement(sql)?;
1015        let now_micros = snapshot.clock.map(|f| f());
1016        rewrite_clock_calls(&mut stmt, now_micros);
1017        if let Statement::Select(s) = &mut stmt {
1018            expand_group_by_all(s);
1019            resolve_order_by_position(s);
1020            reorder::reorder_joins(s, &snapshot.catalog, &snapshot.statistics);
1021        }
1022        Ok(stmt)
1023    }
1024
1025    /// Construct an engine restored from a previously-snapshotted catalog
1026    /// (see `snapshot()`).
1027    pub fn restore(catalog: Catalog) -> Self {
1028        Self {
1029            catalog,
1030            tx_catalogs: BTreeMap::new(),
1031            current_tx: None,
1032            backslash_escapes: false,
1033            next_tx_id: 1,
1034            clock: None,
1035            salt_fn: None,
1036            max_query_rows: None,
1037            users: UserStore::new(),
1038            publications: publications::Publications::new(),
1039            subscriptions: subscriptions::Subscriptions::new(),
1040            statistics: statistics::Statistics::new(),
1041            plan_cache: plan_cache::PlanCache::new(),
1042            query_stats: query_stats::QueryStats::new(),
1043            activity_provider: None,
1044            audit_chain_provider: None,
1045            audit_verifier: None,
1046            slow_query_threshold_us: None,
1047            slow_query_logger: None,
1048            session_params: BTreeMap::new(),
1049            trigger_recursion_depth: 0,
1050            foreign_key_checks: true,
1051            meta_views_materialised: false,
1052            pending_foreign_keys: Vec::new(),
1053        }
1054    }
1055
1056    /// Restore an engine + user table from a v4.1 envelope produced
1057    /// by `snapshot_with_users()`. Falls back to plain catalog-only
1058    /// restore if the envelope magic isn't present (so v3.x snapshot
1059    /// files still load). v6.1.2 adds the optional publications
1060    /// trailer (envelope v3); a v1/v2 envelope deserialises to an
1061    /// empty publication table.
1062    pub fn restore_envelope(buf: &[u8]) -> Result<Self, EngineError> {
1063        match split_envelope(buf) {
1064            EnvelopeParse::Pair {
1065                catalog: catalog_bytes,
1066                users: user_bytes,
1067                publications: pub_bytes,
1068                subscriptions: sub_bytes,
1069                statistics: stats_bytes,
1070            } => {
1071                let catalog = Catalog::deserialize(catalog_bytes).map_err(EngineError::Storage)?;
1072                let users = users::deserialize_users(user_bytes)
1073                    .map_err(|e| EngineError::Unsupported(alloc::format!("users restore: {e}")))?;
1074                let publications = match pub_bytes {
1075                    Some(b) => publications::Publications::deserialize(b).map_err(|e| {
1076                        EngineError::Unsupported(alloc::format!("publications restore: {e:?}"))
1077                    })?,
1078                    None => publications::Publications::new(),
1079                };
1080                let subscriptions = match sub_bytes {
1081                    Some(b) => subscriptions::Subscriptions::deserialize(b).map_err(|e| {
1082                        EngineError::Unsupported(alloc::format!("subscriptions restore: {e:?}"))
1083                    })?,
1084                    None => subscriptions::Subscriptions::new(),
1085                };
1086                let statistics = match stats_bytes {
1087                    Some(b) => statistics::Statistics::deserialize(b).map_err(|e| {
1088                        EngineError::Unsupported(alloc::format!("statistics restore: {e:?}"))
1089                    })?,
1090                    None => statistics::Statistics::new(),
1091                };
1092                Ok(Self {
1093                    catalog,
1094                    tx_catalogs: BTreeMap::new(),
1095                    current_tx: None,
1096                    backslash_escapes: false,
1097                    next_tx_id: 1,
1098                    clock: None,
1099                    salt_fn: None,
1100                    max_query_rows: None,
1101                    users,
1102                    publications,
1103                    subscriptions,
1104                    statistics,
1105                    plan_cache: plan_cache::PlanCache::new(),
1106                    query_stats: query_stats::QueryStats::new(),
1107                    activity_provider: None,
1108                    audit_chain_provider: None,
1109                    audit_verifier: None,
1110                    slow_query_threshold_us: None,
1111                    slow_query_logger: None,
1112                    session_params: BTreeMap::new(),
1113                    trigger_recursion_depth: 0,
1114                    foreign_key_checks: true,
1115                    meta_views_materialised: false,
1116                    pending_foreign_keys: Vec::new(),
1117                })
1118            }
1119            EnvelopeParse::CrcMismatch { expected, computed } => {
1120                Err(EngineError::Storage(StorageError::Corrupt(alloc::format!(
1121                    "snapshot envelope CRC32 mismatch (expected={expected:#010x}, computed={computed:#010x})"
1122                ))))
1123            }
1124            EnvelopeParse::Bare => {
1125                let catalog = Catalog::deserialize(buf).map_err(EngineError::Storage)?;
1126                Ok(Self::restore(catalog))
1127            }
1128        }
1129    }
1130
1131    pub const fn users(&self) -> &UserStore {
1132        &self.users
1133    }
1134
1135    /// `salt` is supplied by the caller (the host has a random
1136    /// source; the engine is `no_std`). Caller should pass a fresh
1137    /// 16-byte random value per user.
1138    pub fn create_user(
1139        &mut self,
1140        name: &str,
1141        password: &str,
1142        role: Role,
1143        salt: [u8; 16],
1144    ) -> Result<(), UserError> {
1145        self.users.create(name, password, role, salt)?;
1146        // v4.8: also derive SCRAM-SHA-256 secrets so PG-wire SASL
1147        // auth can verify without re-running PBKDF2 per attempt.
1148        // Uses a fresh salt from the host RNG (falls back to a
1149        // deterministic per-username salt when no RNG is wired, same
1150        // as the legacy hash path).
1151        let scram_salt = self.salt_fn.map_or_else(
1152            || {
1153                let mut s = [0u8; users::SCRAM_SALT_LEN];
1154                let digest = spg_crypto::hash(name.as_bytes());
1155                // Use bytes 16..32 of BLAKE3 so we don't reuse the
1156                // exact same fallback salt as the BLAKE3 hash path.
1157                s.copy_from_slice(&digest[16..32]);
1158                s
1159            },
1160            |f| f(),
1161        );
1162        self.users
1163            .enable_scram(name, password, scram_salt, users::SCRAM_DEFAULT_ITERS)?;
1164        Ok(())
1165    }
1166
1167    pub fn drop_user(&mut self, name: &str) -> Result<(), UserError> {
1168        self.users.drop(name)
1169    }
1170
1171    pub fn verify_user(&self, name: &str, password: &str) -> Option<Role> {
1172        self.users.verify(name, password)
1173    }
1174
1175    /// Builder: attach a wall clock so `NOW()` / `CURRENT_TIMESTAMP` /
1176    /// `CURRENT_DATE` evaluate to a real value instead of erroring out.
1177    #[must_use]
1178    pub const fn with_clock(mut self, clock: ClockFn) -> Self {
1179        self.clock = Some(clock);
1180        self
1181    }
1182
1183    /// Builder: attach an OS-backed RNG for per-user password salts.
1184    /// The host (`spg-server`) typically wires this to `/dev/urandom`.
1185    #[must_use]
1186    pub const fn with_salt_fn(mut self, f: SaltFn) -> Self {
1187        self.salt_fn = Some(f);
1188        self
1189    }
1190
1191    /// Builder: cap the number of rows a single SELECT may return.
1192    /// Exceeding the cap raises `EngineError::RowLimitExceeded` —
1193    /// the bound is checked inside the executor so a runaway
1194    /// catalog scan can't allocate millions of rows before the
1195    /// server gets a chance to reject the result.
1196    #[must_use]
1197    pub const fn with_max_query_rows(mut self, n: usize) -> Self {
1198        self.max_query_rows = Some(n);
1199        self
1200    }
1201
1202    /// The *committed* catalog. Note: during a transaction this returns the
1203    /// pre-TX state — `SELECT` inside a TX goes through `execute()` and reads
1204    /// the shadow. Tests that inspect outside-TX state should use this.
1205    pub const fn catalog(&self) -> &Catalog {
1206        &self.catalog
1207    }
1208
1209    /// Serialize the *committed* catalog to bytes. v0.6 was full-snapshot; v0.9
1210    /// adds the rule that an open TX's shadow is never snapshotted — only the
1211    /// post-COMMIT state is persisted. v4.1 wraps the catalog in an envelope
1212    /// when there are users to persist; an empty user table snapshots as the
1213    /// bare catalog format (backwards-compat with v3.x readers). v6.1.2
1214    /// adds publications to the envelope condition: either non-empty
1215    /// users OR non-empty publications now triggers the envelope path.
1216    pub fn snapshot(&self) -> Vec<u8> {
1217        if self.users.is_empty()
1218            && self.publications.is_empty()
1219            && self.subscriptions.is_empty()
1220            && self.statistics.is_empty()
1221        {
1222            self.catalog.serialize()
1223        } else {
1224            build_envelope(
1225                &self.catalog.serialize(),
1226                &users::serialize_users(&self.users),
1227                &self.publications.serialize(),
1228                &self.subscriptions.serialize(),
1229                &self.statistics.serialize(),
1230            )
1231        }
1232    }
1233
1234    /// True when at least one TX slot is in flight. v4.41.1 runtime
1235    /// invariant: at most one slot active at a time (dispatch holds
1236    /// `engine.write()` across the entire wrap). v4.42 will let this
1237    /// return true with multiple slots concurrently.
1238    pub fn in_transaction(&self) -> bool {
1239        !self.tx_catalogs.is_empty()
1240    }
1241
1242    /// v4.41.1 allocate a fresh TX handle. Used by spg-server dispatch
1243    /// to scope each implicit-wrap BEGIN..stmt..COMMIT to its own slot
1244    /// in `tx_catalogs`. v4.42 — the commit-barrier leader allocates
1245    /// one of these per task in its group, runs `BEGIN`+sql+`COMMIT`
1246    /// sequentially under a single `engine.write()` so each task's
1247    /// mutations accumulate into shared state, then either keeps the
1248    /// accumulated state (fsync OK) or restores the pre-image via
1249    /// `replace_catalog` (fsync err).
1250    pub fn alloc_tx_id(&mut self) -> TxId {
1251        let id = TxId(self.next_tx_id);
1252        self.next_tx_id = self.next_tx_id.saturating_add(1);
1253        id
1254    }
1255
1256    /// v4.42 — atomically replace the live catalog. Used by the
1257    /// commit-barrier leader to roll back a group whose batched
1258    /// fsync failed: the leader snapshots `engine.catalog().clone()`
1259    /// (O(1) Arc bump after the v4.39/v4.40 persistent migration)
1260    /// at group start, sequentially applies each task's BEGIN+sql+
1261    /// COMMIT under the same write lock to accumulate mutations
1262    /// into shared state, batches the WAL bytes, fsyncs once, and
1263    /// on failure calls this with the pre-image to undo every
1264    /// task in the group at once.
1265    ///
1266    /// **Does NOT touch `tx_catalogs` / `current_tx`.** Any
1267    /// explicit-TX slot from a concurrent client (created via the
1268    /// legacy `IMPLICIT_TX`-less dispatch path or via the future
1269    /// MVCC-readers v5+ work) has its own snapshot baked into the
1270    /// slot — restoring `self.catalog` to the pre-image leaves
1271    /// those slots untouched, exactly as they were when the leader
1272    /// took the lock. The leader's own implicit-TX slots are all
1273    /// already discarded (`exec_commit` removed them as each
1274    /// task's COMMIT ran) by the time this is reached.
1275    pub fn replace_catalog(&mut self, catalog: Catalog) {
1276        self.catalog = catalog;
1277    }
1278
1279    /// v6.7.0 — public shim around `Catalog::freeze_oldest_to_cold`
1280    /// so tests + the spg-server freezer can drive a freeze without
1281    /// reaching into the private `active_catalog_mut`. v6.7.4
1282    /// parallel freezer will build on this surface.
1283    ///
1284    /// Marks the table's cached `cold_row_count` stale because the
1285    /// freeze added cold locators that ANALYZE hasn't yet refreshed.
1286    pub fn freeze_oldest_to_cold(
1287        &mut self,
1288        table_name: &str,
1289        index_name: &str,
1290        max_rows: usize,
1291    ) -> Result<spg_storage::FreezeReport, EngineError> {
1292        let report = self
1293            .active_catalog_mut()
1294            .freeze_oldest_to_cold(table_name, index_name, max_rows)
1295            .map_err(EngineError::Storage)?;
1296        if let Some(t) = self.active_catalog_mut().get_mut(table_name) {
1297            t.mark_cold_row_count_stale();
1298        }
1299        Ok(report)
1300    }
1301
1302    /// v6.7.5 — public shim used by the spg-server follower's
1303    /// segment-forwarding receiver. Registers a cold-tier segment
1304    /// at a specific id (the master's id, as transmitted on the
1305    /// wire) so the follower's BTree-Cold locators stay byte-
1306    /// identical with the master's. Wraps
1307    /// `Catalog::load_segment_bytes_at` under the standard
1308    /// clone-mutate-replace pattern.
1309    ///
1310    /// Returns `Ok(())` on success **and** on the "slot already
1311    /// occupied" case — a follower mid-reconnect may receive a
1312    /// segment chunk for a segment_id it already has on disk
1313    /// (forwarded last session); the caller should treat that
1314    /// path as a no-op rather than a fatal error.
1315    pub fn receive_cold_segment(
1316        &mut self,
1317        segment_id: u32,
1318        bytes: Vec<u8>,
1319    ) -> Result<(), EngineError> {
1320        let mut new_cat = self.catalog.clone();
1321        match new_cat.load_segment_bytes_at(segment_id, bytes) {
1322            Ok(()) => {
1323                self.replace_catalog(new_cat);
1324                Ok(())
1325            }
1326            Err(StorageError::Corrupt(msg)) if msg.contains("already occupied") => Ok(()),
1327            Err(e) => Err(EngineError::Storage(e)),
1328        }
1329    }
1330
1331    /// v6.7.3 — public shim around `Catalog::compact_cold_segments`
1332    /// driving every BTree index on every user table. Returns one
1333    /// `(table, index, report)` triple for each merge that
1334    /// actually happened (no-op (table, index) pairs are filtered
1335    /// out so callers can size persist-side work to the live
1336    /// merges). Caller is responsible for persisting each
1337    /// `report.merged_segment_bytes` and updating the on-disk
1338    /// segment registry; engine layer is no_std and never
1339    /// touches disk.
1340    ///
1341    /// Marks every touched table's cached `cold_row_count` stale
1342    /// — compaction GC'd some shadowed rows, so the count must be
1343    /// re-derived on the next ANALYZE.
1344    pub fn compact_cold_segments_with_target(
1345        &mut self,
1346        target_segment_bytes: u64,
1347    ) -> Result<Vec<(String, String, CompactReport)>, EngineError> {
1348        let table_names = self.active_catalog().table_names();
1349        let mut reports: Vec<(String, String, CompactReport)> = Vec::new();
1350        for tname in table_names {
1351            if is_internal_table_name(&tname) {
1352                continue;
1353            }
1354            let idx_names: Vec<String> = {
1355                let Some(t) = self.active_catalog().get(&tname) else {
1356                    continue;
1357                };
1358                t.indices()
1359                    .iter()
1360                    .filter(|i| matches!(i.kind, IndexKind::BTree(_)))
1361                    .map(|i| i.name.clone())
1362                    .collect()
1363            };
1364            for iname in idx_names {
1365                let report = self
1366                    .active_catalog_mut()
1367                    .compact_cold_segments(&tname, &iname, target_segment_bytes)
1368                    .map_err(EngineError::Storage)?;
1369                if report.merged_segment_id.is_some() {
1370                    if let Some(t) = self.active_catalog_mut().get_mut(&tname) {
1371                        t.mark_cold_row_count_stale();
1372                    }
1373                    reports.push((tname.clone(), iname, report));
1374                }
1375            }
1376        }
1377        Ok(reports)
1378    }
1379
1380    fn active_catalog(&self) -> &Catalog {
1381        match self.current_tx {
1382            Some(t) => self
1383                .tx_catalogs
1384                .get(&t)
1385                .map_or(&self.catalog, |s| &s.catalog),
1386            None => &self.catalog,
1387        }
1388    }
1389
1390    /// v7.12.4 — snapshot every row-level trigger on `table` that
1391    /// fires for `event` (`"INSERT"` / `"UPDATE"` / `"DELETE"`) at
1392    /// the given `timing` (`"BEFORE"` / `"AFTER"`), and clone its
1393    /// referenced function definition. Returned as a vec of owned
1394    /// `FunctionDef` so the row-write loop can fire them without
1395    /// holding a borrow on the catalog (which would conflict with
1396    /// the table.insert / update_row / delete mutable borrows).
1397    /// v7.16.2 — top-level DO block executor. Walks the
1398    /// PlPgSqlBlock via [`triggers::execute_do_block_top_level`],
1399    /// then runs each collected EmbeddedSql statement through
1400    /// the engine's regular execute path (NOT deferred — DO is
1401    /// outside any row-write borrow). Errors from any step
1402    /// abort the block and propagate verbatim.
1403    /// v7.16.2 — resolve every subquery inside a PlPgSqlBlock's
1404    /// expression slots so the downstream trigger-flavoured
1405    /// evaluator (which expects pre-resolved Expr::Literal /
1406    /// Binary chains) doesn't trip on raw Exists/ScalarSubquery
1407    /// nodes. Walks IF conditions, Assign values, RAISE args.
1408    /// EmbeddedSql statements re-enter the engine for execution
1409    /// later so their subqueries get the normal SELECT-side
1410    /// resolution.
1411    fn resolve_plpgsql_block_subqueries(
1412        &self,
1413        block: &mut spg_sql::ast::PlPgSqlBlock,
1414        cancel: CancelToken<'_>,
1415    ) -> Result<(), EngineError> {
1416        for d in &mut block.declarations {
1417            if let Some(e) = &mut d.default {
1418                self.resolve_expr_subqueries(e, cancel)?;
1419            }
1420        }
1421        self.resolve_plpgsql_stmts_subqueries(&mut block.statements, cancel)
1422    }
1423
1424    fn resolve_plpgsql_stmts_subqueries(
1425        &self,
1426        stmts: &mut [spg_sql::ast::PlPgSqlStmt],
1427        cancel: CancelToken<'_>,
1428    ) -> Result<(), EngineError> {
1429        use spg_sql::ast::PlPgSqlStmt;
1430        for stmt in stmts {
1431            match stmt {
1432                PlPgSqlStmt::Assign { value, .. } => {
1433                    self.resolve_expr_subqueries(value, cancel)?;
1434                }
1435                PlPgSqlStmt::Return(spg_sql::ast::ReturnTarget::Expr(e)) => {
1436                    self.resolve_expr_subqueries(e, cancel)?;
1437                }
1438                PlPgSqlStmt::Return(_) => {}
1439                PlPgSqlStmt::If {
1440                    branches,
1441                    else_branch,
1442                } => {
1443                    for (cond, body) in branches.iter_mut() {
1444                        self.resolve_expr_subqueries(cond, cancel)?;
1445                        self.resolve_plpgsql_stmts_subqueries(body, cancel)?;
1446                    }
1447                    self.resolve_plpgsql_stmts_subqueries(else_branch, cancel)?;
1448                }
1449                PlPgSqlStmt::Raise { args, .. } => {
1450                    for a in args {
1451                        self.resolve_expr_subqueries(a, cancel)?;
1452                    }
1453                }
1454                PlPgSqlStmt::EmbeddedSql(_) => {
1455                    // Embedded SQL goes back through execute_stmt
1456                    // _with_cancel which runs the SELECT-side
1457                    // resolver itself; nothing to do here.
1458                }
1459                PlPgSqlStmt::SelectInto { body, .. } => {
1460                    // SELECT INTO runs through Engine::execute
1461                    // when reached, so subquery resolution
1462                    // happens via the normal SELECT-side path.
1463                    // Still walk for nested subqueries inside
1464                    // the SELECT body so eval doesn't trip.
1465                    self.resolve_select_subqueries(body, cancel)?;
1466                }
1467            }
1468        }
1469        Ok(())
1470    }
1471
1472    fn exec_do_block(
1473        &mut self,
1474        body: spg_sql::ast::PlPgSqlBlock,
1475    ) -> Result<QueryResult, EngineError> {
1476        // v7.16.2 — pre-resolve every subquery the body's
1477        // expressions reach. `eval::eval_expr` errors on
1478        // unresolved Exists/ScalarSubquery/InSubquery; the
1479        // top-level SELECT path runs `resolve_select_subqueries`
1480        // for the caller — for plpgsql we have to do the
1481        // equivalent before the body walker runs. Catches the
1482        // mailrs idiom `IF EXISTS (SELECT 1 FROM
1483        // information_schema.columns WHERE …) THEN …`.
1484        let mut body = body;
1485        self.resolve_plpgsql_block_subqueries(&mut body, CancelToken::none())?;
1486        let dts = self
1487            .session_param("default_text_search_config")
1488            .map(String::from);
1489        // v7.16.2 — SELECT … INTO resolver. The walker calls
1490        // this synchronously when it hits a SelectInto stmt
1491        // so the IF / locals scope sees the result before the
1492        // next statement. Body walks for trigger paths (no
1493        // resolver) error loudly on SelectInto.
1494        // SAFETY: the closure shares this engine borrow with
1495        // the walker, but the walker only borrows for the
1496        // duration of `execute_do_block_top_level` and doesn't
1497        // reach back into the engine through any other path —
1498        // so the recursive `&mut` is sound. We use a `RefCell`
1499        // for interior mutability since the closure is
1500        // Fn-shaped.
1501        let engine_cell = core::cell::RefCell::new(&mut *self);
1502        let resolver_fn =
1503            |stmt: &spg_sql::ast::Statement| -> Result<Value, triggers::TriggerError> {
1504                let mut eng = engine_cell.borrow_mut();
1505                let r = eng
1506                    .execute_stmt_with_cancel(stmt.clone(), CancelToken::none())
1507                    .map_err(|e| triggers::TriggerError::EvalFailed {
1508                        function: "DO".into(),
1509                        cause: eval::EvalError::TypeMismatch {
1510                            detail: alloc::format!("SELECT … INTO failed: {e}"),
1511                        },
1512                    })?;
1513                match r {
1514                    QueryResult::Rows { rows, .. } => match rows.into_iter().next() {
1515                        Some(row) => Ok(row.values.into_iter().next().unwrap_or(Value::Null)),
1516                        None => Ok(Value::Null),
1517                    },
1518                    _ => Err(triggers::TriggerError::EvalFailed {
1519                        function: "DO".into(),
1520                        cause: eval::EvalError::TypeMismatch {
1521                            detail: "SELECT … INTO body must be a SELECT".into(),
1522                        },
1523                    }),
1524                }
1525            };
1526        let collected =
1527            triggers::execute_do_block_top_level(&body, dts.as_deref(), Some(&resolver_fn))
1528                .map_err(|e| {
1529                    EngineError::Storage(StorageError::Corrupt(alloc::format!("DO: {e}")))
1530                })?;
1531        // engine_cell goes out of scope here, releasing the &mut self borrow
1532        // Run each embedded statement against the engine. The
1533        // statements were already substitute-walked for NEW/OLD/
1534        // locals (those evaluate to engine literals before they
1535        // land here) so dispatch is plain execute_stmt_with_cancel.
1536        for stmt in collected {
1537            // v7.16.2 — preserve current_tx wrap so an outer
1538            // BEGIN/COMMIT around a DO block keeps the
1539            // EmbeddedSql writes inside that same tx slot.
1540            self.execute_stmt_with_cancel(stmt, CancelToken::none())?;
1541        }
1542        Ok(QueryResult::CommandOk {
1543            affected: 0,
1544            modified_catalog: !self.in_transaction(),
1545        })
1546    }
1547
1548    fn snapshot_row_triggers(
1549        &self,
1550        table: &str,
1551        event: &str,
1552        timing: &str,
1553    ) -> Vec<spg_storage::FunctionDef> {
1554        let cat = self.active_catalog();
1555        cat.triggers()
1556            .iter()
1557            .filter(|t| {
1558                // v7.16.1 — skip disabled triggers (mailrs
1559                // round-9 A.2.b — pg_dump --disable-triggers).
1560                t.enabled
1561                    && t.table == table
1562                    && t.timing.eq_ignore_ascii_case(timing)
1563                    && t.for_each.eq_ignore_ascii_case("row")
1564                    && t.events.iter().any(|e| e.eq_ignore_ascii_case(event))
1565            })
1566            .filter_map(|t| cat.functions().get(&t.function).cloned())
1567            .collect()
1568    }
1569
1570    /// v7.13.0 — UPDATE-side snapshot that pairs each trigger's
1571    /// function with its `UPDATE OF cols` filter (mailrs round-5
1572    /// G7). Empty filter Vec means "fire unconditionally", matching
1573    /// the v7.12 behaviour.
1574    fn snapshot_update_row_triggers(
1575        &self,
1576        table: &str,
1577        timing: &str,
1578    ) -> Vec<(spg_storage::FunctionDef, Vec<String>)> {
1579        let cat = self.active_catalog();
1580        cat.triggers()
1581            .iter()
1582            .filter(|t| {
1583                // v7.16.1 — skip disabled triggers.
1584                t.enabled
1585                    && t.table == table
1586                    && t.timing.eq_ignore_ascii_case(timing)
1587                    && t.for_each.eq_ignore_ascii_case("row")
1588                    && t.events.iter().any(|e| e.eq_ignore_ascii_case("UPDATE"))
1589            })
1590            .filter_map(|t| {
1591                cat.functions()
1592                    .get(&t.function)
1593                    .cloned()
1594                    .map(|fd| (fd, t.update_columns.clone()))
1595            })
1596            .collect()
1597    }
1598
1599    /// v7.12.7 — drain the trigger-emitted embedded SQL queue.
1600    /// Called by the INSERT / UPDATE / DELETE executors after
1601    /// their main row-write loop returns. Each statement runs
1602    /// inside the same cancel scope as the firing DML and bumps
1603    /// the recursion counter; nested embedded SQL beyond
1604    /// [`MAX_TRIGGER_RECURSION`] errors with a clear message so
1605    /// a trigger-graph cycle surfaces as a query failure instead
1606    /// of stack-blowing the engine.
1607    fn execute_deferred_trigger_stmts(
1608        &mut self,
1609        deferred: Vec<triggers::DeferredEmbeddedStmt>,
1610        cancel: CancelToken<'_>,
1611    ) -> Result<(), EngineError> {
1612        for d in deferred {
1613            if self.trigger_recursion_depth >= MAX_TRIGGER_RECURSION {
1614                return Err(EngineError::Storage(StorageError::Corrupt(alloc::format!(
1615                    "trigger embedded SQL recursion depth {} exceeded (trigger function \
1616                     {:?} would push past the {} cap — check for trigger cycles)",
1617                    self.trigger_recursion_depth,
1618                    d.function,
1619                    MAX_TRIGGER_RECURSION,
1620                ))));
1621            }
1622            self.trigger_recursion_depth += 1;
1623            let res = self.execute_stmt_with_cancel(d.stmt, cancel);
1624            self.trigger_recursion_depth -= 1;
1625            res?;
1626        }
1627        Ok(())
1628    }
1629
1630    fn active_catalog_mut(&mut self) -> &mut Catalog {
1631        let tx = self.current_tx;
1632        match tx {
1633            Some(t) => match self.tx_catalogs.get_mut(&t) {
1634                Some(s) => &mut s.catalog,
1635                None => &mut self.catalog,
1636            },
1637            None => &mut self.catalog,
1638        }
1639    }
1640
1641    /// Read-only execute path. Succeeds for `SELECT` / `SHOW TABLES`
1642    /// / `SHOW COLUMNS`; returns `EngineError::WriteRequired` for
1643    /// every other statement, so the caller can fall through to the
1644    /// `&mut self` `execute` path under a write lock. Engine state is
1645    /// not mutated even on the success path (`rewrite_clock_calls`
1646    /// and `resolve_order_by_position` both mutate the locally-owned
1647    /// AST, not `self`).
1648    ///
1649    /// **v4.0 concurrency**: this is the entry point the server takes
1650    /// under an `RwLock::read()` so multiple `SELECT` clients run in
1651    /// parallel without serialising on a single mutex.
1652    pub fn execute_readonly(&self, sql: &str) -> Result<QueryResult, EngineError> {
1653        self.execute_readonly_with_cancel(sql, CancelToken::none())
1654    }
1655
1656    /// v4.5 — read path with cooperative cancellation. Token's
1657    /// `is_cancelled` is checked at the start (so a watchdog that
1658    /// already fired returns Cancelled immediately) and at row-loop
1659    /// checkpoints inside `exec_select`. SHOW paths are O(small) and
1660    /// don't bother checking.
1661    pub fn execute_readonly_with_cancel(
1662        &self,
1663        sql: &str,
1664        cancel: CancelToken<'_>,
1665    ) -> Result<QueryResult, EngineError> {
1666        cancel.check()?;
1667        let mut stmt = parser::parse_statement_with(sql, self.backslash_escapes)?;
1668        let now_micros = self.clock.map(|f| f());
1669        rewrite_clock_calls(&mut stmt, now_micros);
1670        if let Statement::Select(s) = &mut stmt {
1671            resolve_order_by_position(s);
1672            // v6.2.3 — cost-based JOIN reorder (read path).
1673            reorder::reorder_joins(s, &self.catalog, &self.statistics);
1674        }
1675        self.execute_readonly_stmt_with_cancel(stmt, cancel)
1676    }
1677
1678    /// v7.18 — readonly dispatch on a pre-parsed `Statement`.
1679    /// Internal helper shared by the SQL-string path
1680    /// ([`Engine::execute_readonly_with_cancel`]) and the prepared-
1681    /// statement path ([`Engine::execute_readonly_prepared_on_snapshot_with_cancel`]).
1682    /// Statement-level transforms (clock rewrite, ORDER BY position,
1683    /// JOIN reorder, placeholder substitution) are the caller's
1684    /// responsibility — this helper assumes the AST is already
1685    /// execution-ready. Writes / DDL hit
1686    /// [`EngineError::WriteRequired`] the same way the SQL path does.
1687    fn execute_readonly_stmt_with_cancel(
1688        &self,
1689        stmt: Statement,
1690        cancel: CancelToken<'_>,
1691    ) -> Result<QueryResult, EngineError> {
1692        let result = match stmt {
1693            Statement::Select(s) => self.exec_select_cancel(&s, cancel),
1694            Statement::ShowTables => Ok(self.exec_show_tables()),
1695            Statement::ShowDatabases => Ok(self.exec_show_databases()),
1696            Statement::ShowCreateTable(name) => self.exec_show_create_table(&name),
1697            Statement::ShowIndexes(name) => self.exec_show_indexes(&name),
1698            Statement::ShowStatus => Ok(self.exec_show_status()),
1699            Statement::ShowVariables => Ok(self.exec_show_variables()),
1700            Statement::ShowProcesslist => Ok(self.exec_show_processlist()),
1701            Statement::ShowColumns(table) => self.exec_show_columns(&table),
1702            Statement::ShowUsers => Ok(self.exec_show_users()),
1703            Statement::ShowPublications => Ok(self.exec_show_publications()),
1704            Statement::ShowSubscriptions => Ok(self.exec_show_subscriptions()),
1705            Statement::WaitForWalPosition { .. } => Err(EngineError::Unsupported(
1706                "WAIT FOR WAL POSITION must be handled by the server layer".into(),
1707            )),
1708            Statement::Explain(e) => self.exec_explain(&e, cancel),
1709            _ => Err(EngineError::WriteRequired),
1710        };
1711        self.enforce_row_limit(result)
1712    }
1713
1714    /// v4.2: cap result-set size. Applied after the executor
1715    /// materialises rows but before they leave the engine — wrapping
1716    /// every Rows-returning exec_* function would scatter the check.
1717    fn enforce_row_limit(
1718        &self,
1719        result: Result<QueryResult, EngineError>,
1720    ) -> Result<QueryResult, EngineError> {
1721        if let (Ok(QueryResult::Rows { rows, .. }), Some(cap)) = (&result, self.max_query_rows)
1722            && rows.len() > cap
1723        {
1724            return Err(EngineError::RowLimitExceeded(cap));
1725        }
1726        result
1727    }
1728
1729    pub fn execute(&mut self, sql: &str) -> Result<QueryResult, EngineError> {
1730        self.execute_in_with_cancel(sql, IMPLICIT_TX, CancelToken::none())
1731    }
1732
1733    /// v4.5 — write path with cooperative cancellation. Same dispatch
1734    /// as `execute_in_with_cancel(sql, IMPLICIT_TX, cancel)`. Kept as
1735    /// a separate entry point for backward-compat with the v4.5
1736    /// public API.
1737    pub fn execute_with_cancel(
1738        &mut self,
1739        sql: &str,
1740        cancel: CancelToken<'_>,
1741    ) -> Result<QueryResult, EngineError> {
1742        self.execute_in_with_cancel(sql, IMPLICIT_TX, cancel)
1743    }
1744
1745    /// v4.41.1 multi-slot write entry. Routes `sql` through the TX
1746    /// slot identified by `tx_id` so spg-server dispatch can scope
1747    /// each implicit-wrap BEGIN..stmt..COMMIT to its own slot in
1748    /// `tx_catalogs`. `IMPLICIT_TX` is the legacy single-slot path
1749    /// every other caller (engine self-tests, replay, spg-embedded)
1750    /// implicitly takes via `execute()` / `execute_with_cancel()`.
1751    pub fn execute_in(&mut self, sql: &str, tx_id: TxId) -> Result<QueryResult, EngineError> {
1752        self.execute_in_with_cancel(sql, tx_id, CancelToken::none())
1753    }
1754
1755    /// v4.41.1 write path with cooperative cancellation + explicit TX
1756    /// scope. Sets `self.current_tx` for the duration of the call so
1757    /// every `exec_*` helper transparently sees its TX's shadow
1758    /// catalog and savepoint stack; restores on exit so the field is
1759    /// only valid mid-call (no leakage across calls).
1760    pub fn execute_in_with_cancel(
1761        &mut self,
1762        sql: &str,
1763        tx_id: TxId,
1764        cancel: CancelToken<'_>,
1765    ) -> Result<QueryResult, EngineError> {
1766        let saved = self.current_tx;
1767        self.current_tx = Some(tx_id);
1768        let result = self.execute_inner_with_cancel(sql, cancel);
1769        self.current_tx = saved;
1770        result
1771    }
1772
1773    /// v6.1.1 — parse and pre-process a SQL string ONCE so the
1774    /// resulting [`Statement`] can be cached and re-executed via
1775    /// [`Engine::execute_prepared`]. Returns the same `Statement`
1776    /// the simple-query path would synthesise internally (clock
1777    /// rewrites + ORDER BY position-ref resolution applied at
1778    /// prepare time, since both are session-independent). The
1779    /// `$N` placeholders in the SQL stay as `Expr::Placeholder(n)`
1780    /// nodes; they're resolved to concrete values per-call by
1781    /// `execute_prepared`'s substitution walk.
1782    ///
1783    /// Pgwire's `Parse` (P) message lands here.
1784    pub fn prepare(&self, sql: &str) -> Result<Statement, ParseError> {
1785        let mut stmt = parser::parse_statement_with(sql, self.backslash_escapes)?;
1786        let now_micros = self.clock.map(|f| f());
1787        rewrite_clock_calls(&mut stmt, now_micros);
1788        if let Statement::Select(s) = &mut stmt {
1789            // v6.4.1 — expand `GROUP BY ALL` to every non-aggregate
1790            // SELECT-list item BEFORE position / alias resolution so
1791            // downstream passes see the explicit list.
1792            expand_group_by_all(s);
1793            resolve_order_by_position(s);
1794            // v6.2.3 — cost-based JOIN reorder. No-op for
1795            // single-table FROMs or any non-INNER join shape.
1796            reorder::reorder_joins(s, &self.catalog, &self.statistics);
1797        }
1798        Ok(stmt)
1799    }
1800
1801    /// v6.3.0 — cached prepare. Returns a cloned `Statement` from
1802    /// the plan cache on hit, runs the full `prepare()` path on miss
1803    /// and inserts the resulting plan before returning. Skipping the
1804    /// parse + JOIN-reorder pipeline on hit is the dominant win for
1805    /// JDBC / sqlx / pgx clients that reuse the same SQL string.
1806    ///
1807    /// Returns a cloned `Statement` (not a borrow) because the
1808    /// pgwire layer owns its `PreparedStmt` map per-session and the
1809    /// engine-level cache must stay available for other sessions.
1810    /// Clone cost on a 5-table JOIN AST is well under the parse cost
1811    /// it replaces.
1812    pub fn prepare_cached(&mut self, sql: &str) -> Result<Statement, ParseError> {
1813        // v6.3.1 — version-aware lookup. If the cached plan was
1814        // prepared before the most recent ANALYZE, evict and replan.
1815        let current_version = self.statistics.version();
1816        if let Some(plan) = self.plan_cache.get(sql) {
1817            if plan.statistics_version == current_version {
1818                return Ok(plan.stmt.clone());
1819            }
1820            // Stale entry — fall through to evict + re-prepare.
1821        }
1822        self.plan_cache.evict(sql);
1823        let stmt = self.prepare(sql)?;
1824        let source_tables = plan_cache::collect_source_tables(&stmt);
1825        let plan = plan_cache::PreparedPlan {
1826            stmt: stmt.clone(),
1827            statistics_version: current_version,
1828            source_tables,
1829            describe_columns: alloc::vec::Vec::new(),
1830        };
1831        self.plan_cache.insert(String::from(sql), plan);
1832        Ok(stmt)
1833    }
1834
1835    /// v6.3.0 — read-only accessor for tests and v6.3.1 invalidation.
1836    pub fn plan_cache(&self) -> &plan_cache::PlanCache {
1837        &self.plan_cache
1838    }
1839
1840    /// v6.3.0 — mutable accessor for v6.3.1 invalidation hooks.
1841    pub fn plan_cache_mut(&mut self) -> &mut plan_cache::PlanCache {
1842        &mut self.plan_cache
1843    }
1844
1845    /// v6.3.3 — Describe a prepared `Statement` without executing.
1846    /// Returns `(parameter_oids, output_columns)`. Empty
1847    /// `output_columns` means the statement has no row-producing
1848    /// shape we could resolve here (JOIN, subquery, non-SELECT, …)
1849    /// — pgwire layer maps that to a `NoData` reply.
1850    pub fn describe_prepared(&self, stmt: &Statement) -> (Vec<u32>, Vec<ColumnSchema>) {
1851        describe::describe_prepared(stmt, self.active_catalog())
1852    }
1853
1854    /// v6.1.1 — execute a [`Statement`] previously returned by
1855    /// [`Engine::prepare`], substituting `Expr::Placeholder(n)`
1856    /// nodes for the corresponding [`Value`] in `params` (1-based
1857    /// per PG: `$1` → `params[0]`). Bind-time string parameters
1858    /// are decoded into typed `Value`s by the pgwire layer before
1859    /// this call so the resulting AST hits the same execution
1860    /// path as a simple query — no SQL re-parse.
1861    ///
1862    /// Pgwire's `Execute` (E) message after a `Bind` (B) lands here.
1863    pub fn execute_prepared(
1864        &mut self,
1865        stmt: Statement,
1866        params: &[Value],
1867    ) -> Result<QueryResult, EngineError> {
1868        self.execute_prepared_with_cancel(stmt, params, CancelToken::none())
1869    }
1870
1871    /// v7.17.0 Phase 2.3 — prepared-statement entry that honors a
1872    /// caller-supplied `CancelToken`. Mirrors `execute_prepared`'s
1873    /// `current_tx` save/restore so the extended-query path stays
1874    /// transactionally consistent with the simple-query path.
1875    pub fn execute_prepared_with_cancel(
1876        &mut self,
1877        mut stmt: Statement,
1878        params: &[Value],
1879        cancel: CancelToken<'_>,
1880    ) -> Result<QueryResult, EngineError> {
1881        substitute_placeholders(&mut stmt, params)?;
1882        // v7.16.0 — set `current_tx` for the duration of the
1883        // dispatch so the `exec_*` helpers see the right TX
1884        // slot (matches what `execute_in_with_cancel` does for
1885        // simple-query). Pre-v7.16 the simple-query path
1886        // worked because every public entry point routed
1887        // through `execute_in_with_cancel`; the prepared path
1888        // skipped the wrap and so its INSERTs/UPDATEs landed
1889        // in the no-tx default slot, silently invisible to a
1890        // BEGIN/COMMIT-bracketed flow. Caught by spg-sqlx's
1891        // first transaction-visibility test.
1892        let saved = self.current_tx;
1893        self.current_tx = Some(IMPLICIT_TX);
1894        let result = self.execute_stmt_with_cancel(stmt, cancel);
1895        self.current_tx = saved;
1896        result
1897    }
1898
1899    fn execute_inner_with_cancel(
1900        &mut self,
1901        sql: &str,
1902        cancel: CancelToken<'_>,
1903    ) -> Result<QueryResult, EngineError> {
1904        cancel.check()?;
1905        let stmt = self.prepare(sql)?;
1906        // v6.5.1 — wrap the executor with a wall-clock window so we
1907        // can record into spg_stat_query. Skip when the engine has
1908        // no clock attached (no_std embedded callers).
1909        let start_us = self.clock.map(|f| f());
1910        let result = self.execute_stmt_with_cancel(stmt, cancel);
1911        if let (Some(t0), Ok(_)) = (start_us, &result) {
1912            let now = self.clock.map_or(t0, |f| f());
1913            let elapsed = now.saturating_sub(t0).max(0) as u64;
1914            self.query_stats.record(sql, elapsed, now as u64);
1915            // v6.5.6 — slow-query log: fire callback when elapsed
1916            // exceeds the configured floor.
1917            if let (Some(threshold), Some(logger)) =
1918                (self.slow_query_threshold_us, self.slow_query_logger)
1919                && elapsed >= threshold
1920            {
1921                logger(sql, elapsed);
1922            }
1923        }
1924        result
1925    }
1926
1927    fn execute_stmt_with_cancel(
1928        &mut self,
1929        stmt: Statement,
1930        cancel: CancelToken<'_>,
1931    ) -> Result<QueryResult, EngineError> {
1932        cancel.check()?;
1933        // v7.17.0 Phase 1.1 — pre-resolve nextval / currval /
1934        // setval calls in the statement tree. Walks SELECT
1935        // projection, INSERT VALUES, UPDATE SET, DELETE WHERE,
1936        // and DEFAULT exprs; replaces sequence FunctionCall
1937        // nodes with concrete Literal values minted against the
1938        // catalog. This is the only place that mutates sequence
1939        // state from a SELECT-shaped path (exec_select_cancel is
1940        // `&self` and can't reach the catalog mutably).
1941        //
1942        // Fast-path: when no sequences exist anywhere in the
1943        // catalog (the typical hot-path INSERT load), skip the
1944        // walker entirely. Single map-emptiness check on the
1945        // catalog beats walking every expression on every call.
1946        let mut stmt = stmt;
1947        // v7.17 dump-compat — the fast-path check
1948        // `sequences().is_empty()` skips pre-resolve when no
1949        // sequence exists in the *currently active* catalog
1950        // snapshot. The committed catalog or the implicit-TX
1951        // catalog may legitimately disagree on this between
1952        // CREATE SEQUENCE and a later setval(): always run the
1953        // resolver — the walk is O(expr-count) and dwarfed by
1954        // the parse cost we just paid.
1955        self.pre_resolve_sequence_calls_in_statement(&mut stmt)?;
1956        let result = match stmt {
1957            Statement::CreateTable(s) => self.exec_create_table(s),
1958            // v7.9.15 — CREATE EXTENSION is a no-op on SPG. Returns
1959            // CommandOk with affected=0; modified_catalog=false so
1960            // the WAL doesn't grow a useless entry. mailrs F3.
1961            Statement::CreateExtension(_) => Ok(QueryResult::CommandOk {
1962                affected: 0,
1963                modified_catalog: false,
1964            }),
1965            // v7.16.2 — DO $$ ... $$ block. mailrs round-10 A.2
1966            // — the pre-v7.9.27 no-op SILENTLY swallowed every
1967            // mailrs migrate-038/-040/-042 idempotent rename
1968            // (the IF EXISTS … THEN ALTER … END block never
1969            // ran). v7.16.2 dispatches to exec_do_block which
1970            // runs the PlPgSqlBlock at top level via the same
1971            // execute_stmts machinery the trigger executor
1972            // uses (NEW=None, OLD=None — DO blocks have no
1973            // row context).
1974            Statement::DoBlock(body) => self.exec_do_block(body),
1975            // v7.14.0 — empty-statement no-op for pg_dump /
1976            // mysqldump preamble lines that collapse to nothing
1977            // after comment-stripping.
1978            Statement::Empty => Ok(QueryResult::CommandOk {
1979                affected: 0,
1980                modified_catalog: false,
1981            }),
1982            Statement::DropTable { names, if_exists } => self.exec_drop_table(names, if_exists),
1983            Statement::DropIndex { name, if_exists } => self.exec_drop_index(name, if_exists),
1984            Statement::CreateIndex(s) => self.exec_create_index(s),
1985            Statement::Insert(s) => self.exec_insert(s),
1986            Statement::Update(mut s) => {
1987                // Materialise uncorrelated subqueries in SET / WHERE
1988                // before the row walk — the SELECT path has done this
1989                // since v4.10; UPDATE gained it for mailrs's
1990                // `UPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP
1991                // LOCKED)` claim pattern (embed round-12).
1992                for (_, e) in &mut s.assignments {
1993                    self.resolve_expr_subqueries(e, cancel)?;
1994                }
1995                if let Some(w) = &mut s.where_ {
1996                    self.resolve_expr_subqueries(w, cancel)?;
1997                }
1998                self.exec_update_cancel(&s, cancel)
1999            }
2000            Statement::Delete(mut s) => {
2001                if let Some(w) = &mut s.where_ {
2002                    self.resolve_expr_subqueries(w, cancel)?;
2003                }
2004                self.exec_delete_cancel(&s, cancel)
2005            }
2006            Statement::Merge(s) => self.exec_merge_cancel(&s, cancel),
2007            Statement::Select(s) => self.exec_select_cancel(&s, cancel),
2008            Statement::Begin => self.exec_begin(),
2009            Statement::Commit => self.exec_commit(),
2010            Statement::Rollback => self.exec_rollback(),
2011            Statement::Savepoint(name) => self.exec_savepoint(name),
2012            Statement::RollbackToSavepoint(name) => self.exec_rollback_to_savepoint(&name),
2013            Statement::ReleaseSavepoint(name) => self.exec_release_savepoint(&name),
2014            Statement::ShowTables => Ok(self.exec_show_tables()),
2015            Statement::ShowDatabases => Ok(self.exec_show_databases()),
2016            Statement::ShowCreateTable(name) => self.exec_show_create_table(&name),
2017            Statement::ShowIndexes(name) => self.exec_show_indexes(&name),
2018            Statement::ShowStatus => Ok(self.exec_show_status()),
2019            Statement::ShowVariables => Ok(self.exec_show_variables()),
2020            Statement::ShowProcesslist => Ok(self.exec_show_processlist()),
2021            Statement::ShowColumns(table) => self.exec_show_columns(&table),
2022            Statement::ShowUsers => Ok(self.exec_show_users()),
2023            Statement::ShowPublications => Ok(self.exec_show_publications()),
2024            Statement::ShowSubscriptions => Ok(self.exec_show_subscriptions()),
2025            Statement::CreateUser(s) => self.exec_create_user(&s),
2026            Statement::DropUser(name) => self.exec_drop_user(&name),
2027            Statement::Explain(e) => self.exec_explain(&e, cancel),
2028            Statement::AlterIndex(s) => self.exec_alter_index(s),
2029            Statement::AlterTable(s) => self.exec_alter_table(s),
2030            Statement::CreatePublication(s) => self.exec_create_publication(s),
2031            Statement::DropPublication(name) => self.exec_drop_publication(&name),
2032            Statement::CreateSubscription(s) => self.exec_create_subscription(s),
2033            Statement::DropSubscription(name) => self.exec_drop_subscription(&name),
2034            // v6.1.7 — WAIT FOR WAL POSITION needs `lag_state`,
2035            // which lives in spg-server's ServerState. The engine
2036            // surfaces a clear error; the server-layer dispatch
2037            // intercepts the SQL before it reaches the engine on
2038            // a server build, so this arm only fires for
2039            // engine-only callers (spg-embedded, lib tests).
2040            Statement::WaitForWalPosition { .. } => Err(EngineError::Unsupported(
2041                "WAIT FOR WAL POSITION must be handled by the server layer".into(),
2042            )),
2043            // v6.2.0 — ANALYZE recomputes per-column histograms.
2044            Statement::Analyze(target) => self.exec_analyze(target.as_deref()),
2045            // v6.7.3 — COMPACT COLD SEGMENTS.
2046            Statement::CompactColdSegments => self.exec_compact_cold_segments(),
2047            // v7.12.1 — SET / RESET session parameter. Engine
2048            // tracks the value in `session_params`; FTS dispatcher
2049            // reads `default_text_search_config`. Everything else
2050            // is a recorded no-op (PG dump compat).
2051            Statement::SetParameter { name, value } => {
2052                self.set_session_param(name, value);
2053                Ok(QueryResult::CommandOk {
2054                    affected: 0,
2055                    modified_catalog: false,
2056                })
2057            }
2058            // v7.14.0 — MySQL multi-assignment SET. Each pair runs
2059            // through `set_session_param` so engine-known params
2060            // (FOREIGN_KEY_CHECKS, session_replication_role, …) take
2061            // effect; unknown pairs (including `@VAR` LHS from the
2062            // mysqldump preamble) are recorded then ignored.
2063            Statement::SetParameterList(pairs) => {
2064                for (name, value) in pairs {
2065                    self.set_session_param(name, value);
2066                }
2067                Ok(QueryResult::CommandOk {
2068                    affected: 0,
2069                    modified_catalog: false,
2070                })
2071            }
2072            // v7.12.4 — CREATE FUNCTION / CREATE TRIGGER / DROP …
2073            // for the PL/pgSQL trigger surface. exec_* methods are
2074            // defined alongside the existing CREATE handlers below.
2075            Statement::CreateFunction(s) => self.exec_create_function(s),
2076            Statement::CreateTrigger(s) => self.exec_create_trigger(s),
2077            Statement::DropTrigger {
2078                name,
2079                table,
2080                if_exists,
2081            } => self.exec_drop_trigger(&name, &table, if_exists),
2082            Statement::DropFunction { name, if_exists } => {
2083                self.exec_drop_function(&name, if_exists)
2084            }
2085            Statement::CreateSequence(s) => self.exec_create_sequence(s),
2086            Statement::AlterSequence(s) => self.exec_alter_sequence(s),
2087            Statement::DropSequence { names, if_exists } => {
2088                self.exec_drop_sequence(&names, if_exists)
2089            }
2090            Statement::CreateView(s) => self.exec_create_view(s),
2091            Statement::DropView { names, if_exists } => self.exec_drop_view(&names, if_exists),
2092            Statement::CreateMaterializedView(s) => self.exec_create_materialized_view(s),
2093            Statement::RefreshMaterializedView { name, with_data } => {
2094                self.exec_refresh_materialized_view(&name, with_data)
2095            }
2096            Statement::DropMaterializedView { names, if_exists } => {
2097                self.exec_drop_materialized_view(&names, if_exists)
2098            }
2099            Statement::CreateType(s) => self.exec_create_type(s),
2100            Statement::DropType { names, if_exists } => self.exec_drop_type(&names, if_exists),
2101            Statement::CreateDomain(s) => self.exec_create_domain(s),
2102            Statement::DropDomain { names, if_exists } => self.exec_drop_domain(&names, if_exists),
2103            Statement::CreateSchema {
2104                name,
2105                if_not_exists,
2106            } => self.exec_create_schema(name, if_not_exists),
2107            Statement::DropSchema { names, if_exists } => self.exec_drop_schema(&names, if_exists),
2108            Statement::ResetParameter(target) => {
2109                match target {
2110                    None => self.session_params.clear(),
2111                    Some(name) => {
2112                        self.session_params.remove(&name.to_ascii_lowercase());
2113                    }
2114                }
2115                Ok(QueryResult::CommandOk {
2116                    affected: 0,
2117                    modified_catalog: false,
2118                })
2119            }
2120        };
2121        self.enforce_row_limit(result)
2122    }
2123
2124    /// v6.1.2 — `CREATE PUBLICATION` runtime path. Duplicate names
2125    /// surface as `EngineError::Unsupported` so the existing PG-wire
2126    /// error mapping stays uniform; the message carries the name so
2127    /// operators can grep replication-log noise. Inside-transaction
2128    /// invocation is rejected (matches `CREATE USER` / `DROP USER`
2129    /// stance) — replication-catalog mutation is a connection-level
2130    /// administrative op, not a transactional one.
2131    fn exec_create_publication(
2132        &mut self,
2133        s: CreatePublicationStatement,
2134    ) -> Result<QueryResult, EngineError> {
2135        // v6.1.4 — the v6.1.2 "no DDL inside a transaction" guard
2136        // was over-cautious: it also blocked the auto-commit wrap
2137        // path (which begins an internal TX around every WAL-
2138        // logged statement). PG itself allows CREATE PUBLICATION
2139        // inside a transaction (it rolls back with the TX).
2140        self.publications
2141            .create(s.name, s.scope)
2142            .map_err(|e| EngineError::Unsupported(alloc::format!("CREATE PUBLICATION: {e:?}")))?;
2143        Ok(QueryResult::CommandOk {
2144            affected: 1,
2145            modified_catalog: true,
2146        })
2147    }
2148
2149    /// v6.1.2 — `DROP PUBLICATION` runtime path. PG-compatible silent
2150    /// no-op when the publication doesn't exist (returns `affected=0`
2151    /// in that case so the wire-level command tag distinguishes
2152    /// "dropped" from "no-op", though both succeed).
2153    fn exec_drop_publication(&mut self, name: &str) -> Result<QueryResult, EngineError> {
2154        let removed = self.publications.drop(name);
2155        Ok(QueryResult::CommandOk {
2156            affected: usize::from(removed),
2157            modified_catalog: removed,
2158        })
2159    }
2160
2161    /// v6.1.2 — read access to the publication catalog. Used by
2162    /// the v6.1.5 publisher-side WAL filter, by `SHOW PUBLICATIONS`
2163    /// (v6.1.3+), and by e2e tests that need to assert state without
2164    /// going through the wire.
2165    pub const fn publications(&self) -> &publications::Publications {
2166        &self.publications
2167    }
2168
2169    /// v6.1.4 — `CREATE SUBSCRIPTION` runtime path. Defaults
2170    /// `enabled = true` and `last_received_pos = 0` for a freshly-
2171    /// created subscription. The actual worker thread is spawned
2172    /// by spg-server once the engine returns success.
2173    fn exec_create_subscription(
2174        &mut self,
2175        s: CreateSubscriptionStatement,
2176    ) -> Result<QueryResult, EngineError> {
2177        // See exec_create_publication — the in_transaction gate
2178        // was over-cautious; the auto-commit wrap path holds an
2179        // internal TX that this check was incorrectly blocking.
2180        let sub = subscriptions::Subscription {
2181            conn_str: s.conn_str,
2182            publications: s.publications,
2183            enabled: true,
2184            last_received_pos: 0,
2185        };
2186        self.subscriptions
2187            .create(s.name, sub)
2188            .map_err(|e| EngineError::Unsupported(alloc::format!("CREATE SUBSCRIPTION: {e:?}")))?;
2189        Ok(QueryResult::CommandOk {
2190            affected: 1,
2191            modified_catalog: true,
2192        })
2193    }
2194
2195    /// v6.1.4 — `DROP SUBSCRIPTION`. Silent no-op when the name
2196    /// doesn't exist (PG-compatible). The associated worker is
2197    /// torn down by spg-server when it observes the catalog
2198    /// change at the next snapshot or via the engine's
2199    /// subscriptions accessor (the worker polls the catalog on
2200    /// reconnect; v6.1.5's filter-side will tighten this to an
2201    /// explicit signal).
2202    fn exec_drop_subscription(&mut self, name: &str) -> Result<QueryResult, EngineError> {
2203        let removed = self.subscriptions.drop(name);
2204        Ok(QueryResult::CommandOk {
2205            affected: usize::from(removed),
2206            modified_catalog: removed,
2207        })
2208    }
2209
2210    /// v6.1.4 — read access to the subscription catalog. Used by
2211    /// the subscription worker (read its own row to find its
2212    /// publications + last applied position), by SHOW SUBSCRIPTIONS,
2213    /// and by e2e tests asserting state directly.
2214    pub const fn subscriptions(&self) -> &subscriptions::Subscriptions {
2215        &self.subscriptions
2216    }
2217
2218    /// v6.1.4 — write access to `last_received_pos`. Worker
2219    /// calls this after each apply batch (under the engine's
2220    /// write-lock). Returns `false` when the subscription was
2221    /// dropped between when the worker received the record and
2222    /// when this call landed.
2223    pub fn subscription_advance(&mut self, name: &str, pos: u64) -> bool {
2224        self.subscriptions.update_last_received_pos(name, pos)
2225    }
2226
2227    /// v6.1.4 — `SHOW SUBSCRIPTIONS` row materialisation. Returns
2228    /// `(name, conn_str, publications, enabled, last_received_pos)`
2229    /// ordered by subscription name. The `publications` column is
2230    /// the comma-joined list ("p1, p2") for ergonomic SHOW output;
2231    /// callers wanting structured access read `Engine::subscriptions`.
2232    fn exec_show_subscriptions(&self) -> QueryResult {
2233        let columns = alloc::vec![
2234            ColumnSchema::new("name", DataType::Text, false),
2235            ColumnSchema::new("conn_str", DataType::Text, false),
2236            ColumnSchema::new("publications", DataType::Text, false),
2237            ColumnSchema::new("enabled", DataType::Bool, false),
2238            ColumnSchema::new("last_received_pos", DataType::BigInt, false),
2239        ];
2240        let rows: Vec<Row> = self
2241            .subscriptions
2242            .iter()
2243            .map(|(name, sub)| {
2244                Row::new(alloc::vec![
2245                    Value::Text(name.clone()),
2246                    Value::Text(sub.conn_str.clone()),
2247                    Value::Text(sub.publications.join(", ")),
2248                    Value::Bool(sub.enabled),
2249                    Value::BigInt(i64::try_from(sub.last_received_pos).unwrap_or(i64::MAX)),
2250                ])
2251            })
2252            .collect();
2253        QueryResult::Rows { columns, rows }
2254    }
2255
2256    /// v6.2.0 — materialise `spg_statistic` rows. One row per
2257    /// `(table, column)` pair tracked in `Statistics`, with
2258    /// `histogram_bounds` rendered as a `[v0, v1, ...]` string —
2259    /// the same canonical form vector literals use for round-trip.
2260    fn exec_spg_statistic(&self) -> QueryResult {
2261        let columns = alloc::vec![
2262            ColumnSchema::new("table_name", DataType::Text, false),
2263            ColumnSchema::new("column_name", DataType::Text, false),
2264            ColumnSchema::new("null_frac", DataType::Float, false),
2265            ColumnSchema::new("n_distinct", DataType::BigInt, false),
2266            ColumnSchema::new("histogram_bounds", DataType::Text, false),
2267            // v6.7.0 — appended column (v6.2.0 stability contract
2268            // allows APPEND to spg_statistic, not reorder/rename).
2269            // Reports the cached per-table cold-row count; same
2270            // value across every column row of the same table.
2271            ColumnSchema::new("cold_row_count", DataType::BigInt, false),
2272        ];
2273        let rows: Vec<Row> = self
2274            .statistics
2275            .iter()
2276            .map(|((t, c), s)| {
2277                let cold = self
2278                    .catalog
2279                    .get(t)
2280                    .map_or(0, |table| table.cold_row_count());
2281                Row::new(alloc::vec![
2282                    Value::Text(t.clone()),
2283                    Value::Text(c.clone()),
2284                    Value::Float(f64::from(s.null_frac)),
2285                    Value::BigInt(i64::try_from(s.n_distinct).unwrap_or(i64::MAX)),
2286                    Value::Text(render_histogram_bounds(&s.histogram_bounds)),
2287                    Value::BigInt(i64::try_from(cold).unwrap_or(i64::MAX)),
2288                ])
2289            })
2290            .collect();
2291        QueryResult::Rows { columns, rows }
2292    }
2293
2294    /// v6.5.0 — materialise `spg_stat_replication` rows. One row
2295    /// per subscription with `(name, conn_str, publications,
2296    /// last_received_pos, enabled)`. Surface mirrors
2297    /// `SHOW SUBSCRIPTIONS` but follows the virtual-table dispatch
2298    /// shape so it composes with SELECT clauses (WHERE, projection
2299    /// onto specific columns, etc).
2300    fn exec_spg_stat_replication(&self) -> QueryResult {
2301        let columns = alloc::vec![
2302            ColumnSchema::new("name", DataType::Text, false),
2303            ColumnSchema::new("conn_str", DataType::Text, false),
2304            ColumnSchema::new("publications", DataType::Text, false),
2305            ColumnSchema::new("last_received_pos", DataType::BigInt, false),
2306            ColumnSchema::new("enabled", DataType::Bool, false),
2307        ];
2308        let rows: Vec<Row> = self
2309            .subscriptions
2310            .iter()
2311            .map(|(name, sub)| {
2312                Row::new(alloc::vec![
2313                    Value::Text(name.clone()),
2314                    Value::Text(sub.conn_str.clone()),
2315                    Value::Text(sub.publications.join(",")),
2316                    Value::BigInt(i64::try_from(sub.last_received_pos).unwrap_or(i64::MAX)),
2317                    Value::Bool(sub.enabled),
2318                ])
2319            })
2320            .collect();
2321        QueryResult::Rows { columns, rows }
2322    }
2323
2324    /// v6.5.0 — materialise `spg_stat_segment` rows. One row per
2325    /// cold-tier segment with `(segment_id, num_rows, num_pages,
2326    /// total_bytes)`.
2327    ///
2328    /// v6.7.0 — appended `table_name` column resolves the v6.5.0
2329    /// carve-out. Walks every user table's BTree indices to find
2330    /// which table's Cold locators point at each segment. Empty
2331    /// string for orphan segments (loaded via SPG_PRELOAD_COLD_SEGMENT
2332    /// before any index registered a locator). The walk is
2333    /// O(tables × indices × keys); cached per call, not across
2334    /// calls — re-walked on every `SELECT * FROM spg_stat_segment`.
2335    fn exec_spg_stat_segment(&self) -> QueryResult {
2336        let columns = alloc::vec![
2337            ColumnSchema::new("segment_id", DataType::BigInt, false),
2338            ColumnSchema::new("table_name", DataType::Text, false),
2339            ColumnSchema::new("num_rows", DataType::BigInt, false),
2340            ColumnSchema::new("num_pages", DataType::BigInt, false),
2341            ColumnSchema::new("total_bytes", DataType::BigInt, false),
2342        ];
2343        // v6.7.0 — build a segment_id → table_name map by walking
2344        // every user table's BTree indices once. O(tables × indices
2345        // × keys) for the v6.5.0 carve-out resolution; acceptable
2346        // because spg_stat_segment is operator-facing (not on a
2347        // hot-loop path).
2348        let mut segment_owners: alloc::collections::BTreeMap<u32, String> = BTreeMap::new();
2349        for tname in self.catalog.table_names() {
2350            if is_internal_table_name(&tname) {
2351                continue;
2352            }
2353            let Some(t) = self.catalog.get(&tname) else {
2354                continue;
2355            };
2356            for idx in t.indices() {
2357                if let spg_storage::IndexKind::BTree(map) = &idx.kind {
2358                    for (_, locs) in map.iter() {
2359                        for loc in locs {
2360                            if let spg_storage::RowLocator::Cold { segment_id, .. } = loc {
2361                                segment_owners
2362                                    .entry(*segment_id)
2363                                    .or_insert_with(|| tname.clone());
2364                            }
2365                        }
2366                    }
2367                }
2368            }
2369        }
2370        let rows: Vec<Row> = self
2371            .catalog
2372            .cold_segment_ids_global()
2373            .iter()
2374            .filter_map(|&id| {
2375                let seg = self.catalog.cold_segment(id)?;
2376                let meta = seg.meta();
2377                let owner = segment_owners.get(&id).cloned().unwrap_or_default();
2378                Some(Row::new(alloc::vec![
2379                    Value::BigInt(i64::from(id)),
2380                    Value::Text(owner),
2381                    Value::BigInt(i64::try_from(meta.num_rows).unwrap_or(i64::MAX)),
2382                    Value::BigInt(i64::from(meta.num_pages)),
2383                    Value::BigInt(i64::try_from(meta.total_bytes).unwrap_or(i64::MAX)),
2384                ]))
2385            })
2386            .collect();
2387        QueryResult::Rows { columns, rows }
2388    }
2389
2390    /// v6.5.1 — materialise `spg_stat_query` rows. One row per
2391    /// distinct SQL text recorded since the engine booted, capped
2392    /// at `QUERY_STATS_MAX` (1024). Columns:
2393    ///   sql, exec_count, total_us, mean_us, max_us, last_seen_us
2394    /// mean_us = total_us / exec_count (saturating).
2395    fn exec_spg_stat_query(&self) -> QueryResult {
2396        let columns = alloc::vec![
2397            ColumnSchema::new("sql", DataType::Text, false),
2398            ColumnSchema::new("exec_count", DataType::BigInt, false),
2399            ColumnSchema::new("total_us", DataType::BigInt, false),
2400            ColumnSchema::new("mean_us", DataType::BigInt, false),
2401            ColumnSchema::new("max_us", DataType::BigInt, false),
2402            ColumnSchema::new("last_seen_us", DataType::BigInt, false),
2403        ];
2404        let rows: Vec<Row> = self
2405            .query_stats
2406            .snapshot()
2407            .into_iter()
2408            .map(|(sql, s)| {
2409                let mean = if s.exec_count == 0 {
2410                    0
2411                } else {
2412                    s.total_us / s.exec_count
2413                };
2414                Row::new(alloc::vec![
2415                    Value::Text(sql),
2416                    Value::BigInt(i64::try_from(s.exec_count).unwrap_or(i64::MAX)),
2417                    Value::BigInt(i64::try_from(s.total_us).unwrap_or(i64::MAX)),
2418                    Value::BigInt(i64::try_from(mean).unwrap_or(i64::MAX)),
2419                    Value::BigInt(i64::try_from(s.max_us).unwrap_or(i64::MAX)),
2420                    Value::BigInt(i64::try_from(s.last_seen_us).unwrap_or(i64::MAX)),
2421                ])
2422            })
2423            .collect();
2424        QueryResult::Rows { columns, rows }
2425    }
2426
2427    /// v6.5.2 — register a connection-state provider. spg-server
2428    /// calls this at startup with a function that snapshots its
2429    /// per-pgwire-connection registry. Engine reads through the
2430    /// callback on `SELECT * FROM spg_stat_activity`.
2431    #[must_use]
2432    pub const fn with_activity_provider(mut self, f: ActivityProvider) -> Self {
2433        self.activity_provider = Some(f);
2434        self
2435    }
2436
2437    /// v6.5.3 — register audit chain provider + verifier.
2438    #[must_use]
2439    pub const fn with_audit_providers(
2440        mut self,
2441        chain: AuditChainProvider,
2442        verify: AuditVerifier,
2443    ) -> Self {
2444        self.audit_chain_provider = Some(chain);
2445        self.audit_verifier = Some(verify);
2446        self
2447    }
2448
2449    /// v6.5.6 — register a slow-query log callback. `threshold_us`
2450    /// is the floor (in microseconds); only executes above the floor
2451    /// fire the callback. spg-server wires this from
2452    /// `SPG_SLOW_QUERY_THRESHOLD_MS` (default 100 ms).
2453    #[must_use]
2454    pub const fn with_slow_query_log(mut self, threshold_us: u64, logger: SlowQueryLogger) -> Self {
2455        self.slow_query_threshold_us = Some(threshold_us);
2456        self.slow_query_logger = Some(logger);
2457        self
2458    }
2459
2460    /// v6.5.6 — operator knob for plan cache cap. spg-server reads
2461    /// `SPG_PLAN_CACHE_MAX` env at startup; uses this to override
2462    /// the compile-time default of 256.
2463    pub fn set_plan_cache_max(&mut self, n: usize) {
2464        self.plan_cache.set_max_entries(n);
2465    }
2466
2467    /// v6.5.2 — materialise `spg_stat_activity` rows. Pulls a fresh
2468    /// snapshot from the registered `ActivityProvider`. Returns an
2469    /// empty result set when no provider is registered (the no_std
2470    /// embedded path with no pgwire layer).
2471    fn exec_spg_stat_activity(&self) -> QueryResult {
2472        let columns = alloc::vec![
2473            ColumnSchema::new("pid", DataType::Int, false),
2474            ColumnSchema::new("user", DataType::Text, false),
2475            ColumnSchema::new("started_at_us", DataType::BigInt, false),
2476            ColumnSchema::new("current_sql", DataType::Text, false),
2477            ColumnSchema::new("wait_event", DataType::Text, false),
2478            ColumnSchema::new("elapsed_us", DataType::BigInt, false),
2479            ColumnSchema::new("in_transaction", DataType::Bool, false),
2480            ColumnSchema::new("application_name", DataType::Text, false),
2481        ];
2482        let rows: Vec<Row> = self
2483            .activity_provider
2484            .map(|f| f())
2485            .unwrap_or_default()
2486            .into_iter()
2487            .map(|r| {
2488                Row::new(alloc::vec![
2489                    Value::Int(i32::try_from(r.pid).unwrap_or(i32::MAX)),
2490                    Value::Text(r.user),
2491                    Value::BigInt(r.started_at_us),
2492                    Value::Text(r.current_sql),
2493                    Value::Text(r.wait_event),
2494                    Value::BigInt(r.elapsed_us),
2495                    Value::Bool(r.in_transaction),
2496                    Value::Text(r.application_name),
2497                ])
2498            })
2499            .collect();
2500        QueryResult::Rows { columns, rows }
2501    }
2502
2503    /// v6.5.4 — materialise `spg_table_ddl` rows. One row per user
2504    /// table with `(table_name, ddl)`. Reconstructed from catalog
2505    /// state on demand.
2506    fn exec_spg_table_ddl(&self) -> QueryResult {
2507        let columns = alloc::vec![
2508            ColumnSchema::new("table_name", DataType::Text, false),
2509            ColumnSchema::new("ddl", DataType::Text, false),
2510        ];
2511        let rows: Vec<Row> = self
2512            .catalog
2513            .table_names()
2514            .into_iter()
2515            .filter(|n| !is_internal_table_name(n))
2516            .filter_map(|name| {
2517                let table = self.catalog.get(&name)?;
2518                let ddl = render_create_table(&name, &table.schema().columns);
2519                Some(Row::new(alloc::vec![Value::Text(name), Value::Text(ddl),]))
2520            })
2521            .collect();
2522        QueryResult::Rows { columns, rows }
2523    }
2524
2525    /// v6.5.4 — materialise `spg_role_ddl` rows. One row per user
2526    /// with `(role_name, ddl)`. Password is redacted (matches the
2527    /// `Statement::CreateUser` Display which prints `'<redacted>'`).
2528    fn exec_spg_role_ddl(&self) -> QueryResult {
2529        let columns = alloc::vec![
2530            ColumnSchema::new("role_name", DataType::Text, false),
2531            ColumnSchema::new("ddl", DataType::Text, false),
2532        ];
2533        let rows: Vec<Row> = self
2534            .users
2535            .iter()
2536            .map(|(name, rec)| {
2537                let ddl = alloc::format!(
2538                    "CREATE USER {name} WITH PASSWORD '<redacted>' ROLE '{}'",
2539                    rec.role.as_str(),
2540                );
2541                Row::new(alloc::vec![
2542                    Value::Text(String::from(name)),
2543                    Value::Text(ddl)
2544                ])
2545            })
2546            .collect();
2547        QueryResult::Rows { columns, rows }
2548    }
2549
2550    /// v6.5.4 — materialise `spg_database_ddl`: single row whose
2551    /// `ddl` column concatenates every user table's CREATE +
2552    /// every role's CREATE in deterministic catalog order. Suitable
2553    /// for piping back through `Engine::execute` to recreate a
2554    /// schema-equivalent database.
2555    fn exec_spg_database_ddl(&self) -> QueryResult {
2556        let columns = alloc::vec![ColumnSchema::new("ddl", DataType::Text, false)];
2557        let mut out = String::new();
2558        for (name, rec) in self.users.iter() {
2559            out.push_str(&alloc::format!(
2560                "CREATE USER {name} WITH PASSWORD '<redacted>' ROLE '{}';\n",
2561                rec.role.as_str(),
2562            ));
2563        }
2564        for name in self.catalog.table_names() {
2565            if is_internal_table_name(&name) {
2566                continue;
2567            }
2568            if let Some(table) = self.catalog.get(&name) {
2569                out.push_str(&render_create_table(&name, &table.schema().columns));
2570                out.push_str(";\n");
2571            }
2572        }
2573        QueryResult::Rows {
2574            columns,
2575            rows: alloc::vec![Row::new(alloc::vec![Value::Text(out)])],
2576        }
2577    }
2578
2579    /// v6.5.3 — materialise `spg_audit_chain` rows. Pulls a fresh
2580    /// snapshot from the registered provider; empty when no
2581    /// provider is set.
2582    fn exec_spg_audit_chain(&self) -> QueryResult {
2583        let columns = alloc::vec![
2584            ColumnSchema::new("seq", DataType::BigInt, false),
2585            ColumnSchema::new("ts_ms", DataType::BigInt, false),
2586            ColumnSchema::new("prev_hash", DataType::Text, false),
2587            ColumnSchema::new("entry_hash", DataType::Text, false),
2588            ColumnSchema::new("sql", DataType::Text, false),
2589        ];
2590        let rows: Vec<Row> = self
2591            .audit_chain_provider
2592            .map(|f| f())
2593            .unwrap_or_default()
2594            .into_iter()
2595            .map(|r| {
2596                Row::new(alloc::vec![
2597                    Value::BigInt(r.seq),
2598                    Value::BigInt(r.ts_ms),
2599                    Value::Text(r.prev_hash_hex),
2600                    Value::Text(r.entry_hash_hex),
2601                    Value::Text(r.sql),
2602                ])
2603            })
2604            .collect();
2605        QueryResult::Rows { columns, rows }
2606    }
2607
2608    /// v6.5.3 — materialise `spg_audit_verify` single-row result.
2609    /// `(verified_count, broken_at_seq)` — broken_at_seq is `-1`
2610    /// on a clean chain. Returns one row with both values 0 when
2611    /// no verifier is registered (no-data fallback for embedded
2612    /// callers).
2613    fn exec_spg_audit_verify(&self) -> QueryResult {
2614        let columns = alloc::vec![
2615            ColumnSchema::new("verified_count", DataType::BigInt, false),
2616            ColumnSchema::new("broken_at_seq", DataType::BigInt, false),
2617        ];
2618        let (verified, broken) = self.audit_verifier.map(|f| f()).unwrap_or((0, -1));
2619        let row = Row::new(alloc::vec![Value::BigInt(verified), Value::BigInt(broken),]);
2620        QueryResult::Rows {
2621            columns,
2622            rows: alloc::vec![row],
2623        }
2624    }
2625
2626    /// v6.5.1 — read-only accessor for tests + v6.5.6 ops resets.
2627    pub fn query_stats(&self) -> &query_stats::QueryStats {
2628        &self.query_stats
2629    }
2630
2631    /// v6.5.1 — mutable accessor (clear, etc).
2632    pub fn query_stats_mut(&mut self) -> &mut query_stats::QueryStats {
2633        &mut self.query_stats
2634    }
2635
2636    /// v6.2.0 — read access to the per-column statistics table.
2637    /// Used by the planner (v6.2.2 selectivity functions read this),
2638    /// by `SELECT * FROM spg_statistic`, and by e2e tests.
2639    pub const fn statistics(&self) -> &statistics::Statistics {
2640        &self.statistics
2641    }
2642
2643    /// v6.2.1 — return tables whose modified-row count crossed the
2644    /// auto-analyze threshold since the last ANALYZE on that table.
2645    /// The threshold is `0.1 × max(row_count, MIN_ROWS_FOR_AUTO_
2646    /// ANALYZE)` — combines PG-style fractional + absolute lower
2647    /// bound so a fresh / tiny table doesn't get hammered on every
2648    /// INSERT.
2649    ///
2650    /// Designed to be cheap: walks every user table's
2651    /// `Catalog::table_names()` + reads `statistics::modified_
2652    /// since_last_analyze()` (BTreeMap lookup). The background
2653    /// worker calls this under `engine.read()` then drops the lock
2654    /// before re-acquiring `engine.write()` for the actual ANALYZE.
2655    pub fn tables_needing_analyze(&self) -> Vec<String> {
2656        const MIN_ROWS: u64 = 100;
2657        let mut out = Vec::new();
2658        for name in self.catalog.table_names() {
2659            if is_internal_table_name(&name) {
2660                continue;
2661            }
2662            let Some(table) = self.catalog.get(&name) else {
2663                continue;
2664            };
2665            let row_count = table.rows().len() as u64;
2666            let modified = self.statistics.modified_since_last_analyze(&name);
2667            // Threshold: ceil(0.1 × max(row_count, MIN_ROWS)),
2668            // computed in integer arithmetic so spg-engine stays
2669            // no_std without pulling in libm. `(n + 9) / 10` is
2670            // `ceil(n / 10)` for non-negative `n`.
2671            let base = row_count.max(MIN_ROWS);
2672            let threshold = base.saturating_add(9) / 10;
2673            if modified >= threshold {
2674                out.push(name);
2675            }
2676        }
2677        out
2678    }
2679
2680    /// v6.2.0 — `ANALYZE [<table>]` runtime. Bare `ANALYZE` walks
2681    /// every user table; `ANALYZE <name>` re-stats one. For each
2682    /// target table, single-pass scan + per-column histogram +
2683    /// `null_frac` + `n_distinct`. Replaces the table's prior
2684    /// stats; resets the modified-row counter.
2685    ///
2686    /// v6.2.0 doesn't sample — it scans the full table. v6.2.x
2687    /// can add reservoir sampling at the > 100 K-row mark; not a
2688    /// scope blocker for the current commit since rows ≤ 100 K
2689    /// analyse in milliseconds.
2690    fn exec_analyze(&mut self, target: Option<&str>) -> Result<QueryResult, EngineError> {
2691        let names: Vec<String> = if let Some(name) = target {
2692            // Verify the table exists; surface a clear error if not.
2693            if self.catalog.get(name).is_none() {
2694                return Err(EngineError::Storage(StorageError::TableNotFound {
2695                    name: name.to_string(),
2696                }));
2697            }
2698            alloc::vec![name.to_string()]
2699        } else {
2700            self.catalog
2701                .table_names()
2702                .into_iter()
2703                .filter(|n| !is_internal_table_name(n))
2704                .collect()
2705        };
2706        let mut analysed = 0usize;
2707        for table_name in &names {
2708            self.analyze_one_table(table_name)?;
2709            analysed += 1;
2710        }
2711        // v6.3.1 — plan cache invalidation. Bump stats version so
2712        // future lookups see the new generation, and selectively
2713        // evict every plan whose `source_tables` overlap with the
2714        // ANALYZE target set. Bare ANALYZE (all tables) clears the
2715        // whole cache.
2716        if analysed > 0 {
2717            self.statistics.bump_version();
2718            if target.is_some() {
2719                for t in &names {
2720                    self.plan_cache.evict_referencing(t);
2721                }
2722            } else {
2723                self.plan_cache.clear();
2724            }
2725        }
2726        Ok(QueryResult::CommandOk {
2727            affected: analysed,
2728            modified_catalog: true,
2729        })
2730    }
2731
2732    /// v6.7.3 — `COMPACT COLD SEGMENTS` runtime path. Drives the
2733    /// engine-layer compaction shim with the default
2734    /// 4 MiB segment-size threshold. spg-server intercepts the
2735    /// SQL before it reaches the engine on a server build —
2736    /// it reads `SPG_COMPACTION_TARGET_SEGMENT_BYTES`, calls
2737    /// `Engine::compact_cold_segments_with_target` directly with
2738    /// the env value, and persists every merged segment to
2739    /// v7.12.1 — record a `SET <name> = <value>` parameter. Names
2740    /// are case-folded to lowercase to match PG; values keep their
2741    /// caller-supplied form so observability paths see what was
2742    /// requested. Only `default_text_search_config` is consulted by
2743    /// the engine today.
2744    fn set_session_param(&mut self, name: String, value: spg_sql::ast::SetValue) {
2745        let normalised = match value {
2746            spg_sql::ast::SetValue::String(s) => s,
2747            spg_sql::ast::SetValue::Ident(s) => s,
2748            spg_sql::ast::SetValue::Number(s) => s,
2749            spg_sql::ast::SetValue::Default => String::new(),
2750        };
2751        let key = name.to_ascii_lowercase();
2752        // v7.14.0 — mysqldump preamble emits
2753        // `SET FOREIGN_KEY_CHECKS=0` so it can CREATE TABLE in any
2754        // order despite cross-table FK references; the closing
2755        // section emits `SET FOREIGN_KEY_CHECKS=1` (or
2756        // `=@OLD_FOREIGN_KEY_CHECKS` which resolves to "ON" in our
2757        // session-variable-aware path). Match both shapes.
2758        // Also accept PG's `session_replication_role = 'replica'`
2759        // which suppresses trigger + FK enforcement during a
2760        // logical replication apply (pg_dump preserves this for
2761        // schema-only mode but it shows up in some restores).
2762        let value_off = matches!(
2763            normalised.to_ascii_lowercase().as_str(),
2764            "0" | "off" | "false"
2765        );
2766        let value_on = matches!(
2767            normalised.to_ascii_lowercase().as_str(),
2768            "1" | "on" | "true"
2769        );
2770        if key == "foreign_key_checks"
2771            || key == "session_replication_role" && normalised.eq_ignore_ascii_case("replica")
2772        {
2773            if value_off || key == "session_replication_role" {
2774                self.foreign_key_checks = false;
2775            } else if value_on
2776                || (key == "session_replication_role" && normalised.eq_ignore_ascii_case("origin"))
2777            {
2778                self.foreign_key_checks = true;
2779                // Drain pending FK queue against the now-complete
2780                // catalog. Errors here surface as the SET reply —
2781                // caller knows enabling checks revealed orphans.
2782                let _ = self.drain_pending_foreign_keys();
2783            }
2784        }
2785        // v7.22 (round-13 T3) — string-literal dialect signals.
2786        // `SET sql_mode = …` is something only MySQL clients and
2787        // mysqldump preambles emit → MySQL escape semantics.
2788        // `SET standard_conforming_strings = on|off` is PG's own
2789        // switch for exactly this behaviour (every pg_dump preamble
2790        // sets it to on). The same SQL text lexes differently per
2791        // dialect, so a flip invalidates the plan cache.
2792        let new_escapes = if key == "sql_mode" {
2793            Some(true)
2794        } else if key == "standard_conforming_strings" {
2795            Some(value_off)
2796        } else {
2797            None
2798        };
2799        if let Some(flag) = new_escapes
2800            && flag != self.backslash_escapes
2801        {
2802            self.backslash_escapes = flag;
2803            self.plan_cache.clear();
2804        }
2805        self.session_params.insert(key, normalised);
2806    }
2807
2808    /// v7.14.0 — resolve every queued FK whose installation was
2809    /// deferred (`SET FOREIGN_KEY_CHECKS=0` window). Called by
2810    /// `set_session_param` when checks flip back on and by the
2811    /// drop-import release gate. Each FK is resolved against the
2812    /// current catalog; remaining missing-parent errors propagate
2813    /// up so the caller knows the import was incomplete.
2814    fn drain_pending_foreign_keys(&mut self) -> Result<(), EngineError> {
2815        let pending = core::mem::take(&mut self.pending_foreign_keys);
2816        for (child, fk) in pending {
2817            // Resolve against the current catalog. Skip silently
2818            // when the child table itself was dropped between
2819            // queue + drain.
2820            let cols_snapshot = match self.active_catalog().get(&child) {
2821                Some(t) => t.schema().columns.clone(),
2822                None => continue,
2823            };
2824            let storage_fk =
2825                resolve_foreign_key(&child, &cols_snapshot, fk, self.active_catalog())?;
2826            let table = self
2827                .active_catalog_mut()
2828                .get_mut(&child)
2829                .expect("checked above");
2830            table.schema_mut().foreign_keys.push(storage_fk);
2831        }
2832        Ok(())
2833    }
2834
2835    /// v7.12.1 — read a session parameter set via `SET`. Used by
2836    /// the FTS function dispatcher to resolve the default config
2837    /// for `to_tsvector(text)` / `plainto_tsquery(text)` etc.
2838    #[must_use]
2839    pub fn session_param(&self, name: &str) -> Option<&str> {
2840        self.session_params
2841            .get(&name.to_ascii_lowercase())
2842            .map(String::as_str)
2843    }
2844
2845    /// v7.12.1 — build an `EvalContext` chained with the session's
2846    /// `default_text_search_config`. Engine-internal callers use
2847    /// this instead of `EvalContext::new` so the FTS function
2848    /// dispatcher sees the SET configuration.
2849    fn ev_ctx<'a>(
2850        &'a self,
2851        columns: &'a [ColumnSchema],
2852        alias: Option<&'a str>,
2853    ) -> EvalContext<'a> {
2854        EvalContext::new(columns, alias)
2855            .with_default_text_search_config(self.session_param("default_text_search_config"))
2856    }
2857
2858    /// `<db>.spg/segments/`. This arm only fires for engine-only
2859    /// callers (spg-embedded, lib tests); in that mode merged
2860    /// segments live in memory and are dropped at process exit.
2861    fn exec_compact_cold_segments(&mut self) -> Result<QueryResult, EngineError> {
2862        let target = COMPACTION_TARGET_DEFAULT_BYTES;
2863        let reports = self.compact_cold_segments_with_target(target)?;
2864        let columns = alloc::vec![
2865            ColumnSchema::new("table_name", DataType::Text, false),
2866            ColumnSchema::new("index_name", DataType::Text, false),
2867            ColumnSchema::new("sources_merged", DataType::BigInt, false),
2868            ColumnSchema::new("merged_segment_id", DataType::BigInt, false),
2869            ColumnSchema::new("merged_rows", DataType::BigInt, false),
2870            ColumnSchema::new("deleted_rows_pruned", DataType::BigInt, false),
2871            ColumnSchema::new("bytes_reclaimed_estimate", DataType::BigInt, false),
2872        ];
2873        let rows: Vec<Row> = reports
2874            .into_iter()
2875            .map(|(tname, iname, report)| {
2876                Row::new(alloc::vec![
2877                    Value::Text(tname),
2878                    Value::Text(iname),
2879                    Value::BigInt(i64::try_from(report.sources.len()).unwrap_or(i64::MAX)),
2880                    Value::BigInt(i64::from(report.merged_segment_id.unwrap_or(0))),
2881                    Value::BigInt(i64::try_from(report.merged_rows).unwrap_or(i64::MAX)),
2882                    Value::BigInt(i64::try_from(report.deleted_rows_pruned).unwrap_or(i64::MAX),),
2883                    Value::BigInt(
2884                        i64::try_from(report.bytes_reclaimed_estimate).unwrap_or(i64::MAX),
2885                    ),
2886                ])
2887            })
2888            .collect();
2889        Ok(QueryResult::Rows { columns, rows })
2890    }
2891
2892    /// Walk a single table's rows once and (re-)populate per-column
2893    /// stats. Drops the existing stats for `table` first so columns
2894    /// that have been DROP-ed between ANALYZEs don't leave stale
2895    /// rows.
2896    fn analyze_one_table(&mut self, table_name: &str) -> Result<(), EngineError> {
2897        let table = self.catalog.get(table_name).ok_or_else(|| {
2898            EngineError::Storage(StorageError::TableNotFound {
2899                name: table_name.to_string(),
2900            })
2901        })?;
2902        let schema = table.schema().clone();
2903        let row_count = table.rows().len();
2904        // For each column, collect (sorted) non-NULL textual values
2905        // + count NULLs; then ask `statistics::build_histogram` to
2906        // produce the 101 bounds and `estimate_n_distinct` the
2907        // distinct count.
2908        self.statistics.clear_table(table_name);
2909        for (col_pos, col_schema) in schema.columns.iter().enumerate() {
2910            // v6.2.0 skip: vector columns have their own stats
2911            // shape (HNSW graph topology). v6.2 deliberation #1.
2912            if matches!(col_schema.ty, DataType::Vector { .. }) {
2913                continue;
2914            }
2915            let mut non_null_values: Vec<Value> = Vec::with_capacity(row_count);
2916            let mut nulls: u64 = 0;
2917            for row in table.rows() {
2918                match row.values.get(col_pos) {
2919                    Some(Value::Null) | None => nulls += 1,
2920                    Some(v) => non_null_values.push(v.clone()),
2921                }
2922            }
2923            // Sort by type-aware ordering (Int as int, Text as
2924            // lex, etc.) so histogram bounds reflect the column's
2925            // natural order — not lexicographic on the string
2926            // representation, which would put "9" after "49".
2927            non_null_values.sort_by(|a, b| sort_values_for_histogram(a, b));
2928            let non_null: Vec<String> = non_null_values.iter().map(canonical_value_repr).collect();
2929            let null_frac = if row_count == 0 {
2930                0.0
2931            } else {
2932                #[allow(clippy::cast_precision_loss)]
2933                let f = nulls as f32 / row_count as f32;
2934                f
2935            };
2936            let n_distinct = statistics::estimate_n_distinct(&non_null);
2937            let histogram_bounds = statistics::build_histogram(&non_null);
2938            self.statistics.set(
2939                table_name.to_string(),
2940                col_schema.name.clone(),
2941                statistics::ColumnStats {
2942                    null_frac,
2943                    n_distinct,
2944                    histogram_bounds,
2945                },
2946            );
2947        }
2948        self.statistics.reset_modified(table_name);
2949        // v6.7.0 — refresh the per-table cold_rows cache. Walk the
2950        // BTree indices and count Cold locators (MAX across
2951        // indices); store the result on the table. Surfaced via
2952        // `spg_statistic.cold_row_count` (new column) and
2953        // `spg_stat_segment.table_name` (new column).
2954        let cold_count = {
2955            let table = self
2956                .active_catalog()
2957                .get(table_name)
2958                .expect("table still present");
2959            table.count_cold_locators()
2960        };
2961        let table_mut = self
2962            .active_catalog_mut()
2963            .get_mut(table_name)
2964            .expect("table still present");
2965        table_mut.set_cold_row_count(cold_count);
2966        Ok(())
2967    }
2968
2969    /// v6.1.3 — `SHOW PUBLICATIONS` row materialisation. Returns
2970    /// `(name, scope, table_count)` ordered by publication name.
2971    ///   - `scope` is the human-readable string:
2972    ///       `"FOR ALL TABLES"` /
2973    ///       `"FOR TABLE t1, t2"` /
2974    ///       `"FOR ALL TABLES EXCEPT t1, t2"`.
2975    ///   - `table_count` is NULL for `AllTables`, the list length
2976    ///     otherwise. NULLability lets clients distinguish "publish
2977    ///     everything" from "publish exactly 0 tables" (the v6.1.3
2978    ///     parser forbids the empty list, but the column shape is
2979    ///     ready for the v6.1.5 publisher-side semantics).
2980    fn exec_show_publications(&self) -> QueryResult {
2981        let columns = alloc::vec![
2982            ColumnSchema::new("name", DataType::Text, false),
2983            ColumnSchema::new("scope", DataType::Text, false),
2984            ColumnSchema::new("table_count", DataType::Int, true),
2985        ];
2986        let rows: Vec<Row> = self
2987            .publications
2988            .iter()
2989            .map(|(name, scope)| {
2990                let (scope_str, count_val) = match scope {
2991                    spg_sql::ast::PublicationScope::AllTables => {
2992                        ("FOR ALL TABLES".to_string(), Value::Null)
2993                    }
2994                    spg_sql::ast::PublicationScope::ForTables(ts) => (
2995                        alloc::format!("FOR TABLE {}", ts.join(", ")),
2996                        Value::Int(i32::try_from(ts.len()).unwrap_or(i32::MAX)),
2997                    ),
2998                    spg_sql::ast::PublicationScope::AllTablesExcept(ts) => (
2999                        alloc::format!("FOR ALL TABLES EXCEPT {}", ts.join(", ")),
3000                        Value::Int(i32::try_from(ts.len()).unwrap_or(i32::MAX)),
3001                    ),
3002                };
3003                Row::new(alloc::vec![
3004                    Value::Text(name.clone()),
3005                    Value::Text(scope_str),
3006                    count_val,
3007                ])
3008            })
3009            .collect();
3010        QueryResult::Rows { columns, rows }
3011    }
3012
3013    /// v4.1 `SHOW USERS` — `(name, role)` per row, ordered by name.
3014    fn exec_show_users(&self) -> QueryResult {
3015        let columns = alloc::vec![
3016            ColumnSchema::new("name", DataType::Text, false),
3017            ColumnSchema::new("role", DataType::Text, false),
3018        ];
3019        let rows: Vec<Row> = self
3020            .users
3021            .iter()
3022            .map(|(name, rec)| {
3023                Row::new(alloc::vec![
3024                    Value::Text(name.to_string()),
3025                    Value::Text(rec.role.as_str().to_string()),
3026                ])
3027            })
3028            .collect();
3029        QueryResult::Rows { columns, rows }
3030    }
3031
3032    fn exec_create_user(&mut self, s: &CreateUserStatement) -> Result<QueryResult, EngineError> {
3033        if self.in_transaction() {
3034            return Err(EngineError::Unsupported(
3035                "CREATE USER is not allowed inside a transaction".into(),
3036            ));
3037        }
3038        let role = users::Role::parse(&s.role).ok_or_else(|| {
3039            EngineError::Unsupported(alloc::format!("invalid role: {:?}", s.role))
3040        })?;
3041        // Prefer the host-injected RNG. Falls back to a deterministic
3042        // salt derived from the username only when no RNG is wired —
3043        // acceptable for tests; the server always installs one.
3044        let salt = self.salt_fn.map_or_else(
3045            || {
3046                let mut s_bytes = [0u8; 16];
3047                let digest = spg_crypto::hash(s.name.as_bytes());
3048                s_bytes.copy_from_slice(&digest[..16]);
3049                s_bytes
3050            },
3051            |f| f(),
3052        );
3053        self.users
3054            .create(&s.name, &s.password, role, salt)
3055            .map_err(|e| EngineError::Unsupported(alloc::format!("CREATE USER: {e}")))?;
3056        Ok(QueryResult::CommandOk {
3057            affected: 1,
3058            modified_catalog: true,
3059        })
3060    }
3061
3062    fn exec_drop_user(&mut self, name: &str) -> Result<QueryResult, EngineError> {
3063        if self.in_transaction() {
3064            return Err(EngineError::Unsupported(
3065                "DROP USER is not allowed inside a transaction".into(),
3066            ));
3067        }
3068        self.users
3069            .drop(name)
3070            .map_err(|e| EngineError::Unsupported(alloc::format!("DROP USER: {e}")))?;
3071        Ok(QueryResult::CommandOk {
3072            affected: 1,
3073            modified_catalog: true,
3074        })
3075    }
3076
3077    /// v7.12.4 — `CREATE [OR REPLACE] FUNCTION`. Stores the
3078    /// function metadata in the catalog. PL/pgSQL bodies are
3079    /// already parsed by the SQL parser; we re-canonicalise the
3080    /// body to source text for storage (the executor re-parses
3081    /// it at trigger fire time — see the trigger fire path).
3082    fn exec_create_function(
3083        &mut self,
3084        s: spg_sql::ast::CreateFunctionStatement,
3085    ) -> Result<QueryResult, EngineError> {
3086        let args_repr = render_function_args(&s.args);
3087        let returns = match &s.returns {
3088            spg_sql::ast::FunctionReturn::Trigger => alloc::string::String::from("TRIGGER"),
3089            spg_sql::ast::FunctionReturn::Void => alloc::string::String::from("VOID"),
3090            spg_sql::ast::FunctionReturn::Type(t) => alloc::format!("{t}"),
3091            spg_sql::ast::FunctionReturn::Other(s) => s.clone(),
3092        };
3093        let body_text = match &s.body {
3094            spg_sql::ast::FunctionBody::PlPgSql(b) => alloc::format!("{b}"),
3095            spg_sql::ast::FunctionBody::Raw(s) => s.clone(),
3096        };
3097        let def = spg_storage::FunctionDef {
3098            name: s.name.clone(),
3099            args_repr,
3100            returns,
3101            language: s.language.clone(),
3102            body: body_text,
3103        };
3104        self.active_catalog_mut()
3105            .create_function(def, s.or_replace)
3106            .map_err(EngineError::Storage)?;
3107        Ok(QueryResult::CommandOk {
3108            affected: 0,
3109            modified_catalog: true,
3110        })
3111    }
3112
3113    /// v7.12.4 — `CREATE [OR REPLACE] TRIGGER`. The referenced
3114    /// function must already exist in the catalog (forward
3115    /// references defer to a later release). Persists the
3116    /// trigger metadata for the row-write hooks below to consult.
3117    fn exec_create_trigger(
3118        &mut self,
3119        s: spg_sql::ast::CreateTriggerStatement,
3120    ) -> Result<QueryResult, EngineError> {
3121        let timing = match s.timing {
3122            spg_sql::ast::TriggerTiming::Before => "BEFORE",
3123            spg_sql::ast::TriggerTiming::After => "AFTER",
3124            spg_sql::ast::TriggerTiming::InsteadOf => "INSTEAD OF",
3125        };
3126        let events: Vec<alloc::string::String> = s
3127            .events
3128            .iter()
3129            .map(|e| match e {
3130                spg_sql::ast::TriggerEvent::Insert => alloc::string::String::from("INSERT"),
3131                spg_sql::ast::TriggerEvent::Update => alloc::string::String::from("UPDATE"),
3132                spg_sql::ast::TriggerEvent::Delete => alloc::string::String::from("DELETE"),
3133                spg_sql::ast::TriggerEvent::Truncate => alloc::string::String::from("TRUNCATE"),
3134            })
3135            .collect();
3136        let for_each = match s.for_each {
3137            spg_sql::ast::TriggerForEach::Row => "ROW",
3138            spg_sql::ast::TriggerForEach::Statement => "STATEMENT",
3139        };
3140        let def = spg_storage::TriggerDef {
3141            name: s.name.clone(),
3142            table: s.table.clone(),
3143            timing: alloc::string::String::from(timing),
3144            events,
3145            for_each: alloc::string::String::from(for_each),
3146            function: s.function.clone(),
3147            update_columns: s.update_columns.clone(),
3148            // v7.16.1 — every trigger is born enabled. Toggled
3149            // by ALTER TABLE … { ENABLE | DISABLE } TRIGGER.
3150            enabled: true,
3151        };
3152        self.active_catalog_mut()
3153            .create_trigger(def, s.or_replace)
3154            .map_err(EngineError::Storage)?;
3155        Ok(QueryResult::CommandOk {
3156            affected: 0,
3157            modified_catalog: true,
3158        })
3159    }
3160
3161    fn exec_drop_trigger(
3162        &mut self,
3163        name: &str,
3164        table: &str,
3165        if_exists: bool,
3166    ) -> Result<QueryResult, EngineError> {
3167        let removed = self.active_catalog_mut().drop_trigger(name, table);
3168        if !removed && !if_exists {
3169            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3170                alloc::format!("trigger {name:?} on {table:?} does not exist"),
3171            )));
3172        }
3173        Ok(QueryResult::CommandOk {
3174            affected: usize::from(removed),
3175            modified_catalog: removed,
3176        })
3177    }
3178
3179    fn exec_drop_function(
3180        &mut self,
3181        name: &str,
3182        if_exists: bool,
3183    ) -> Result<QueryResult, EngineError> {
3184        let removed = self.active_catalog_mut().drop_function(name);
3185        if !removed && !if_exists {
3186            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3187                alloc::format!("function {name:?} does not exist"),
3188            )));
3189        }
3190        Ok(QueryResult::CommandOk {
3191            affected: usize::from(removed),
3192            modified_catalog: removed,
3193        })
3194    }
3195
3196    /// v7.17.0 — `CREATE SEQUENCE` engine path. Resolves
3197    /// `min_value` / `max_value` / `start` against PG defaults
3198    /// when omitted, then installs the SequenceDef in the catalog.
3199    fn exec_create_sequence(
3200        &mut self,
3201        s: spg_sql::ast::CreateSequenceStatement,
3202    ) -> Result<QueryResult, EngineError> {
3203        use spg_sql::ast::{SeqBound, SequenceDataType as AstDt};
3204        use spg_storage::{SequenceDataType, SequenceDef};
3205        let dt = match s.data_type {
3206            None => SequenceDataType::BigInt,
3207            Some(AstDt::SmallInt) => SequenceDataType::SmallInt,
3208            Some(AstDt::Int) => SequenceDataType::Int,
3209            Some(AstDt::BigInt) => SequenceDataType::BigInt,
3210        };
3211        let increment = s.options.increment.unwrap_or(1);
3212        if increment == 0 {
3213            return Err(EngineError::Unsupported(
3214                "INCREMENT must not be zero".into(),
3215            ));
3216        }
3217        let (def_min, def_max) = dt.default_bounds(increment > 0);
3218        let min_value = match s.options.min_value {
3219            None | Some(SeqBound::NoBound) => def_min,
3220            Some(SeqBound::Value(n)) => n,
3221        };
3222        let max_value = match s.options.max_value {
3223            None | Some(SeqBound::NoBound) => def_max,
3224            Some(SeqBound::Value(n)) => n,
3225        };
3226        if min_value > max_value {
3227            return Err(EngineError::Unsupported(alloc::format!(
3228                "MINVALUE ({min_value}) must be <= MAXVALUE ({max_value})"
3229            )));
3230        }
3231        let start = s
3232            .options
3233            .start
3234            .unwrap_or(if increment > 0 { min_value } else { max_value });
3235        if start < min_value || start > max_value {
3236            return Err(EngineError::Unsupported(alloc::format!(
3237                "START WITH ({start}) is outside MINVALUE..MAXVALUE ({min_value}..{max_value})"
3238            )));
3239        }
3240        let cache = s.options.cache.unwrap_or(1);
3241        if cache < 1 {
3242            return Err(EngineError::Unsupported("CACHE must be >= 1".into()));
3243        }
3244        let cycle = s.options.cycle.unwrap_or(false);
3245        let owned_by = match s.options.owned_by {
3246            None | Some(spg_sql::ast::SequenceOwnedBy::None) => None,
3247            Some(spg_sql::ast::SequenceOwnedBy::Column { table, column }) => Some((table, column)),
3248        };
3249        let def = SequenceDef {
3250            name: s.name.clone(),
3251            data_type: dt,
3252            start,
3253            increment,
3254            min_value,
3255            max_value,
3256            cache,
3257            cycle,
3258            owned_by,
3259            last_value: start,
3260            is_called: false,
3261        };
3262        self.active_catalog_mut()
3263            .create_sequence(def, s.if_not_exists)
3264            .map_err(EngineError::Storage)?;
3265        Ok(QueryResult::CommandOk {
3266            affected: 0,
3267            modified_catalog: !self.in_transaction(),
3268        })
3269    }
3270
3271    /// v7.17.0 — `ALTER SEQUENCE` engine path. Re-uses the catalog
3272    /// `alter_sequence` merge helper.
3273    fn exec_alter_sequence(
3274        &mut self,
3275        s: spg_sql::ast::AlterSequenceStatement,
3276    ) -> Result<QueryResult, EngineError> {
3277        use spg_sql::ast::SeqBound;
3278        let cat = self.active_catalog_mut();
3279        if !cat.sequences().contains_key(&s.name) {
3280            if s.if_exists {
3281                return Ok(QueryResult::CommandOk {
3282                    affected: 0,
3283                    modified_catalog: false,
3284                });
3285            }
3286            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3287                alloc::format!("sequence {:?} does not exist", s.name),
3288            )));
3289        }
3290        let min_value = match s.options.min_value {
3291            None => None,
3292            Some(SeqBound::NoBound) => None, // NO MINVALUE → keep current
3293            Some(SeqBound::Value(n)) => Some(n),
3294        };
3295        let max_value = match s.options.max_value {
3296            None => None,
3297            Some(SeqBound::NoBound) => None,
3298            Some(SeqBound::Value(n)) => Some(n),
3299        };
3300        let owned_by = s.options.owned_by.map(|ob| match ob {
3301            spg_sql::ast::SequenceOwnedBy::None => None,
3302            spg_sql::ast::SequenceOwnedBy::Column { table, column } => Some((table, column)),
3303        });
3304        cat.alter_sequence(
3305            &s.name,
3306            s.options.increment,
3307            min_value,
3308            max_value,
3309            s.options.start,
3310            s.options.restart,
3311            s.options.cache,
3312            s.options.cycle,
3313            owned_by,
3314        )
3315        .map_err(EngineError::Storage)?;
3316        Ok(QueryResult::CommandOk {
3317            affected: 0,
3318            modified_catalog: !self.in_transaction(),
3319        })
3320    }
3321
3322    /// v7.17.0 Phase 1.1 — walk a Statement tree and pre-resolve
3323    /// any sequence FunctionCall nodes inside its Expr slots.
3324    /// Delegates per-statement-kind: SELECT projection +
3325    /// WHERE, INSERT VALUES, UPDATE SET, DELETE WHERE.
3326    fn pre_resolve_sequence_calls_in_statement(
3327        &mut self,
3328        stmt: &mut Statement,
3329    ) -> Result<(), EngineError> {
3330        match stmt {
3331            Statement::Select(s) => self.pre_resolve_sequence_calls_in_select(s),
3332            Statement::Insert(s) => {
3333                for tuple in &mut s.rows {
3334                    for cell in tuple.iter_mut() {
3335                        self.resolve_sequence_calls_in_expr(cell)?;
3336                    }
3337                }
3338                Ok(())
3339            }
3340            Statement::Update(s) => {
3341                for (_col, expr) in &mut s.assignments {
3342                    self.resolve_sequence_calls_in_expr(expr)?;
3343                }
3344                if let Some(w) = &mut s.where_ {
3345                    self.resolve_sequence_calls_in_expr(w)?;
3346                }
3347                Ok(())
3348            }
3349            Statement::Delete(s) => {
3350                if let Some(w) = &mut s.where_ {
3351                    self.resolve_sequence_calls_in_expr(w)?;
3352                }
3353                Ok(())
3354            }
3355            _ => Ok(()),
3356        }
3357    }
3358
3359    fn pre_resolve_sequence_calls_in_select(
3360        &mut self,
3361        s: &mut spg_sql::ast::SelectStatement,
3362    ) -> Result<(), EngineError> {
3363        for item in &mut s.items {
3364            match item {
3365                spg_sql::ast::SelectItem::Expr { expr, .. } => {
3366                    self.resolve_sequence_calls_in_expr(expr)?;
3367                }
3368                spg_sql::ast::SelectItem::Wildcard => {}
3369            }
3370        }
3371        if let Some(w) = &mut s.where_ {
3372            self.resolve_sequence_calls_in_expr(w)?;
3373        }
3374        Ok(())
3375    }
3376
3377    /// v7.17.0 Phase 1.1 — walk an Expr tree and pre-resolve any
3378    /// `nextval(name)` / `currval(name)` / `setval(name, value[,
3379    /// is_called])` FunctionCall nodes by calling the catalog and
3380    /// replacing the node with the resulting `Expr::Literal`.
3381    /// Used by INSERT VALUES / UPDATE SET / DEFAULT eval so the
3382    /// row-eval path sees pre-computed sequence values instead of
3383    /// needing mutable catalog access mid-eval.
3384    #[allow(clippy::too_many_lines)]
3385    fn resolve_sequence_calls_in_expr(&mut self, expr: &mut Expr) -> Result<(), EngineError> {
3386        match expr {
3387            Expr::Literal(_) | Expr::Column(_) | Expr::Placeholder(_) => Ok(()),
3388            Expr::FunctionCall { name, args } => {
3389                // Descend first so nested calls — e.g.
3390                // setval('seq', currval('other')) — resolve
3391                // innermost-first.
3392                for a in args.iter_mut() {
3393                    self.resolve_sequence_calls_in_expr(a)?;
3394                }
3395                let lc = name.to_ascii_lowercase();
3396                if lc == "nextval" || lc == "currval" || lc == "setval" {
3397                    let v = self.eval_sequence_call(&lc, args)?;
3398                    *expr = Expr::Literal(value_to_literal(v));
3399                }
3400                Ok(())
3401            }
3402            Expr::Binary { lhs, rhs, .. } => {
3403                self.resolve_sequence_calls_in_expr(lhs)?;
3404                self.resolve_sequence_calls_in_expr(rhs)
3405            }
3406            Expr::Unary { expr, .. } => self.resolve_sequence_calls_in_expr(expr),
3407            Expr::Cast { expr, .. } => self.resolve_sequence_calls_in_expr(expr),
3408            Expr::IsNull { expr, .. } => self.resolve_sequence_calls_in_expr(expr),
3409            Expr::Like { expr, pattern, .. } => {
3410                self.resolve_sequence_calls_in_expr(expr)?;
3411                self.resolve_sequence_calls_in_expr(pattern)
3412            }
3413            Expr::Extract { source, .. } => self.resolve_sequence_calls_in_expr(source),
3414            Expr::Array(items) => {
3415                for it in items.iter_mut() {
3416                    self.resolve_sequence_calls_in_expr(it)?;
3417                }
3418                Ok(())
3419            }
3420            // Window / subquery / etc — sequence calls inside these
3421            // are uncommon and require separate row-eval; leave
3422            // untouched for now and rely on the eval-time error
3423            // (no sequence_resolver attached).
3424            _ => Ok(()),
3425        }
3426    }
3427
3428    /// v7.17.0 Phase 1.1 — evaluate a single nextval/currval/
3429    /// setval call. `args` are already pre-resolved Expr nodes
3430    /// (literals) — we extract their constant values.
3431    fn eval_sequence_call(&mut self, op: &str, args: &[Expr]) -> Result<Value, EngineError> {
3432        if args.is_empty() {
3433            return Err(EngineError::Unsupported(alloc::format!(
3434                "{op}() takes at least one argument"
3435            )));
3436        }
3437        let seq_name = match &args[0] {
3438            Expr::Literal(spg_sql::ast::Literal::String(s)) => {
3439                // v7.17 dump-compat — pg_dump emits sequence
3440                // names schema-qualified (`'public.posts_id_seq'`).
3441                // SPG is single-schema; strip a leading
3442                // `public.` / `pg_catalog.` so the catalog lookup
3443                // matches the bare-name CREATE SEQUENCE used.
3444                let trimmed = s
3445                    .strip_prefix("public.")
3446                    .or_else(|| s.strip_prefix("pg_catalog."))
3447                    .unwrap_or(s);
3448                trimmed.to_string()
3449            }
3450            // v7.17 dump-compat — pg_dump also emits
3451            // `nextval('public.posts_id_seq'::regclass)`
3452            // where the cast wraps the literal. Peel the cast
3453            // and continue.
3454            Expr::Cast { expr, .. } => {
3455                if let Expr::Literal(spg_sql::ast::Literal::String(s)) = expr.as_ref() {
3456                    let trimmed = s
3457                        .strip_prefix("public.")
3458                        .or_else(|| s.strip_prefix("pg_catalog."))
3459                        .unwrap_or(s);
3460                    trimmed.to_string()
3461                } else {
3462                    return Err(EngineError::Unsupported(alloc::format!(
3463                        "{op}() first argument must be a literal sequence name"
3464                    )));
3465                }
3466            }
3467            other => {
3468                return Err(EngineError::Unsupported(alloc::format!(
3469                    "{op}() first argument must be a literal sequence name, got {other:?}"
3470                )));
3471            }
3472        };
3473        match op {
3474            "nextval" => {
3475                let v = self
3476                    .active_catalog_mut()
3477                    .sequence_next_value(&seq_name)
3478                    .map_err(EngineError::Storage)?;
3479                Ok(Value::BigInt(v))
3480            }
3481            "currval" => {
3482                let v = self
3483                    .active_catalog()
3484                    .sequence_current_value(&seq_name)
3485                    .map_err(EngineError::Storage)?;
3486                Ok(Value::BigInt(v))
3487            }
3488            "setval" => {
3489                if args.len() < 2 || args.len() > 3 {
3490                    return Err(EngineError::Unsupported(alloc::format!(
3491                        "setval() takes 2 or 3 arguments, got {}",
3492                        args.len()
3493                    )));
3494                }
3495                let value = match &args[1] {
3496                    Expr::Literal(spg_sql::ast::Literal::Integer(n)) => *n,
3497                    other => {
3498                        return Err(EngineError::Unsupported(alloc::format!(
3499                            "setval() value argument must be a literal integer, got {other:?}"
3500                        )));
3501                    }
3502                };
3503                let is_called = if args.len() == 3 {
3504                    match &args[2] {
3505                        Expr::Literal(spg_sql::ast::Literal::Bool(b)) => *b,
3506                        other => {
3507                            return Err(EngineError::Unsupported(alloc::format!(
3508                                "setval() is_called argument must be a literal BOOL, got {other:?}"
3509                            )));
3510                        }
3511                    }
3512                } else {
3513                    true
3514                };
3515                let v = self
3516                    .active_catalog_mut()
3517                    .sequence_set_value(&seq_name, value, is_called)
3518                    .map_err(EngineError::Storage)?;
3519                Ok(Value::BigInt(v))
3520            }
3521            other => Err(EngineError::Unsupported(alloc::format!(
3522                "unknown sequence op {other:?}"
3523            ))),
3524        }
3525    }
3526
3527    /// v7.17.0 Phase 1.2 — find every catalog VIEW referenced in
3528    /// the SELECT's FROM / JOIN graph, re-parse each view's body
3529    /// source, and prepend it as a synthetic CTE on the
3530    /// returned SelectStatement. Returns `None` when no view
3531    /// references are found (caller proceeds with the original
3532    /// statement); returns `Some(rewritten)` otherwise (caller
3533    /// re-runs exec_select_cancel on the rewritten form so the
3534    /// regular CTE materialiser handles it).
3535    fn expand_views_in_select(
3536        &self,
3537        stmt: &SelectStatement,
3538    ) -> Result<Option<SelectStatement>, EngineError> {
3539        let cat = self.active_catalog();
3540        let mut referenced: Vec<String> = Vec::new();
3541        if let Some(from) = &stmt.from {
3542            collect_view_refs(&from.primary, cat, &mut referenced);
3543            for j in &from.joins {
3544                collect_view_refs(&j.table, cat, &mut referenced);
3545            }
3546        }
3547        // Don't expand a view name that's already shadowed by a
3548        // CTE on the same SELECT — the CTE wins per PG.
3549        referenced.retain(|n| !stmt.ctes.iter().any(|c| c.name == *n));
3550        if referenced.is_empty() {
3551            return Ok(None);
3552        }
3553        let mut new_ctes: Vec<spg_sql::ast::Cte> = Vec::with_capacity(referenced.len());
3554        for name in &referenced {
3555            let view = cat.views().get(name).ok_or_else(|| {
3556                EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
3557                    "view {name:?} disappeared mid-expansion"
3558                )))
3559            })?;
3560            let parsed = spg_sql::parser::parse_statement(&view.body).map_err(|e| {
3561                EngineError::Unsupported(alloc::format!("view {name:?} body re-parse failed: {e}"))
3562            })?;
3563            let Statement::Select(body) = parsed else {
3564                return Err(EngineError::Unsupported(alloc::format!(
3565                    "view {name:?} body is not a SELECT (catalog corruption)"
3566                )));
3567            };
3568            new_ctes.push(spg_sql::ast::Cte {
3569                name: name.clone(),
3570                body,
3571                recursive: false,
3572                column_overrides: view.columns.clone(),
3573            });
3574        }
3575        let mut out = stmt.clone();
3576        // Prepend so view CTEs are visible to caller-supplied CTEs.
3577        new_ctes.extend(out.ctes);
3578        out.ctes = new_ctes;
3579        Ok(Some(out))
3580    }
3581
3582    /// v7.17.0 Phase 1.2 — `CREATE VIEW` engine path. Stores the
3583    /// Display-rendered body verbatim in the catalog; SELECT-from-
3584    /// view at exec time re-parses + prepends as a synthetic CTE.
3585    fn exec_create_view(
3586        &mut self,
3587        s: spg_sql::ast::CreateViewStatement,
3588    ) -> Result<QueryResult, EngineError> {
3589        // Render the SELECT body to canonical form so the catalog
3590        // round-trips a deterministic source (no whitespace /
3591        // comment surprises in the on-disk snapshot).
3592        let body_repr = alloc::format!("{}", spg_sql::ast::Statement::Select(s.body));
3593        let def = spg_storage::ViewDef {
3594            name: s.name.clone(),
3595            columns: s.columns,
3596            body: body_repr,
3597        };
3598        self.active_catalog_mut()
3599            .create_view(def, s.or_replace, s.if_not_exists)
3600            .map_err(EngineError::Storage)?;
3601        Ok(QueryResult::CommandOk {
3602            affected: 0,
3603            modified_catalog: !self.in_transaction(),
3604        })
3605    }
3606
3607    /// v7.17.0 Phase 1.4 — `CREATE TYPE name AS ENUM (…)` engine
3608    /// path. Registers the enum in the catalog with order-
3609    /// preserving labels. PG semantics: CREATE TYPE errors if the
3610    /// name is taken (no IF NOT EXISTS).
3611    fn exec_create_type(
3612        &mut self,
3613        s: spg_sql::ast::CreateTypeStatement,
3614    ) -> Result<QueryResult, EngineError> {
3615        // Name-collision check against tables / sequences / views /
3616        // materialized views.
3617        let cat = self.active_catalog();
3618        if cat.get(&s.name).is_some() {
3619            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3620                alloc::format!("type {:?} would shadow an existing table", s.name),
3621            )));
3622        }
3623        if cat.sequences().contains_key(&s.name) {
3624            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3625                alloc::format!("type {:?} would shadow an existing sequence", s.name),
3626            )));
3627        }
3628        if cat.views().contains_key(&s.name) {
3629            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3630                alloc::format!("type {:?} would shadow an existing view", s.name),
3631            )));
3632        }
3633        let def = match s.kind {
3634            spg_sql::ast::TypeKind::Enum { labels } => {
3635                if labels.is_empty() {
3636                    return Err(EngineError::Unsupported(
3637                        "CREATE TYPE … AS ENUM requires at least one label".into(),
3638                    ));
3639                }
3640                // Reject duplicate labels per PG.
3641                for i in 0..labels.len() {
3642                    for j in (i + 1)..labels.len() {
3643                        if labels[i] == labels[j] {
3644                            return Err(EngineError::Unsupported(alloc::format!(
3645                                "CREATE TYPE {:?}: duplicate ENUM label {:?}",
3646                                s.name,
3647                                labels[i]
3648                            )));
3649                        }
3650                    }
3651                }
3652                spg_storage::EnumDef {
3653                    name: s.name.clone(),
3654                    labels,
3655                }
3656            }
3657        };
3658        self.active_catalog_mut()
3659            .create_enum_type(def)
3660            .map_err(EngineError::Storage)?;
3661        Ok(QueryResult::CommandOk {
3662            affected: 0,
3663            modified_catalog: !self.in_transaction(),
3664        })
3665    }
3666
3667    /// v7.17.0 Phase 1.5 — `CREATE DOMAIN name AS base [DEFAULT
3668    /// expr] [NOT NULL] [CHECK (expr)]*` engine path. Stores the
3669    /// base type + Display-rendered CHECK / DEFAULT sources so
3670    /// INSERT/UPDATE on bound columns can re-eval the checks.
3671    fn exec_create_domain(
3672        &mut self,
3673        s: spg_sql::ast::CreateDomainStatement,
3674    ) -> Result<QueryResult, EngineError> {
3675        let cat = self.active_catalog();
3676        if cat.domain_types().contains_key(&s.name) {
3677            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3678                alloc::format!("domain {:?} already exists", s.name),
3679            )));
3680        }
3681        if cat.get(&s.name).is_some()
3682            || cat.sequences().contains_key(&s.name)
3683            || cat.views().contains_key(&s.name)
3684            || cat.enum_types().contains_key(&s.name)
3685        {
3686            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3687                alloc::format!("domain {:?} would shadow an existing object", s.name),
3688            )));
3689        }
3690        let base_type = column_type_to_data_type(s.base_type);
3691        let default = s.default.as_ref().map(|e| alloc::format!("{e}"));
3692        let checks = s
3693            .checks
3694            .iter()
3695            .map(|e| alloc::format!("{e}"))
3696            .collect::<Vec<_>>();
3697        let def = spg_storage::DomainDef {
3698            name: s.name.clone(),
3699            base_type,
3700            nullable: !s.not_null,
3701            default,
3702            checks,
3703        };
3704        self.active_catalog_mut()
3705            .create_domain_type(def)
3706            .map_err(EngineError::Storage)?;
3707        Ok(QueryResult::CommandOk {
3708            affected: 0,
3709            modified_catalog: !self.in_transaction(),
3710        })
3711    }
3712
3713    /// v7.17.0 Phase 1.5 — `DROP DOMAIN [IF EXISTS] names`.
3714    fn exec_drop_domain(
3715        &mut self,
3716        names: &[String],
3717        if_exists: bool,
3718    ) -> Result<QueryResult, EngineError> {
3719        let mut removed = 0usize;
3720        for name in names {
3721            let was_present = self.active_catalog_mut().drop_domain_type(name);
3722            if was_present {
3723                removed += 1;
3724            } else if !if_exists {
3725                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3726                    alloc::format!("domain {name:?} does not exist"),
3727                )));
3728            }
3729        }
3730        Ok(QueryResult::CommandOk {
3731            affected: removed,
3732            modified_catalog: removed > 0 && !self.in_transaction(),
3733        })
3734    }
3735
3736    /// v7.17.0 Phase 1.6 — `CREATE SCHEMA [IF NOT EXISTS] name`.
3737    /// Registers the schema in the catalog. Schema-qualified
3738    /// table references continue to strip the prefix at lookup
3739    /// time (prefix routing, not isolation — see project-next-
3740    /// docket for the v7.18+ real-isolation tracking).
3741    fn exec_create_schema(
3742        &mut self,
3743        name: String,
3744        if_not_exists: bool,
3745    ) -> Result<QueryResult, EngineError> {
3746        self.active_catalog_mut()
3747            .create_schema(name, if_not_exists)
3748            .map_err(EngineError::Storage)?;
3749        Ok(QueryResult::CommandOk {
3750            affected: 0,
3751            modified_catalog: !self.in_transaction(),
3752        })
3753    }
3754
3755    /// v7.17.0 Phase 1.6 — `DROP SCHEMA [IF EXISTS] names`.
3756    /// Built-in schemas always reject the drop with a clear
3757    /// error.
3758    fn exec_drop_schema(
3759        &mut self,
3760        names: &[String],
3761        if_exists: bool,
3762    ) -> Result<QueryResult, EngineError> {
3763        let mut removed = 0usize;
3764        for name in names {
3765            let was_present = self
3766                .active_catalog_mut()
3767                .drop_schema(name)
3768                .map_err(EngineError::Storage)?;
3769            if was_present {
3770                removed += 1;
3771            } else if !if_exists {
3772                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3773                    alloc::format!("schema {name:?} does not exist"),
3774                )));
3775            }
3776        }
3777        Ok(QueryResult::CommandOk {
3778            affected: removed,
3779            modified_catalog: removed > 0 && !self.in_transaction(),
3780        })
3781    }
3782
3783    /// v7.17.0 Phase 1.4 — `DROP TYPE [IF EXISTS] names`. Only
3784    /// ENUM types are catalogued today; other types silently
3785    /// no-op even outside IF EXISTS to mirror the prior
3786    /// "everything's text" lax stance.
3787    fn exec_drop_type(
3788        &mut self,
3789        names: &[String],
3790        if_exists: bool,
3791    ) -> Result<QueryResult, EngineError> {
3792        let mut removed = 0usize;
3793        for name in names {
3794            let was_present = self.active_catalog_mut().drop_enum_type(name);
3795            if was_present {
3796                removed += 1;
3797            } else if !if_exists {
3798                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3799                    alloc::format!("type {name:?} does not exist"),
3800                )));
3801            }
3802        }
3803        Ok(QueryResult::CommandOk {
3804            affected: removed,
3805            modified_catalog: removed > 0 && !self.in_transaction(),
3806        })
3807    }
3808
3809    /// v7.17.0 Phase 1.3 — `CREATE MATERIALIZED VIEW` engine path.
3810    /// Materialises the body at CREATE time (unless WITH NO DATA),
3811    /// stores the result as a regular `Table`, and registers the
3812    /// body source in the catalog so REFRESH can re-run it.
3813    fn exec_create_materialized_view(
3814        &mut self,
3815        s: spg_sql::ast::CreateMaterializedViewStatement,
3816    ) -> Result<QueryResult, EngineError> {
3817        // Name-collision check (table / view / sequence / mat-view).
3818        let cat = self.active_catalog();
3819        if cat.materialized_views().contains_key(&s.name) || cat.get(&s.name).is_some() {
3820            if s.if_not_exists {
3821                return Ok(QueryResult::CommandOk {
3822                    affected: 0,
3823                    modified_catalog: false,
3824                });
3825            }
3826            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3827                alloc::format!("materialized view {:?} already exists", s.name),
3828            )));
3829        }
3830        if cat.views().contains_key(&s.name) {
3831            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3832                alloc::format!(
3833                    "materialized view {:?} would shadow an existing view",
3834                    s.name
3835                ),
3836            )));
3837        }
3838        if cat.sequences().contains_key(&s.name) {
3839            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3840                alloc::format!(
3841                    "materialized view {:?} would shadow an existing sequence",
3842                    s.name
3843                ),
3844            )));
3845        }
3846        // Render the body to canonical form for the registry.
3847        let body_repr = alloc::format!("{}", spg_sql::ast::Statement::Select(s.body.clone()));
3848        // Execute the body to learn the columns. With WITH DATA we
3849        // also materialise the rows; with WITH NO DATA we only need
3850        // the schema, so re-use a LIMIT 0 wrap to keep the column
3851        // inference path uniform without paying for the rows.
3852        let result = self.exec_select_cancel(&s.body, CancelToken::none())?;
3853        let (mut cols, rows) = match result {
3854            QueryResult::Rows { columns, rows } => (columns, rows),
3855            other => {
3856                return Err(EngineError::Unsupported(alloc::format!(
3857                    "CREATE MATERIALIZED VIEW body did not return rows: {other:?}"
3858                )));
3859            }
3860        };
3861        // Apply the column-rename list per PG semantics.
3862        if !s.columns.is_empty() {
3863            if s.columns.len() != cols.len() {
3864                return Err(EngineError::Unsupported(alloc::format!(
3865                    "CREATE MATERIALIZED VIEW {:?}: column list has {} names but body returns {}",
3866                    s.name,
3867                    s.columns.len(),
3868                    cols.len()
3869                )));
3870            }
3871            for (c, name) in cols.iter_mut().zip(s.columns.iter()) {
3872                c.name.clone_from(name);
3873            }
3874        }
3875        // Promote any synthetic-Text projections to their actual
3876        // observed types so the backing table accepts the rows.
3877        cols = infer_column_types(&cols, &rows);
3878        let schema = spg_storage::TableSchema::new(s.name.clone(), cols);
3879        let cat = self.active_catalog_mut();
3880        cat.create_table(schema).map_err(EngineError::Storage)?;
3881        if s.with_data {
3882            let table = cat
3883                .get_mut(&s.name)
3884                .expect("just-created materialized-view backing table must exist");
3885            for row in rows {
3886                table.insert(row).map_err(EngineError::Storage)?;
3887            }
3888        }
3889        cat.register_materialized_view(s.name.clone(), body_repr);
3890        Ok(QueryResult::CommandOk {
3891            affected: 0,
3892            modified_catalog: !self.in_transaction(),
3893        })
3894    }
3895
3896    /// v7.17.0 Phase 1.3 — `REFRESH MATERIALIZED VIEW name [WITH
3897    /// [NO] DATA]`. Looks up the source, re-runs it, replaces the
3898    /// backing table's rows.
3899    fn exec_refresh_materialized_view(
3900        &mut self,
3901        name: &str,
3902        with_data: bool,
3903    ) -> Result<QueryResult, EngineError> {
3904        let source = self
3905            .active_catalog()
3906            .materialized_views()
3907            .get(name)
3908            .cloned()
3909            .ok_or_else(|| {
3910                EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
3911                    "materialized view {name:?} does not exist"
3912                )))
3913            })?;
3914        // Wipe the existing rows first (PG truncates the matview
3915        // and rebuilds; we approximate with an empty INSERT loop).
3916        {
3917            let cat = self.active_catalog_mut();
3918            let table = cat.get_mut(name).ok_or_else(|| {
3919                EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
3920                    "materialized view {name:?} backing table missing"
3921                )))
3922            })?;
3923            table.truncate();
3924        }
3925        if !with_data {
3926            return Ok(QueryResult::CommandOk {
3927                affected: 0,
3928                modified_catalog: !self.in_transaction(),
3929            });
3930        }
3931        let parsed = spg_sql::parser::parse_statement(&source).map_err(|e| {
3932            EngineError::Unsupported(alloc::format!(
3933                "materialized view {name:?} body re-parse failed: {e}"
3934            ))
3935        })?;
3936        let Statement::Select(body) = parsed else {
3937            return Err(EngineError::Unsupported(alloc::format!(
3938                "materialized view {name:?} body is not a SELECT (catalog corruption)"
3939            )));
3940        };
3941        let rows = match self.exec_select_cancel(&body, CancelToken::none())? {
3942            QueryResult::Rows { rows, .. } => rows,
3943            other => {
3944                return Err(EngineError::Unsupported(alloc::format!(
3945                    "REFRESH MATERIALIZED VIEW {name:?} body did not return rows: {other:?}"
3946                )));
3947            }
3948        };
3949        let cat = self.active_catalog_mut();
3950        let table = cat.get_mut(name).expect("backing table verified above");
3951        let affected = rows.len();
3952        for row in rows {
3953            table.insert(row).map_err(EngineError::Storage)?;
3954        }
3955        Ok(QueryResult::CommandOk {
3956            affected,
3957            modified_catalog: !self.in_transaction(),
3958        })
3959    }
3960
3961    /// v7.17.0 Phase 1.3 — `DROP MATERIALIZED VIEW [IF EXISTS]
3962    /// names`. Drops the backing table + unregisters the source.
3963    fn exec_drop_materialized_view(
3964        &mut self,
3965        names: &[String],
3966        if_exists: bool,
3967    ) -> Result<QueryResult, EngineError> {
3968        let mut removed = 0usize;
3969        for name in names {
3970            let was_present = self
3971                .active_catalog_mut()
3972                .drop_materialized_view_source(name);
3973            if was_present {
3974                // Drop the backing table too.
3975                self.active_catalog_mut().drop_table(name);
3976                removed += 1;
3977            } else if !if_exists {
3978                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3979                    alloc::format!("materialized view {name:?} does not exist"),
3980                )));
3981            }
3982        }
3983        Ok(QueryResult::CommandOk {
3984            affected: removed,
3985            modified_catalog: removed > 0 && !self.in_transaction(),
3986        })
3987    }
3988
3989    /// v7.17.0 Phase 1.2 — `DROP VIEW [IF EXISTS] name [, name…]`.
3990    fn exec_drop_view(
3991        &mut self,
3992        names: &[String],
3993        if_exists: bool,
3994    ) -> Result<QueryResult, EngineError> {
3995        let mut removed = 0usize;
3996        for name in names {
3997            let was_present = self.active_catalog_mut().drop_view(name);
3998            if !was_present && !if_exists {
3999                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
4000                    alloc::format!("view {name:?} does not exist"),
4001                )));
4002            }
4003            if was_present {
4004                removed += 1;
4005            }
4006        }
4007        Ok(QueryResult::CommandOk {
4008            affected: removed,
4009            modified_catalog: removed > 0 && !self.in_transaction(),
4010        })
4011    }
4012
4013    /// v7.17.0 — `DROP SEQUENCE [IF EXISTS] name [, name…]`.
4014    fn exec_drop_sequence(
4015        &mut self,
4016        names: &[String],
4017        if_exists: bool,
4018    ) -> Result<QueryResult, EngineError> {
4019        let mut removed = 0usize;
4020        for name in names {
4021            let was_present = self.active_catalog_mut().drop_sequence(name);
4022            if !was_present && !if_exists {
4023                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
4024                    alloc::format!("sequence {name:?} does not exist"),
4025                )));
4026            }
4027            if was_present {
4028                removed += 1;
4029            }
4030        }
4031        Ok(QueryResult::CommandOk {
4032            affected: removed,
4033            modified_catalog: removed > 0 && !self.in_transaction(),
4034        })
4035    }
4036
4037    /// v4.4 `UPDATE <table> SET col = expr [, ...] [WHERE cond]`.
4038    /// Filter pass uses the same WHERE eval as `exec_select`. Per
4039    /// matched row, evaluate each RHS expression against the *old*
4040    /// row, then call `Table::update_row` which rebuilds indices.
4041    /// Indexed columns are correctly reflected because rebuild
4042    /// happens after the cell rewrite.
4043    fn exec_update_cancel(
4044        &mut self,
4045        stmt: &spg_sql::ast::UpdateStatement,
4046        cancel: CancelToken<'_>,
4047    ) -> Result<QueryResult, EngineError> {
4048        // v7.12.5 — snapshot BEFORE/AFTER UPDATE row triggers + the
4049        // session FTS config before the table mut-borrow opens (the
4050        // INSERT path uses the same pattern). Empty vecs are the
4051        // common "no triggers on this table" fast path.
4052        // v7.13.0 — UPDATE triggers carry an optional `UPDATE OF
4053        // cols` filter. The filter is paired with each function so
4054        // the per-row fire loop can skip when no listed column
4055        // actually differs between OLD and NEW.
4056        let before_update_triggers = self.snapshot_update_row_triggers(&stmt.table, "BEFORE");
4057        let after_update_triggers = self.snapshot_update_row_triggers(&stmt.table, "AFTER");
4058        let trigger_session_cfg: Option<String> = self
4059            .session_params
4060            .get("default_text_search_config")
4061            .cloned();
4062        // v5.2.3: if the WHERE is a PK equality and matches a cold-
4063        // tier row, promote it back to the hot tier *before* the
4064        // hot-row walk. The promote pushes the row to the end of
4065        // `table.rows`, where the upcoming SET-evaluation loop will
4066        // pick it up and apply the assignments. Lookups for the key
4067        // never observe a gap because `promote_cold_row` inserts the
4068        // hot row before retiring the cold locator.
4069        if let Some(w) = &stmt.where_ {
4070            let schema_cols = self
4071                .active_catalog()
4072                .get(&stmt.table)
4073                .ok_or_else(|| {
4074                    EngineError::Storage(StorageError::TableNotFound {
4075                        name: stmt.table.clone(),
4076                    })
4077                })?
4078                .schema()
4079                .columns
4080                .clone();
4081            if let Some((col_pos, key)) = try_pk_predicate(w, &schema_cols, stmt.table.as_str())
4082                && let Some(idx_name) = self
4083                    .active_catalog()
4084                    .get(&stmt.table)
4085                    .and_then(|t| t.index_on(col_pos).map(|i| i.name.clone()))
4086            {
4087                // Promote may be a no-op (key is hot-only or absent);
4088                // we don't care about the return value here — the
4089                // subsequent hot walk will either match or not.
4090                let _ = self
4091                    .active_catalog_mut()
4092                    .promote_cold_row(&stmt.table, &idx_name, &key);
4093            }
4094        }
4095
4096        // v7.12.1 — cache session FTS config before the table
4097        // mut-borrow (same reason as exec_delete).
4098        let ts_cfg: Option<String> = self
4099            .session_param("default_text_search_config")
4100            .map(String::from);
4101        // v7.17.0 Phase 2.1 — snapshot the clock pointer before
4102        // we hold the catalog mutably so ON UPDATE runtime
4103        // overrides see the engine wall clock.
4104        let clock_for_on_update = self.clock;
4105        let table = self
4106            .active_catalog_mut()
4107            .get_mut(&stmt.table)
4108            .ok_or_else(|| {
4109                EngineError::Storage(StorageError::TableNotFound {
4110                    name: stmt.table.clone(),
4111                })
4112            })?;
4113        let schema_cols: Vec<ColumnSchema> = table.schema().columns.clone();
4114        // Resolve each SET target to a column position once, validate
4115        // up front so a typo'd column doesn't leave a partial mutation
4116        // behind.
4117        let mut targets: Vec<(usize, &Expr)> = Vec::with_capacity(stmt.assignments.len());
4118        for (col, expr) in &stmt.assignments {
4119            let pos = schema_cols
4120                .iter()
4121                .position(|c| c.name == *col)
4122                .ok_or_else(|| {
4123                    EngineError::Eval(EvalError::ColumnNotFound { name: col.clone() })
4124                })?;
4125            targets.push((pos, expr));
4126        }
4127        // v7.17.0 Phase 2.1 — for every column with an
4128        // `ON UPDATE CURRENT_TIMESTAMP` binding that the caller
4129        // did NOT explicitly set, schedule an automatic override.
4130        // Reuses `eval_runtime_default_free` so the same
4131        // canonical runtime-expression whitelist (now /
4132        // current_timestamp / current_date / …) governs both
4133        // DEFAULT and ON UPDATE.
4134        let mut on_update_overrides: Vec<(usize, String)> = Vec::new();
4135        for (i, col) in schema_cols.iter().enumerate() {
4136            if targets.iter().any(|(p, _)| *p == i) {
4137                continue;
4138            }
4139            if let Some(src) = &col.on_update_runtime {
4140                on_update_overrides.push((i, src.clone()));
4141            }
4142        }
4143        let ctx = EvalContext::new(&schema_cols, Some(stmt.table.as_str()))
4144            .with_default_text_search_config(ts_cfg.as_deref());
4145        // Walk candidate rows, evaluate WHERE then SET
4146        // expressions. We gather (position, new_values) tuples
4147        // first and apply them afterwards so the WHERE/RHS
4148        // evaluation reads the original row state — matches PG
4149        // semantics (UPDATE doesn't see its own writes).
4150        //
4151        // v7.20 P4 — index seek: a single-column equality WHERE
4152        // on an indexed column narrows the walk from
4153        // O(table.rows()) to O(matches). The full WHERE still
4154        // re-evaluates per candidate (the seek may be an
4155        // over-approximation under AND-composites), so semantics
4156        // are unchanged. profile: the bench's `UPDATE … WHERE
4157        // id = $1` on a 5 000-row table was a ~1.3 ms full scan
4158        // per statement; with the seek it's ~2 µs.
4159        let seek_positions: Option<Vec<usize>> = stmt
4160            .where_
4161            .as_ref()
4162            .and_then(|w| try_index_seek_positions(w, &schema_cols, table, stmt.table.as_str()));
4163        let mut planned: Vec<(usize, Vec<Value>)> = Vec::new();
4164        let candidate_positions: Vec<usize> = match &seek_positions {
4165            Some(list) => list.clone(),
4166            None => (0..table.row_count()).collect(),
4167        };
4168        for (loop_n, &i) in candidate_positions.iter().enumerate() {
4169            // v4.5: cooperative cancel checkpoint every 256 rows so
4170            // a runaway UPDATE without WHERE doesn't drag past the
4171            // server's query-timeout watchdog.
4172            if loop_n.is_multiple_of(256) {
4173                cancel.check()?;
4174            }
4175            let Some(row) = table.rows().get(i) else {
4176                continue;
4177            };
4178            if let Some(w) = &stmt.where_ {
4179                let cond = eval::eval_expr(w, row, &ctx)?;
4180                if !matches!(cond, Value::Bool(true)) {
4181                    continue;
4182                }
4183            }
4184            let mut new_vals = row.values.clone();
4185            for (pos, expr) in &targets {
4186                let v = eval::eval_expr(expr, row, &ctx)?;
4187                let coerced = coerce_value(v, schema_cols[*pos].ty, &schema_cols[*pos].name, *pos)?;
4188                check_unsigned_range(&coerced, &schema_cols[*pos], *pos)?;
4189                new_vals[*pos] = coerced;
4190            }
4191            // v7.17.0 Phase 2.1 — apply ON UPDATE overrides for
4192            // any column the SET clause didn't touch.
4193            for (pos, src) in &on_update_overrides {
4194                let v = eval_runtime_default_free(src, schema_cols[*pos].ty, clock_for_on_update)?;
4195                new_vals[*pos] = v;
4196            }
4197            planned.push((i, new_vals));
4198        }
4199        // planned must stay position-sorted: downstream passes
4200        // (FK pairing, trigger walks, the apply loop) iterate it
4201        // assuming ascending row order, which the full-scan path
4202        // guaranteed implicitly.
4203        planned.sort_by_key(|(i, _)| *i);
4204        // v7.6.6 — capture pre-update row values for the FK
4205        // enforcement passes below. `planned` carries new values
4206        // only; pair them with the old row.
4207        let plan_with_old: Vec<(usize, Vec<Value>, Vec<Value>)> = planned
4208            .iter()
4209            .map(|(pos, new_vals)| (*pos, table.rows()[*pos].values.clone(), new_vals.clone()))
4210            .collect();
4211        let self_fks = table.schema().foreign_keys.clone();
4212        // v7.12.5 — `affected` is computed post-BEFORE-trigger
4213        // below (triggers may RETURN NULL to skip individual
4214        // rows). The pre-trigger len shape is no longer accurate.
4215        // Release mutable borrow on `table` for the FK passes.
4216        let _ = table;
4217        // v7.6.6 — Stage 2a: outbound FK check. For every row whose
4218        // local FK columns changed, the new value must exist in the
4219        // parent.
4220        if !self_fks.is_empty() {
4221            let new_rows: Vec<Vec<Value>> = planned
4222                .iter()
4223                .map(|(_pos, new_vals)| new_vals.clone())
4224                .collect();
4225            enforce_fk_inserts(self.active_catalog(), &stmt.table, &self_fks, &new_rows)?;
4226        }
4227        // v7.13.0 — CHECK constraint enforcement on UPDATE
4228        // (mailrs round-5 G3). Predicates evaluated against the
4229        // candidate post-UPDATE row; false rejects the UPDATE.
4230        {
4231            let new_rows: Vec<Vec<Value>> = planned
4232                .iter()
4233                .map(|(_pos, new_vals)| new_vals.clone())
4234                .collect();
4235            enforce_check_constraints(self.active_catalog(), &stmt.table, &new_rows)?;
4236        }
4237        // v7.6.6 — Stage 2b: inbound FK check. For every row that
4238        // changed value in a column that *some other table* uses as
4239        // a FK parent column, react per `on_update` action.
4240        let child_plan =
4241            plan_fk_parent_updates(self.active_catalog(), &stmt.table, &plan_with_old)?;
4242        // Stage 3a — apply each child-side action.
4243        for step in &child_plan {
4244            apply_fk_child_step(self.active_catalog_mut(), step)?;
4245        }
4246        // Stage 3b — apply the original UPDATE.
4247        let table = self
4248            .active_catalog_mut()
4249            .get_mut(&stmt.table)
4250            .ok_or_else(|| {
4251                EngineError::Storage(StorageError::TableNotFound {
4252                    name: stmt.table.clone(),
4253                })
4254            })?;
4255        // v7.12.5 — fire BEFORE/AFTER UPDATE row-level triggers
4256        // around the apply loop. BEFORE sees NEW=candidate +
4257        // OLD=current; may rewrite NEW or RETURN NULL to skip.
4258        // AFTER sees NEW=post-write + OLD=pre-write (both read-
4259        // only).
4260        //
4261        // Filter `planned` through the BEFORE pass first so the
4262        // RETURNING snapshot reflects what actually got written
4263        // (triggers may rewrite cells, including a cancellation).
4264        let mut applied_after_before: Vec<(usize, Row, Row)> = Vec::with_capacity(planned.len());
4265        // v7.12.7 — embedded SQL queue.
4266        let mut deferred_embedded: Vec<triggers::DeferredEmbeddedStmt> = Vec::new();
4267        for (pos, new_vals) in &planned {
4268            let old_row = table.rows()[*pos].clone();
4269            let mut new_row = Row::new(new_vals.clone());
4270            let mut skip = false;
4271            for (fd, filter) in &before_update_triggers {
4272                // v7.13.0 — `UPDATE OF cols` filter (mailrs round-5
4273                // G7). Skip this trigger when the filter is set and
4274                // no listed column actually differs between OLD and
4275                // NEW for this row.
4276                if !filter.is_empty()
4277                    && !any_column_changed(filter, &schema_cols, &old_row, &new_row)
4278                {
4279                    continue;
4280                }
4281                let (outcome, deferred) = triggers::fire_row_trigger(
4282                    fd,
4283                    Some(new_row.clone()),
4284                    Some(&old_row),
4285                    &stmt.table,
4286                    &schema_cols,
4287                    &[],
4288                    trigger_session_cfg.as_deref(),
4289                    false,
4290                )
4291                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
4292                deferred_embedded.extend(deferred);
4293                match outcome {
4294                    triggers::TriggerOutcome::Row(r) => new_row = r,
4295                    triggers::TriggerOutcome::Skip => {
4296                        skip = true;
4297                        break;
4298                    }
4299                }
4300            }
4301            if !skip {
4302                applied_after_before.push((*pos, new_row, old_row));
4303            }
4304        }
4305        // v7.9.4 — snapshot post-update values for RETURNING (post-
4306        // BEFORE-trigger because triggers can rewrite cells).
4307        let updated_for_returning: Vec<Vec<Value>> = if stmt.returning.is_some() {
4308            applied_after_before
4309                .iter()
4310                .map(|(_pos, new_row, _old)| new_row.values.clone())
4311                .collect()
4312        } else {
4313            Vec::new()
4314        };
4315        let affected = applied_after_before.len();
4316        // Apply, then fire AFTER triggers per row. AFTER runs read-
4317        // only against the freshly-written row; v7.12.4-shape
4318        // assignment errors with a clear message.
4319        for (pos, new_row, old_row) in applied_after_before {
4320            table.update_row(pos, new_row.values.clone())?;
4321            for (fd, filter) in &after_update_triggers {
4322                if !filter.is_empty()
4323                    && !any_column_changed(filter, &schema_cols, &old_row, &new_row)
4324                {
4325                    continue;
4326                }
4327                let (_outcome, deferred) = triggers::fire_row_trigger(
4328                    fd,
4329                    Some(new_row.clone()),
4330                    Some(&old_row),
4331                    &stmt.table,
4332                    &schema_cols,
4333                    &[],
4334                    trigger_session_cfg.as_deref(),
4335                    true,
4336                )
4337                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
4338                deferred_embedded.extend(deferred);
4339            }
4340        }
4341        let _ = table;
4342        // v7.12.7 — drain trigger-emitted embedded SQL for this UPDATE.
4343        self.execute_deferred_trigger_stmts(deferred_embedded, cancel)?;
4344        // v6.2.1 — auto-analyze modified-row tracking for UPDATE.
4345        if !self.in_transaction() && affected > 0 {
4346            self.statistics
4347                .record_modifications(&stmt.table, affected as u64);
4348        }
4349        // v7.9.4 — RETURNING projection.
4350        if let Some(items) = &stmt.returning {
4351            return self.build_returning_rows(&stmt.table, items, updated_for_returning);
4352        }
4353        Ok(QueryResult::CommandOk {
4354            affected,
4355            modified_catalog: !self.in_transaction(),
4356        })
4357    }
4358
4359    /// v4.4 `DELETE FROM <table> [WHERE cond]`. Collects matching
4360    /// positions then delegates to `Table::delete_rows` (single index
4361    /// rebuild for the batch).
4362    /// v7.17.0 Phase 3.P0-42 — SQL:2003 / PG 15+ `MERGE` execution.
4363    ///
4364    /// Semantics:
4365    ///   * Resolve `target` and `source` tables (catalog reads).
4366    ///   * Build a combined `(target_alias.col, source_alias.col)`
4367    ///     schema so the ON / WHEN AND / SET / VALUES expressions
4368    ///     resolve through the standard qualifier-aware resolver.
4369    ///   * Pass 1: walk every source row × every target hot row,
4370    ///     evaluate ON, then pick the first WHEN clause that fits
4371    ///     (`Matched` if any target row matched, `NotMatched`
4372    ///     otherwise; AND-condition must hold). Collect the action
4373    ///     plan as `(deletes, updates, inserts)` so the apply pass
4374    ///     reads the original target row state.
4375    ///   * Pass 2: apply the plan against the target's mutable row
4376    ///     vector. Deletes execute by index in descending order so
4377    ///     earlier indices remain stable; updates next; inserts
4378    ///     last (matching PG's "INSERT branch sees the post-delete
4379    ///     state" behaviour for the common upsert shape).
4380    ///
4381    /// v7.17 simplifications (documented limitations):
4382    ///   * No triggers / WAL plumbing (MVP); MERGE rows don't fire
4383    ///     INSERT / UPDATE / DELETE row triggers in v7.17.
4384    ///   * No cardinality check (PG-canonical: "MERGE command
4385    ///     cannot affect row a second time" — SPG silently applies
4386    ///     the last action for a target row covered twice).
4387    ///   * Source must be a catalog-resolvable table (no subquery
4388    ///     source); RETURNING / BY SOURCE / BY TARGET unsupported.
4389    fn exec_merge_cancel(
4390        &mut self,
4391        stmt: &spg_sql::ast::MergeStatement,
4392        cancel: CancelToken<'_>,
4393    ) -> Result<QueryResult, EngineError> {
4394        let target_alias = stmt
4395            .target_alias
4396            .clone()
4397            .unwrap_or_else(|| stmt.target.clone());
4398        let source_alias = stmt
4399            .source_alias
4400            .clone()
4401            .unwrap_or_else(|| stmt.source.clone());
4402        let (target_cols, target_rows_snapshot) = {
4403            let t = self.active_catalog().get(&stmt.target).ok_or_else(|| {
4404                EngineError::Storage(StorageError::TableNotFound {
4405                    name: stmt.target.clone(),
4406                })
4407            })?;
4408            (
4409                t.schema().columns.clone(),
4410                t.rows().iter().cloned().collect::<Vec<Row>>(),
4411            )
4412        };
4413        let (source_cols, source_rows) = {
4414            let s = self.active_catalog().get(&stmt.source).ok_or_else(|| {
4415                EngineError::Storage(StorageError::TableNotFound {
4416                    name: stmt.source.clone(),
4417                })
4418            })?;
4419            (
4420                s.schema().columns.clone(),
4421                s.rows().iter().cloned().collect::<Vec<Row>>(),
4422            )
4423        };
4424        // Composite schema: target_alias.col ... source_alias.col ...
4425        let mut combined_schema: Vec<ColumnSchema> = Vec::new();
4426        for col in &target_cols {
4427            combined_schema.push(ColumnSchema::new(
4428                alloc::format!("{target_alias}.{}", col.name),
4429                col.ty,
4430                col.nullable,
4431            ));
4432        }
4433        for col in &source_cols {
4434            combined_schema.push(ColumnSchema::new(
4435                alloc::format!("{source_alias}.{}", col.name),
4436                col.ty,
4437                col.nullable,
4438            ));
4439        }
4440        let combined_ctx = EvalContext::new(&combined_schema, None);
4441        // Source-only context for WHEN NOT MATCHED actions (no
4442        // matched target row exists — the source-side qualified
4443        // columns must still resolve).
4444        let mut source_only_schema: Vec<ColumnSchema> = Vec::new();
4445        for col in &target_cols {
4446            source_only_schema.push(ColumnSchema::new(
4447                alloc::format!("{target_alias}.{}", col.name),
4448                col.ty,
4449                col.nullable,
4450            ));
4451        }
4452        for col in &source_cols {
4453            source_only_schema.push(ColumnSchema::new(
4454                alloc::format!("{source_alias}.{}", col.name),
4455                col.ty,
4456                col.nullable,
4457            ));
4458        }
4459        let source_only_ctx = EvalContext::new(&source_only_schema, None);
4460        let target_arity = target_cols.len();
4461        let source_arity = source_cols.len();
4462
4463        // Resolve INSERT column positions once (validate names).
4464        // For each clause that's an INSERT, map column names → target positions.
4465        let mut delete_indices: Vec<usize> = Vec::new();
4466        let mut updates: Vec<(usize, Vec<Value>)> = Vec::new();
4467        let mut inserts: Vec<Vec<Value>> = Vec::new();
4468        let mut affected: usize = 0;
4469
4470        for (src_idx, src_row) in source_rows.iter().enumerate() {
4471            if src_idx.is_multiple_of(256) {
4472                cancel.check()?;
4473            }
4474            // Find every matched target index (per the ON predicate).
4475            let mut matched_targets: Vec<usize> = Vec::new();
4476            for (t_idx, t_row) in target_rows_snapshot.iter().enumerate() {
4477                let mut combined_vals = t_row.values.clone();
4478                combined_vals.extend(src_row.values.iter().cloned());
4479                let combined_row = Row::new(combined_vals);
4480                let cond = eval::eval_expr(&stmt.on, &combined_row, &combined_ctx)?;
4481                if matches!(cond, Value::Bool(true)) {
4482                    matched_targets.push(t_idx);
4483                }
4484            }
4485            let is_matched = !matched_targets.is_empty();
4486            // Pick the first WHEN clause whose kind agrees with
4487            // `is_matched` and whose AND condition (if any) holds.
4488            // AND condition for MATCHED: evaluated against the
4489            // first matched target row × source. For NOT MATCHED:
4490            // evaluated with target side NULL-padded.
4491            let fired_clause = stmt.clauses.iter().find(|c| {
4492                let kind_ok = match c.matched {
4493                    spg_sql::ast::MergeMatched::Matched => is_matched,
4494                    spg_sql::ast::MergeMatched::NotMatched => !is_matched,
4495                };
4496                if !kind_ok {
4497                    return false;
4498                }
4499                let Some(cond_expr) = &c.condition else {
4500                    return true;
4501                };
4502                let row = if is_matched {
4503                    let t = &target_rows_snapshot[matched_targets[0]];
4504                    let mut vals = t.values.clone();
4505                    vals.extend(src_row.values.iter().cloned());
4506                    Row::new(vals)
4507                } else {
4508                    let mut vals: Vec<Value> = (0..target_arity).map(|_| Value::Null).collect();
4509                    vals.extend(src_row.values.iter().cloned());
4510                    Row::new(vals)
4511                };
4512                let ctx_ref = if is_matched {
4513                    &combined_ctx
4514                } else {
4515                    &source_only_ctx
4516                };
4517                matches!(
4518                    eval::eval_expr(cond_expr, &row, ctx_ref),
4519                    Ok(Value::Bool(true))
4520                )
4521            });
4522            let Some(clause) = fired_clause else { continue };
4523            match &clause.action {
4524                spg_sql::ast::MergeAction::DoNothing => {}
4525                spg_sql::ast::MergeAction::Delete => {
4526                    for &t_idx in &matched_targets {
4527                        if !delete_indices.contains(&t_idx) {
4528                            delete_indices.push(t_idx);
4529                            affected += 1;
4530                        }
4531                    }
4532                }
4533                spg_sql::ast::MergeAction::Update { assignments } => {
4534                    // Pre-resolve SET targets to target column positions.
4535                    let mut planned_sets: Vec<(usize, &Expr)> =
4536                        Vec::with_capacity(assignments.len());
4537                    for (col, expr) in assignments {
4538                        let pos =
4539                            target_cols
4540                                .iter()
4541                                .position(|c| c.name == *col)
4542                                .ok_or_else(|| {
4543                                    EngineError::Eval(EvalError::ColumnNotFound {
4544                                        name: col.clone(),
4545                                    })
4546                                })?;
4547                        planned_sets.push((pos, expr));
4548                    }
4549                    for &t_idx in &matched_targets {
4550                        let t_row = &target_rows_snapshot[t_idx];
4551                        let mut new_values = t_row.values.clone();
4552                        let mut combined_vals = t_row.values.clone();
4553                        combined_vals.extend(src_row.values.iter().cloned());
4554                        let combined_row = Row::new(combined_vals);
4555                        for (pos, expr) in &planned_sets {
4556                            let raw = eval::eval_expr(expr, &combined_row, &combined_ctx)?;
4557                            let coerced = coerce_value(
4558                                raw,
4559                                target_cols[*pos].ty,
4560                                &target_cols[*pos].name,
4561                                *pos,
4562                            )?;
4563                            new_values[*pos] = coerced;
4564                        }
4565                        updates.push((t_idx, new_values));
4566                        affected += 1;
4567                    }
4568                }
4569                spg_sql::ast::MergeAction::Insert { columns, values } => {
4570                    // For INSERT NOT MATCHED, target side is NULL-padded.
4571                    let mut vals: Vec<Value> = (0..target_arity).map(|_| Value::Null).collect();
4572                    vals.extend(src_row.values.iter().cloned());
4573                    let synth_row = Row::new(vals);
4574                    let mut new_row_values: Vec<Value> =
4575                        (0..target_arity).map(|_| Value::Null).collect();
4576                    for (col, expr) in columns.iter().zip(values.iter()) {
4577                        let pos =
4578                            target_cols
4579                                .iter()
4580                                .position(|c| c.name == *col)
4581                                .ok_or_else(|| {
4582                                    EngineError::Eval(EvalError::ColumnNotFound {
4583                                        name: col.clone(),
4584                                    })
4585                                })?;
4586                        let raw = eval::eval_expr(expr, &synth_row, &source_only_ctx)?;
4587                        let coerced =
4588                            coerce_value(raw, target_cols[pos].ty, &target_cols[pos].name, pos)?;
4589                        new_row_values[pos] = coerced;
4590                    }
4591                    inserts.push(new_row_values);
4592                    affected += 1;
4593                }
4594            }
4595        }
4596        let _ = source_arity; // captured for symmetry; cancellation cost negligible.
4597
4598        // Apply the plan to the target table.
4599        let table = self
4600            .active_catalog_mut()
4601            .get_mut(&stmt.target)
4602            .ok_or_else(|| {
4603                EngineError::Storage(StorageError::TableNotFound {
4604                    name: stmt.target.clone(),
4605                })
4606            })?;
4607        // Apply updates first (in-place), then deletes (one batch),
4608        // then inserts. The storage API uses `update_row(pos,
4609        // new_values)`, `delete_rows(&[positions])`, and `insert(row)`.
4610        for (idx, new_vals) in &updates {
4611            table
4612                .update_row(*idx, new_vals.clone())
4613                .map_err(EngineError::Storage)?;
4614        }
4615        if !delete_indices.is_empty() {
4616            table.delete_rows(&delete_indices);
4617        }
4618        for vals in inserts {
4619            table.insert(Row::new(vals)).map_err(EngineError::Storage)?;
4620        }
4621        Ok(QueryResult::CommandOk {
4622            affected,
4623            modified_catalog: affected > 0,
4624        })
4625    }
4626
4627    fn exec_delete_cancel(
4628        &mut self,
4629        stmt: &spg_sql::ast::DeleteStatement,
4630        cancel: CancelToken<'_>,
4631    ) -> Result<QueryResult, EngineError> {
4632        // v7.12.5 — snapshot BEFORE/AFTER DELETE row triggers + the
4633        // session FTS config before the mut borrow (same shape as
4634        // INSERT / UPDATE).
4635        let before_delete_triggers = self.snapshot_row_triggers(&stmt.table, "DELETE", "BEFORE");
4636        let after_delete_triggers = self.snapshot_row_triggers(&stmt.table, "DELETE", "AFTER");
4637        let trigger_session_cfg: Option<String> = self
4638            .session_params
4639            .get("default_text_search_config")
4640            .cloned();
4641        // v5.2.3: PK-targeted DELETE → first retire any cold-tier
4642        // locator for the key. The cold row body stays in the
4643        // segment (becoming shadowed garbage that a future
4644        // compaction pass reclaims) but the index no longer
4645        // resolves it. The shadow count contributes to the
4646        // affected total; the subsequent hot walk handles any hot
4647        // rows for the same key.
4648        let mut cold_shadow_count: usize = 0;
4649        if let Some(w) = &stmt.where_ {
4650            let schema_cols = self
4651                .active_catalog()
4652                .get(&stmt.table)
4653                .ok_or_else(|| {
4654                    EngineError::Storage(StorageError::TableNotFound {
4655                        name: stmt.table.clone(),
4656                    })
4657                })?
4658                .schema()
4659                .columns
4660                .clone();
4661            if let Some((col_pos, key)) = try_pk_predicate(w, &schema_cols, stmt.table.as_str())
4662                && let Some(idx_name) = self
4663                    .active_catalog()
4664                    .get(&stmt.table)
4665                    .and_then(|t| t.index_on(col_pos).map(|i| i.name.clone()))
4666            {
4667                cold_shadow_count = self
4668                    .active_catalog_mut()
4669                    .shadow_cold_row(&stmt.table, &idx_name, &key)
4670                    .unwrap_or(0);
4671            }
4672        }
4673
4674        // v7.12.1 — cache the session FTS config as an owned
4675        // String before the mutable table borrow below; the
4676        // ctx-builder then references it via `as_deref` so the
4677        // immutable read of `session_params` doesn't conflict
4678        // with the mut borrow chain.
4679        let ts_cfg: Option<String> = self
4680            .session_param("default_text_search_config")
4681            .map(String::from);
4682        let table = self
4683            .active_catalog_mut()
4684            .get_mut(&stmt.table)
4685            .ok_or_else(|| {
4686                EngineError::Storage(StorageError::TableNotFound {
4687                    name: stmt.table.clone(),
4688                })
4689            })?;
4690        let schema_cols: Vec<ColumnSchema> = table.schema().columns.clone();
4691        let ctx = EvalContext::new(&schema_cols, Some(stmt.table.as_str()))
4692            .with_default_text_search_config(ts_cfg.as_deref());
4693        let mut positions: Vec<usize> = Vec::new();
4694        // v7.6.3 — collect every to-delete row's full Value tuple
4695        // alongside its position, so the FK enforcement pass can
4696        // run after the mut borrow drops.
4697        let mut to_delete_rows: Vec<Vec<Value>> = Vec::new();
4698        // v7.20 P4 — index seek (same shape as exec_update_cancel):
4699        // an equality WHERE on an indexed column narrows the walk
4700        // to the matching hot positions; the full WHERE still
4701        // re-evaluates per candidate. Downstream passes assume
4702        // ascending position order, so the seek result is sorted.
4703        let seek_positions: Option<Vec<usize>> = stmt
4704            .where_
4705            .as_ref()
4706            .and_then(|w| try_index_seek_positions(w, &schema_cols, table, stmt.table.as_str()));
4707        let candidate_positions: Vec<usize> = match seek_positions {
4708            Some(mut list) => {
4709                list.sort_unstable();
4710                list
4711            }
4712            None => (0..table.row_count()).collect(),
4713        };
4714        for (loop_n, &i) in candidate_positions.iter().enumerate() {
4715            if loop_n.is_multiple_of(256) {
4716                cancel.check()?;
4717            }
4718            let Some(row) = table.rows().get(i) else {
4719                continue;
4720            };
4721            let keep = if let Some(w) = &stmt.where_ {
4722                let cond = eval::eval_expr(w, row, &ctx)?;
4723                !matches!(cond, Value::Bool(true))
4724            } else {
4725                false
4726            };
4727            if !keep {
4728                positions.push(i);
4729                to_delete_rows.push(row.values.clone());
4730            }
4731        }
4732        // v7.6.3 / v7.6.4 — Stage 2: FK enforcement on the immutable
4733        // catalog. Release the mut borrow and run reverse-scan
4734        // against every child table whose FK targets this table.
4735        // RESTRICT / NoAction raise an error; CASCADE returns a
4736        // cascade plan that stage 3 applies after the primary delete.
4737        // SET NULL / SET DEFAULT remain Unsupported until v7.6.5.
4738        let _ = table;
4739        // v7.12.5 — BEFORE DELETE row-level triggers. Each fires
4740        // with NEW=None / OLD=pre-delete row; RETURN OLD (or NEW)
4741        // = proceed, RETURN NULL = skip the row entirely. The
4742        // filter must run BEFORE the FK cascade plan so cascaded
4743        // child rows track the trigger's skip-decision on the
4744        // parent.
4745        // v7.12.7 — embedded SQL queue.
4746        let mut deferred_embedded: Vec<triggers::DeferredEmbeddedStmt> = Vec::new();
4747        if !before_delete_triggers.is_empty() {
4748            let mut filtered_positions: Vec<usize> = Vec::with_capacity(positions.len());
4749            let mut filtered_old_rows: Vec<Vec<Value>> = Vec::with_capacity(to_delete_rows.len());
4750            for (pos, old_vals) in positions.iter().zip(to_delete_rows.iter()) {
4751                let old_row = Row::new(old_vals.clone());
4752                let mut cancel_this = false;
4753                for fd in &before_delete_triggers {
4754                    let (outcome, deferred) = triggers::fire_row_trigger(
4755                        fd,
4756                        None,
4757                        Some(&old_row),
4758                        &stmt.table,
4759                        &schema_cols,
4760                        &[],
4761                        trigger_session_cfg.as_deref(),
4762                        false,
4763                    )
4764                    .map_err(|e| {
4765                        EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}")))
4766                    })?;
4767                    deferred_embedded.extend(deferred);
4768                    if matches!(outcome, triggers::TriggerOutcome::Skip) {
4769                        cancel_this = true;
4770                        break;
4771                    }
4772                }
4773                if !cancel_this {
4774                    filtered_positions.push(*pos);
4775                    filtered_old_rows.push(old_vals.clone());
4776                }
4777            }
4778            positions = filtered_positions;
4779            to_delete_rows = filtered_old_rows;
4780        }
4781        let cascade_plan = plan_fk_parent_deletions(
4782            self.active_catalog(),
4783            &stmt.table,
4784            &positions,
4785            &to_delete_rows,
4786        )?;
4787        // Stage 3a — apply each FK child step (SET NULL / SET
4788        // DEFAULT / CASCADE delete) before deleting the parent.
4789        // The plan is already ordered: nulls/defaults first, then
4790        // cascade deletes (so a row mutated and later deleted
4791        // surfaces as deleted — though v7.6.5 doesn't produce
4792        // that overlap today).
4793        for step in &cascade_plan {
4794            apply_fk_child_step(self.active_catalog_mut(), step)?;
4795        }
4796        // Stage 3b — actually delete the original target rows.
4797        let table = self
4798            .active_catalog_mut()
4799            .get_mut(&stmt.table)
4800            .ok_or_else(|| {
4801                EngineError::Storage(StorageError::TableNotFound {
4802                    name: stmt.table.clone(),
4803                })
4804            })?;
4805        let affected = table.delete_rows(&positions) + cold_shadow_count;
4806        let _ = table;
4807        // v7.12.5 — AFTER DELETE row-level triggers fire post-write
4808        // with NEW=None / OLD=pre-delete row (each from the
4809        // already-snapshotted to_delete_rows). Return value is
4810        // ignored (matches PG AFTER semantics).
4811        if !after_delete_triggers.is_empty() {
4812            for old_vals in &to_delete_rows {
4813                let old_row = Row::new(old_vals.clone());
4814                for fd in &after_delete_triggers {
4815                    let (_outcome, deferred) = triggers::fire_row_trigger(
4816                        fd,
4817                        None,
4818                        Some(&old_row),
4819                        &stmt.table,
4820                        &schema_cols,
4821                        &[],
4822                        trigger_session_cfg.as_deref(),
4823                        true,
4824                    )
4825                    .map_err(|e| {
4826                        EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}")))
4827                    })?;
4828                    deferred_embedded.extend(deferred);
4829                }
4830            }
4831        }
4832        // v7.12.7 — drain trigger-emitted embedded SQL for this DELETE.
4833        self.execute_deferred_trigger_stmts(deferred_embedded, cancel)?;
4834        // v6.2.1 — auto-analyze modified-row tracking for DELETE.
4835        if !self.in_transaction() && affected > 0 {
4836            self.statistics
4837                .record_modifications(&stmt.table, affected as u64);
4838        }
4839        // v7.9.4 — RETURNING projection over the soon-to-be-gone
4840        // rows. `to_delete_rows` was snapshotted in stage 1 before
4841        // mutation, so the projection sees the pre-delete state
4842        // (matches PG semantics: DELETE RETURNING returns the row
4843        // as it was just before removal).
4844        if let Some(items) = &stmt.returning {
4845            return self.build_returning_rows(&stmt.table, items, to_delete_rows);
4846        }
4847        Ok(QueryResult::CommandOk {
4848            affected,
4849            modified_catalog: !self.in_transaction(),
4850        })
4851    }
4852
4853    /// `SHOW TABLES` — one row per table in the active catalog.
4854    /// Column name is `name` so result-set consumers can downstream
4855    /// `SELECT name FROM ...` style logic if needed.
4856    /// v4.26: `EXPLAIN [ANALYZE] <select>`. Returns a single-column
4857    /// `QUERY PLAN` text table — first line names the top operator
4858    /// (Scan / Aggregate / Window / etc.), indented children list
4859    /// FROM joins, WHERE filters, ORDER BY / LIMIT, projection
4860    /// shape, and any active index hits. `ANALYZE` execs the inner
4861    /// SELECT and appends actual-row + elapsed-micros annotations.
4862    #[allow(clippy::format_push_string)]
4863    fn exec_explain(
4864        &self,
4865        e: &spg_sql::ast::ExplainStatement,
4866        cancel: CancelToken<'_>,
4867    ) -> Result<QueryResult, EngineError> {
4868        let mut lines = Vec::<String>::new();
4869        explain_select(&e.inner, self, 0, &mut lines);
4870        if e.suggest {
4871            // v6.8.3 — index advisor. Walks the SELECT's FROM
4872            // tables + WHERE column refs; for each (table, column)
4873            // pair that lacks an index, append a SUGGEST line with
4874            // a copy-pastable `CREATE INDEX` statement. This is a
4875            // pure-syntax heuristic — no cardinality estimation —
4876            // matching the v6.8.3 design intent of "tell the
4877            // operator where indexes are missing", not "give the
4878            // mathematically optimal index set".
4879            let suggestions = build_index_suggestions(&e.inner, self);
4880            for s in suggestions {
4881                lines.push(s);
4882            }
4883        } else if e.analyze {
4884            // v6.2.4 — EXPLAIN ANALYZE annotates each operator line
4885            // with `(rows=N)` where the row count is computable
4886            // without re-executing the full query:
4887            //   - Top-level operator (first non-indented line):
4888            //     rows = final result.len()
4889            //   - "From: <table> [full scan]" lines: rows =
4890            //     table.rows().len() (catalog read; no execution)
4891            //   - "From: <table> [index seek]": indeterminate —
4892            //     the index step would need re-execution; v6.2.5
4893            //     adds per-operator wall-clock + hot/cold rows
4894            //     instrumentation that makes this concrete.
4895            //   - Everything else: marked `(—)` so the surface
4896            //     stays well-defined without silently dropping
4897            //     stats. v6.2.5 fills in via inline executor
4898            //     instrumentation.
4899            // Total elapsed lands on a trailing `Total: …` line.
4900            let started = self.clock.map(|f| f());
4901            let exec = self.exec_select_cancel(&e.inner, cancel)?;
4902            let elapsed_micros = match (self.clock, started) {
4903                (Some(f), Some(s)) => Some(f().saturating_sub(s)),
4904                _ => None,
4905            };
4906            let row_count = if let QueryResult::Rows { rows, .. } = &exec {
4907                rows.len()
4908            } else {
4909                0
4910            };
4911            annotate_explain_lines(&mut lines, row_count, self);
4912            let mut total = alloc::format!("Total: rows={row_count}");
4913            if let Some(us) = elapsed_micros {
4914                total.push_str(&alloc::format!(" elapsed={us}us"));
4915            }
4916            lines.push(total);
4917        }
4918        let columns = alloc::vec![ColumnSchema::new("QUERY PLAN", DataType::Text, false)];
4919        let rows: Vec<Row> = lines
4920            .into_iter()
4921            .map(|l| Row::new(alloc::vec![Value::Text(l)]))
4922            .collect();
4923        Ok(QueryResult::Rows { columns, rows })
4924    }
4925
4926    fn exec_show_tables(&self) -> QueryResult {
4927        let columns = alloc::vec![ColumnSchema::new("name", DataType::Text, false)];
4928        let rows: Vec<Row> = self
4929            .active_catalog()
4930            .table_names()
4931            .into_iter()
4932            .map(|n| Row::new(alloc::vec![Value::Text(n)]))
4933            .collect();
4934        QueryResult::Rows { columns, rows }
4935    }
4936
4937    /// v7.17.0 Phase 3.P0-59 — `SHOW CREATE TABLE <t>`. Synthesise
4938    /// a minimal MySQL-flavoured CREATE TABLE DDL from the
4939    /// catalog's TableSchema so mysqldump round-trips load against
4940    /// SPG without splitting init scripts.
4941    fn exec_show_create_table(&self, name: &str) -> Result<QueryResult, EngineError> {
4942        let t = self.active_catalog().get(name).ok_or_else(|| {
4943            EngineError::Storage(StorageError::TableNotFound { name: name.into() })
4944        })?;
4945        let cols: Vec<String> = t
4946            .schema()
4947            .columns
4948            .iter()
4949            .map(|c| {
4950                let ty = render_data_type(c.ty);
4951                let nullable = if c.nullable { "" } else { " NOT NULL" };
4952                alloc::format!("  `{}` {}{}", c.name, ty, nullable)
4953            })
4954            .collect();
4955        let mut body = cols.join(",\n");
4956        // Append UNIQUE / PRIMARY KEY clauses.
4957        for uc in &t.schema().uniqueness_constraints {
4958            let col_names: Vec<String> = uc
4959                .columns
4960                .iter()
4961                .map(|&p| {
4962                    t.schema().columns.get(p).map_or_else(
4963                        || alloc::format!("col{p}"),
4964                        |c| alloc::format!("`{}`", c.name),
4965                    )
4966                })
4967                .collect();
4968            let kw = if uc.is_primary_key {
4969                "PRIMARY KEY"
4970            } else {
4971                "UNIQUE KEY"
4972            };
4973            body.push_str(",\n  ");
4974            body.push_str(&alloc::format!("{kw} ({})", col_names.join(", ")));
4975        }
4976        // Foreign keys.
4977        for fk in &t.schema().foreign_keys {
4978            let local: Vec<String> = fk
4979                .local_columns
4980                .iter()
4981                .map(|&p| {
4982                    t.schema().columns.get(p).map_or_else(
4983                        || alloc::format!("col{p}"),
4984                        |c| alloc::format!("`{}`", c.name),
4985                    )
4986                })
4987                .collect();
4988            let parent_cols: Vec<String> =
4989                if let Some(parent) = self.active_catalog().get(&fk.parent_table) {
4990                    fk.parent_columns
4991                        .iter()
4992                        .map(|&p| {
4993                            parent.schema().columns.get(p).map_or_else(
4994                                || alloc::format!("col{p}"),
4995                                |c| alloc::format!("`{}`", c.name),
4996                            )
4997                        })
4998                        .collect()
4999                } else {
5000                    fk.parent_columns
5001                        .iter()
5002                        .map(|p| alloc::format!("col{p}"))
5003                        .collect()
5004                };
5005            body.push_str(",\n  ");
5006            body.push_str(&alloc::format!(
5007                "FOREIGN KEY ({}) REFERENCES `{}` ({})",
5008                local.join(", "),
5009                fk.parent_table,
5010                parent_cols.join(", ")
5011            ));
5012        }
5013        let ddl = alloc::format!(
5014            "CREATE TABLE `{}` (\n{}\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
5015            name,
5016            body
5017        );
5018        let columns = alloc::vec![
5019            ColumnSchema::new("Table", DataType::Text, false),
5020            ColumnSchema::new("Create Table", DataType::Text, false),
5021        ];
5022        let rows = alloc::vec![Row::new(alloc::vec![
5023            Value::Text(name.into()),
5024            Value::Text(ddl),
5025        ])];
5026        Ok(QueryResult::Rows { columns, rows })
5027    }
5028
5029    /// v7.17.0 Phase 3.P0-60 — `SHOW INDEXES FROM <t>`. MySQL
5030    /// surface returns one row per (index × column) with 14
5031    /// columns; v7.17 ships the columns admin probes actually
5032    /// filter on: Table, Non_unique, Key_name, Seq_in_index,
5033    /// Column_name, Null, Index_type.
5034    fn exec_show_indexes(&self, name: &str) -> Result<QueryResult, EngineError> {
5035        let t = self.active_catalog().get(name).ok_or_else(|| {
5036            EngineError::Storage(StorageError::TableNotFound { name: name.into() })
5037        })?;
5038        let columns = alloc::vec![
5039            ColumnSchema::new("Table", DataType::Text, false),
5040            ColumnSchema::new("Non_unique", DataType::Int, false),
5041            ColumnSchema::new("Key_name", DataType::Text, false),
5042            ColumnSchema::new("Seq_in_index", DataType::Int, false),
5043            ColumnSchema::new("Column_name", DataType::Text, false),
5044            ColumnSchema::new("Null", DataType::Text, false),
5045            ColumnSchema::new("Index_type", DataType::Text, false),
5046        ];
5047        let mut rows: Vec<Row> = Vec::new();
5048        for idx in t.indices() {
5049            let col = t
5050                .schema()
5051                .columns
5052                .get(idx.column_position)
5053                .map_or("?".into(), |c| c.name.clone());
5054            let nullable = t
5055                .schema()
5056                .columns
5057                .get(idx.column_position)
5058                .map_or(true, |c| c.nullable);
5059            rows.push(Row::new(alloc::vec![
5060                Value::Text(name.into()),
5061                Value::Int(i32::from(!idx.is_unique)),
5062                Value::Text(idx.name.clone()),
5063                Value::Int(1),
5064                Value::Text(col),
5065                Value::Text(if nullable {
5066                    "YES".into()
5067                } else {
5068                    String::new()
5069                }),
5070                Value::Text("BTREE".into()),
5071            ]));
5072        }
5073        Ok(QueryResult::Rows { columns, rows })
5074    }
5075
5076    /// v7.17.0 Phase 3.P0-61 — `SHOW STATUS`. Returns canonical
5077    /// MySQL server-status counters (2-column `(Variable_name,
5078    /// Value)`).
5079    fn exec_show_status(&self) -> QueryResult {
5080        let columns = alloc::vec![
5081            ColumnSchema::new("Variable_name", DataType::Text, false),
5082            ColumnSchema::new("Value", DataType::Text, false),
5083        ];
5084        let pairs: &[(&str, &str)] = &[
5085            ("Uptime", "0"),
5086            ("Threads_connected", "1"),
5087            ("Threads_running", "1"),
5088            ("Questions", "0"),
5089            ("Slow_queries", "0"),
5090            ("Opened_tables", "0"),
5091            ("Innodb_buffer_pool_pages_total", "0"),
5092        ];
5093        let rows: Vec<Row> = pairs
5094            .iter()
5095            .map(|(k, v)| {
5096                Row::new(alloc::vec![
5097                    Value::Text((*k).into()),
5098                    Value::Text((*v).into())
5099                ])
5100            })
5101            .collect();
5102        QueryResult::Rows { columns, rows }
5103    }
5104
5105    /// v7.17.0 Phase 3.P0-61 — `SHOW VARIABLES`. Returns server-side
5106    /// variables MySQL/MariaDB clients probe at connect time.
5107    fn exec_show_variables(&self) -> QueryResult {
5108        let columns = alloc::vec![
5109            ColumnSchema::new("Variable_name", DataType::Text, false),
5110            ColumnSchema::new("Value", DataType::Text, false),
5111        ];
5112        let mut rows: Vec<Row> = Vec::new();
5113        let canonical: &[(&str, &str)] = &[
5114            ("version", "8.0.35-spg"),
5115            ("version_comment", "SPG dual-stack engine"),
5116            ("character_set_server", "utf8mb4"),
5117            ("collation_server", "utf8mb4_0900_ai_ci"),
5118            ("max_allowed_packet", "67108864"),
5119            ("autocommit", "ON"),
5120            ("sql_mode", "STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION"),
5121            ("time_zone", "SYSTEM"),
5122            ("transaction_isolation", "REPEATABLE-READ"),
5123        ];
5124        for &(k, v) in canonical {
5125            rows.push(Row::new(alloc::vec![
5126                Value::Text(k.into()),
5127                Value::Text(v.into()),
5128            ]));
5129        }
5130        // Session-set parameters surface here too.
5131        for (k, v) in &self.session_params {
5132            if !canonical.iter().any(|(n, _)| (*n).eq_ignore_ascii_case(k)) {
5133                rows.push(Row::new(alloc::vec![
5134                    Value::Text(k.clone()),
5135                    Value::Text(v.clone()),
5136                ]));
5137            }
5138        }
5139        QueryResult::Rows { columns, rows }
5140    }
5141
5142    /// v7.17.0 Phase 3.P0-62 — `SHOW PROCESSLIST`. SPG is
5143    /// single-process so the surface returns one synthetic row
5144    /// describing the current connection (Id, User, Host, db,
5145    /// Command, Time, State, Info).
5146    fn exec_show_processlist(&self) -> QueryResult {
5147        let columns = alloc::vec![
5148            ColumnSchema::new("Id", DataType::Int, false),
5149            ColumnSchema::new("User", DataType::Text, false),
5150            ColumnSchema::new("Host", DataType::Text, false),
5151            ColumnSchema::new("db", DataType::Text, true),
5152            ColumnSchema::new("Command", DataType::Text, false),
5153            ColumnSchema::new("Time", DataType::Int, false),
5154            ColumnSchema::new("State", DataType::Text, true),
5155            ColumnSchema::new("Info", DataType::Text, true),
5156        ];
5157        let rows = alloc::vec![Row::new(alloc::vec![
5158            Value::Int(1),
5159            Value::Text("postgres".into()),
5160            Value::Text("localhost".into()),
5161            Value::Text("postgres".into()),
5162            Value::Text("Query".into()),
5163            Value::Int(0),
5164            Value::Text("executing".into()),
5165            Value::Text("SHOW PROCESSLIST".into()),
5166        ])];
5167        QueryResult::Rows { columns, rows }
5168    }
5169
5170    /// v7.17.0 Phase 3.P0-58 — `SHOW DATABASES` / `SHOW SCHEMAS`.
5171    /// SPG is single-database so the result is the canonical MySQL
5172    /// set every mysql/MariaDB client expects at connect time:
5173    /// `information_schema`, `mysql`, `performance_schema`, `sys`,
5174    /// plus a `postgres` slot so dual-stack callers find their
5175    /// PG-compatible database too.
5176    fn exec_show_databases(&self) -> QueryResult {
5177        let columns = alloc::vec![ColumnSchema::new("Database", DataType::Text, false)];
5178        let names = [
5179            "information_schema",
5180            "mysql",
5181            "performance_schema",
5182            "sys",
5183            "postgres",
5184        ];
5185        let rows: Vec<Row> = names
5186            .iter()
5187            .map(|n| Row::new(alloc::vec![Value::Text((*n).into())]))
5188            .collect();
5189        QueryResult::Rows { columns, rows }
5190    }
5191
5192    /// `SHOW COLUMNS FROM <table>` — one row per column with the
5193    /// declared name, SQL type rendering, and nullability flag.
5194    fn exec_show_columns(&self, table_name: &str) -> Result<QueryResult, EngineError> {
5195        let table =
5196            self.active_catalog()
5197                .get(table_name)
5198                .ok_or_else(|| StorageError::TableNotFound {
5199                    name: table_name.into(),
5200                })?;
5201        let columns = alloc::vec![
5202            ColumnSchema::new("name", DataType::Text, false),
5203            ColumnSchema::new("type", DataType::Text, false),
5204            ColumnSchema::new("nullable", DataType::Bool, false),
5205        ];
5206        let rows: Vec<Row> = table
5207            .schema()
5208            .columns
5209            .iter()
5210            .map(|c| {
5211                Row::new(alloc::vec![
5212                    Value::Text(c.name.clone()),
5213                    Value::Text(alloc::format!("{}", c.ty)),
5214                    Value::Bool(c.nullable),
5215                ])
5216            })
5217            .collect();
5218        Ok(QueryResult::Rows { columns, rows })
5219    }
5220
5221    fn exec_begin(&mut self) -> Result<QueryResult, EngineError> {
5222        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5223        if self.tx_catalogs.contains_key(&tx_id) {
5224            return Err(EngineError::TransactionAlreadyOpen);
5225        }
5226        self.tx_catalogs.insert(
5227            tx_id,
5228            TxState {
5229                catalog: self.catalog.clone(),
5230                savepoints: Vec::new(),
5231            },
5232        );
5233        Ok(QueryResult::CommandOk {
5234            affected: 0,
5235            modified_catalog: false,
5236        })
5237    }
5238
5239    fn exec_commit(&mut self) -> Result<QueryResult, EngineError> {
5240        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5241        let state = self
5242            .tx_catalogs
5243            .remove(&tx_id)
5244            .ok_or(EngineError::NoActiveTransaction)?;
5245        self.catalog = state.catalog;
5246        // All savepoints become permanent at COMMIT and the stack
5247        // resets for the next TX (`state.savepoints` is discarded with
5248        // `state`).
5249        Ok(QueryResult::CommandOk {
5250            affected: 0,
5251            modified_catalog: true,
5252        })
5253    }
5254
5255    fn exec_rollback(&mut self) -> Result<QueryResult, EngineError> {
5256        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5257        if self.tx_catalogs.remove(&tx_id).is_none() {
5258            return Err(EngineError::NoActiveTransaction);
5259        }
5260        // savepoints discarded with the TxState
5261        Ok(QueryResult::CommandOk {
5262            affected: 0,
5263            modified_catalog: false,
5264        })
5265    }
5266
5267    fn exec_savepoint(&mut self, name: String) -> Result<QueryResult, EngineError> {
5268        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5269        let state = self
5270            .tx_catalogs
5271            .get_mut(&tx_id)
5272            .ok_or(EngineError::NoActiveTransaction)?;
5273        // PG re-uses an existing savepoint name by dropping the older
5274        // entry and pushing a fresh one — match that behaviour so
5275        // application code can `SAVEPOINT sp; ...; SAVEPOINT sp` freely.
5276        state.savepoints.retain(|(n, _)| n != &name);
5277        let snapshot = state.catalog.clone();
5278        state.savepoints.push((name, snapshot));
5279        Ok(QueryResult::CommandOk {
5280            affected: 0,
5281            modified_catalog: false,
5282        })
5283    }
5284
5285    fn exec_rollback_to_savepoint(&mut self, name: &str) -> Result<QueryResult, EngineError> {
5286        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5287        let state = self
5288            .tx_catalogs
5289            .get_mut(&tx_id)
5290            .ok_or(EngineError::NoActiveTransaction)?;
5291        let pos = state
5292            .savepoints
5293            .iter()
5294            .rposition(|(n, _)| n == name)
5295            .ok_or_else(|| {
5296                EngineError::Unsupported(alloc::format!("savepoint not found: {name}"))
5297            })?;
5298        // The savepoint stays on the stack (PG semantics): a later
5299        // `RELEASE` or further `ROLLBACK TO` is still allowed. Everything
5300        // after it is discarded.
5301        let snapshot = state.savepoints[pos].1.clone();
5302        state.savepoints.truncate(pos + 1);
5303        state.catalog = snapshot;
5304        Ok(QueryResult::CommandOk {
5305            affected: 0,
5306            modified_catalog: false,
5307        })
5308    }
5309
5310    fn exec_release_savepoint(&mut self, name: &str) -> Result<QueryResult, EngineError> {
5311        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5312        let state = self
5313            .tx_catalogs
5314            .get_mut(&tx_id)
5315            .ok_or(EngineError::NoActiveTransaction)?;
5316        let pos = state
5317            .savepoints
5318            .iter()
5319            .rposition(|(n, _)| n == name)
5320            .ok_or_else(|| {
5321                EngineError::Unsupported(alloc::format!("savepoint not found: {name}"))
5322            })?;
5323        // RELEASE keeps the work since the savepoint, just discards the
5324        // bookmark plus everything nested under it.
5325        state.savepoints.truncate(pos);
5326        Ok(QueryResult::CommandOk {
5327            affected: 0,
5328            modified_catalog: false,
5329        })
5330    }
5331
5332    /// v6.0.4 — synchronous `ALTER INDEX <name> REBUILD [WITH
5333    /// (encoding = …)]`. Walks every table in the active catalog
5334    /// looking for an index matching `stmt.name`, then delegates the
5335    /// rebuild (including any encoding switch) to
5336    /// `Table::rebuild_nsw_index`. The "live" non-blocking
5337    /// optimisation is v6.0.4.1 / v6.1.x territory.
5338    /// v6.7.2 — `ALTER TABLE t SET hot_tier_bytes = X`. Dispatch
5339    /// arm. Currently the only setting is `hot_tier_bytes`; later
5340    /// v6.7.x can extend `AlterTableTarget` without touching this
5341    /// arm structure.
5342    fn exec_alter_table(
5343        &mut self,
5344        s: spg_sql::ast::AlterTableStatement,
5345    ) -> Result<QueryResult, EngineError> {
5346        // v7.13.2 — mailrs round-6 S1: apply each subaction in order.
5347        // On first error the statement aborts; subactions already
5348        // applied stay (no transactional rollback in v7.13 — wrap in
5349        // BEGIN/COMMIT if atomicity matters).
5350        let table_name = s.name.clone();
5351        for target in s.targets {
5352            self.exec_alter_table_subaction(&table_name, target)?;
5353        }
5354        Ok(QueryResult::CommandOk {
5355            affected: 0,
5356            modified_catalog: !self.in_transaction(),
5357        })
5358    }
5359
5360    fn exec_alter_table_subaction(
5361        &mut self,
5362        table_name_outer: &str,
5363        target: spg_sql::ast::AlterTableTarget,
5364    ) -> Result<(), EngineError> {
5365        // Inner helper retains the s.name closure shape; alias to `s`
5366        // for minimal diff against the v7.13.0 body.
5367        struct S<'a> {
5368            name: &'a str,
5369        }
5370        let s = S {
5371            name: table_name_outer,
5372        };
5373        match target {
5374            spg_sql::ast::AlterTableTarget::SetHotTierBytes(n) => {
5375                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5376                    EngineError::Storage(StorageError::TableNotFound {
5377                        name: s.name.into(),
5378                    })
5379                })?;
5380                table.schema_mut().hot_tier_bytes = Some(n);
5381            }
5382            spg_sql::ast::AlterTableTarget::AddForeignKey(fk) => {
5383                // v7.6.8 — resolve FK against the live catalog first
5384                // (validates parent table, columns, indices). Then
5385                // verify every existing row in the child table
5386                // satisfies the new constraint. Then install it.
5387                let cols_snapshot = self
5388                    .active_catalog()
5389                    .get(s.name)
5390                    .ok_or_else(|| {
5391                        EngineError::Storage(StorageError::TableNotFound {
5392                            name: s.name.into(),
5393                        })
5394                    })?
5395                    .schema()
5396                    .columns
5397                    .clone();
5398                let storage_fk =
5399                    resolve_foreign_key(s.name, &cols_snapshot, fk, self.active_catalog())?;
5400                // Verify existing rows. Treat them as a virtual
5401                // INSERT batch — reusing the v7.6.2 enforce helper.
5402                let existing_rows: Vec<Vec<Value>> = self
5403                    .active_catalog()
5404                    .get(s.name)
5405                    .expect("checked above")
5406                    .rows()
5407                    .iter()
5408                    .map(|r| r.values.clone())
5409                    .collect();
5410                enforce_fk_inserts(
5411                    self.active_catalog(),
5412                    s.name,
5413                    core::slice::from_ref(&storage_fk),
5414                    &existing_rows,
5415                )?;
5416                // Reject duplicate constraint name.
5417                let table = self
5418                    .active_catalog_mut()
5419                    .get_mut(s.name)
5420                    .expect("checked above");
5421                if let Some(name) = &storage_fk.name
5422                    && table
5423                        .schema()
5424                        .foreign_keys
5425                        .iter()
5426                        .any(|f| f.name.as_ref() == Some(name))
5427                {
5428                    return Err(EngineError::Unsupported(alloc::format!(
5429                        "ALTER TABLE ADD CONSTRAINT: a constraint named {name:?} already exists"
5430                    )));
5431                }
5432                table.schema_mut().foreign_keys.push(storage_fk);
5433            }
5434            spg_sql::ast::AlterTableTarget::DropForeignKey { name, if_exists } => {
5435                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5436                    EngineError::Storage(StorageError::TableNotFound {
5437                        name: s.name.into(),
5438                    })
5439                })?;
5440                let fks = &mut table.schema_mut().foreign_keys;
5441                let before = fks.len();
5442                fks.retain(|f| f.name.as_ref() != Some(&name));
5443                if fks.len() == before && !if_exists {
5444                    return Err(EngineError::Unsupported(alloc::format!(
5445                        "ALTER TABLE DROP CONSTRAINT: no FK named {name:?} on {:?}",
5446                        s.name
5447                    )));
5448                }
5449                // v7.13.2 mailrs round-6 S7: IF EXISTS silences the miss.
5450            }
5451            spg_sql::ast::AlterTableTarget::AddColumn {
5452                column,
5453                if_not_exists,
5454            } => {
5455                // v7.13.0 — mailrs round-5 G1. Append-only column add
5456                // with back-fill of the DEFAULT (or NULL) into every
5457                // existing row. Column positions don't shift, so we
5458                // skip index rebuild.
5459                let clock = self.clock;
5460                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5461                    EngineError::Storage(StorageError::TableNotFound {
5462                        name: s.name.into(),
5463                    })
5464                })?;
5465                if table
5466                    .schema()
5467                    .columns
5468                    .iter()
5469                    .any(|c| c.name.eq_ignore_ascii_case(&column.name))
5470                {
5471                    if if_not_exists {
5472                        return Ok(());
5473                    }
5474                    return Err(EngineError::Unsupported(alloc::format!(
5475                        "ALTER TABLE ADD COLUMN: column {:?} already exists on {:?}",
5476                        column.name,
5477                        s.name
5478                    )));
5479                }
5480                let col_name = column.name.clone();
5481                let nullable = column.nullable;
5482                let has_default = column.default.is_some() || column.auto_increment;
5483                let col_schema = column_def_to_schema(column)?;
5484                let row_count = table.row_count();
5485                // Compute the back-fill value. Literal / runtime DEFAULT
5486                // funnels through the same resolver that INSERT uses
5487                // (v7.9.21 `resolve_column_default_free`). NULL when
5488                // the column is nullable and has no DEFAULT. NOT NULL
5489                // without DEFAULT errors when the table has existing
5490                // rows — same as PG.
5491                let fill_value: Value = if has_default || col_schema.runtime_default.is_some() {
5492                    resolve_column_default_free(&col_schema, clock)?
5493                } else if nullable || row_count == 0 {
5494                    Value::Null
5495                } else {
5496                    return Err(EngineError::Unsupported(alloc::format!(
5497                        "ALTER TABLE ADD COLUMN {col_name:?}: NOT NULL column requires DEFAULT \
5498                         when the table has existing rows"
5499                    )));
5500                };
5501                table.add_column(col_schema, fill_value);
5502            }
5503            spg_sql::ast::AlterTableTarget::AlterColumnType {
5504                column,
5505                new_type,
5506                using,
5507            } => {
5508                // v7.13.0 — mailrs round-5 G8. Re-evaluate each
5509                // row's column value (either through the USING
5510                // expression if supplied, or as a direct CAST of
5511                // the existing value) and re-coerce to the new
5512                // type. Indices on the column get rebuilt.
5513                let new_data_type = column_type_to_data_type(new_type);
5514                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5515                    EngineError::Storage(StorageError::TableNotFound {
5516                        name: s.name.into(),
5517                    })
5518                })?;
5519                let col_pos = table
5520                    .schema()
5521                    .columns
5522                    .iter()
5523                    .position(|c| c.name.eq_ignore_ascii_case(&column))
5524                    .ok_or_else(|| {
5525                        EngineError::Unsupported(alloc::format!(
5526                            "ALTER COLUMN TYPE: column {column:?} not found on {:?}",
5527                            s.name
5528                        ))
5529                    })?;
5530                let schema_cols = table.schema().columns.clone();
5531                let ctx = eval::EvalContext::new(&schema_cols, None);
5532                let mut new_values: alloc::vec::Vec<Value> =
5533                    alloc::vec::Vec::with_capacity(table.row_count());
5534                for row in table.rows().iter() {
5535                    let raw = match &using {
5536                        Some(expr) => eval::eval_expr(expr, row, &ctx).map_err(|e| {
5537                            EngineError::Unsupported(alloc::format!(
5538                                "ALTER COLUMN TYPE: USING expression failed: {e:?}"
5539                            ))
5540                        })?,
5541                        None => row.values.get(col_pos).cloned().unwrap_or(Value::Null),
5542                    };
5543                    let coerced = coerce_value(raw, new_data_type, &column, col_pos)?;
5544                    new_values.push(coerced);
5545                }
5546                table.schema_mut().columns[col_pos].ty = new_data_type;
5547                for (i, v) in new_values.into_iter().enumerate() {
5548                    let mut row_values = table
5549                        .rows()
5550                        .get(i)
5551                        .expect("bounds-checked above")
5552                        .values
5553                        .clone();
5554                    row_values[col_pos] = v;
5555                    table.update_row(i, row_values)?;
5556                }
5557            }
5558            spg_sql::ast::AlterTableTarget::AddTableConstraint(tc) => {
5559                // v7.14.0 — pg_dump emits PKs as a separate
5560                // ALTER TABLE ADD CONSTRAINT post-CREATE-TABLE.
5561                // For PRIMARY KEY / UNIQUE, install a UC entry
5562                // and the implicit BTree index on the leading
5563                // column. CHECK: append predicate to schema.
5564                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5565                    EngineError::Storage(StorageError::TableNotFound {
5566                        name: s.name.into(),
5567                    })
5568                })?;
5569                let is_pk = matches!(tc, spg_sql::ast::TableConstraint::PrimaryKey { .. });
5570                // v7.22 (mailrs round-13 gap 6) — carry the parsed
5571                // NULLS NOT DISTINCT flag through the ALTER path;
5572                // it was hardcoded false here while the CREATE
5573                // TABLE path honoured it since v7.13.
5574                let nnd = matches!(
5575                    tc,
5576                    spg_sql::ast::TableConstraint::Unique {
5577                        nulls_not_distinct: true,
5578                        ..
5579                    }
5580                );
5581                match tc {
5582                    spg_sql::ast::TableConstraint::PrimaryKey { columns, .. }
5583                    | spg_sql::ast::TableConstraint::Unique { columns, .. } => {
5584                        let positions: Vec<usize> = columns
5585                            .iter()
5586                            .map(|c| {
5587                                table
5588                                    .schema()
5589                                    .columns
5590                                    .iter()
5591                                    .position(|sc| sc.name.eq_ignore_ascii_case(c))
5592                                    .ok_or_else(|| {
5593                                        EngineError::Unsupported(alloc::format!(
5594                                            "ALTER TABLE ADD CONSTRAINT: column {c:?} not found on {:?}",
5595                                            s.name
5596                                        ))
5597                                    })
5598                            })
5599                            .collect::<Result<Vec<_>, _>>()?;
5600                        // Skip if an equivalent UC is already there
5601                        // (idempotent — pg_dump's PK + a prior inline
5602                        // PK shouldn't double-install).
5603                        let already = table
5604                            .schema()
5605                            .uniqueness_constraints
5606                            .iter()
5607                            .any(|u| u.columns == positions);
5608                        if !already {
5609                            table.schema_mut().uniqueness_constraints.push(
5610                                spg_storage::UniquenessConstraint {
5611                                    is_primary_key: is_pk,
5612                                    columns: positions.clone(),
5613                                    nulls_not_distinct: nnd,
5614                                },
5615                            );
5616                            // PK implies NOT NULL on referenced cols.
5617                            if is_pk {
5618                                for p in &positions {
5619                                    if let Some(c) = table.schema_mut().columns.get_mut(*p) {
5620                                        c.nullable = false;
5621                                    }
5622                                }
5623                            }
5624                            // Add a BTree index on the leading
5625                            // column for INSERT-side enforcement.
5626                            let leading = &columns[0];
5627                            let already_idx = table.indices().iter().any(|idx| {
5628                                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
5629                                    && table.schema().columns[idx.column_position].name == *leading
5630                            });
5631                            if !already_idx {
5632                                let suffix = if is_pk { "pkey" } else { "key" };
5633                                let idx_name = alloc::format!("{}_{leading}_{suffix}", s.name);
5634                                let _ = table.add_index(idx_name, leading);
5635                            }
5636                        }
5637                    }
5638                    spg_sql::ast::TableConstraint::Check { expr, .. } => {
5639                        table.schema_mut().checks.push(alloc::format!("{expr}"));
5640                    }
5641                    spg_sql::ast::TableConstraint::Index { name, columns } => {
5642                        // v7.15.0 — ALTER TABLE ADD KEY (cols).
5643                        // mysqldump occasionally emits this
5644                        // post-CREATE-TABLE shape; build a BTree
5645                        // on the leading column using the
5646                        // user-supplied or synthesised name.
5647                        let leading = &columns[0];
5648                        let already_idx = table.indices().iter().any(|idx| {
5649                            matches!(idx.kind, spg_storage::IndexKind::BTree(_))
5650                                && table.schema().columns[idx.column_position].name == *leading
5651                        });
5652                        if !already_idx {
5653                            let idx_name = name
5654                                .clone()
5655                                .unwrap_or_else(|| alloc::format!("{}_{leading}_idx", s.name));
5656                            let _ = table.add_index(idx_name, leading);
5657                        }
5658                    }
5659                    spg_sql::ast::TableConstraint::FulltextIndex { name, columns } => {
5660                        // v7.17.0 Phase 2.2 — ALTER TABLE ADD
5661                        // FULLTEXT KEY (cols). Builds one
5662                        // fulltext-GIN per named column so MATCH
5663                        // AGAINST gets a real inverted index.
5664                        // Multi-column declarations expand to
5665                        // per-column GINs (the leading column
5666                        // drives MATCH AGAINST planning).
5667                        for (k, col) in columns.iter().enumerate() {
5668                            let already_idx = table.indices().iter().any(|idx| {
5669                                matches!(idx.kind, spg_storage::IndexKind::GinFulltext(_))
5670                                    && table.schema().columns[idx.column_position].name == *col
5671                            });
5672                            if already_idx {
5673                                continue;
5674                            }
5675                            let idx_name = match (&name, columns.len(), k) {
5676                                (Some(n), 1, _) => n.clone(),
5677                                (Some(n), _, k) => alloc::format!("{n}_{k}"),
5678                                (None, _, _) => {
5679                                    alloc::format!("{}_{col}_ftidx", s.name)
5680                                }
5681                            };
5682                            let _ = table.add_gin_fulltext_index(idx_name, col);
5683                        }
5684                    }
5685                }
5686            }
5687            spg_sql::ast::AlterTableTarget::DropColumn {
5688                column,
5689                if_exists,
5690                cascade,
5691            } => {
5692                // v7.13.3 — mailrs round-7 S8. Remove the column +
5693                // every row's value at that position; drop any index
5694                // on the column. RESTRICT (default) rejects when an
5695                // FK on this table or partial-index predicate
5696                // references the column; CASCADE removes those
5697                // dependents first.
5698                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5699                    EngineError::Storage(StorageError::TableNotFound {
5700                        name: s.name.into(),
5701                    })
5702                })?;
5703                let col_pos = match table
5704                    .schema()
5705                    .columns
5706                    .iter()
5707                    .position(|c| c.name.eq_ignore_ascii_case(&column))
5708                {
5709                    Some(p) => p,
5710                    None => {
5711                        if if_exists {
5712                            return Ok(());
5713                        }
5714                        return Err(EngineError::Unsupported(alloc::format!(
5715                            "ALTER TABLE DROP COLUMN: column {column:?} not found on {:?}",
5716                            s.name
5717                        )));
5718                    }
5719                };
5720                // Dependent check: FKs whose local columns include
5721                // col_pos. CASCADE drops them; otherwise reject.
5722                let dependent_fks: Vec<usize> = table
5723                    .schema()
5724                    .foreign_keys
5725                    .iter()
5726                    .enumerate()
5727                    .filter_map(|(i, fk)| {
5728                        if fk.local_columns.contains(&col_pos) {
5729                            Some(i)
5730                        } else {
5731                            None
5732                        }
5733                    })
5734                    .collect();
5735                if !dependent_fks.is_empty() && !cascade {
5736                    return Err(EngineError::Unsupported(alloc::format!(
5737                        "ALTER TABLE DROP COLUMN {column:?}: column has FK dependents; \
5738                         use DROP COLUMN ... CASCADE to remove them"
5739                    )));
5740                }
5741                // CASCADE the FK removals first.
5742                if cascade {
5743                    // Drop in reverse so indices stay valid.
5744                    let mut sorted = dependent_fks.clone();
5745                    sorted.sort();
5746                    sorted.reverse();
5747                    let fks = &mut table.schema_mut().foreign_keys;
5748                    for i in sorted {
5749                        fks.remove(i);
5750                    }
5751                }
5752                // Drop the column. New helper on Table does the
5753                // row + schema + index shift atomically.
5754                table.drop_column(col_pos);
5755            }
5756            spg_sql::ast::AlterTableTarget::SetTriggerEnabled { which, enabled } => {
5757                // v7.16.1 — mailrs round-9 A.2.b. pg_dump
5758                // --disable-triggers wraps each table's data
5759                // block with `ALTER TABLE … DISABLE TRIGGER ALL`
5760                // / `… ENABLE TRIGGER ALL`. Toggle the enabled
5761                // flag on every matching trigger so the row-
5762                // write paths skip them; the catalog snapshot
5763                // persists the new state across restarts.
5764                let table_name = s.name.to_string();
5765                let trigs = self.active_catalog_mut().triggers_mut();
5766                let mut touched = false;
5767                for t in trigs.iter_mut() {
5768                    if !t.table.eq_ignore_ascii_case(&table_name) {
5769                        continue;
5770                    }
5771                    match &which {
5772                        spg_sql::ast::TriggerSelector::All => {
5773                            t.enabled = enabled;
5774                            touched = true;
5775                        }
5776                        spg_sql::ast::TriggerSelector::Named(name) => {
5777                            if t.name.eq_ignore_ascii_case(name) {
5778                                t.enabled = enabled;
5779                                touched = true;
5780                            }
5781                        }
5782                    }
5783                }
5784                // PG semantics: `ALL` on a table with no
5785                // triggers is a no-op (no error). A `Named`
5786                // form pointing at a non-existent trigger
5787                // raises in PG; v7.16.1 also raises so we
5788                // don't silently lose state.
5789                if !touched {
5790                    if let spg_sql::ast::TriggerSelector::Named(name) = &which {
5791                        return Err(EngineError::Unsupported(alloc::format!(
5792                            "ALTER TABLE {table_name:?} {} TRIGGER {name:?}: no such trigger on table",
5793                            if enabled { "ENABLE" } else { "DISABLE" },
5794                        )));
5795                    }
5796                }
5797            }
5798            spg_sql::ast::AlterTableTarget::SetColumnAutoIncrement { column, seq_name } => {
5799                // pg_dump's identity form names an IMPLICIT sequence
5800                // (`… AS IDENTITY ( SEQUENCE NAME s … )`) that never
5801                // gets its own CREATE SEQUENCE statement, while the
5802                // data section still calls `setval(s, …)`. Make the
5803                // sequence exist (idempotent) so those calls land.
5804                if let Some(seq) = seq_name {
5805                    let _ = self.exec_create_sequence(spg_sql::ast::CreateSequenceStatement {
5806                        name: seq,
5807                        if_not_exists: true,
5808                        temporary: false,
5809                        data_type: None,
5810                        options: spg_sql::ast::SequenceOptions::default(),
5811                    })?;
5812                }
5813                // v7.22 (round-13 T2) — pg_dump's serial/identity
5814                // spellings (`SET DEFAULT nextval(…)` / `ADD
5815                // GENERATED … AS IDENTITY`) lower here: flip the
5816                // column's auto-increment flag so post-import
5817                // INSERTs without an explicit value keep numbering
5818                // (max+1 semantics; the dump's setval() calls are
5819                // no-ops by construction).
5820                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5821                    EngineError::Storage(StorageError::TableNotFound {
5822                        name: s.name.into(),
5823                    })
5824                })?;
5825                let pos = table
5826                    .schema()
5827                    .columns
5828                    .iter()
5829                    .position(|c| c.name.eq_ignore_ascii_case(&column))
5830                    .ok_or_else(|| {
5831                        EngineError::Unsupported(alloc::format!(
5832                            "ALTER COLUMN {column:?}: no such column on {:?}",
5833                            s.name
5834                        ))
5835                    })?;
5836                let col = &table.schema().columns[pos];
5837                if !matches!(
5838                    col.ty,
5839                    spg_storage::DataType::SmallInt
5840                        | spg_storage::DataType::Int
5841                        | spg_storage::DataType::BigInt
5842                ) {
5843                    return Err(EngineError::Unsupported(alloc::format!(
5844                        "auto-increment applies to integer columns only ({column:?} is {:?})",
5845                        col.ty
5846                    )));
5847                }
5848                table.schema_mut().columns[pos].auto_increment = true;
5849            }
5850            spg_sql::ast::AlterTableTarget::RenameTable { new } => {
5851                // v7.16.2 — table-level rename (mailrs round-10
5852                // A.5 — used by migrate-042's `ALTER TABLE
5853                // contacts RENAME TO email_contacts`). Storage
5854                // helper updates the schema + by_name index +
5855                // dangling FK / trigger references in one
5856                // atomic step.
5857                let old = s.name.to_string();
5858                self.active_catalog_mut()
5859                    .rename_table(&old, &new)
5860                    .map_err(EngineError::Storage)?;
5861            }
5862            spg_sql::ast::AlterTableTarget::RenameColumn { old, new } => {
5863                // v7.15.0 — `ALTER TABLE t RENAME [COLUMN] old TO
5864                // new`. Rename the column in the schema; rewrite
5865                // every stored source string on this table that
5866                // references it as a (potentially-qualified)
5867                // column identifier: CHECK predicates, partial-
5868                // index predicates, runtime DEFAULT expressions.
5869                // Then walk catalog triggers on this table and
5870                // patch any `UPDATE OF` column list. Function and
5871                // trigger bodies are NOT auto-rewritten — that
5872                // surface is dynamic SQL territory; users update
5873                // those separately (matches PG plpgsql behavior:
5874                // a column rename invalidates name-referencing
5875                // plpgsql at call time, not rename time).
5876                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5877                    EngineError::Storage(StorageError::TableNotFound {
5878                        name: s.name.into(),
5879                    })
5880                })?;
5881                let col_pos = table
5882                    .schema()
5883                    .columns
5884                    .iter()
5885                    .position(|c| c.name.eq_ignore_ascii_case(&old))
5886                    .ok_or_else(|| {
5887                        EngineError::Unsupported(alloc::format!(
5888                            "ALTER TABLE RENAME COLUMN: column {old:?} not found on {:?}",
5889                            s.name
5890                        ))
5891                    })?;
5892                // Reject same-name (case-insensitive) collision.
5893                if table
5894                    .schema()
5895                    .columns
5896                    .iter()
5897                    .enumerate()
5898                    .any(|(i, c)| i != col_pos && c.name.eq_ignore_ascii_case(&new))
5899                {
5900                    return Err(EngineError::Unsupported(alloc::format!(
5901                        "ALTER TABLE RENAME COLUMN: column {new:?} already exists on {:?}",
5902                        s.name
5903                    )));
5904                }
5905                // Schema rename first — even idempotent same-name
5906                // rename (`ALTER TABLE t RENAME a TO a`) needs to
5907                // be a no-op, not an error.
5908                if old.eq_ignore_ascii_case(&new) {
5909                    return Ok(());
5910                }
5911                table.rename_column(col_pos, &new);
5912                // Rewrite per-column runtime_default sources on
5913                // every column of this table — a DEFAULT expression
5914                // on column X may reference column Y by name (rare,
5915                // but legal in PG when the value is supplied via a
5916                // function that takes the row).
5917                let n_cols = table.schema().columns.len();
5918                for i in 0..n_cols {
5919                    let rt = table.schema().columns[i].runtime_default.clone();
5920                    if let Some(src) = rt {
5921                        let rewritten = rewrite_column_in_source(&src, &old, &new)?;
5922                        table.schema_mut().columns[i].runtime_default = Some(rewritten);
5923                    }
5924                }
5925                // Rewrite table-level CHECK predicates.
5926                let checks = table.schema().checks.clone();
5927                let mut new_checks = Vec::with_capacity(checks.len());
5928                for chk in checks {
5929                    new_checks.push(rewrite_column_in_source(&chk, &old, &new)?);
5930                }
5931                table.schema_mut().checks = new_checks;
5932                // Rewrite per-index partial_predicate sources.
5933                let n_idx = table.indices().len();
5934                for i in 0..n_idx {
5935                    let pred = table.indices()[i].partial_predicate.clone();
5936                    if let Some(src) = pred {
5937                        let rewritten = rewrite_column_in_source(&src, &old, &new)?;
5938                        // SAFETY: indices_mut would be cleanest, but
5939                        // partial_predicate is the only mutable field
5940                        // here; reach in via the public mut accessor.
5941                        table.set_partial_predicate(i, Some(rewritten));
5942                    }
5943                }
5944                // Walk catalog triggers; patch `update_columns` on
5945                // triggers attached to this table.
5946                let table_name = s.name.to_string();
5947                for trig in self.active_catalog_mut().triggers_mut() {
5948                    if !trig.table.eq_ignore_ascii_case(&table_name) {
5949                        continue;
5950                    }
5951                    for c in &mut trig.update_columns {
5952                        if c.eq_ignore_ascii_case(&old) {
5953                            *c = new.clone();
5954                        }
5955                    }
5956                }
5957            }
5958        }
5959        Ok(())
5960    }
5961
5962    fn exec_alter_index(
5963        &mut self,
5964        stmt: spg_sql::ast::AlterIndexStatement,
5965    ) -> Result<QueryResult, EngineError> {
5966        // Translate the optional SQL-side encoding choice into the
5967        // storage-side enum; the same SqlVecEncoding -> VecEncoding
5968        // bridge `column_type_to_data_type` uses.
5969        let spg_sql::ast::AlterIndexStatement {
5970            name: idx_name,
5971            target,
5972        } = stmt;
5973        // v7.16.2 — RENAME TO branch (mailrs round-10 migrate-042).
5974        // IF EXISTS makes a missing index a no-op rather than an
5975        // error, mirroring PG semantics.
5976        if let spg_sql::ast::AlterIndexTarget::Rename { new, if_exists } = target {
5977            let renamed = self.active_catalog_mut().rename_index(&idx_name, &new);
5978            return match renamed {
5979                Ok(()) => Ok(QueryResult::CommandOk {
5980                    affected: 0,
5981                    modified_catalog: !self.in_transaction(),
5982                }),
5983                Err(StorageError::IndexNotFound { .. }) if if_exists => {
5984                    Ok(QueryResult::CommandOk {
5985                        affected: 0,
5986                        modified_catalog: false,
5987                    })
5988                }
5989                Err(e) => Err(EngineError::Storage(e)),
5990            };
5991        }
5992        let spg_sql::ast::AlterIndexTarget::Rebuild { encoding } = target else {
5993            unreachable!("Rename branch returned above");
5994        };
5995        let target = encoding.map(|e| match e {
5996            SqlVecEncoding::F32 => VecEncoding::F32,
5997            SqlVecEncoding::Sq8 => VecEncoding::Sq8,
5998            SqlVecEncoding::F16 => VecEncoding::F16,
5999        });
6000        // Linear scan: index names are globally unique within a
6001        // catalog (enforced by add_nsw_index_inner) so the first
6002        // match is the only one. Save the table name to avoid
6003        // borrowing while we then take a mut borrow.
6004        let table_name = {
6005            let cat = self.active_catalog();
6006            let mut found: Option<String> = None;
6007            for tname in cat.table_names() {
6008                if let Some(t) = cat.get(&tname)
6009                    && t.indices().iter().any(|i| i.name == idx_name)
6010                {
6011                    found = Some(tname);
6012                    break;
6013                }
6014            }
6015            found.ok_or_else(|| {
6016                EngineError::Storage(StorageError::IndexNotFound {
6017                    name: idx_name.clone(),
6018                })
6019            })?
6020        };
6021        let table = self
6022            .active_catalog_mut()
6023            .get_mut(&table_name)
6024            .expect("table found above");
6025        table.rebuild_nsw_index(&idx_name, target)?;
6026        // v6.3.1 — ALTER INDEX REBUILD potentially with new encoding
6027        // changes cost characteristics; evict any cached plans.
6028        self.plan_cache.evict_referencing(&table_name);
6029        Ok(QueryResult::CommandOk {
6030            affected: 0,
6031            modified_catalog: !self.in_transaction(),
6032        })
6033    }
6034
6035    fn exec_create_index(
6036        &mut self,
6037        stmt: CreateIndexStatement,
6038    ) -> Result<QueryResult, EngineError> {
6039        let table = self
6040            .active_catalog_mut()
6041            .get_mut(&stmt.table)
6042            .ok_or_else(|| {
6043                EngineError::Storage(StorageError::TableNotFound {
6044                    name: stmt.table.clone(),
6045                })
6046            })?;
6047        // `IF NOT EXISTS` reduces DuplicateIndex to a no-op CommandOk.
6048        if stmt.if_not_exists && table.indices().iter().any(|i| i.name == stmt.name) {
6049            return Ok(QueryResult::CommandOk {
6050                affected: 0,
6051                modified_catalog: false,
6052            });
6053        }
6054        // v7.9.14 — multi-column index parses through; engine
6055        // builds a single-column BTree on the leading column only.
6056        // The extras live on the AST so spg-server's dispatcher
6057        // can emit a PG-wire NoticeResponse / log line. Composite
6058        // BTree keys land in v7.10.
6059        let _ = &stmt.extra_columns; // intentional drop on engine side
6060        let table_name = stmt.table.clone();
6061        // v6.8.0 — resolve INCLUDE column names to positions. Done
6062        // before `add_index` so a typo error surfaces before any
6063        // catalog mutation lands.
6064        let included_positions: Vec<usize> = if stmt.included_columns.is_empty() {
6065            Vec::new()
6066        } else {
6067            let schema = table.schema();
6068            stmt.included_columns
6069                .iter()
6070                .map(|c| {
6071                    schema.column_position(c).ok_or_else(|| {
6072                        EngineError::Storage(StorageError::ColumnNotFound { column: c.clone() })
6073                    })
6074                })
6075                .collect::<Result<Vec<_>, _>>()?
6076        };
6077        match stmt.method {
6078            IndexMethod::BTree => table.add_index(stmt.name.clone(), &stmt.column)?,
6079            IndexMethod::Hnsw => {
6080                if !included_positions.is_empty() {
6081                    return Err(EngineError::Unsupported(
6082                        "INCLUDE columns are not supported on HNSW indexes".into(),
6083                    ));
6084                }
6085                table.add_nsw_index(stmt.name.clone(), &stmt.column, spg_storage::NSW_DEFAULT_M)?;
6086            }
6087            // v6.7.1 — BRIN. Pure metadata; no in-memory data.
6088            IndexMethod::Brin => {
6089                if !included_positions.is_empty() {
6090                    return Err(EngineError::Unsupported(
6091                        "INCLUDE columns are not supported on BRIN indexes".into(),
6092                    ));
6093                }
6094                table.add_brin_index(stmt.name.clone(), &stmt.column)?;
6095            }
6096            // v7.12.3 — GIN inverted index. Real posting-list-backed
6097            // GIN when the indexed column is `tsvector`; falls back
6098            // to a BTree on the leading column for any other column
6099            // type so v7.9.26b's `pg_dump` compatibility (GIN on
6100            // JSONB etc. silently loading as BTree) is preserved.
6101            // Operators see the real GIN only where it matters; old
6102            // schemas keep loading.
6103            IndexMethod::Gin => {
6104                if !included_positions.is_empty() {
6105                    return Err(EngineError::Unsupported(
6106                        "INCLUDE columns are not supported on GIN indexes".into(),
6107                    ));
6108                }
6109                let col_pos = table
6110                    .schema()
6111                    .column_position(&stmt.column)
6112                    .ok_or_else(|| {
6113                        EngineError::Storage(StorageError::ColumnNotFound {
6114                            column: stmt.column.clone(),
6115                        })
6116                    })?;
6117                let col_ty = table.schema().columns[col_pos].ty;
6118                // v7.15.0 — `gin_trgm_ops` on a TEXT/VARCHAR
6119                // column dispatches to the real trigram-shingle
6120                // GIN build (LIKE / similarity acceleration).
6121                // Other GIN opclasses fall through to the regular
6122                // tsvector-vs-BTree split below.
6123                let is_trgm = stmt
6124                    .opclass
6125                    .as_deref()
6126                    .is_some_and(|op| op.eq_ignore_ascii_case("gin_trgm_ops"));
6127                if is_trgm
6128                    && matches!(
6129                        col_ty,
6130                        spg_storage::DataType::Text | spg_storage::DataType::Varchar(_)
6131                    )
6132                {
6133                    table
6134                        .add_gin_trgm_index(stmt.name.clone(), &stmt.column)
6135                        .map_err(EngineError::Storage)?;
6136                } else if col_ty == spg_storage::DataType::TsVector {
6137                    table
6138                        .add_gin_index(stmt.name.clone(), &stmt.column)
6139                        .map_err(EngineError::Storage)?;
6140                } else {
6141                    // v7.9.26b BTree fallback — the catalog still
6142                    // gets an index entry on the leading column so
6143                    // pg_dump scripts that name GIN on JSONB / etc.
6144                    // load clean; query-time gain stays opt-in for
6145                    // tsvector callers.
6146                    table.add_index(stmt.name.clone(), &stmt.column)?;
6147                }
6148            }
6149        }
6150        if !included_positions.is_empty()
6151            && let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name)
6152        {
6153            idx.included_columns = included_positions;
6154        }
6155        // v6.8.1 — persist partial-index predicate. Stored as the
6156        // expression's Display form so the catalog snapshot stays
6157        // pure (storage has no spg-sql dependency). The runtime
6158        // maintenance path treats partial indexes identically to
6159        // full indexes for v6.8.1 (over-maintenance is safe; the
6160        // planner-side "use partial when query WHERE implies the
6161        // predicate" pass is STABILITY carve-out).
6162        if let Some(pred_expr) = &stmt.partial_predicate {
6163            let canonical = pred_expr.to_string();
6164            // v7.13.2 — mailrs round-6 S2. PG's `pg_trgm` uses
6165            // `CREATE INDEX … USING gin(col gin_trgm_ops) WHERE …`
6166            // routinely to slim trigram indexes. SPG now persists
6167            // the predicate for GIN / BRIN / HNSW the same way it
6168            // already does for BTree — same v6.8.1 "over-maintain
6169            // is safe; planner-side partial routing is STABILITY
6170            // carve-out" semantics. HNSW carries an additional
6171            // caveat: the predicate isn't applied at index build
6172            // time (would require per-row eval inside the NSW
6173            // construction loop), so the index oversamples; query
6174            // time the WHERE clause still filters correctly.
6175            if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
6176                idx.partial_predicate = Some(canonical);
6177            }
6178        }
6179        // v6.8.2 — persist expression index key. Same Display-form
6180        // storage; the runtime maintenance pass evaluates each
6181        // row's expression to derive the index key, but for v6.8.2
6182        // the engine falls through to the bare-column-reference
6183        // path and the expression is preserved for format-layer
6184        // round-trip + future planner work. Carved-out in
6185        // STABILITY § "Out of v6.8".
6186        if let Some(key_expr) = &stmt.expression {
6187            if matches!(
6188                stmt.method,
6189                IndexMethod::Hnsw | IndexMethod::Brin | IndexMethod::Gin
6190            ) {
6191                return Err(EngineError::Unsupported(
6192                    "Expression keys are not supported on HNSW or BRIN indexes".into(),
6193                ));
6194            }
6195            let canonical = key_expr.to_string();
6196            if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
6197                idx.expression = Some(canonical);
6198            }
6199        }
6200        // v7.9.29 — persist `is_unique` flag on the storage Index.
6201        // Combined with `partial_predicate`, INSERT enforcement
6202        // checks that no other row whose predicate evaluates true
6203        // shares the same indexed key. Parser already rejected
6204        // `UNIQUE` on HNSW / BRIN, so plain BTree here.
6205        // For multi-column UNIQUE INDEX the extras matter (the
6206        // full tuple is the uniqueness key), so resolve them to
6207        // column positions and persist on the index too.
6208        if stmt.is_unique {
6209            let mut extra_positions: alloc::vec::Vec<usize> = alloc::vec::Vec::new();
6210            for col_name in &stmt.extra_columns {
6211                let pos = table
6212                    .schema()
6213                    .columns
6214                    .iter()
6215                    .position(|c| c.name.eq_ignore_ascii_case(col_name))
6216                    .ok_or_else(|| {
6217                        EngineError::Unsupported(alloc::format!(
6218                            "UNIQUE INDEX {:?}: extra column {col_name:?} not in table {:?}",
6219                            stmt.name,
6220                            stmt.table
6221                        ))
6222                    })?;
6223                extra_positions.push(pos);
6224            }
6225            if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
6226                idx.is_unique = true;
6227                idx.extra_column_positions = extra_positions;
6228            }
6229            // At index-creation time, check the existing rows for
6230            // pre-existing duplicates that would have violated the
6231            // new constraint — otherwise CREATE UNIQUE INDEX would
6232            // silently leave duplicates in place.
6233            let snapshot_indices = table.indices().to_vec();
6234            let snapshot_rows: alloc::vec::Vec<spg_storage::Row> =
6235                table.rows().iter().cloned().collect();
6236            let snapshot_schema = table.schema().clone();
6237            let idx_ref = snapshot_indices
6238                .iter()
6239                .find(|i| i.name == stmt.name)
6240                .expect("just-added index");
6241            check_existing_unique_violation(idx_ref, &snapshot_schema, &snapshot_rows)?;
6242        }
6243        // v6.3.1 — adding an index can change the optimal plan for
6244        // any cached query that references this table.
6245        self.plan_cache.evict_referencing(&table_name);
6246        Ok(QueryResult::CommandOk {
6247            affected: 0,
6248            modified_catalog: !self.in_transaction(),
6249        })
6250    }
6251
6252    /// v7.13.3 — mailrs round-7 S9. SPG-specific reconciliation
6253    /// for `CREATE TABLE IF NOT EXISTS` when the table already
6254    /// exists. Adds missing columns + inline FKs from the new
6255    /// definition; existing columns / constraints stay untouched.
6256    /// New columns with a `NOT NULL` declaration without a
6257    /// `DEFAULT` are reported as a clear error rather than
6258    /// silently dropped — this is the "fail loud on real
6259    /// incompatibility, fail silent on schema-superset" tradeoff.
6260    fn reconcile_table_if_not_exists(
6261        &mut self,
6262        stmt: CreateTableStatement,
6263    ) -> Result<QueryResult, EngineError> {
6264        let table_name = stmt.name.clone();
6265        let clock = self.clock;
6266        let existing_col_names: alloc::collections::BTreeSet<String> = self
6267            .active_catalog()
6268            .get(&table_name)
6269            .expect("checked above")
6270            .schema()
6271            .columns
6272            .iter()
6273            .map(|c| c.name.to_ascii_lowercase())
6274            .collect();
6275        let row_count = self
6276            .active_catalog()
6277            .get(&table_name)
6278            .expect("checked above")
6279            .row_count();
6280        // Collect missing column defs in source order.
6281        let new_columns: alloc::vec::Vec<spg_sql::ast::ColumnDef> = stmt
6282            .columns
6283            .iter()
6284            .filter(|c| !existing_col_names.contains(&c.name.to_ascii_lowercase()))
6285            .cloned()
6286            .collect();
6287        for col_def in new_columns {
6288            let col_name = col_def.name.clone();
6289            let nullable = col_def.nullable;
6290            let has_default = col_def.default.is_some() || col_def.auto_increment;
6291            let col_schema = column_def_to_schema(col_def)?;
6292            let fill_value: Value = if has_default || col_schema.runtime_default.is_some() {
6293                resolve_column_default_free(&col_schema, clock)?
6294            } else if nullable || row_count == 0 {
6295                Value::Null
6296            } else {
6297                return Err(EngineError::Unsupported(alloc::format!(
6298                    "CREATE TABLE IF NOT EXISTS {table_name:?}: reconciling \
6299                     column {col_name:?} requires DEFAULT (existing rows would violate NOT NULL)"
6300                )));
6301            };
6302            let table = self
6303                .active_catalog_mut()
6304                .get_mut(&table_name)
6305                .expect("checked above");
6306            table.add_column(col_schema, fill_value);
6307        }
6308        // Resolve any newly-added inline FKs (column-level
6309        // REFERENCES forms) and install. Skip FKs whose local
6310        // columns we didn't have in the existing table.
6311        let table_cols_now = self
6312            .active_catalog()
6313            .get(&table_name)
6314            .expect("checked above")
6315            .schema()
6316            .columns
6317            .clone();
6318        for fk in stmt.foreign_keys {
6319            // Only install FKs whose every local column resolves
6320            // — older catalogs may have a column the new FK
6321            // references but not the column the new FK declares.
6322            let all_resolved = fk.columns.iter().all(|c| {
6323                table_cols_now
6324                    .iter()
6325                    .any(|sc| sc.name.eq_ignore_ascii_case(c))
6326            });
6327            if !all_resolved {
6328                continue;
6329            }
6330            let already_present = {
6331                let table = self
6332                    .active_catalog()
6333                    .get(&table_name)
6334                    .expect("checked above");
6335                table.schema().foreign_keys.iter().any(|f| {
6336                    f.parent_table.eq_ignore_ascii_case(&fk.parent_table)
6337                        && f.local_columns.len() == fk.columns.len()
6338                })
6339            };
6340            if already_present {
6341                continue;
6342            }
6343            let storage_fk =
6344                resolve_foreign_key(&table_name, &table_cols_now, fk, self.active_catalog())?;
6345            let table = self
6346                .active_catalog_mut()
6347                .get_mut(&table_name)
6348                .expect("checked above");
6349            table.schema_mut().foreign_keys.push(storage_fk);
6350        }
6351        Ok(QueryResult::CommandOk {
6352            affected: 0,
6353            modified_catalog: !self.in_transaction(),
6354        })
6355    }
6356
6357    /// v7.14.0 — DROP TABLE handler (pg_dump / mysqldump preamble).
6358    fn exec_drop_table(
6359        &mut self,
6360        names: Vec<String>,
6361        if_exists: bool,
6362    ) -> Result<QueryResult, EngineError> {
6363        for name in names {
6364            let dropped = self.active_catalog_mut().drop_table(&name);
6365            if !dropped && !if_exists {
6366                return Err(EngineError::Storage(StorageError::TableNotFound { name }));
6367            }
6368        }
6369        Ok(QueryResult::CommandOk {
6370            affected: 0,
6371            modified_catalog: !self.in_transaction(),
6372        })
6373    }
6374
6375    /// v7.14.0 — DROP INDEX handler.
6376    fn exec_drop_index(
6377        &mut self,
6378        name: String,
6379        if_exists: bool,
6380    ) -> Result<QueryResult, EngineError> {
6381        let dropped = self.active_catalog_mut().drop_named_index(&name);
6382        if !dropped && !if_exists {
6383            return Err(EngineError::Storage(StorageError::IndexNotFound { name }));
6384        }
6385        Ok(QueryResult::CommandOk {
6386            affected: 0,
6387            modified_catalog: !self.in_transaction(),
6388        })
6389    }
6390
6391    fn exec_create_table(
6392        &mut self,
6393        stmt: CreateTableStatement,
6394    ) -> Result<QueryResult, EngineError> {
6395        if stmt.if_not_exists && self.active_catalog().get(&stmt.name).is_some() {
6396            // v7.16.2 — PG-strict silent no-op (mailrs round-10
6397            // surfaced this). v7.13.3's "reconcile by adding
6398            // missing columns" was friendly for mailrs round-7
6399            // where init-schema's `contacts` and migrate-023's
6400            // CardDAV `contacts` collided; but it ALSO silently
6401            // added columns to existing tables when later
6402            // migrations had a duplicate `CREATE TABLE IF NOT
6403            // EXISTS <t> (different-shape-cols)` shape. mailrs's
6404            // migrate-030 has exactly that — re-declares
6405            // system_config with `key` even though init-schema
6406            // already created it with `config_key`. PG's silent
6407            // no-op leaves system_config at `config_key`;
6408            // v7.13.3 added a phantom `key` column that then
6409            // tripped migrate-040's idempotent rename guard.
6410            // mailrs v1.7.106 ships the proper PG-style
6411            // contacts rename via DO + IF EXISTS, so SPG can
6412            // revert to PG-strict here without re-breaking the
6413            // round-7 case.
6414            return Ok(QueryResult::CommandOk {
6415                affected: 0,
6416                modified_catalog: false,
6417            });
6418        }
6419        let table_name = stmt.name.clone();
6420        // v7.9.13 — pluck the names of any columns marked
6421        // `PRIMARY KEY` inline so the post-create-table pass can
6422        // build an implicit BTree index. mailrs F1.
6423        let inline_pk_columns: Vec<String> = stmt
6424            .columns
6425            .iter()
6426            .filter(|c| c.is_primary_key)
6427            .map(|c| c.name.clone())
6428            .collect();
6429        // v7.9.19 — table-level constraints: PRIMARY KEY (a, b, ...)
6430        // and UNIQUE (a, b, ...). Each builds a BTree index on the
6431        // leading column (the existing single-column storage tier)
6432        // and registers a UniquenessConstraint on the schema for
6433        // INSERT-time enforcement of the full tuple. mailrs G1/G6.
6434        let cols = stmt
6435            .columns
6436            .into_iter()
6437            .map(column_def_to_schema)
6438            .collect::<Result<Vec<_>, _>>()?;
6439        // v7.17.0 Phase 1.4 + 1.5 — classify every raw
6440        // user_type_ref (parked as user_enum_type by
6441        // column_def_to_schema) into either an enum binding or a
6442        // domain binding. For domains, also rewrite the column's
6443        // base DataType from the placeholder Text to the domain's
6444        // declared base. Unknown idents are still a hard error
6445        // here (same as Phase 1.4) so silent acceptance never
6446        // happens.
6447        let mut cols = cols;
6448        for col in cols.iter_mut() {
6449            let Some(name) = col.user_enum_type.take() else {
6450                continue;
6451            };
6452            let cat = self.active_catalog();
6453            if cat.enum_types().contains_key(&name) {
6454                col.user_enum_type = Some(name);
6455                continue;
6456            }
6457            if let Some(dom) = cat.domain_types().get(&name) {
6458                col.ty = dom.base_type;
6459                col.user_domain_type = Some(name);
6460                if !dom.nullable {
6461                    col.nullable = false;
6462                }
6463                continue;
6464            }
6465            return Err(EngineError::Unsupported(alloc::format!(
6466                "column {:?}: unknown column type {:?} (not a built-in, ENUM, or DOMAIN)",
6467                col.name,
6468                name
6469            )));
6470        }
6471        for tc in &stmt.table_constraints {
6472            if let spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } = tc {
6473                for col_name in columns {
6474                    if let Some(col) = cols.iter_mut().find(|c| c.name == *col_name) {
6475                        col.nullable = false;
6476                    }
6477                }
6478            }
6479        }
6480        // v7.6.1 — resolve every FK in the statement against the
6481        // already-known catalog. Validates: parent table exists,
6482        // parent column names exist, arity matches, parent columns
6483        // have a PK / UNIQUE index. Self-referencing FKs (parent
6484        // table == this table) resolve against the column list we
6485        // just built — they don't need the catalog yet.
6486        let mut fks: Vec<spg_storage::ForeignKeyConstraint> =
6487            Vec::with_capacity(stmt.foreign_keys.len());
6488        for fk in stmt.foreign_keys {
6489            // v7.14.0 — when SET FOREIGN_KEY_CHECKS=0 is in effect
6490            // (mysqldump preamble + bulk imports), defer FK
6491            // resolution if the parent table isn't in the catalog
6492            // yet. The FK is queued and resolved when checks flip
6493            // back on. Self-references stay in-band (the parent is
6494            // the same as the child we're building).
6495            let needs_parent = !fk.parent_table.eq_ignore_ascii_case(&table_name);
6496            if !self.foreign_key_checks
6497                && needs_parent
6498                && self.active_catalog().get(&fk.parent_table).is_none()
6499            {
6500                self.pending_foreign_keys.push((table_name.clone(), fk));
6501                continue;
6502            }
6503            fks.push(resolve_foreign_key(
6504                &table_name,
6505                &cols,
6506                fk,
6507                self.active_catalog(),
6508            )?);
6509        }
6510        let mut schema = TableSchema::new(table_name.clone(), cols);
6511        schema.foreign_keys = fks;
6512        // v7.9.19 — translate AST table_constraints to storage
6513        // UniquenessConstraints (column name → position) so the
6514        // INSERT enforcement helper sees positions directly.
6515        let mut uc_storage: Vec<spg_storage::UniquenessConstraint> = Vec::new();
6516        let mut check_exprs: Vec<String> = Vec::new();
6517        for tc in &stmt.table_constraints {
6518            let (is_pk, names, nnd) = match tc {
6519                spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } => {
6520                    (true, columns.clone(), false)
6521                }
6522                spg_sql::ast::TableConstraint::Unique {
6523                    columns,
6524                    nulls_not_distinct,
6525                    ..
6526                } => (false, columns.clone(), *nulls_not_distinct),
6527                spg_sql::ast::TableConstraint::Check { expr, .. } => {
6528                    // v7.13.0 — collect CHECK predicate sources;
6529                    // they get attached to the schema below.
6530                    check_exprs.push(alloc::format!("{expr}"));
6531                    continue;
6532                }
6533                // v7.15.0 — plain `KEY (cols)` from MySQL inline
6534                // is NOT a uniqueness constraint; skip the UC
6535                // build path entirely. The BTree index lands in
6536                // the post-create loop below alongside the PK/UQ
6537                // implicit indexes.
6538                spg_sql::ast::TableConstraint::Index { .. } => continue,
6539                // v7.17.0 Phase 2.2 — MySQL FULLTEXT KEY is not
6540                // a uniqueness constraint either; its GIN gets
6541                // built in the post-create loop below.
6542                spg_sql::ast::TableConstraint::FulltextIndex { .. } => continue,
6543            };
6544            let mut positions = Vec::with_capacity(names.len());
6545            for n in &names {
6546                let pos = schema
6547                    .columns
6548                    .iter()
6549                    .position(|c| c.name == *n)
6550                    .ok_or_else(|| {
6551                        EngineError::Unsupported(alloc::format!(
6552                            "table constraint references unknown column {n:?}"
6553                        ))
6554                    })?;
6555                positions.push(pos);
6556            }
6557            uc_storage.push(spg_storage::UniquenessConstraint {
6558                is_primary_key: is_pk,
6559                columns: positions,
6560                nulls_not_distinct: nnd,
6561            });
6562        }
6563        schema.uniqueness_constraints = uc_storage.clone();
6564        schema.checks = check_exprs;
6565        self.active_catalog_mut().create_table(schema)?;
6566        // v7.9.13 — implicit BTree per inline PK column +
6567        // v7.9.19 — implicit BTree on the leading column of every
6568        // table-level PRIMARY KEY / UNIQUE constraint.
6569        let table = self
6570            .active_catalog_mut()
6571            .get_mut(&table_name)
6572            .expect("just created");
6573        for (i, col_name) in inline_pk_columns.iter().enumerate() {
6574            let idx_name = if inline_pk_columns.len() == 1 {
6575                alloc::format!("{table_name}_pkey")
6576            } else {
6577                alloc::format!("{table_name}_pkey_{i}")
6578            };
6579            if let Err(e) = table.add_index(idx_name, col_name) {
6580                return Err(EngineError::Storage(e));
6581            }
6582        }
6583        for (i, tc) in stmt.table_constraints.iter().enumerate() {
6584            // v7.17.0 Phase 2.2 — FULLTEXT KEY lands a real
6585            // tsvector-GIN per declared column instead of the
6586            // BTree the PK / UQ / KEY paths build. Branch early
6587            // so the BTree loop never sees the FULLTEXT shape.
6588            if let spg_sql::ast::TableConstraint::FulltextIndex { name, columns } = tc {
6589                for (k, col) in columns.iter().enumerate() {
6590                    let already = table.indices().iter().any(|idx| {
6591                        matches!(idx.kind, spg_storage::IndexKind::GinFulltext(_))
6592                            && table.schema().columns[idx.column_position].name == *col
6593                    });
6594                    if already {
6595                        continue;
6596                    }
6597                    let idx_name = match (name.as_ref(), columns.len(), k) {
6598                        (Some(n), 1, _) => n.clone(),
6599                        (Some(n), _, k) => alloc::format!("{n}_{k}"),
6600                        (None, _, _) => {
6601                            alloc::format!("{table_name}_{col}_ftidx")
6602                        }
6603                    };
6604                    if let Err(e) = table.add_gin_fulltext_index(idx_name, col) {
6605                        return Err(EngineError::Storage(e));
6606                    }
6607                }
6608                continue;
6609            }
6610            // v7.15.0 — plain KEY/INDEX rides this same loop so
6611            // the implicit BTree gets built. It carries its own
6612            // user-supplied name; PK/UQ still synthesise.
6613            let (suffix, names, explicit_name): (&str, &Vec<String>, Option<&String>) = match tc {
6614                spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } => {
6615                    ("pkey", columns, None)
6616                }
6617                spg_sql::ast::TableConstraint::Unique { columns, .. } => ("key", columns, None),
6618                spg_sql::ast::TableConstraint::Index { name, columns } => {
6619                    ("idx", columns, name.as_ref())
6620                }
6621                spg_sql::ast::TableConstraint::Check { .. } => continue,
6622                // Handled by the early-branch above.
6623                spg_sql::ast::TableConstraint::FulltextIndex { .. } => continue,
6624            };
6625            let leading = &names[0];
6626            // Skip if a same-column BTree already exists (e.g.
6627            // inline PK on the leading column).
6628            let already = table.indices().iter().any(|idx| {
6629                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
6630                    && table.schema().columns[idx.column_position].name == *leading
6631            });
6632            if already {
6633                continue;
6634            }
6635            let idx_name = if let Some(n) = explicit_name {
6636                n.clone()
6637            } else if names.len() == 1 {
6638                alloc::format!("{table_name}_{leading}_{suffix}")
6639            } else {
6640                alloc::format!("{table_name}_{leading}_{suffix}_{i}")
6641            };
6642            if let Err(e) = table.add_index(idx_name, leading) {
6643                return Err(EngineError::Storage(e));
6644            }
6645        }
6646        Ok(QueryResult::CommandOk {
6647            affected: 0,
6648            modified_catalog: !self.in_transaction(),
6649        })
6650    }
6651
6652    fn exec_insert(&mut self, mut stmt: InsertStatement) -> Result<QueryResult, EngineError> {
6653        // v7.17.0 Phase 1.1 — pre-resolve any nextval / currval /
6654        // setval calls against the catalog before the row loop. We
6655        // walk each tuple expression and replace matching
6656        // FunctionCall nodes with their concrete Literal. This
6657        // keeps `literal_expr_to_value` free of `&mut self` and
6658        // lets multi-row INSERT VALUES (… nextval('seq') …)
6659        // mint a separate sequence value per row.
6660        for tuple in &mut stmt.rows {
6661            for cell in tuple.iter_mut() {
6662                self.resolve_sequence_calls_in_expr(cell)?;
6663            }
6664        }
6665        // v7.13.0 — `INSERT INTO t [(cols)] SELECT …` (mailrs
6666        // round-5 G4). Execute the inner SELECT first, then route
6667        // back through the regular VALUES code path with the
6668        // materialised rows.
6669        if let Some(select) = stmt.select_source.clone() {
6670            let select_result = self.exec_select_cancel(&select, CancelToken::none())?;
6671            let rows = match select_result {
6672                QueryResult::Rows { rows, .. } => rows,
6673                other => {
6674                    return Err(EngineError::Unsupported(alloc::format!(
6675                        "INSERT … SELECT: inner statement produced {other:?} instead of a row set"
6676                    )));
6677                }
6678            };
6679            let mut materialised: Vec<Vec<Expr>> = Vec::with_capacity(rows.len());
6680            for row in rows {
6681                let mut tuple: Vec<Expr> = Vec::with_capacity(row.values.len());
6682                for v in row.values {
6683                    tuple.push(value_to_literal_expr_permissive(v)?);
6684                }
6685                materialised.push(tuple);
6686            }
6687            let recurse = InsertStatement {
6688                table: stmt.table,
6689                columns: stmt.columns,
6690                rows: materialised,
6691                select_source: None,
6692                on_conflict: stmt.on_conflict,
6693                returning: stmt.returning,
6694            };
6695            return self.exec_insert(recurse);
6696        }
6697        // v7.9.21 — snapshot the clock fn pointer before the mut
6698        // borrow on the catalog opens; runtime DEFAULT eval needs
6699        // it inside the row hot loop.
6700        let clock = self.clock;
6701        // v7.12.4 — snapshot row-level triggers + their referenced
6702        // functions before the mut borrow on the catalog opens.
6703        // Cloned out so the row hot loop can fire them without
6704        // re-borrowing the catalog (which would conflict with
6705        // table.insert's mutable borrow).
6706        let before_insert_triggers = self.snapshot_row_triggers(&stmt.table, "INSERT", "BEFORE");
6707        let after_insert_triggers = self.snapshot_row_triggers(&stmt.table, "INSERT", "AFTER");
6708        let trigger_session_cfg: Option<alloc::string::String> = self
6709            .session_params
6710            .get("default_text_search_config")
6711            .cloned();
6712        // v7.17.0 Phase 1.4 — snapshot the enum label lookup BEFORE
6713        // opening the mutable borrow on the table below. We need
6714        // catalog-level read access (enum_types lives at the
6715        // catalog level, not the table) and the upcoming mutable
6716        // borrow shadows it.
6717        let pre_borrow_column_meta: Vec<ColumnSchema> = {
6718            let preview_table = self.active_catalog().get(&stmt.table).ok_or_else(|| {
6719                EngineError::Storage(StorageError::TableNotFound {
6720                    name: stmt.table.clone(),
6721                })
6722            })?;
6723            preview_table.schema().columns.clone()
6724        };
6725        let enum_label_lookup: alloc::collections::BTreeMap<usize, Vec<String>> =
6726            pre_borrow_column_meta
6727                .iter()
6728                .enumerate()
6729                .filter_map(|(i, col)| {
6730                    // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM
6731                    // variant lists take priority over the PG
6732                    // catalog enum_types lookup (they're
6733                    // column-local and authoritative when set).
6734                    if let Some(inline) = &col.inline_enum_variants {
6735                        return Some((i, inline.clone()));
6736                    }
6737                    col.user_enum_type.as_ref().and_then(|ename| {
6738                        self.active_catalog()
6739                            .enum_types()
6740                            .get(ename)
6741                            .map(|e| (i, e.labels.clone()))
6742                    })
6743                })
6744                .collect();
6745        // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant lists.
6746        // Distinct from enum_label_lookup: SET validates that
6747        // every comma-separated token is in the variant list, and
6748        // canonicalises the cell to definition-order de-duped text.
6749        let set_variant_lookup: alloc::collections::BTreeMap<usize, Vec<String>> =
6750            pre_borrow_column_meta
6751                .iter()
6752                .enumerate()
6753                .filter_map(|(i, col)| col.inline_set_variants.as_ref().map(|vs| (i, vs.clone())))
6754                .collect();
6755        let table = self
6756            .active_catalog_mut()
6757            .get_mut(&stmt.table)
6758            .ok_or_else(|| {
6759                EngineError::Storage(StorageError::TableNotFound {
6760                    name: stmt.table.clone(),
6761                })
6762            })?;
6763        // v3.1.5: clone the columns vector only (not the whole
6764        // TableSchema — saves one String alloc for the table name).
6765        // We need an owned snapshot because we'll call `table.insert`
6766        // (mutable borrow on `table`) inside the row loop while
6767        // reading schema fields.
6768        let column_meta: Vec<ColumnSchema> = table.schema().columns.clone();
6769        let schema_cols_len = column_meta.len();
6770        // Build a permutation `tuple_pos[c] = Some(j)` meaning schema
6771        // column `c` is filled from the `j`-th tuple slot; `None` means
6772        // "fill with NULL". Validated once and reused for every row.
6773        let tuple_pos: Option<Vec<Option<usize>>> = match &stmt.columns {
6774            None => None, // 1-1 mapping, fast path
6775            Some(cols) => {
6776                let mut map = alloc::vec![None; schema_cols_len];
6777                for (j, name) in cols.iter().enumerate() {
6778                    let idx = column_meta
6779                        .iter()
6780                        .position(|c| c.name == *name)
6781                        .ok_or_else(|| {
6782                            EngineError::Eval(EvalError::ColumnNotFound { name: name.clone() })
6783                        })?;
6784                    if map[idx].is_some() {
6785                        return Err(EngineError::Storage(StorageError::ArityMismatch {
6786                            expected: schema_cols_len,
6787                            actual: cols.len(),
6788                        }));
6789                    }
6790                    map[idx] = Some(j);
6791                }
6792                // Omitted columns must either be nullable, carry a
6793                // DEFAULT, or be AUTO_INCREMENT. Catch NOT NULL
6794                // omissions up front so the WAL stays clean.
6795                for (i, col) in column_meta.iter().enumerate() {
6796                    if map[i].is_none()
6797                        && !col.nullable
6798                        && col.default.is_none()
6799                        && col.runtime_default.is_none()
6800                        && !col.auto_increment
6801                    {
6802                        return Err(EngineError::Storage(StorageError::NullInNotNull {
6803                            column: col.name.clone(),
6804                        }));
6805                    }
6806                }
6807                Some(map)
6808            }
6809        };
6810        let expected_tuple_len = stmt.columns.as_ref().map_or(schema_cols_len, Vec::len);
6811        // v7.6.2 — snapshot this table's FK list before the
6812        // mutable-borrow window so we can run parent lookups
6813        // against the immutable catalog after parsing. Empty vec is
6814        // the no-FK fast path; clone cost is O(fks * arity) which
6815        // is < 100 ns for typical schemas.
6816        let fks = table.schema().foreign_keys.clone();
6817        let mut affected = 0usize;
6818        // Stage 1 — parse + AUTO_INC + coerce all rows under the
6819        // single mutable borrow.
6820        let mut all_values: Vec<Vec<Value>> = Vec::with_capacity(stmt.rows.len());
6821        for tuple in stmt.rows {
6822            if tuple.len() != expected_tuple_len {
6823                return Err(EngineError::Storage(StorageError::ArityMismatch {
6824                    expected: expected_tuple_len,
6825                    actual: tuple.len(),
6826                }));
6827            }
6828            // Fast path: no column-list permutation → tuple slot j
6829            // maps to schema column j. We can zip schema with tuple
6830            // and skip the `raw_tuple` staging allocation entirely.
6831            let values: Vec<Value> = if let Some(map) = &tuple_pos {
6832                // Permuted path: still need raw_tuple to index by `map[i]`.
6833                let raw_tuple: Vec<Value> = tuple
6834                    .into_iter()
6835                    .map(literal_expr_to_value)
6836                    .collect::<Result<_, _>>()?;
6837                let mut out = Vec::with_capacity(schema_cols_len);
6838                for (i, col) in column_meta.iter().enumerate() {
6839                    let mut raw = match map[i] {
6840                        Some(j) => raw_tuple[j].clone(),
6841                        None => resolve_column_default_free(col, clock)?,
6842                    };
6843                    if col.auto_increment && raw.is_null() {
6844                        let next = table.next_auto_value(i).ok_or_else(|| {
6845                            EngineError::Unsupported(alloc::format!(
6846                                "AUTO_INCREMENT applies to integer columns only (column `{}`)",
6847                                col.name
6848                            ))
6849                        })?;
6850                        raw = Value::BigInt(next);
6851                    }
6852                    let coerced = coerce_value(raw, col.ty, &col.name, i)?;
6853                    enforce_enum_label(&enum_label_lookup, i, &col.name, &coerced)?;
6854                    let coerced =
6855                        canonicalize_set_value(&set_variant_lookup, i, &col.name, coerced)?;
6856                    check_unsigned_range(&coerced, col, i)?;
6857                    out.push(coerced);
6858                }
6859                out
6860            } else {
6861                // 1-1 mapping fast path: single Vec alloc, no raw_tuple.
6862                let mut out = Vec::with_capacity(schema_cols_len);
6863                for (i, (col, expr)) in column_meta.iter().zip(tuple).enumerate() {
6864                    let mut raw = literal_expr_to_value(expr)?;
6865                    if col.auto_increment && raw.is_null() {
6866                        let next = table.next_auto_value(i).ok_or_else(|| {
6867                            EngineError::Unsupported(alloc::format!(
6868                                "AUTO_INCREMENT applies to integer columns only (column `{}`)",
6869                                col.name
6870                            ))
6871                        })?;
6872                        raw = Value::BigInt(next);
6873                    }
6874                    let coerced = coerce_value(raw, col.ty, &col.name, i)?;
6875                    enforce_enum_label(&enum_label_lookup, i, &col.name, &coerced)?;
6876                    let coerced =
6877                        canonicalize_set_value(&set_variant_lookup, i, &col.name, coerced)?;
6878                    check_unsigned_range(&coerced, col, i)?;
6879                    out.push(coerced);
6880                }
6881                out
6882            };
6883            all_values.push(values);
6884        }
6885        // Stage 2 — FK enforcement on the immutable catalog.
6886        // Non-lexical lifetimes release the mutable borrow on
6887        // `table` here since stage 1 was the last use. The
6888        // parent-table lookup runs before any row is committed.
6889        let uniqueness = table.schema().uniqueness_constraints.clone();
6890        let _ = table;
6891        if !fks.is_empty() {
6892            enforce_fk_inserts(self.active_catalog(), &stmt.table, &fks, &all_values)?;
6893        }
6894        // v7.13.0 — CHECK constraint enforcement (mailrs round-5 G3).
6895        enforce_check_constraints(self.active_catalog(), &stmt.table, &all_values)?;
6896        // NOTE (mailrs embed round-12): UNIQUE / PRIMARY KEY and
6897        // UNIQUE INDEX enforcement moved BELOW the ON CONFLICT
6898        // resolution pass. Running them first made every
6899        // `ON CONFLICT … DO UPDATE` upsert fail with a uniqueness
6900        // violation before the conflict handler could route the row
6901        // to an UPDATE — PG resolves the conflict action first and
6902        // only errors on rows no arbiter matched.
6903        // v7.9.8 / v7.9.9 — ON CONFLICT handling.
6904        //   - `DO NOTHING` filters `all_values` to non-conflicting
6905        //     rows + drops within-batch duplicates.
6906        //   - `DO UPDATE SET …` ALSO filters, but for each
6907        //     conflicting row it queues an UPDATE on the existing
6908        //     row using the incoming row's values as `EXCLUDED.*`.
6909        let mut pending_updates: Vec<(usize, Vec<Value>)> = Vec::new();
6910        let mut skipped_count = 0usize;
6911        if let Some(clause) = &stmt.on_conflict {
6912            let conflict_cols = resolve_on_conflict_columns(
6913                self.active_catalog(),
6914                &stmt.table,
6915                clause.target_columns.as_slice(),
6916            )?;
6917            let mut kept: Vec<Vec<Value>> = Vec::with_capacity(all_values.len());
6918            let mut seen_keys: Vec<Vec<Value>> = Vec::new();
6919            for values in all_values {
6920                let key_tuple: Vec<&Value> = conflict_cols.iter().map(|&c| &values[c]).collect();
6921                // SQL spec: NULL in any conflict column means "no
6922                // conflict possible" (NULL ≠ NULL for uniqueness).
6923                let has_null_key = key_tuple.iter().any(|v| matches!(v, Value::Null));
6924                let collides_with_table = !has_null_key
6925                    && on_conflict_keys_exist(
6926                        self.active_catalog(),
6927                        &stmt.table,
6928                        &conflict_cols,
6929                        &key_tuple,
6930                    );
6931                let key_tuple_owned: Vec<Value> = key_tuple.iter().map(|v| (*v).clone()).collect();
6932                let collides_with_batch =
6933                    !has_null_key && seen_keys.iter().any(|k| k == &key_tuple_owned);
6934                let collides = collides_with_table || collides_with_batch;
6935                match (&clause.action, collides) {
6936                    (_, false) => {
6937                        seen_keys.push(key_tuple_owned);
6938                        kept.push(values);
6939                    }
6940                    (spg_sql::ast::OnConflictAction::Nothing, true) => {
6941                        skipped_count += 1;
6942                    }
6943                    (
6944                        spg_sql::ast::OnConflictAction::Update {
6945                            assignments,
6946                            where_,
6947                        },
6948                        true,
6949                    ) => {
6950                        if !collides_with_table {
6951                            skipped_count += 1;
6952                            continue;
6953                        }
6954                        let target_pos = lookup_row_position_by_keys(
6955                            self.active_catalog(),
6956                            &stmt.table,
6957                            &conflict_cols,
6958                            &key_tuple,
6959                        )
6960                        .ok_or_else(|| {
6961                            EngineError::Unsupported(
6962                                "ON CONFLICT DO UPDATE: conflict detected but row \
6963                                 position could not be resolved (cold-tier row?)"
6964                                    .into(),
6965                            )
6966                        })?;
6967                        let updated = apply_on_conflict_assignments(
6968                            self.active_catalog(),
6969                            &stmt.table,
6970                            target_pos,
6971                            &values,
6972                            assignments,
6973                            where_.as_ref(),
6974                        )?;
6975                        if let Some(new_row) = updated {
6976                            pending_updates.push((target_pos, new_row));
6977                        } else {
6978                            skipped_count += 1;
6979                        }
6980                    }
6981                }
6982            }
6983            all_values = kept;
6984        }
6985        // v7.9.19 — composite UNIQUE / PRIMARY KEY enforcement.
6986        // v7.9.29 — CREATE UNIQUE INDEX [WHERE pred] enforcement.
6987        // Both run on the post-ON-CONFLICT row set: conflicting rows
6988        // already left `all_values` (DO NOTHING drop / DO UPDATE
6989        // reroute), so what remains must be genuinely unique.
6990        enforce_uniqueness_inserts(self.active_catalog(), &stmt.table, &uniqueness, &all_values)?;
6991        enforce_unique_index_inserts(self.active_catalog(), &stmt.table, &all_values)?;
6992        // Stage 3 — insert all rows under a fresh mutable borrow.
6993        let table = self
6994            .active_catalog_mut()
6995            .get_mut(&stmt.table)
6996            .ok_or_else(|| {
6997                EngineError::Storage(StorageError::TableNotFound {
6998                    name: stmt.table.clone(),
6999                })
7000            })?;
7001        // v7.9.4 — keep RETURNING projection rows separate per
7002        // INSERT and per UPDATE branch so DO UPDATE pushes the new
7003        // post-update state, not the incoming-only values.
7004        let mut returning_rows: Vec<Vec<Value>> = Vec::new();
7005        // v7.12.7 — collect embedded SQL emitted by any trigger
7006        // fire across the row loop; engine drains the queue after
7007        // the table mut borrow drops.
7008        let mut deferred_embedded: Vec<triggers::DeferredEmbeddedStmt> = Vec::new();
7009        'rowloop: for values in all_values {
7010            let mut row = Row::new(values);
7011            // v7.12.4 — BEFORE INSERT row-level triggers. Each
7012            // trigger may rewrite NEW cells (e.g. populate
7013            // `search_vector := to_tsvector(...)`) and may return
7014            // NULL to skip the row entirely.
7015            for fd in &before_insert_triggers {
7016                let (outcome, deferred) = triggers::fire_row_trigger(
7017                    fd,
7018                    Some(row.clone()),
7019                    None,
7020                    &stmt.table,
7021                    &column_meta,
7022                    &[],
7023                    trigger_session_cfg.as_deref(),
7024                    false,
7025                )
7026                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
7027                deferred_embedded.extend(deferred);
7028                match outcome {
7029                    triggers::TriggerOutcome::Row(r) => row = r,
7030                    triggers::TriggerOutcome::Skip => continue 'rowloop,
7031                }
7032            }
7033            if stmt.returning.is_some() {
7034                returning_rows.push(row.values.clone());
7035            }
7036            // v7.12.4 — clone for the AFTER trigger view; insert
7037            // moves the row into the table.
7038            let inserted = row.clone();
7039            table.insert(row)?;
7040            affected += 1;
7041            // v7.12.4 — AFTER INSERT row-level triggers fire post-
7042            // write. Return value is ignored (PG semantics); we
7043            // surface any error from the body up to the caller.
7044            for fd in &after_insert_triggers {
7045                let (_outcome, deferred) = triggers::fire_row_trigger(
7046                    fd,
7047                    Some(inserted.clone()),
7048                    None,
7049                    &stmt.table,
7050                    &column_meta,
7051                    &[],
7052                    trigger_session_cfg.as_deref(),
7053                    true,
7054                )
7055                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
7056                deferred_embedded.extend(deferred);
7057            }
7058        }
7059        // v7.9.9 — apply ON CONFLICT DO UPDATE rewrites collected
7060        // in the conflict-resolution pass. update_row handles
7061        // index maintenance + body re-encoding.
7062        for (pos, new_row) in pending_updates {
7063            if stmt.returning.is_some() {
7064                returning_rows.push(new_row.clone());
7065            }
7066            table.update_row(pos, new_row)?;
7067            affected += 1;
7068        }
7069        let _ = skipped_count;
7070        // v7.12.7 — drop the table mut borrow and drain any
7071        // trigger-emitted embedded SQL queued during this INSERT.
7072        // The borrow has to release first because each deferred
7073        // stmt may UPDATE / INSERT / DELETE the same (or another)
7074        // table — including, in principle, this one.
7075        let _ = table;
7076        self.execute_deferred_trigger_stmts(deferred_embedded, CancelToken::none())?;
7077        // v7.9.4/v7.9.9 — RETURNING streams the rows that ended
7078        // up in the table after this statement (insert or
7079        // post-update on conflict).
7080        if let Some(items) = &stmt.returning {
7081            return self.build_returning_rows(&stmt.table, items, returning_rows);
7082        }
7083        // v6.2.1 — auto-analyze: track per-table modified-row
7084        // counter so the background sweep can decide when to
7085        // re-ANALYZE. Cheap path on the autocommit-wrap hot loop
7086        // — one BTreeMap entry update per INSERT batch.
7087        if !self.in_transaction() && affected > 0 {
7088            self.statistics
7089                .record_modifications(&stmt.table, affected as u64);
7090        }
7091        Ok(QueryResult::CommandOk {
7092            affected,
7093            modified_catalog: !self.in_transaction(),
7094        })
7095    }
7096
7097    /// v4.5: SELECT with cooperative cancellation. The token is
7098    /// honoured between UNION peers and inside the bare-SELECT row
7099    /// loop; HNSW kNN graph walks and the aggregate executor don't
7100    /// honour it yet (deferred — those paths bound their work
7101    /// internally by `LIMIT k` and `GROUP BY` cardinality).
7102    /// v6.10.2 — cold-tier time-travel scan. Resolves the segment
7103    /// by id, decodes each row body against the table's current
7104    /// schema, applies the SELECT's projection + optional WHERE +
7105    /// optional LIMIT, returns a `Rows` result. JOINs / aggregates
7106    /// / ORDER BY are unsupported on this path (STABILITY carve-
7107    /// out); operators wanting them should restore the segment
7108    /// into a regular table first.
7109    fn exec_select_as_of_segment(
7110        &self,
7111        stmt: &SelectStatement,
7112        from: &spg_sql::ast::FromClause,
7113        segment_id: u32,
7114    ) -> Result<QueryResult, EngineError> {
7115        // v6.10.2 scope: no joins, no aggregates, no ORDER BY,
7116        // no GROUP BY / HAVING / UNION / OFFSET / DISTINCT.
7117        if !from.joins.is_empty()
7118            || stmt.group_by.is_some()
7119            || stmt.having.is_some()
7120            || !stmt.unions.is_empty()
7121            || !stmt.order_by.is_empty()
7122            || stmt.offset.is_some()
7123            || stmt.distinct
7124            || aggregate::uses_aggregate(stmt)
7125        {
7126            return Err(EngineError::Unsupported(
7127                "AS OF SEGMENT supports SELECT projection + WHERE + LIMIT only \
7128                 (joins / aggregates / ORDER BY are STABILITY § \"Out of v6.10\")"
7129                    .into(),
7130            ));
7131        }
7132        let table = self
7133            .active_catalog()
7134            .get(&from.primary.name)
7135            .ok_or_else(|| StorageError::TableNotFound {
7136                name: from.primary.name.clone(),
7137            })?;
7138        let schema = table.schema().clone();
7139        let schema_cols = &schema.columns;
7140        let alias = from
7141            .primary
7142            .alias
7143            .as_deref()
7144            .unwrap_or(from.primary.name.as_str());
7145        let ctx = EvalContext::new(schema_cols, Some(alias));
7146        let seg = self
7147            .active_catalog()
7148            .cold_segment(segment_id)
7149            .ok_or_else(|| {
7150                EngineError::Unsupported(alloc::format!(
7151                    "AS OF SEGMENT: cold segment {segment_id} not registered"
7152                ))
7153            })?;
7154        let mut out_rows: Vec<Row> = Vec::new();
7155        let mut limit_remaining: Option<usize> =
7156            stmt.limit_literal().and_then(|n| usize::try_from(n).ok());
7157        for (_key, body) in seg.scan() {
7158            let (row, _consumed) =
7159                spg_storage::decode_row_body_dense(&body, &schema, seg.long_strings())
7160                    .map_err(EngineError::Storage)?;
7161            if let Some(where_expr) = &stmt.where_ {
7162                let cond = self.eval_expr_simple(where_expr, &row, &ctx)?;
7163                if !matches!(cond, Value::Bool(true)) {
7164                    continue;
7165                }
7166            }
7167            // Projection.
7168            let projected = self.project_row_simple(&row, &stmt.items, schema_cols, alias)?;
7169            out_rows.push(projected);
7170            if let Some(rem) = limit_remaining.as_mut() {
7171                if *rem == 0 {
7172                    out_rows.pop();
7173                    break;
7174                }
7175                *rem -= 1;
7176            }
7177        }
7178        // Output column schema: derive from SELECT items.
7179        let columns = self.derive_output_columns(&stmt.items, schema_cols, alias);
7180        Ok(QueryResult::Rows {
7181            columns,
7182            rows: out_rows,
7183        })
7184    }
7185
7186    /// v6.10.2 — simple-path WHERE eval that doesn't go through
7187    /// the correlated-subquery / Memoize machinery. AS OF SEGMENT
7188    /// scan paths predicate against a snapshot frozen segment, no
7189    /// cross-row state.
7190    fn eval_expr_simple(
7191        &self,
7192        expr: &Expr,
7193        row: &Row,
7194        ctx: &EvalContext,
7195    ) -> Result<Value, EngineError> {
7196        let cancel = CancelToken::none();
7197        self.eval_expr_with_correlated(expr, row, ctx, cancel, None)
7198    }
7199
7200    /// v7.9.4 — INSERT / UPDATE / DELETE RETURNING projector.
7201    /// Given the table name, the user-supplied projection items,
7202    /// and the mutated rows (post-insert / post-update values, or
7203    /// pre-delete snapshot), build a `QueryResult::Rows` whose
7204    /// schema describes the projected columns. Mailrs migration
7205    /// blocker #1.
7206    fn build_returning_rows(
7207        &self,
7208        table_name: &str,
7209        items: &[SelectItem],
7210        mutated_rows: Vec<Vec<Value>>,
7211    ) -> Result<QueryResult, EngineError> {
7212        let table = self.active_catalog().get(table_name).ok_or_else(|| {
7213            EngineError::Storage(StorageError::TableNotFound {
7214                name: table_name.into(),
7215            })
7216        })?;
7217        let schema_cols = table.schema().columns.clone();
7218        let columns = self.derive_output_columns(items, &schema_cols, table_name);
7219        let mut out_rows: Vec<Row> = Vec::with_capacity(mutated_rows.len());
7220        for values in mutated_rows {
7221            let row = Row::new(values);
7222            let projected = self.project_row_simple(&row, items, &schema_cols, table_name)?;
7223            out_rows.push(projected);
7224        }
7225        Ok(QueryResult::Rows {
7226            columns,
7227            rows: out_rows,
7228        })
7229    }
7230
7231    /// v6.10.2 — projection for AS OF SEGMENT. Resolves
7232    /// `SelectItem::Wildcard` to all schema columns and
7233    /// `SelectItem::Expr` via the regular eval path.
7234    fn project_row_simple(
7235        &self,
7236        row: &Row,
7237        items: &[SelectItem],
7238        schema_cols: &[ColumnSchema],
7239        alias: &str,
7240    ) -> Result<Row, EngineError> {
7241        let ctx = EvalContext::new(schema_cols, Some(alias));
7242        let cancel = CancelToken::none();
7243        let mut out_vals = Vec::new();
7244        for item in items {
7245            match item {
7246                SelectItem::Wildcard => {
7247                    out_vals.extend(row.values.iter().cloned());
7248                }
7249                SelectItem::Expr { expr, .. } => {
7250                    let v = self.eval_expr_with_correlated(expr, row, &ctx, cancel, None)?;
7251                    out_vals.push(v);
7252                }
7253            }
7254        }
7255        Ok(Row::new(out_vals))
7256    }
7257
7258    /// v6.10.2 — derive the output `ColumnSchema` list for an
7259    /// AS OF SEGMENT projection. Wildcards take the full schema;
7260    /// expressions take the alias if present or a synthetic
7261    /// `?column?` (PG convention) otherwise.
7262    fn derive_output_columns(
7263        &self,
7264        items: &[SelectItem],
7265        schema_cols: &[ColumnSchema],
7266        _alias: &str,
7267    ) -> Vec<ColumnSchema> {
7268        let mut out = Vec::new();
7269        for item in items {
7270            match item {
7271                SelectItem::Wildcard => {
7272                    out.extend(schema_cols.iter().cloned());
7273                }
7274                SelectItem::Expr { expr, alias } => {
7275                    // Bare column references inherit the schema
7276                    // column's name + type — PG names `RETURNING id`
7277                    // "id" and types it BIGINT, and the sqlx embed
7278                    // path type-checks RowDescription against the
7279                    // Rust target (mailrs embed round-12).
7280                    if let Expr::Column(col) = expr
7281                        && let Some(sc) = schema_cols.iter().find(|c| c.name == col.name)
7282                    {
7283                        let name = alias.clone().unwrap_or_else(|| sc.name.clone());
7284                        out.push(ColumnSchema::new(name, sc.ty, sc.nullable));
7285                        continue;
7286                    }
7287                    let name = alias.clone().unwrap_or_else(|| "?column?".to_string());
7288                    // Default to Text; the caller's row values
7289                    // carry the actual type. v6.10.2 scope.
7290                    out.push(ColumnSchema::new(name, DataType::Text, true));
7291                }
7292            }
7293        }
7294        out
7295    }
7296
7297    fn exec_select_cancel(
7298        &self,
7299        stmt: &SelectStatement,
7300        cancel: CancelToken<'_>,
7301    ) -> Result<QueryResult, EngineError> {
7302        cancel.check()?;
7303        // v7.17.0 Phase 1.2 — user-defined VIEW expansion. If the
7304        // FROM / JOIN graph references any catalogued view name,
7305        // re-parse the view body and prepend it as a synthetic
7306        // CTE. Recurses on views-in-views via the regular CTE
7307        // dispatch below. Fast-path: skip the walker entirely when
7308        // the catalog has no views (the typical OLTP load).
7309        if !self.active_catalog().views().is_empty() {
7310            if let Some(rewritten) = self.expand_views_in_select(stmt)? {
7311                return self.exec_select_cancel(&rewritten, cancel);
7312            }
7313        }
7314        // v7.16.2 — information_schema / pg_catalog virtual
7315        // views (mailrs round-10 A.3). If the SELECT touches a
7316        // synthetic meta-table name (`__spg_info_*` /
7317        // `__spg_pg_*` — produced by the parser for
7318        // `information_schema.X` / `pg_catalog.X`), clone the
7319        // catalog, materialise the requested view as a real
7320        // temporary table, and re-execute against an enriched
7321        // engine. Same pattern as `exec_with_ctes` for CTEs.
7322        if !self.meta_views_materialised && select_references_meta_view(stmt) {
7323            return self.exec_select_with_meta_views(stmt, cancel);
7324        }
7325        // v6.10.2 — cold-tier time-travel short-circuit. When the
7326        // primary TableRef carries `AS OF SEGMENT '<id>'`, run a
7327        // dedicated cold-segment scan instead of the regular
7328        // hot+index path. The scope is intentionally narrow for
7329        // v6.10.2 — bare `SELECT * FROM <t> AS OF SEGMENT 'id'`,
7330        // optionally with a single-column-equality WHERE. JOINs /
7331        // aggregates / ORDER BY / subqueries on top of a time-
7332        // travelled scan are STABILITY § "Out of v6.10".
7333        if let Some(from) = &stmt.from
7334            && let Some(seg_id) = from.primary.as_of_segment
7335        {
7336            return self.exec_select_as_of_segment(stmt, from, seg_id);
7337        }
7338        // v6.2.0 / v6.5.0 — virtual-table short-circuits. Detected
7339        // pre-CTE because they don't read from the catalog and
7340        // shouldn't participate in regular FROM resolution.
7341        if let Some(from) = &stmt.from
7342            && from.joins.is_empty()
7343            && stmt.where_.is_none()
7344            && stmt.group_by.is_none()
7345            && stmt.having.is_none()
7346            && stmt.unions.is_empty()
7347            && stmt.order_by.is_empty()
7348            && stmt.limit.is_none()
7349            && stmt.offset.is_none()
7350            && !stmt.distinct
7351            && stmt.items.iter().all(|i| matches!(i, SelectItem::Wildcard))
7352        {
7353            let lower = from.primary.name.to_ascii_lowercase();
7354            match lower.as_str() {
7355                "spg_statistic" => return Ok(self.exec_spg_statistic()),
7356                // v6.5.0 — observability v2 virtual tables.
7357                "spg_stat_replication" => return Ok(self.exec_spg_stat_replication()),
7358                "spg_stat_segment" => return Ok(self.exec_spg_stat_segment()),
7359                "spg_stat_query" => return Ok(self.exec_spg_stat_query()),
7360                "spg_stat_activity" => return Ok(self.exec_spg_stat_activity()),
7361                "spg_audit_chain" => return Ok(self.exec_spg_audit_chain()),
7362                "spg_audit_verify" => return Ok(self.exec_spg_audit_verify()),
7363                "spg_table_ddl" => return Ok(self.exec_spg_table_ddl()),
7364                "spg_role_ddl" => return Ok(self.exec_spg_role_ddl()),
7365                "spg_database_ddl" => return Ok(self.exec_spg_database_ddl()),
7366                _ => {}
7367            }
7368        }
7369        // v4.11: CTEs materialise into a temporary enriched catalog
7370        // *before* anything else — the body SELECT can then refer
7371        // to CTE names via the regular FROM-clause resolution.
7372        // Uncorrelated only: each CTE body runs once against the
7373        // current catalog, not against later CTEs' results (left-
7374        // to-right materialisation would relax this, but we keep
7375        // it simple for v4.11 MVP).
7376        if !stmt.ctes.is_empty() {
7377            return self.exec_with_ctes(stmt, cancel);
7378        }
7379        // v4.10: subqueries (uncorrelated) are resolved here, before
7380        // the executor sees the row loop. We clone the statement so
7381        // we can mutate without disturbing the caller's AST — most
7382        // queries pass through with no subquery nodes and the clone
7383        // is cheap; with subqueries the materialisation cost
7384        // dominates anyway.
7385        let mut stmt_owned;
7386        let stmt_ref: &SelectStatement = if expr_tree_has_subquery(stmt) {
7387            stmt_owned = stmt.clone();
7388            self.resolve_select_subqueries(&mut stmt_owned, cancel)?;
7389            &stmt_owned
7390        } else {
7391            stmt
7392        };
7393        if stmt_ref.unions.is_empty() {
7394            return self.exec_bare_select_cancel(stmt_ref, cancel);
7395        }
7396        // UNION path: clone-strip the head into a bare block (its own
7397        // DISTINCT and any inner ORDER BY are dropped by parser rule —
7398        // the wrapper SelectStatement carries them), execute, then chain
7399        // peers with left-associative dedup semantics.
7400        let mut head = stmt_ref.clone();
7401        head.unions = Vec::new();
7402        head.order_by = Vec::new();
7403        head.limit = None;
7404        let QueryResult::Rows { columns, mut rows } =
7405            self.exec_bare_select_cancel(&head, cancel)?
7406        else {
7407            unreachable!("bare SELECT cannot return CommandOk")
7408        };
7409        for (kind, peer) in &stmt_ref.unions {
7410            let QueryResult::Rows {
7411                columns: peer_cols,
7412                rows: peer_rows,
7413            } = self.exec_bare_select_cancel(peer, cancel)?
7414            else {
7415                unreachable!("bare SELECT cannot return CommandOk")
7416            };
7417            if peer_cols.len() != columns.len() {
7418                return Err(EngineError::Unsupported(alloc::format!(
7419                    "UNION arity mismatch: head has {} columns, peer has {}",
7420                    columns.len(),
7421                    peer_cols.len()
7422                )));
7423            }
7424            rows.extend(peer_rows);
7425            if matches!(kind, UnionKind::Distinct) {
7426                rows = dedup_rows(rows);
7427            }
7428        }
7429        // ORDER BY at the top of a UNION applies to the combined result.
7430        // Eval against the projected schema (NOT the source table).
7431        if !stmt.order_by.is_empty() {
7432            let synth_ctx = EvalContext::new(&columns, None);
7433            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
7434            let mut tagged: Vec<(Vec<f64>, Row)> = Vec::with_capacity(rows.len());
7435            for r in rows {
7436                let keys = build_order_keys(&stmt.order_by, &r, &synth_ctx)?;
7437                tagged.push((keys, r));
7438            }
7439            sort_by_keys(&mut tagged, &descs);
7440            rows = tagged.into_iter().map(|(_, r)| r).collect();
7441        }
7442        apply_offset_and_limit(&mut rows, stmt.offset_literal(), stmt.limit_literal());
7443        Ok(QueryResult::Rows { columns, rows })
7444    }
7445
7446    #[allow(clippy::too_many_lines)]
7447    #[allow(clippy::too_many_lines)] // huge match — splitting fragments the planner
7448    /// v7.11.7 — execute `SELECT … FROM unnest(expr) [AS] alias …`.
7449    /// Synthesises a single-column virtual table whose column type
7450    /// is TEXT and whose rows are the array elements. Routes
7451    /// through the regular projection / WHERE / ORDER BY / LIMIT
7452    /// machinery so set-returning UNNEST composes naturally with
7453    /// the rest of the SELECT surface.
7454    fn exec_select_unnest(
7455        &self,
7456        stmt: &SelectStatement,
7457        primary: &TableRef,
7458        cancel: CancelToken<'_>,
7459    ) -> Result<QueryResult, EngineError> {
7460        let expr = primary
7461            .unnest_expr
7462            .as_deref()
7463            .expect("caller guards unnest_expr.is_some()");
7464        // Evaluate the array expression once. Empty schema / empty
7465        // row — uncorrelated UNNEST cannot reference outer columns.
7466        let empty_schema: alloc::vec::Vec<ColumnSchema> = alloc::vec::Vec::new();
7467        let ctx = EvalContext::new(&empty_schema, None);
7468        let dummy_row = Row::new(alloc::vec::Vec::new());
7469        // v7.11.13 — unnest dispatches per array element type so
7470        // INT[] / BIGINT[] surface their PG types in projection.
7471        let (elem_dtype, rows): (DataType, alloc::vec::Vec<Row>) =
7472            match eval::eval_expr(expr, &dummy_row, &ctx).map_err(EngineError::Eval)? {
7473                Value::Null => (DataType::Text, alloc::vec::Vec::new()),
7474                Value::TextArray(items) => {
7475                    let rows = items
7476                        .into_iter()
7477                        .map(|item| {
7478                            Row::new(alloc::vec![match item {
7479                                Some(s) => Value::Text(s),
7480                                None => Value::Null,
7481                            }])
7482                        })
7483                        .collect();
7484                    (DataType::Text, rows)
7485                }
7486                Value::IntArray(items) => {
7487                    let rows = items
7488                        .into_iter()
7489                        .map(|item| {
7490                            Row::new(alloc::vec![match item {
7491                                Some(n) => Value::Int(n),
7492                                None => Value::Null,
7493                            }])
7494                        })
7495                        .collect();
7496                    (DataType::Int, rows)
7497                }
7498                Value::BigIntArray(items) => {
7499                    let rows = items
7500                        .into_iter()
7501                        .map(|item| {
7502                            Row::new(alloc::vec![match item {
7503                                Some(n) => Value::BigInt(n),
7504                                None => Value::Null,
7505                            }])
7506                        })
7507                        .collect();
7508                    (DataType::BigInt, rows)
7509                }
7510                other => {
7511                    return Err(EngineError::Unsupported(alloc::format!(
7512                        "unnest() expects an array argument, got {:?}",
7513                        other.data_type()
7514                    )));
7515                }
7516            };
7517        let alias = primary
7518            .alias
7519            .clone()
7520            .unwrap_or_else(|| "unnest".to_string());
7521        // v7.13.2 — mailrs round-6 S5. Honour PG-standard
7522        // `UNNEST(arr) AS p(col_name)` column-list aliasing: the
7523        // first entry overrides the projected column's name.
7524        // Without the column list, fall back to the table alias
7525        // (pre-v7.13.2 behaviour).
7526        let col_name = primary
7527            .unnest_column_aliases
7528            .first()
7529            .cloned()
7530            .unwrap_or_else(|| alias.clone());
7531        let col_schema = ColumnSchema::new(col_name, elem_dtype, true);
7532        let schema_cols = alloc::vec![col_schema.clone()];
7533        let scan_ctx = EvalContext::new(&schema_cols, Some(&alias));
7534        // Apply WHERE.
7535        let filtered: alloc::vec::Vec<Row> = if let Some(w) = &stmt.where_ {
7536            let mut out = alloc::vec::Vec::with_capacity(rows.len());
7537            for row in rows {
7538                cancel.check()?;
7539                let v = eval::eval_expr(w, &row, &scan_ctx).map_err(EngineError::Eval)?;
7540                if matches!(v, Value::Bool(true)) {
7541                    out.push(row);
7542                }
7543            }
7544            out
7545        } else {
7546            rows
7547        };
7548        // v7.17.0 Phase 3.P0-48 — aggregate dispatch over the
7549        // unnest source. Same routing the relational scan path
7550        // already takes — without it `SELECT COUNT(*) FROM
7551        // unnest(ARRAY[…])` either errored at projection time or
7552        // returned the wrong shape.
7553        if aggregate::uses_aggregate(stmt) {
7554            let filtered_refs: alloc::vec::Vec<&Row> = filtered.iter().collect();
7555            let mut agg = aggregate::run(stmt, &filtered_refs, &schema_cols, Some(&alias))?;
7556            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
7557            return Ok(QueryResult::Rows {
7558                columns: agg.columns,
7559                rows: agg.rows,
7560            });
7561        }
7562        // Projection.
7563        let projection = build_projection(&stmt.items, &schema_cols, &alias)?;
7564        let mut projected_rows: alloc::vec::Vec<Row> =
7565            alloc::vec::Vec::with_capacity(filtered.len());
7566        // v7.19 P5 — Set-Returning-Function in projection
7567        // position (PG `SELECT unnest(arr) FROM t` shape). When a
7568        // SELECT item evaluates to a top-level unnest(arr) call,
7569        // expand it: for each input row, evaluate the array, emit
7570        // one output row per element, broadcasting non-SRF
7571        // projections from the same input row. Multi-SRF + LCM
7572        // padding stays a documented carve-out; mailrs uses
7573        // single-SRF for redirect_uris.
7574        let srf_position = projection.iter().position(|p| is_top_level_unnest(&p.expr));
7575        if let Some(srf_idx) = srf_position {
7576            let srf_arg = top_level_unnest_arg(&projection[srf_idx].expr)
7577                .expect("checked by is_top_level_unnest above");
7578            for row in &filtered {
7579                let arr_val =
7580                    eval::eval_expr(srf_arg, row, &scan_ctx).map_err(EngineError::Eval)?;
7581                let elements = array_value_to_elements(&arr_val)?;
7582                // Empty array → zero rows for this input row (PG
7583                // semantics: `SELECT unnest('{}'::int[])` returns
7584                // 0 rows, not a single NULL row).
7585                for elem in elements {
7586                    let mut vals = alloc::vec::Vec::with_capacity(projection.len());
7587                    for (i, p) in projection.iter().enumerate() {
7588                        if i == srf_idx {
7589                            vals.push(elem.clone());
7590                        } else {
7591                            vals.push(
7592                                eval::eval_expr(&p.expr, row, &scan_ctx)
7593                                    .map_err(EngineError::Eval)?,
7594                            );
7595                        }
7596                    }
7597                    projected_rows.push(Row::new(vals));
7598                }
7599            }
7600        } else {
7601            for row in &filtered {
7602                let mut vals = alloc::vec::Vec::with_capacity(projection.len());
7603                for p in &projection {
7604                    vals.push(eval::eval_expr(&p.expr, row, &scan_ctx).map_err(EngineError::Eval)?);
7605                }
7606                projected_rows.push(Row::new(vals));
7607            }
7608        }
7609        // ORDER BY / LIMIT — apply on the projected rows (cheap;
7610        // unnest result sets are small by design).
7611        let columns: alloc::vec::Vec<ColumnSchema> = projection
7612            .iter()
7613            .map(|p| ColumnSchema::new(p.output_name.clone(), p.ty, p.nullable))
7614            .collect();
7615        // Re-evaluate ORDER BY against the source schema (pre-projection
7616        // so col refs by name still resolve through `scan_ctx`).
7617        if !stmt.order_by.is_empty() {
7618            let mut indexed: alloc::vec::Vec<(usize, Vec<Value>)> = filtered
7619                .iter()
7620                .enumerate()
7621                .map(|(i, r)| -> Result<_, EngineError> {
7622                    let keys: Result<Vec<Value>, EngineError> = stmt
7623                        .order_by
7624                        .iter()
7625                        .map(|ob| {
7626                            eval::eval_expr(&ob.expr, r, &scan_ctx).map_err(EngineError::Eval)
7627                        })
7628                        .collect();
7629                    Ok((i, keys?))
7630                })
7631                .collect::<Result<_, _>>()?;
7632            indexed.sort_by(|a, b| {
7633                for (idx, (ka, kb)) in a.1.iter().zip(b.1.iter()).enumerate() {
7634                    let mut cmp = value_cmp(ka, kb);
7635                    if stmt.order_by[idx].desc {
7636                        cmp = cmp.reverse();
7637                    }
7638                    if cmp != core::cmp::Ordering::Equal {
7639                        return cmp;
7640                    }
7641                }
7642                core::cmp::Ordering::Equal
7643            });
7644            projected_rows = indexed
7645                .into_iter()
7646                .map(|(i, _)| projected_rows[i].clone())
7647                .collect();
7648        }
7649        // LIMIT / OFFSET — apply at the tail.
7650        if let Some(offset) = stmt.offset_literal() {
7651            let off = (offset as usize).min(projected_rows.len());
7652            projected_rows.drain(..off);
7653        }
7654        if let Some(limit) = stmt.limit_literal() {
7655            projected_rows.truncate(limit as usize);
7656        }
7657        Ok(QueryResult::Rows {
7658            columns,
7659            rows: projected_rows,
7660        })
7661    }
7662
7663    /// v7.17.0 Phase 3.10 — `FROM generate_series(start, stop [,
7664    /// step])` set-returning source. Mirrors `exec_select_unnest`'s
7665    /// shape: evaluate the arg list once against an empty row,
7666    /// materialise the row stream by stepping start → stop, then
7667    /// route through the standard WHERE / projection / ORDER BY /
7668    /// LIMIT pipeline. Two arg-type combos in v7.17:
7669    ///   * integer / integer [/ integer] — SmallInt, Int, BigInt
7670    ///     (widened to BigInt internally; step defaults to 1)
7671    ///   * timestamp / timestamp / interval — date-range
7672    ///     iteration (mailrs's daily-report pattern)
7673    fn exec_select_generate_series(
7674        &self,
7675        stmt: &SelectStatement,
7676        primary: &TableRef,
7677        cancel: CancelToken<'_>,
7678    ) -> Result<QueryResult, EngineError> {
7679        let args = primary
7680            .generate_series_args
7681            .as_ref()
7682            .expect("caller guards generate_series_args.is_some()");
7683        let empty_schema: alloc::vec::Vec<ColumnSchema> = alloc::vec::Vec::new();
7684        let ctx = EvalContext::new(&empty_schema, None);
7685        let dummy_row = Row::new(alloc::vec::Vec::new());
7686        let mut arg_values: alloc::vec::Vec<Value> = alloc::vec::Vec::with_capacity(args.len());
7687        for a in args {
7688            arg_values.push(eval::eval_expr(a, &dummy_row, &ctx).map_err(EngineError::Eval)?);
7689        }
7690        // Dispatch on the start value's shape. Reject mixed-shape
7691        // calls early (e.g. start = timestamp, stop = integer) so
7692        // the caller gets a clean error rather than a panic.
7693        let (elem_dtype, rows) = match arg_values.as_slice() {
7694            [Value::Timestamp(start), Value::Timestamp(stop), step] => {
7695                let interval_step = match step {
7696                    Value::Interval { .. } => step.clone(),
7697                    other => {
7698                        return Err(EngineError::Unsupported(alloc::format!(
7699                            "generate_series(timestamp, timestamp, …): \
7700                             step must be INTERVAL, got {:?}",
7701                            other.data_type()
7702                        )));
7703                    }
7704                };
7705                let rows = generate_series_timestamps(*start, *stop, interval_step, &cancel)?;
7706                (DataType::Timestamp, rows)
7707            }
7708            [start, stop, step]
7709                if value_is_integer(start) && value_is_integer(stop) && value_is_integer(step) =>
7710            {
7711                let s = value_to_i64(start);
7712                let e = value_to_i64(stop);
7713                let st = value_to_i64(step);
7714                let rows = generate_series_integers(s, e, st, &cancel)?;
7715                (DataType::BigInt, rows)
7716            }
7717            [start, stop] if value_is_integer(start) && value_is_integer(stop) => {
7718                let s = value_to_i64(start);
7719                let e = value_to_i64(stop);
7720                let rows = generate_series_integers(s, e, 1, &cancel)?;
7721                (DataType::BigInt, rows)
7722            }
7723            _ => {
7724                return Err(EngineError::Unsupported(alloc::format!(
7725                    "generate_series(): v7.17 supports integer or (timestamp, timestamp, interval) \
7726                     argument shapes; got {:?}",
7727                    arg_values
7728                        .iter()
7729                        .map(|v| v.data_type())
7730                        .collect::<alloc::vec::Vec<_>>()
7731                )));
7732            }
7733        };
7734        let alias = primary
7735            .alias
7736            .clone()
7737            .unwrap_or_else(|| "generate_series".to_string());
7738        let col_name = alias.clone();
7739        let col_schema = ColumnSchema::new(col_name, elem_dtype, true);
7740        let schema_cols = alloc::vec![col_schema.clone()];
7741        let scan_ctx = EvalContext::new(&schema_cols, Some(&alias));
7742        // WHERE.
7743        let filtered: alloc::vec::Vec<Row> = if let Some(w) = &stmt.where_ {
7744            let mut out = alloc::vec::Vec::with_capacity(rows.len());
7745            for row in rows {
7746                cancel.check()?;
7747                let v = eval::eval_expr(w, &row, &scan_ctx).map_err(EngineError::Eval)?;
7748                if matches!(v, Value::Bool(true)) {
7749                    out.push(row);
7750                }
7751            }
7752            out
7753        } else {
7754            rows
7755        };
7756        // v7.17.0 Phase 3.P0-48 — aggregate dispatch for set-
7757        // returning sources. When the SELECT projection contains
7758        // aggregate functions (COUNT/SUM/MIN/MAX/AVG/string_agg/
7759        // …) we route the filtered row stream through the same
7760        // aggregate executor the relational scan path uses, so
7761        // `SELECT COUNT(*) FROM generate_series(1, 100)` returns
7762        // a single 100 row instead of erroring at projection
7763        // time. GROUP BY / HAVING / ORDER BY over the aggregate
7764        // output all ride through `aggregate::run`.
7765        if aggregate::uses_aggregate(stmt) {
7766            let filtered_refs: alloc::vec::Vec<&Row> = filtered.iter().collect();
7767            let mut agg = aggregate::run(stmt, &filtered_refs, &schema_cols, Some(&alias))?;
7768            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
7769            return Ok(QueryResult::Rows {
7770                columns: agg.columns,
7771                rows: agg.rows,
7772            });
7773        }
7774        // Projection.
7775        let projection = build_projection(&stmt.items, &schema_cols, &alias)?;
7776        let mut projected_rows: alloc::vec::Vec<Row> =
7777            alloc::vec::Vec::with_capacity(filtered.len());
7778        for row in &filtered {
7779            let mut vals = alloc::vec::Vec::with_capacity(projection.len());
7780            for p in &projection {
7781                vals.push(eval::eval_expr(&p.expr, row, &scan_ctx).map_err(EngineError::Eval)?);
7782            }
7783            projected_rows.push(Row::new(vals));
7784        }
7785        let columns: alloc::vec::Vec<ColumnSchema> = projection
7786            .iter()
7787            .map(|p| ColumnSchema::new(p.output_name.clone(), p.ty, p.nullable))
7788            .collect();
7789        // ORDER BY against the source schema.
7790        if !stmt.order_by.is_empty() {
7791            let mut indexed: alloc::vec::Vec<(usize, Vec<Value>)> = filtered
7792                .iter()
7793                .enumerate()
7794                .map(|(i, r)| -> Result<_, EngineError> {
7795                    let keys: Result<Vec<Value>, EngineError> = stmt
7796                        .order_by
7797                        .iter()
7798                        .map(|ob| {
7799                            eval::eval_expr(&ob.expr, r, &scan_ctx).map_err(EngineError::Eval)
7800                        })
7801                        .collect();
7802                    Ok((i, keys?))
7803                })
7804                .collect::<Result<_, _>>()?;
7805            indexed.sort_by(|a, b| {
7806                for (idx, (ka, kb)) in a.1.iter().zip(b.1.iter()).enumerate() {
7807                    let mut cmp = value_cmp(ka, kb);
7808                    if stmt.order_by[idx].desc {
7809                        cmp = cmp.reverse();
7810                    }
7811                    if cmp != core::cmp::Ordering::Equal {
7812                        return cmp;
7813                    }
7814                }
7815                core::cmp::Ordering::Equal
7816            });
7817            projected_rows = indexed
7818                .into_iter()
7819                .map(|(i, _)| projected_rows[i].clone())
7820                .collect();
7821        }
7822        if let Some(offset) = stmt.offset_literal() {
7823            let off = (offset as usize).min(projected_rows.len());
7824            projected_rows.drain(..off);
7825        }
7826        if let Some(limit) = stmt.limit_literal() {
7827            projected_rows.truncate(limit as usize);
7828        }
7829        Ok(QueryResult::Rows {
7830            columns,
7831            rows: projected_rows,
7832        })
7833    }
7834
7835    fn exec_bare_select_cancel(
7836        &self,
7837        stmt: &SelectStatement,
7838        cancel: CancelToken<'_>,
7839    ) -> Result<QueryResult, EngineError> {
7840        // v7.17.0 Phase 3.P0-49 — `FETCH FIRST N ROWS WITH TIES`
7841        // is meaningless without an ORDER BY; PG raises a hard
7842        // error and SPG mirrors the surface so the same DDL/app
7843        // path behaves identically on cutover.
7844        check_with_ties_requires_order_by(stmt)?;
7845        // v7.16.2 — same meta-view dispatch as
7846        // `exec_select_cancel`, applied here too because
7847        // `subquery_replacement` enters this function directly
7848        // for Exists / ScalarSubquery / InSubquery resolution
7849        // (bypassing the top-level entry to avoid double
7850        // subquery walking). Without this dispatch the subquery
7851        // hits `__spg_info_columns` and reports TableNotFound.
7852        if !self.meta_views_materialised && select_references_meta_view(stmt) {
7853            return self.exec_select_with_meta_views(stmt, cancel);
7854        }
7855        // v4.12: window-function path. When the projection contains
7856        // any `name(args) OVER (...)` we route to the dedicated
7857        // executor — partition + sort + per-row window value before
7858        // the regular projection.
7859        if select_has_window(stmt) {
7860            return self.exec_select_with_window(stmt, cancel);
7861        }
7862        // Constant SELECT (no FROM) — evaluate each item once against an
7863        // empty dummy row. Useful for `SELECT 1`, `SELECT coalesce(...)`,
7864        // `SELECT '7'::INT`. Column references will surface as
7865        // ColumnNotFound on eval since the schema is empty.
7866        let Some(from) = &stmt.from else {
7867            let empty_schema: Vec<ColumnSchema> = Vec::new();
7868            let ctx = self.ev_ctx(&empty_schema, None);
7869            let projection = build_projection(&stmt.items, &empty_schema, "")?;
7870            let dummy_row = Row::new(Vec::new());
7871            let mut values = Vec::with_capacity(projection.len());
7872            for p in &projection {
7873                values.push(eval::eval_expr(&p.expr, &dummy_row, &ctx)?);
7874            }
7875            let columns: Vec<ColumnSchema> = projection
7876                .into_iter()
7877                .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
7878                .collect();
7879            return Ok(QueryResult::Rows {
7880                columns,
7881                rows: alloc::vec![Row::new(values)],
7882            });
7883        };
7884        // Multi-table FROM (one or more joined peers) goes through the
7885        // nested-loop join executor. Single-table FROM stays on the
7886        // existing scan + index-seek path.
7887        if !from.joins.is_empty() {
7888            return self.exec_joined_select(stmt, from);
7889        }
7890        // v7.11.7 — `FROM unnest(<expr>) [AS] <alias>`. Synthesise a
7891        // single-column table at SELECT entry by evaluating the
7892        // expression once against the empty row (UNNEST is
7893        // uncorrelated in v7.11; correlated / LATERAL unnest is a
7894        // v7.12 carve-out). Build a virtual `Table` in a heap-only
7895        // catalog, then route to the regular scan path.
7896        if from.primary.unnest_expr.is_some() {
7897            return self.exec_select_unnest(stmt, &from.primary, cancel);
7898        }
7899        // v7.17.0 Phase 3.10 — `FROM generate_series(start, stop
7900        // [, step])` set-returning source. Dispatch mirrors UNNEST:
7901        // materialise the row stream from a single eval pass, then
7902        // run the regular projection / WHERE / ORDER BY / LIMIT
7903        // pipeline over the synthetic single-column table.
7904        if from.primary.generate_series_args.is_some() {
7905            return self.exec_select_generate_series(stmt, &from.primary, cancel);
7906        }
7907        let primary = &from.primary;
7908        let table = self.active_catalog().get(&primary.name).ok_or_else(|| {
7909            StorageError::TableNotFound {
7910                name: primary.name.clone(),
7911            }
7912        })?;
7913        let schema_cols = &table.schema().columns;
7914        // The qualifier accepted on column refs is the alias (if any) else the
7915        // bare table name.
7916        let alias = primary.alias.as_deref().unwrap_or(primary.name.as_str());
7917        let ctx = self.ev_ctx(schema_cols, Some(alias));
7918
7919        // NSW kNN planner: `ORDER BY col <-> literal LIMIT k` with no
7920        // WHERE and an NSW index on `col` skips the full scan. The
7921        // walk returns rows already in ascending-distance order, so
7922        // ORDER BY / LIMIT are honoured implicitly.
7923        if let Some(nsw_rows) = try_nsw_knn(stmt, table, schema_cols, alias) {
7924            return materialise_in_order(stmt, table, schema_cols, alias, &nsw_rows);
7925        }
7926
7927        // Index seek: if WHERE is `col = literal` (or commuted) and the
7928        // referenced column has an index, dispatch each locator through
7929        // the catalog (hot tier → borrow, cold tier → page-read +
7930        // decode) and iterate just those rows. Otherwise fall back to a
7931        // full scan over the hot tier (cold-tier rows are only reached
7932        // via index seek in v5.1 — full table scans against cold-tier
7933        // data ship in v5.2 with the freezer's per-segment scan API).
7934        let indexed_rows: Option<Vec<Cow<'_, Row>>> = stmt.where_.as_ref().and_then(|w| {
7935            // BTree / col=literal seek first — covers the v7.11.3 multi-
7936            // column AND case and the leading-column equality lookup.
7937            try_index_seek(w, schema_cols, self.active_catalog(), table, alias)
7938                .or_else(|| {
7939                    // v7.12.3 — GIN-accelerated `WHERE col @@
7940                    // tsquery` when the column has a `USING gin`
7941                    // index. Returns an over-approximate candidate
7942                    // set; the WHERE re-eval loop below verifies
7943                    // the full `@@` predicate per row.
7944                    try_gin_seek(w, schema_cols, self.active_catalog(), table, alias, &ctx)
7945                })
7946                .or_else(|| {
7947                    // v7.15.0 — trigram-GIN-accelerated
7948                    // `WHERE col LIKE / ILIKE '<pat>'` when the
7949                    // column has a `gin_trgm_ops` GIN index.
7950                    // Over-approximate candidate set; the WHERE
7951                    // re-eval verifies the LIKE per row.
7952                    try_trgm_seek(w, schema_cols, table, alias)
7953                })
7954        });
7955
7956        // Aggregate path: filter rows first, then hand off to the
7957        // aggregate executor which does its own projection + ORDER BY.
7958        if aggregate::uses_aggregate(stmt) {
7959            let mut filtered: Vec<&Row> = Vec::new();
7960            // v6.2.6 — Memoize: per-query LRU cache for correlated
7961            // scalar subqueries. Fresh per row-loop entry so each
7962            // SELECT execution gets an isolated cache.
7963            let mut memo = memoize::MemoizeCache::new();
7964            if let Some(rows) = &indexed_rows {
7965                for cow in rows {
7966                    let row = cow.as_ref();
7967                    if let Some(where_expr) = &stmt.where_ {
7968                        let cond = self.eval_expr_with_correlated(
7969                            where_expr,
7970                            row,
7971                            &ctx,
7972                            cancel,
7973                            Some(&mut memo),
7974                        )?;
7975                        if !matches!(cond, Value::Bool(true)) {
7976                            continue;
7977                        }
7978                    }
7979                    filtered.push(row);
7980                }
7981            } else {
7982                for i in 0..table.row_count() {
7983                    let row = &table.rows()[i];
7984                    if let Some(where_expr) = &stmt.where_ {
7985                        let cond = self.eval_expr_with_correlated(
7986                            where_expr,
7987                            row,
7988                            &ctx,
7989                            cancel,
7990                            Some(&mut memo),
7991                        )?;
7992                        if !matches!(cond, Value::Bool(true)) {
7993                            continue;
7994                        }
7995                    }
7996                    filtered.push(row);
7997                }
7998            }
7999            let mut agg = aggregate::run(stmt, &filtered, schema_cols, Some(alias))?;
8000            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
8001            return Ok(QueryResult::Rows {
8002                columns: agg.columns,
8003                rows: agg.rows,
8004            });
8005        }
8006
8007        let projection = build_projection(&stmt.items, schema_cols, alias)?;
8008        // v7.19 P5 — single-table SELECT path for SRF
8009        // `SELECT unnest(arr) FROM t` shape. Detect a top-level
8010        // unnest in the projection list. When present, the
8011        // per-row processor emits one output row per array
8012        // element (broadcasting non-SRF projections from the
8013        // same input row). Empty / NULL arrays emit zero rows
8014        // for that input — PG semantics.
8015        let srf_position = projection.iter().position(|p| is_top_level_unnest(&p.expr));
8016
8017        // Materialise the filter pass into `(order_key, projected_row)`
8018        // tuples. The order key is `None` when there's no ORDER BY clause.
8019        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::new();
8020        // v6.2.6 — Memoize per-row WHERE eval shares one cache.
8021        let mut memo = memoize::MemoizeCache::new();
8022        // Inline the per-row work in a closure so the indexed and full-
8023        // scan branches share the body.
8024        let mut process_row = |row: &Row, loop_idx: usize| -> Result<(), EngineError> {
8025            if loop_idx.is_multiple_of(256) {
8026                cancel.check()?;
8027            }
8028            if let Some(where_expr) = &stmt.where_ {
8029                let cond =
8030                    self.eval_expr_with_correlated(where_expr, row, &ctx, cancel, Some(&mut memo))?;
8031                if !matches!(cond, Value::Bool(true)) {
8032                    return Ok(());
8033                }
8034            }
8035            let order_keys = if stmt.order_by.is_empty() {
8036                Vec::new()
8037            } else {
8038                build_order_keys(&stmt.order_by, row, &ctx)?
8039            };
8040            if let Some(srf_idx) = srf_position {
8041                let srf_arg = top_level_unnest_arg(&projection[srf_idx].expr)
8042                    .expect("checked by is_top_level_unnest above");
8043                let arr_val = eval::eval_expr(srf_arg, row, &ctx)?;
8044                let elements = array_value_to_elements(&arr_val)?;
8045                for elem in elements {
8046                    let mut values = Vec::with_capacity(projection.len());
8047                    for (i, p) in projection.iter().enumerate() {
8048                        if i == srf_idx {
8049                            values.push(elem.clone());
8050                        } else {
8051                            values.push(eval::eval_expr(&p.expr, row, &ctx)?);
8052                        }
8053                    }
8054                    tagged.push((order_keys.clone(), Row::new(values)));
8055                }
8056            } else {
8057                let mut values = Vec::with_capacity(projection.len());
8058                for p in &projection {
8059                    values.push(eval::eval_expr(&p.expr, row, &ctx)?);
8060                }
8061                tagged.push((order_keys, Row::new(values)));
8062            }
8063            Ok(())
8064        };
8065        if let Some(rows) = &indexed_rows {
8066            for (loop_idx, cow) in rows.iter().enumerate() {
8067                process_row(cow.as_ref(), loop_idx)?;
8068            }
8069        } else {
8070            for i in 0..table.row_count() {
8071                process_row(&table.rows()[i], i)?;
8072            }
8073        }
8074
8075        if !stmt.order_by.is_empty() {
8076            // Partial-sort fast path: when LIMIT is small relative to
8077            // the row count, select_nth_unstable + sort just the
8078            // prefix is O(n + k log k) instead of O(n log n). DISTINCT
8079            // requires the full sort because de-dup happens after.
8080            // WITH TIES likewise needs the full sort so the tie
8081            // extension can scan past `limit` to find rows that
8082            // share the last-kept row's key.
8083            let keep = if stmt.distinct || stmt.limit_with_ties {
8084                None
8085            } else {
8086                stmt.limit_literal()
8087                    .map(|l| l as usize + stmt.offset_literal().map_or(0, |o| o as usize))
8088            };
8089            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
8090            partial_sort_tagged(&mut tagged, keep, &descs);
8091        }
8092
8093        // v7.17.0 Phase 3.P0-49 — `FETCH FIRST … WITH TIES` extends
8094        // past the truncated tail through every row that shares the
8095        // last-kept row's ORDER BY key. The tie check uses the
8096        // already-computed `(order_keys, row)` pairs so it matches
8097        // the sort comparator exactly. DISTINCT + WITH TIES falls
8098        // through to the no-ties path (PG also disallows their
8099        // combination; SPG silently drops the tie extension here so
8100        // the customer doesn't see a hard error mid-query — the
8101        // user-visible result is still correct, just narrower).
8102        let output_rows: Vec<Row> = if stmt.limit_with_ties && !stmt.distinct {
8103            apply_offset_and_limit_tagged(
8104                &mut tagged,
8105                stmt.offset_literal(),
8106                stmt.limit_literal(),
8107                true,
8108            );
8109            tagged.into_iter().map(|(_, r)| r).collect()
8110        } else {
8111            let mut output_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
8112            if stmt.distinct {
8113                output_rows = dedup_rows(output_rows);
8114            }
8115            apply_offset_and_limit(
8116                &mut output_rows,
8117                stmt.offset_literal(),
8118                stmt.limit_literal(),
8119            );
8120            output_rows
8121        };
8122
8123        let columns: Vec<ColumnSchema> = projection
8124            .into_iter()
8125            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
8126            .collect();
8127
8128        Ok(QueryResult::Rows {
8129            columns,
8130            rows: output_rows,
8131        })
8132    }
8133
8134    /// Multi-table SELECT executor (one or more JOIN peers).
8135    ///
8136    /// v1.10 builds the joined row set up-front via nested-loop joins,
8137    /// then runs WHERE + projection + ORDER BY against the combined
8138    /// rows. No index seek. Aggregates and DISTINCT still work because
8139    /// the executor delegates projection through the same shared paths.
8140    #[allow(clippy::too_many_lines)]
8141    /// v7.13.2 — mailrs round-6 S5. Resolve a TableRef into an
8142    /// owned (rows, schema) pair. Catalog tables clone their hot
8143    /// rows + schema; UNNEST table refs evaluate their array
8144    /// expression once and synthesise a single-column row set
8145    /// using the same dispatch as `exec_select_unnest`. Used by
8146    /// the joined-select path so UNNEST can appear in any FROM
8147    /// position, not just as the primary.
8148    fn materialise_table_ref(
8149        &self,
8150        tref: &TableRef,
8151    ) -> Result<(Vec<Row>, Vec<ColumnSchema>), EngineError> {
8152        if let Some(expr) = tref.unnest_expr.as_deref() {
8153            let empty_schema: Vec<ColumnSchema> = Vec::new();
8154            let ctx = EvalContext::new(&empty_schema, None);
8155            let dummy_row = Row::new(Vec::new());
8156            let (elem_dtype, rows) =
8157                match eval::eval_expr(expr, &dummy_row, &ctx).map_err(EngineError::Eval)? {
8158                    Value::Null => (DataType::Text, Vec::new()),
8159                    Value::TextArray(items) => (
8160                        DataType::Text,
8161                        items
8162                            .into_iter()
8163                            .map(|item| {
8164                                Row::new(alloc::vec![match item {
8165                                    Some(s) => Value::Text(s),
8166                                    None => Value::Null,
8167                                }])
8168                            })
8169                            .collect(),
8170                    ),
8171                    Value::IntArray(items) => (
8172                        DataType::Int,
8173                        items
8174                            .into_iter()
8175                            .map(|item| {
8176                                Row::new(alloc::vec![match item {
8177                                    Some(n) => Value::Int(n),
8178                                    None => Value::Null,
8179                                }])
8180                            })
8181                            .collect(),
8182                    ),
8183                    Value::BigIntArray(items) => (
8184                        DataType::BigInt,
8185                        items
8186                            .into_iter()
8187                            .map(|item| {
8188                                Row::new(alloc::vec![match item {
8189                                    Some(n) => Value::BigInt(n),
8190                                    None => Value::Null,
8191                                }])
8192                            })
8193                            .collect(),
8194                    ),
8195                    other => {
8196                        return Err(EngineError::Unsupported(alloc::format!(
8197                            "unnest() expects an array argument, got {:?}",
8198                            other.data_type()
8199                        )));
8200                    }
8201                };
8202            let alias = tref.alias.clone().unwrap_or_else(|| "unnest".to_string());
8203            let col_name = tref.unnest_column_aliases.first().cloned().unwrap_or(alias);
8204            return Ok((
8205                rows,
8206                alloc::vec![ColumnSchema::new(col_name, elem_dtype, true)],
8207            ));
8208        }
8209        let table =
8210            self.active_catalog()
8211                .get(&tref.name)
8212                .ok_or_else(|| StorageError::TableNotFound {
8213                    name: tref.name.clone(),
8214                })?;
8215        let rows: Vec<Row> = table.rows().iter().cloned().collect();
8216        let cols = table.schema().columns.clone();
8217        Ok((rows, cols))
8218    }
8219
8220    /// v7.17.0 Phase 3.P0-43 — materialise a `FROM` with one or more
8221    /// JOINs into `(combined_schema, filtered_rows)`. The combined
8222    /// schema uses composite `alias.col` column names so the
8223    /// qualifier-aware column resolver finds every join peer by
8224    /// exact match; the filtered rows are the join cross-product
8225    /// after the optional WHERE clause is applied.
8226    ///
8227    /// Shared by `exec_joined_select` and the JOIN branch of
8228    /// `exec_select_with_window`; both paths used to inline the
8229    /// same nested-loop logic and the window path rejected JOIN
8230    /// outright.
8231    fn build_joined_filtered_rows(
8232        &self,
8233        from: &FromClause,
8234        where_: Option<&Expr>,
8235    ) -> Result<(Vec<ColumnSchema>, Vec<Row>), EngineError> {
8236        let (primary_rows, primary_cols) = self.materialise_table_ref(&from.primary)?;
8237        let primary_alias = from
8238            .primary
8239            .alias
8240            .as_deref()
8241            .unwrap_or(from.primary.name.as_str())
8242            .to_string();
8243        // v7.17.0 Phase 3.P0-41 — LATERAL peers can't be
8244        // pre-materialised because their rows depend on outer
8245        // columns. For each peer, build either an eager
8246        // (rows, schema) pair or a "lateral" sentinel carrying
8247        // just the schema and the inner SELECT to re-run per
8248        // outer row.
8249        #[allow(clippy::type_complexity)]
8250        let mut joined: Vec<JoinedPeer<'_>> = Vec::new();
8251        for j in &from.joins {
8252            let a = j
8253                .table
8254                .alias
8255                .as_deref()
8256                .unwrap_or(j.table.name.as_str())
8257                .to_string();
8258            if let Some(inner_box) = &j.table.lateral_subquery {
8259                // Probe schema by running the inner SELECT against a
8260                // NULL-padded outer context. The probe gives us the
8261                // projection's column shape; rows materialise per
8262                // left-row below.
8263                let schema = self.lateral_probe_schema(inner_box)?;
8264                joined.push(JoinedPeer {
8265                    eager_rows: None,
8266                    cols: schema,
8267                    alias: a,
8268                    kind: j.kind,
8269                    on: j.on.as_ref(),
8270                    lateral: Some(inner_box.as_ref()),
8271                });
8272            } else {
8273                let (rows, cols) = self.materialise_table_ref(&j.table)?;
8274                joined.push(JoinedPeer {
8275                    eager_rows: Some(rows),
8276                    cols,
8277                    alias: a,
8278                    kind: j.kind,
8279                    on: j.on.as_ref(),
8280                    lateral: None,
8281                });
8282            }
8283        }
8284        let mut combined_schema: Vec<ColumnSchema> = Vec::new();
8285        for col in &primary_cols {
8286            combined_schema.push(ColumnSchema::new(
8287                alloc::format!("{primary_alias}.{}", col.name),
8288                col.ty,
8289                col.nullable,
8290            ));
8291        }
8292        for peer in &joined {
8293            for col in &peer.cols {
8294                combined_schema.push(ColumnSchema::new(
8295                    alloc::format!("{}.{}", peer.alias, col.name),
8296                    col.ty,
8297                    col.nullable,
8298                ));
8299            }
8300        }
8301        let ctx = EvalContext::new(&combined_schema, None);
8302        let mut working: Vec<Row> = primary_rows;
8303        // Track the per-row width consumed by the outer left side so
8304        // each lateral evaluation sees the correct schema slice.
8305        let mut consumed_cols = primary_cols.len();
8306        for peer in &joined {
8307            let right_arity = peer.cols.len();
8308            let mut next: Vec<Row> = Vec::new();
8309            for left in &working {
8310                let mut left_matched = false;
8311                let per_left_rrows: alloc::borrow::Cow<'_, [Row]> = match peer.lateral {
8312                    Some(inner) => {
8313                        // Substitute outer columns and run the inner
8314                        // SELECT against the current left row's slice
8315                        // of the combined schema.
8316                        let outer_schema = &combined_schema[..consumed_cols];
8317                        let rows = self.materialise_lateral_for_outer(inner, outer_schema, left)?;
8318                        alloc::borrow::Cow::Owned(rows)
8319                    }
8320                    None => {
8321                        let r = peer.eager_rows.as_ref().expect("non-lateral peer eager");
8322                        alloc::borrow::Cow::Borrowed(r.as_slice())
8323                    }
8324                };
8325                for right in per_left_rrows.as_ref() {
8326                    let mut combined_vals = left.values.clone();
8327                    combined_vals.extend(right.values.iter().cloned());
8328                    let combined = Row::new(combined_vals);
8329                    let keep = if let Some(on_expr) = peer.on {
8330                        let cond = eval::eval_expr(on_expr, &combined, &ctx)?;
8331                        matches!(cond, Value::Bool(true))
8332                    } else {
8333                        true
8334                    };
8335                    if keep {
8336                        next.push(combined);
8337                        left_matched = true;
8338                    }
8339                }
8340                if !left_matched && matches!(peer.kind, JoinKind::Left) {
8341                    let mut combined_vals = left.values.clone();
8342                    for _ in 0..right_arity {
8343                        combined_vals.push(Value::Null);
8344                    }
8345                    next.push(Row::new(combined_vals));
8346                }
8347            }
8348            working = next;
8349            consumed_cols += right_arity;
8350            debug_assert!(consumed_cols <= combined_schema.len());
8351        }
8352        let mut filtered: Vec<Row> = Vec::new();
8353        for row in working {
8354            if let Some(where_expr) = where_ {
8355                let cond = eval::eval_expr(where_expr, &row, &ctx)?;
8356                if !matches!(cond, Value::Bool(true)) {
8357                    continue;
8358                }
8359            }
8360            filtered.push(row);
8361        }
8362        Ok((combined_schema, filtered))
8363    }
8364
8365    /// v7.17.0 Phase 3.P0-41 — probe a LATERAL subquery's projection
8366    /// schema by running it once with a NULL-padded outer context.
8367    /// The probe never materialises real outer rows; it just executes
8368    /// the inner SELECT with `outer_alias.col` references substituted
8369    /// to NULL so the projection's type inference is exercised.
8370    fn lateral_probe_schema(
8371        &self,
8372        inner: &SelectStatement,
8373    ) -> Result<Vec<ColumnSchema>, EngineError> {
8374        // Substitute every qualified column reference whose qualifier
8375        // does NOT match an in-subquery FROM alias with NULL. The
8376        // safest probe is to walk the inner SELECT and replace any
8377        // `<qual>.<col>` whose qual isn't bound inside the subquery
8378        // with a Null literal. For the v7.17 probe we just run the
8379        // unmodified subquery and surface the columns; if it fails
8380        // (e.g. references an outer column the probe can't resolve),
8381        // we synthesise a best-effort schema from the SELECT items
8382        // by inferring a single Text-typed column per projection.
8383        match self.execute_readonly_select_for_lateral_probe(inner) {
8384            Ok(QueryResult::Rows { columns, .. }) => Ok(columns),
8385            // Best-effort fallback: each SELECT item becomes a TEXT
8386            // column. Real schemas only differ when the inner SELECT
8387            // references outer columns at projection-time; those
8388            // queries surface via the substitution path during
8389            // per-row execution and still return the right values.
8390            _ => {
8391                let mut out: Vec<ColumnSchema> = Vec::new();
8392                for (i, item) in inner.items.iter().enumerate() {
8393                    let name = match item {
8394                        SelectItem::Expr { alias: Some(a), .. } => a.clone(),
8395                        SelectItem::Expr { expr, .. } => synth_lateral_col_name(expr, i),
8396                        SelectItem::Wildcard => alloc::format!("col{i}"),
8397                    };
8398                    out.push(ColumnSchema::new(name, DataType::Text, true));
8399                }
8400                Ok(out)
8401            }
8402        }
8403    }
8404
8405    /// v7.17.0 Phase 3.P0-41 — try the inner LATERAL subquery against
8406    /// the engine in read-only mode for schema-probe purposes. Failure
8407    /// is expected when the subquery references an outer column the
8408    /// probe can't resolve; the caller falls back to a best-effort
8409    /// schema based on the SELECT items.
8410    fn execute_readonly_select_for_lateral_probe(
8411        &self,
8412        inner: &SelectStatement,
8413    ) -> Result<QueryResult, EngineError> {
8414        self.exec_bare_select_cancel(inner, CancelToken::none())
8415    }
8416
8417    /// v7.17.0 Phase 3.P0-41 — materialise a LATERAL subquery's rows
8418    /// for one outer-row context. Walks the inner SELECT, replaces
8419    /// every `<outer_alias>.<col>` reference whose alias appears in
8420    /// the outer schema with the literal value from the outer row,
8421    /// then runs the rewritten SELECT against the engine.
8422    fn materialise_lateral_for_outer(
8423        &self,
8424        inner: &SelectStatement,
8425        outer_schema: &[ColumnSchema],
8426        outer_row: &Row,
8427    ) -> Result<Vec<Row>, EngineError> {
8428        let mut substituted = inner.clone();
8429        substitute_outer_columns_multi(&mut substituted, outer_row, outer_schema);
8430        let result = self.exec_bare_select_cancel(&substituted, CancelToken::none())?;
8431        match result {
8432            QueryResult::Rows { rows, .. } => Ok(rows),
8433            _ => Err(EngineError::Unsupported(
8434                "LATERAL subquery must be a SELECT (cannot be a write statement)".into(),
8435            )),
8436        }
8437    }
8438
8439    fn exec_joined_select(
8440        &self,
8441        stmt: &SelectStatement,
8442        from: &FromClause,
8443    ) -> Result<QueryResult, EngineError> {
8444        // v7.17.0 Phase 3.P0-43 + P0-41 — delegate the join +
8445        // WHERE materialisation to the shared helper so the LATERAL
8446        // / UNNEST / regular-catalog paths route through one place.
8447        // (`build_joined_filtered_rows` carries LATERAL support as
8448        // of Phase 3.P0-41.) Downstream we still handle aggregate /
8449        // projection / ORDER BY / DISTINCT / LIMIT inline because
8450        // those depend on the SelectStatement's items list.
8451        let (combined_schema, filtered) =
8452            self.build_joined_filtered_rows(from, stmt.where_.as_ref())?;
8453        let ctx = EvalContext::new(&combined_schema, None);
8454        // Aggregate path: handle GROUP BY / aggregate calls over the
8455        // joined+filtered rows.
8456        if aggregate::uses_aggregate(stmt) {
8457            let refs: Vec<&Row> = filtered.iter().collect();
8458            let mut agg = aggregate::run(stmt, &refs, &combined_schema, None)?;
8459            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
8460            return Ok(QueryResult::Rows {
8461                columns: agg.columns,
8462                rows: agg.rows,
8463            });
8464        }
8465
8466        let projection = build_projection(&stmt.items, &combined_schema, "")?;
8467        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::new();
8468        for row in &filtered {
8469            let mut values = Vec::with_capacity(projection.len());
8470            for p in &projection {
8471                values.push(eval::eval_expr(&p.expr, row, &ctx)?);
8472            }
8473            let order_keys = if stmt.order_by.is_empty() {
8474                Vec::new()
8475            } else {
8476                build_order_keys(&stmt.order_by, row, &ctx)?
8477            };
8478            tagged.push((order_keys, Row::new(values)));
8479        }
8480        if !stmt.order_by.is_empty() {
8481            let keep = if stmt.distinct {
8482                None
8483            } else {
8484                stmt.limit_literal()
8485                    .map(|l| l as usize + stmt.offset_literal().map_or(0, |o| o as usize))
8486            };
8487            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
8488            partial_sort_tagged(&mut tagged, keep, &descs);
8489        }
8490        let mut output_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
8491        if stmt.distinct {
8492            output_rows = dedup_rows(output_rows);
8493        }
8494        apply_offset_and_limit(
8495            &mut output_rows,
8496            stmt.offset_literal(),
8497            stmt.limit_literal(),
8498        );
8499        let columns: Vec<ColumnSchema> = projection
8500            .into_iter()
8501            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
8502            .collect();
8503        Ok(QueryResult::Rows {
8504            columns,
8505            rows: output_rows,
8506        })
8507    }
8508}
8509
8510/// One row-producing projection: an expression to evaluate, the resulting
8511/// column's user-visible name, its inferred type, and nullability.
8512#[derive(Debug, Clone)]
8513struct ProjectedItem {
8514    expr: Expr,
8515    output_name: String,
8516    ty: DataType,
8517    nullable: bool,
8518}
8519
8520/// Dedupe a row set, preserving first-seen order. `Row`'s `PartialEq` is
8521/// structural (`Vec<Value>` ⇒ pairwise `Value` equality), which gives SQL
8522/// `NULL = NULL → TRUE` and `NaN = NaN → FALSE`. The first agrees with
8523/// the spec's "two NULLs are not distinct"; the second is a tolerated
8524/// quirk for v1 (no NaN literals are reachable from the SQL surface).
8525fn dedup_rows(rows: Vec<Row>) -> Vec<Row> {
8526    let mut out: Vec<Row> = Vec::with_capacity(rows.len());
8527    for r in rows {
8528        if !out.iter().any(|seen| seen == &r) {
8529            out.push(r);
8530        }
8531    }
8532    out
8533}
8534
8535/// Coerce a `Value` to an `f64` sort key for ORDER BY. Numbers map directly;
8536/// NULL sorts last (treated as `+∞`); booleans are 0.0 / 1.0; text uses lex
8537/// order via the byte values; vectors are not sortable.
8538fn value_to_order_key(v: &Value) -> Result<f64, EngineError> {
8539    match v {
8540        Value::Null => Ok(f64::INFINITY),
8541        Value::SmallInt(n) => Ok(f64::from(*n)),
8542        Value::Int(n) => Ok(f64::from(*n)),
8543        Value::Date(d) => Ok(f64::from(*d)),
8544        #[allow(clippy::cast_precision_loss)]
8545        Value::Timestamp(t) => Ok(*t as f64),
8546        // v7.17.0 Phase 3.P0-32 — PG TIME ordered by underlying
8547        // i64 microseconds (matches wall-clock ordering).
8548        #[allow(clippy::cast_precision_loss)]
8549        Value::Time(us) => Ok(*us as f64),
8550        // v7.17.0 Phase 3.P0-33 — MySQL YEAR ordered by underlying
8551        // u16 (matches calendar ordering; zero-year sentinel
8552        // sorts before 1901).
8553        Value::Year(y) => Ok(f64::from(*y)),
8554        // v7.17.0 Phase 3.P0-34 — PG TIMETZ ordered by the
8555        // UTC-equivalent microseconds (local wall - offset). Two
8556        // values for the same physical instant in different zones
8557        // sort equal — matches PG TIMETZ index behaviour.
8558        #[allow(clippy::cast_precision_loss)]
8559        Value::TimeTz { us, offset_secs } => Ok((us - i64::from(*offset_secs) * 1_000_000) as f64),
8560        // v7.17.0 Phase 3.P0-35 — PG MONEY ordered by i64 cents.
8561        #[allow(clippy::cast_precision_loss)]
8562        Value::Money(c) => Ok(*c as f64),
8563        // v7.17.0 Phase 3.P0-38 — range ordering is not supported
8564        // in v7.17.0 (needs lex-then-inclusivity tiebreak).
8565        Value::Range { .. } => Err(EngineError::Unsupported(
8566            "ORDER BY of a range value is not supported in v7.17.0".into(),
8567        )),
8568        // v7.17.0 Phase 3.P0-39 — hstore is not orderable.
8569        Value::Hstore(_) => Err(EngineError::Unsupported(
8570            "ORDER BY of a hstore value is not supported".into(),
8571        )),
8572        // v7.17.0 Phase 3.P0-40 — 2D arrays not orderable.
8573        Value::IntArray2D(_) | Value::BigIntArray2D(_) | Value::TextArray2D(_) => Err(
8574            EngineError::Unsupported("ORDER BY of a 2D array is not supported in v7.17.0".into()),
8575        ),
8576        #[allow(clippy::cast_precision_loss)]
8577        Value::Numeric { scaled, scale } => {
8578            // Scaled integer / 10^scale, computed via f64 for sort
8579            // ordering only. Precision losses here only matter for
8580            // ORDER BY tie-breaks well past 15 significant digits.
8581            // `f64::powi` lives in std; we hand-roll the loop so the
8582            // no_std engine crate doesn't need it.
8583            let mut divisor = 1.0_f64;
8584            for _ in 0..*scale {
8585                divisor *= 10.0;
8586            }
8587            Ok((*scaled as f64) / divisor)
8588        }
8589        #[allow(clippy::cast_precision_loss)]
8590        Value::BigInt(n) => Ok(*n as f64),
8591        Value::Float(x) => Ok(*x),
8592        Value::Bool(b) => Ok(if *b { 1.0 } else { 0.0 }),
8593        Value::Text(s) => {
8594            // Lex order by codepoints — good enough for ORDER BY name.
8595            // Map first 8 bytes packed into u64 as a coarse key; ties fall to
8596            // partial_cmp Equal. v1.x can swap in a real string comparator.
8597            let mut key: u64 = 0;
8598            for &b in s.as_bytes().iter().take(8) {
8599                key = (key << 8) | u64::from(b);
8600            }
8601            #[allow(clippy::cast_precision_loss)]
8602            Ok(key as f64)
8603        }
8604        Value::Vector(_) | Value::Sq8Vector(_) | Value::HalfVector(_) => {
8605            Err(EngineError::Unsupported(
8606                "ORDER BY of a raw vector column is not meaningful — use `<->`".into(),
8607            ))
8608        }
8609        Value::Interval { .. } => Err(EngineError::Unsupported(
8610            "ORDER BY of an INTERVAL is not supported in v2.11 \
8611             (months vs micros has no single canonical ordering)"
8612                .into(),
8613        )),
8614        Value::Json(_) => Err(EngineError::Unsupported(
8615            "ORDER BY of a JSON value is not supported — cast the document to text first".into(),
8616        )),
8617        // v7.5.0 — Value is #[non_exhaustive]; future variants need
8618        // an explicit ORDER BY mapping. Surface as Unsupported until
8619        // engine support is added.
8620        _ => Err(EngineError::Unsupported(
8621            "ORDER BY of this value type is not supported".into(),
8622        )),
8623    }
8624}
8625
8626/// Try to plan a WHERE clause as an equality lookup against an existing
8627/// index. Returns the candidate row indices on success; `None` means the
8628/// caller should fall back to a full scan.
8629///
8630/// v0.8 recognises a single top-level `col = literal` (in either operand
8631/// order). AND chains and range scans land in later milestones.
8632/// Look for `ORDER BY col <dist-op> literal LIMIT k` against an
8633/// NSW-indexed vector column. Recognised distance ops: `<->` (L2),
8634/// `<#>` (inner product), `<=>` (cosine). When a WHERE clause is
8635/// present, the planner does an "over-fetch and filter" pass — it
8636/// asks the graph for `k * over_fetch` candidates, evaluates WHERE
8637/// against each, and trims back to `k`. Returns the row indices in
8638/// ascending-distance order when the plan applies.
8639fn try_nsw_knn(
8640    stmt: &SelectStatement,
8641    table: &Table,
8642    schema_cols: &[ColumnSchema],
8643    table_alias: &str,
8644) -> Option<Vec<usize>> {
8645    if stmt.distinct {
8646        return None;
8647    }
8648    let limit = usize::try_from(stmt.limit_literal()?).ok()?;
8649    if limit == 0 {
8650        return None;
8651    }
8652    // v6.4.0 — NSW kNN dispatch needs a single ORDER BY key on the
8653    // distance metric. Multi-key ORDER BY falls through to the
8654    // generic sort path.
8655    if stmt.order_by.len() != 1 {
8656        return None;
8657    }
8658    let order = &stmt.order_by[0];
8659    // NSW kNN returns rows ascending by distance — DESC inverts the
8660    // natural order, so the planner can't handle it without a sort
8661    // pass. Fall back to the generic ORDER BY path.
8662    if order.desc {
8663        return None;
8664    }
8665    let Expr::Binary { lhs, op, rhs } = &order.expr else {
8666        return None;
8667    };
8668    let metric = match op {
8669        BinOp::L2Distance => spg_storage::NswMetric::L2,
8670        BinOp::InnerProduct => spg_storage::NswMetric::InnerProduct,
8671        BinOp::CosineDistance => spg_storage::NswMetric::Cosine,
8672        _ => return None,
8673    };
8674    // Accept both `col <op> literal` and `literal <op> col`.
8675    let ((Expr::Column(col), literal) | (literal, Expr::Column(col))) =
8676        (lhs.as_ref(), rhs.as_ref())
8677    else {
8678        return None;
8679    };
8680    if let Some(q) = &col.qualifier
8681        && q != table_alias
8682    {
8683        return None;
8684    }
8685    let col_pos = schema_cols.iter().position(|s| s.name == col.name)?;
8686    let query = literal_to_vector(literal)?;
8687    let idx = spg_storage::nsw_index_on(table, col_pos)?;
8688    if let Some(where_expr) = &stmt.where_ {
8689        // Over-fetch and filter. The factor (10×) is a heuristic that
8690        // covers typical selectivity for the corpus tests; v2.x will
8691        // make it configurable.
8692        let over_fetch = limit.saturating_mul(10).max(NSW_OVER_FETCH_FLOOR);
8693        let candidates = spg_storage::nsw_query(table, &idx.name, &query, over_fetch, metric);
8694        let ctx = EvalContext::new(schema_cols, Some(table_alias));
8695        let mut kept: Vec<usize> = Vec::with_capacity(limit);
8696        for i in candidates {
8697            let row = &table.rows()[i];
8698            let cond = eval::eval_expr(where_expr, row, &ctx).ok()?;
8699            if matches!(cond, Value::Bool(true)) {
8700                kept.push(i);
8701                if kept.len() >= limit {
8702                    break;
8703                }
8704            }
8705        }
8706        Some(kept)
8707    } else {
8708        Some(spg_storage::nsw_query(
8709            table, &idx.name, &query, limit, metric,
8710        ))
8711    }
8712}
8713
8714/// Lower bound on the over-fetch pool when WHERE is present — even
8715/// for tiny `LIMIT 1` queries we keep enough candidates to absorb a
8716/// few WHERE rejections.
8717const NSW_OVER_FETCH_FLOOR: usize = 32;
8718
8719/// Pull a `Vec<f32>` out of a literal-or-cast expression. Returns
8720/// `None` for anything we can't fold at plan time.
8721fn literal_to_vector(e: &Expr) -> Option<Vec<f32>> {
8722    match e {
8723        Expr::Literal(Literal::Vector(v)) => Some(v.clone()),
8724        Expr::Cast { expr, .. } => literal_to_vector(expr),
8725        _ => None,
8726    }
8727}
8728
8729/// Materialise rows in a planner-supplied order (used by the NSW path)
8730/// without re-running ORDER BY. The projection + LIMIT slot mirror the
8731/// equivalent block in `exec_bare_select`.
8732fn materialise_in_order(
8733    stmt: &SelectStatement,
8734    table: &Table,
8735    schema_cols: &[ColumnSchema],
8736    table_alias: &str,
8737    ordered_rows: &[usize],
8738) -> Result<QueryResult, EngineError> {
8739    let ctx = EvalContext::new(schema_cols, Some(table_alias));
8740    let projection = build_projection(&stmt.items, schema_cols, table_alias)?;
8741    let mut output_rows: Vec<Row> = Vec::with_capacity(ordered_rows.len());
8742    for &i in ordered_rows {
8743        let row = &table.rows()[i];
8744        let mut values = Vec::with_capacity(projection.len());
8745        for p in &projection {
8746            values.push(eval::eval_expr(&p.expr, row, &ctx)?);
8747        }
8748        output_rows.push(Row::new(values));
8749    }
8750    apply_offset_and_limit(
8751        &mut output_rows,
8752        stmt.offset_literal(),
8753        stmt.limit_literal(),
8754    );
8755    let columns: Vec<ColumnSchema> = projection
8756        .into_iter()
8757        .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
8758        .collect();
8759    Ok(QueryResult::Rows {
8760        columns,
8761        rows: output_rows,
8762    })
8763}
8764
8765/// v7.20 P4 — hot-row POSITION seek for the mutation paths
8766/// (UPDATE / DELETE index their planned writes by position in
8767/// `table.rows()`, so the Cow-row shape `try_index_seek`
8768/// returns doesn't fit). Same top-level-AND recursion and
8769/// col=literal resolution; the caller re-applies the full WHERE
8770/// to every returned row so the index only narrows candidates.
8771///
8772/// Returns `None` (→ caller full-scans) when no equality leaf
8773/// hits an index OR any matching locator lives in the cold tier
8774/// — the mutation paths operate on hot rows, and the PK
8775/// promote-then-walk upstream already handles the
8776/// cold-single-row case.
8777fn try_index_seek_positions(
8778    where_expr: &Expr,
8779    schema_cols: &[ColumnSchema],
8780    table: &Table,
8781    table_alias: &str,
8782) -> Option<Vec<usize>> {
8783    if let Expr::Binary {
8784        lhs,
8785        op: BinOp::And,
8786        rhs,
8787    } = where_expr
8788    {
8789        if let Some(p) = try_index_seek_positions(lhs, schema_cols, table, table_alias) {
8790            return Some(p);
8791        }
8792        return try_index_seek_positions(rhs, schema_cols, table, table_alias);
8793    }
8794    let Expr::Binary {
8795        lhs,
8796        op: BinOp::Eq,
8797        rhs,
8798    } = where_expr
8799    else {
8800        return None;
8801    };
8802    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
8803        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
8804    let idx = table.index_on(col_pos)?;
8805    let key = IndexKey::from_value(&value)?;
8806    let locators = idx.lookup_eq(&key);
8807    let mut out = Vec::with_capacity(locators.len());
8808    for loc in locators {
8809        match *loc {
8810            spg_storage::RowLocator::Hot(i) => out.push(i),
8811            spg_storage::RowLocator::Cold { .. } => return None,
8812        }
8813    }
8814    Some(out)
8815}
8816
8817fn try_index_seek<'a>(
8818    where_expr: &Expr,
8819    schema_cols: &[ColumnSchema],
8820    catalog: &'a Catalog,
8821    table: &'a Table,
8822    table_alias: &str,
8823) -> Option<Vec<Cow<'a, Row>>> {
8824    // v7.11.3 — recurse through top-level `AND` so a PG-style
8825    // composite predicate like `WHERE id = 1 AND created_at > $1`
8826    // still hits the index on `id`. The caller re-applies the
8827    // full WHERE expression to each returned row, so dropping the
8828    // residual conjuncts here is correct — the index just narrows
8829    // the candidate set.
8830    if let Expr::Binary {
8831        lhs,
8832        op: BinOp::And,
8833        rhs,
8834    } = where_expr
8835    {
8836        // Try LHS first (typical convention: leading equality on
8837        // the indexed column comes first in user-written SQL).
8838        if let Some(rows) = try_index_seek(lhs, schema_cols, catalog, table, table_alias) {
8839            return Some(rows);
8840        }
8841        return try_index_seek(rhs, schema_cols, catalog, table, table_alias);
8842    }
8843    let Expr::Binary {
8844        lhs,
8845        op: BinOp::Eq,
8846        rhs,
8847    } = where_expr
8848    else {
8849        return None;
8850    };
8851    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
8852        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
8853    let idx = table.index_on(col_pos)?;
8854    let key = IndexKey::from_value(&value)?;
8855    let locators = idx.lookup_eq(&key);
8856    let table_name = table.schema().name.as_str();
8857    // v5.1: each locator dispatches to either the hot tier (zero-
8858    // copy borrow of `table.rows()[i]`) or a cold-tier segment
8859    // (one page read + dense row decode, ~µs scale). Cold rows are
8860    // returned as `Cow::Owned` so the caller's `&Row` iteration
8861    // doesn't see a tier distinction; pre-freezer (no cold
8862    // segments loaded) every locator is `Hot` and every entry is
8863    // `Cow::Borrowed` — identical cost to the pre-v5.1 path.
8864    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(locators.len());
8865    for loc in locators {
8866        match *loc {
8867            spg_storage::RowLocator::Hot(i) => {
8868                if let Some(row) = table.rows().get(i) {
8869                    out.push(Cow::Borrowed(row));
8870                }
8871            }
8872            spg_storage::RowLocator::Cold { segment_id, .. } => {
8873                if let Some(row) = catalog.resolve_cold_locator(table_name, segment_id, &key) {
8874                    out.push(Cow::Owned(row));
8875                }
8876            }
8877        }
8878    }
8879    Some(out)
8880}
8881
8882/// v7.12.3 — GIN-accelerated candidate seek for `WHERE col @@ <ts_query>`.
8883///
8884/// Recurses through top-level `AND` like [`try_index_seek`] so a
8885/// composite predicate `WHERE search_vector @@ q AND id > $1` still
8886/// hits the GIN index on `search_vector` — the caller re-applies the
8887/// full WHERE expression to each returned candidate, so dropping the
8888/// `id > $1` residual here stays semantically correct.
8889///
8890/// Returns `None` when:
8891///   - no leaf is a `col @@ <rhs>` shape on a GIN-indexed column;
8892///   - the RHS can't be const-evaluated to a `Value::TsQuery`
8893///     (typically because it references row columns);
8894///   - the resolved `TsQuery` uses query shapes the MVP doesn't
8895///     accelerate (`Not`, `Phrase` — those fall through to full scan).
8896///
8897/// On `Some(rows)` the caller iterates only `rows` and re-evaluates
8898/// the full `@@` predicate per row, so an over-approximate candidate
8899/// set is safe.
8900fn try_gin_seek<'a>(
8901    where_expr: &Expr,
8902    schema_cols: &[ColumnSchema],
8903    catalog: &'a Catalog,
8904    table: &'a Table,
8905    table_alias: &str,
8906    ctx: &eval::EvalContext<'_>,
8907) -> Option<Vec<Cow<'a, Row>>> {
8908    if let Expr::Binary {
8909        lhs,
8910        op: BinOp::And,
8911        rhs,
8912    } = where_expr
8913    {
8914        if let Some(rows) = try_gin_seek(lhs, schema_cols, catalog, table, table_alias, ctx) {
8915            return Some(rows);
8916        }
8917        return try_gin_seek(rhs, schema_cols, catalog, table, table_alias, ctx);
8918    }
8919    // v7.17.0 Phase 3.P0-44 — MySQL `MATCH(col1, col2) AGAINST (...)`
8920    // desugars into `(to_tsvector(col1) @@ q) OR (to_tsvector(col2) @@ q)`
8921    // in the parser. To accelerate the multi-column case, walk OR the same
8922    // way we walk AND: only emit a candidate set if BOTH sides can seek
8923    // (otherwise the OR result is unbounded and we must fall through to
8924    // the full scan). Candidates are union'd; the caller's WHERE re-eval
8925    // verifies the full predicate per row, so duplicates / supersets stay
8926    // semantically safe.
8927    if let Expr::Binary {
8928        lhs,
8929        op: BinOp::Or,
8930        rhs,
8931    } = where_expr
8932    {
8933        let left = try_gin_seek(lhs, schema_cols, catalog, table, table_alias, ctx)?;
8934        let right = try_gin_seek(rhs, schema_cols, catalog, table, table_alias, ctx)?;
8935        let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(left.len() + right.len());
8936        out.extend(left);
8937        out.extend(right);
8938        return Some(out);
8939    }
8940    let Expr::Binary {
8941        lhs,
8942        op: BinOp::TsMatch,
8943        rhs,
8944    } = where_expr
8945    else {
8946        return None;
8947    };
8948    // Either side can be the column; pgvector idiom (`vec @@ q`)
8949    // hits the first arm, FROM-clause-derived (`plainto_tsquery($1)
8950    // q ... WHERE search_vector @@ q`) the same. CROSS JOIN derived
8951    // tables resolve `q` to a Column too.
8952    let (col_pos, query) = resolve_gin_col_query(lhs, rhs, schema_cols, table_alias, ctx)
8953        .or_else(|| resolve_gin_col_query(rhs, lhs, schema_cols, table_alias, ctx))?;
8954    // v7.17.0 Phase 3.P0-44 — MySQL `FULLTEXT KEY` builds a
8955    // `IndexKind::GinFulltext` posting list (Phase 2.2). It shares
8956    // the same `gin_lookup_word` shape as the tsvector-typed GIN,
8957    // so the MATCH-AGAINST `@@` predicate (desugared by the parser
8958    // into `to_tsvector(col) @@ plainto_tsquery('term')`) routes
8959    // through the same candidate-set seek.
8960    let idx = table
8961        .indices()
8962        .iter()
8963        .find(|i| i.column_position == col_pos && (i.is_gin() || i.is_gin_fulltext()))?;
8964    let candidates = gin_query_candidates(idx, &query)?;
8965    let _ = catalog; // cold-tier row resolution unused in MVP; see below.
8966    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(candidates.len());
8967    for loc in candidates {
8968        match loc {
8969            spg_storage::RowLocator::Hot(i) => {
8970                if let Some(row) = table.rows().get(i) {
8971                    out.push(Cow::Borrowed(row));
8972                }
8973            }
8974            // GIN cold-tier rows in the MVP: skipped, matching the
8975            // full-scan `@@` path which itself only iterates
8976            // `table.rows()` (hot tier). When v7.13+ adds cold-tier
8977            // scan-time materialisation for `@@`, the parallel
8978            // resolution lands here; until then both paths see the
8979            // same hot-only candidate set so correctness is preserved.
8980            spg_storage::RowLocator::Cold { .. } => {}
8981        }
8982    }
8983    Some(out)
8984}
8985
8986/// v7.15.0 — trigram-GIN-accelerated candidate seek for
8987/// `WHERE col LIKE '<pat>'` and `WHERE col ILIKE '<pat>'` when
8988/// the column has a `gin_trgm_ops` GIN index.
8989///
8990/// Walks top-level `AND` so multi-predicate WHEREs (`col LIKE
8991/// 'foo%' AND id > 1`) still hit the trigram index; the caller
8992/// re-evaluates the full WHERE per candidate row, so dropping
8993/// non-LIKE conjuncts here stays semantically correct.
8994///
8995/// Returns `None` when:
8996///   - no leaf is `col LIKE/ILIKE <literal>` on a trigram-GIN-
8997///     indexed column;
8998///   - the pattern's literal runs are too short to constrain
8999///     (pattern decomposes into `< 3`-char runs, e.g. `%ab%`);
9000///   - the pattern doesn't const-evaluate to a TEXT.
9001fn try_trgm_seek<'a>(
9002    where_expr: &Expr,
9003    schema_cols: &[ColumnSchema],
9004    table: &'a Table,
9005    table_alias: &str,
9006) -> Option<Vec<Cow<'a, Row>>> {
9007    if let Expr::Binary {
9008        lhs,
9009        op: BinOp::And,
9010        rhs,
9011    } = where_expr
9012    {
9013        if let Some(rows) = try_trgm_seek(lhs, schema_cols, table, table_alias) {
9014            return Some(rows);
9015        }
9016        return try_trgm_seek(rhs, schema_cols, table, table_alias);
9017    }
9018    // LIKE node is what carries the column reference + pattern.
9019    // ILIKE is the same AST node — PG's LIKE/ILIKE both lower
9020    // through `Expr::Like { expr, pattern, negated }`. The trigram
9021    // index posting-list keys are already lower-cased and
9022    // case-folded, so we only need the pattern's literal runs.
9023    let Expr::Like { expr, pattern, .. } = where_expr else {
9024        return None;
9025    };
9026    // Column side.
9027    let Expr::Column(c) = expr.as_ref() else {
9028        return None;
9029    };
9030    if let Some(q) = &c.qualifier
9031        && q != table_alias
9032    {
9033        return None;
9034    }
9035    let col_pos = schema_cols
9036        .iter()
9037        .position(|s| s.name.eq_ignore_ascii_case(&c.name))?;
9038    // Index must exist on that column AND be a trigram-GIN.
9039    let idx = table
9040        .indices()
9041        .iter()
9042        .find(|i| i.column_position == col_pos && i.is_gin_trgm())?;
9043    // Pattern side must be a literal TEXT — anything else (column
9044    // ref, function call, parameter that hasn't been bound yet)
9045    // falls through to full scan.
9046    let Expr::Literal(spg_sql::ast::Literal::String(pat)) = pattern.as_ref() else {
9047        return None;
9048    };
9049    let trigrams = spg_storage::trgm::trigrams_from_like_pattern(pat)?;
9050    // Intersect every trigram's posting list. Empty intersection
9051    // → empty candidate set (caller short-circuits its row loop).
9052    let mut iter = trigrams.iter();
9053    let first = iter.next()?;
9054    let mut acc: Vec<spg_storage::RowLocator> = {
9055        let mut v = idx.gin_trgm_lookup(first).to_vec();
9056        v.sort_by_key(locator_sort_key);
9057        v.dedup_by_key(|l| locator_sort_key(l));
9058        v
9059    };
9060    for tri in iter {
9061        let mut next: Vec<spg_storage::RowLocator> = idx.gin_trgm_lookup(tri).to_vec();
9062        next.sort_by_key(locator_sort_key);
9063        next.dedup_by_key(|l| locator_sort_key(l));
9064        // Sorted-merge intersection.
9065        let mut merged: Vec<spg_storage::RowLocator> =
9066            Vec::with_capacity(acc.len().min(next.len()));
9067        let (mut i, mut j) = (0usize, 0usize);
9068        while i < acc.len() && j < next.len() {
9069            let lk = locator_sort_key(&acc[i]);
9070            let rk = locator_sort_key(&next[j]);
9071            match lk.cmp(&rk) {
9072                core::cmp::Ordering::Less => i += 1,
9073                core::cmp::Ordering::Greater => j += 1,
9074                core::cmp::Ordering::Equal => {
9075                    merged.push(acc[i]);
9076                    i += 1;
9077                    j += 1;
9078                }
9079            }
9080        }
9081        acc = merged;
9082        if acc.is_empty() {
9083            break;
9084        }
9085    }
9086    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(acc.len());
9087    for loc in acc {
9088        if let spg_storage::RowLocator::Hot(i) = loc
9089            && let Some(row) = table.rows().get(i)
9090        {
9091            out.push(Cow::Borrowed(row));
9092        }
9093        // Cold-tier rows: skipped in MVP (same as try_gin_seek).
9094    }
9095    Some(out)
9096}
9097
9098/// v7.12.3 — extract `(column_position, TsQueryAst)` when one side of
9099/// the binary is a column reference to a GIN-indexed tsvector column
9100/// and the other side const-evaluates to a `Value::TsQuery`. Returns
9101/// `None` if the column reference is for the wrong table alias, or if
9102/// the RHS expression depends on row data.
9103fn resolve_gin_col_query(
9104    col_side: &Expr,
9105    query_side: &Expr,
9106    schema_cols: &[ColumnSchema],
9107    table_alias: &str,
9108    ctx: &eval::EvalContext<'_>,
9109) -> Option<(usize, spg_storage::TsQueryAst)> {
9110    // v7.17.0 Phase 3.P0-44 — the MATCH AGAINST desugar wraps the
9111    // column in `to_tsvector('simple', col)`, so we peel that wrapper
9112    // before the column lookup. Direct `col @@ tsquery` paths (the
9113    // tsvector-typed v7.12 surface) skip the wrapper entirely.
9114    let column = match col_side {
9115        Expr::Column(c) => c,
9116        Expr::FunctionCall { name, args }
9117            if name.eq_ignore_ascii_case("to_tsvector") && !args.is_empty() =>
9118        {
9119            // PG `to_tsvector` accepts either `to_tsvector(col)` or
9120            // `to_tsvector(config, col)`. In both shapes the column
9121            // we care about is the final argument.
9122            if let Expr::Column(c) = args.last().unwrap() {
9123                c
9124            } else {
9125                return None;
9126            }
9127        }
9128        _ => return None,
9129    };
9130    let c = column;
9131    if let Some(q) = &c.qualifier
9132        && q != table_alias
9133    {
9134        return None;
9135    }
9136    let pos = schema_cols.iter().position(|s| s.name == c.name)?;
9137    // Const-evaluate the query side with an empty row — fails fast
9138    // (with a `ColumnNotFound` / similar) if the expression actually
9139    // depends on row data, which is exactly the bail signal we want.
9140    let empty_row = Row::new(Vec::new());
9141    let v = eval::eval_expr(query_side, &empty_row, ctx).ok()?;
9142    let Value::TsQuery(q) = v else { return None };
9143    Some((pos, q))
9144}
9145
9146/// v7.12.3 — walk a `TsQueryAst` against an [`IndexKind::Gin`] index
9147/// to produce a candidate row-locator set. Returns `None` for query
9148/// shapes the MVP doesn't accelerate (`Not` / `Phrase` — both bail to
9149/// full scan since their semantics need either complementation across
9150/// the whole row set or positional verification beyond what the
9151/// posting list carries).
9152///
9153/// Candidate sets are over-approximate — the caller re-applies the
9154/// full `@@` predicate per row, so reporting "row was in some
9155/// posting list" without verifying positions / weights stays correct.
9156fn gin_query_candidates(
9157    idx: &spg_storage::Index,
9158    query: &spg_storage::TsQueryAst,
9159) -> Option<Vec<spg_storage::RowLocator>> {
9160    use spg_storage::TsQueryAst;
9161    match query {
9162        TsQueryAst::Term { word, .. } => {
9163            let mut v: Vec<spg_storage::RowLocator> = idx.gin_lookup_word(word).to_vec();
9164            v.sort_by_key(locator_sort_key);
9165            v.dedup_by_key(|l| locator_sort_key(l));
9166            Some(v)
9167        }
9168        TsQueryAst::And(l, r) => {
9169            let mut left = gin_query_candidates(idx, l)?;
9170            let mut right = gin_query_candidates(idx, r)?;
9171            left.sort_by_key(locator_sort_key);
9172            right.sort_by_key(locator_sort_key);
9173            // Sorted-merge intersection.
9174            let mut out: Vec<spg_storage::RowLocator> = Vec::new();
9175            let (mut i, mut j) = (0usize, 0usize);
9176            while i < left.len() && j < right.len() {
9177                let lk = locator_sort_key(&left[i]);
9178                let rk = locator_sort_key(&right[j]);
9179                match lk.cmp(&rk) {
9180                    core::cmp::Ordering::Less => i += 1,
9181                    core::cmp::Ordering::Greater => j += 1,
9182                    core::cmp::Ordering::Equal => {
9183                        out.push(left[i]);
9184                        i += 1;
9185                        j += 1;
9186                    }
9187                }
9188            }
9189            Some(out)
9190        }
9191        TsQueryAst::Or(l, r) => {
9192            let mut out = gin_query_candidates(idx, l)?;
9193            out.extend(gin_query_candidates(idx, r)?);
9194            out.sort_by_key(locator_sort_key);
9195            out.dedup_by_key(|l| locator_sort_key(l));
9196            Some(out)
9197        }
9198        // Not / Phrase bail to full scan in the MVP. Not needs
9199        // complementation against the whole row set (not represented
9200        // in the posting-list view); Phrase needs positional
9201        // verification beyond what `word → rows` carries.
9202        TsQueryAst::Not(_) | TsQueryAst::Phrase { .. } => None,
9203    }
9204}
9205
9206/// v7.12.3 — total ordering on `RowLocator` for sort/dedup purposes
9207/// inside the GIN intersection / union loops. Hot rows order by their
9208/// row index; Cold rows order after all Hot rows, then by
9209/// `(segment_id, the cold sub-key)`.
9210fn locator_sort_key(l: &spg_storage::RowLocator) -> (u8, u64, u64) {
9211    match *l {
9212        spg_storage::RowLocator::Hot(i) => (0, i as u64, 0),
9213        spg_storage::RowLocator::Cold {
9214            segment_id,
9215            page_offset,
9216        } => (1, u64::from(segment_id), u64::from(page_offset)),
9217    }
9218}
9219
9220/// v5.2.3: extract `(column_position, IndexKey)` when `where_expr`
9221/// is a simple `col = literal` predicate suitable for a `BTree` index
9222/// seek. Used by `exec_update_cancel` / `exec_delete_cancel` to
9223/// decide whether a write touches a cold-tier row (which requires
9224/// promote-on-write / shadow-on-delete) before falling through to
9225/// the hot-tier row walk.
9226///
9227/// Returns `None` for any predicate shape the planner can't push
9228/// down to an index seek — complex WHERE clauses always take the
9229/// hot-only path (cold rows are immutable to non-indexed writes
9230/// until a future scan-fanout sub-version).
9231fn try_pk_predicate(
9232    where_expr: &Expr,
9233    schema_cols: &[ColumnSchema],
9234    table_alias: &str,
9235) -> Option<(usize, IndexKey)> {
9236    let Expr::Binary {
9237        lhs,
9238        op: BinOp::Eq,
9239        rhs,
9240    } = where_expr
9241    else {
9242        return None;
9243    };
9244    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
9245        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
9246    let key = IndexKey::from_value(&value)?;
9247    Some((col_pos, key))
9248}
9249
9250fn resolve_col_literal_pair(
9251    col_side: &Expr,
9252    lit_side: &Expr,
9253    schema_cols: &[ColumnSchema],
9254    table_alias: &str,
9255) -> Option<(usize, Value)> {
9256    let Expr::Column(c) = col_side else {
9257        return None;
9258    };
9259    if let Some(q) = &c.qualifier
9260        && q != table_alias
9261    {
9262        return None;
9263    }
9264    let pos = schema_cols.iter().position(|s| s.name == c.name)?;
9265    let Expr::Literal(l) = lit_side else {
9266        return None;
9267    };
9268    let v = match l {
9269        Literal::Integer(n) => {
9270            if let Ok(small) = i32::try_from(*n) {
9271                Value::Int(small)
9272            } else {
9273                Value::BigInt(*n)
9274            }
9275        }
9276        Literal::Float(x) => Value::Float(*x),
9277        Literal::String(s) => Value::Text(s.clone()),
9278        Literal::Bool(b) => Value::Bool(*b),
9279        Literal::Null => Value::Null,
9280        // Vector, array and Interval literals can't be used as B-tree
9281        // index keys. Tell the planner to fall back to full-scan.
9282        Literal::Vector(_)
9283        | Literal::Interval { .. }
9284        | Literal::TextArray(_)
9285        | Literal::IntArray(_)
9286        | Literal::BigIntArray(_) => return None,
9287    };
9288    Some((pos, v))
9289}
9290
9291/// Find the schema entry that a SELECT-list `Expr::Column` refers to.
9292/// Mirrors `resolve_column` in `eval.rs`, but returns a proper
9293/// `EngineError` so the projection-build path keeps `UnknownQualifier`
9294/// vs `ColumnNotFound` distinct.
9295fn resolve_projection_column<'a>(
9296    c: &ColumnName,
9297    schema_cols: &'a [ColumnSchema],
9298    table_alias: &str,
9299) -> Result<&'a ColumnSchema, EngineError> {
9300    if let Some(q) = &c.qualifier {
9301        let composite = alloc::format!("{q}.{name}", name = c.name);
9302        if let Some(s) = schema_cols.iter().find(|s| s.name == composite) {
9303            return Ok(s);
9304        }
9305        // Single-table case: the qualifier may equal the active alias —
9306        // then look for the bare column name.
9307        if q == table_alias
9308            && let Some(s) = schema_cols.iter().find(|s| s.name == c.name)
9309        {
9310            return Ok(s);
9311        }
9312        // For multi-table schemas the qualifier is unknown only if no
9313        // column bears the "<q>." prefix. For single-table, the alias
9314        // mismatch alone is enough.
9315        let prefix = alloc::format!("{q}.");
9316        let qualifier_known =
9317            q == table_alias || schema_cols.iter().any(|s| s.name.starts_with(&prefix));
9318        if !qualifier_known {
9319            return Err(EngineError::Eval(EvalError::UnknownQualifier {
9320                qualifier: q.clone(),
9321            }));
9322        }
9323        return Err(EngineError::Eval(EvalError::ColumnNotFound {
9324            name: c.name.clone(),
9325        }));
9326    }
9327    if let Some(s) = schema_cols.iter().find(|s| s.name == c.name) {
9328        return Ok(s);
9329    }
9330    let suffix = alloc::format!(".{name}", name = c.name);
9331    let mut matches = schema_cols.iter().filter(|s| s.name.ends_with(&suffix));
9332    let first = matches.next();
9333    let extra = matches.next();
9334    match (first, extra) {
9335        (Some(s), None) => Ok(s),
9336        (Some(_), Some(_)) => Err(EngineError::Eval(EvalError::TypeMismatch {
9337            detail: alloc::format!("ambiguous column reference: {}", c.name),
9338        })),
9339        _ => Err(EngineError::Eval(EvalError::ColumnNotFound {
9340            name: c.name.clone(),
9341        })),
9342    }
9343}
9344
9345fn build_projection(
9346    items: &[SelectItem],
9347    schema_cols: &[ColumnSchema],
9348    table_alias: &str,
9349) -> Result<Vec<ProjectedItem>, EngineError> {
9350    let mut out = Vec::new();
9351    for item in items {
9352        match item {
9353            SelectItem::Wildcard => {
9354                for col in schema_cols {
9355                    out.push(ProjectedItem {
9356                        expr: Expr::Column(ColumnName {
9357                            qualifier: None,
9358                            name: col.name.clone(),
9359                        }),
9360                        output_name: col.name.clone(),
9361                        ty: col.ty,
9362                        nullable: col.nullable,
9363                    });
9364                }
9365            }
9366            SelectItem::Expr { expr, alias } => {
9367                // Plain column ref keeps full schema info (real type +
9368                // nullability). For compound expressions try the
9369                // describe-side function-return-type table first
9370                // (e.g. `SELECT now()` → Timestamptz, `SELECT
9371                // concat(…)` → Text). Falls back to nullable Text
9372                // for shapes the describe path can't resolve.
9373                if let Expr::Column(c) = expr {
9374                    let sch = resolve_projection_column(c, schema_cols, table_alias)?;
9375                    let output_name = alias.clone().unwrap_or_else(|| c.name.clone());
9376                    out.push(ProjectedItem {
9377                        expr: expr.clone(),
9378                        output_name,
9379                        ty: sch.ty,
9380                        nullable: sch.nullable,
9381                    });
9382                } else if let Some(shape) = describe::describe_expr(expr, schema_cols) {
9383                    let output_name = alias.clone().unwrap_or_else(|| expr.to_string());
9384                    out.push(ProjectedItem {
9385                        expr: expr.clone(),
9386                        output_name,
9387                        ty: shape.ty,
9388                        nullable: shape.nullable,
9389                    });
9390                } else {
9391                    let output_name = alias.clone().unwrap_or_else(|| expr.to_string());
9392                    out.push(ProjectedItem {
9393                        expr: expr.clone(),
9394                        output_name,
9395                        ty: DataType::Text,
9396                        nullable: true,
9397                    });
9398                }
9399            }
9400        }
9401    }
9402    Ok(out)
9403}
9404
9405/// Promote an integer to a NUMERIC value at the requested scale.
9406/// Rejects values that, after scaling, would overflow the column's
9407/// precision budget.
9408fn numeric_from_integer(
9409    n: i128,
9410    precision: u8,
9411    scale: u8,
9412    col_name: &str,
9413) -> Result<Value, EngineError> {
9414    let factor = pow10_i128(scale);
9415    let scaled = n.checked_mul(factor).ok_or_else(|| {
9416        EngineError::Unsupported(alloc::format!(
9417            "integer overflow scaling value for column `{col_name}` to scale {scale}"
9418        ))
9419    })?;
9420    check_precision(scaled, precision, col_name)?;
9421    Ok(Value::Numeric { scaled, scale })
9422}
9423
9424/// Float → NUMERIC. Uses round-half-away-from-zero on `x * 10^scale`,
9425/// then verifies the result fits the column's precision.
9426#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
9427fn numeric_from_float(
9428    x: f64,
9429    precision: u8,
9430    scale: u8,
9431    col_name: &str,
9432) -> Result<Value, EngineError> {
9433    if !x.is_finite() {
9434        return Err(EngineError::Unsupported(alloc::format!(
9435            "cannot store non-finite float in NUMERIC column `{col_name}`"
9436        )));
9437    }
9438    let mut factor = 1.0_f64;
9439    for _ in 0..scale {
9440        factor *= 10.0;
9441    }
9442    // Round half-away-from-zero by biasing then casting (`as i128`
9443    // truncates toward zero, so the bias + truncation gives the
9444    // desired rounding). `f64::floor` / `ceil` live in std; we don't
9445    // need them — the cast handles the truncation step.
9446    let shifted = x * factor;
9447    let biased = if shifted >= 0.0 {
9448        shifted + 0.5
9449    } else {
9450        shifted - 0.5
9451    };
9452    // Range-check before casting back to i128 — the cast itself is
9453    // saturating in Rust, which would silently truncate huge inputs.
9454    if !(-1e38..=1e38).contains(&biased) {
9455        return Err(EngineError::Unsupported(alloc::format!(
9456            "value {x} overflows NUMERIC range for column `{col_name}`"
9457        )));
9458    }
9459    let scaled = biased as i128;
9460    check_precision(scaled, precision, col_name)?;
9461    Ok(Value::Numeric { scaled, scale })
9462}
9463
9464/// v7.17.0 Phase 3.P0-67 — parse PG-canonical decimal text into
9465/// `(mantissa: i128, source_scale: u8)`. Accepts optional sign,
9466/// optional integer part, optional fractional part. Rejects
9467/// scientific notation, embedded spaces, locale-specific
9468/// thousand separators. Returns None on bad input — coerce_value
9469/// turns that into a TypeMismatch error.
9470fn parse_numeric_text(s: &str) -> Option<(i128, u8)> {
9471    let s = s.trim();
9472    if s.is_empty() {
9473        return None;
9474    }
9475    let (negative, rest) = match s.as_bytes()[0] {
9476        b'-' => (true, &s[1..]),
9477        b'+' => (false, &s[1..]),
9478        _ => (false, s),
9479    };
9480    if rest.is_empty() {
9481        return None;
9482    }
9483    // Reject scientific notation — bigdecimal collapses it before
9484    // hitting the wire, and we want a clear error if a stray `e`
9485    // sneaks in.
9486    if rest.bytes().any(|b| b == b'e' || b == b'E') {
9487        return None;
9488    }
9489    let (int_part, frac_part) = match rest.find('.') {
9490        Some(idx) => (&rest[..idx], &rest[idx + 1..]),
9491        None => (rest, ""),
9492    };
9493    if int_part.is_empty() && frac_part.is_empty() {
9494        return None;
9495    }
9496    if int_part.bytes().any(|b| !b.is_ascii_digit()) {
9497        return None;
9498    }
9499    if frac_part.bytes().any(|b| !b.is_ascii_digit()) {
9500        return None;
9501    }
9502    let scale_u32 = u32::try_from(frac_part.len()).ok()?;
9503    if scale_u32 > u32::from(u8::MAX) {
9504        return None;
9505    }
9506    let scale = scale_u32 as u8;
9507    let mut digits = alloc::string::String::with_capacity(int_part.len() + frac_part.len() + 1);
9508    if negative {
9509        digits.push('-');
9510    }
9511    digits.push_str(int_part);
9512    digits.push_str(frac_part);
9513    // Strip a leading "+0..0" so parse doesn't choke on "00" etc.
9514    let digits = if digits == "-" {
9515        return None;
9516    } else if digits.is_empty() {
9517        "0"
9518    } else {
9519        digits.as_str()
9520    };
9521    let mantissa: i128 = digits.parse().ok()?;
9522    Some((mantissa, scale))
9523}
9524
9525/// Move a Numeric value from `src_scale` to `dst_scale`. Going up
9526/// multiplies by 10; going down rounds half-away-from-zero.
9527fn numeric_rescale(
9528    scaled: i128,
9529    src_scale: u8,
9530    precision: u8,
9531    dst_scale: u8,
9532    col_name: &str,
9533) -> Result<Value, EngineError> {
9534    let new_scaled = if dst_scale >= src_scale {
9535        let bump = pow10_i128(dst_scale - src_scale);
9536        scaled.checked_mul(bump).ok_or_else(|| {
9537            EngineError::Unsupported(alloc::format!(
9538                "overflow rescaling NUMERIC for column `{col_name}`"
9539            ))
9540        })?
9541    } else {
9542        let drop = pow10_i128(src_scale - dst_scale);
9543        let half = drop / 2;
9544        if scaled >= 0 {
9545            (scaled + half) / drop
9546        } else {
9547            (scaled - half) / drop
9548        }
9549    };
9550    check_precision(new_scaled, precision, col_name)?;
9551    Ok(Value::Numeric {
9552        scaled: new_scaled,
9553        scale: dst_scale,
9554    })
9555}
9556
9557/// Drop the fractional part of a scaled integer, returning the integer
9558/// portion (toward zero). Used for NUMERIC → INT casts.
9559const fn numeric_truncate_to_integer(scaled: i128, scale: u8) -> i128 {
9560    if scale == 0 {
9561        return scaled;
9562    }
9563    let factor = pow10_i128_const(scale);
9564    scaled / factor
9565}
9566
9567/// Verify a scaled NUMERIC value fits the column's declared precision.
9568/// `precision == 0` is the "unconstrained" form (bare `NUMERIC`); we
9569/// skip the check there.
9570fn check_precision(scaled: i128, precision: u8, col_name: &str) -> Result<(), EngineError> {
9571    if precision == 0 {
9572        return Ok(());
9573    }
9574    let limit = pow10_i128(precision);
9575    if scaled.unsigned_abs() >= limit.unsigned_abs() {
9576        return Err(EngineError::Unsupported(alloc::format!(
9577            "NUMERIC value exceeds precision {precision} for column `{col_name}`"
9578        )));
9579    }
9580    Ok(())
9581}
9582
9583const fn pow10_i128_const(p: u8) -> i128 {
9584    let mut acc: i128 = 1;
9585    let mut i = 0;
9586    while i < p {
9587        acc *= 10;
9588        i += 1;
9589    }
9590    acc
9591}
9592
9593fn pow10_i128(p: u8) -> i128 {
9594    pow10_i128_const(p)
9595}
9596
9597/// Walk a parsed `Statement`, swapping any `NOW()` /
9598/// `CURRENT_TIMESTAMP()` / `CURRENT_DATE()` function calls for a
9599/// literal cast that wraps the engine's per-statement clock reading.
9600/// When `now_micros` is `None`, calls stay as-is and surface as
9601/// `unknown function` at eval time — keeps the error path explicit.
9602/// v4.10: pre-walk the WHERE / projection / etc. of a SELECT and
9603/// replace every subquery node with a materialised literal. SPG
9604/// only supports uncorrelated subqueries — the inner SELECT does
9605/// not see outer-row columns, so the result is the same for every
9606/// outer row and can be evaluated once.
9607///
9608/// Returns the rewritten statement; the caller passes this to the
9609/// regular row-loop executor which no longer sees Subquery nodes
9610/// in its tree.
9611impl Engine {
9612    /// v4.12 window executor. Implements `ROW_NUMBER` / `RANK` /
9613    /// `DENSE_RANK` and the partition-aware aggregates `SUM` /
9614    /// `AVG` / `COUNT` / `MIN` / `MAX`. The plan is:
9615    /// 1. Apply the WHERE filter.
9616    /// 2. For each unique `WindowFunction` node in the projection,
9617    ///    partition + sort, compute the per-row value.
9618    /// 3. Append the window values as synthetic columns (`__win_N`)
9619    ///    to the row schema.
9620    /// 4. Rewrite the projection to read those columns.
9621    /// 5. Hand off to the regular project / ORDER BY / LIMIT pipe.
9622    #[allow(
9623        clippy::too_many_lines,
9624        clippy::type_complexity,
9625        clippy::needless_range_loop
9626    )] // window-eval is one cohesive pipe; splitting fragments
9627    fn exec_select_with_window(
9628        &self,
9629        stmt: &SelectStatement,
9630        cancel: CancelToken<'_>,
9631    ) -> Result<QueryResult, EngineError> {
9632        let from = stmt.from.as_ref().ok_or_else(|| {
9633            EngineError::Unsupported("window functions require a FROM clause".into())
9634        })?;
9635        // v7.17.0 Phase 3.P0-43 — JOIN + window functions. Phase
9636        // 3.6 rejected this combination outright ("queued for
9637        // v5.x"); P0-43 materialises the join + WHERE through the
9638        // existing nested-loop helper and runs the window pipeline
9639        // on the joined row set with the combined `alias.col`
9640        // schema. The window expressions resolve through the
9641        // qualifier-aware column resolver same as the aggregate /
9642        // projection paths on JOIN.
9643        let (schema_cols_owned, alias_opt): (Vec<ColumnSchema>, Option<&str>);
9644        let filtered: Vec<Row>;
9645        if from.joins.is_empty() {
9646            let primary = &from.primary;
9647            let table = self.active_catalog().get(&primary.name).ok_or_else(|| {
9648                StorageError::TableNotFound {
9649                    name: primary.name.clone(),
9650                }
9651            })?;
9652            let alias = primary.alias.as_deref().unwrap_or(primary.name.as_str());
9653            schema_cols_owned = table.schema().columns.clone();
9654            alias_opt = Some(alias);
9655            // Materialise WHERE-filtered rows owned so the JOIN
9656            // and single-table paths share a single downstream
9657            // shape. The clone is cheap relative to the window
9658            // computation that follows.
9659            let ctx = self.ev_ctx(&schema_cols_owned, alias_opt);
9660            let mut owned: Vec<Row> = Vec::new();
9661            for (i, row) in table.rows().iter().enumerate() {
9662                if i.is_multiple_of(256) {
9663                    cancel.check()?;
9664                }
9665                if let Some(w) = &stmt.where_ {
9666                    let cond = eval::eval_expr(w, row, &ctx)?;
9667                    if !matches!(cond, Value::Bool(true)) {
9668                        continue;
9669                    }
9670                }
9671                owned.push(row.clone());
9672            }
9673            filtered = owned;
9674        } else {
9675            let (combined_schema, rows) =
9676                self.build_joined_filtered_rows(from, stmt.where_.as_ref())?;
9677            schema_cols_owned = combined_schema;
9678            alias_opt = None;
9679            filtered = rows;
9680        }
9681        let schema_cols = &schema_cols_owned;
9682        let ctx = self.ev_ctx(schema_cols, alias_opt);
9683        let alias = alias_opt.unwrap_or("");
9684        let n_rows = filtered.len();
9685        // Borrow refs into the owned row vec once so the downstream
9686        // `compute_window_partition` call (which takes `&[&Row]`) and
9687        // the per-row eval loops share a single backing buffer.
9688        let filtered_refs: Vec<&Row> = filtered.iter().collect();
9689
9690        // 2) Collect unique window function nodes from projection.
9691        let mut window_nodes: Vec<Expr> = Vec::new();
9692        for item in &stmt.items {
9693            if let SelectItem::Expr { expr, .. } = item {
9694                collect_window_nodes(expr, &mut window_nodes);
9695            }
9696        }
9697
9698        // 3) For each window, compute per-row value.
9699        // Index: same order as window_nodes; for row i, win_vals[w][i].
9700        let mut win_vals: Vec<Vec<Value>> = Vec::with_capacity(window_nodes.len());
9701        for wnode in &window_nodes {
9702            let Expr::WindowFunction {
9703                name,
9704                args,
9705                partition_by,
9706                order_by,
9707                frame,
9708                null_treatment,
9709            } = wnode
9710            else {
9711                unreachable!("collect_window_nodes pushes only WindowFunction");
9712            };
9713            // Compute (partition_key, order_key, original_index) for each row.
9714            let mut indexed: Vec<(Vec<Value>, Vec<(Value, bool)>, usize)> =
9715                Vec::with_capacity(n_rows);
9716            for (i, row) in filtered.iter().enumerate() {
9717                let pkey: Vec<Value> = partition_by
9718                    .iter()
9719                    .map(|p| eval::eval_expr(p, row, &ctx))
9720                    .collect::<Result<_, _>>()?;
9721                let okey: Vec<(Value, bool)> = order_by
9722                    .iter()
9723                    .map(|(e, desc)| eval::eval_expr(e, row, &ctx).map(|v| (v, *desc)))
9724                    .collect::<Result<_, _>>()?;
9725                indexed.push((pkey, okey, i));
9726            }
9727            // Sort by (partition_key, order_key). Partition key uses
9728            // a stable encoded form; order key respects ASC/DESC.
9729            indexed.sort_by(|a, b| {
9730                let p_cmp = partition_key_cmp(&a.0, &b.0);
9731                if p_cmp != core::cmp::Ordering::Equal {
9732                    return p_cmp;
9733                }
9734                order_key_cmp(&a.1, &b.1)
9735            });
9736            // Per-partition compute.
9737            let mut out_vals: Vec<Value> = alloc::vec![Value::Null; n_rows];
9738            let mut p_start = 0;
9739            while p_start < indexed.len() {
9740                let mut p_end = p_start + 1;
9741                while p_end < indexed.len()
9742                    && partition_key_cmp(&indexed[p_start].0, &indexed[p_end].0)
9743                        == core::cmp::Ordering::Equal
9744                {
9745                    p_end += 1;
9746                }
9747                // Compute the function within this partition slice.
9748                compute_window_partition(
9749                    name,
9750                    args,
9751                    !order_by.is_empty(),
9752                    frame.as_ref(),
9753                    *null_treatment,
9754                    &indexed[p_start..p_end],
9755                    &filtered_refs,
9756                    &ctx,
9757                    &mut out_vals,
9758                )?;
9759                p_start = p_end;
9760            }
9761            win_vals.push(out_vals);
9762        }
9763
9764        // 4) Build extended schema: original columns + synthetic.
9765        let mut ext_cols = schema_cols.clone();
9766        for i in 0..window_nodes.len() {
9767            ext_cols.push(ColumnSchema::new(
9768                alloc::format!("__win_{i}"),
9769                DataType::Text, // type doesn't matter for projection eval
9770                true,
9771            ));
9772        }
9773        // 5) Build extended rows: each row gets its window values appended.
9774        let mut ext_rows: Vec<Row> = Vec::with_capacity(n_rows);
9775        for i in 0..n_rows {
9776            let mut values = filtered[i].values.clone();
9777            for w in 0..window_nodes.len() {
9778                values.push(win_vals[w][i].clone());
9779            }
9780            ext_rows.push(Row::new(values));
9781        }
9782        // 6) Rewrite the projection: WindowFunction nodes → Column(__win_N).
9783        let mut rewritten_items: Vec<SelectItem> = Vec::with_capacity(stmt.items.len());
9784        for item in &stmt.items {
9785            let new_item = match item {
9786                SelectItem::Wildcard => SelectItem::Wildcard,
9787                SelectItem::Expr { expr, alias } => {
9788                    let mut e = expr.clone();
9789                    rewrite_window_to_columns(&mut e, &window_nodes);
9790                    SelectItem::Expr {
9791                        expr: e,
9792                        alias: alias.clone(),
9793                    }
9794                }
9795            };
9796            rewritten_items.push(new_item);
9797        }
9798
9799        // 7) Project into final rows. JOIN case uses None so the
9800        // qualifier check in `resolve_column` falls through to the
9801        // composite `alias.col` schema lookup; single-table case
9802        // keeps the bare alias so `bare_col` resolution still
9803        // works for the projection's per-row column references.
9804        let ext_ctx = EvalContext::new(&ext_cols, alias_opt);
9805        let projection = build_projection(&rewritten_items, &ext_cols, alias)?;
9806        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::with_capacity(n_rows);
9807        for (i, row) in ext_rows.iter().enumerate() {
9808            if i.is_multiple_of(256) {
9809                cancel.check()?;
9810            }
9811            let mut values = Vec::with_capacity(projection.len());
9812            for p in &projection {
9813                values.push(eval::eval_expr(&p.expr, row, &ext_ctx)?);
9814            }
9815            let order_keys = if stmt.order_by.is_empty() {
9816                Vec::new()
9817            } else {
9818                let mut keys = Vec::with_capacity(stmt.order_by.len());
9819                for o in &stmt.order_by {
9820                    let mut e = o.expr.clone();
9821                    rewrite_window_to_columns(&mut e, &window_nodes);
9822                    let key = eval::eval_expr(&e, row, &ext_ctx)?;
9823                    keys.push(value_to_order_key(&key)?);
9824                }
9825                keys
9826            };
9827            tagged.push((order_keys, Row::new(values)));
9828        }
9829        // ORDER BY + LIMIT/OFFSET on the projected rows.
9830        if !stmt.order_by.is_empty() {
9831            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
9832            sort_by_keys(&mut tagged, &descs);
9833        }
9834        let mut out_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
9835        apply_offset_and_limit(&mut out_rows, stmt.offset_literal(), stmt.limit_literal());
9836        let final_cols: Vec<ColumnSchema> = projection
9837            .into_iter()
9838            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
9839            .collect();
9840        Ok(QueryResult::Rows {
9841            columns: final_cols,
9842            rows: out_rows,
9843        })
9844    }
9845
9846    /// v4.11: materialise each CTE into a temp table inside a
9847    /// cloned catalog, then run the body SELECT against a fresh
9848    /// engine instance that owns the enriched catalog. The clone
9849    /// is moderately expensive — only paid by CTE-bearing queries.
9850    /// Subqueries inside CTE bodies / the main body resolve as
9851    /// usual; `clock_fn` is propagated so `NOW()` lines up.
9852    /// v7.16.2 — mailrs round-10 A.3. Materialise the
9853    /// `information_schema.*` / `pg_catalog.*` virtual views
9854    /// the SELECT references, then re-execute the SELECT
9855    /// against an enriched catalog where those views are real
9856    /// tables. Same pattern as `exec_with_ctes`. The temp
9857    /// engine carries `meta_views_materialised = true` so its
9858    /// own meta-dispatch short-circuits — without that we'd
9859    /// infinite-recurse since the temp catalog's view name
9860    /// still starts with `__spg_info_` and re-triggers the
9861    /// check.
9862    fn exec_select_with_meta_views(
9863        &self,
9864        stmt: &SelectStatement,
9865        cancel: CancelToken<'_>,
9866    ) -> Result<QueryResult, EngineError> {
9867        let mut needed: alloc::collections::BTreeSet<String> = alloc::collections::BTreeSet::new();
9868        collect_meta_view_names(stmt, &mut needed);
9869        let mut catalog = self.active_catalog().clone();
9870        for view in &needed {
9871            if catalog.get(view).is_some() {
9872                continue;
9873            }
9874            match view.as_str() {
9875                "__spg_info_columns" => {
9876                    let (schema, rows) = synth_information_schema_columns(self.active_catalog());
9877                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9878                }
9879                "__spg_info_tables" => {
9880                    let (schema, rows) = synth_information_schema_tables(self.active_catalog());
9881                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9882                }
9883                "__spg_pg_class" => {
9884                    let (schema, rows) = synth_pg_class(self.active_catalog());
9885                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9886                }
9887                "__spg_pg_attribute" => {
9888                    let (schema, rows) = synth_pg_attribute(self.active_catalog());
9889                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9890                }
9891                // v7.17.0 Phase 3.P0-50 — pg_catalog.pg_type for
9892                // sqlx / SQLAlchemy / Diesel / pgAdmin lookups.
9893                "__spg_pg_type" => {
9894                    let (schema, rows) = synth_pg_type(self.active_catalog());
9895                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9896                }
9897                // v7.17.0 Phase 3.P0-51 — pg_catalog.pg_proc for
9898                // function-name introspection (ORM / pgAdmin).
9899                "__spg_pg_proc" => {
9900                    let (schema, rows) = synth_pg_proc(self.active_catalog());
9901                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9902                }
9903                // v7.17.0 Phase 3.P0-52 — pg_catalog.pg_namespace
9904                // (schema list for admin tools' tree views).
9905                "__spg_pg_namespace" => {
9906                    let (schema, rows) = synth_pg_namespace(self.active_catalog());
9907                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9908                }
9909                // v7.17.0 Phase 3.P0-53 — pg_catalog.pg_indexes view
9910                // for pgAdmin / DataGrip "indexes per table" listings.
9911                "__spg_pg_indexes" => {
9912                    let (schema, rows) = synth_pg_indexes(self.active_catalog());
9913                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9914                }
9915                // v7.17.0 Phase 3.P0-53 — pg_catalog.pg_index (raw)
9916                // for index introspection by ORM compilers.
9917                "__spg_pg_index" => {
9918                    let (schema, rows) = synth_pg_index_raw(self.active_catalog());
9919                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9920                }
9921                // v7.17.0 Phase 3.P0-54 — pg_catalog.pg_constraint
9922                // for FK / UNIQUE / PK / CHECK introspection.
9923                "__spg_pg_constraint" => {
9924                    let (schema, rows) = synth_pg_constraint(self.active_catalog());
9925                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9926                }
9927                // v7.17.0 Phase 3.P0-55 — pg_catalog.pg_database /
9928                // pg_roles / pg_user. SPG is single-database so
9929                // pg_database surfaces just `postgres`; pg_roles
9930                // / pg_user walk the engine's UserStore.
9931                "__spg_pg_database" => {
9932                    let (schema, rows) = synth_pg_database(self.active_catalog());
9933                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9934                }
9935                "__spg_pg_roles" | "__spg_pg_user" => {
9936                    let (schema, rows) = synth_pg_roles(self);
9937                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9938                }
9939                // v7.17.0 Phase 3.P0-56 — pg_catalog.pg_views. PG's
9940                // pg_views surfaces every CREATE VIEW result; SPG
9941                // ships one row per declared view from the catalog.
9942                "__spg_pg_views" => {
9943                    let (schema, rows) = synth_pg_views(self.active_catalog());
9944                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9945                }
9946                // v7.17.0 Phase 3.P0-56 — pg_catalog.pg_matviews.
9947                // SPG has no materialised view surface yet so the
9948                // table shares pg_views's schema but stays empty.
9949                "__spg_pg_matviews" => {
9950                    let (schema, _) = synth_pg_views(self.active_catalog());
9951                    materialise_meta_view(&mut catalog, view, schema, Vec::new())?;
9952                }
9953                // pg_catalog.pg_extension — native capability list
9954                // (mailrs embed round-12).
9955                "__spg_pg_extension" => {
9956                    let (schema, rows) = synth_pg_extension();
9957                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9958                }
9959                // v7.17.0 Phase 3.P0-57 — pg_catalog.pg_settings.
9960                "__spg_pg_settings" => {
9961                    let (schema, rows) = synth_pg_settings(self);
9962                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9963                }
9964                // v7.17.0 Phase 3.P0-63 — information_schema.KEY_COLUMN_USAGE.
9965                "__spg_info_key_column_usage" => {
9966                    let (schema, rows) = synth_info_key_column_usage(self.active_catalog());
9967                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9968                }
9969                // v7.17.0 Phase 3.P0-64 — information_schema.REFERENTIAL_CONSTRAINTS.
9970                "__spg_info_referential_constraints" => {
9971                    let (schema, rows) = synth_info_referential_constraints(self.active_catalog());
9972                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9973                }
9974                // v7.17.0 Phase 3.P0-64 — information_schema.STATISTICS.
9975                "__spg_info_statistics" => {
9976                    let (schema, rows) = synth_info_statistics(self.active_catalog());
9977                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9978                }
9979                // v7.17.0 Phase 3.P0-64 — information_schema.ROUTINES.
9980                "__spg_info_routines" => {
9981                    let (schema, rows) = synth_info_routines();
9982                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9983                }
9984                // v7.17.0 Phase 3.P0-65 — mysql.user / mysql.db.
9985                "__spg_mysql_user" => {
9986                    let (schema, rows) = synth_mysql_user(self);
9987                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9988                }
9989                "__spg_mysql_db" => {
9990                    let (schema, rows) = synth_mysql_db();
9991                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9992                }
9993                _ => {
9994                    return Err(EngineError::Unsupported(alloc::format!(
9995                        "meta view {view:?} is not yet materialisable; \
9996                         v7.16.2 covers information_schema.columns / .tables \
9997                         and pg_catalog.pg_class / pg_attribute; \
9998                         v7.17.0 P0-50..P0-57 add pg_type / pg_proc / pg_namespace / \
9999                         pg_indexes / pg_index / pg_constraint / pg_database / pg_roles / \
10000                         pg_user / pg_views / pg_matviews / pg_settings"
10001                    )));
10002                }
10003            }
10004        }
10005        let mut temp = Engine::restore(catalog);
10006        if let Some(c) = self.clock {
10007            temp = temp.with_clock(c);
10008        }
10009        if let Some(f) = self.salt_fn {
10010            temp = temp.with_salt_fn(f);
10011        }
10012        temp.meta_views_materialised = true;
10013        temp.exec_select_cancel(stmt, cancel)
10014    }
10015
10016    fn exec_with_ctes(
10017        &self,
10018        stmt: &SelectStatement,
10019        cancel: CancelToken<'_>,
10020    ) -> Result<QueryResult, EngineError> {
10021        cancel.check()?;
10022        let mut catalog = self.active_catalog().clone();
10023        for cte in &stmt.ctes {
10024            if catalog.get(&cte.name).is_some() {
10025                return Err(EngineError::Unsupported(alloc::format!(
10026                    "CTE name {:?} shadows an existing table; rename the CTE",
10027                    cte.name
10028                )));
10029            }
10030            let (columns, rows) = if cte.recursive {
10031                self.materialise_recursive_cte(cte, &catalog, cancel)?
10032            } else {
10033                let body_result = self.exec_select_cancel(&cte.body, cancel)?;
10034                let QueryResult::Rows { columns, rows } = body_result else {
10035                    return Err(EngineError::Unsupported(alloc::format!(
10036                        "CTE {:?} body did not return rows",
10037                        cte.name
10038                    )));
10039                };
10040                (columns, rows)
10041            };
10042            // v4.22: the projection builder labels any non-column
10043            // expression as Text — including literal SELECT 1.
10044            // Promote each column's type to whatever the rows
10045            // actually carry so the CTE storage table accepts them.
10046            let inferred = infer_column_types(&columns, &rows);
10047            let mut columns = inferred;
10048            // v4.22: apply optional `WITH name(a, b, c)` overrides.
10049            if !cte.column_overrides.is_empty() {
10050                if cte.column_overrides.len() != columns.len() {
10051                    return Err(EngineError::Unsupported(alloc::format!(
10052                        "CTE {:?} column list has {} names but body returns {} columns",
10053                        cte.name,
10054                        cte.column_overrides.len(),
10055                        columns.len()
10056                    )));
10057                }
10058                for (col, name) in columns.iter_mut().zip(cte.column_overrides.iter()) {
10059                    col.name.clone_from(name);
10060                }
10061            }
10062            let schema = TableSchema::new(cte.name.clone(), columns);
10063            catalog.create_table(schema).map_err(EngineError::Storage)?;
10064            let table = catalog
10065                .get_mut(&cte.name)
10066                .expect("just-created CTE table must exist");
10067            for row in rows {
10068                table.insert(row).map_err(EngineError::Storage)?;
10069            }
10070        }
10071        // Strip CTEs from the body before running on the temp engine
10072        // so we don't recurse forever.
10073        let mut body = stmt.clone();
10074        body.ctes = Vec::new();
10075        let mut temp = Engine::restore(catalog);
10076        if let Some(c) = self.clock {
10077            temp = temp.with_clock(c);
10078        }
10079        if let Some(f) = self.salt_fn {
10080            temp = temp.with_salt_fn(f);
10081        }
10082        temp.exec_select_cancel(&body, cancel)
10083    }
10084
10085    /// v4.22: materialise a WITH RECURSIVE CTE. The body must be a
10086    /// UNION (or UNION ALL) of an anchor that does not reference
10087    /// the CTE name, and one or more recursive terms that do. The
10088    /// anchor runs first; each subsequent iteration runs the
10089    /// recursive term against a temp catalog where the CTE name is
10090    /// bound to the *previous* iteration's output. Iteration stops
10091    /// when the recursive term yields no rows; UNION (DISTINCT)
10092    /// deduplicates against the accumulated result, UNION ALL does
10093    /// not. A hard cap on total rows prevents runaway queries.
10094    #[allow(clippy::too_many_lines)]
10095    fn materialise_recursive_cte(
10096        &self,
10097        cte: &spg_sql::ast::Cte,
10098        base_catalog: &Catalog,
10099        cancel: CancelToken<'_>,
10100    ) -> Result<(Vec<ColumnSchema>, Vec<Row>), EngineError> {
10101        const MAX_TOTAL_ROWS: usize = 1_000_000;
10102        const MAX_ITERATIONS: usize = 100_000;
10103        cancel.check()?;
10104        if cte.body.unions.is_empty() {
10105            return Err(EngineError::Unsupported(alloc::format!(
10106                "WITH RECURSIVE {:?} body must be a UNION of an anchor and a recursive term",
10107                cte.name
10108            )));
10109        }
10110        // Anchor: the body's leading SELECT, with unions stripped.
10111        let mut anchor = cte.body.clone();
10112        let union_terms = core::mem::take(&mut anchor.unions);
10113        anchor.ctes = Vec::new();
10114        // Anchor must not reference the CTE name.
10115        if select_refers_to(&anchor, &cte.name) {
10116            return Err(EngineError::Unsupported(alloc::format!(
10117                "WITH RECURSIVE {:?}: the anchor must not reference the CTE itself",
10118                cte.name
10119            )));
10120        }
10121        let anchor_result = self.exec_select_cancel(&anchor, cancel)?;
10122        let QueryResult::Rows {
10123            columns: anchor_cols,
10124            rows: anchor_rows,
10125        } = anchor_result
10126        else {
10127            return Err(EngineError::Unsupported(alloc::format!(
10128                "WITH RECURSIVE {:?}: anchor did not return rows",
10129                cte.name
10130            )));
10131        };
10132        // The projection builder labels non-column expressions Text;
10133        // refine column types from the anchor's actual values so the
10134        // intermediate iter-catalog tables accept them.
10135        let mut columns = infer_column_types(&anchor_cols, &anchor_rows);
10136        if !cte.column_overrides.is_empty() {
10137            if cte.column_overrides.len() != columns.len() {
10138                return Err(EngineError::Unsupported(alloc::format!(
10139                    "CTE {:?} column list has {} names but anchor returns {} columns",
10140                    cte.name,
10141                    cte.column_overrides.len(),
10142                    columns.len()
10143                )));
10144            }
10145            for (col, name) in columns.iter_mut().zip(cte.column_overrides.iter()) {
10146                col.name.clone_from(name);
10147            }
10148        }
10149        let mut all_rows: Vec<Row> = anchor_rows.clone();
10150        let mut working_set: Vec<Row> = anchor_rows;
10151        let mut seen: alloc::collections::BTreeSet<Vec<u8>> = alloc::collections::BTreeSet::new();
10152        // Track at least one "all UNION ALL" flag — if every union
10153        // kind is ALL we skip the dedup step (faster + matches PG).
10154        let all_union_all = union_terms.iter().all(|(k, _)| matches!(k, UnionKind::All));
10155        if !all_union_all {
10156            for r in &all_rows {
10157                seen.insert(encode_row_key(r));
10158            }
10159        }
10160        for iter in 0..MAX_ITERATIONS {
10161            cancel.check()?;
10162            if working_set.is_empty() {
10163                break;
10164            }
10165            // Build a fresh catalog: base + CTE bound to working_set.
10166            let mut iter_catalog = base_catalog.clone();
10167            let schema = TableSchema::new(cte.name.clone(), columns.clone());
10168            iter_catalog
10169                .create_table(schema)
10170                .map_err(EngineError::Storage)?;
10171            {
10172                let table = iter_catalog.get_mut(&cte.name).expect("just-created");
10173                for row in &working_set {
10174                    table.insert(row.clone()).map_err(EngineError::Storage)?;
10175                }
10176            }
10177            let mut iter_engine = Engine::restore(iter_catalog);
10178            if let Some(c) = self.clock {
10179                iter_engine = iter_engine.with_clock(c);
10180            }
10181            if let Some(f) = self.salt_fn {
10182                iter_engine = iter_engine.with_salt_fn(f);
10183            }
10184            // Run each recursive term in sequence and collect new rows.
10185            let mut next_set: Vec<Row> = Vec::new();
10186            for (_, term) in &union_terms {
10187                let mut term = term.clone();
10188                term.ctes = Vec::new();
10189                let r = iter_engine.exec_select_cancel(&term, cancel)?;
10190                let QueryResult::Rows {
10191                    columns: rc,
10192                    rows: rs,
10193                } = r
10194                else {
10195                    return Err(EngineError::Unsupported(alloc::format!(
10196                        "WITH RECURSIVE {:?}: recursive term did not return rows",
10197                        cte.name
10198                    )));
10199                };
10200                if rc.len() != columns.len() {
10201                    return Err(EngineError::Unsupported(alloc::format!(
10202                        "WITH RECURSIVE {:?}: column count of recursive term ({}) does not match anchor ({})",
10203                        cte.name,
10204                        rc.len(),
10205                        columns.len()
10206                    )));
10207                }
10208                for row in rs {
10209                    if !all_union_all {
10210                        let key = encode_row_key(&row);
10211                        if !seen.insert(key) {
10212                            continue;
10213                        }
10214                    }
10215                    next_set.push(row);
10216                }
10217            }
10218            if next_set.is_empty() {
10219                break;
10220            }
10221            all_rows.extend(next_set.iter().cloned());
10222            working_set = next_set;
10223            if all_rows.len() > MAX_TOTAL_ROWS {
10224                return Err(EngineError::Unsupported(alloc::format!(
10225                    "WITH RECURSIVE {:?}: produced more than {MAX_TOTAL_ROWS} rows — likely runaway recursion",
10226                    cte.name
10227                )));
10228            }
10229            if iter + 1 == MAX_ITERATIONS {
10230                return Err(EngineError::Unsupported(alloc::format!(
10231                    "WITH RECURSIVE {:?}: exceeded {MAX_ITERATIONS} iterations",
10232                    cte.name
10233                )));
10234            }
10235        }
10236        Ok((columns, all_rows))
10237    }
10238
10239    fn resolve_select_subqueries(
10240        &self,
10241        stmt: &mut SelectStatement,
10242        cancel: CancelToken<'_>,
10243    ) -> Result<(), EngineError> {
10244        for item in &mut stmt.items {
10245            if let SelectItem::Expr { expr, .. } = item {
10246                self.resolve_expr_subqueries(expr, cancel)?;
10247            }
10248        }
10249        if let Some(w) = &mut stmt.where_ {
10250            self.resolve_expr_subqueries(w, cancel)?;
10251        }
10252        if let Some(gs) = &mut stmt.group_by {
10253            for g in gs {
10254                self.resolve_expr_subqueries(g, cancel)?;
10255            }
10256        }
10257        if let Some(h) = &mut stmt.having {
10258            self.resolve_expr_subqueries(h, cancel)?;
10259        }
10260        for o in &mut stmt.order_by {
10261            self.resolve_expr_subqueries(&mut o.expr, cancel)?;
10262        }
10263        for (_, peer) in &mut stmt.unions {
10264            self.resolve_select_subqueries(peer, cancel)?;
10265        }
10266        Ok(())
10267    }
10268
10269    #[allow(clippy::only_used_in_recursion)] // engine handle reads aren't really pure
10270    fn resolve_expr_subqueries(
10271        &self,
10272        e: &mut Expr,
10273        cancel: CancelToken<'_>,
10274    ) -> Result<(), EngineError> {
10275        // Replace-on-this-node cases first.
10276        if let Some(replacement) = self.subquery_replacement(e, cancel)? {
10277            *e = replacement;
10278            return Ok(());
10279        }
10280        match e {
10281            Expr::Binary { lhs, rhs, .. } => {
10282                self.resolve_expr_subqueries(lhs, cancel)?;
10283                self.resolve_expr_subqueries(rhs, cancel)?;
10284            }
10285            Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
10286                self.resolve_expr_subqueries(expr, cancel)?;
10287            }
10288            Expr::FunctionCall { args, .. } => {
10289                for a in args {
10290                    self.resolve_expr_subqueries(a, cancel)?;
10291                }
10292            }
10293            Expr::Like { expr, pattern, .. } => {
10294                self.resolve_expr_subqueries(expr, cancel)?;
10295                self.resolve_expr_subqueries(pattern, cancel)?;
10296            }
10297            Expr::Extract { source, .. } => self.resolve_expr_subqueries(source, cancel)?,
10298            // v4.12 window functions — recurse into args + ORDER BY
10299            // + PARTITION BY in case they carry inner subqueries.
10300            Expr::WindowFunction {
10301                args,
10302                partition_by,
10303                order_by,
10304                ..
10305            } => {
10306                for a in args {
10307                    self.resolve_expr_subqueries(a, cancel)?;
10308                }
10309                for p in partition_by {
10310                    self.resolve_expr_subqueries(p, cancel)?;
10311                }
10312                for (e, _) in order_by {
10313                    self.resolve_expr_subqueries(e, cancel)?;
10314                }
10315            }
10316            // Subquery nodes are handled in subquery_replacement
10317            // (which returned None — defensive no-op); Literal /
10318            // Column are leaves.
10319            Expr::ScalarSubquery(_)
10320            | Expr::Exists { .. }
10321            | Expr::InSubquery { .. }
10322            | Expr::Literal(_)
10323            | Expr::Placeholder(_)
10324            | Expr::Column(_) => {}
10325            // v7.10.10 — recurse children.
10326            Expr::Array(items) => {
10327                for elem in items {
10328                    self.resolve_expr_subqueries(elem, cancel)?;
10329                }
10330            }
10331            Expr::ArraySubscript { target, index } => {
10332                self.resolve_expr_subqueries(target, cancel)?;
10333                self.resolve_expr_subqueries(index, cancel)?;
10334            }
10335            Expr::AnyAll { expr, array, .. } => {
10336                self.resolve_expr_subqueries(expr, cancel)?;
10337                self.resolve_expr_subqueries(array, cancel)?;
10338            }
10339            Expr::Case {
10340                operand,
10341                branches,
10342                else_branch,
10343            } => {
10344                if let Some(o) = operand {
10345                    self.resolve_expr_subqueries(o, cancel)?;
10346                }
10347                for (w, t) in branches {
10348                    self.resolve_expr_subqueries(w, cancel)?;
10349                    self.resolve_expr_subqueries(t, cancel)?;
10350                }
10351                if let Some(e) = else_branch {
10352                    self.resolve_expr_subqueries(e, cancel)?;
10353                }
10354            }
10355        }
10356        Ok(())
10357    }
10358
10359    /// v4.23: per-row eval that handles correlated subqueries.
10360    /// Equivalent to `eval::eval_expr` when the expression has no
10361    /// subqueries; otherwise clones the expression, substitutes
10362    /// outer-row columns into each surviving subquery node, runs
10363    /// the inner SELECT, and replaces the node with the literal
10364    /// result. Only the WHERE-filter call sites use this path so
10365    /// the uncorrelated fast path is preserved everywhere else.
10366    fn eval_expr_with_correlated(
10367        &self,
10368        expr: &Expr,
10369        row: &Row,
10370        ctx: &EvalContext<'_>,
10371        cancel: CancelToken<'_>,
10372        memo: Option<&mut memoize::MemoizeCache>,
10373    ) -> Result<Value, EngineError> {
10374        if !expr_has_subquery(expr) {
10375            return eval::eval_expr(expr, row, ctx).map_err(EngineError::Eval);
10376        }
10377        let mut e = expr.clone();
10378        self.resolve_correlated_in_expr(&mut e, row, ctx, cancel, memo)?;
10379        eval::eval_expr(&e, row, ctx).map_err(EngineError::Eval)
10380    }
10381
10382    fn resolve_correlated_in_expr(
10383        &self,
10384        e: &mut Expr,
10385        row: &Row,
10386        ctx: &EvalContext<'_>,
10387        cancel: CancelToken<'_>,
10388        mut memo: Option<&mut memoize::MemoizeCache>,
10389    ) -> Result<(), EngineError> {
10390        match e {
10391            Expr::ScalarSubquery(inner) => {
10392                // v6.2.6 — Memoize: build the cache key from the
10393                // pre-substitution subquery repr + the outer row's
10394                // values. Two outer rows with identical correlated
10395                // values hit the same entry.
10396                let cache_key = memo.as_ref().map(|_| memoize::CacheKey {
10397                    subquery_repr: alloc::format!("{}", **inner),
10398                    outer_values: row.values.clone(),
10399                });
10400                if let (Some(cache), Some(k)) = (memo.as_deref_mut(), cache_key.as_ref())
10401                    && let Some(cached) = cache.get(k)
10402                {
10403                    *e = value_to_literal_expr(cached)?;
10404                    return Ok(());
10405                }
10406                let mut s = (**inner).clone();
10407                substitute_outer_columns(&mut s, row, ctx);
10408                let r = self.exec_select_cancel(&s, cancel)?;
10409                let QueryResult::Rows { rows, .. } = r else {
10410                    return Err(EngineError::Unsupported(
10411                        "scalar subquery: inner did not return rows".into(),
10412                    ));
10413                };
10414                let value = match rows.as_slice() {
10415                    [] => Value::Null,
10416                    [r0] => r0.values.first().cloned().unwrap_or(Value::Null),
10417                    _ => {
10418                        return Err(EngineError::Unsupported(alloc::format!(
10419                            "scalar subquery returned {} rows; expected 0 or 1",
10420                            rows.len()
10421                        )));
10422                    }
10423                };
10424                if let (Some(cache), Some(k)) = (memo.as_deref_mut(), cache_key) {
10425                    cache.insert(k, value.clone());
10426                }
10427                *e = value_to_literal_expr(value)?;
10428            }
10429            Expr::Exists { subquery, negated } => {
10430                let mut s = (**subquery).clone();
10431                substitute_outer_columns(&mut s, row, ctx);
10432                let r = self.exec_select_cancel(&s, cancel)?;
10433                let exists = matches!(r, QueryResult::Rows { rows, .. } if !rows.is_empty());
10434                let bit = if *negated { !exists } else { exists };
10435                *e = Expr::Literal(Literal::Bool(bit));
10436            }
10437            Expr::InSubquery {
10438                expr: lhs,
10439                subquery,
10440                negated,
10441            } => {
10442                self.resolve_correlated_in_expr(lhs, row, ctx, cancel, memo.as_deref_mut())?;
10443                let lhs_val = eval::eval_expr(lhs, row, ctx).map_err(EngineError::Eval)?;
10444                let mut s = (**subquery).clone();
10445                substitute_outer_columns(&mut s, row, ctx);
10446                let r = self.exec_select_cancel(&s, cancel)?;
10447                let QueryResult::Rows { columns, rows, .. } = r else {
10448                    return Err(EngineError::Unsupported(
10449                        "IN-subquery: inner did not return rows".into(),
10450                    ));
10451                };
10452                if columns.len() != 1 {
10453                    return Err(EngineError::Unsupported(alloc::format!(
10454                        "IN-subquery must project exactly one column; got {}",
10455                        columns.len()
10456                    )));
10457                }
10458                let mut found = false;
10459                let mut any_null = false;
10460                for r0 in rows {
10461                    let v = r0.values.into_iter().next().unwrap_or(Value::Null);
10462                    if v.is_null() {
10463                        any_null = true;
10464                        continue;
10465                    }
10466                    if value_cmp(&v, &lhs_val) == core::cmp::Ordering::Equal {
10467                        found = true;
10468                        break;
10469                    }
10470                }
10471                let bit = if found {
10472                    !*negated
10473                } else if any_null {
10474                    return Err(EngineError::Unsupported(
10475                        "IN-subquery with NULL in result and no match: NULL semantics not yet implemented".into(),
10476                    ));
10477                } else {
10478                    *negated
10479                };
10480                *e = Expr::Literal(Literal::Bool(bit));
10481            }
10482            Expr::Binary { lhs, rhs, .. } => {
10483                self.resolve_correlated_in_expr(lhs, row, ctx, cancel, memo.as_deref_mut())?;
10484                self.resolve_correlated_in_expr(rhs, row, ctx, cancel, memo.as_deref_mut())?;
10485            }
10486            Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
10487                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
10488            }
10489            Expr::Like { expr, pattern, .. } => {
10490                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
10491                self.resolve_correlated_in_expr(pattern, row, ctx, cancel, memo.as_deref_mut())?;
10492            }
10493            Expr::FunctionCall { args, .. } => {
10494                for a in args {
10495                    self.resolve_correlated_in_expr(a, row, ctx, cancel, memo.as_deref_mut())?;
10496                }
10497            }
10498            Expr::Extract { source, .. } => {
10499                self.resolve_correlated_in_expr(source, row, ctx, cancel, memo.as_deref_mut())?;
10500            }
10501            Expr::WindowFunction { .. }
10502            | Expr::Literal(_)
10503            | Expr::Placeholder(_)
10504            | Expr::Column(_) => {}
10505            // v7.10.10 — recurse children.
10506            Expr::Array(items) => {
10507                for elem in items {
10508                    self.resolve_correlated_in_expr(elem, row, ctx, cancel, memo.as_deref_mut())?;
10509                }
10510            }
10511            Expr::ArraySubscript { target, index } => {
10512                self.resolve_correlated_in_expr(target, row, ctx, cancel, memo.as_deref_mut())?;
10513                self.resolve_correlated_in_expr(index, row, ctx, cancel, memo.as_deref_mut())?;
10514            }
10515            Expr::AnyAll { expr, array, .. } => {
10516                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
10517                self.resolve_correlated_in_expr(array, row, ctx, cancel, memo.as_deref_mut())?;
10518            }
10519            Expr::Case {
10520                operand,
10521                branches,
10522                else_branch,
10523            } => {
10524                if let Some(o) = operand {
10525                    self.resolve_correlated_in_expr(o, row, ctx, cancel, memo.as_deref_mut())?;
10526                }
10527                for (w, t) in branches {
10528                    self.resolve_correlated_in_expr(w, row, ctx, cancel, memo.as_deref_mut())?;
10529                    self.resolve_correlated_in_expr(t, row, ctx, cancel, memo.as_deref_mut())?;
10530                }
10531                if let Some(e) = else_branch {
10532                    self.resolve_correlated_in_expr(e, row, ctx, cancel, memo.as_deref_mut())?;
10533                }
10534            }
10535        }
10536        Ok(())
10537    }
10538
10539    fn subquery_replacement(
10540        &self,
10541        e: &Expr,
10542        cancel: CancelToken<'_>,
10543    ) -> Result<Option<Expr>, EngineError> {
10544        match e {
10545            Expr::ScalarSubquery(inner) => {
10546                let mut s = (**inner).clone();
10547                // Recurse into the inner SELECT first so nested
10548                // subqueries materialise bottom-up.
10549                self.resolve_select_subqueries(&mut s, cancel)?;
10550                let r = match self.exec_bare_select_cancel(&s, cancel) {
10551                    Ok(r) => r,
10552                    Err(e) if is_correlation_error(&e) => return Ok(None),
10553                    Err(e) => return Err(e),
10554                };
10555                let QueryResult::Rows { rows, .. } = r else {
10556                    return Err(EngineError::Unsupported(
10557                        "scalar subquery: inner statement did not return rows".into(),
10558                    ));
10559                };
10560                let value = match rows.as_slice() {
10561                    [] => Value::Null,
10562                    [row] => row.values.first().cloned().unwrap_or(Value::Null),
10563                    _ => {
10564                        return Err(EngineError::Unsupported(alloc::format!(
10565                            "scalar subquery returned {} rows; expected 0 or 1",
10566                            rows.len()
10567                        )));
10568                    }
10569                };
10570                Ok(Some(value_to_literal_expr(value)?))
10571            }
10572            Expr::Exists { subquery, negated } => {
10573                let mut s = (**subquery).clone();
10574                self.resolve_select_subqueries(&mut s, cancel)?;
10575                let r = match self.exec_bare_select_cancel(&s, cancel) {
10576                    Ok(r) => r,
10577                    Err(e) if is_correlation_error(&e) => return Ok(None),
10578                    Err(e) => return Err(e),
10579                };
10580                let exists = match r {
10581                    QueryResult::Rows { rows, .. } => !rows.is_empty(),
10582                    QueryResult::CommandOk { .. } => false,
10583                };
10584                let bit = if *negated { !exists } else { exists };
10585                Ok(Some(Expr::Literal(Literal::Bool(bit))))
10586            }
10587            Expr::InSubquery {
10588                expr,
10589                subquery,
10590                negated,
10591            } => {
10592                let mut s = (**subquery).clone();
10593                self.resolve_select_subqueries(&mut s, cancel)?;
10594                let r = match self.exec_bare_select_cancel(&s, cancel) {
10595                    Ok(r) => r,
10596                    Err(e) if is_correlation_error(&e) => return Ok(None),
10597                    Err(e) => return Err(e),
10598                };
10599                let QueryResult::Rows { columns, rows, .. } = r else {
10600                    return Err(EngineError::Unsupported(
10601                        "IN-subquery: inner statement did not return rows".into(),
10602                    ));
10603                };
10604                if columns.len() != 1 {
10605                    return Err(EngineError::Unsupported(alloc::format!(
10606                        "IN-subquery must project exactly one column; got {}",
10607                        columns.len()
10608                    )));
10609                }
10610                // Build the same OR-Eq chain the parse-time literal-list
10611                // path constructs, with each value lifted into a Literal.
10612                let mut acc: Option<Expr> = None;
10613                for row in rows {
10614                    let v = row.values.into_iter().next().unwrap_or(Value::Null);
10615                    let lit = value_to_literal_expr(v)?;
10616                    let cmp = Expr::Binary {
10617                        lhs: expr.clone(),
10618                        op: BinOp::Eq,
10619                        rhs: Box::new(lit),
10620                    };
10621                    acc = Some(match acc {
10622                        None => cmp,
10623                        Some(prev) => Expr::Binary {
10624                            lhs: Box::new(prev),
10625                            op: BinOp::Or,
10626                            rhs: Box::new(cmp),
10627                        },
10628                    });
10629                }
10630                let combined = acc.unwrap_or(Expr::Literal(Literal::Bool(false)));
10631                let final_expr = if *negated {
10632                    Expr::Unary {
10633                        op: UnOp::Not,
10634                        expr: Box::new(combined),
10635                    }
10636                } else {
10637                    combined
10638                };
10639                Ok(Some(final_expr))
10640            }
10641            _ => Ok(None),
10642        }
10643    }
10644}
10645
10646// ---- v4.12 window-function helpers ----
10647// The (partition-key, order-key, original-index) tuple shape used
10648// across these helpers is intrinsic to the planner. Factoring it
10649// into a typedef adds indirection without making the code clearer,
10650// so several lints are allowed inline on the affected functions
10651// rather than module-wide.
10652
10653/// v4.22: cheap structural scan for `FROM <name>` (qualified or
10654/// not) inside a SELECT — used to verify the anchor of a WITH
10655/// RECURSIVE CTE doesn't recurse into itself. Conservative: walks
10656/// FROM joins, subqueries, and unions.
10657fn select_refers_to(stmt: &SelectStatement, target: &str) -> bool {
10658    if let Some(from) = &stmt.from
10659        && from_refers_to(from, target)
10660    {
10661        return true;
10662    }
10663    for (_, peer) in &stmt.unions {
10664        if select_refers_to(peer, target) {
10665            return true;
10666        }
10667    }
10668    for item in &stmt.items {
10669        if let SelectItem::Expr { expr, .. } = item
10670            && expr_refers_to(expr, target)
10671        {
10672            return true;
10673        }
10674    }
10675    if let Some(w) = &stmt.where_
10676        && expr_refers_to(w, target)
10677    {
10678        return true;
10679    }
10680    false
10681}
10682
10683fn from_refers_to(from: &FromClause, target: &str) -> bool {
10684    if from.primary.name.eq_ignore_ascii_case(target) {
10685        return true;
10686    }
10687    from.joins
10688        .iter()
10689        .any(|j| j.table.name.eq_ignore_ascii_case(target))
10690}
10691
10692fn expr_refers_to(e: &Expr, target: &str) -> bool {
10693    match e {
10694        Expr::ScalarSubquery(s) => select_refers_to(s, target),
10695        Expr::Exists { subquery, .. } | Expr::InSubquery { subquery, .. } => {
10696            select_refers_to(subquery, target)
10697        }
10698        Expr::Binary { lhs, rhs, .. } => expr_refers_to(lhs, target) || expr_refers_to(rhs, target),
10699        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
10700            expr_refers_to(expr, target)
10701        }
10702        Expr::Like { expr, pattern, .. } => {
10703            expr_refers_to(expr, target) || expr_refers_to(pattern, target)
10704        }
10705        Expr::FunctionCall { args, .. } => args.iter().any(|a| expr_refers_to(a, target)),
10706        Expr::Extract { source, .. } => expr_refers_to(source, target),
10707        Expr::WindowFunction {
10708            args,
10709            partition_by,
10710            order_by,
10711            ..
10712        } => {
10713            args.iter().any(|a| expr_refers_to(a, target))
10714                || partition_by.iter().any(|p| expr_refers_to(p, target))
10715                || order_by.iter().any(|(o, _)| expr_refers_to(o, target))
10716        }
10717        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => false,
10718        Expr::Array(items) => items.iter().any(|e| expr_refers_to(e, target)),
10719        Expr::ArraySubscript { target: t, index } => {
10720            expr_refers_to(t, target) || expr_refers_to(index, target)
10721        }
10722        Expr::AnyAll { expr, array, .. } => {
10723            expr_refers_to(expr, target) || expr_refers_to(array, target)
10724        }
10725        Expr::Case {
10726            operand,
10727            branches,
10728            else_branch,
10729        } => {
10730            operand
10731                .as_deref()
10732                .is_some_and(|o| expr_refers_to(o, target))
10733                || branches
10734                    .iter()
10735                    .any(|(w, t)| expr_refers_to(w, target) || expr_refers_to(t, target))
10736                || else_branch
10737                    .as_deref()
10738                    .is_some_and(|e| expr_refers_to(e, target))
10739        }
10740    }
10741}
10742
10743/// v4.22: pick more specific column types from observed rows when
10744/// the projection builder defaulted to Text (the v1.x behavior for
10745/// non-column expressions). Lets `WITH t(n) AS (SELECT 1 ...)`
10746/// land an Int column in the CTE storage table rather than failing
10747/// the insert with "expected TEXT, got INT".
10748/// v7.16.2 — map an SPG [`DataType`] to the PG-canonical
10749/// `information_schema.columns.data_type` text. Covers the
10750/// values mailrs's migrations probe (`'ARRAY'`, `'integer'`,
10751/// `'text'`, …). Unknown variants fall back to the SPG name
10752/// downcased — better than panicking on a future DataType.
10753fn pg_data_type_text(ty: DataType) -> alloc::string::String {
10754    let s = match ty {
10755        DataType::Int => "integer",
10756        DataType::BigInt => "bigint",
10757        DataType::SmallInt => "smallint",
10758        DataType::Float => "double precision",
10759        DataType::Bool => "boolean",
10760        DataType::Text => "text",
10761        DataType::Varchar(_) => "character varying",
10762        DataType::Date => "date",
10763        DataType::Timestamp => "timestamp without time zone",
10764        DataType::Timestamptz => "timestamp with time zone",
10765        DataType::Json => "jsonb",
10766        DataType::Bytes => "bytea",
10767        DataType::TextArray | DataType::IntArray | DataType::BigIntArray => "ARRAY",
10768        DataType::TsVector => "tsvector",
10769        DataType::TsQuery => "tsquery",
10770        DataType::Vector { .. } => "USER-DEFINED",
10771        // Non-exhaustive — fall back to "USER-DEFINED" the way
10772        // PG labels any pg_type it doesn't recognise.
10773        _ => "USER-DEFINED",
10774    };
10775    alloc::string::String::from(s)
10776}
10777
10778/// v7.16.2 — synthesise `information_schema.columns`. mailrs
10779/// queries are of shape `SELECT 1 FROM information_schema.columns
10780/// WHERE table_name = … AND column_name = … AND data_type = …` —
10781/// the v7.16.2 view returns the columns mailrs probes; broader
10782/// PG-spec parity (ordinal_position, is_nullable, character_
10783/// maximum_length, udt_name, …) lands as needed.
10784fn synth_information_schema_columns(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10785    let schema = alloc::vec![
10786        ColumnSchema::new("table_catalog", DataType::Text, false),
10787        ColumnSchema::new("table_schema", DataType::Text, false),
10788        ColumnSchema::new("table_name", DataType::Text, false),
10789        ColumnSchema::new("column_name", DataType::Text, false),
10790        ColumnSchema::new("ordinal_position", DataType::Int, false),
10791        ColumnSchema::new("is_nullable", DataType::Text, false),
10792        ColumnSchema::new("data_type", DataType::Text, false),
10793    ];
10794    let mut rows: Vec<Row> = Vec::new();
10795    for tname in cat.table_names() {
10796        let Some(t) = cat.get(&tname) else { continue };
10797        for (i, col) in t.schema().columns.iter().enumerate() {
10798            #[allow(clippy::cast_possible_wrap)]
10799            let ordinal = (i + 1) as i32;
10800            rows.push(Row::new(alloc::vec![
10801                Value::Text("spg".into()),
10802                Value::Text("public".into()),
10803                Value::Text(tname.clone()),
10804                Value::Text(col.name.clone()),
10805                Value::Int(ordinal),
10806                Value::Text(if col.nullable {
10807                    "YES".into()
10808                } else {
10809                    "NO".into()
10810                }),
10811                Value::Text(pg_data_type_text(col.ty)),
10812            ]));
10813        }
10814    }
10815    (schema, rows)
10816}
10817
10818/// v7.16.2 — synthesise `information_schema.tables`.
10819fn synth_information_schema_tables(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10820    let schema = alloc::vec![
10821        ColumnSchema::new("table_catalog", DataType::Text, false),
10822        ColumnSchema::new("table_schema", DataType::Text, false),
10823        ColumnSchema::new("table_name", DataType::Text, false),
10824        ColumnSchema::new("table_type", DataType::Text, false),
10825    ];
10826    let mut rows: Vec<Row> = Vec::new();
10827    for tname in cat.table_names() {
10828        rows.push(Row::new(alloc::vec![
10829            Value::Text("spg".into()),
10830            Value::Text("public".into()),
10831            Value::Text(tname.clone()),
10832            Value::Text("BASE TABLE".into()),
10833        ]));
10834    }
10835    (schema, rows)
10836}
10837
10838/// v7.16.2 — synthesise `pg_catalog.pg_class`. Minimum shape
10839/// for psql `\d` / ORM probes: `relname` + `relkind`. Each
10840/// user table emits one row.
10841fn synth_pg_class(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10842    let schema = alloc::vec![
10843        ColumnSchema::new("relname", DataType::Text, false),
10844        ColumnSchema::new("relkind", DataType::Text, false),
10845        ColumnSchema::new("relnamespace", DataType::BigInt, false),
10846    ];
10847    let mut rows: Vec<Row> = Vec::new();
10848    for tname in cat.table_names() {
10849        rows.push(Row::new(alloc::vec![
10850            Value::Text(tname.clone()),
10851            Value::Text("r".into()),
10852            Value::BigInt(2200), // PG's `public` namespace OID
10853        ]));
10854    }
10855    (schema, rows)
10856}
10857
10858/// v7.16.2 — synthesise `pg_catalog.pg_attribute`. Minimum
10859/// shape: `attrelid` (text — SPG has no OID), `attname`,
10860/// `attnum`, `atttypid` (text), `attnotnull`.
10861fn synth_pg_attribute(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10862    let schema = alloc::vec![
10863        ColumnSchema::new("attrelid", DataType::Text, false),
10864        ColumnSchema::new("attname", DataType::Text, false),
10865        ColumnSchema::new("attnum", DataType::Int, false),
10866        ColumnSchema::new("atttypid", DataType::Text, false),
10867        ColumnSchema::new("attnotnull", DataType::Bool, false),
10868    ];
10869    let mut rows: Vec<Row> = Vec::new();
10870    for tname in cat.table_names() {
10871        let Some(t) = cat.get(&tname) else { continue };
10872        for (i, col) in t.schema().columns.iter().enumerate() {
10873            #[allow(clippy::cast_possible_wrap)]
10874            let ordinal = (i + 1) as i32;
10875            rows.push(Row::new(alloc::vec![
10876                Value::Text(tname.clone()),
10877                Value::Text(col.name.clone()),
10878                Value::Int(ordinal),
10879                Value::Text(pg_data_type_text(col.ty)),
10880                Value::Bool(!col.nullable),
10881            ]));
10882        }
10883    }
10884    (schema, rows)
10885}
10886
10887/// v7.17.0 Phase 3.P0-50 — synthesise `pg_catalog.pg_type`. The
10888/// returned rows cover every built-in scalar / array type sqlx,
10889/// SQLAlchemy, Diesel and pgAdmin look up at compile / connect
10890/// time. PG-canonical schema columns we expose:
10891///   * oid           — type OID (the lookup key sqlx uses)
10892///   * typname       — canonical type name (`int4`, `text`, …)
10893///   * typlen        — width in bytes (-1 for var-length)
10894///   * typtype       — `b`ase / `c`omposite / `e`num / etc.
10895///   * typcategory   — PG type category single-char
10896///   * typelem       — element OID for arrays (0 otherwise)
10897///   * typarray      — array-type OID (0 if no array type)
10898///   * typnamespace  — schema OID (always `public` = 2200)
10899///
10900/// Other pg_type columns (typowner, typinput/typoutput, etc.)
10901/// land in follow-up work — sqlx encoders don't query them at
10902/// connect time.
10903fn synth_pg_type(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10904    let schema = alloc::vec![
10905        ColumnSchema::new("oid", DataType::BigInt, false),
10906        ColumnSchema::new("typname", DataType::Text, false),
10907        ColumnSchema::new("typlen", DataType::SmallInt, false),
10908        ColumnSchema::new("typtype", DataType::Text, false),
10909        ColumnSchema::new("typcategory", DataType::Text, false),
10910        ColumnSchema::new("typelem", DataType::BigInt, false),
10911        ColumnSchema::new("typarray", DataType::BigInt, false),
10912        ColumnSchema::new("typnamespace", DataType::BigInt, false),
10913    ];
10914    // (oid, name, len, type, cat, elem, array_oid). PG OID
10915    // numbers come straight from `pg_type.dat`.
10916    let scalars: &[(i64, &str, i16, &str, &str, i64, i64)] = &[
10917        // bool
10918        (16, "bool", 1, "b", "B", 0, 1000),
10919        (17, "bytea", -1, "b", "U", 0, 1001),
10920        (18, "char", 1, "b", "S", 0, 1002),
10921        (19, "name", 64, "b", "S", 0, 1003),
10922        (20, "int8", 8, "b", "N", 0, 1016),
10923        (21, "int2", 2, "b", "N", 0, 1005),
10924        (23, "int4", 4, "b", "N", 0, 1007),
10925        (24, "regproc", 4, "b", "N", 0, 1008),
10926        (25, "text", -1, "b", "S", 0, 1009),
10927        (26, "oid", 4, "b", "N", 0, 1028),
10928        (114, "json", -1, "b", "U", 0, 199),
10929        (142, "xml", -1, "b", "U", 0, 143),
10930        (700, "float4", 4, "b", "N", 0, 1021),
10931        (701, "float8", 8, "b", "N", 0, 1022),
10932        (650, "cidr", -1, "b", "I", 0, 651),
10933        (869, "inet", -1, "b", "I", 0, 1041),
10934        (829, "macaddr", 6, "b", "U", 0, 1040),
10935        (1042, "bpchar", -1, "b", "S", 0, 1014),
10936        (1043, "varchar", -1, "b", "S", 0, 1015),
10937        (1082, "date", 4, "b", "D", 0, 1182),
10938        (1083, "time", 8, "b", "D", 0, 1183),
10939        (1114, "timestamp", 8, "b", "D", 0, 1115),
10940        (1184, "timestamptz", 8, "b", "D", 0, 1185),
10941        (1186, "interval", 16, "b", "T", 0, 1187),
10942        (1266, "timetz", 12, "b", "D", 0, 1270),
10943        (1700, "numeric", -1, "b", "N", 0, 1231),
10944        (790, "money", 8, "b", "N", 0, 791),
10945        (2950, "uuid", 16, "b", "U", 0, 2951),
10946        (3802, "jsonb", -1, "b", "U", 0, 3807),
10947        (3614, "tsvector", -1, "b", "U", 0, 3643),
10948        (3615, "tsquery", -1, "b", "U", 0, 3645),
10949        // hstore + range types — typcategory 'U' (user) / 'R' (range).
10950        (3908, "tstzrange", -1, "r", "R", 0, 3909),
10951        (3910, "tsrange", -1, "r", "R", 0, 3911),
10952        (3904, "int4range", -1, "r", "R", 0, 3905),
10953        (3926, "int8range", -1, "r", "R", 0, 3927),
10954        (3906, "numrange", -1, "r", "R", 0, 3907),
10955        (3912, "daterange", -1, "r", "R", 0, 3913),
10956    ];
10957    // Array companion types share the typelem / typcategory='A'.
10958    // We emit just the array OIDs the scalars reference.
10959    let arrays: &[(i64, &str, i64)] = &[
10960        (1000, "_bool", 16),
10961        (1001, "_bytea", 17),
10962        (1002, "_char", 18),
10963        (1003, "_name", 19),
10964        (1016, "_int8", 20),
10965        (1005, "_int2", 21),
10966        (1007, "_int4", 23),
10967        (1008, "_regproc", 24),
10968        (1009, "_text", 25),
10969        (1028, "_oid", 26),
10970        (199, "_json", 114),
10971        (143, "_xml", 142),
10972        (1021, "_float4", 700),
10973        (1022, "_float8", 701),
10974        (651, "_cidr", 650),
10975        (1041, "_inet", 869),
10976        (1040, "_macaddr", 829),
10977        (1014, "_bpchar", 1042),
10978        (1015, "_varchar", 1043),
10979        (1182, "_date", 1082),
10980        (1183, "_time", 1083),
10981        (1115, "_timestamp", 1114),
10982        (1185, "_timestamptz", 1184),
10983        (1187, "_interval", 1186),
10984        (1270, "_timetz", 1266),
10985        (1231, "_numeric", 1700),
10986        (791, "_money", 790),
10987        (2951, "_uuid", 2950),
10988        (3807, "_jsonb", 3802),
10989        (3643, "_tsvector", 3614),
10990        (3645, "_tsquery", 3615),
10991    ];
10992    let mut rows: Vec<Row> = Vec::with_capacity(scalars.len() + arrays.len());
10993    for &(oid, name, len, ty, cat, elem, arr) in scalars {
10994        rows.push(Row::new(alloc::vec![
10995            Value::BigInt(oid),
10996            Value::Text(name.into()),
10997            Value::SmallInt(len),
10998            Value::Text(ty.into()),
10999            Value::Text(cat.into()),
11000            Value::BigInt(elem),
11001            Value::BigInt(arr),
11002            Value::BigInt(2200),
11003        ]));
11004    }
11005    for &(oid, name, elem) in arrays {
11006        rows.push(Row::new(alloc::vec![
11007            Value::BigInt(oid),
11008            Value::Text(name.into()),
11009            Value::SmallInt(-1),
11010            Value::Text("b".into()),
11011            Value::Text("A".into()),
11012            Value::BigInt(elem),
11013            Value::BigInt(0),
11014            Value::BigInt(2200),
11015        ]));
11016    }
11017    (schema, rows)
11018}
11019
11020/// v7.17.0 Phase 3.P0-51 — synthesise `pg_catalog.pg_proc`. ORM /
11021/// pgAdmin probes look up functions by name; SPG synthesises rows
11022/// for the built-in scalar functions / aggregates / window funcs
11023/// the engine actually dispatches. SPG has no user-defined
11024/// functions yet so the table is a stable static list.
11025///
11026/// Schema columns exposed:
11027///   * oid (BigInt) — function OID from PG's pg_proc.dat
11028///   * proname (Text) — function name (lowercase)
11029///   * pronamespace (BigInt) — 11 (`pg_catalog`)
11030///   * prokind (Text) — 'f' function, 'a' aggregate, 'w' window
11031///   * pronargs (SmallInt) — declared arg count (-1 for variadic)
11032///   * prorettype (BigInt) — return type OID (matches synth_pg_type)
11033fn synth_pg_proc(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11034    let schema = alloc::vec![
11035        ColumnSchema::new("oid", DataType::BigInt, false),
11036        ColumnSchema::new("proname", DataType::Text, false),
11037        ColumnSchema::new("pronamespace", DataType::BigInt, false),
11038        ColumnSchema::new("prokind", DataType::Text, false),
11039        ColumnSchema::new("pronargs", DataType::Int, false),
11040        ColumnSchema::new("prorettype", DataType::BigInt, false),
11041    ];
11042    // (oid, name, kind, nargs, rettype). OIDs taken from PG's
11043    // pg_proc.dat for the common subset.
11044    let funcs: &[(i64, &str, &str, i32, i64)] = &[
11045        // Scalar functions.
11046        (1318, "length", "f", 1, 23),
11047        (871, "upper", "f", 1, 25),
11048        (870, "lower", "f", 1, 25),
11049        (936, "substring", "f", 3, 25),
11050        (937, "substring", "f", 2, 25),
11051        (3055, "btrim", "f", 1, 25),
11052        (885, "btrim", "f", 2, 25),
11053        (3056, "ltrim", "f", 1, 25),
11054        (875, "ltrim", "f", 2, 25),
11055        (3057, "rtrim", "f", 1, 25),
11056        (876, "rtrim", "f", 2, 25),
11057        (1397, "abs", "f", 1, 23),
11058        (1396, "abs", "f", 1, 20),
11059        (1606, "round", "f", 1, 1700),
11060        (1707, "round", "f", 2, 1700),
11061        (2308, "ceil", "f", 1, 701),
11062        (2309, "ceiling", "f", 1, 701),
11063        (2310, "floor", "f", 1, 701),
11064        (1376, "sqrt", "f", 1, 701),
11065        (1369, "ln", "f", 1, 701),
11066        (1373, "exp", "f", 1, 701),
11067        (1368, "power", "f", 2, 701),
11068        (2228, "random", "f", 0, 701),
11069        // Date / time.
11070        (1299, "now", "f", 0, 1184),
11071        (1274, "current_timestamp", "f", 0, 1184),
11072        (1140, "current_date", "f", 0, 1082),
11073        (2050, "current_time", "f", 0, 1083),
11074        (1158, "date_trunc", "f", 2, 1184),
11075        (1171, "date_part", "f", 2, 701),
11076        (1172, "age", "f", 1, 1186),
11077        (936, "to_char", "f", 2, 25),
11078        // Session / introspection.
11079        (861, "current_database", "f", 0, 19),
11080        (745, "current_user", "f", 0, 19),
11081        (745, "session_user", "f", 0, 19),
11082        (1402, "current_schema", "f", 0, 19),
11083        // String concat / format.
11084        (3058, "concat", "f", -1, 25),
11085        (3059, "concat_ws", "f", -1, 25),
11086        (3539, "format", "f", -1, 25),
11087        // Type introspection.
11088        (2877, "pg_typeof", "f", 1, 2206),
11089        // JSON.
11090        (3198, "json_build_object", "f", -1, 114),
11091        (3199, "jsonb_build_object", "f", -1, 3802),
11092        (3271, "json_build_array", "f", -1, 114),
11093        (3272, "jsonb_build_array", "f", -1, 3802),
11094        // UUID.
11095        (3253, "gen_random_uuid", "f", 0, 2950),
11096        (3252, "uuid_generate_v4", "f", 0, 2950),
11097        // Aggregates.
11098        (2147, "count", "a", 0, 20),
11099        (2803, "count", "a", -1, 20),
11100        (2116, "max", "a", 1, 23),
11101        (2132, "min", "a", 1, 23),
11102        (2108, "sum", "a", 1, 20),
11103        (2100, "avg", "a", 1, 1700),
11104        (2517, "string_agg", "a", 2, 25),
11105        (2747, "array_agg", "a", 1, 1009),
11106        (2517, "bool_and", "a", 1, 16),
11107        (2518, "bool_or", "a", 1, 16),
11108        (2519, "every", "a", 1, 16),
11109        // Window functions.
11110        (3100, "row_number", "w", 0, 20),
11111        (3101, "rank", "w", 0, 20),
11112        (3102, "dense_rank", "w", 0, 20),
11113        (3103, "percent_rank", "w", 0, 701),
11114        (3104, "cume_dist", "w", 0, 701),
11115        (3105, "lag", "w", -1, 2283),
11116        (3106, "lead", "w", -1, 2283),
11117        (3107, "first_value", "w", 1, 2283),
11118        (3108, "last_value", "w", 1, 2283),
11119        (3109, "nth_value", "w", 2, 2283),
11120    ];
11121    let mut rows: Vec<Row> = Vec::with_capacity(funcs.len());
11122    for &(oid, name, kind, nargs, rettype) in funcs {
11123        rows.push(Row::new(alloc::vec![
11124            Value::BigInt(oid),
11125            Value::Text(name.into()),
11126            Value::BigInt(11),
11127            Value::Text(kind.into()),
11128            Value::Int(nargs),
11129            Value::BigInt(rettype),
11130        ]));
11131    }
11132    (schema, rows)
11133}
11134
11135/// v7.17.0 Phase 3.P0-65 — synthesise `mysql.user`. MySQL admin
11136/// queries (`SELECT user, host FROM mysql.user`) probe this at
11137/// connect time to list accounts. SPG ships one row per
11138/// UserStore entry plus a synthetic `root` superuser row for
11139/// MySQL bootstrap compat.
11140fn synth_mysql_user(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
11141    let schema = alloc::vec![
11142        ColumnSchema::new("user", DataType::Text, false),
11143        ColumnSchema::new("host", DataType::Text, false),
11144        ColumnSchema::new("select_priv", DataType::Text, false),
11145    ];
11146    let mut rows: Vec<Row> = Vec::new();
11147    rows.push(Row::new(alloc::vec![
11148        Value::Text("root".into()),
11149        Value::Text("localhost".into()),
11150        Value::Text("Y".into()),
11151    ]));
11152    for (name, _) in engine.users.iter() {
11153        if name != "root" {
11154            rows.push(Row::new(alloc::vec![
11155                Value::Text(name.to_string()),
11156                Value::Text("%".into()),
11157                Value::Text("Y".into()),
11158            ]));
11159        }
11160    }
11161    (schema, rows)
11162}
11163
11164/// v7.17.0 Phase 3.P0-65 — synthesise `mysql.db`. The
11165/// per-database privileges table. SPG is single-database so the
11166/// table surfaces one row per declared user with full privileges
11167/// on the canonical `postgres` database.
11168fn synth_mysql_db() -> (Vec<ColumnSchema>, Vec<Row>) {
11169    let schema = alloc::vec![
11170        ColumnSchema::new("host", DataType::Text, false),
11171        ColumnSchema::new("db", DataType::Text, false),
11172        ColumnSchema::new("user", DataType::Text, false),
11173        ColumnSchema::new("select_priv", DataType::Text, false),
11174    ];
11175    let rows = alloc::vec![Row::new(alloc::vec![
11176        Value::Text("localhost".into()),
11177        Value::Text("postgres".into()),
11178        Value::Text("root".into()),
11179        Value::Text("Y".into()),
11180    ])];
11181    (schema, rows)
11182}
11183
11184/// v7.17.0 Phase 3.P0-63 — synthesise
11185/// `information_schema.KEY_COLUMN_USAGE`. ORM migration tools
11186/// (Alembic, Sequelize, TypeORM) walk this view to discover FK
11187/// relationships in MySQL-flavoured introspection queries.
11188///
11189/// Schema columns exposed:
11190///   * CONSTRAINT_NAME (Text)
11191///   * TABLE_NAME (Text)
11192///   * COLUMN_NAME (Text)
11193///   * ORDINAL_POSITION (Int)
11194///   * REFERENCED_TABLE_NAME (Text) — empty for non-FK rows
11195///   * REFERENCED_COLUMN_NAME (Text) — empty for non-FK rows
11196fn synth_info_key_column_usage(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11197    let schema = alloc::vec![
11198        ColumnSchema::new("constraint_name", DataType::Text, false),
11199        ColumnSchema::new("table_name", DataType::Text, false),
11200        ColumnSchema::new("column_name", DataType::Text, false),
11201        ColumnSchema::new("ordinal_position", DataType::Int, false),
11202        ColumnSchema::new("referenced_table_name", DataType::Text, false),
11203        ColumnSchema::new("referenced_column_name", DataType::Text, false),
11204    ];
11205    let mut rows: Vec<Row> = Vec::new();
11206    for tname in cat.table_names() {
11207        let Some(t) = cat.get(&tname) else { continue };
11208        let cols = &t.schema().columns;
11209        let col_name_at = |pos: usize| -> String {
11210            cols.get(pos)
11211                .map_or_else(|| alloc::format!("col{pos}"), |c| c.name.clone())
11212        };
11213        // FKs.
11214        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
11215            let conname = fk
11216                .name
11217                .clone()
11218                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
11219            for (i, (&local, &parent)) in fk
11220                .local_columns
11221                .iter()
11222                .zip(fk.parent_columns.iter())
11223                .enumerate()
11224            {
11225                let parent_name = cat
11226                    .get(&fk.parent_table)
11227                    .and_then(|pt| pt.schema().columns.get(parent).map(|c| c.name.clone()))
11228                    .unwrap_or_else(|| alloc::format!("col{parent}"));
11229                #[allow(clippy::cast_possible_wrap)]
11230                let ordinal = (i + 1) as i32;
11231                rows.push(Row::new(alloc::vec![
11232                    Value::Text(conname.clone()),
11233                    Value::Text(tname.clone()),
11234                    Value::Text(col_name_at(local)),
11235                    Value::Int(ordinal),
11236                    Value::Text(fk.parent_table.clone()),
11237                    Value::Text(parent_name),
11238                ]));
11239            }
11240        }
11241        // PK / composite UC entries.
11242        for (ci, uc) in t.schema().uniqueness_constraints.iter().enumerate() {
11243            let conname = if uc.is_primary_key {
11244                alloc::format!("{}_pkey", tname)
11245            } else {
11246                alloc::format!("{}_uniq{ci}", tname)
11247            };
11248            for (i, &local) in uc.columns.iter().enumerate() {
11249                #[allow(clippy::cast_possible_wrap)]
11250                let ordinal = (i + 1) as i32;
11251                rows.push(Row::new(alloc::vec![
11252                    Value::Text(conname.clone()),
11253                    Value::Text(tname.clone()),
11254                    Value::Text(col_name_at(local)),
11255                    Value::Int(ordinal),
11256                    Value::Text(String::new()),
11257                    Value::Text(String::new()),
11258                ]));
11259            }
11260        }
11261    }
11262    (schema, rows)
11263}
11264
11265/// v7.17.0 Phase 3.P0-64 — synthesise
11266/// `information_schema.REFERENTIAL_CONSTRAINTS`. One row per FK.
11267fn synth_info_referential_constraints(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11268    let schema = alloc::vec![
11269        ColumnSchema::new("constraint_name", DataType::Text, false),
11270        ColumnSchema::new("table_name", DataType::Text, false),
11271        ColumnSchema::new("referenced_table_name", DataType::Text, false),
11272        ColumnSchema::new("update_rule", DataType::Text, false),
11273        ColumnSchema::new("delete_rule", DataType::Text, false),
11274    ];
11275    fn rule_name(a: spg_storage::FkAction) -> &'static str {
11276        match a {
11277            spg_storage::FkAction::Cascade => "CASCADE",
11278            spg_storage::FkAction::SetNull => "SET NULL",
11279            spg_storage::FkAction::SetDefault => "SET DEFAULT",
11280            spg_storage::FkAction::Restrict => "RESTRICT",
11281            spg_storage::FkAction::NoAction => "NO ACTION",
11282        }
11283    }
11284    let mut rows: Vec<Row> = Vec::new();
11285    for tname in cat.table_names() {
11286        let Some(t) = cat.get(&tname) else { continue };
11287        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
11288            let conname = fk
11289                .name
11290                .clone()
11291                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
11292            rows.push(Row::new(alloc::vec![
11293                Value::Text(conname),
11294                Value::Text(tname.clone()),
11295                Value::Text(fk.parent_table.clone()),
11296                Value::Text(rule_name(fk.on_update).into()),
11297                Value::Text(rule_name(fk.on_delete).into()),
11298            ]));
11299        }
11300    }
11301    (schema, rows)
11302}
11303
11304/// v7.17.0 Phase 3.P0-64 — synthesise `information_schema.STATISTICS`.
11305/// One row per (index × column) — admin tools walk this to
11306/// surface index-cardinality estimates.
11307fn synth_info_statistics(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11308    let schema = alloc::vec![
11309        ColumnSchema::new("table_name", DataType::Text, false),
11310        ColumnSchema::new("index_name", DataType::Text, false),
11311        ColumnSchema::new("column_name", DataType::Text, false),
11312        ColumnSchema::new("seq_in_index", DataType::Int, false),
11313        ColumnSchema::new("non_unique", DataType::Int, false),
11314        ColumnSchema::new("index_type", DataType::Text, false),
11315    ];
11316    let mut rows: Vec<Row> = Vec::new();
11317    for tname in cat.table_names() {
11318        let Some(t) = cat.get(&tname) else { continue };
11319        for idx in t.indices() {
11320            let col = t
11321                .schema()
11322                .columns
11323                .get(idx.column_position)
11324                .map_or("?".into(), |c| c.name.clone());
11325            rows.push(Row::new(alloc::vec![
11326                Value::Text(tname.clone()),
11327                Value::Text(idx.name.clone()),
11328                Value::Text(col),
11329                Value::Int(1),
11330                Value::Int(i32::from(!idx.is_unique)),
11331                Value::Text("BTREE".into()),
11332            ]));
11333        }
11334    }
11335    (schema, rows)
11336}
11337
11338/// v7.17.0 Phase 3.P0-64 — synthesise `information_schema.ROUTINES`.
11339/// SPG has no user-defined functions in v7.17 so the surface is
11340/// always empty; admin tools just need the table to exist.
11341fn synth_info_routines() -> (Vec<ColumnSchema>, Vec<Row>) {
11342    let schema = alloc::vec![
11343        ColumnSchema::new("routine_name", DataType::Text, false),
11344        ColumnSchema::new("routine_type", DataType::Text, false),
11345        ColumnSchema::new("data_type", DataType::Text, false),
11346    ];
11347    (schema, Vec::new())
11348}
11349
11350/// v7.17.0 Phase 3.P0-54 — synthesise `pg_catalog.pg_constraint`.
11351/// ORM compilers (Diesel, sea-orm) and admin tools probe this for
11352/// FK / UNIQUE / PK / CHECK definitions to surface relationship
11353/// graphs and validation rules. SPG ships one row per
11354/// uniqueness constraint + foreign key declared in the catalog.
11355///
11356/// Schema columns exposed:
11357///   * conname (Text) — constraint name (synthetic when anonymous)
11358///   * contype (Text) — `p` PK, `u` UNIQUE, `f` FK, `c` CHECK
11359///   * conrelid (Text) — owner table name
11360///   * confrelid (Text) — referenced parent table (FK only;
11361///     empty string otherwise)
11362///   * conkey (Text) — comma-separated column names
11363///   * confkey (Text) — comma-separated parent column names (FK only)
11364fn synth_pg_constraint(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11365    let schema = alloc::vec![
11366        ColumnSchema::new("conname", DataType::Text, false),
11367        ColumnSchema::new("contype", DataType::Text, false),
11368        ColumnSchema::new("conrelid", DataType::Text, false),
11369        ColumnSchema::new("confrelid", DataType::Text, false),
11370        ColumnSchema::new("conkey", DataType::Text, false),
11371        ColumnSchema::new("confkey", DataType::Text, false),
11372    ];
11373    let mut rows: Vec<Row> = Vec::new();
11374    for tname in cat.table_names() {
11375        let Some(t) = cat.get(&tname) else { continue };
11376        let cols = &t.schema().columns;
11377        let col_name_at = |pos: usize| -> String {
11378            cols.get(pos)
11379                .map_or_else(|| alloc::format!("col{pos}"), |c| c.name.clone())
11380        };
11381        // Uniqueness constraints (composite UNIQUE / PRIMARY KEY).
11382        for (ci, uc) in t.schema().uniqueness_constraints.iter().enumerate() {
11383            let kind = if uc.is_primary_key { "p" } else { "u" };
11384            let conname = if uc.is_primary_key {
11385                alloc::format!("{}_pkey", tname)
11386            } else {
11387                alloc::format!("{}_uniq{ci}", tname)
11388            };
11389            let conkey: Vec<String> = uc.columns.iter().map(|&p| col_name_at(p)).collect();
11390            rows.push(Row::new(alloc::vec![
11391                Value::Text(conname),
11392                Value::Text(kind.into()),
11393                Value::Text(tname.clone()),
11394                Value::Text(String::new()),
11395                Value::Text(conkey.join(",")),
11396                Value::Text(String::new()),
11397            ]));
11398        }
11399        // Single-column PK / UNIQUE indexes that have no
11400        // matching entry in `uniqueness_constraints` (the engine
11401        // creates only the BTree index for the bare-column case;
11402        // composite forms ride the UC path above).
11403        for idx in t.indices() {
11404            if !idx.is_unique {
11405                continue;
11406            }
11407            let is_primary = idx.name.ends_with("_pkey");
11408            let conname = idx.name.clone();
11409            let kind = if is_primary { "p" } else { "u" };
11410            let col_name = col_name_at(idx.column_position);
11411            // Skip if already emitted via the UC loop above (same
11412            // tuple shape — single-column).
11413            let already = t
11414                .schema()
11415                .uniqueness_constraints
11416                .iter()
11417                .any(|uc| uc.columns.len() == 1 && uc.columns[0] == idx.column_position);
11418            if already {
11419                continue;
11420            }
11421            rows.push(Row::new(alloc::vec![
11422                Value::Text(conname),
11423                Value::Text(kind.into()),
11424                Value::Text(tname.clone()),
11425                Value::Text(String::new()),
11426                Value::Text(col_name),
11427                Value::Text(String::new()),
11428            ]));
11429        }
11430        // Foreign keys.
11431        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
11432            let conname = fk
11433                .name
11434                .clone()
11435                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
11436            let conkey: Vec<String> = fk.local_columns.iter().map(|&p| col_name_at(p)).collect();
11437            // Parent column names: look up the parent table's
11438            // schema if it exists; otherwise emit positions.
11439            let confkey: Vec<String> = if let Some(parent) = cat.get(&fk.parent_table) {
11440                fk.parent_columns
11441                    .iter()
11442                    .map(|&p| {
11443                        parent
11444                            .schema()
11445                            .columns
11446                            .get(p)
11447                            .map_or_else(|| alloc::format!("col{p}"), |c| c.name.clone())
11448                    })
11449                    .collect()
11450            } else {
11451                fk.parent_columns
11452                    .iter()
11453                    .map(|p| alloc::format!("col{p}"))
11454                    .collect()
11455            };
11456            rows.push(Row::new(alloc::vec![
11457                Value::Text(conname),
11458                Value::Text("f".into()),
11459                Value::Text(tname.clone()),
11460                Value::Text(fk.parent_table.clone()),
11461                Value::Text(conkey.join(",")),
11462                Value::Text(confkey.join(",")),
11463            ]));
11464        }
11465    }
11466    (schema, rows)
11467}
11468
11469/// v7.17.0 Phase 3.P0-55 — synthesise `pg_catalog.pg_database`.
11470/// SPG is single-database so we surface a single row keyed on the
11471/// canonical `postgres` database name (matching what every PG
11472/// admin tool's startup screen expects to find).
11473fn synth_pg_database(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11474    let schema = alloc::vec![
11475        ColumnSchema::new("oid", DataType::BigInt, false),
11476        ColumnSchema::new("datname", DataType::Text, false),
11477        ColumnSchema::new("datdba", DataType::BigInt, false),
11478        ColumnSchema::new("encoding", DataType::Int, false),
11479        ColumnSchema::new("datcollate", DataType::Text, false),
11480    ];
11481    let rows = alloc::vec![Row::new(alloc::vec![
11482        Value::BigInt(16384),
11483        Value::Text("postgres".into()),
11484        Value::BigInt(10),
11485        Value::Int(6), // UTF8
11486        Value::Text("en_US.UTF-8".into()),
11487    ])];
11488    (schema, rows)
11489}
11490
11491/// v7.17.0 Phase 3.P0-55 — synthesise `pg_catalog.pg_roles`. PG's
11492/// pg_roles is a view over pg_authid showing all roles. SPG ships
11493/// one row per declared user from the engine's UserStore so admin
11494/// tool startup screens can populate.
11495fn synth_pg_roles(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
11496    let schema = alloc::vec![
11497        ColumnSchema::new("oid", DataType::BigInt, false),
11498        ColumnSchema::new("rolname", DataType::Text, false),
11499        ColumnSchema::new("rolsuper", DataType::Bool, false),
11500        ColumnSchema::new("rolinherit", DataType::Bool, false),
11501        ColumnSchema::new("rolcanlogin", DataType::Bool, false),
11502    ];
11503    let mut rows: Vec<Row> = Vec::new();
11504    let oid: i64 = 10;
11505    for (i, (name, _)) in engine.users.iter().enumerate() {
11506        rows.push(Row::new(alloc::vec![
11507            Value::BigInt(oid + (i as i64) + 1),
11508            Value::Text(name.to_string()),
11509            Value::Bool(false),
11510            Value::Bool(true),
11511            Value::Bool(true),
11512        ]));
11513    }
11514    // Always include `postgres` as the bootstrap superuser if not
11515    // already present — admin tools probe for it.
11516    if !rows
11517        .iter()
11518        .any(|r| matches!(&r.values[1], Value::Text(s) if s == "postgres"))
11519    {
11520        rows.insert(
11521            0,
11522            Row::new(alloc::vec![
11523                Value::BigInt(10),
11524                Value::Text("postgres".into()),
11525                Value::Bool(true),
11526                Value::Bool(true),
11527                Value::Bool(true),
11528            ]),
11529        );
11530    }
11531    (schema, rows)
11532}
11533
11534/// v7.17.0 Phase 3.P0-56 — synthesise `pg_catalog.pg_views`. PG's
11535/// pg_views is a view listing every catalog view; SPG ships one
11536/// row per declared view + its definition text.
11537/// Synthesise `pg_catalog.pg_extension`. SPG ships its "extension"
11538/// surfaces natively (vector, pg_trgm, plpgsql-shaped DO blocks), so
11539/// the table lists those as installed — `SELECT … FROM pg_extension
11540/// WHERE extname = 'vector'` probes from PG clients (mailrs embed
11541/// round-12) answer truthfully about capability presence.
11542fn synth_pg_extension() -> (Vec<ColumnSchema>, Vec<Row>) {
11543    let schema = alloc::vec![
11544        ColumnSchema::new("oid", DataType::BigInt, false),
11545        ColumnSchema::new("extname", DataType::Text, false),
11546        ColumnSchema::new("extversion", DataType::Text, false),
11547        ColumnSchema::new("extnamespace", DataType::Text, false),
11548    ];
11549    let exts: &[(&str, &str)] = &[("plpgsql", "1.0"), ("vector", "0.8.0"), ("pg_trgm", "1.6")];
11550    let rows = exts
11551        .iter()
11552        .enumerate()
11553        .map(|(i, (name, ver))| {
11554            Row::new(alloc::vec![
11555                Value::BigInt(16384 + i as i64),
11556                Value::Text((*name).into()),
11557                Value::Text((*ver).into()),
11558                Value::Text("pg_catalog".into()),
11559            ])
11560        })
11561        .collect();
11562    (schema, rows)
11563}
11564
11565fn synth_pg_views(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11566    let schema = alloc::vec![
11567        ColumnSchema::new("schemaname", DataType::Text, false),
11568        ColumnSchema::new("viewname", DataType::Text, false),
11569        ColumnSchema::new("definition", DataType::Text, false),
11570    ];
11571    let mut rows: Vec<Row> = Vec::new();
11572    for (name, def) in cat.views() {
11573        rows.push(Row::new(alloc::vec![
11574            Value::Text("public".into()),
11575            Value::Text(name.clone()),
11576            Value::Text(def.body.clone()),
11577        ]));
11578    }
11579    (schema, rows)
11580}
11581
11582/// v7.17.0 Phase 3.P0-57 — synthesise `pg_catalog.pg_settings`. ORM
11583/// connection-checkers (sqlx pre-flight, Diesel migrator) and admin
11584/// tools read `pg_settings` to discover server-side configuration.
11585/// SPG surfaces every session_param + a small set of canonical PG
11586/// defaults so the pre-flight queries match.
11587fn synth_pg_settings(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
11588    let schema = alloc::vec![
11589        ColumnSchema::new("name", DataType::Text, false),
11590        ColumnSchema::new("setting", DataType::Text, false),
11591        ColumnSchema::new("category", DataType::Text, false),
11592    ];
11593    let mut rows: Vec<Row> = Vec::new();
11594    // Canonical defaults every admin tool expects to find.
11595    let defaults: &[(&str, &str, &str)] = &[
11596        ("server_version", "16.0 (spg)", "Preset Options"),
11597        ("server_encoding", "UTF8", "Client Connection Defaults"),
11598        ("client_encoding", "UTF8", "Client Connection Defaults"),
11599        ("DateStyle", "ISO, MDY", "Client Connection Defaults"),
11600        ("TimeZone", "UTC", "Client Connection Defaults"),
11601        ("standard_conforming_strings", "on", "Compatibility"),
11602        ("integer_datetimes", "on", "Compatibility"),
11603        ("max_connections", "100", "Connections and Authentication"),
11604    ];
11605    for &(name, val, cat) in defaults {
11606        rows.push(Row::new(alloc::vec![
11607            Value::Text(name.into()),
11608            Value::Text(val.into()),
11609            Value::Text(cat.into()),
11610        ]));
11611    }
11612    // Session-set params override the static defaults.
11613    for (k, v) in &engine.session_params {
11614        if !defaults
11615            .iter()
11616            .any(|(n, _, _)| (*n).eq_ignore_ascii_case(k))
11617        {
11618            rows.push(Row::new(alloc::vec![
11619                Value::Text(k.clone()),
11620                Value::Text(v.clone()),
11621                Value::Text("Session".into()),
11622            ]));
11623        }
11624    }
11625    (schema, rows)
11626}
11627
11628/// v7.17.0 Phase 3.P0-53 — synthesise `pg_catalog.pg_indexes`.
11629/// PG's pg_indexes is a real view on pg_index + pg_class + pg_attribute.
11630/// SPG ships it as a synthesised flat table so admin tools (pgAdmin,
11631/// DataGrip) can list indexes by tablename without joining four catalogs.
11632///
11633/// Schema columns exposed:
11634///   * schemaname (Text) — always `public`
11635///   * tablename (Text)
11636///   * indexname (Text)
11637///   * indexdef (Text) — best-effort CREATE INDEX DDL
11638fn synth_pg_indexes(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11639    let schema = alloc::vec![
11640        ColumnSchema::new("schemaname", DataType::Text, false),
11641        ColumnSchema::new("tablename", DataType::Text, false),
11642        ColumnSchema::new("indexname", DataType::Text, false),
11643        ColumnSchema::new("indexdef", DataType::Text, false),
11644    ];
11645    let mut rows: Vec<Row> = Vec::new();
11646    for tname in cat.table_names() {
11647        let Some(t) = cat.get(&tname) else { continue };
11648        for idx in t.indices() {
11649            let col_name = t
11650                .schema()
11651                .columns
11652                .get(idx.column_position)
11653                .map_or("?".into(), |c| c.name.clone());
11654            let unique_kw = if idx.is_unique { "UNIQUE " } else { "" };
11655            let indexdef = alloc::format!(
11656                "CREATE {unique_kw}INDEX {} ON public.{} ({})",
11657                idx.name,
11658                tname,
11659                col_name
11660            );
11661            rows.push(Row::new(alloc::vec![
11662                Value::Text("public".into()),
11663                Value::Text(tname.clone()),
11664                Value::Text(idx.name.clone()),
11665                Value::Text(indexdef),
11666            ]));
11667        }
11668    }
11669    (schema, rows)
11670}
11671
11672/// v7.17.0 Phase 3.P0-53 — synthesise `pg_catalog.pg_index`. The
11673/// "raw" pg_index catalog used by PG-internal tooling for index
11674/// flags and ordinal information. SPG ships the columns ORM probes
11675/// actually filter on.
11676///
11677/// Schema columns exposed:
11678///   * indexrelid (BigInt) — index OID (synthetic = position+1)
11679///   * indrelid (BigInt) — table OID (synthetic = position+1)
11680///   * indnatts (Int) — number of indexed columns
11681///   * indisunique (Bool)
11682///   * indisprimary (Bool)
11683fn synth_pg_index_raw(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11684    let schema = alloc::vec![
11685        ColumnSchema::new("indexrelid", DataType::BigInt, false),
11686        ColumnSchema::new("indrelid", DataType::BigInt, false),
11687        ColumnSchema::new("indnatts", DataType::Int, false),
11688        ColumnSchema::new("indisunique", DataType::Bool, false),
11689        ColumnSchema::new("indisprimary", DataType::Bool, false),
11690    ];
11691    let mut rows: Vec<Row> = Vec::new();
11692    let mut idx_oid: i64 = 100_000;
11693    for (table_idx, tname) in cat.table_names().iter().enumerate() {
11694        let Some(t) = cat.get(tname) else { continue };
11695        for idx in t.indices() {
11696            idx_oid += 1;
11697            #[allow(clippy::cast_possible_wrap)]
11698            let nattrs = (1 + idx.extra_column_positions.len()) as i32;
11699            // is_primary: SPG / PG flag the primary via the
11700            // index name convention `<table>_pkey`.
11701            let is_primary = idx.name.ends_with("_pkey");
11702            rows.push(Row::new(alloc::vec![
11703                Value::BigInt(idx_oid),
11704                Value::BigInt((table_idx + 1) as i64),
11705                Value::Int(nattrs),
11706                Value::Bool(idx.is_unique),
11707                Value::Bool(is_primary),
11708            ]));
11709        }
11710    }
11711    (schema, rows)
11712}
11713
11714/// v7.17.0 Phase 3.P0-52 — synthesise `pg_catalog.pg_namespace`.
11715/// SPG is single-schema so we expose the canonical PG schemas:
11716/// `public` (user-facing), `pg_catalog` (built-in), and
11717/// `information_schema` (PG meta).
11718fn synth_pg_namespace(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11719    let schema = alloc::vec![
11720        ColumnSchema::new("oid", DataType::BigInt, false),
11721        ColumnSchema::new("nspname", DataType::Text, false),
11722        ColumnSchema::new("nspowner", DataType::BigInt, false),
11723    ];
11724    let rows = alloc::vec![
11725        Row::new(alloc::vec![
11726            Value::BigInt(11),
11727            Value::Text("pg_catalog".into()),
11728            Value::BigInt(10),
11729        ]),
11730        Row::new(alloc::vec![
11731            Value::BigInt(2200),
11732            Value::Text("public".into()),
11733            Value::BigInt(10),
11734        ]),
11735        Row::new(alloc::vec![
11736            Value::BigInt(13000),
11737            Value::Text("information_schema".into()),
11738            Value::BigInt(10),
11739        ]),
11740    ];
11741    (schema, rows)
11742}
11743
11744/// v7.16.2 — drop the synthesised meta view into the enriched
11745/// catalog so the regular FROM-resolution path can see it.
11746fn materialise_meta_view(
11747    catalog: &mut Catalog,
11748    name: &str,
11749    columns: Vec<ColumnSchema>,
11750    rows: Vec<Row>,
11751) -> Result<(), EngineError> {
11752    let schema = TableSchema::new(name.to_string(), columns);
11753    catalog.create_table(schema).map_err(EngineError::Storage)?;
11754    let table = catalog
11755        .get_mut(name)
11756        .expect("just-created meta view must exist");
11757    for row in rows {
11758        table.insert(row).map_err(EngineError::Storage)?;
11759    }
11760    Ok(())
11761}
11762
11763/// v7.16.2 — true when the SELECT statement references any
11764/// `__spg_info_*` or `__spg_pg_*` synthetic table name (the
11765/// parser produces these for `information_schema.X` /
11766/// `pg_catalog.X`). Used by `exec_select_cancel` to short-
11767/// circuit into the meta-view materialisation path.
11768/// v7.17.0 Phase 1.2 — append the names of any catalog-known
11769/// views referenced by `tref` to `into`. Helper for
11770/// `Engine::expand_views_in_select`. A view that's been already
11771/// materialised as a table (e.g. via the synthetic CTE pass for
11772/// SELECT FROM v) is skipped — the table form wins so the
11773/// recursive exec_select_cancel call inside exec_with_ctes
11774/// doesn't re-expand and trigger the CTE-shadow guard.
11775fn collect_view_refs(
11776    tref: &spg_sql::ast::TableRef,
11777    cat: &spg_storage::Catalog,
11778    into: &mut Vec<String>,
11779) {
11780    if cat.views().contains_key(&tref.name)
11781        && cat.get(&tref.name).is_none()
11782        && !into.iter().any(|n| n == &tref.name)
11783    {
11784        into.push(tref.name.clone());
11785    }
11786}
11787
11788fn select_references_meta_view(stmt: &SelectStatement) -> bool {
11789    fn is_meta(name: &str) -> bool {
11790        name.starts_with("__spg_info_")
11791            || name.starts_with("__spg_pg_")
11792            || name.starts_with("__spg_mysql_")
11793    }
11794    if let Some(from) = &stmt.from {
11795        if is_meta(&from.primary.name) {
11796            return true;
11797        }
11798        for j in &from.joins {
11799            if is_meta(&j.table.name) {
11800                return true;
11801            }
11802        }
11803    }
11804    for cte in &stmt.ctes {
11805        if select_references_meta_view(&cte.body) {
11806            return true;
11807        }
11808    }
11809    false
11810}
11811
11812/// v7.16.2 — collect every meta-view name a SELECT touches.
11813/// Returns a deduplicated, sorted list. Caller materialises
11814/// each one into the enriched catalog before re-running the
11815/// SELECT. Walks JOINs, CTEs, and the primary FROM.
11816fn collect_meta_view_names(
11817    stmt: &SelectStatement,
11818    into: &mut alloc::collections::BTreeSet<String>,
11819) {
11820    fn is_meta(name: &str) -> bool {
11821        name.starts_with("__spg_info_")
11822            || name.starts_with("__spg_pg_")
11823            || name.starts_with("__spg_mysql_")
11824    }
11825    if let Some(from) = &stmt.from {
11826        if is_meta(&from.primary.name) {
11827            into.insert(from.primary.name.clone());
11828        }
11829        for j in &from.joins {
11830            if is_meta(&j.table.name) {
11831                into.insert(j.table.name.clone());
11832            }
11833        }
11834    }
11835    for cte in &stmt.ctes {
11836        collect_meta_view_names(&cte.body, into);
11837    }
11838}
11839
11840fn infer_column_types(columns: &[ColumnSchema], rows: &[Row]) -> Vec<ColumnSchema> {
11841    let mut out = columns.to_vec();
11842    for (col_idx, col) in out.iter_mut().enumerate() {
11843        if col.ty != DataType::Text {
11844            continue;
11845        }
11846        let mut inferred: Option<DataType> = None;
11847        let mut all_null = true;
11848        for row in rows {
11849            let Some(v) = row.values.get(col_idx) else {
11850                continue;
11851            };
11852            let ty = match v {
11853                Value::Null => continue,
11854                Value::SmallInt(_) => DataType::SmallInt,
11855                Value::Int(_) => DataType::Int,
11856                Value::BigInt(_) => DataType::BigInt,
11857                Value::Float(_) => DataType::Float,
11858                Value::Bool(_) => DataType::Bool,
11859                Value::Vector(_) => DataType::Vector {
11860                    dim: 0,
11861                    encoding: VecEncoding::F32,
11862                },
11863                _ => DataType::Text,
11864            };
11865            all_null = false;
11866            inferred = Some(match inferred {
11867                None => ty,
11868                Some(prev) if prev == ty => prev,
11869                Some(_) => DataType::Text,
11870            });
11871        }
11872        if let Some(t) = inferred {
11873            col.ty = t;
11874            col.nullable = true;
11875        } else if all_null {
11876            col.nullable = true;
11877        }
11878    }
11879    out
11880}
11881
11882/// v4.26: render a human-readable plan tree for `EXPLAIN <select>`.
11883/// Lines are pushed into `out`; `depth` controls indentation. We
11884/// describe the rewritten SELECT — what the executor *would* do —
11885/// using the engine handle to spot indexed lookups and table shapes.
11886#[allow(clippy::too_many_lines, clippy::format_push_string)]
11887/// v6.2.4 — Walk every line of the rendered plan tree and append
11888/// per-operator stats. Lines that name a known operator get
11889/// `(rows=N)` (`actual_rows` of the top-level operator equals the
11890/// final result row count; scans report their catalog row count
11891/// as the rows-considered metric). Other lines — Filter / Join /
11892/// GroupBy / OrderBy etc. — are marked `(—)` so the surface is
11893/// complete-by-construction; v6.2.5 fills these in via inline
11894/// executor counters.
11895/// v6.8.3 — surface "CREATE INDEX …" suggestions for every
11896/// `(table, column)` pair the query touches via WHERE / JOIN
11897/// that doesn't already have an index on the owning table.
11898/// Walks the SELECT's FROM clauses + WHERE expression tree;
11899/// returns one line per missing index. Deterministic order:
11900/// FROM-clause iteration order, then column-reference walk
11901/// order inside each WHERE. Each suggestion is a copy-pastable
11902/// DDL string.
11903fn build_index_suggestions(stmt: &SelectStatement, engine: &Engine) -> Vec<String> {
11904    use alloc::collections::BTreeSet;
11905    let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
11906    let mut out: Vec<String> = Vec::new();
11907    let cat = engine.active_catalog();
11908    // Build a (table, qualifier-or-alias) list from the FROM clause
11909    // so unqualified column refs in WHERE resolve to the correct
11910    // table.
11911    let Some(from) = &stmt.from else {
11912        return out;
11913    };
11914    let mut tables: Vec<String> = Vec::new();
11915    tables.push(from.primary.name.clone());
11916    for j in &from.joins {
11917        tables.push(j.table.name.clone());
11918    }
11919    // Collect column refs from the WHERE expression. JOIN ON
11920    // predicates also feed in.
11921    let mut col_refs: Vec<spg_sql::ast::ColumnName> = Vec::new();
11922    if let Some(w) = &stmt.where_ {
11923        collect_column_refs(w, &mut col_refs);
11924    }
11925    for j in &from.joins {
11926        if let Some(on) = &j.on {
11927            collect_column_refs(on, &mut col_refs);
11928        }
11929    }
11930    for cn in &col_refs {
11931        // Resolve owner table: explicit qualifier first, else
11932        // first table in FROM that has a column of this name.
11933        let owner: Option<String> = if let Some(q) = &cn.qualifier {
11934            tables.iter().find(|t| t == &q).cloned()
11935        } else {
11936            tables.iter().find_map(|t| {
11937                cat.get(t).and_then(|tbl| {
11938                    if tbl.schema().column_position(&cn.name).is_some() {
11939                        Some(t.clone())
11940                    } else {
11941                        None
11942                    }
11943                })
11944            })
11945        };
11946        let Some(owner) = owner else {
11947            continue;
11948        };
11949        let Some(tbl) = cat.get(&owner) else {
11950            continue;
11951        };
11952        let Some(col_pos) = tbl.schema().column_position(&cn.name) else {
11953            continue;
11954        };
11955        // Skip if any BTree index already covers this column as
11956        // its key.
11957        let already_indexed = tbl.indices().iter().any(|i| {
11958            matches!(i.kind, spg_storage::IndexKind::BTree(_))
11959                && i.column_position == col_pos
11960                && i.expression.is_none()
11961                && i.partial_predicate.is_none()
11962        });
11963        if already_indexed {
11964            continue;
11965        }
11966        if seen.insert((owner.clone(), cn.name.clone())) {
11967            out.push(alloc::format!(
11968                "SUGGEST: CREATE INDEX ix_{}_{} ON {} ({})",
11969                owner,
11970                cn.name,
11971                owner,
11972                cn.name
11973            ));
11974        }
11975    }
11976    out
11977}
11978
11979/// Walks an `Expr` and pushes every `ColumnName` it references.
11980/// Order is depth-first, left-to-right.
11981fn collect_column_refs(expr: &Expr, out: &mut Vec<spg_sql::ast::ColumnName>) {
11982    match expr {
11983        Expr::Column(cn) => out.push(cn.clone()),
11984        Expr::FunctionCall { args, .. } => {
11985            for a in args {
11986                collect_column_refs(a, out);
11987            }
11988        }
11989        Expr::Binary { lhs, rhs, .. } => {
11990            collect_column_refs(lhs, out);
11991            collect_column_refs(rhs, out);
11992        }
11993        Expr::Unary { expr: e, .. } => collect_column_refs(e, out),
11994        _ => {}
11995    }
11996}
11997
11998fn annotate_explain_lines(lines: &mut [String], total_rows: usize, engine: &Engine) {
11999    let catalog = engine.active_catalog();
12000    let cold_ids = catalog.cold_segment_ids_global();
12001    let any_cold = !cold_ids.is_empty();
12002    let cold_ids_repr = if any_cold {
12003        let mut s = alloc::string::String::from("[");
12004        for (i, id) in cold_ids.iter().enumerate() {
12005            if i > 0 {
12006                s.push(',');
12007            }
12008            s.push_str(&alloc::format!("{id}"));
12009        }
12010        s.push(']');
12011        s
12012    } else {
12013        alloc::string::String::new()
12014    };
12015    for (idx, line) in lines.iter_mut().enumerate() {
12016        let trimmed = line.trim_start();
12017        let is_top_level = idx == 0;
12018        if is_top_level {
12019            line.push_str(&alloc::format!(" (rows={total_rows})"));
12020            continue;
12021        }
12022        if let Some(rest) = trimmed.strip_prefix("From: ") {
12023            let (name, scan_kind) = match rest.split_once(" [") {
12024                Some((n, k)) => (n.trim(), k.trim_end_matches(']')),
12025                None => (rest.trim(), ""),
12026            };
12027            let bare = name.split_whitespace().next().unwrap_or(name);
12028            let hot = catalog.get(bare).map(|t| t.rows().len());
12029            // v6.2.7 — `cold_segments=[id0,id1,…]` enumerates every
12030            // cold-tier segment the scan COULD have walked. v6.2.x
12031            // can tighten to per-table by walking the table's
12032            // BTree-index cold locators.
12033            let annot = match (hot, scan_kind) {
12034                (Some(h), "full scan") => {
12035                    let mut s = alloc::format!(" (hot_rows={h}");
12036                    if any_cold {
12037                        s.push_str(&alloc::format!(
12038                            ", cold_tier=present, cold_segments={cold_ids_repr}"
12039                        ));
12040                    }
12041                    s.push(')');
12042                    s
12043                }
12044                (Some(h), "index seek") => {
12045                    let mut s = alloc::format!(" (hot_rows≤{h}");
12046                    if any_cold {
12047                        s.push_str(&alloc::format!(
12048                            ", cold_tier=present, cold_segments={cold_ids_repr}"
12049                        ));
12050                    }
12051                    s.push(')');
12052                    s
12053                }
12054                _ => " (rows=—)".to_string(),
12055            };
12056            line.push_str(&annot);
12057            continue;
12058        }
12059        // Filter / GroupBy / Having / OrderBy / Limit / Join etc.
12060        line.push_str(" (rows=—)");
12061    }
12062}
12063
12064fn explain_select(stmt: &SelectStatement, engine: &Engine, depth: usize, out: &mut Vec<String>) {
12065    let pad = "  ".repeat(depth);
12066    // 1) Top-level operator label.
12067    let top = if !stmt.ctes.is_empty() {
12068        if stmt.ctes.iter().any(|c| c.recursive) {
12069            "CTEScan (WITH RECURSIVE)"
12070        } else {
12071            "CTEScan (WITH)"
12072        }
12073    } else if !stmt.unions.is_empty() {
12074        "UnionScan"
12075    } else if select_has_window(stmt) {
12076        "WindowAgg"
12077    } else if aggregate::uses_aggregate(stmt) {
12078        "Aggregate"
12079    } else if stmt.distinct {
12080        "Distinct"
12081    } else if stmt.from.is_some() {
12082        "TableScan"
12083    } else {
12084        "Result"
12085    };
12086    out.push(alloc::format!("{pad}{top}"));
12087    let child = "  ".repeat(depth + 1);
12088    // 2) CTE bodies.
12089    for cte in &stmt.ctes {
12090        let head = if cte.recursive {
12091            alloc::format!("{child}CTE (recursive): {}", cte.name)
12092        } else {
12093            alloc::format!("{child}CTE: {}", cte.name)
12094        };
12095        out.push(head);
12096        explain_select(&cte.body, engine, depth + 2, out);
12097    }
12098    // 3) FROM details — primary table + joins, index hits.
12099    if let Some(from) = &stmt.from {
12100        let mut tag = alloc::format!("{child}From: {}", from.primary.name);
12101        if let Some(alias) = &from.primary.alias {
12102            tag.push_str(&alloc::format!(" AS {alias}"));
12103        }
12104        // Try to detect an index-seek opportunity on WHERE against
12105        // the primary table — same heuristic the executor uses.
12106        if let Some(w) = &stmt.where_
12107            && let Some(table) = engine.active_catalog().get(&from.primary.name)
12108        {
12109            let alias = from.primary.alias.as_deref().unwrap_or(&from.primary.name);
12110            let cols = &table.schema().columns;
12111            if try_index_seek(w, cols, engine.active_catalog(), table, alias).is_some() {
12112                tag.push_str(" [index seek]");
12113            } else {
12114                tag.push_str(" [full scan]");
12115            }
12116        } else {
12117            tag.push_str(" [full scan]");
12118        }
12119        out.push(tag);
12120        for j in &from.joins {
12121            let kind = match j.kind {
12122                spg_sql::ast::JoinKind::Inner => "INNER JOIN",
12123                spg_sql::ast::JoinKind::Left => "LEFT JOIN",
12124                spg_sql::ast::JoinKind::Cross => "CROSS JOIN",
12125            };
12126            let mut s = alloc::format!("{child}{kind}: {}", j.table.name);
12127            if let Some(alias) = &j.table.alias {
12128                s.push_str(&alloc::format!(" AS {alias}"));
12129            }
12130            if j.on.is_some() {
12131                s.push_str(" (ON …)");
12132            }
12133            out.push(s);
12134        }
12135    }
12136    // 4) WHERE / GROUP BY / HAVING / ORDER BY / LIMIT / OFFSET.
12137    if let Some(w) = &stmt.where_ {
12138        let mut s = alloc::format!("{child}Filter: {w}");
12139        if expr_has_subquery(w) {
12140            s.push_str(" [subquery]");
12141        }
12142        out.push(s);
12143    }
12144    if let Some(gs) = &stmt.group_by {
12145        let mut parts = Vec::new();
12146        for g in gs {
12147            parts.push(alloc::format!("{g}"));
12148        }
12149        out.push(alloc::format!("{child}GroupBy: {}", parts.join(", ")));
12150    }
12151    if let Some(h) = &stmt.having {
12152        out.push(alloc::format!("{child}Having: {h}"));
12153    }
12154    for o in &stmt.order_by {
12155        let dir = if o.desc { "DESC" } else { "ASC" };
12156        out.push(alloc::format!("{child}OrderBy: {} {dir}", o.expr));
12157    }
12158    if let Some(lim) = stmt.limit {
12159        out.push(alloc::format!("{child}Limit: {lim}"));
12160    }
12161    if let Some(off) = stmt.offset {
12162        out.push(alloc::format!("{child}Offset: {off}"));
12163    }
12164    // 5) Projection — collapse Wildcard or render N items.
12165    if stmt
12166        .items
12167        .iter()
12168        .any(|it| matches!(it, SelectItem::Wildcard))
12169    {
12170        out.push(alloc::format!("{child}Project: *"));
12171    } else {
12172        out.push(alloc::format!(
12173            "{child}Project: {} item(s)",
12174            stmt.items.len()
12175        ));
12176    }
12177    // 6) Recurse into UNION peers.
12178    for (kind, peer) in &stmt.unions {
12179        let label = match kind {
12180            UnionKind::All => "UNION ALL",
12181            UnionKind::Distinct => "UNION",
12182        };
12183        out.push(alloc::format!("{child}{label}"));
12184        explain_select(peer, engine, depth + 2, out);
12185    }
12186}
12187
12188/// v4.23: recognise the engine errors that indicate the inner
12189/// SELECT couldn't be evaluated in isolation because it references
12190/// an outer column — used by `subquery_replacement` to skip
12191/// materialisation and let row-eval handle it instead.
12192fn is_correlation_error(e: &EngineError) -> bool {
12193    matches!(
12194        e,
12195        EngineError::Eval(
12196            eval::EvalError::ColumnNotFound { .. } | eval::EvalError::UnknownQualifier { .. }
12197        )
12198    )
12199}
12200
12201/// v4.23: walk every Expr in `stmt` and replace each Column ref
12202/// that targets the outer scope (qualifier matches the outer
12203/// table alias) with a Literal carrying the outer row's value.
12204/// Conservative: only qualified refs are substituted, so the user
12205/// must write `outer_alias.col` to reference an outer column. This
12206/// matches PG's lexical scoping for correlated subqueries and
12207/// avoids accidentally rebinding inner columns of the same name.
12208/// v7.17.0 Phase 3.P0-41 — LATERAL peer descriptor. Either eagerly
12209/// materialised (every regular table / unnest / generate_series) or
12210/// lateral (subquery re-evaluated per outer row).
12211struct JoinedPeer<'a> {
12212    eager_rows: Option<Vec<Row>>,
12213    cols: Vec<ColumnSchema>,
12214    alias: String,
12215    kind: JoinKind,
12216    on: Option<&'a Expr>,
12217    lateral: Option<&'a SelectStatement>,
12218}
12219
12220/// v7.17.0 Phase 3.P0-41 — synthesise a column name for a LATERAL
12221/// projection item that has no explicit alias. PG names anonymous
12222/// projection items by the function call's name or by `column<i>`.
12223/// SPG mirrors the latter (lower-overhead than walking arbitrary
12224/// Expr shapes) so the probe-schema fallback path produces stable
12225/// names for the lateral peer's columns.
12226fn synth_lateral_col_name(expr: &Expr, idx: usize) -> String {
12227    match expr {
12228        // Bare column reference — use the column's own name.
12229        Expr::Column(c) => c.name.clone(),
12230        // Function call — use the function name (PG canonical:
12231        // `count` / `max` / `lower` …).
12232        Expr::FunctionCall { name, .. } => name.clone(),
12233        // Cast — drill into the inner expression.
12234        Expr::Cast { expr: inner, .. } => synth_lateral_col_name(inner, idx),
12235        // Everything else falls back to PG's `column<N>` placeholder.
12236        _ => alloc::format!("column{}", idx + 1),
12237    }
12238}
12239
12240/// v7.17.0 Phase 3.P0-41 — substitute every `<alias>.<col>` Expr
12241/// reference whose `<alias>.<col>` exists in the outer composite
12242/// schema with the matching value from the outer row. Walks the
12243/// entire SELECT body (items, WHERE, GROUP BY, HAVING, ORDER BY,
12244/// UNION peers) so any depth of outer reference inside the
12245/// LATERAL subquery resolves before execution.
12246fn substitute_outer_columns_multi(
12247    stmt: &mut SelectStatement,
12248    outer_row: &Row,
12249    outer_schema: &[ColumnSchema],
12250) {
12251    substitute_outer_in_select(stmt, outer_row, outer_schema);
12252}
12253
12254fn substitute_outer_in_select(
12255    stmt: &mut SelectStatement,
12256    outer_row: &Row,
12257    outer_schema: &[ColumnSchema],
12258) {
12259    for item in &mut stmt.items {
12260        if let SelectItem::Expr { expr, .. } = item {
12261            substitute_outer_in_expr(expr, outer_row, outer_schema);
12262        }
12263    }
12264    if let Some(w) = &mut stmt.where_ {
12265        substitute_outer_in_expr(w, outer_row, outer_schema);
12266    }
12267    if let Some(gs) = &mut stmt.group_by {
12268        for g in gs {
12269            substitute_outer_in_expr(g, outer_row, outer_schema);
12270        }
12271    }
12272    if let Some(h) = &mut stmt.having {
12273        substitute_outer_in_expr(h, outer_row, outer_schema);
12274    }
12275    for o in &mut stmt.order_by {
12276        substitute_outer_in_expr(&mut o.expr, outer_row, outer_schema);
12277    }
12278    for (_, peer) in &mut stmt.unions {
12279        substitute_outer_in_select(peer, outer_row, outer_schema);
12280    }
12281}
12282
12283fn substitute_outer_in_expr(e: &mut Expr, outer_row: &Row, outer_schema: &[ColumnSchema]) {
12284    if let Expr::Column(c) = e
12285        && let Some(qual) = &c.qualifier
12286    {
12287        let composite = alloc::format!("{qual}.{}", c.name);
12288        if let Some(idx) = outer_schema
12289            .iter()
12290            .position(|sc| sc.name.eq_ignore_ascii_case(&composite))
12291        {
12292            let v = outer_row.values.get(idx).cloned().unwrap_or(Value::Null);
12293            if let Ok(lit) = value_to_literal_expr(v) {
12294                *e = lit;
12295                return;
12296            }
12297        }
12298    }
12299    match e {
12300        Expr::Binary { lhs, rhs, .. } => {
12301            substitute_outer_in_expr(lhs, outer_row, outer_schema);
12302            substitute_outer_in_expr(rhs, outer_row, outer_schema);
12303        }
12304        Expr::Unary { expr: inner, .. } => {
12305            substitute_outer_in_expr(inner, outer_row, outer_schema);
12306        }
12307        Expr::FunctionCall { args, .. } => {
12308            for a in args {
12309                substitute_outer_in_expr(a, outer_row, outer_schema);
12310            }
12311        }
12312        Expr::Cast { expr: inner, .. } => {
12313            substitute_outer_in_expr(inner, outer_row, outer_schema);
12314        }
12315        Expr::Case {
12316            operand,
12317            branches,
12318            else_branch,
12319        } => {
12320            if let Some(op) = operand {
12321                substitute_outer_in_expr(op, outer_row, outer_schema);
12322            }
12323            for (cond, val) in branches {
12324                substitute_outer_in_expr(cond, outer_row, outer_schema);
12325                substitute_outer_in_expr(val, outer_row, outer_schema);
12326            }
12327            if let Some(e) = else_branch {
12328                substitute_outer_in_expr(e, outer_row, outer_schema);
12329            }
12330        }
12331        _ => {}
12332    }
12333}
12334
12335fn substitute_outer_columns(stmt: &mut SelectStatement, row: &Row, ctx: &EvalContext<'_>) {
12336    let Some(outer_alias) = ctx.table_alias else {
12337        return;
12338    };
12339    substitute_in_select(stmt, row, ctx, outer_alias);
12340}
12341
12342fn substitute_in_select(
12343    stmt: &mut SelectStatement,
12344    row: &Row,
12345    ctx: &EvalContext<'_>,
12346    outer_alias: &str,
12347) {
12348    for item in &mut stmt.items {
12349        if let SelectItem::Expr { expr, .. } = item {
12350            substitute_in_expr(expr, row, ctx, outer_alias);
12351        }
12352    }
12353    if let Some(w) = &mut stmt.where_ {
12354        substitute_in_expr(w, row, ctx, outer_alias);
12355    }
12356    if let Some(gs) = &mut stmt.group_by {
12357        for g in gs {
12358            substitute_in_expr(g, row, ctx, outer_alias);
12359        }
12360    }
12361    if let Some(h) = &mut stmt.having {
12362        substitute_in_expr(h, row, ctx, outer_alias);
12363    }
12364    for o in &mut stmt.order_by {
12365        substitute_in_expr(&mut o.expr, row, ctx, outer_alias);
12366    }
12367    for (_, peer) in &mut stmt.unions {
12368        substitute_in_select(peer, row, ctx, outer_alias);
12369    }
12370}
12371
12372fn substitute_in_expr(e: &mut Expr, row: &Row, ctx: &EvalContext<'_>, outer_alias: &str) {
12373    if let Expr::Column(c) = e
12374        && let Some(qual) = &c.qualifier
12375        && qual.eq_ignore_ascii_case(outer_alias)
12376    {
12377        // Look up the column's index in the outer schema.
12378        if let Some(idx) = ctx
12379            .columns
12380            .iter()
12381            .position(|sc| sc.name.eq_ignore_ascii_case(&c.name))
12382        {
12383            let v = row.values.get(idx).cloned().unwrap_or(Value::Null);
12384            if let Ok(lit) = value_to_literal_expr(v) {
12385                *e = lit;
12386                return;
12387            }
12388        }
12389    }
12390    match e {
12391        Expr::Binary { lhs, rhs, .. } => {
12392            substitute_in_expr(lhs, row, ctx, outer_alias);
12393            substitute_in_expr(rhs, row, ctx, outer_alias);
12394        }
12395        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12396            substitute_in_expr(expr, row, ctx, outer_alias);
12397        }
12398        Expr::Like { expr, pattern, .. } => {
12399            substitute_in_expr(expr, row, ctx, outer_alias);
12400            substitute_in_expr(pattern, row, ctx, outer_alias);
12401        }
12402        Expr::FunctionCall { args, .. } => {
12403            for a in args {
12404                substitute_in_expr(a, row, ctx, outer_alias);
12405            }
12406        }
12407        Expr::Extract { source, .. } => substitute_in_expr(source, row, ctx, outer_alias),
12408        Expr::WindowFunction {
12409            args,
12410            partition_by,
12411            order_by,
12412            ..
12413        } => {
12414            for a in args {
12415                substitute_in_expr(a, row, ctx, outer_alias);
12416            }
12417            for p in partition_by {
12418                substitute_in_expr(p, row, ctx, outer_alias);
12419            }
12420            for (o, _) in order_by {
12421                substitute_in_expr(o, row, ctx, outer_alias);
12422            }
12423        }
12424        Expr::ScalarSubquery(s) => substitute_in_select(s, row, ctx, outer_alias),
12425        Expr::Exists { subquery, .. } | Expr::InSubquery { subquery, .. } => {
12426            substitute_in_select(subquery, row, ctx, outer_alias);
12427        }
12428        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => {}
12429        Expr::Array(items) => {
12430            for elem in items {
12431                substitute_in_expr(elem, row, ctx, outer_alias);
12432            }
12433        }
12434        Expr::ArraySubscript { target, index } => {
12435            substitute_in_expr(target, row, ctx, outer_alias);
12436            substitute_in_expr(index, row, ctx, outer_alias);
12437        }
12438        Expr::AnyAll { expr, array, .. } => {
12439            substitute_in_expr(expr, row, ctx, outer_alias);
12440            substitute_in_expr(array, row, ctx, outer_alias);
12441        }
12442        Expr::Case {
12443            operand,
12444            branches,
12445            else_branch,
12446        } => {
12447            if let Some(o) = operand {
12448                substitute_in_expr(o, row, ctx, outer_alias);
12449            }
12450            for (w, t) in branches {
12451                substitute_in_expr(w, row, ctx, outer_alias);
12452                substitute_in_expr(t, row, ctx, outer_alias);
12453            }
12454            if let Some(e) = else_branch {
12455                substitute_in_expr(e, row, ctx, outer_alias);
12456            }
12457        }
12458    }
12459}
12460
12461/// v4.22: encode a Row to a comparable byte key for UNION-DISTINCT
12462/// dedup inside the recursive iteration. Crude but deterministic
12463/// — Debug prints embed type discriminants so NULL ≠ "" ≠ 0.
12464fn encode_row_key(row: &Row) -> Vec<u8> {
12465    let mut out = Vec::new();
12466    for v in &row.values {
12467        let s = alloc::format!("{v:?}|");
12468        out.extend_from_slice(s.as_bytes());
12469    }
12470    out
12471}
12472
12473fn select_has_window(stmt: &SelectStatement) -> bool {
12474    for item in &stmt.items {
12475        if let SelectItem::Expr { expr, .. } = item
12476            && expr_has_window(expr)
12477        {
12478            return true;
12479        }
12480    }
12481    false
12482}
12483
12484fn expr_has_window(e: &Expr) -> bool {
12485    match e {
12486        Expr::WindowFunction { .. } => true,
12487        Expr::Binary { lhs, rhs, .. } => expr_has_window(lhs) || expr_has_window(rhs),
12488        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12489            expr_has_window(expr)
12490        }
12491        Expr::FunctionCall { args, .. } => args.iter().any(expr_has_window),
12492        Expr::Like { expr, pattern, .. } => expr_has_window(expr) || expr_has_window(pattern),
12493        Expr::Extract { source, .. } => expr_has_window(source),
12494        Expr::ScalarSubquery(_)
12495        | Expr::Exists { .. }
12496        | Expr::InSubquery { .. }
12497        | Expr::Literal(_)
12498        | Expr::Placeholder(_)
12499        | Expr::Column(_) => false,
12500        Expr::Array(items) => items.iter().any(expr_has_window),
12501        Expr::ArraySubscript { target, index } => expr_has_window(target) || expr_has_window(index),
12502        Expr::AnyAll { expr, array, .. } => expr_has_window(expr) || expr_has_window(array),
12503        Expr::Case {
12504            operand,
12505            branches,
12506            else_branch,
12507        } => {
12508            operand.as_deref().is_some_and(expr_has_window)
12509                || branches
12510                    .iter()
12511                    .any(|(w, t)| expr_has_window(w) || expr_has_window(t))
12512                || else_branch.as_deref().is_some_and(expr_has_window)
12513        }
12514    }
12515}
12516
12517fn collect_window_nodes(e: &Expr, out: &mut Vec<Expr>) {
12518    if let Expr::WindowFunction { .. } = e {
12519        // Deduplicate by structural equality on the expression
12520        // (cheap because window args + partition + order are
12521        // small). Without dedup we'd recompute identical windows
12522        // once per occurrence in the projection.
12523        if !out.iter().any(|x| x == e) {
12524            out.push(e.clone());
12525        }
12526        return;
12527    }
12528    match e {
12529        // Already handled by the early-return at the top.
12530        Expr::WindowFunction { .. } => unreachable!(),
12531        Expr::Binary { lhs, rhs, .. } => {
12532            collect_window_nodes(lhs, out);
12533            collect_window_nodes(rhs, out);
12534        }
12535        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12536            collect_window_nodes(expr, out);
12537        }
12538        Expr::FunctionCall { args, .. } => {
12539            for a in args {
12540                collect_window_nodes(a, out);
12541            }
12542        }
12543        Expr::Like { expr, pattern, .. } => {
12544            collect_window_nodes(expr, out);
12545            collect_window_nodes(pattern, out);
12546        }
12547        Expr::Extract { source, .. } => collect_window_nodes(source, out),
12548        _ => {}
12549    }
12550}
12551
12552fn rewrite_window_to_columns(e: &mut Expr, window_nodes: &[Expr]) {
12553    if let Expr::WindowFunction { .. } = e
12554        && let Some(idx) = window_nodes.iter().position(|w| w == e)
12555    {
12556        *e = Expr::Column(spg_sql::ast::ColumnName {
12557            qualifier: None,
12558            name: alloc::format!("__win_{idx}"),
12559        });
12560        return;
12561    }
12562    match e {
12563        Expr::Binary { lhs, rhs, .. } => {
12564            rewrite_window_to_columns(lhs, window_nodes);
12565            rewrite_window_to_columns(rhs, window_nodes);
12566        }
12567        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12568            rewrite_window_to_columns(expr, window_nodes);
12569        }
12570        Expr::FunctionCall { args, .. } => {
12571            for a in args {
12572                rewrite_window_to_columns(a, window_nodes);
12573            }
12574        }
12575        Expr::Like { expr, pattern, .. } => {
12576            rewrite_window_to_columns(expr, window_nodes);
12577            rewrite_window_to_columns(pattern, window_nodes);
12578        }
12579        Expr::Extract { source, .. } => rewrite_window_to_columns(source, window_nodes),
12580        _ => {}
12581    }
12582}
12583
12584/// Total order over partition-key tuples. NULL sorts as the
12585/// lowest value (matches the `<` partial order's NULL-last
12586/// behaviour with `INFINITY` flipped).
12587fn partition_key_cmp(a: &[Value], b: &[Value]) -> core::cmp::Ordering {
12588    for (x, y) in a.iter().zip(b.iter()) {
12589        let c = value_cmp(x, y);
12590        if c != core::cmp::Ordering::Equal {
12591            return c;
12592        }
12593    }
12594    a.len().cmp(&b.len())
12595}
12596
12597fn order_key_cmp(a: &[(Value, bool)], b: &[(Value, bool)]) -> core::cmp::Ordering {
12598    for ((va, desc), (vb, _)) in a.iter().zip(b.iter()) {
12599        let c = value_cmp(va, vb);
12600        let c = if *desc { c.reverse() } else { c };
12601        if c != core::cmp::Ordering::Equal {
12602            return c;
12603        }
12604    }
12605    a.len().cmp(&b.len())
12606}
12607
12608/// v7.17.0 Phase 3.10 — true when the Value is one of the
12609/// integer-shaped variants `generate_series` accepts as a start
12610/// / stop / step component. Float / NUMERIC are rejected — PG's
12611/// `generate_series(numeric, numeric)` overload is out of v7.17
12612/// scope.
12613const fn value_is_integer(v: &Value) -> bool {
12614    matches!(v, Value::SmallInt(_) | Value::Int(_) | Value::BigInt(_))
12615}
12616
12617/// v7.17.0 Phase 3.10 — widen any integer-shaped Value to i64 for
12618/// the generate_series iteration loop. Non-integer inputs panic;
12619/// caller guards via `value_is_integer`.
12620const fn value_to_i64(v: &Value) -> i64 {
12621    match v {
12622        Value::SmallInt(n) => *n as i64,
12623        Value::Int(n) => *n as i64,
12624        Value::BigInt(n) => *n,
12625        _ => panic!("value_to_i64 called on non-integer Value"),
12626    }
12627}
12628
12629/// v7.17.0 Phase 3.10 — integer-mode generate_series materialiser.
12630/// Step direction follows the sign: positive step iterates upward
12631/// (stops when current > stop); negative iterates downward; zero
12632/// errors. Caller-facing row stream is `BigInt`-typed so a single
12633/// projection schema covers SmallInt / Int / BigInt callers.
12634fn generate_series_integers(
12635    start: i64,
12636    stop: i64,
12637    step: i64,
12638    cancel: &CancelToken<'_>,
12639) -> Result<alloc::vec::Vec<Row>, EngineError> {
12640    if step == 0 {
12641        return Err(EngineError::Unsupported(
12642            "generate_series(): step argument cannot be zero".into(),
12643        ));
12644    }
12645    let mut out = alloc::vec::Vec::new();
12646    let mut cur = start;
12647    // Hard cap to keep a runaway call from eating all memory. PG
12648    // has no such cap but does honour query timeout; SPG's cancel
12649    // token will fire too — this is a defense-in-depth backstop.
12650    const MAX_ROWS: usize = 10_000_000;
12651    loop {
12652        cancel.check()?;
12653        if step > 0 && cur > stop {
12654            break;
12655        }
12656        if step < 0 && cur < stop {
12657            break;
12658        }
12659        out.push(Row::new(alloc::vec![Value::BigInt(cur)]));
12660        if out.len() > MAX_ROWS {
12661            return Err(EngineError::Unsupported(alloc::format!(
12662                "generate_series(): exceeded {MAX_ROWS} rows; \
12663                 narrow start/stop or use a larger step"
12664            )));
12665        }
12666        cur = match cur.checked_add(step) {
12667            Some(n) => n,
12668            None => break,
12669        };
12670    }
12671    Ok(out)
12672}
12673
12674/// v7.17.0 Phase 3.10 — timestamp-mode generate_series. step is a
12675/// `Value::Interval { months, micros }` per the caller's guard;
12676/// each iteration adds the interval via `apply_binary_interval`
12677/// so month-shifting handles short-month rollover (PG semantics).
12678fn generate_series_timestamps(
12679    start: i64,
12680    stop: i64,
12681    step: Value,
12682    cancel: &CancelToken<'_>,
12683) -> Result<alloc::vec::Vec<Row>, EngineError> {
12684    let (months, micros) = match &step {
12685        Value::Interval { months, micros } => (*months, *micros),
12686        _ => unreachable!("caller guards step.is_interval"),
12687    };
12688    if months == 0 && micros == 0 {
12689        return Err(EngineError::Unsupported(
12690            "generate_series(): INTERVAL step cannot be zero".into(),
12691        ));
12692    }
12693    let ascending = months > 0 || micros > 0;
12694    let mut out = alloc::vec::Vec::new();
12695    let mut cur = Value::Timestamp(start);
12696    const MAX_ROWS: usize = 10_000_000;
12697    loop {
12698        cancel.check()?;
12699        let cur_t = match cur {
12700            Value::Timestamp(t) => t,
12701            _ => unreachable!("loop invariant: cur is Timestamp"),
12702        };
12703        if ascending && cur_t > stop {
12704            break;
12705        }
12706        if !ascending && cur_t < stop {
12707            break;
12708        }
12709        out.push(Row::new(alloc::vec![Value::Timestamp(cur_t)]));
12710        if out.len() > MAX_ROWS {
12711            return Err(EngineError::Unsupported(alloc::format!(
12712                "generate_series(): exceeded {MAX_ROWS} rows; \
12713                 narrow start/stop or use a larger step"
12714            )));
12715        }
12716        let next = eval::apply_binary_interval(
12717            spg_sql::ast::BinOp::Add,
12718            &cur,
12719            &Value::Interval { months, micros },
12720        )
12721        .map_err(EngineError::Eval)?;
12722        cur = match next {
12723            Some(v) => v,
12724            None => break,
12725        };
12726    }
12727    Ok(out)
12728}
12729
12730#[allow(clippy::match_same_arms)] // explicit arms per type document the supported pairs
12731fn value_cmp(a: &Value, b: &Value) -> core::cmp::Ordering {
12732    use core::cmp::Ordering;
12733    match (a, b) {
12734        (Value::Null, Value::Null) => Ordering::Equal,
12735        (Value::Null, _) => Ordering::Less,
12736        (_, Value::Null) => Ordering::Greater,
12737        (Value::Int(x), Value::Int(y)) => x.cmp(y),
12738        (Value::BigInt(x), Value::BigInt(y)) => x.cmp(y),
12739        (Value::SmallInt(x), Value::SmallInt(y)) => x.cmp(y),
12740        (Value::Text(x), Value::Text(y)) => x.cmp(y),
12741        (Value::Bool(x), Value::Bool(y)) => x.cmp(y),
12742        (Value::Float(x), Value::Float(y)) => x.partial_cmp(y).unwrap_or(Ordering::Equal),
12743        (Value::Date(x), Value::Date(y)) => x.cmp(y),
12744        (Value::Timestamp(x), Value::Timestamp(y)) => x.cmp(y),
12745        // Cross-type compare: fall back to the debug rendering —
12746        // same-partition is the goal, exact order is irrelevant.
12747        _ => alloc::format!("{a:?}").cmp(&alloc::format!("{b:?}")),
12748    }
12749}
12750
12751/// Compute the window function's per-row output for one partition.
12752/// `slice` has (partition key, order key, original-row-index)
12753/// tuples already sorted by order key. `filtered_rows` is the
12754/// full row list indexed by original-row-index. `out_vals` is
12755/// the destination, also indexed by original-row-index.
12756#[allow(
12757    clippy::too_many_arguments,
12758    clippy::cast_possible_truncation,
12759    clippy::cast_possible_wrap,
12760    clippy::cast_precision_loss,
12761    clippy::cast_sign_loss,
12762    clippy::doc_markdown,
12763    clippy::too_many_lines,
12764    clippy::type_complexity,
12765    clippy::match_same_arms
12766)]
12767fn compute_window_partition(
12768    name: &str,
12769    args: &[Expr],
12770    ordered: bool,
12771    frame: Option<&WindowFrame>,
12772    null_treatment: spg_sql::ast::NullTreatment,
12773    slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)],
12774    filtered_rows: &[&Row],
12775    ctx: &EvalContext<'_>,
12776    out_vals: &mut [Value],
12777) -> Result<(), EngineError> {
12778    let ignore_nulls = matches!(null_treatment, spg_sql::ast::NullTreatment::Ignore);
12779    let lower = name.to_ascii_lowercase();
12780    match lower.as_str() {
12781        "row_number" => {
12782            for (rank, (_, _, idx)) in slice.iter().enumerate() {
12783                out_vals[*idx] = Value::BigInt((rank + 1) as i64);
12784            }
12785            Ok(())
12786        }
12787        "rank" => {
12788            let mut prev_key: Option<&[(Value, bool)]> = None;
12789            let mut current_rank: i64 = 1;
12790            for (i, (_, okey, idx)) in slice.iter().enumerate() {
12791                if let Some(p) = prev_key
12792                    && order_key_cmp(p, okey) != core::cmp::Ordering::Equal
12793                {
12794                    current_rank = (i + 1) as i64;
12795                }
12796                if prev_key.is_none() {
12797                    current_rank = 1;
12798                }
12799                out_vals[*idx] = Value::BigInt(current_rank);
12800                prev_key = Some(okey.as_slice());
12801            }
12802            Ok(())
12803        }
12804        "dense_rank" => {
12805            let mut prev_key: Option<&[(Value, bool)]> = None;
12806            let mut current_rank: i64 = 0;
12807            for (_, okey, idx) in slice {
12808                if prev_key.is_none_or(|p| order_key_cmp(p, okey) != core::cmp::Ordering::Equal) {
12809                    current_rank += 1;
12810                }
12811                out_vals[*idx] = Value::BigInt(current_rank);
12812                prev_key = Some(okey.as_slice());
12813            }
12814            Ok(())
12815        }
12816        "sum" | "avg" | "min" | "max" | "count" | "count_star" => {
12817            // Pre-evaluate the function arg per row in the slice
12818            // (count_star has no arg).
12819            let arg_values: Vec<Value> = if lower == "count_star" || args.is_empty() {
12820                slice.iter().map(|_| Value::Null).collect()
12821            } else {
12822                slice
12823                    .iter()
12824                    .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
12825                    .collect::<Result<_, _>>()
12826                    .map_err(EngineError::Eval)?
12827            };
12828            // v4.20: pick the effective frame. Explicit frame
12829            // overrides the implicit default (running for ordered,
12830            // whole-partition for unordered).
12831            let eff = effective_frame(frame, ordered)?;
12832            #[allow(clippy::needless_range_loop)]
12833            for i in 0..slice.len() {
12834                let (lo, hi) = frame_bounds_for_row(&eff, i, slice);
12835                let mut sum: f64 = 0.0;
12836                let mut count: i64 = 0;
12837                let mut min_v: Option<f64> = None;
12838                let mut max_v: Option<f64> = None;
12839                let mut row_count: i64 = 0;
12840                if lo <= hi {
12841                    for j in lo..=hi {
12842                        let v = &arg_values[j];
12843                        match lower.as_str() {
12844                            "count_star" => row_count += 1,
12845                            "count" => {
12846                                if !v.is_null() {
12847                                    count += 1;
12848                                }
12849                            }
12850                            _ => {
12851                                if let Some(x) = value_to_f64(v) {
12852                                    sum += x;
12853                                    count += 1;
12854                                    min_v = Some(min_v.map_or(x, |m| m.min(x)));
12855                                    max_v = Some(max_v.map_or(x, |m| m.max(x)));
12856                                }
12857                            }
12858                        }
12859                    }
12860                }
12861                let value = match lower.as_str() {
12862                    "count_star" => Value::BigInt(row_count),
12863                    "count" => Value::BigInt(count),
12864                    "sum" => Value::Float(sum),
12865                    "avg" => {
12866                        if count == 0 {
12867                            Value::Null
12868                        } else {
12869                            Value::Float(sum / count as f64)
12870                        }
12871                    }
12872                    "min" => min_v.map_or(Value::Null, Value::Float),
12873                    "max" => max_v.map_or(Value::Null, Value::Float),
12874                    _ => unreachable!(),
12875                };
12876                let (_, _, idx) = &slice[i];
12877                out_vals[*idx] = value;
12878            }
12879            Ok(())
12880        }
12881        "lag" | "lead" => {
12882            // lag(expr [, offset [, default]])
12883            // lead(expr [, offset [, default]])
12884            if args.is_empty() {
12885                return Err(EngineError::Unsupported(alloc::format!(
12886                    "{lower}() requires at least one argument"
12887                )));
12888            }
12889            let offset: i64 = if args.len() >= 2 {
12890                let v = eval::eval_expr(&args[1], filtered_rows[slice[0].2], ctx)
12891                    .map_err(EngineError::Eval)?;
12892                match v {
12893                    Value::SmallInt(n) => i64::from(n),
12894                    Value::Int(n) => i64::from(n),
12895                    Value::BigInt(n) => n,
12896                    _ => {
12897                        return Err(EngineError::Unsupported(alloc::format!(
12898                            "{lower}() offset must be integer"
12899                        )));
12900                    }
12901                }
12902            } else {
12903                1
12904            };
12905            let default: Value = if args.len() >= 3 {
12906                eval::eval_expr(&args[2], filtered_rows[slice[0].2], ctx)
12907                    .map_err(EngineError::Eval)?
12908            } else {
12909                Value::Null
12910            };
12911            let values: Vec<Value> = slice
12912                .iter()
12913                .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
12914                .collect::<Result<_, _>>()
12915                .map_err(EngineError::Eval)?;
12916            let n = slice.len();
12917            for (i, (_, _, idx)) in slice.iter().enumerate() {
12918                let signed_offset = if lower == "lag" { -offset } else { offset };
12919                let v = if ignore_nulls {
12920                    // v6.4.2 — IGNORE NULLS: walk in the offset direction
12921                    // skipping NULL values; the `offset`-th non-NULL
12922                    // encountered is the result.
12923                    let step: i64 = if signed_offset >= 0 { 1 } else { -1 };
12924                    let needed: i64 = signed_offset.abs();
12925                    if needed == 0 {
12926                        values[i].clone()
12927                    } else {
12928                        let mut j: i64 = i as i64;
12929                        let mut hits: i64 = 0;
12930                        let mut found: Option<Value> = None;
12931                        loop {
12932                            j += step;
12933                            if j < 0 || j >= n as i64 {
12934                                break;
12935                            }
12936                            #[allow(clippy::cast_sign_loss)]
12937                            let v = &values[j as usize];
12938                            if !v.is_null() {
12939                                hits += 1;
12940                                if hits == needed {
12941                                    found = Some(v.clone());
12942                                    break;
12943                                }
12944                            }
12945                        }
12946                        found.unwrap_or_else(|| default.clone())
12947                    }
12948                } else {
12949                    let target_signed = i64::try_from(i).unwrap_or(i64::MAX) + signed_offset;
12950                    if target_signed < 0 || target_signed >= i64::try_from(n).unwrap_or(i64::MAX) {
12951                        default.clone()
12952                    } else {
12953                        #[allow(clippy::cast_sign_loss)]
12954                        {
12955                            values[target_signed as usize].clone()
12956                        }
12957                    }
12958                };
12959                out_vals[*idx] = v;
12960            }
12961            Ok(())
12962        }
12963        "first_value" | "last_value" | "nth_value" => {
12964            if args.is_empty() {
12965                return Err(EngineError::Unsupported(alloc::format!(
12966                    "{lower}() requires at least one argument"
12967                )));
12968            }
12969            let values: Vec<Value> = slice
12970                .iter()
12971                .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
12972                .collect::<Result<_, _>>()
12973                .map_err(EngineError::Eval)?;
12974            let nth: usize = if lower == "nth_value" {
12975                if args.len() < 2 {
12976                    return Err(EngineError::Unsupported(
12977                        "nth_value() requires (expr, n)".into(),
12978                    ));
12979                }
12980                let v = eval::eval_expr(&args[1], filtered_rows[slice[0].2], ctx)
12981                    .map_err(EngineError::Eval)?;
12982                let raw = match v {
12983                    Value::SmallInt(n) => i64::from(n),
12984                    Value::Int(n) => i64::from(n),
12985                    Value::BigInt(n) => n,
12986                    _ => {
12987                        return Err(EngineError::Unsupported(
12988                            "nth_value() n must be integer".into(),
12989                        ));
12990                    }
12991                };
12992                if raw < 1 {
12993                    return Err(EngineError::Unsupported(
12994                        "nth_value() n must be >= 1".into(),
12995                    ));
12996                }
12997                #[allow(clippy::cast_sign_loss)]
12998                {
12999                    raw as usize
13000                }
13001            } else {
13002                0
13003            };
13004            let eff = effective_frame(frame, ordered)?;
13005            for i in 0..slice.len() {
13006                let (lo, hi) = frame_bounds_for_row(&eff, i, slice);
13007                let (_, _, idx) = &slice[i];
13008                let v = if lo > hi {
13009                    Value::Null
13010                } else if ignore_nulls && matches!(lower.as_str(), "first_value" | "last_value") {
13011                    // v6.4.2 — IGNORE NULLS: skip NULL cells when
13012                    // selecting the boundary value within the frame.
13013                    if lower == "first_value" {
13014                        (lo..=hi)
13015                            .find_map(|j| {
13016                                let v = &values[j];
13017                                (!v.is_null()).then(|| v.clone())
13018                            })
13019                            .unwrap_or(Value::Null)
13020                    } else {
13021                        (lo..=hi)
13022                            .rev()
13023                            .find_map(|j| {
13024                                let v = &values[j];
13025                                (!v.is_null()).then(|| v.clone())
13026                            })
13027                            .unwrap_or(Value::Null)
13028                    }
13029                } else {
13030                    match lower.as_str() {
13031                        "first_value" => values[lo].clone(),
13032                        "last_value" => values[hi].clone(),
13033                        "nth_value" => {
13034                            let pos = lo + nth - 1;
13035                            if pos > hi {
13036                                Value::Null
13037                            } else {
13038                                values[pos].clone()
13039                            }
13040                        }
13041                        _ => unreachable!(),
13042                    }
13043                };
13044                out_vals[*idx] = v;
13045            }
13046            Ok(())
13047        }
13048        "ntile" => {
13049            if args.is_empty() {
13050                return Err(EngineError::Unsupported(
13051                    "ntile(n) requires an integer argument".into(),
13052                ));
13053            }
13054            let v = eval::eval_expr(&args[0], filtered_rows[slice[0].2], ctx)
13055                .map_err(EngineError::Eval)?;
13056            let bucket_count: i64 = match v {
13057                Value::SmallInt(n) => i64::from(n),
13058                Value::Int(n) => i64::from(n),
13059                Value::BigInt(n) => n,
13060                _ => {
13061                    return Err(EngineError::Unsupported(
13062                        "ntile() argument must be integer".into(),
13063                    ));
13064                }
13065            };
13066            if bucket_count < 1 {
13067                return Err(EngineError::Unsupported(
13068                    "ntile() argument must be >= 1".into(),
13069                ));
13070            }
13071            #[allow(clippy::cast_sign_loss)]
13072            let buckets = bucket_count as usize;
13073            let n = slice.len();
13074            // Each bucket gets `base` rows; the first `extras` buckets
13075            // get one extra. PG semantics.
13076            let base = n / buckets;
13077            let extras = n % buckets;
13078            let mut bucket: usize = 1;
13079            let mut remaining_in_bucket = if extras > 0 { base + 1 } else { base };
13080            let mut buckets_with_extra_remaining = extras;
13081            for (_, _, idx) in slice {
13082                if remaining_in_bucket == 0 {
13083                    bucket += 1;
13084                    buckets_with_extra_remaining = buckets_with_extra_remaining.saturating_sub(1);
13085                    remaining_in_bucket = if buckets_with_extra_remaining > 0 {
13086                        base + 1
13087                    } else {
13088                        base
13089                    };
13090                    // Edge: if base==0 and extras==0, all rows fit;
13091                    // shouldn't reach here, but guard anyway.
13092                    if remaining_in_bucket == 0 {
13093                        remaining_in_bucket = 1;
13094                    }
13095                }
13096                out_vals[*idx] = Value::BigInt(i64::try_from(bucket).unwrap_or(i64::MAX));
13097                remaining_in_bucket -= 1;
13098            }
13099            Ok(())
13100        }
13101        "percent_rank" => {
13102            // (rank - 1) / (n - 1) where rank is the standard RANK().
13103            // Single-row partitions get 0.
13104            let n = slice.len();
13105            let mut prev_key: Option<&[(Value, bool)]> = None;
13106            let mut current_rank: i64 = 1;
13107            for (i, (_, okey, idx)) in slice.iter().enumerate() {
13108                if let Some(p) = prev_key
13109                    && order_key_cmp(p, okey) != core::cmp::Ordering::Equal
13110                {
13111                    current_rank = i64::try_from(i + 1).unwrap_or(i64::MAX);
13112                }
13113                if prev_key.is_none() {
13114                    current_rank = 1;
13115                }
13116                #[allow(clippy::cast_precision_loss)]
13117                let pr = if n <= 1 {
13118                    0.0
13119                } else {
13120                    (current_rank - 1) as f64 / (n - 1) as f64
13121                };
13122                out_vals[*idx] = Value::Float(pr);
13123                prev_key = Some(okey.as_slice());
13124            }
13125            Ok(())
13126        }
13127        "cume_dist" => {
13128            // # rows up to and including this row's peer group / n.
13129            let n = slice.len();
13130            // First pass: find peer-group-end rank for each row.
13131            for i in 0..slice.len() {
13132                let peer_end = peer_group_end(slice, i);
13133                #[allow(clippy::cast_precision_loss)]
13134                let cd = (peer_end + 1) as f64 / n as f64;
13135                let (_, _, idx) = &slice[i];
13136                out_vals[*idx] = Value::Float(cd);
13137            }
13138            Ok(())
13139        }
13140        other => Err(EngineError::Unsupported(alloc::format!(
13141            "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)"
13142        ))),
13143    }
13144}
13145
13146/// v4.20: resolve the user-provided frame down to a normalised
13147/// `(kind, start, end)`. `None` means default — derive from
13148/// `ordered`: ordered ⇒ RANGE UNBOUNDED PRECEDING AND CURRENT ROW,
13149/// unordered ⇒ ROWS UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
13150/// Single-bound shorthand (e.g. `ROWS 5 PRECEDING`) normalises
13151/// end → CURRENT ROW per the PG spec.
13152fn effective_frame(
13153    frame: Option<&WindowFrame>,
13154    ordered: bool,
13155) -> Result<(FrameKind, FrameBound, FrameBound), EngineError> {
13156    match frame {
13157        None => {
13158            if ordered {
13159                Ok((
13160                    FrameKind::Range,
13161                    FrameBound::UnboundedPreceding,
13162                    FrameBound::CurrentRow,
13163                ))
13164            } else {
13165                Ok((
13166                    FrameKind::Rows,
13167                    FrameBound::UnboundedPreceding,
13168                    FrameBound::UnboundedFollowing,
13169                ))
13170            }
13171        }
13172        Some(fr) => {
13173            let end = fr.end.clone().unwrap_or(FrameBound::CurrentRow);
13174            // Reject start > end (a few impossible combinations).
13175            if matches!(fr.start, FrameBound::UnboundedFollowing)
13176                || matches!(end, FrameBound::UnboundedPreceding)
13177            {
13178                return Err(EngineError::Unsupported(alloc::format!(
13179                    "invalid frame: start={:?} end={:?}",
13180                    fr.start,
13181                    end
13182                )));
13183            }
13184            // RANGE OFFSET PRECEDING / FOLLOWING needs value-typed
13185            // arithmetic on the ORDER BY key (e.g. `RANGE BETWEEN
13186            // INTERVAL '1 day' PRECEDING AND CURRENT ROW`). Not
13187            // implemented in v4.20.
13188            if fr.kind == FrameKind::Range
13189                && (matches!(
13190                    fr.start,
13191                    FrameBound::OffsetPreceding(_) | FrameBound::OffsetFollowing(_)
13192                ) || matches!(
13193                    end,
13194                    FrameBound::OffsetPreceding(_) | FrameBound::OffsetFollowing(_)
13195                ))
13196            {
13197                return Err(EngineError::Unsupported(
13198                    "RANGE with explicit offset bounds is not supported (v4.20: only UNBOUNDED / CURRENT ROW for RANGE)".into(),
13199                ));
13200            }
13201            Ok((fr.kind, fr.start.clone(), end))
13202        }
13203    }
13204}
13205
13206/// Compute `(lo, hi)` row-index bounds inside the partition slice
13207/// for the row at position `i`. Inclusive, clamped to
13208/// `[0, slice.len()-1]`. Empty result if `lo > hi`.
13209#[allow(clippy::type_complexity)]
13210fn frame_bounds_for_row(
13211    eff: &(FrameKind, FrameBound, FrameBound),
13212    i: usize,
13213    slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)],
13214) -> (usize, usize) {
13215    let (kind, start, end) = eff;
13216    let n = slice.len();
13217    let last = n.saturating_sub(1);
13218    let (mut lo, mut hi) = match kind {
13219        FrameKind::Rows => {
13220            let lo = match start {
13221                FrameBound::UnboundedPreceding => 0,
13222                FrameBound::OffsetPreceding(k) => {
13223                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
13224                    i.saturating_sub(k)
13225                }
13226                FrameBound::CurrentRow => i,
13227                FrameBound::OffsetFollowing(k) => {
13228                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
13229                    i.saturating_add(k).min(last)
13230                }
13231                FrameBound::UnboundedFollowing => last,
13232            };
13233            let hi = match end {
13234                FrameBound::UnboundedPreceding => 0,
13235                FrameBound::OffsetPreceding(k) => {
13236                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
13237                    i.saturating_sub(k)
13238                }
13239                FrameBound::CurrentRow => i,
13240                FrameBound::OffsetFollowing(k) => {
13241                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
13242                    i.saturating_add(k).min(last)
13243                }
13244                FrameBound::UnboundedFollowing => last,
13245            };
13246            (lo, hi)
13247        }
13248        FrameKind::Range => {
13249            // RANGE bounds are peer-aware. With only UNBOUNDED and
13250            // CURRENT ROW supported (rejected at effective_frame for
13251            // explicit offsets), the start/end map to the
13252            // partition's full extent at the same-order-key peer
13253            // group boundary.
13254            let lo = match start {
13255                FrameBound::UnboundedPreceding => 0,
13256                FrameBound::CurrentRow => peer_group_start(slice, i),
13257                FrameBound::UnboundedFollowing => last,
13258                _ => unreachable!("offset bounds rejected for RANGE"),
13259            };
13260            let hi = match end {
13261                FrameBound::UnboundedPreceding => 0,
13262                FrameBound::CurrentRow => peer_group_end(slice, i),
13263                FrameBound::UnboundedFollowing => last,
13264                _ => unreachable!("offset bounds rejected for RANGE"),
13265            };
13266            (lo, hi)
13267        }
13268    };
13269    if hi >= n {
13270        hi = last;
13271    }
13272    if lo >= n {
13273        lo = last;
13274    }
13275    (lo, hi)
13276}
13277
13278/// Find the inclusive index of the first row with the same ORDER
13279/// BY key as `slice[i]`. Slice is already sorted by partition then
13280/// order, so peers are contiguous.
13281#[allow(clippy::type_complexity)]
13282fn peer_group_start(slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)], i: usize) -> usize {
13283    let key = &slice[i].1;
13284    let mut j = i;
13285    while j > 0 && order_key_cmp(&slice[j - 1].1, key) == core::cmp::Ordering::Equal {
13286        j -= 1;
13287    }
13288    j
13289}
13290
13291/// Find the inclusive index of the last row with the same ORDER
13292/// BY key as `slice[i]`.
13293#[allow(clippy::type_complexity)]
13294fn peer_group_end(slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)], i: usize) -> usize {
13295    let key = &slice[i].1;
13296    let mut j = i;
13297    while j + 1 < slice.len() && order_key_cmp(&slice[j + 1].1, key) == core::cmp::Ordering::Equal {
13298        j += 1;
13299    }
13300    j
13301}
13302
13303fn value_to_f64(v: &Value) -> Option<f64> {
13304    match v {
13305        Value::SmallInt(n) => Some(f64::from(*n)),
13306        Value::Int(n) => Some(f64::from(*n)),
13307        #[allow(clippy::cast_precision_loss)]
13308        Value::BigInt(n) => Some(*n as f64),
13309        Value::Float(x) => Some(*x),
13310        _ => None,
13311    }
13312}
13313
13314/// Quick scan for any subquery-bearing node in a SELECT's WHERE /
13315/// projection / `order_by` — saves cloning the AST when there are
13316/// none (the common case).
13317fn expr_tree_has_subquery(stmt: &SelectStatement) -> bool {
13318    let mut any = false;
13319    for item in &stmt.items {
13320        if let SelectItem::Expr { expr, .. } = item {
13321            any = any || expr_has_subquery(expr);
13322        }
13323    }
13324    if let Some(w) = &stmt.where_ {
13325        any = any || expr_has_subquery(w);
13326    }
13327    if let Some(h) = &stmt.having {
13328        any = any || expr_has_subquery(h);
13329    }
13330    for o in &stmt.order_by {
13331        any = any || expr_has_subquery(&o.expr);
13332    }
13333    for (_, peer) in &stmt.unions {
13334        any = any || expr_tree_has_subquery(peer);
13335    }
13336    any
13337}
13338
13339fn expr_has_subquery(e: &Expr) -> bool {
13340    match e {
13341        Expr::ScalarSubquery(_) | Expr::Exists { .. } | Expr::InSubquery { .. } => true,
13342        Expr::Binary { lhs, rhs, .. } => expr_has_subquery(lhs) || expr_has_subquery(rhs),
13343        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13344            expr_has_subquery(expr)
13345        }
13346        Expr::FunctionCall { args, .. } => args.iter().any(expr_has_subquery),
13347        Expr::Like { expr, pattern, .. } => expr_has_subquery(expr) || expr_has_subquery(pattern),
13348        Expr::Extract { source, .. } => expr_has_subquery(source),
13349        Expr::WindowFunction {
13350            args,
13351            partition_by,
13352            order_by,
13353            ..
13354        } => {
13355            args.iter().any(expr_has_subquery)
13356                || partition_by.iter().any(expr_has_subquery)
13357                || order_by.iter().any(|(e, _)| expr_has_subquery(e))
13358        }
13359        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => false,
13360        Expr::Array(items) => items.iter().any(expr_has_subquery),
13361        Expr::ArraySubscript { target, index } => {
13362            expr_has_subquery(target) || expr_has_subquery(index)
13363        }
13364        Expr::AnyAll { expr, array, .. } => expr_has_subquery(expr) || expr_has_subquery(array),
13365        Expr::Case {
13366            operand,
13367            branches,
13368            else_branch,
13369        } => {
13370            operand.as_deref().is_some_and(expr_has_subquery)
13371                || branches
13372                    .iter()
13373                    .any(|(w, t)| expr_has_subquery(w) || expr_has_subquery(t))
13374                || else_branch.as_deref().is_some_and(expr_has_subquery)
13375        }
13376    }
13377}
13378
13379/// v4.10 helper: materialise a runtime `Value` back into an AST
13380/// `Expr::Literal` for the subquery-rewrite path. Supports the
13381/// types `Literal` can represent (Integer / Float / Text / Bool /
13382/// Null). Date / Timestamp / Numeric / Vector / Interval / JSON
13383/// would lose precision through Literal and aren't supported in
13384/// uncorrelated-subquery results; they error with a clear hint.
13385fn value_to_literal_expr(v: Value) -> Result<Expr, EngineError> {
13386    let lit = match v {
13387        Value::Null => Literal::Null,
13388        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
13389        Value::Int(n) => Literal::Integer(i64::from(n)),
13390        Value::BigInt(n) => Literal::Integer(n),
13391        Value::Float(x) => Literal::Float(x),
13392        Value::Text(s) | Value::Json(s) => Literal::String(s),
13393        Value::Bool(b) => Literal::Bool(b),
13394        other => {
13395            return Err(EngineError::Unsupported(alloc::format!(
13396                "subquery result type {:?} not yet materialisable; cast to text or integer in the inner SELECT",
13397                other.data_type()
13398            )));
13399        }
13400    };
13401    Ok(Expr::Literal(lit))
13402}
13403
13404/// v7.13.0 — wider helper used by `INSERT … SELECT` (mailrs
13405/// round-5 G4). Covers the most common `Value` variants. Types
13406/// that need lossy textual round-trip (BYTEA, arrays, ts*)
13407/// surface as an Unsupported error so the caller can add a cast
13408/// in the inner SELECT.
13409fn value_to_literal_expr_permissive(v: Value) -> Result<Expr, EngineError> {
13410    let lit = match v {
13411        Value::Null => Literal::Null,
13412        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
13413        Value::Int(n) => Literal::Integer(i64::from(n)),
13414        Value::BigInt(n) => Literal::Integer(n),
13415        Value::Float(x) => Literal::Float(x),
13416        Value::Text(s) | Value::Json(s) => Literal::String(s),
13417        Value::Bool(b) => Literal::Bool(b),
13418        Value::Vector(xs) => Literal::Vector(xs),
13419        // Date / Timestamp / Timestamptz / Numeric round-trip
13420        // through a TEXT literal that `coerce_value` re-parses
13421        // against the target column type.
13422        Value::Date(days) => {
13423            let micros = (i64::from(days)) * 86_400_000_000;
13424            Literal::String(format_timestamp_micros_as_date(micros))
13425        }
13426        Value::Timestamp(us) => Literal::String(format_timestamp_micros(us)),
13427        Value::Numeric { scaled, scale } => Literal::String(format_numeric(scaled, scale)),
13428        other => {
13429            return Err(EngineError::Unsupported(alloc::format!(
13430                "INSERT … SELECT cannot materialise value of type {:?}; \
13431                 add an explicit CAST in the inner SELECT",
13432                other.data_type()
13433            )));
13434        }
13435    };
13436    Ok(Expr::Literal(lit))
13437}
13438
13439fn format_timestamp_micros(us: i64) -> String {
13440    // Same Y/M/D split used by the wire layer; epoch-relative.
13441    let days = us.div_euclid(86_400_000_000);
13442    let intra_day = us.rem_euclid(86_400_000_000);
13443    let date = format_timestamp_micros_as_date(days * 86_400_000_000);
13444    let secs = intra_day / 1_000_000;
13445    let us_rem = intra_day % 1_000_000;
13446    let h = (secs / 3600) % 24;
13447    let m = (secs / 60) % 60;
13448    let s = secs % 60;
13449    if us_rem == 0 {
13450        alloc::format!("{date} {h:02}:{m:02}:{s:02}")
13451    } else {
13452        alloc::format!("{date} {h:02}:{m:02}:{s:02}.{us_rem:06}")
13453    }
13454}
13455
13456fn format_timestamp_micros_as_date(us: i64) -> String {
13457    // Days since 1970-01-01 → calendar Y-M-D via the proleptic
13458    // Gregorian conversion used by spg-engine's date helpers.
13459    let days = us.div_euclid(86_400_000_000);
13460    // 1970-01-01 = JDN 2440588.
13461    let jdn = days + 2_440_588;
13462    let (y, mo, d) = jdn_to_ymd(jdn);
13463    alloc::format!("{y:04}-{mo:02}-{d:02}")
13464}
13465
13466fn jdn_to_ymd(jdn: i64) -> (i64, u32, u32) {
13467    // Fliegel & Van Flandern (1968) — works for all positive JDNs.
13468    let l = jdn + 68569;
13469    let n = (4 * l) / 146_097;
13470    let l = l - (146_097 * n + 3) / 4;
13471    let i = (4000 * (l + 1)) / 1_461_001;
13472    let l = l - (1461 * i) / 4 + 31;
13473    let j = (80 * l) / 2447;
13474    let day = (l - (2447 * j) / 80) as u32;
13475    let l = j / 11;
13476    let month = (j + 2 - 12 * l) as u32;
13477    let year = 100 * (n - 49) + i + l;
13478    (year, month, day)
13479}
13480
13481fn format_numeric(scaled: i128, scale: u8) -> String {
13482    if scale == 0 {
13483        return alloc::format!("{scaled}");
13484    }
13485    let abs = scaled.unsigned_abs();
13486    let divisor = 10u128.pow(u32::from(scale));
13487    let whole = abs / divisor;
13488    let frac = abs % divisor;
13489    let sign = if scaled < 0 { "-" } else { "" };
13490    alloc::format!("{sign}{whole}.{frac:0width$}", width = usize::from(scale))
13491}
13492
13493/// v6.1.1 — walk the prepared `Statement` AST and replace every
13494/// `Expr::Placeholder(n)` with `Expr::Literal(value_to_literal(
13495/// params[n-1]))`. The dispatch downstream sees a `Statement`
13496/// indistinguishable from a simple-query parse, so the exec path
13497/// stays unchanged.
13498///
13499/// Errors fall into one shape: a `$N` references past the bound
13500/// `params.len()`. Out-of-range happens when the Bind didn't
13501/// supply enough values; pgwire surfaces this as a protocol error
13502/// to the client.
13503/// v7.15.0 — rewrite every (potentially-qualified) column
13504/// identifier matching `old` to `new` in a stored SQL source
13505/// string. Used by `ALTER TABLE … RENAME COLUMN` to patch
13506/// CHECK predicate sources, partial-index predicate sources,
13507/// and runtime DEFAULT expression sources before they get
13508/// re-parsed on the next INSERT/UPDATE.
13509///
13510/// Round-trips through the parser, so the rewritten output is
13511/// the canonical Display form (matches what the engine stores
13512/// for fresh predicates). If the source doesn't parse, surfaces
13513/// the parse error — the invariant that stored predicates are
13514/// in canonical Display form means a parse failure here is a
13515/// real bug, not a user mistake to swallow.
13516fn rewrite_column_in_source(
13517    src: &str,
13518    old: &str,
13519    new: &str,
13520) -> Result<alloc::string::String, EngineError> {
13521    let mut expr = spg_sql::parser::parse_expression(src).map_err(|e| {
13522        EngineError::Unsupported(alloc::format!(
13523            "ALTER TABLE RENAME COLUMN: stored predicate source {src:?} \
13524             failed to parse for rewrite ({e})"
13525        ))
13526    })?;
13527    rewrite_column_in_expr(&mut expr, old, new);
13528    Ok(alloc::format!("{expr}"))
13529}
13530
13531/// v7.15.0 — Expr walker that swaps `Expr::Column { name: old, .. }`
13532/// for `Expr::Column { name: new, .. }`. Qualifier is preserved
13533/// (e.g. `t.old` → `t.new`); a foreign-table qualifier still
13534/// gets rewritten because the AST has no way to tell us this
13535/// predicate is on table T versus table T2 — predicate sources
13536/// in SPG are always scoped to the owning table, so any
13537/// qualifier present is either redundant or wrong.
13538fn rewrite_column_in_expr(e: &mut Expr, old: &str, new: &str) {
13539    match e {
13540        Expr::Column(c) => {
13541            if c.name.eq_ignore_ascii_case(old) {
13542                c.name = new.to_string();
13543            }
13544        }
13545        Expr::Binary { lhs, rhs, .. } => {
13546            rewrite_column_in_expr(lhs, old, new);
13547            rewrite_column_in_expr(rhs, old, new);
13548        }
13549        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13550            rewrite_column_in_expr(expr, old, new);
13551        }
13552        Expr::FunctionCall { args, .. } => {
13553            for a in args {
13554                rewrite_column_in_expr(a, old, new);
13555            }
13556        }
13557        Expr::Like { expr, pattern, .. } => {
13558            rewrite_column_in_expr(expr, old, new);
13559            rewrite_column_in_expr(pattern, old, new);
13560        }
13561        Expr::Extract { source, .. } => rewrite_column_in_expr(source, old, new),
13562        Expr::WindowFunction {
13563            args,
13564            partition_by,
13565            order_by,
13566            ..
13567        } => {
13568            for a in args {
13569                rewrite_column_in_expr(a, old, new);
13570            }
13571            for p in partition_by {
13572                rewrite_column_in_expr(p, old, new);
13573            }
13574            for (o, _) in order_by {
13575                rewrite_column_in_expr(o, old, new);
13576            }
13577        }
13578        Expr::Array(items) => {
13579            for elem in items {
13580                rewrite_column_in_expr(elem, old, new);
13581            }
13582        }
13583        Expr::ArraySubscript { target, index } => {
13584            rewrite_column_in_expr(target, old, new);
13585            rewrite_column_in_expr(index, old, new);
13586        }
13587        Expr::AnyAll { expr, array, .. } => {
13588            rewrite_column_in_expr(expr, old, new);
13589            rewrite_column_in_expr(array, old, new);
13590        }
13591        Expr::Case {
13592            operand,
13593            branches,
13594            else_branch,
13595        } => {
13596            if let Some(o) = operand {
13597                rewrite_column_in_expr(o, old, new);
13598            }
13599            for (w, t) in branches {
13600                rewrite_column_in_expr(w, old, new);
13601                rewrite_column_in_expr(t, old, new);
13602            }
13603            if let Some(e) = else_branch {
13604                rewrite_column_in_expr(e, old, new);
13605            }
13606        }
13607        // Stored predicate sources never contain subqueries —
13608        // CHECK / partial-index / runtime_default are all scalar.
13609        // If a future feature changes that, recurse here.
13610        Expr::ScalarSubquery(_) | Expr::Exists { .. } | Expr::InSubquery { .. } => {}
13611        Expr::Literal(_) | Expr::Placeholder(_) => {}
13612    }
13613}
13614
13615/// v7.16.0 — walks a parsed statement and replaces every
13616/// `Expr::Placeholder(N)` with the corresponding `params[N-1]`
13617/// re-encoded as an `Expr::Literal`. Used internally by
13618/// `Engine::execute_prepared` AND surfaced for the spg-embedded
13619/// WAL path (which needs the bind-final AST so replay sees a
13620/// simple-query-shaped statement, not a `$1`-shaped one). Errors
13621/// when a placeholder references an index past the params slice.
13622pub fn substitute_placeholders(stmt: &mut Statement, params: &[Value]) -> Result<(), EngineError> {
13623    match stmt {
13624        Statement::Select(s) => substitute_select(s, params)?,
13625        Statement::Insert(ins) => {
13626            for row in &mut ins.rows {
13627                for e in row {
13628                    substitute_expr(e, params)?;
13629                }
13630            }
13631            // ON CONFLICT DO UPDATE assignments / WHERE can carry
13632            // placeholders too (`… DO UPDATE SET reason = $2` —
13633            // mailrs embed round-12).
13634            if let Some(clause) = &mut ins.on_conflict
13635                && let spg_sql::ast::OnConflictAction::Update {
13636                    assignments,
13637                    where_,
13638                } = &mut clause.action
13639            {
13640                for (_, e) in assignments.iter_mut() {
13641                    substitute_expr(e, params)?;
13642                }
13643                if let Some(w) = where_ {
13644                    substitute_expr(w, params)?;
13645                }
13646            }
13647        }
13648        Statement::Update(u) => {
13649            for (_, e) in &mut u.assignments {
13650                substitute_expr(e, params)?;
13651            }
13652            if let Some(w) = &mut u.where_ {
13653                substitute_expr(w, params)?;
13654            }
13655        }
13656        Statement::Delete(d) => {
13657            if let Some(w) = &mut d.where_ {
13658                substitute_expr(w, params)?;
13659            }
13660        }
13661        Statement::Explain(e) => substitute_select(&mut e.inner, params)?,
13662        // Other statements (CREATE / BEGIN / SHOW / …) have no
13663        // expression slots; no walk needed.
13664        _ => {}
13665    }
13666    Ok(())
13667}
13668
13669fn substitute_select(s: &mut SelectStatement, params: &[Value]) -> Result<(), EngineError> {
13670    for item in &mut s.items {
13671        if let SelectItem::Expr { expr, .. } = item {
13672            substitute_expr(expr, params)?;
13673        }
13674    }
13675    if let Some(w) = &mut s.where_ {
13676        substitute_expr(w, params)?;
13677    }
13678    if let Some(gs) = &mut s.group_by {
13679        for g in gs {
13680            substitute_expr(g, params)?;
13681        }
13682    }
13683    if let Some(h) = &mut s.having {
13684        substitute_expr(h, params)?;
13685    }
13686    for o in &mut s.order_by {
13687        substitute_expr(&mut o.expr, params)?;
13688    }
13689    for (_, peer) in &mut s.unions {
13690        substitute_select(peer, params)?;
13691    }
13692    // v7.9.24 — LIMIT $N / OFFSET $N placeholder resolution.
13693    // mailrs H2. After this pass each LIMIT/OFFSET that was a
13694    // Placeholder is rewritten to Literal so the existing
13695    // `LimitExpr::as_literal` path consumes a concrete u32.
13696    if let Some(le) = s.limit {
13697        s.limit = Some(resolve_limit_placeholder(le, params)?);
13698    }
13699    if let Some(le) = s.offset {
13700        s.offset = Some(resolve_limit_placeholder(le, params)?);
13701    }
13702    Ok(())
13703}
13704
13705fn resolve_limit_placeholder(
13706    le: spg_sql::ast::LimitExpr,
13707    params: &[Value],
13708) -> Result<spg_sql::ast::LimitExpr, EngineError> {
13709    use spg_sql::ast::LimitExpr;
13710    match le {
13711        LimitExpr::Literal(_) => Ok(le),
13712        LimitExpr::Placeholder(n) => {
13713            let idx = usize::from(n).saturating_sub(1);
13714            let v = params.get(idx).ok_or_else(|| {
13715                EngineError::Eval(EvalError::PlaceholderOutOfRange {
13716                    n,
13717                    bound: u16::try_from(params.len()).unwrap_or(u16::MAX),
13718                })
13719            })?;
13720            let int = match v {
13721                Value::SmallInt(x) => Some(i64::from(*x)),
13722                Value::Int(x) => Some(i64::from(*x)),
13723                Value::BigInt(x) => Some(*x),
13724                _ => None,
13725            }
13726            .ok_or_else(|| {
13727                EngineError::Unsupported(alloc::format!(
13728                    "LIMIT/OFFSET ${n} bound to non-integer {v:?}"
13729                ))
13730            })?;
13731            if int < 0 {
13732                return Err(EngineError::Unsupported(alloc::format!(
13733                    "LIMIT/OFFSET ${n} bound to negative value {int}"
13734                )));
13735            }
13736            let bounded = u32::try_from(int).map_err(|_| {
13737                EngineError::Unsupported(alloc::format!(
13738                    "LIMIT/OFFSET ${n} value {int} exceeds u32 range"
13739                ))
13740            })?;
13741            Ok(LimitExpr::Literal(bounded))
13742        }
13743    }
13744}
13745
13746fn substitute_expr(e: &mut Expr, params: &[Value]) -> Result<(), EngineError> {
13747    if let Expr::Placeholder(n) = e {
13748        let idx = usize::from(*n).saturating_sub(1);
13749        let v = params.get(idx).ok_or_else(|| {
13750            EngineError::Eval(EvalError::PlaceholderOutOfRange {
13751                n: *n,
13752                bound: u16::try_from(params.len()).unwrap_or(u16::MAX),
13753            })
13754        })?;
13755        *e = Expr::Literal(value_to_literal(v.clone()));
13756        return Ok(());
13757    }
13758    match e {
13759        Expr::Binary { lhs, rhs, .. } => {
13760            substitute_expr(lhs, params)?;
13761            substitute_expr(rhs, params)?;
13762        }
13763        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13764            substitute_expr(expr, params)?;
13765        }
13766        Expr::FunctionCall { args, .. } => {
13767            for a in args {
13768                substitute_expr(a, params)?;
13769            }
13770        }
13771        Expr::Like { expr, pattern, .. } => {
13772            substitute_expr(expr, params)?;
13773            substitute_expr(pattern, params)?;
13774        }
13775        Expr::Extract { source, .. } => substitute_expr(source, params)?,
13776        Expr::ScalarSubquery(s) => substitute_select(s, params)?,
13777        Expr::Exists { subquery, .. } => substitute_select(subquery, params)?,
13778        Expr::InSubquery { expr, subquery, .. } => {
13779            substitute_expr(expr, params)?;
13780            substitute_select(subquery, params)?;
13781        }
13782        Expr::WindowFunction {
13783            args,
13784            partition_by,
13785            order_by,
13786            ..
13787        } => {
13788            for a in args {
13789                substitute_expr(a, params)?;
13790            }
13791            for p in partition_by {
13792                substitute_expr(p, params)?;
13793            }
13794            for (e, _) in order_by {
13795                substitute_expr(e, params)?;
13796            }
13797        }
13798        Expr::Literal(_) | Expr::Column(_) => {}
13799        // Already handled above.
13800        Expr::Placeholder(_) => unreachable!("Placeholder handled at top of fn"),
13801        Expr::Array(items) => {
13802            for elem in items {
13803                substitute_expr(elem, params)?;
13804            }
13805        }
13806        Expr::ArraySubscript { target, index } => {
13807            substitute_expr(target, params)?;
13808            substitute_expr(index, params)?;
13809        }
13810        Expr::AnyAll { expr, array, .. } => {
13811            substitute_expr(expr, params)?;
13812            substitute_expr(array, params)?;
13813        }
13814        Expr::Case {
13815            operand,
13816            branches,
13817            else_branch,
13818        } => {
13819            if let Some(o) = operand {
13820                substitute_expr(o, params)?;
13821            }
13822            for (w, t) in branches {
13823                substitute_expr(w, params)?;
13824                substitute_expr(t, params)?;
13825            }
13826            if let Some(e) = else_branch {
13827                substitute_expr(e, params)?;
13828            }
13829        }
13830    }
13831    Ok(())
13832}
13833
13834/// v6.1.1 — convert a runtime `Value` into the closest matching
13835/// `Literal` for the substitute walker. Lossless for the simple
13836/// scalars (Int / Float / Text / Bool); Numeric / Date / Timestamp
13837/// / Json / Interval render as their canonical text form so the
13838/// downstream coerce_value can re-parse against the target column
13839/// type. SQ8 / HalfVector cells are NOT expected as bind params;
13840/// pgwire's Bind decodes vector params to the f32 representation
13841/// before they reach this helper.
13842/// v6.2.0 — total ordering on `Value`s used by ANALYZE to sort a
13843/// column's non-NULL sample before histogram building. Cross-type
13844/// pairs (Int vs Float, Date vs Timestamp, …) compare via the
13845/// same widening the eval-side `compare` operator uses; everything
13846/// else (the genuinely-incompatible pairs) falls back to ordering
13847/// by canonical string form so the sort is still total + stable.
13848/// Vector / SQ8 / Half / Json / Numeric / Interval values reach
13849/// here only via the string-fallback path because vector columns
13850/// are filtered out upstream.
13851fn sort_values_for_histogram(a: &Value, b: &Value) -> core::cmp::Ordering {
13852    use core::cmp::Ordering;
13853    match (a, b) {
13854        (Value::SmallInt(a), Value::SmallInt(b)) => a.cmp(b),
13855        (Value::Int(a), Value::Int(b)) => a.cmp(b),
13856        (Value::BigInt(a), Value::BigInt(b)) => a.cmp(b),
13857        (Value::SmallInt(a), Value::Int(b)) => i32::from(*a).cmp(b),
13858        (Value::Int(a), Value::SmallInt(b)) => a.cmp(&i32::from(*b)),
13859        (Value::Int(a), Value::BigInt(b)) => i64::from(*a).cmp(b),
13860        (Value::BigInt(a), Value::Int(b)) => a.cmp(&i64::from(*b)),
13861        (Value::SmallInt(a), Value::BigInt(b)) => i64::from(*a).cmp(b),
13862        (Value::BigInt(a), Value::SmallInt(b)) => a.cmp(&i64::from(*b)),
13863        (Value::Float(a), Value::Float(b)) => a.partial_cmp(b).unwrap_or(Ordering::Equal),
13864        (Value::Text(a), Value::Text(b)) | (Value::Json(a), Value::Json(b)) => a.cmp(b),
13865        (Value::Bool(a), Value::Bool(b)) => a.cmp(b),
13866        (Value::Date(a), Value::Date(b)) => a.cmp(b),
13867        (Value::Timestamp(a), Value::Timestamp(b)) => a.cmp(b),
13868        // Mixed numeric/float — widen to f64 and compare.
13869        (Value::SmallInt(n), Value::Float(x)) => {
13870            (f64::from(*n)).partial_cmp(x).unwrap_or(Ordering::Equal)
13871        }
13872        (Value::Float(x), Value::SmallInt(n)) => {
13873            x.partial_cmp(&f64::from(*n)).unwrap_or(Ordering::Equal)
13874        }
13875        (Value::Int(n), Value::Float(x)) => {
13876            (f64::from(*n)).partial_cmp(x).unwrap_or(Ordering::Equal)
13877        }
13878        (Value::Float(x), Value::Int(n)) => {
13879            x.partial_cmp(&f64::from(*n)).unwrap_or(Ordering::Equal)
13880        }
13881        (Value::BigInt(n), Value::Float(x)) => {
13882            #[allow(clippy::cast_precision_loss)]
13883            let nf = *n as f64;
13884            nf.partial_cmp(x).unwrap_or(Ordering::Equal)
13885        }
13886        (Value::Float(x), Value::BigInt(n)) => {
13887            #[allow(clippy::cast_precision_loss)]
13888            let nf = *n as f64;
13889            x.partial_cmp(&nf).unwrap_or(Ordering::Equal)
13890        }
13891        // Cross-type fallback: lexicographic on canonical form.
13892        // Total + stable so the sort is well-defined.
13893        _ => canonical_value_repr(a).cmp(&canonical_value_repr(b)),
13894    }
13895}
13896
13897/// v6.2.0 — render the histogram bounds list as a `[v0, v1, ...]`
13898/// string for the `spg_statistic.histogram_bounds` column. Values
13899/// containing `,` or `[` / `]` are JSON-style escaped so the
13900/// rendering round-trips through a future parser; v6.2.0 only
13901/// uses the rendered form for human consumption, so the escaping
13902/// is conservative.
13903fn render_histogram_bounds(bounds: &[alloc::string::String]) -> alloc::string::String {
13904    let mut out = alloc::string::String::with_capacity(bounds.len() * 8 + 2);
13905    out.push('[');
13906    for (i, b) in bounds.iter().enumerate() {
13907        if i > 0 {
13908            out.push_str(", ");
13909        }
13910        let needs_quote = b.contains([',', '[', ']', '"']) || b.is_empty();
13911        if needs_quote {
13912            out.push('"');
13913            for ch in b.chars() {
13914                if ch == '"' || ch == '\\' {
13915                    out.push('\\');
13916                }
13917                out.push(ch);
13918            }
13919            out.push('"');
13920        } else {
13921            out.push_str(b);
13922        }
13923    }
13924    out.push(']');
13925    out
13926}
13927
13928/// v6.2.0 — canonical textual form of a `Value` for histogram
13929/// bound storage. Strings used by ANALYZE for sort + bound output.
13930/// INT / BIGINT → decimal; FLOAT → shortest-round-trip via
13931/// `{:?}`; TEXT pass-through; BOOL → `t` / `f`; DATE / TIMESTAMP →
13932/// the same form `format_date` / `format_timestamp` produce for
13933/// SQL Display. Vector / SQ8 / Half / Json / Numeric / Interval
13934/// reach this only via a non-Vector column (vector columns are
13935/// skipped upstream); they fall back to a Debug-derived form so
13936/// stats still serialise without crashing.
13937pub(crate) fn canonical_value_repr(v: &Value) -> alloc::string::String {
13938    match v {
13939        Value::Null => "NULL".to_string(),
13940        Value::SmallInt(n) => alloc::format!("{n}"),
13941        Value::Int(n) => alloc::format!("{n}"),
13942        Value::BigInt(n) => alloc::format!("{n}"),
13943        Value::Float(x) => alloc::format!("{x:?}"),
13944        Value::Text(s) | Value::Json(s) => s.clone(),
13945        Value::Bool(b) => if *b { "t" } else { "f" }.to_string(),
13946        Value::Date(d) => eval::format_date(*d),
13947        Value::Timestamp(t) => eval::format_timestamp(*t),
13948        // v7.17.0 Phase 3.P0-32 — PG TIME canonical text form.
13949        Value::Time(us) => eval::format_time(*us),
13950        // v7.17.0 Phase 3.P0-33 — MySQL YEAR 4-digit zero-padded.
13951        Value::Year(y) => alloc::format!("{y:04}"),
13952        // v7.17.0 Phase 3.P0-34 — PG TIMETZ canonical text form.
13953        Value::TimeTz { us, offset_secs } => eval::format_timetz(*us, *offset_secs),
13954        // v7.17.0 Phase 3.P0-35 — PG MONEY canonical en_US text form.
13955        Value::Money(c) => eval::format_money(*c),
13956        // v7.17.0 Phase 3.P0-38 — PG range canonical text form.
13957        v @ Value::Range { .. } => format_range_str(v),
13958        // v7.17.0 Phase 3.P0-39 — PG hstore canonical text form.
13959        Value::Hstore(pairs) => format_hstore_str(pairs),
13960        // v7.17.0 Phase 3.P0-40 — 2D array canonical text form.
13961        Value::IntArray2D(rows) => format_int_2d_text(rows),
13962        Value::BigIntArray2D(rows) => format_bigint_2d_text(rows),
13963        Value::TextArray2D(rows) => format_text_2d_text(rows),
13964        Value::Interval { months, micros } => eval::format_interval(*months, *micros),
13965        Value::Numeric { scaled, scale } => eval::format_numeric(*scaled, *scale),
13966        Value::Vector(_) | Value::Sq8Vector(_) | Value::HalfVector(_) => {
13967            // Unreachable in practice (vector columns are filtered
13968            // out before this). Defensive fallback so a future
13969            // vector-stats path doesn't crash.
13970            alloc::format!("{v:?}")
13971        }
13972        // v7.5.0 — Value is #[non_exhaustive] for downstream
13973        // forward-compat. Future variants fall through to Debug
13974        // form here (same shape as the vector fallback above).
13975        _ => alloc::format!("{v:?}"),
13976    }
13977}
13978
13979/// v6.2.0 — true for engine-managed catalog tables that the bare
13980/// `ANALYZE` (no target) should skip. v6.2.0 has no internal
13981/// tables yet (publications / subscriptions / users / statistics
13982/// all live as engine fields, not catalog tables), so this is a
13983/// reserved future-proofing hook — every existing user table is
13984/// analysed.
13985const fn is_internal_table_name(_name: &str) -> bool {
13986    false
13987}
13988
13989fn value_to_literal(v: Value) -> Literal {
13990    match v {
13991        Value::Null => Literal::Null,
13992        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
13993        Value::Int(n) => Literal::Integer(i64::from(n)),
13994        Value::BigInt(n) => Literal::Integer(n),
13995        Value::Float(x) => Literal::Float(x),
13996        Value::Text(s) | Value::Json(s) => Literal::String(s),
13997        Value::Bool(b) => Literal::Bool(b),
13998        Value::Vector(v) => Literal::Vector(v),
13999        Value::Numeric { scaled, scale } => Literal::String(eval::format_numeric(scaled, scale)),
14000        Value::Date(d) => Literal::String(eval::format_date(d)),
14001        Value::Timestamp(t) => Literal::String(eval::format_timestamp(t)),
14002        // v7.17.0 Phase 3.P0-69 — UUID round-trips via canonical
14003        // hyphenated text. Without this arm the fallback below
14004        // renders `Debug` form ("Uuid([85, …])") which the
14005        // engine's Text → Uuid coerce can't parse, breaking
14006        // prepared-bind round-trip from the spg-sqlx adapter.
14007        Value::Uuid(b) => Literal::String(spg_storage::format_uuid(&b)),
14008        // v7.16.0 — BYTEA round-trip for the spg-sqlx Bind path.
14009        // PG-canonical text rep is `\x` + lowercase hex; the
14010        // engine's coerce_value already accepts that on the
14011        // text → bytea direction.
14012        Value::Bytes(b) => Literal::String(eval::format_bytea_hex(&b)),
14013        // Arrays ride the AST natively (mailrs embed round-12) —
14014        // the prior `{a,b,c}` text form only worked where a column
14015        // type drove the re-parse; `= ANY($1)` has no column
14016        // context and saw a bare Text value.
14017        Value::TextArray(items) => Literal::TextArray(items),
14018        Value::IntArray(items) => Literal::IntArray(items),
14019        Value::BigIntArray(items) => Literal::BigIntArray(items),
14020        Value::Interval { months, micros } => Literal::Interval {
14021            months,
14022            micros,
14023            text: eval::format_interval(months, micros),
14024        },
14025        // SQ8 / halfvec cells dequantise to f32 before reaching the
14026        // substitute walker; pgwire's Bind path handles that.
14027        Value::Sq8Vector(q) => Literal::Vector(spg_storage::quantize::dequantize(&q)),
14028        Value::HalfVector(h) => Literal::Vector(h.to_f32_vec()),
14029        // v7.5.0 — Value is #[non_exhaustive]; future variants
14030        // render as Debug-form String literal until explicit
14031        // mapping is added.
14032        v => Literal::String(alloc::format!("{v:?}")),
14033    }
14034}
14035
14036fn rewrite_clock_calls(stmt: &mut Statement, now_micros: Option<i64>) {
14037    let Some(now) = now_micros else {
14038        return;
14039    };
14040    match stmt {
14041        Statement::Select(s) => rewrite_select_clock(s, now),
14042        Statement::Insert(ins) => {
14043            for row in &mut ins.rows {
14044                for e in row {
14045                    rewrite_expr_clock(e, now);
14046                }
14047            }
14048            // `ON CONFLICT … DO UPDATE SET created_at = NOW()` —
14049            // the upsert assignments carry clock calls too (mailrs
14050            // embed round-12).
14051            if let Some(clause) = &mut ins.on_conflict
14052                && let spg_sql::ast::OnConflictAction::Update {
14053                    assignments,
14054                    where_,
14055                } = &mut clause.action
14056            {
14057                for (_, e) in assignments.iter_mut() {
14058                    rewrite_expr_clock(e, now);
14059                }
14060                if let Some(w) = where_ {
14061                    rewrite_expr_clock(w, now);
14062                }
14063            }
14064        }
14065        // `UPDATE … SET seen_at = NOW() WHERE …` / `DELETE … WHERE
14066        // ts < NOW()` (mailrs embed round-12 — previously only
14067        // SELECT / INSERT-rows were walked).
14068        Statement::Update(u) => {
14069            for (_, e) in &mut u.assignments {
14070                rewrite_expr_clock(e, now);
14071            }
14072            if let Some(w) = &mut u.where_ {
14073                rewrite_expr_clock(w, now);
14074            }
14075        }
14076        Statement::Delete(d) => {
14077            if let Some(w) = &mut d.where_ {
14078                rewrite_expr_clock(w, now);
14079            }
14080        }
14081        _ => {}
14082    }
14083}
14084
14085fn rewrite_select_clock(s: &mut SelectStatement, now: i64) {
14086    for item in &mut s.items {
14087        if let SelectItem::Expr { expr, .. } = item {
14088            rewrite_expr_clock(expr, now);
14089        }
14090    }
14091    if let Some(w) = &mut s.where_ {
14092        rewrite_expr_clock(w, now);
14093    }
14094    if let Some(gs) = &mut s.group_by {
14095        for g in gs {
14096            rewrite_expr_clock(g, now);
14097        }
14098    }
14099    if let Some(h) = &mut s.having {
14100        rewrite_expr_clock(h, now);
14101    }
14102    for o in &mut s.order_by {
14103        rewrite_expr_clock(&mut o.expr, now);
14104    }
14105    for (_, peer) in &mut s.unions {
14106        rewrite_select_clock(peer, now);
14107    }
14108}
14109
14110/// v3.0.3 hot path: every recursion lands in exactly one `match` arm.
14111/// Literal / Column-with-qualifier (the dominant cases on a typical
14112/// AST) take a single pattern dispatch and exit. The clock-rewrite
14113/// targets (zero-arg `NOW` / `CURRENT_TIMESTAMP` / `CURRENT_DATE`
14114/// functions, and bare `CURRENT_TIMESTAMP` / `CURRENT_DATE` column
14115/// refs) sit on their own arms with match guards so the fall-through
14116/// to the recursive arms is unambiguous.
14117fn rewrite_expr_clock(e: &mut Expr, now: i64) {
14118    // Fast-path test on the no-recursion shapes first. We can't fold
14119    // them into the big match below because they need to *replace* `e`
14120    // outright; the recursive arms below match on its sub-fields.
14121    if let Some(replacement) = clock_replacement_for(e, now) {
14122        *e = replacement;
14123        return;
14124    }
14125    match e {
14126        Expr::Binary { lhs, rhs, .. } => {
14127            rewrite_expr_clock(lhs, now);
14128            rewrite_expr_clock(rhs, now);
14129        }
14130        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
14131            rewrite_expr_clock(expr, now);
14132        }
14133        Expr::FunctionCall { args, .. } => {
14134            for a in args {
14135                rewrite_expr_clock(a, now);
14136            }
14137        }
14138        Expr::Like { expr, pattern, .. } => {
14139            rewrite_expr_clock(expr, now);
14140            rewrite_expr_clock(pattern, now);
14141        }
14142        Expr::Extract { source, .. } => rewrite_expr_clock(source, now),
14143        // v4.10 subquery nodes — recurse into the inner SELECT's
14144        // expression slots so e.g. SELECT NOW() in a scalar
14145        // subquery picks up the same instant as the outer query.
14146        Expr::ScalarSubquery(s) => rewrite_select_clock(s, now),
14147        Expr::Exists { subquery, .. } => rewrite_select_clock(subquery, now),
14148        Expr::InSubquery { expr, subquery, .. } => {
14149            rewrite_expr_clock(expr, now);
14150            rewrite_select_clock(subquery, now);
14151        }
14152        // v4.12 window functions — args + PARTITION BY + ORDER BY
14153        // may all reference clock literals.
14154        Expr::WindowFunction {
14155            args,
14156            partition_by,
14157            order_by,
14158            ..
14159        } => {
14160            for a in args {
14161                rewrite_expr_clock(a, now);
14162            }
14163            for p in partition_by {
14164                rewrite_expr_clock(p, now);
14165            }
14166            for (e, _) in order_by {
14167                rewrite_expr_clock(e, now);
14168            }
14169        }
14170        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => {}
14171        Expr::Array(items) => {
14172            for elem in items {
14173                rewrite_expr_clock(elem, now);
14174            }
14175        }
14176        Expr::ArraySubscript { target, index } => {
14177            rewrite_expr_clock(target, now);
14178            rewrite_expr_clock(index, now);
14179        }
14180        Expr::AnyAll { expr, array, .. } => {
14181            rewrite_expr_clock(expr, now);
14182            rewrite_expr_clock(array, now);
14183        }
14184        Expr::Case {
14185            operand,
14186            branches,
14187            else_branch,
14188        } => {
14189            if let Some(o) = operand {
14190                rewrite_expr_clock(o, now);
14191            }
14192            for (w, t) in branches {
14193                rewrite_expr_clock(w, now);
14194                rewrite_expr_clock(t, now);
14195            }
14196            if let Some(e) = else_branch {
14197                rewrite_expr_clock(e, now);
14198            }
14199        }
14200    }
14201}
14202
14203/// Returns `Some(Expr)` when `e` is one of the clock-call shapes that
14204/// must be rewritten; otherwise `None` so the caller falls through to
14205/// the recursive walk. Identifies both function-call forms (`NOW()` /
14206/// `CURRENT_TIMESTAMP()` / `CURRENT_DATE()`) and bare-identifier forms
14207/// (`CURRENT_TIMESTAMP` / `CURRENT_DATE` as unqualified column refs,
14208/// which is how PG accepts them without parens).
14209fn clock_replacement_for(e: &Expr, now: i64) -> Option<Expr> {
14210    let (kind, name) = match e {
14211        Expr::FunctionCall { name, args } if args.is_empty() => (ClockSite::Fn, name.as_str()),
14212        Expr::Column(c) if c.qualifier.is_none() => (ClockSite::BareIdent, c.name.as_str()),
14213        _ => return None,
14214    };
14215    // ASCII case-insensitive name match. Each entry decides what
14216    // synthetic literal the call expands to.
14217    //
14218    // v7.17.0 Phase 3.P0-29 — `unix_timestamp` (no args) joins this
14219    // table as MySQL's epoch-seconds equivalent of `now()`. Folded
14220    // to a BigInt literal here so apply_function never needs a
14221    // clock dependency.
14222    enum ClockShape {
14223        Timestamp,
14224        Date,
14225        UnixSeconds,
14226    }
14227    let shape = match name.len() {
14228        3 if kind == ClockSite::Fn && name.eq_ignore_ascii_case("now") => {
14229            Some(ClockShape::Timestamp)
14230        }
14231        12 if name.eq_ignore_ascii_case("current_date") => Some(ClockShape::Date),
14232        14 if kind == ClockSite::Fn && name.eq_ignore_ascii_case("unix_timestamp") => {
14233            Some(ClockShape::UnixSeconds)
14234        }
14235        17 if name.eq_ignore_ascii_case("current_timestamp") => Some(ClockShape::Timestamp),
14236        _ => None,
14237    };
14238    let shape = shape?;
14239    let payload = match shape {
14240        ClockShape::Timestamp => now,
14241        ClockShape::Date => now.div_euclid(86_400_000_000),
14242        ClockShape::UnixSeconds => now.div_euclid(1_000_000),
14243    };
14244    let target = match shape {
14245        ClockShape::Timestamp => spg_sql::ast::CastTarget::Timestamp,
14246        ClockShape::Date => spg_sql::ast::CastTarget::Date,
14247        ClockShape::UnixSeconds => spg_sql::ast::CastTarget::BigInt,
14248    };
14249    Some(Expr::Cast {
14250        expr: alloc::boxed::Box::new(Expr::Literal(spg_sql::ast::Literal::Integer(payload))),
14251        target,
14252    })
14253}
14254
14255#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14256enum ClockSite {
14257    Fn,
14258    BareIdent,
14259}
14260
14261/// `ORDER BY <integer>` references the N-th SELECT item (1-based).
14262/// Swap the integer literal for the matching item's expression so the
14263/// executor doesn't need a special-case branch. Recurses into UNION
14264/// peers because each peer keeps its own SELECT list.
14265/// v6.4.1 — expand `GROUP BY ALL` to every non-aggregate SELECT-list
14266/// item. Mirrors DuckDB / PG 19 semantics. Wildcards (`SELECT * …`)
14267/// are NOT expanded by GROUP BY ALL (PG 19 leaves the wildcard intact
14268/// and groups by whatever explicit non-aggregates remain — none in
14269/// the wildcard-only case, which still works for non-aggregate
14270/// queries).
14271fn expand_group_by_all(s: &mut SelectStatement) {
14272    if !s.group_by_all {
14273        for (_, peer) in &mut s.unions {
14274            expand_group_by_all(peer);
14275        }
14276        return;
14277    }
14278    let mut groups: Vec<Expr> = Vec::new();
14279    for item in &s.items {
14280        if let SelectItem::Expr { expr, .. } = item
14281            && !aggregate::contains_aggregate(expr)
14282        {
14283            groups.push(expr.clone());
14284        }
14285    }
14286    s.group_by = Some(groups);
14287    s.group_by_all = false;
14288    for (_, peer) in &mut s.unions {
14289        expand_group_by_all(peer);
14290    }
14291}
14292
14293fn resolve_order_by_position(s: &mut SelectStatement) {
14294    // v6.4.0 — iterate every ORDER BY key. Position references
14295    // (`ORDER BY 2`) bind to the 1-based projection index;
14296    // identifier references that match a SELECT-list alias bind to
14297    // the projected expression (Step 4 of L3a).
14298    for order in &mut s.order_by {
14299        match &order.expr {
14300            Expr::Literal(Literal::Integer(n)) if *n >= 1 => {
14301                if let Ok(idx_one_based) = usize::try_from(*n) {
14302                    let idx = idx_one_based - 1;
14303                    if idx < s.items.len()
14304                        && let SelectItem::Expr { expr, .. } = &s.items[idx]
14305                    {
14306                        order.expr = expr.clone();
14307                    }
14308                }
14309            }
14310            Expr::Column(c) if c.qualifier.is_none() => {
14311                // Alias-in-ORDER-BY lookup.
14312                for item in &s.items {
14313                    if let SelectItem::Expr {
14314                        expr,
14315                        alias: Some(a),
14316                    } = item
14317                        && a == &c.name
14318                    {
14319                        order.expr = expr.clone();
14320                        break;
14321                    }
14322                }
14323            }
14324            _ => {}
14325        }
14326    }
14327    for (_, peer) in &mut s.unions {
14328        resolve_order_by_position(peer);
14329    }
14330}
14331
14332/// Sort `tagged` by `f64` key, reversing the comparator under DESC.
14333/// Used by the UNION ORDER BY path; per-block paths inline the same
14334/// comparator because they already hold `&OrderBy` directly.
14335/// v3.1.1: partial-sort helper. When `keep` (= offset + limit) is
14336/// strictly less than `tagged.len()`, run `select_nth_unstable_by` to
14337/// partition the prefix in O(n), then sort just that prefix in O(k
14338/// log k). Total O(n + k log k), vs O(n log n) for a full sort. The
14339/// caller decides what `keep` is; passing `None` (no LIMIT) keeps the
14340/// full-sort behaviour.
14341///
14342/// `tagged` holds `(Option<f64>, Row)` (the SELECT path) — `None` keys
14343/// sort last in ascending order, mirroring NULL-sorts-last in SQL.
14344fn partial_sort_tagged(tagged: &mut Vec<(Vec<f64>, Row)>, keep: Option<usize>, descs: &[bool]) {
14345    let cmp = |a: &(Vec<f64>, Row), b: &(Vec<f64>, Row)| cmp_multi_key(&a.0, &b.0, descs);
14346    match keep {
14347        Some(k) if k < tagged.len() && k > 0 => {
14348            let pivot = k - 1;
14349            tagged.select_nth_unstable_by(pivot, cmp);
14350            tagged[..k].sort_by(cmp);
14351            tagged.truncate(k);
14352        }
14353        _ => {
14354            tagged.sort_by(cmp);
14355        }
14356    }
14357}
14358
14359fn sort_by_keys(tagged: &mut [(Vec<f64>, Row)], descs: &[bool]) {
14360    tagged.sort_by(|a, b| cmp_multi_key(&a.0, &b.0, descs));
14361}
14362
14363/// v6.4.0 — multi-key ORDER BY comparator. Each key's per-key DESC
14364/// flag is honored independently. NULL is encoded as `f64::INFINITY`
14365/// so it sorts last in ASC and first in DESC (matches PG default).
14366fn cmp_multi_key(a: &[f64], b: &[f64], descs: &[bool]) -> core::cmp::Ordering {
14367    use core::cmp::Ordering;
14368    for (i, (ka, kb)) in a.iter().zip(b.iter()).enumerate() {
14369        let ord = ka.partial_cmp(kb).unwrap_or(Ordering::Equal);
14370        let ord = if descs.get(i).copied().unwrap_or(false) {
14371            ord.reverse()
14372        } else {
14373            ord
14374        };
14375        if ord != Ordering::Equal {
14376            return ord;
14377        }
14378    }
14379    Ordering::Equal
14380}
14381
14382/// v6.4.0 — eval every ORDER BY expression for a row and pack the
14383/// resulting keys into a `Vec<f64>`. NULL → `f64::INFINITY`.
14384fn build_order_keys(
14385    order_by: &[OrderBy],
14386    row: &Row,
14387    ctx: &EvalContext,
14388) -> Result<Vec<f64>, EngineError> {
14389    let mut keys = Vec::with_capacity(order_by.len());
14390    for o in order_by {
14391        let v = eval::eval_expr(&o.expr, row, ctx)?;
14392        keys.push(value_to_order_key(&v)?);
14393    }
14394    Ok(keys)
14395}
14396
14397/// Drop the first `offset` rows then truncate to `limit`. PG / `MySQL`
14398/// agree: OFFSET applies *after* ORDER BY but *before* LIMIT (so
14399/// `LIMIT 10 OFFSET 5` keeps rows 6..=15).
14400fn apply_offset_and_limit(rows: &mut Vec<Row>, offset: Option<u32>, limit: Option<u32>) {
14401    if let Some(off) = offset {
14402        let off = off as usize;
14403        if off >= rows.len() {
14404            rows.clear();
14405        } else {
14406            rows.drain(..off);
14407        }
14408    }
14409    if let Some(n) = limit {
14410        rows.truncate(n as usize);
14411    }
14412}
14413
14414/// v7.17.0 Phase 3.P0-49 — offset + limit applied to a tagged
14415/// `(order_keys, row)` sequence, with optional SQL:2008 `WITH
14416/// TIES` extension. When `with_ties` is set, the truncated tail
14417/// is extended through every subsequent row whose order keys
14418/// equal the last-kept row's keys (so a "top 3 by score" with
14419/// WITH TIES emits row 4 too when row 4 ties row 3 on `score`).
14420///
14421/// The order-key vector is the per-row sort key the caller already
14422/// computed via `build_order_keys`; equal-key detection therefore
14423/// matches the sort comparator exactly.
14424fn apply_offset_and_limit_tagged(
14425    tagged: &mut Vec<(Vec<f64>, Row)>,
14426    offset: Option<u32>,
14427    limit: Option<u32>,
14428    with_ties: bool,
14429) {
14430    if let Some(off) = offset {
14431        let off = off as usize;
14432        if off >= tagged.len() {
14433            tagged.clear();
14434        } else {
14435            tagged.drain(..off);
14436        }
14437    }
14438    if let Some(n) = limit {
14439        let n = n as usize;
14440        if with_ties && n > 0 && n < tagged.len() {
14441            let cutoff_key = tagged[n - 1].0.clone();
14442            let mut end = n;
14443            while end < tagged.len() && tagged[end].0 == cutoff_key {
14444                end += 1;
14445            }
14446            tagged.truncate(end);
14447        } else {
14448            tagged.truncate(n);
14449        }
14450    }
14451}
14452
14453/// v7.17.0 Phase 3.P0-49 — PG-canonical: `FETCH FIRST <n> ROWS
14454/// WITH TIES` requires an `ORDER BY`. Without one, there's no
14455/// way to identify "ties" deterministically, so PG errors at
14456/// plan time. SPG mirrors that surface so the same DDL / app
14457/// behaviour holds on cutover.
14458fn check_with_ties_requires_order_by(stmt: &SelectStatement) -> Result<(), EngineError> {
14459    if stmt.limit_with_ties && stmt.order_by.is_empty() {
14460        return Err(EngineError::Unsupported(alloc::string::String::from(
14461            "FETCH FIRST … ROWS WITH TIES requires an ORDER BY clause",
14462        )));
14463    }
14464    Ok(())
14465}
14466
14467/// v7.6.1 — resolve a parser-level `ForeignKeyConstraint` (column
14468/// names + parent table name) into the storage-layer shape (column
14469/// indices + same parent table). Validates everything the engine
14470/// needs to know about the FK at CREATE TABLE time:
14471///
14472///   - parent table exists (catalog lookup, unless self-referencing)
14473///   - parent columns exist on the parent table
14474///   - parent column list matches the local arity (defaults to the
14475///     parent's primary index column when omitted)
14476///   - parent columns are covered by a `BTree` UNIQUE-class index
14477///     (SPG's stand-in for `PRIMARY KEY`/`UNIQUE`) — required so
14478///     the v7.6.2 INSERT path can do an O(log n) parent lookup
14479///   - local columns exist on the table being created
14480fn resolve_foreign_key(
14481    local_table_name: &str,
14482    local_cols: &[ColumnSchema],
14483    fk: spg_sql::ast::ForeignKeyConstraint,
14484    catalog: &Catalog,
14485) -> Result<spg_storage::ForeignKeyConstraint, EngineError> {
14486    // Resolve local columns.
14487    let mut local_columns = Vec::with_capacity(fk.columns.len());
14488    for name in &fk.columns {
14489        let pos = local_cols
14490            .iter()
14491            .position(|c| c.name == *name)
14492            .ok_or_else(|| {
14493                EngineError::Unsupported(alloc::format!(
14494                    "FOREIGN KEY references unknown local column {name:?}"
14495                ))
14496            })?;
14497        local_columns.push(pos);
14498    }
14499    // Self-referencing FK: parent table is the one we're creating.
14500    // The parent column resolution uses the local column list since
14501    // the catalog doesn't have this table yet.
14502    let is_self_ref = fk.parent_table == local_table_name;
14503    let (parent_cols_for_lookup, parent_table_str): (&[ColumnSchema], &str) = if is_self_ref {
14504        (local_cols, local_table_name)
14505    } else {
14506        let parent_table = catalog.get(&fk.parent_table).ok_or_else(|| {
14507            EngineError::Storage(StorageError::TableNotFound {
14508                name: fk.parent_table.clone(),
14509            })
14510        })?;
14511        (
14512            parent_table.schema().columns.as_slice(),
14513            fk.parent_table.as_str(),
14514        )
14515    };
14516    // Resolve parent column names → positions. If the FK omitted the
14517    // parent column list, fall back to the parent's primary index
14518    // column (single-column only — composite default is rejected
14519    // because there's no unambiguous "PK" in SPG's index list).
14520    let parent_columns: Vec<usize> = if fk.parent_columns.is_empty() {
14521        if fk.columns.len() != 1 {
14522            return Err(EngineError::Unsupported(
14523                "composite FOREIGN KEY without explicit parent column list is not supported \
14524                 — list the parent columns explicitly"
14525                    .into(),
14526            ));
14527        }
14528        // Find a single BTree index on the parent and use its column.
14529        let pos = pick_pk_index_column(catalog, parent_table_str, is_self_ref, local_cols)
14530            .ok_or_else(|| {
14531                EngineError::Unsupported(alloc::format!(
14532                    "parent table {parent_table_str:?} has no PRIMARY-key / UNIQUE BTree index \
14533                     to default the FOREIGN KEY against"
14534                ))
14535            })?;
14536        alloc::vec![pos]
14537    } else {
14538        let mut out = Vec::with_capacity(fk.parent_columns.len());
14539        for name in &fk.parent_columns {
14540            let pos = parent_cols_for_lookup
14541                .iter()
14542                .position(|c| c.name == *name)
14543                .ok_or_else(|| {
14544                    EngineError::Unsupported(alloc::format!(
14545                        "FOREIGN KEY references unknown parent column \
14546                         {name:?} on table {parent_table_str:?}"
14547                    ))
14548                })?;
14549            out.push(pos);
14550        }
14551        out
14552    };
14553    if parent_columns.len() != local_columns.len() {
14554        return Err(EngineError::Unsupported(alloc::format!(
14555            "FOREIGN KEY arity mismatch: {} local columns vs {} parent columns",
14556            local_columns.len(),
14557            parent_columns.len()
14558        )));
14559    }
14560    // For non-self-referencing FKs, verify the parent column set is
14561    // covered by a BTree index. SPG doesn't have a `PRIMARY KEY`
14562    // declaration; the convention is "the parent column for FK
14563    // purposes must have a BTree index" — which the user creates via
14564    // `CREATE INDEX ... USING btree (col)` (the default). We accept
14565    // any single-column BTree index that covers a parent column;
14566    // composite parent column lists require an index whose `column_position`
14567    // matches the first parent column (multi-column BTree indices
14568    // are not in the v7.x roadmap).
14569    if !is_self_ref {
14570        let parent_table = catalog.get(&fk.parent_table).expect("checked above");
14571        let primary_parent_col = parent_columns[0];
14572        let has_btree = parent_table
14573            .schema()
14574            .columns
14575            .get(primary_parent_col)
14576            .is_some()
14577            && parent_table.indices().iter().any(|idx| {
14578                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14579                    && idx.column_position == primary_parent_col
14580                    && idx.partial_predicate.is_none()
14581            });
14582        if !has_btree {
14583            return Err(EngineError::Unsupported(alloc::format!(
14584                "FOREIGN KEY parent column on {:?} is not covered by an unconditional BTree \
14585                 index — create one with `CREATE INDEX ... ON {} ({})` first",
14586                parent_table_str,
14587                parent_table_str,
14588                parent_table.schema().columns[primary_parent_col].name,
14589            )));
14590        }
14591    }
14592    let on_delete = fk_action_sql_to_storage(fk.on_delete);
14593    let on_update = fk_action_sql_to_storage(fk.on_update);
14594    Ok(spg_storage::ForeignKeyConstraint {
14595        name: fk.name,
14596        local_columns,
14597        parent_table: fk.parent_table,
14598        parent_columns,
14599        on_delete,
14600        on_update,
14601    })
14602}
14603
14604/// v7.6.1 — pick a sentinel "primary key" column from the parent
14605/// table when the FK didn't name parent columns. Picks the first
14606/// single-column unconditional BTree index — that's the closest
14607/// thing SPG has to a PRIMARY KEY today. Self-referencing FKs use
14608/// `local_cols` as the column source.
14609fn pick_pk_index_column(
14610    catalog: &Catalog,
14611    parent_name: &str,
14612    is_self_ref: bool,
14613    local_cols: &[ColumnSchema],
14614) -> Option<usize> {
14615    if is_self_ref {
14616        // Self-ref FK omitted parent columns: pick column 0 by
14617        // convention (no catalog entry yet). Engine will widen this
14618        // when v7.6.7 lands; v7.6.1 only handles the explicit form.
14619        let _ = local_cols;
14620        return Some(0);
14621    }
14622    let parent = catalog.get(parent_name)?;
14623    parent.indices().iter().find_map(|idx| {
14624        if matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14625            && idx.partial_predicate.is_none()
14626            && idx.included_columns.is_empty()
14627            && idx.expression.is_none()
14628        {
14629            Some(idx.column_position)
14630        } else {
14631            None
14632        }
14633    })
14634}
14635
14636/// v7.9.8 / v7.9.10 — resolve the column positions that
14637/// identify a conflict for ON CONFLICT. Returns a Vec of
14638/// column positions (1 element for single-column form, N for
14639/// composite). When the user wrote bare `ON CONFLICT DO …`,
14640/// falls back to the table's first unconditional BTree index
14641/// (always single-column today).
14642fn resolve_on_conflict_columns(
14643    catalog: &Catalog,
14644    table_name: &str,
14645    target: &[String],
14646) -> Result<Vec<usize>, EngineError> {
14647    let table = catalog.get(table_name).ok_or_else(|| {
14648        EngineError::Storage(StorageError::TableNotFound {
14649            name: table_name.into(),
14650        })
14651    })?;
14652    if target.is_empty() {
14653        // v7.13.2 — mailrs round-6 S5 follow-up. Composite UNIQUE
14654        // constraints carry a multi-column tuple; the prior code
14655        // path picked only the leading column of the first BTree
14656        // index, which caused `ON CONFLICT DO NOTHING` to dedup
14657        // by leading column alone (3 rows with same group_id but
14658        // different permission collapsed to 1). PG semantics use
14659        // the full tuple. Prefer a UniquenessConstraint's full
14660        // column list when one exists; fall back to the leading
14661        // BTree column for legacy single-column UNIQUE.
14662        if let Some(uc) = table.schema().uniqueness_constraints.first() {
14663            return Ok(uc.columns.clone());
14664        }
14665        let pos = table
14666            .indices()
14667            .iter()
14668            .find_map(|idx| {
14669                if matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14670                    && idx.partial_predicate.is_none()
14671                    && idx.included_columns.is_empty()
14672                    && idx.expression.is_none()
14673                {
14674                    Some(idx.column_position)
14675                } else {
14676                    None
14677                }
14678            })
14679            .ok_or_else(|| {
14680                EngineError::Unsupported(alloc::format!(
14681                    "ON CONFLICT without target requires a UNIQUE BTree index on {table_name:?}"
14682                ))
14683            })?;
14684        return Ok(alloc::vec![pos]);
14685    }
14686    let mut out = Vec::with_capacity(target.len());
14687    for name in target {
14688        let pos = table
14689            .schema()
14690            .columns
14691            .iter()
14692            .position(|c| c.name == *name)
14693            .ok_or_else(|| {
14694                EngineError::Unsupported(alloc::format!(
14695                    "ON CONFLICT target column {name:?} not found on {table_name:?}"
14696                ))
14697            })?;
14698        out.push(pos);
14699    }
14700    Ok(out)
14701}
14702
14703/// v7.9.8 — check whether the BTree index on `column_pos` of
14704/// `table_name` already has a row with this key.
14705fn on_conflict_key_exists(
14706    catalog: &Catalog,
14707    table_name: &str,
14708    column_pos: usize,
14709    key: &Value,
14710) -> bool {
14711    let Some(table) = catalog.get(table_name) else {
14712        return false;
14713    };
14714    let Some(idx_key) = spg_storage::IndexKey::from_value(key) else {
14715        return false;
14716    };
14717    table.indices().iter().any(|idx| {
14718        matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14719            && idx.column_position == column_pos
14720            && idx.partial_predicate.is_none()
14721            && !idx.lookup_eq(&idx_key).is_empty()
14722    })
14723}
14724
14725/// v7.9.9 / v7.9.10 — look up an existing row's position by
14726/// matching all `column_positions` against the incoming `key`
14727/// tuple. Single-column shape (one column) reduces to the
14728/// canonical PK lookup; composite shapes scan linearly until
14729/// every position matches.
14730fn lookup_row_position_by_keys(
14731    catalog: &Catalog,
14732    table_name: &str,
14733    column_positions: &[usize],
14734    key: &[&Value],
14735) -> Option<usize> {
14736    let table = catalog.get(table_name)?;
14737    table.rows().iter().position(|r| {
14738        column_positions
14739            .iter()
14740            .enumerate()
14741            .all(|(i, &pos)| r.values.get(pos) == Some(key[i]))
14742    })
14743}
14744
14745/// v7.9.10 — does the table already contain a row whose
14746/// `column_positions` tuple equals `key`? Single-column shape
14747/// uses the existing BTree fast path; composite shapes fall
14748/// back to a row scan.
14749fn on_conflict_keys_exist(
14750    catalog: &Catalog,
14751    table_name: &str,
14752    column_positions: &[usize],
14753    key: &[&Value],
14754) -> bool {
14755    if column_positions.len() == 1 {
14756        return on_conflict_key_exists(catalog, table_name, column_positions[0], key[0]);
14757    }
14758    let Some(table) = catalog.get(table_name) else {
14759        return false;
14760    };
14761    table.rows().iter().any(|r| {
14762        column_positions
14763            .iter()
14764            .enumerate()
14765            .all(|(i, &pos)| r.values.get(pos) == Some(key[i]))
14766    })
14767}
14768
14769/// v7.9.9 — apply ON CONFLICT DO UPDATE SET assignments to an
14770/// existing row.
14771///
14772/// `incoming` is the rejected INSERT row (used to resolve
14773/// `EXCLUDED.col` references in the assignment exprs);
14774/// `target_pos` is the position of the existing row in the table.
14775/// Each assignment substitutes `EXCLUDED.col` with the matching
14776/// incoming value, evaluates the resulting expression against
14777/// the existing row, and writes the new value into the
14778/// corresponding column of the returned `Vec<Value>`. If
14779/// `where_` evaluates falsy, returns Ok(None) — PG behaviour:
14780/// the conflicting row is silently kept unchanged.
14781fn apply_on_conflict_assignments(
14782    catalog: &Catalog,
14783    table_name: &str,
14784    target_pos: usize,
14785    incoming: &[Value],
14786    assignments: &[(String, Expr)],
14787    where_: Option<&Expr>,
14788) -> Result<Option<Vec<Value>>, EngineError> {
14789    let table = catalog.get(table_name).ok_or_else(|| {
14790        EngineError::Storage(StorageError::TableNotFound {
14791            name: table_name.into(),
14792        })
14793    })?;
14794    let schema_cols = table.schema().columns.clone();
14795    let existing = table
14796        .rows()
14797        .get(target_pos)
14798        .ok_or_else(|| {
14799            EngineError::Unsupported(alloc::format!(
14800                "ON CONFLICT DO UPDATE: row position {target_pos} out of bounds on {table_name:?}"
14801            ))
14802        })?
14803        .clone();
14804    let ctx = eval::EvalContext::new(&schema_cols, Some(table_name));
14805    // Optional WHERE filter on the conflict row.
14806    if let Some(w) = where_ {
14807        let pred = w.clone();
14808        let pred = substitute_excluded_refs(pred, &schema_cols, incoming);
14809        let v = eval::eval_expr(&pred, &existing, &ctx)?;
14810        if !matches!(v, Value::Bool(true)) {
14811            return Ok(None);
14812        }
14813    }
14814    let mut new_values = existing.values.clone();
14815    for (col_name, expr) in assignments {
14816        let target_idx = schema_cols
14817            .iter()
14818            .position(|c| c.name == *col_name)
14819            .ok_or_else(|| {
14820                EngineError::Eval(EvalError::ColumnNotFound {
14821                    name: col_name.clone(),
14822                })
14823            })?;
14824        let sub = substitute_excluded_refs(expr.clone(), &schema_cols, incoming);
14825        let v = eval::eval_expr(&sub, &existing, &ctx)?;
14826        let coerced = coerce_value(v, schema_cols[target_idx].ty, col_name, target_idx)?;
14827        check_unsigned_range(&coerced, &schema_cols[target_idx], target_idx)?;
14828        new_values[target_idx] = coerced;
14829    }
14830    Ok(Some(new_values))
14831}
14832
14833/// v7.9.9 — walk an `Expr` tree replacing any `Column { qualifier:
14834/// "EXCLUDED", name }` reference with a `Literal` of the matching
14835/// value from the incoming-row vec. Resolution against the
14836/// child-table column list (by name).
14837fn substitute_excluded_refs(expr: Expr, schema_cols: &[ColumnSchema], incoming: &[Value]) -> Expr {
14838    use spg_sql::ast::ColumnName;
14839    match expr {
14840        Expr::Column(ColumnName { qualifier, name })
14841            if qualifier
14842                .as_deref()
14843                .is_some_and(|q| q.eq_ignore_ascii_case("excluded")) =>
14844        {
14845            let pos = schema_cols.iter().position(|c| c.name == name);
14846            match pos {
14847                Some(p) => {
14848                    let v = incoming.get(p).cloned().unwrap_or(Value::Null);
14849                    value_to_literal_expr(v)
14850                        .unwrap_or_else(|_| Expr::Literal(spg_sql::ast::Literal::Null))
14851                }
14852                None => Expr::Column(ColumnName { qualifier, name }),
14853            }
14854        }
14855        Expr::Binary { op, lhs, rhs } => Expr::Binary {
14856            op,
14857            lhs: Box::new(substitute_excluded_refs(*lhs, schema_cols, incoming)),
14858            rhs: Box::new(substitute_excluded_refs(*rhs, schema_cols, incoming)),
14859        },
14860        Expr::Unary { op, expr } => Expr::Unary {
14861            op,
14862            expr: Box::new(substitute_excluded_refs(*expr, schema_cols, incoming)),
14863        },
14864        Expr::FunctionCall { name, args } => Expr::FunctionCall {
14865            name,
14866            args: args
14867                .into_iter()
14868                .map(|a| substitute_excluded_refs(a, schema_cols, incoming))
14869                .collect(),
14870        },
14871        other => other,
14872    }
14873}
14874
14875/// v7.6.2 / v7.6.7 — INSERT-side FK enforcement. For every row
14876/// about to be inserted into `child_table`, every FK declared on
14877/// that table is checked: the row's FK columns must either be
14878/// NULL (SQL spec skip) or match an existing parent row via the
14879/// parent's BTree PK / UNIQUE index.
14880///
14881/// Returns `EngineError::Unsupported` with a `FOREIGN KEY violation`
14882/// payload on first failure.
14883///
14884/// **Self-referencing FKs (v7.6.7 widening):** when `fk.parent_table
14885/// == child_table`, the parent rows visible to this check are
14886///  (a) rows already committed to the table, plus
14887///  (b) earlier rows from the *same* `rows` batch.
14888/// This makes `INSERT INTO tree VALUES (1, NULL), (2, 1), (3, 2)`
14889/// work in a single statement — common pattern for bulk-loading
14890/// hierarchies.
14891/// v7.9.19 — enforce table-level UNIQUE / PRIMARY KEY tuple
14892/// constraints at INSERT time. For each constraint declared on
14893/// the target table, check that no existing row + no earlier row
14894/// in the same batch has the same full-column tuple. NULL in
14895/// any column lifts the row out of the check (SQL spec: NULL
14896/// ≠ NULL for uniqueness). mailrs G1 + G6.
14897fn enforce_uniqueness_inserts(
14898    catalog: &Catalog,
14899    child_table: &str,
14900    constraints: &[spg_storage::UniquenessConstraint],
14901    rows: &[Vec<Value>],
14902) -> Result<(), EngineError> {
14903    if constraints.is_empty() {
14904        return Ok(());
14905    }
14906    let table = catalog.get(child_table).ok_or_else(|| {
14907        EngineError::Storage(StorageError::TableNotFound {
14908            name: child_table.into(),
14909        })
14910    })?;
14911    let schema = table.schema();
14912    for uc in constraints {
14913        for (batch_idx, row_values) in rows.iter().enumerate() {
14914            // v7.17.0 Phase 3.P0-45 — fold each key cell by its
14915            // column's declared Collation before comparing. Phase
14916            // 2.5b wired Collation into GROUP BY / ORDER BY / `=`
14917            // but the UNIQUE-constraint enforcement still compared
14918            // Text byte-wise; a `*_ci` column would let
14919            // `('Foo')` and `('FOO')` coexist when MySQL would
14920            // reject the second. Owned Values so the fold and
14921            // the borrow live in the same scope.
14922            let key: Vec<Value> = uc
14923                .columns
14924                .iter()
14925                .map(|&i| collated_key_cell(&row_values[i], i, schema))
14926                .collect();
14927            let has_null = key.iter().any(|v| matches!(v, Value::Null));
14928            // v7.13.0 — `NULLS NOT DISTINCT` (mailrs round-5 G10,
14929            // PG 15+): two rows whose constrained columns are all
14930            // NULL collide. SQL-standard `NULLS DISTINCT` lets any
14931            // NULL skip the check.
14932            if has_null && !uc.nulls_not_distinct {
14933                continue;
14934            }
14935            // Table-side collision: scan existing rows.
14936            let collides_in_table = table.rows().iter().any(|prow| {
14937                uc.columns.iter().enumerate().all(|(i, &p)| {
14938                    prow.values
14939                        .get(p)
14940                        .is_some_and(|v| collated_key_cell(v, p, schema) == key[i])
14941                })
14942            });
14943            // Batch-side collision: earlier rows in the same INSERT.
14944            let collides_in_batch = rows[..batch_idx].iter().any(|earlier| {
14945                uc.columns.iter().enumerate().all(|(i, &p)| {
14946                    earlier
14947                        .get(p)
14948                        .is_some_and(|v| collated_key_cell(v, p, schema) == key[i])
14949                })
14950            });
14951            if collides_in_table || collides_in_batch {
14952                let kind = if uc.is_primary_key {
14953                    "PRIMARY KEY"
14954                } else {
14955                    "UNIQUE"
14956                };
14957                let col_names: Vec<String> = uc
14958                    .columns
14959                    .iter()
14960                    .map(|&i| table.schema().columns[i].name.clone())
14961                    .collect();
14962                return Err(EngineError::Unsupported(alloc::format!(
14963                    "{kind} violation on {child_table:?} columns {col_names:?}: \
14964                     row #{batch_idx} duplicates an existing key"
14965                )));
14966            }
14967        }
14968    }
14969    Ok(())
14970}
14971
14972/// v7.17.0 Phase 3.P0-45 — return a key cell folded by its column's
14973/// declared `Collation`. For `CaseInsensitive`, fold Text payloads to
14974/// ASCII lowercase (matches Phase 2.5's `*_ci` semantics: ASCII case-
14975/// fold only, non-ASCII bytes stay byte-wise). For `Binary` or non-Text
14976/// values, the cell passes through unchanged. The caller compares the
14977/// folded values with `==`.
14978fn collated_key_cell(
14979    v: &spg_storage::Value,
14980    column_position: usize,
14981    schema: &spg_storage::TableSchema,
14982) -> spg_storage::Value {
14983    match (v, schema.columns.get(column_position).map(|c| c.collation)) {
14984        (spg_storage::Value::Text(s), Some(spg_storage::Collation::CaseInsensitive)) => {
14985            spg_storage::Value::Text(s.to_ascii_lowercase())
14986        }
14987        _ => v.clone(),
14988    }
14989}
14990
14991/// v7.9.29 — `true` iff `v` counts as a truthy SQL value for a
14992/// WHERE-style predicate. NULL → false (three-valued logic
14993/// collapses to "skip this row" for index inclusion). Numeric
14994/// non-zero, BIGINT non-zero, TINYINT non-zero, BOOLEAN true → true.
14995/// Everything else (strings, vectors, JSON, …) is not a valid
14996/// predicate result and surfaces as `false` so a malformed
14997/// predicate degrades to "row not in index" rather than panicking.
14998fn predicate_truthy(v: &spg_storage::Value) -> bool {
14999    use spg_storage::Value as V;
15000    match v {
15001        V::Bool(b) => *b,
15002        V::Int(n) => *n != 0,
15003        V::BigInt(n) => *n != 0,
15004        V::SmallInt(n) => *n != 0,
15005        _ => false,
15006    }
15007}
15008
15009/// v7.9.29 — at CREATE UNIQUE INDEX time, scan the table's
15010/// committed rows for pre-existing duplicates. If any pair of rows
15011/// matches the predicate AND has the same index key, refuse to
15012/// create the index so the user fixes the data before retrying.
15013fn check_existing_unique_violation(
15014    idx: &spg_storage::Index,
15015    schema: &spg_storage::TableSchema,
15016    rows: &[spg_storage::Row],
15017) -> Result<(), EngineError> {
15018    let predicate_expr = match idx.partial_predicate.as_deref() {
15019        Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
15020            EngineError::Unsupported(alloc::format!(
15021                "stored partial predicate {s:?} failed to re-parse: {e:?}"
15022            ))
15023        })?),
15024        None => None,
15025    };
15026    let ctx = eval::EvalContext::new(&schema.columns, None);
15027    let key_positions = unique_key_positions(idx);
15028    let mut seen: alloc::vec::Vec<alloc::vec::Vec<spg_storage::Value>> = alloc::vec::Vec::new();
15029    for row in rows {
15030        if let Some(expr) = &predicate_expr {
15031            let v = eval::eval_expr(expr, row, &ctx).map_err(|e| {
15032                EngineError::Unsupported(alloc::format!(
15033                    "evaluating UNIQUE INDEX predicate against existing row: {e:?}"
15034                ))
15035            })?;
15036            if !predicate_truthy(&v) {
15037                continue;
15038            }
15039        }
15040        let key: alloc::vec::Vec<spg_storage::Value> = key_positions
15041            .iter()
15042            .map(|&p| {
15043                let v = row
15044                    .values
15045                    .get(p)
15046                    .cloned()
15047                    .unwrap_or(spg_storage::Value::Null);
15048                collated_key_cell(&v, p, schema)
15049            })
15050            .collect();
15051        if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
15052            continue;
15053        }
15054        if seen.iter().any(|other| *other == key) {
15055            return Err(EngineError::Unsupported(alloc::format!(
15056                "CREATE UNIQUE INDEX {:?}: existing rows already violate the constraint",
15057                idx.name
15058            )));
15059        }
15060        seen.push(key);
15061    }
15062    Ok(())
15063}
15064
15065/// v7.9.29 — full key tuple for a UNIQUE INDEX (leading +
15066/// extra positions). For single-column indexes this is just
15067/// `[column_position]`.
15068fn unique_key_positions(idx: &spg_storage::Index) -> alloc::vec::Vec<usize> {
15069    let mut out = alloc::vec::Vec::with_capacity(1 + idx.extra_column_positions.len());
15070    out.push(idx.column_position);
15071    out.extend_from_slice(&idx.extra_column_positions);
15072    out
15073}
15074
15075/// v7.9.29 — at INSERT time, walk every `is_unique` index on the
15076/// target table. For each, eval the index's optional predicate
15077/// against (a) the candidate row and (b) every committed row plus
15078/// earlier batch rows; only rows where the predicate is truthy
15079/// participate. A duplicate key among predicate-matching rows is a
15080/// uniqueness violation. NULL keys lift the row out of the check
15081/// (matching PG's "UNIQUE allows multiple NULLs" semantics).
15082fn enforce_unique_index_inserts(
15083    catalog: &Catalog,
15084    table_name: &str,
15085    rows: &[alloc::vec::Vec<spg_storage::Value>],
15086) -> Result<(), EngineError> {
15087    let table = catalog.get(table_name).ok_or_else(|| {
15088        EngineError::Storage(StorageError::TableNotFound {
15089            name: table_name.into(),
15090        })
15091    })?;
15092    let schema = table.schema();
15093    let ctx = eval::EvalContext::new(&schema.columns, None);
15094    for idx in table.indices() {
15095        if !idx.is_unique {
15096            continue;
15097        }
15098        // Re-parse the predicate once per index per batch.
15099        let predicate_expr = match idx.partial_predicate.as_deref() {
15100            Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
15101                EngineError::Unsupported(alloc::format!(
15102                    "UNIQUE INDEX {:?} predicate {s:?} failed to re-parse: {e:?}",
15103                    idx.name
15104                ))
15105            })?),
15106            None => None,
15107        };
15108        let key_positions = unique_key_positions(idx);
15109        let key_of = |values: &[spg_storage::Value]| -> alloc::vec::Vec<spg_storage::Value> {
15110            // v7.17.0 Phase 3.P0-45 — fold per-column collation
15111            // before building the comparison key so a `*_ci`
15112            // column treats `'Foo'` and `'FOO'` as equal.
15113            key_positions
15114                .iter()
15115                .map(|&p| {
15116                    let v = values.get(p).cloned().unwrap_or(spg_storage::Value::Null);
15117                    collated_key_cell(&v, p, schema)
15118                })
15119                .collect()
15120        };
15121        // Helper: does `values` participate in this index? (predicate
15122        // truthy when present.) Wraps `values` into a transient Row
15123        // because eval_expr requires &Row.
15124        let participates = |values: &[spg_storage::Value]| -> Result<bool, EngineError> {
15125            let Some(expr) = &predicate_expr else {
15126                return Ok(true);
15127            };
15128            let tmp_row = spg_storage::Row {
15129                values: values.to_vec(),
15130            };
15131            let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
15132                EngineError::Unsupported(alloc::format!(
15133                    "UNIQUE INDEX {:?} predicate eval: {e:?}",
15134                    idx.name
15135                ))
15136            })?;
15137            Ok(predicate_truthy(&v))
15138        };
15139        for (batch_idx, row_values) in rows.iter().enumerate() {
15140            if !participates(row_values)? {
15141                continue;
15142            }
15143            let key = key_of(row_values);
15144            if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
15145                continue;
15146            }
15147            // Committed-table collision.
15148            for prow in table.rows() {
15149                if !participates(&prow.values)? {
15150                    continue;
15151                }
15152                if key_of(&prow.values) == key {
15153                    return Err(EngineError::Unsupported(alloc::format!(
15154                        "UNIQUE INDEX {:?} violation on {table_name:?}: \
15155                         row #{batch_idx} duplicates an existing key",
15156                        idx.name
15157                    )));
15158                }
15159            }
15160            // Within-batch collision: earlier rows in the same INSERT.
15161            for earlier in &rows[..batch_idx] {
15162                if !participates(earlier)? {
15163                    continue;
15164                }
15165                if key_of(earlier) == key {
15166                    return Err(EngineError::Unsupported(alloc::format!(
15167                        "UNIQUE INDEX {:?} violation on {table_name:?}: \
15168                         row #{batch_idx} duplicates an earlier row in the same batch",
15169                        idx.name
15170                    )));
15171                }
15172            }
15173        }
15174    }
15175    Ok(())
15176}
15177
15178/// v7.13.0 — `UPDATE OF cols` filter helper (mailrs round-5 G7).
15179/// Returns `true` when at least one of `filter_cols` has a
15180/// different value in `new_row` vs `old_row`. Column lookup is
15181/// case-insensitive against `schema_cols`; unknown filter columns
15182/// are treated as "not changed" (the trigger therefore won't
15183/// fire on them — surfacing a parse-time error would be too
15184/// strict for catalog reloads where the schema may have drifted).
15185fn any_column_changed(
15186    filter_cols: &[String],
15187    schema_cols: &[ColumnSchema],
15188    old_row: &Row,
15189    new_row: &Row,
15190) -> bool {
15191    for col_name in filter_cols {
15192        let Some(pos) = schema_cols
15193            .iter()
15194            .position(|c| c.name.eq_ignore_ascii_case(col_name))
15195        else {
15196            continue;
15197        };
15198        let old_v = old_row.values.get(pos);
15199        let new_v = new_row.values.get(pos);
15200        if old_v != new_v {
15201            return true;
15202        }
15203    }
15204    false
15205}
15206
15207/// v7.13.0 — evaluate every CHECK predicate on the schema against
15208/// each candidate row. Mirrors PG semantics: a `false` result
15209/// rejects the mutation; a NULL result *passes* (CHECK rejects
15210/// only on definite-false, not on unknown). mailrs round-5 G3.
15211fn enforce_check_constraints(
15212    catalog: &Catalog,
15213    table_name: &str,
15214    rows: &[alloc::vec::Vec<spg_storage::Value>],
15215) -> Result<(), EngineError> {
15216    let table = catalog.get(table_name).ok_or_else(|| {
15217        EngineError::Storage(StorageError::TableNotFound {
15218            name: table_name.into(),
15219        })
15220    })?;
15221    let schema = table.schema();
15222    // v7.17.0 Phase 1.5 — domain-level CHECKs are enforced in
15223    // parallel with table-level CHECKs. Collect both lists up
15224    // front; if neither exists we early-out.
15225    let mut domain_checks_per_col: alloc::vec::Vec<(usize, alloc::vec::Vec<Expr>)> =
15226        alloc::vec::Vec::new();
15227    for (idx, col) in schema.columns.iter().enumerate() {
15228        let Some(dname) = &col.user_domain_type else {
15229            continue;
15230        };
15231        let Some(dom) = catalog.domain_types().get(dname) else {
15232            continue;
15233        };
15234        let mut parsed_for_col: alloc::vec::Vec<Expr> =
15235            alloc::vec::Vec::with_capacity(dom.checks.len());
15236        for src in &dom.checks {
15237            let expr = spg_sql::parser::parse_expression(src).map_err(|e| {
15238                EngineError::Unsupported(alloc::format!(
15239                    "DOMAIN {dname:?} CHECK ({src:?}) on column {:?}: re-parse failed: {e:?}",
15240                    col.name
15241                ))
15242            })?;
15243            parsed_for_col.push(expr);
15244        }
15245        if !parsed_for_col.is_empty() {
15246            domain_checks_per_col.push((idx, parsed_for_col));
15247        }
15248    }
15249    if schema.checks.is_empty() && domain_checks_per_col.is_empty() {
15250        return Ok(());
15251    }
15252    let ctx = eval::EvalContext::new(&schema.columns, None);
15253    let mut parsed: alloc::vec::Vec<(usize, Expr)> = alloc::vec::Vec::new();
15254    for (i, src) in schema.checks.iter().enumerate() {
15255        let expr = spg_sql::parser::parse_expression(src).map_err(|e| {
15256            EngineError::Unsupported(alloc::format!(
15257                "CHECK constraint #{i} on {table_name:?} ({src:?}) failed to re-parse: {e:?}"
15258            ))
15259        })?;
15260        parsed.push((i, expr));
15261    }
15262    for (batch_idx, row_values) in rows.iter().enumerate() {
15263        let tmp_row = spg_storage::Row {
15264            values: row_values.clone(),
15265        };
15266        for (i, expr) in &parsed {
15267            let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
15268                EngineError::Unsupported(alloc::format!(
15269                    "CHECK constraint #{i} on {table_name:?} eval at row #{batch_idx}: {e:?}"
15270                ))
15271            })?;
15272            // PG: NULL passes (CHECK rejects on definite-false only).
15273            if matches!(v, spg_storage::Value::Bool(false)) {
15274                return Err(EngineError::Unsupported(alloc::format!(
15275                    "CHECK constraint violation on {table_name:?} (row #{batch_idx}): {:?}",
15276                    schema.checks[*i]
15277                )));
15278            }
15279        }
15280        // v7.17.0 Phase 1.5 — domain-level CHECKs. Each CHECK
15281        // expression references VALUE as a column-name; we
15282        // substitute the per-row cell into the eval context by
15283        // synthesising a single-column row of just that value
15284        // under a temporary `value` column schema.
15285        for (col_idx, checks) in &domain_checks_per_col {
15286            let cell = row_values
15287                .get(*col_idx)
15288                .cloned()
15289                .unwrap_or(spg_storage::Value::Null);
15290            let synth_cols = alloc::vec![spg_storage::ColumnSchema::new(
15291                "value",
15292                schema.columns[*col_idx].ty,
15293                schema.columns[*col_idx].nullable,
15294            )];
15295            let synth_ctx = eval::EvalContext::new(&synth_cols, None);
15296            let synth_row = spg_storage::Row {
15297                values: alloc::vec![cell],
15298            };
15299            for (ci, expr) in checks.iter().enumerate() {
15300                let v = eval::eval_expr(expr, &synth_row, &synth_ctx).map_err(|e| {
15301                    EngineError::Unsupported(alloc::format!(
15302                        "DOMAIN CHECK #{ci} on column {:?} eval at row #{batch_idx}: {e:?}",
15303                        schema.columns[*col_idx].name
15304                    ))
15305                })?;
15306                if matches!(v, spg_storage::Value::Bool(false)) {
15307                    return Err(EngineError::Unsupported(alloc::format!(
15308                        "DOMAIN CHECK violation on column {:?} (row #{batch_idx})",
15309                        schema.columns[*col_idx].name
15310                    )));
15311                }
15312            }
15313        }
15314    }
15315    Ok(())
15316}
15317
15318fn enforce_fk_inserts(
15319    catalog: &Catalog,
15320    child_table: &str,
15321    fks: &[spg_storage::ForeignKeyConstraint],
15322    rows: &[Vec<Value>],
15323) -> Result<(), EngineError> {
15324    for fk in fks {
15325        let parent_is_self = fk.parent_table == child_table;
15326        let parent = if parent_is_self {
15327            // Self-ref: read the current state of the same table.
15328            // The mut borrow on child has been dropped by the caller.
15329            catalog.get(child_table).ok_or_else(|| {
15330                EngineError::Storage(StorageError::TableNotFound {
15331                    name: child_table.into(),
15332                })
15333            })?
15334        } else {
15335            catalog.get(&fk.parent_table).ok_or_else(|| {
15336                EngineError::Storage(StorageError::TableNotFound {
15337                    name: fk.parent_table.clone(),
15338                })
15339            })?
15340        };
15341        for (batch_idx, row_values) in rows.iter().enumerate() {
15342            // Single-column FK fast path: try the parent's BTree
15343            // index for an O(log n) lookup. Composite FKs fall back
15344            // to a parent-row scan.
15345            if fk.local_columns.len() == 1 {
15346                let v = &row_values[fk.local_columns[0]];
15347                if matches!(v, Value::Null) {
15348                    continue;
15349                }
15350                let parent_col = fk.parent_columns[0];
15351                let key = spg_storage::IndexKey::from_value(v).ok_or_else(|| {
15352                    EngineError::Unsupported(alloc::format!(
15353                        "FOREIGN KEY column value of type {:?} is not index-eligible",
15354                        v.data_type()
15355                    ))
15356                })?;
15357                let present_committed = parent.indices().iter().any(|idx| {
15358                    matches!(idx.kind, spg_storage::IndexKind::BTree(_))
15359                        && idx.column_position == parent_col
15360                        && idx.partial_predicate.is_none()
15361                        && !idx.lookup_eq(&key).is_empty()
15362                });
15363                // v7.6.7 self-ref widening: also accept a match
15364                // against earlier rows in this same batch when the
15365                // FK points at the table being inserted into.
15366                let present_in_batch = parent_is_self
15367                    && rows[..batch_idx]
15368                        .iter()
15369                        .any(|earlier| earlier.get(parent_col) == Some(v));
15370                if !(present_committed || present_in_batch) {
15371                    return Err(EngineError::Unsupported(alloc::format!(
15372                        "FOREIGN KEY violation: no parent row in {:?} where {} = {:?}",
15373                        fk.parent_table,
15374                        parent
15375                            .schema()
15376                            .columns
15377                            .get(parent_col)
15378                            .map_or("?", |c| c.name.as_str()),
15379                        v,
15380                    )));
15381                }
15382            } else {
15383                // Composite FK: scan parent rows. v7.6.7 also
15384                // accepts a match against earlier rows in the same
15385                // batch (self-ref bulk-loading of hierarchies).
15386                if fk
15387                    .local_columns
15388                    .iter()
15389                    .all(|&i| matches!(row_values.get(i), Some(Value::Null)))
15390                {
15391                    continue;
15392                }
15393                let local: Vec<&Value> = fk.local_columns.iter().map(|&i| &row_values[i]).collect();
15394                let parent_match_committed = parent.rows().iter().any(|prow| {
15395                    fk.parent_columns
15396                        .iter()
15397                        .enumerate()
15398                        .all(|(i, &pi)| prow.values.get(pi) == Some(local[i]))
15399                });
15400                let parent_match_in_batch = parent_is_self
15401                    && rows[..batch_idx].iter().any(|earlier| {
15402                        fk.parent_columns
15403                            .iter()
15404                            .enumerate()
15405                            .all(|(i, &pi)| earlier.get(pi) == Some(local[i]))
15406                    });
15407                if !(parent_match_committed || parent_match_in_batch) {
15408                    return Err(EngineError::Unsupported(alloc::format!(
15409                        "FOREIGN KEY violation: no parent row in {:?} matching composite key",
15410                        fk.parent_table,
15411                    )));
15412                }
15413            }
15414        }
15415    }
15416    Ok(())
15417}
15418
15419/// v7.6.4 / v7.6.5 — one step of the FK action plan computed for a
15420/// DELETE on a parent. The plan is a list of these steps, stacked
15421/// across the FK graph by `plan_fk_parent_deletions`.
15422#[derive(Debug, Clone)]
15423struct FkChildStep {
15424    child_table: String,
15425    action: FkChildAction,
15426}
15427
15428#[derive(Debug, Clone)]
15429enum FkChildAction {
15430    /// CASCADE — remove these rows. Sorted, deduplicated positions.
15431    Delete { positions: Vec<usize> },
15432    /// SET NULL — for each (row, column) in the flat list, write
15433    /// NULL into that child cell. Multiple FKs on the same row may
15434    /// produce overlapping entries (deduped at plan time).
15435    SetNull {
15436        positions: Vec<usize>,
15437        columns: Vec<usize>,
15438    },
15439    /// SET DEFAULT — same shape as SetNull but writes the column's
15440    /// declared DEFAULT value (resolved at plan time). Columns
15441    /// without a DEFAULT raise an error during planning.
15442    SetDefault {
15443        positions: Vec<usize>,
15444        columns: Vec<usize>,
15445        defaults: Vec<Value>,
15446    },
15447}
15448
15449/// v7.6.3 → v7.6.5 — plan FK fallout for a DELETE on a parent table.
15450///
15451/// Walks every table in the catalog looking for FKs whose
15452/// `parent_table` is `parent_table_name`. For each such FK + each
15453/// to-be-deleted parent row:
15454///
15455///   - RESTRICT / NoAction → error, no plan returned
15456///   - CASCADE → child rows get scheduled for deletion; recursive
15457///   - SetNull → child FK column(s) scheduled to be NULL-ed.
15458///     Verified NULL-able at plan time.
15459///   - SetDefault → child FK column(s) scheduled to be reset to
15460///     their declared DEFAULT. Columns without a DEFAULT raise.
15461///
15462/// SET NULL / SET DEFAULT do NOT cascade further — the child row
15463/// stays; only one of its columns mutates.
15464fn plan_fk_parent_deletions(
15465    catalog: &Catalog,
15466    parent_table_name: &str,
15467    to_delete_positions: &[usize],
15468    to_delete_rows: &[Vec<Value>],
15469) -> Result<Vec<FkChildStep>, EngineError> {
15470    use alloc::collections::{BTreeMap, BTreeSet};
15471    if to_delete_rows.is_empty() {
15472        return Ok(Vec::new());
15473    }
15474    let mut delete_plan: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
15475    // setnull / setdefault keyed by child_table → (row_idx, col_idx) → optional default
15476    let mut setnull_plan: BTreeMap<String, BTreeSet<(usize, usize)>> = BTreeMap::new();
15477    let mut setdefault_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
15478    let mut visited: BTreeSet<(String, usize)> = BTreeSet::new();
15479    for &p in to_delete_positions {
15480        visited.insert((parent_table_name.to_string(), p));
15481    }
15482    let mut work: Vec<(String, Vec<Value>)> = to_delete_rows
15483        .iter()
15484        .map(|r| (parent_table_name.to_string(), r.clone()))
15485        .collect();
15486    while let Some((cur_parent, parent_row)) = work.pop() {
15487        for child_name in catalog.table_names() {
15488            let child = catalog
15489                .get(&child_name)
15490                .expect("table_names → catalog.get round-trip is total");
15491            for fk in &child.schema().foreign_keys {
15492                if fk.parent_table != cur_parent {
15493                    continue;
15494                }
15495                let parent_key: Vec<&Value> = fk
15496                    .parent_columns
15497                    .iter()
15498                    .map(|&pi| &parent_row[pi])
15499                    .collect();
15500                if parent_key.iter().any(|v| matches!(v, Value::Null)) {
15501                    continue;
15502                }
15503                for (child_row_idx, child_row) in child.rows().iter().enumerate() {
15504                    if child_name == cur_parent
15505                        && visited.contains(&(child_name.clone(), child_row_idx))
15506                    {
15507                        continue;
15508                    }
15509                    let matches_key = fk
15510                        .local_columns
15511                        .iter()
15512                        .enumerate()
15513                        .all(|(i, &li)| child_row.values.get(li) == Some(parent_key[i]));
15514                    if !matches_key {
15515                        continue;
15516                    }
15517                    match fk.on_delete {
15518                        spg_storage::FkAction::Restrict | spg_storage::FkAction::NoAction => {
15519                            return Err(EngineError::Unsupported(alloc::format!(
15520                                "FOREIGN KEY violation: DELETE on {cur_parent:?} is \
15521                                 restricted by FK from {child_name:?}.{:?}",
15522                                fk.local_columns,
15523                            )));
15524                        }
15525                        spg_storage::FkAction::Cascade => {
15526                            if visited.insert((child_name.clone(), child_row_idx)) {
15527                                delete_plan
15528                                    .entry(child_name.clone())
15529                                    .or_default()
15530                                    .insert(child_row_idx);
15531                                work.push((child_name.clone(), child_row.values.clone()));
15532                            }
15533                        }
15534                        spg_storage::FkAction::SetNull => {
15535                            // Verify every local FK column is NULL-able.
15536                            for &li in &fk.local_columns {
15537                                let col = child.schema().columns.get(li).ok_or_else(|| {
15538                                    EngineError::Unsupported(alloc::format!(
15539                                        "FK local column {li} missing in {child_name:?}"
15540                                    ))
15541                                })?;
15542                                if !col.nullable {
15543                                    return Err(EngineError::Unsupported(alloc::format!(
15544                                        "FOREIGN KEY ON DELETE SET NULL: column \
15545                                         {child_name:?}.{:?} is NOT NULL — cannot SET NULL",
15546                                        col.name,
15547                                    )));
15548                                }
15549                            }
15550                            let entry = setnull_plan.entry(child_name.clone()).or_default();
15551                            for &li in &fk.local_columns {
15552                                entry.insert((child_row_idx, li));
15553                            }
15554                        }
15555                        spg_storage::FkAction::SetDefault => {
15556                            // Resolve the DEFAULT for every local FK col.
15557                            let entry = setdefault_plan.entry(child_name.clone()).or_default();
15558                            for &li in &fk.local_columns {
15559                                let col = child.schema().columns.get(li).ok_or_else(|| {
15560                                    EngineError::Unsupported(alloc::format!(
15561                                        "FK local column {li} missing in {child_name:?}"
15562                                    ))
15563                                })?;
15564                                let default = col.default.clone().ok_or_else(|| {
15565                                    EngineError::Unsupported(alloc::format!(
15566                                        "FOREIGN KEY ON DELETE SET DEFAULT: column \
15567                                         {child_name:?}.{:?} has no DEFAULT declared",
15568                                        col.name,
15569                                    ))
15570                                })?;
15571                                entry.insert((child_row_idx, li), default);
15572                            }
15573                        }
15574                    }
15575                }
15576            }
15577        }
15578    }
15579    // Flatten the three plans into the ordered `FkChildStep` list.
15580    // Deletes are applied last per child (after any null/default
15581    // re-writes on the same child) so a child row that's both
15582    // re-written and then cascade-deleted only ends up deleted —
15583    // but in v7.6.5 SetNull/Cascade never overlap on the same row
15584    // (a single FK chooses exactly one action), so the order is
15585    // mostly a precaution.
15586    let mut steps: Vec<FkChildStep> = Vec::new();
15587    for (child_table, entries) in setnull_plan {
15588        let (positions, columns): (Vec<usize>, Vec<usize>) = entries.into_iter().unzip();
15589        steps.push(FkChildStep {
15590            child_table,
15591            action: FkChildAction::SetNull { positions, columns },
15592        });
15593    }
15594    for (child_table, entries) in setdefault_plan {
15595        let mut positions = Vec::with_capacity(entries.len());
15596        let mut columns = Vec::with_capacity(entries.len());
15597        let mut defaults = Vec::with_capacity(entries.len());
15598        for ((p, c), v) in entries {
15599            positions.push(p);
15600            columns.push(c);
15601            defaults.push(v);
15602        }
15603        steps.push(FkChildStep {
15604            child_table,
15605            action: FkChildAction::SetDefault {
15606                positions,
15607                columns,
15608                defaults,
15609            },
15610        });
15611    }
15612    for (child_table, positions) in delete_plan {
15613        steps.push(FkChildStep {
15614            child_table,
15615            action: FkChildAction::Delete {
15616                positions: positions.into_iter().collect(),
15617            },
15618        });
15619    }
15620    Ok(steps)
15621}
15622
15623/// v7.6.6 — plan FK fallout for an UPDATE that mutates parent-side
15624/// PK/UNIQUE columns. Walks every other table whose FK references
15625/// `parent_table_name`; for each FK whose parent_columns overlap a
15626/// mutated column, decides the action by `fk.on_update`.
15627///
15628///   - RESTRICT / NoAction → error if any child references the OLD
15629///     value
15630///   - CASCADE → child FK columns get rewritten to the NEW parent
15631///     value (a SetNull-style update step with the new value)
15632///   - SetNull → child FK columns set to NULL
15633///   - SetDefault → child FK columns set to declared default
15634///
15635/// `plan_with_old` is `(row_position, old_values, new_values)` so
15636/// the planner can detect "did this row's parent key actually
15637/// change?" — only rows where at least one referenced parent
15638/// column moved trigger inbound work.
15639fn plan_fk_parent_updates(
15640    catalog: &Catalog,
15641    parent_table_name: &str,
15642    plan_with_old: &[(usize, Vec<Value>, Vec<Value>)],
15643) -> Result<Vec<FkChildStep>, EngineError> {
15644    use alloc::collections::BTreeMap;
15645    if plan_with_old.is_empty() {
15646        return Ok(Vec::new());
15647    }
15648    // For each child table we may touch, build per-child step
15649    // lists. UPDATE never deletes children — `delete_plan` stays
15650    // empty here but is kept structurally aligned with
15651    // `plan_fk_parent_deletions` for future use.
15652    let delete_plan: BTreeMap<String, alloc::collections::BTreeSet<usize>> = BTreeMap::new();
15653    let mut setnull_plan: BTreeMap<String, alloc::collections::BTreeSet<(usize, usize)>> =
15654        BTreeMap::new();
15655    let mut setdefault_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
15656    // Cascade-update plan: child_table → row_idx → col_idx → new_value
15657    let mut cascade_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
15658
15659    for child_name in catalog.table_names() {
15660        let child = catalog
15661            .get(&child_name)
15662            .expect("table_names → catalog.get total");
15663        for fk in &child.schema().foreign_keys {
15664            if fk.parent_table != parent_table_name {
15665                continue;
15666            }
15667            for (_pos, old_row, new_row) in plan_with_old {
15668                // Did any parent FK column change?
15669                let key_changed = fk
15670                    .parent_columns
15671                    .iter()
15672                    .any(|&pi| old_row.get(pi) != new_row.get(pi));
15673                if !key_changed {
15674                    continue;
15675                }
15676                // The OLD parent key — used to find referring children.
15677                let old_key: Vec<&Value> =
15678                    fk.parent_columns.iter().map(|&pi| &old_row[pi]).collect();
15679                if old_key.iter().any(|v| matches!(v, Value::Null)) {
15680                    // NULL parent has no children — skip.
15681                    continue;
15682                }
15683                let new_key: Vec<&Value> =
15684                    fk.parent_columns.iter().map(|&pi| &new_row[pi]).collect();
15685                for (child_row_idx, child_row) in child.rows().iter().enumerate() {
15686                    // Self-ref same-row updates: a row updating its
15687                    // own PK doesn't restrict itself.
15688                    if child_name == parent_table_name
15689                        && plan_with_old.iter().any(|(p, _, _)| *p == child_row_idx)
15690                    {
15691                        continue;
15692                    }
15693                    let matches_key = fk
15694                        .local_columns
15695                        .iter()
15696                        .enumerate()
15697                        .all(|(i, &li)| child_row.values.get(li) == Some(old_key[i]));
15698                    if !matches_key {
15699                        continue;
15700                    }
15701                    match fk.on_update {
15702                        spg_storage::FkAction::Restrict | spg_storage::FkAction::NoAction => {
15703                            return Err(EngineError::Unsupported(alloc::format!(
15704                                "FOREIGN KEY violation: UPDATE on {parent_table_name:?} PK is \
15705                                 restricted by FK from {child_name:?}.{:?}",
15706                                fk.local_columns,
15707                            )));
15708                        }
15709                        spg_storage::FkAction::Cascade => {
15710                            // Rewrite child FK columns to new key.
15711                            let entry = cascade_plan.entry(child_name.clone()).or_default();
15712                            for (i, &li) in fk.local_columns.iter().enumerate() {
15713                                entry.insert((child_row_idx, li), new_key[i].clone());
15714                            }
15715                        }
15716                        spg_storage::FkAction::SetNull => {
15717                            for &li in &fk.local_columns {
15718                                let col = child.schema().columns.get(li).ok_or_else(|| {
15719                                    EngineError::Unsupported(alloc::format!(
15720                                        "FK local column {li} missing in {child_name:?}"
15721                                    ))
15722                                })?;
15723                                if !col.nullable {
15724                                    return Err(EngineError::Unsupported(alloc::format!(
15725                                        "FOREIGN KEY ON UPDATE SET NULL: column \
15726                                         {child_name:?}.{:?} is NOT NULL",
15727                                        col.name,
15728                                    )));
15729                                }
15730                            }
15731                            let entry = setnull_plan.entry(child_name.clone()).or_default();
15732                            for &li in &fk.local_columns {
15733                                entry.insert((child_row_idx, li));
15734                            }
15735                        }
15736                        spg_storage::FkAction::SetDefault => {
15737                            let entry = setdefault_plan.entry(child_name.clone()).or_default();
15738                            for &li in &fk.local_columns {
15739                                let col = child.schema().columns.get(li).ok_or_else(|| {
15740                                    EngineError::Unsupported(alloc::format!(
15741                                        "FK local column {li} missing in {child_name:?}"
15742                                    ))
15743                                })?;
15744                                let default = col.default.clone().ok_or_else(|| {
15745                                    EngineError::Unsupported(alloc::format!(
15746                                        "FOREIGN KEY ON UPDATE SET DEFAULT: column \
15747                                         {child_name:?}.{:?} has no DEFAULT",
15748                                        col.name,
15749                                    ))
15750                                })?;
15751                                entry.insert((child_row_idx, li), default);
15752                            }
15753                        }
15754                    }
15755                }
15756            }
15757        }
15758    }
15759    // Flatten into FkChildStep list. UPDATE doesn't produce
15760    // DeleteSteps (CASCADE on UPDATE just rewrites FK values).
15761    let mut steps: Vec<FkChildStep> = Vec::new();
15762    for (child_table, entries) in cascade_plan {
15763        let mut positions = Vec::with_capacity(entries.len());
15764        let mut columns = Vec::with_capacity(entries.len());
15765        let mut defaults = Vec::with_capacity(entries.len());
15766        for ((p, c), v) in entries {
15767            positions.push(p);
15768            columns.push(c);
15769            defaults.push(v);
15770        }
15771        // We reuse `FkChildAction::SetDefault` for cascade-update:
15772        // both shapes are "write a known value into specific cells"
15773        // — `apply_per_cell_writes` doesn't care whether the value
15774        // came from a DEFAULT declaration or a new parent key.
15775        steps.push(FkChildStep {
15776            child_table,
15777            action: FkChildAction::SetDefault {
15778                positions,
15779                columns,
15780                defaults,
15781            },
15782        });
15783    }
15784    for (child_table, entries) in setnull_plan {
15785        let (positions, columns): (Vec<usize>, Vec<usize>) = entries.into_iter().unzip();
15786        steps.push(FkChildStep {
15787            child_table,
15788            action: FkChildAction::SetNull { positions, columns },
15789        });
15790    }
15791    for (child_table, entries) in setdefault_plan {
15792        let mut positions = Vec::with_capacity(entries.len());
15793        let mut columns = Vec::with_capacity(entries.len());
15794        let mut defaults = Vec::with_capacity(entries.len());
15795        for ((p, c), v) in entries {
15796            positions.push(p);
15797            columns.push(c);
15798            defaults.push(v);
15799        }
15800        steps.push(FkChildStep {
15801            child_table,
15802            action: FkChildAction::SetDefault {
15803                positions,
15804                columns,
15805                defaults,
15806            },
15807        });
15808    }
15809    let _ = delete_plan; // UPDATE never deletes children.
15810    Ok(steps)
15811}
15812
15813/// v7.6.5 — apply one FK child step to the catalog. Encapsulates
15814/// the three action variants so the DELETE executor stays a
15815/// simple loop over the planned steps.
15816fn apply_fk_child_step(catalog: &mut Catalog, step: &FkChildStep) -> Result<(), EngineError> {
15817    let child = catalog.get_mut(&step.child_table).ok_or_else(|| {
15818        EngineError::Storage(StorageError::TableNotFound {
15819            name: step.child_table.clone(),
15820        })
15821    })?;
15822    match &step.action {
15823        FkChildAction::Delete { positions } => {
15824            let _ = child.delete_rows(positions);
15825        }
15826        FkChildAction::SetNull { positions, columns } => {
15827            apply_per_cell_writes(child, positions, columns, |_| Value::Null)?;
15828        }
15829        FkChildAction::SetDefault {
15830            positions,
15831            columns,
15832            defaults,
15833        } => {
15834            apply_per_cell_writes(child, positions, columns, |i| defaults[i].clone())?;
15835        }
15836    }
15837    Ok(())
15838}
15839
15840/// v7.6.5 — write new values into selected child cells via
15841/// `Table::update_row` (the catalog's existing UPDATE entry).
15842/// Groups writes by row position so multi-column updates on the
15843/// same row only call `update_row` once. `value_for(i)` produces
15844/// the new value for the i-th (position, column) entry.
15845fn apply_per_cell_writes(
15846    child: &mut spg_storage::Table,
15847    positions: &[usize],
15848    columns: &[usize],
15849    mut value_for: impl FnMut(usize) -> Value,
15850) -> Result<(), EngineError> {
15851    use alloc::collections::BTreeMap;
15852    let mut by_row: BTreeMap<usize, Vec<(usize, Value)>> = BTreeMap::new();
15853    for i in 0..positions.len() {
15854        by_row
15855            .entry(positions[i])
15856            .or_default()
15857            .push((columns[i], value_for(i)));
15858    }
15859    for (pos, mutations) in by_row {
15860        let mut new_values = child.rows()[pos].values.clone();
15861        for (col, v) in mutations {
15862            if let Some(slot) = new_values.get_mut(col) {
15863                *slot = v;
15864            }
15865        }
15866        child
15867            .update_row(pos, new_values)
15868            .map_err(EngineError::Storage)?;
15869    }
15870    Ok(())
15871}
15872
15873fn fk_action_sql_to_storage(a: spg_sql::ast::FkAction) -> spg_storage::FkAction {
15874    match a {
15875        spg_sql::ast::FkAction::Restrict => spg_storage::FkAction::Restrict,
15876        spg_sql::ast::FkAction::Cascade => spg_storage::FkAction::Cascade,
15877        spg_sql::ast::FkAction::SetNull => spg_storage::FkAction::SetNull,
15878        spg_sql::ast::FkAction::SetDefault => spg_storage::FkAction::SetDefault,
15879        spg_sql::ast::FkAction::NoAction => spg_storage::FkAction::NoAction,
15880    }
15881}
15882
15883/// v7.9.21 — resolve a column's DEFAULT for INSERT-time
15884/// default-fill. Free fn (rather than `&self`) so callers
15885/// with an active `&mut Table` borrow can still use it.
15886/// Literal defaults take the cached path (`col.default`);
15887/// runtime defaults hit `clock_fn` at each call. mailrs G4.
15888fn resolve_column_default_free(
15889    col: &ColumnSchema,
15890    clock_fn: Option<ClockFn>,
15891) -> Result<Value, EngineError> {
15892    if let Some(rt) = &col.runtime_default {
15893        return eval_runtime_default_free(rt, col.ty, clock_fn);
15894    }
15895    Ok(col.default.clone().unwrap_or(Value::Null))
15896}
15897
15898fn eval_runtime_default_free(
15899    rt: &str,
15900    ty: DataType,
15901    clock_fn: Option<ClockFn>,
15902) -> Result<Value, EngineError> {
15903    let s = rt.trim().to_ascii_lowercase();
15904    // v7.17.0 Phase 2.1 — also strip `(N)` precision suffix
15905    // so MySQL `CURRENT_TIMESTAMP(6)` resolves the same as
15906    // bare `CURRENT_TIMESTAMP`. SPG stores TIMESTAMP at fixed
15907    // microsecond resolution; the precision modifier is
15908    // parser-only.
15909    let with_no_parens = s.trim_end_matches("()");
15910    let canonical: &str = if let Some(open_idx) = with_no_parens.find('(') {
15911        if with_no_parens.ends_with(')') {
15912            &with_no_parens[..open_idx]
15913        } else {
15914            with_no_parens
15915        }
15916    } else {
15917        with_no_parens
15918    };
15919    let now_us = match clock_fn {
15920        Some(f) => f(),
15921        None => 0,
15922    };
15923    let v = match canonical {
15924        "now" | "current_timestamp" | "localtimestamp" => Value::Timestamp(now_us),
15925        "current_date" => Value::Date((now_us / 86_400_000_000) as i32),
15926        "current_time" | "localtime" => Value::Timestamp(now_us),
15927        // v7.17.0 — UUID generators in DEFAULT clauses. Required
15928        // for the canonical Django / Rails / Hibernate `id UUID
15929        // PRIMARY KEY DEFAULT gen_random_uuid()` pattern. Each
15930        // INSERT evaluates the function fresh; the per-row UUID
15931        // is the storage value, not a cached literal.
15932        "gen_random_uuid" | "uuid_generate_v4" => Value::Uuid(eval::gen_random_uuid_bytes()),
15933        other => {
15934            return Err(EngineError::Unsupported(alloc::format!(
15935                "runtime DEFAULT expression {other:?} not supported \
15936                 (v7.17.0 whitelist: now() / current_timestamp / \
15937                 current_date / current_time / localtimestamp / \
15938                 localtime / gen_random_uuid() / \
15939                 uuid_generate_v4())"
15940            )));
15941        }
15942    };
15943    coerce_value(v, ty, "DEFAULT", 0)
15944}
15945
15946/// v7.9.21 — true when a DEFAULT expression needs INSERT-time
15947/// evaluation rather than being cacheable as a literal Value.
15948/// FunctionCall is the immediate case (`now()`,
15949/// `current_timestamp`). Literal expressions and simple sign-
15950/// flipped numerics still take the static-cache path.
15951fn is_runtime_default_expr(expr: &Expr) -> bool {
15952    match expr {
15953        Expr::FunctionCall { .. } => true,
15954        Expr::Unary { expr, .. } => is_runtime_default_expr(expr),
15955        _ => false,
15956    }
15957}
15958
15959/// v7.17.0 Phase 1.4 — INSERT/UPDATE-time enum label check. When
15960/// `col_idx` has a registered label list, the cell value must be
15961/// NULL or one of the labels (case-sensitive per PG).
15962/// v7.17.0 Phase 3.P0-37 — validate + canonicalise a MySQL inline
15963/// SET cell. For non-SET columns this is a no-op pass-through.
15964///
15965/// Semantics:
15966///   * NULL preserved.
15967///   * Empty string → `''` (zero flags).
15968///   * Otherwise split on ',', trim each token, validate every
15969///     token against the column's variant list (error on miss),
15970///     de-dup, then re-emit in DEFINITION order joined by ','.
15971fn canonicalize_set_value(
15972    lookup: &alloc::collections::BTreeMap<usize, Vec<String>>,
15973    col_idx: usize,
15974    col_name: &str,
15975    value: Value,
15976) -> Result<Value, EngineError> {
15977    let Some(variants) = lookup.get(&col_idx) else {
15978        return Ok(value);
15979    };
15980    match value {
15981        Value::Null => Ok(Value::Null),
15982        Value::Text(s) => {
15983            if s.is_empty() {
15984                return Ok(Value::Text(alloc::string::String::new()));
15985            }
15986            // Collect a presence-set of variant indices to keep
15987            // definition order + handle de-dup in one pass.
15988            let mut present = alloc::vec![false; variants.len()];
15989            for raw in s.split(',') {
15990                let tok = raw.trim();
15991                if tok.is_empty() {
15992                    continue;
15993                }
15994                let idx = variants.iter().position(|v| v == tok).ok_or_else(|| {
15995                    EngineError::Unsupported(alloc::format!(
15996                        "column {col_name:?}: invalid SET token {tok:?}; \
15997                         allowed: {variants:?}"
15998                    ))
15999                })?;
16000                present[idx] = true;
16001            }
16002            // Re-emit in definition order.
16003            let mut out = alloc::string::String::new();
16004            let mut first = true;
16005            for (i, keep) in present.iter().enumerate() {
16006                if !keep {
16007                    continue;
16008                }
16009                if !first {
16010                    out.push(',');
16011                }
16012                first = false;
16013                out.push_str(&variants[i]);
16014            }
16015            Ok(Value::Text(out))
16016        }
16017        other => Err(EngineError::Unsupported(alloc::format!(
16018            "column {col_name:?}: SET-typed column expects TEXT, got {:?}",
16019            other.data_type()
16020        ))),
16021    }
16022}
16023
16024fn enforce_enum_label(
16025    lookup: &alloc::collections::BTreeMap<usize, Vec<String>>,
16026    col_idx: usize,
16027    col_name: &str,
16028    value: &Value,
16029) -> Result<(), EngineError> {
16030    if let Some(labels) = lookup.get(&col_idx) {
16031        match value {
16032            Value::Null => Ok(()),
16033            Value::Text(s) => {
16034                if labels.iter().any(|l| l == s) {
16035                    Ok(())
16036                } else {
16037                    Err(EngineError::Unsupported(alloc::format!(
16038                        "column {col_name:?}: invalid enum label {s:?}; allowed: {labels:?}"
16039                    )))
16040                }
16041            }
16042            other => Err(EngineError::Unsupported(alloc::format!(
16043                "column {col_name:?}: enum-typed column expects TEXT, got {:?}",
16044                other.data_type()
16045            ))),
16046        }
16047    } else {
16048        Ok(())
16049    }
16050}
16051
16052fn column_def_to_schema(c: ColumnDef) -> Result<ColumnSchema, EngineError> {
16053    let ty = column_type_to_data_type(c.ty);
16054    let mut schema = ColumnSchema::new(c.name.clone(), ty, c.nullable);
16055    // user_type_ref is the raw ident the parser couldn't resolve
16056    // to a built-in; classification into enum vs domain happens
16057    // at exec_create_table where we have catalog access. We
16058    // park it temporarily as user_enum_type and the engine
16059    // promotes domain bindings to user_domain_type before the
16060    // table is stored.
16061    if let Some(name) = c.user_type_ref {
16062        schema.user_enum_type = Some(name);
16063    }
16064    // v7.17.0 Phase 2.1 — render the ON UPDATE expression to
16065    // canonical text (the engine re-parses at UPDATE time).
16066    if let Some(expr) = c.on_update_runtime {
16067        schema.on_update_runtime = Some(alloc::format!("{expr}"));
16068    }
16069    // v7.17.0 Phase 2.5 — bridge the AST `Collation` enum to the
16070    // storage one. Same variants, different crates (spg-storage
16071    // owns no dep on spg-sql).
16072    schema.collation = match c.collation {
16073        spg_sql::ast::Collation::Binary => spg_storage::Collation::Binary,
16074        spg_sql::ast::Collation::CaseInsensitive => spg_storage::Collation::CaseInsensitive,
16075    };
16076    // v7.17.0 Phase 4.4 — MySQL `UNSIGNED` flag propagates to
16077    // storage so engine INSERT / UPDATE can range-check.
16078    schema.is_unsigned = c.is_unsigned;
16079    // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM variant list.
16080    // INSERT validation lives in coerce_value (Text → Text path
16081    // with the column's variant list as the accept-set).
16082    schema.inline_enum_variants = c.inline_enum_variants;
16083    // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant list.
16084    // INSERT canonicalisation (de-dup + sort by definition order)
16085    // lives in the exec_insert path next to the ENUM check.
16086    schema.inline_set_variants = c.inline_set_variants;
16087    if let Some(default_expr) = c.default {
16088        // v7.9.21 — distinguish literal defaults (evaluated once
16089        // at CREATE TABLE) from expression defaults (deferred to
16090        // INSERT). Function calls (`now()`, `current_timestamp`
16091        // — see v7.9.20 keyword promotion) take the runtime path.
16092        // Literals continue to cache. mailrs G4.
16093        if is_runtime_default_expr(&default_expr) {
16094            let display = alloc::format!("{default_expr}");
16095            schema = schema.with_runtime_default(display);
16096        } else {
16097            let raw = literal_expr_to_value(default_expr)?;
16098            let coerced = coerce_value(raw, ty, &c.name, 0)?;
16099            schema = schema.with_default(coerced);
16100        }
16101    }
16102    if c.auto_increment {
16103        // AUTO_INCREMENT only makes sense on integer-shaped columns.
16104        if !matches!(ty, DataType::SmallInt | DataType::Int | DataType::BigInt) {
16105            return Err(EngineError::Unsupported(alloc::format!(
16106                "AUTO_INCREMENT requires an integer column type, got {ty:?}"
16107            )));
16108        }
16109        schema = schema.with_auto_increment();
16110    }
16111    Ok(schema)
16112}
16113
16114/// v7.10.4 — decode a BYTEA literal. Accepts:
16115///   * `\xDEADBEEF` (case-insensitive hex; whitespace stripped)
16116///   * `Hello\000world` (backslash escape form; `\\` for literal backslash)
16117///   * Anything else → raw UTF-8 bytes of the input (PG accepts this too).
16118fn decode_bytea_literal(s: &str) -> Result<alloc::vec::Vec<u8>, &'static str> {
16119    let s = s.trim();
16120    if let Some(hex) = s.strip_prefix("\\x").or_else(|| s.strip_prefix("\\X")) {
16121        // Hex form. Each pair of hex digits → one byte.
16122        let cleaned: alloc::string::String = hex.chars().filter(|c| !c.is_whitespace()).collect();
16123        if cleaned.len() % 2 != 0 {
16124            return Err("odd-length hex literal");
16125        }
16126        let mut out = alloc::vec::Vec::with_capacity(cleaned.len() / 2);
16127        let cleaned_bytes = cleaned.as_bytes();
16128        for i in (0..cleaned_bytes.len()).step_by(2) {
16129            let hi = hex_nibble(cleaned_bytes[i])?;
16130            let lo = hex_nibble(cleaned_bytes[i + 1])?;
16131            out.push((hi << 4) | lo);
16132        }
16133        return Ok(out);
16134    }
16135    // Escape form or raw. Walk char-by-char; `\\` and `\NNN` octal
16136    // sequences decode; anything else is a literal byte.
16137    let bytes = s.as_bytes();
16138    let mut out = alloc::vec::Vec::with_capacity(bytes.len());
16139    let mut i = 0;
16140    while i < bytes.len() {
16141        let b = bytes[i];
16142        if b == b'\\' && i + 1 < bytes.len() {
16143            let n = bytes[i + 1];
16144            if n == b'\\' {
16145                out.push(b'\\');
16146                i += 2;
16147                continue;
16148            }
16149            if n.is_ascii_digit()
16150                && i + 3 < bytes.len()
16151                && bytes[i + 2].is_ascii_digit()
16152                && bytes[i + 3].is_ascii_digit()
16153            {
16154                let oct = |x: u8| (x - b'0') as u32;
16155                let v = oct(n) * 64 + oct(bytes[i + 2]) * 8 + oct(bytes[i + 3]);
16156                if v <= 0xFF {
16157                    out.push(v as u8);
16158                    i += 4;
16159                    continue;
16160                }
16161            }
16162        }
16163        out.push(b);
16164        i += 1;
16165    }
16166    Ok(out)
16167}
16168
16169fn hex_nibble(b: u8) -> Result<u8, &'static str> {
16170    match b {
16171        b'0'..=b'9' => Ok(b - b'0'),
16172        b'a'..=b'f' => Ok(b - b'a' + 10),
16173        b'A'..=b'F' => Ok(b - b'A' + 10),
16174        _ => Err("invalid hex digit"),
16175    }
16176}
16177
16178/// v7.10.11 — decode a PG TEXT[] external array form
16179/// (`{a,b,NULL}` with optional double-quoted elements). The
16180/// engine takes a leading/trailing `{`/`}` and splits at commas.
16181/// Quoted elements (`"hello, world"`) preserve embedded commas;
16182/// `\\` and `\"` decode to literal backslash / quote. Plain
16183/// unquoted `NULL` (case-insensitive) maps to `None`.
16184/// v7.11.13 — pick the array type for `ARRAY[lit, …]` from the
16185/// element values. Single-element-type rules:
16186///   - all NULL / all Text → TextArray
16187///   - all Int (or Int+NULL) → IntArray
16188///   - any BigInt without Text → BigIntArray (widening)
16189///   - any Text → TextArray (fallback; non-string elements
16190///     render as text)
16191fn array_literal_widen(items: alloc::vec::Vec<Value>) -> Value {
16192    let mut has_text = false;
16193    let mut has_bigint = false;
16194    let mut has_int = false;
16195    for v in &items {
16196        match v {
16197            Value::Null => {}
16198            Value::Text(_) | Value::Json(_) => has_text = true,
16199            Value::BigInt(_) => has_bigint = true,
16200            Value::Int(_) | Value::SmallInt(_) => has_int = true,
16201            _ => has_text = true,
16202        }
16203    }
16204    if has_text || (!has_bigint && !has_int) {
16205        let out: alloc::vec::Vec<Option<alloc::string::String>> = items
16206            .into_iter()
16207            .map(|v| match v {
16208                Value::Null => None,
16209                Value::Text(s) | Value::Json(s) => Some(s),
16210                other => Some(alloc::format!("{other:?}")),
16211            })
16212            .collect();
16213        return Value::TextArray(out);
16214    }
16215    if has_bigint {
16216        let out: alloc::vec::Vec<Option<i64>> = items
16217            .into_iter()
16218            .map(|v| match v {
16219                Value::Null => None,
16220                Value::Int(n) => Some(i64::from(n)),
16221                Value::SmallInt(n) => Some(i64::from(n)),
16222                Value::BigInt(n) => Some(n),
16223                _ => unreachable!("widen: unexpected non-integer in BigInt path"),
16224            })
16225            .collect();
16226        return Value::BigIntArray(out);
16227    }
16228    let out: alloc::vec::Vec<Option<i32>> = items
16229        .into_iter()
16230        .map(|v| match v {
16231            Value::Null => None,
16232            Value::Int(n) => Some(n),
16233            Value::SmallInt(n) => Some(i32::from(n)),
16234            _ => unreachable!("widen: unexpected non-i32-compatible in Int path"),
16235        })
16236        .collect();
16237    Value::IntArray(out)
16238}
16239
16240fn decode_text_array_literal(
16241    s: &str,
16242) -> Result<alloc::vec::Vec<Option<alloc::string::String>>, &'static str> {
16243    let trimmed = s.trim();
16244    let inner = trimmed
16245        .strip_prefix('{')
16246        .and_then(|x| x.strip_suffix('}'))
16247        .ok_or("TEXT[] literal must be enclosed in '{...}'")?;
16248    let mut out: alloc::vec::Vec<Option<alloc::string::String>> = alloc::vec::Vec::new();
16249    if inner.trim().is_empty() {
16250        return Ok(out);
16251    }
16252    let bytes = inner.as_bytes();
16253    let mut i = 0;
16254    while i <= bytes.len() {
16255        // Skip leading whitespace.
16256        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
16257            i += 1;
16258        }
16259        // Quoted element.
16260        if i < bytes.len() && bytes[i] == b'"' {
16261            i += 1; // open quote
16262            let mut buf = alloc::string::String::new();
16263            while i < bytes.len() && bytes[i] != b'"' {
16264                if bytes[i] == b'\\' && i + 1 < bytes.len() {
16265                    buf.push(bytes[i + 1] as char);
16266                    i += 2;
16267                } else {
16268                    buf.push(bytes[i] as char);
16269                    i += 1;
16270                }
16271            }
16272            if i >= bytes.len() {
16273                return Err("unterminated quoted element");
16274            }
16275            i += 1; // close quote
16276            out.push(Some(buf));
16277        } else {
16278            // Unquoted element — read until next comma or end.
16279            let start = i;
16280            while i < bytes.len() && bytes[i] != b',' {
16281                i += 1;
16282            }
16283            let raw = inner[start..i].trim();
16284            if raw.eq_ignore_ascii_case("NULL") {
16285                out.push(None);
16286            } else {
16287                out.push(Some(alloc::string::ToString::to_string(raw)));
16288            }
16289        }
16290        // Skip whitespace, expect comma or end.
16291        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
16292            i += 1;
16293        }
16294        if i >= bytes.len() {
16295            break;
16296        }
16297        if bytes[i] != b',' {
16298            return Err("expected ',' between TEXT[] elements");
16299        }
16300        i += 1;
16301    }
16302    Ok(out)
16303}
16304
16305/// v7.10.11 — encode a TEXT[] back into the PG external array
16306/// form. NULL elements become the literal `NULL`; elements
16307/// containing commas, quotes, backslashes, or braces are
16308/// double-quoted with `\\` / `\"` escapes.
16309fn encode_text_array(items: &[Option<alloc::string::String>]) -> alloc::string::String {
16310    let mut out = alloc::string::String::with_capacity(2 + items.len() * 8);
16311    out.push('{');
16312    for (i, item) in items.iter().enumerate() {
16313        if i > 0 {
16314            out.push(',');
16315        }
16316        match item {
16317            None => out.push_str("NULL"),
16318            Some(s) => {
16319                let needs_quote = s.is_empty()
16320                    || s.eq_ignore_ascii_case("NULL")
16321                    || s.chars()
16322                        .any(|c| matches!(c, ',' | '{' | '}' | '"' | '\\' | ' ' | '\t'));
16323                if needs_quote {
16324                    out.push('"');
16325                    for c in s.chars() {
16326                        if c == '"' || c == '\\' {
16327                            out.push('\\');
16328                        }
16329                        out.push(c);
16330                    }
16331                    out.push('"');
16332                } else {
16333                    out.push_str(s);
16334                }
16335            }
16336        }
16337    }
16338    out.push('}');
16339    out
16340}
16341
16342/// v7.10.4 — encode BYTEA bytes in PG hex output format
16343/// (`\x` prefix, lowercase hex pairs). Used by Text-side
16344/// round-trip + the wire layer's text-mode encoder.
16345fn encode_bytea_hex(b: &[u8]) -> alloc::string::String {
16346    let mut out = alloc::string::String::with_capacity(2 + 2 * b.len());
16347    out.push_str("\\x");
16348    for byte in b {
16349        let hi = byte >> 4;
16350        let lo = byte & 0x0F;
16351        out.push(hex_digit(hi));
16352        out.push(hex_digit(lo));
16353    }
16354    out
16355}
16356
16357const fn hex_digit(n: u8) -> char {
16358    match n {
16359        0..=9 => (b'0' + n) as char,
16360        10..=15 => (b'a' + n - 10) as char,
16361        _ => '?',
16362    }
16363}
16364
16365/// v7.17.0 Phase 3.P0-39 — parse a PG `hstore` text literal into
16366/// a flat key→value map. Empty string → empty map. Duplicate
16367/// keys take last-write-wins (matches PG `hstore_in`).
16368///
16369/// Accepted shapes (minimal subset):
16370///   * `'a=>1, b=>2'`            — bareword keys/values
16371///   * `'"a"=>"1", "b"=>"2"'`    — quoted keys/values
16372///   * `'a=>NULL'`               — case-insensitive NULL token
16373///     surfaces as `None` (no quotes around NULL)
16374///
16375/// Returns None on parse failure → caller surfaces as hard error.
16376fn parse_hstore_str(
16377    s: &str,
16378) -> Option<Vec<(alloc::string::String, Option<alloc::string::String>)>> {
16379    let bytes = s.as_bytes();
16380    let mut i = 0;
16381    let mut out: Vec<(alloc::string::String, Option<alloc::string::String>)> = Vec::new();
16382    let skip_ws = |bytes: &[u8], i: &mut usize| {
16383        while *i < bytes.len() && matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r') {
16384            *i += 1;
16385        }
16386    };
16387    let parse_token = |bytes: &[u8], i: &mut usize| -> Option<alloc::string::String> {
16388        if *i >= bytes.len() {
16389            return None;
16390        }
16391        if bytes[*i] == b'"' {
16392            *i += 1;
16393            let mut out = alloc::string::String::new();
16394            while *i < bytes.len() {
16395                match bytes[*i] {
16396                    b'"' => {
16397                        *i += 1;
16398                        return Some(out);
16399                    }
16400                    b'\\' if *i + 1 < bytes.len() => {
16401                        out.push(bytes[*i + 1] as char);
16402                        *i += 2;
16403                    }
16404                    c => {
16405                        out.push(c as char);
16406                        *i += 1;
16407                    }
16408                }
16409            }
16410            None
16411        } else {
16412            let start = *i;
16413            while *i < bytes.len()
16414                && !matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r' | b',' | b'=')
16415            {
16416                *i += 1;
16417            }
16418            if *i == start {
16419                return None;
16420            }
16421            Some(alloc::str::from_utf8(&bytes[start..*i]).ok()?.to_string())
16422        }
16423    };
16424    skip_ws(bytes, &mut i);
16425    while i < bytes.len() {
16426        let key = parse_token(bytes, &mut i)?;
16427        skip_ws(bytes, &mut i);
16428        if i + 1 >= bytes.len() || bytes[i] != b'=' || bytes[i + 1] != b'>' {
16429            return None;
16430        }
16431        i += 2;
16432        skip_ws(bytes, &mut i);
16433        // Check for unquoted NULL token (case-insensitive).
16434        let val_token = if i + 4 <= bytes.len()
16435            && bytes[i..i + 4].eq_ignore_ascii_case(b"NULL")
16436            && (i + 4 == bytes.len() || matches!(bytes[i + 4], b' ' | b'\t' | b',' | b'\n' | b'\r'))
16437        {
16438            i += 4;
16439            None
16440        } else {
16441            Some(parse_token(bytes, &mut i)?)
16442        };
16443        // Replace any existing entry with the same key (last-wins).
16444        if let Some(pos) = out.iter().position(|(k, _)| k == &key) {
16445            out[pos] = (key, val_token);
16446        } else {
16447            out.push((key, val_token));
16448        }
16449        skip_ws(bytes, &mut i);
16450        if i >= bytes.len() {
16451            break;
16452        }
16453        if bytes[i] == b',' {
16454            i += 1;
16455            skip_ws(bytes, &mut i);
16456            continue;
16457        }
16458        return None;
16459    }
16460    Some(out)
16461}
16462
16463/// v7.17.0 Phase 3.P0-39 — render a hstore as canonical PG text
16464/// form `"k"=>"v"` (keys and non-NULL values always quoted;
16465/// NULL token is bare).
16466fn format_hstore_str(
16467    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
16468) -> alloc::string::String {
16469    let mut out = alloc::string::String::new();
16470    for (i, (k, v)) in pairs.iter().enumerate() {
16471        if i > 0 {
16472            out.push_str(", ");
16473        }
16474        out.push('"');
16475        out.push_str(k);
16476        out.push_str("\"=>");
16477        match v {
16478            None => out.push_str("NULL"),
16479            Some(val) => {
16480                out.push('"');
16481                out.push_str(val);
16482                out.push('"');
16483            }
16484        }
16485    }
16486    out
16487}
16488
16489/// v7.17.0 Phase 3.P0-39 — pub re-export so pgwire + sqllogictest
16490/// share the single hstore renderer.
16491pub fn format_hstore_text(
16492    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
16493) -> alloc::string::String {
16494    format_hstore_str(pairs)
16495}
16496
16497// ─── v7.17.0 Phase 3.P0-40 — 2D array parse + display ─────────
16498
16499/// Split a PG external 2D-array literal `'{{a,b},{c,d}}'` into
16500/// per-row token lists. Returns Err on shape mismatch.
16501fn split_2d_literal(s: &str) -> Result<Vec<Vec<alloc::string::String>>, &'static str> {
16502    let s = s.trim();
16503    let outer = s
16504        .strip_prefix('{')
16505        .and_then(|x| x.strip_suffix('}'))
16506        .ok_or("missing outer '{...}' braces")?;
16507    let trimmed = outer.trim();
16508    if trimmed.is_empty() {
16509        return Ok(Vec::new());
16510    }
16511    let mut rows: Vec<Vec<alloc::string::String>> = Vec::new();
16512    let mut i = 0;
16513    let bytes = trimmed.as_bytes();
16514    while i < bytes.len() {
16515        while i < bytes.len() && matches!(bytes[i], b' ' | b'\t' | b'\n' | b'\r' | b',') {
16516            i += 1;
16517        }
16518        if i >= bytes.len() {
16519            break;
16520        }
16521        if bytes[i] != b'{' {
16522            return Err("expected '{' opening a row");
16523        }
16524        i += 1;
16525        let row_start = i;
16526        let mut depth = 1;
16527        while i < bytes.len() && depth > 0 {
16528            match bytes[i] {
16529                b'{' => depth += 1,
16530                b'}' => depth -= 1,
16531                _ => {}
16532            }
16533            if depth > 0 {
16534                i += 1;
16535            }
16536        }
16537        if depth != 0 {
16538            return Err("unbalanced '{...}' in row");
16539        }
16540        let row_text = &trimmed[row_start..i];
16541        i += 1;
16542        let cells: Vec<alloc::string::String> = if row_text.trim().is_empty() {
16543            Vec::new()
16544        } else {
16545            row_text.split(',').map(|t| t.trim().to_string()).collect()
16546        };
16547        rows.push(cells);
16548    }
16549    if let Some(first) = rows.first() {
16550        let cols = first.len();
16551        for r in &rows {
16552            if r.len() != cols {
16553                return Err("ragged 2D array (rows have different column counts)");
16554            }
16555        }
16556    }
16557    Ok(rows)
16558}
16559
16560fn parse_int_2d_literal(s: &str) -> Result<Vec<Vec<Option<i32>>>, &'static str> {
16561    let raw = split_2d_literal(s)?;
16562    raw.into_iter()
16563        .map(|row| {
16564            row.into_iter()
16565                .map(|cell| {
16566                    if cell.eq_ignore_ascii_case("NULL") {
16567                        Ok(None)
16568                    } else {
16569                        cell.parse::<i32>()
16570                            .map(Some)
16571                            .map_err(|_| "invalid int element")
16572                    }
16573                })
16574                .collect()
16575        })
16576        .collect()
16577}
16578
16579fn parse_bigint_2d_literal(s: &str) -> Result<Vec<Vec<Option<i64>>>, &'static str> {
16580    let raw = split_2d_literal(s)?;
16581    raw.into_iter()
16582        .map(|row| {
16583            row.into_iter()
16584                .map(|cell| {
16585                    if cell.eq_ignore_ascii_case("NULL") {
16586                        Ok(None)
16587                    } else {
16588                        cell.parse::<i64>()
16589                            .map(Some)
16590                            .map_err(|_| "invalid bigint element")
16591                    }
16592                })
16593                .collect()
16594        })
16595        .collect()
16596}
16597
16598fn parse_text_2d_literal(s: &str) -> Result<Vec<Vec<Option<alloc::string::String>>>, &'static str> {
16599    let raw = split_2d_literal(s)?;
16600    Ok(raw
16601        .into_iter()
16602        .map(|row| {
16603            row.into_iter()
16604                .map(|cell| {
16605                    if cell.eq_ignore_ascii_case("NULL") {
16606                        None
16607                    } else {
16608                        Some(cell.trim_matches('"').to_string())
16609                    }
16610                })
16611                .collect()
16612        })
16613        .collect())
16614}
16615
16616fn format_int_2d_text(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
16617    let mut out = alloc::string::String::from("{");
16618    for (i, row) in rows.iter().enumerate() {
16619        if i > 0 {
16620            out.push(',');
16621        }
16622        out.push('{');
16623        for (j, cell) in row.iter().enumerate() {
16624            if j > 0 {
16625                out.push(',');
16626            }
16627            match cell {
16628                None => out.push_str("NULL"),
16629                Some(n) => out.push_str(&alloc::format!("{n}")),
16630            }
16631        }
16632        out.push('}');
16633    }
16634    out.push('}');
16635    out
16636}
16637
16638fn format_bigint_2d_text(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
16639    let mut out = alloc::string::String::from("{");
16640    for (i, row) in rows.iter().enumerate() {
16641        if i > 0 {
16642            out.push(',');
16643        }
16644        out.push('{');
16645        for (j, cell) in row.iter().enumerate() {
16646            if j > 0 {
16647                out.push(',');
16648            }
16649            match cell {
16650                None => out.push_str("NULL"),
16651                Some(n) => out.push_str(&alloc::format!("{n}")),
16652            }
16653        }
16654        out.push('}');
16655    }
16656    out.push('}');
16657    out
16658}
16659
16660fn format_text_2d_text(rows: &[Vec<Option<alloc::string::String>>]) -> alloc::string::String {
16661    let mut out = alloc::string::String::from("{");
16662    for (i, row) in rows.iter().enumerate() {
16663        if i > 0 {
16664            out.push(',');
16665        }
16666        out.push('{');
16667        for (j, cell) in row.iter().enumerate() {
16668            if j > 0 {
16669                out.push(',');
16670            }
16671            match cell {
16672                None => out.push_str("NULL"),
16673                Some(s) => out.push_str(s),
16674            }
16675        }
16676        out.push('}');
16677    }
16678    out.push('}');
16679    out
16680}
16681
16682/// v7.17.0 Phase 3.P0-40 — pub re-exports so pgwire + sqllogictest
16683/// share the single 2D-array renderer.
16684pub fn format_int_2d_text_pub(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
16685    format_int_2d_text(rows)
16686}
16687pub fn format_bigint_2d_text_pub(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
16688    format_bigint_2d_text(rows)
16689}
16690pub fn format_text_2d_text_pub(
16691    rows: &[Vec<Option<alloc::string::String>>],
16692) -> alloc::string::String {
16693    format_text_2d_text(rows)
16694}
16695
16696/// v7.17.0 Phase 3.P0-38 — parse a PG range literal of the form
16697/// `'[lo,up)'` / `'(lo,up]'` / `'[lo,up]'` / `'(lo,up)'` /
16698/// `'empty'`. Lower / upper may be empty (unbounded). Returns
16699/// `None` on any parse failure; caller surfaces as hard error.
16700fn parse_range_str(s: &str, kind: spg_storage::RangeKind) -> Option<Value> {
16701    let s = s.trim();
16702    if s.eq_ignore_ascii_case("empty") {
16703        return Some(Value::Range {
16704            kind,
16705            lower: None,
16706            upper: None,
16707            lower_inc: false,
16708            upper_inc: false,
16709            empty: true,
16710        });
16711    }
16712    let bytes = s.as_bytes();
16713    if bytes.len() < 3 {
16714        return None;
16715    }
16716    let lower_inc = match bytes[0] {
16717        b'[' => true,
16718        b'(' => false,
16719        _ => return None,
16720    };
16721    let upper_inc = match bytes[bytes.len() - 1] {
16722        b']' => true,
16723        b')' => false,
16724        _ => return None,
16725    };
16726    let inner = &s[1..s.len() - 1];
16727    let (lo_text, up_text) = inner.split_once(',')?;
16728    let lower = if lo_text.is_empty() {
16729        None
16730    } else {
16731        Some(alloc::boxed::Box::new(parse_range_element(lo_text, kind)?))
16732    };
16733    let upper = if up_text.is_empty() {
16734        None
16735    } else {
16736        Some(alloc::boxed::Box::new(parse_range_element(up_text, kind)?))
16737    };
16738    Some(Value::Range {
16739        kind,
16740        lower,
16741        upper,
16742        lower_inc,
16743        upper_inc,
16744        empty: false,
16745    })
16746}
16747
16748/// v7.17.0 Phase 3.P0-38 — parse a single range bound text into
16749/// the matching element Value for the RangeKind.
16750fn parse_range_element(text: &str, kind: spg_storage::RangeKind) -> Option<Value> {
16751    let text = text.trim().trim_matches('"');
16752    use spg_storage::RangeKind as K;
16753    match kind {
16754        K::Int4 => text.parse::<i32>().ok().map(Value::Int),
16755        K::Int8 => text.parse::<i64>().ok().map(Value::BigInt),
16756        K::Num => {
16757            // Reuse the Numeric parse via the engine's text-coercion
16758            // path; bail to None on failure.
16759            let dot = text.find('.');
16760            let scale: u8 = dot.map_or(0, |p| (text.len() - p - 1) as u8);
16761            let digits: alloc::string::String = text
16762                .chars()
16763                .filter(|c| *c == '-' || c.is_ascii_digit())
16764                .collect();
16765            let scaled: i128 = digits.parse().ok()?;
16766            Some(Value::Numeric { scaled, scale })
16767        }
16768        K::Ts | K::TsTz => {
16769            // Reuse the existing timestamp parse path. v7.17.0
16770            // expects `'YYYY-MM-DD HH:MM:SS[.ffffff]'` in range
16771            // bounds (TZ offset on TsTz is OOS for the initial
16772            // P0-38; ship plain Timestamp shape).
16773            crate::eval::parse_timestamp_literal(text).map(Value::Timestamp)
16774        }
16775        K::Date => crate::eval::parse_date_literal(text).map(Value::Date),
16776    }
16777}
16778
16779/// v7.17.0 Phase 3.P0-38 — render a Range value as its canonical
16780/// PG text form. Re-exported via [`format_range_text`] for use
16781/// from spg-server's pgwire layer.
16782pub fn format_range_text(v: &Value) -> alloc::string::String {
16783    format_range_str(v)
16784}
16785
16786fn format_range_str(v: &Value) -> alloc::string::String {
16787    let Value::Range {
16788        lower,
16789        upper,
16790        lower_inc,
16791        upper_inc,
16792        empty,
16793        ..
16794    } = v
16795    else {
16796        return alloc::string::String::new();
16797    };
16798    if *empty {
16799        return "empty".into();
16800    }
16801    let mut out = alloc::string::String::new();
16802    out.push(if *lower_inc { '[' } else { '(' });
16803    if let Some(l) = lower {
16804        out.push_str(&format_range_element(l));
16805    }
16806    out.push(',');
16807    if let Some(u) = upper {
16808        out.push_str(&format_range_element(u));
16809    }
16810    out.push(if *upper_inc { ']' } else { ')' });
16811    out
16812}
16813
16814fn format_range_element(v: &Value) -> alloc::string::String {
16815    match v {
16816        Value::Int(n) => alloc::format!("{n}"),
16817        Value::BigInt(n) => alloc::format!("{n}"),
16818        Value::Date(d) => crate::eval::format_date(*d),
16819        Value::Timestamp(t) => crate::eval::format_timestamp(*t),
16820        Value::Numeric { scaled, scale } => crate::eval::format_numeric(*scaled, *scale),
16821        other => alloc::format!("{other:?}"),
16822    }
16823}
16824
16825/// v7.17.0 Phase 3.P0-35 — parse a PG `money` literal into i64
16826/// cents. Accepts:
16827///   * Optional leading `-` (negative)
16828///   * Optional `$` prefix
16829///   * Integer portion with optional `,` thousands separators
16830///   * Optional `.` followed by 1-2 digits (cents); 1 digit
16831///     auto-pads to 2 (`.5` → 50 cents).
16832///
16833/// Returns None on any parse failure — caller surfaces as hard
16834/// SQL error.
16835fn parse_money_str(s: &str) -> Option<i64> {
16836    let s = s.trim();
16837    let (neg, rest) = match s.strip_prefix('-') {
16838        Some(r) => (true, r.trim_start()),
16839        None => (false, s),
16840    };
16841    let rest = rest.strip_prefix('$').unwrap_or(rest).trim_start();
16842    let (int_part, frac_part) = match rest.split_once('.') {
16843        Some((i, f)) => (i, Some(f)),
16844        None => (rest, None),
16845    };
16846    if int_part.is_empty() {
16847        return None;
16848    }
16849    // Validate + strip commas from the integer portion.
16850    let mut int_digits = alloc::string::String::with_capacity(int_part.len());
16851    for b in int_part.bytes() {
16852        match b {
16853            b',' => {}
16854            b'0'..=b'9' => int_digits.push(b as char),
16855            _ => return None,
16856        }
16857    }
16858    if int_digits.is_empty() {
16859        return None;
16860    }
16861    let dollars: i64 = int_digits.parse().ok()?;
16862    let cents: i64 = match frac_part {
16863        None => 0,
16864        Some(f) => {
16865            if f.is_empty() || f.len() > 2 || !f.bytes().all(|b| b.is_ascii_digit()) {
16866                return None;
16867            }
16868            let padded = if f.len() == 1 {
16869                alloc::format!("{f}0")
16870            } else {
16871                f.to_string()
16872            };
16873            padded.parse().ok()?
16874        }
16875    };
16876    let total = dollars.checked_mul(100)?.checked_add(cents)?;
16877    Some(if neg { -total } else { total })
16878}
16879
16880/// v7.17.0 Phase 3.P0-34 — parse a PG `timetz` literal
16881/// `HH:MM:SS[.fraction]±HH[:MM]` into (us, offset_secs).
16882///
16883/// The offset suffix is MANDATORY: SPG doesn't have a session TZ
16884/// wired into eval, so a bare `HH:MM:SS` literal would be
16885/// ambiguous. Returns None for any parse failure or out-of-range
16886/// component — caller surfaces as a hard SQL error.
16887///
16888/// Offset range: ±14 hours (±50400 seconds), matching PG's
16889/// internal limit.
16890fn parse_timetz_str(s: &str) -> Option<(i64, i32)> {
16891    let s = s.trim();
16892    // Find the offset sign — scan from right since the time part
16893    // never contains '+' / '-' (after the optional fractional dot
16894    // it's all digits and ':').
16895    let bytes = s.as_bytes();
16896    let sign_pos = bytes
16897        .iter()
16898        .enumerate()
16899        .rev()
16900        .find(|&(_, &b)| b == b'+' || b == b'-')
16901        .map(|(i, _)| i)?;
16902    if sign_pos == 0 {
16903        return None; // bare sign — no time component
16904    }
16905    let time_part = &s[..sign_pos];
16906    let offset_part = &s[sign_pos..];
16907    let us = parse_time_str(time_part)?;
16908    let sign: i32 = if offset_part.starts_with('+') { 1 } else { -1 };
16909    let offset_body = &offset_part[1..];
16910    let (hh_str, mm_str) = match offset_body.split_once(':') {
16911        Some((h, m)) => (h, m),
16912        None => (offset_body, "0"),
16913    };
16914    let hh: i32 = hh_str.parse().ok()?;
16915    let mm: i32 = mm_str.parse().ok()?;
16916    if !(0..=14).contains(&hh) || !(0..=59).contains(&mm) {
16917        return None;
16918    }
16919    let total = sign * (hh * 3600 + mm * 60);
16920    if total.abs() > 50_400 {
16921        return None;
16922    }
16923    Some((us, total))
16924}
16925
16926/// v7.17.0 Phase 3.P0-33 — funnel an integer literal through MySQL
16927/// YEAR range validation: 0 sentinel or 1901..=2155. Out-of-range
16928/// surfaces as a hard SQL error (no silent truncation, mirrors PG
16929/// `time_in` / `uuid_in` discipline).
16930fn coerce_int_to_year(n: i64, col_name: &str) -> Result<Value, EngineError> {
16931    if n == 0 || (1901..=2155).contains(&n) {
16932        // u16::try_from cannot fail in this range; the cast also
16933        // covers the 0 sentinel.
16934        return Ok(Value::Year(n as u16));
16935    }
16936    Err(EngineError::Eval(EvalError::TypeMismatch {
16937        detail: alloc::format!(
16938            "year value out of range: {n} (column `{col_name}`; \
16939             MySQL accepts 0 or 1901..=2155)"
16940        ),
16941    }))
16942}
16943
16944/// v7.17.0 Phase 3.P0-32 — parse a PG `time` literal
16945/// `HH:MM:SS[.fraction]` into microseconds since 00:00:00.
16946///
16947/// Accepts:
16948///   * `HH:MM:SS`            — exact-second precision
16949///   * `HH:MM:SS.f` .. `.ffffff` — 1-6 fractional digits, right-padded
16950///     with zeros to microseconds
16951///
16952/// Range: hour 0..=23, minute 0..=59, second 0..=59. Anything else
16953/// returns None — caller surfaces as a hard SQL error (no silent
16954/// truncation, matches PG's `time_in` behaviour).
16955fn parse_time_str(s: &str) -> Option<i64> {
16956    let s = s.trim();
16957    let (hms, frac) = match s.split_once('.') {
16958        Some((h, f)) => (h, Some(f)),
16959        None => (s, None),
16960    };
16961    let mut parts = hms.split(':');
16962    let hh: u32 = parts.next()?.parse().ok()?;
16963    let mm: u32 = parts.next()?.parse().ok()?;
16964    let ss: u32 = parts.next()?.parse().ok()?;
16965    if parts.next().is_some() {
16966        return None;
16967    }
16968    if hh > 23 || mm > 59 || ss > 59 {
16969        return None;
16970    }
16971    let frac_us: i64 = match frac {
16972        None => 0,
16973        Some(f) => {
16974            if f.is_empty() || f.len() > 6 || !f.bytes().all(|b| b.is_ascii_digit()) {
16975                return None;
16976            }
16977            // Right-pad with zeros so '.5' = 500000 µsec.
16978            let mut padded = alloc::string::String::with_capacity(6);
16979            padded.push_str(f);
16980            while padded.len() < 6 {
16981                padded.push('0');
16982            }
16983            padded.parse().ok()?
16984        }
16985    };
16986    Some(
16987        i64::from(hh) * 3_600_000_000
16988            + i64::from(mm) * 60_000_000
16989            + i64::from(ss) * 1_000_000
16990            + frac_us,
16991    )
16992}
16993
16994const fn column_type_to_data_type(t: ColumnTypeName) -> DataType {
16995    match t {
16996        ColumnTypeName::SmallInt => DataType::SmallInt,
16997        ColumnTypeName::Int => DataType::Int,
16998        ColumnTypeName::BigInt => DataType::BigInt,
16999        ColumnTypeName::Float => DataType::Float,
17000        ColumnTypeName::Text => DataType::Text,
17001        ColumnTypeName::Varchar(n) => DataType::Varchar(n),
17002        ColumnTypeName::Char(n) => DataType::Char(n),
17003        ColumnTypeName::Bool => DataType::Bool,
17004        ColumnTypeName::Vector { dim, encoding } => DataType::Vector {
17005            dim,
17006            encoding: match encoding {
17007                SqlVecEncoding::F32 => VecEncoding::F32,
17008                SqlVecEncoding::Sq8 => VecEncoding::Sq8,
17009                SqlVecEncoding::F16 => VecEncoding::F16,
17010            },
17011        },
17012        ColumnTypeName::Numeric(precision, scale) => DataType::Numeric { precision, scale },
17013        ColumnTypeName::Date => DataType::Date,
17014        ColumnTypeName::Timestamp => DataType::Timestamp,
17015        ColumnTypeName::Timestamptz => DataType::Timestamptz,
17016        ColumnTypeName::Json => DataType::Json,
17017        ColumnTypeName::Jsonb => DataType::Jsonb,
17018        ColumnTypeName::Bytes => DataType::Bytes,
17019        ColumnTypeName::TextArray => DataType::TextArray,
17020        ColumnTypeName::IntArray => DataType::IntArray,
17021        ColumnTypeName::BigIntArray => DataType::BigIntArray,
17022        ColumnTypeName::TsVector => DataType::TsVector,
17023        ColumnTypeName::TsQuery => DataType::TsQuery,
17024        ColumnTypeName::Uuid => DataType::Uuid,
17025        ColumnTypeName::Time => DataType::Time,
17026        ColumnTypeName::Year => DataType::Year,
17027        ColumnTypeName::TimeTz => DataType::TimeTz,
17028        ColumnTypeName::Money => DataType::Money,
17029        ColumnTypeName::Range(k) => DataType::Range(match k {
17030            spg_sql::ast::RangeKindAst::Int4 => spg_storage::RangeKind::Int4,
17031            spg_sql::ast::RangeKindAst::Int8 => spg_storage::RangeKind::Int8,
17032            spg_sql::ast::RangeKindAst::Num => spg_storage::RangeKind::Num,
17033            spg_sql::ast::RangeKindAst::Ts => spg_storage::RangeKind::Ts,
17034            spg_sql::ast::RangeKindAst::TsTz => spg_storage::RangeKind::TsTz,
17035            spg_sql::ast::RangeKindAst::Date => spg_storage::RangeKind::Date,
17036        }),
17037        ColumnTypeName::Hstore => DataType::Hstore,
17038        ColumnTypeName::IntArray2D => DataType::IntArray2D,
17039        ColumnTypeName::BigIntArray2D => DataType::BigIntArray2D,
17040        ColumnTypeName::TextArray2D => DataType::TextArray2D,
17041    }
17042}
17043
17044/// Convert an INSERT VALUES expression to a storage Value. Supports literal
17045/// expressions, unary-minus over numeric literals, and pgvector-style
17046/// `'[..]'::vector` cast (v1.2). Anything more complex returns `Unsupported`.
17047fn literal_expr_to_value(expr: Expr) -> Result<Value, EngineError> {
17048    match expr {
17049        Expr::Literal(l) => Ok(literal_to_value(l)),
17050        Expr::Cast { expr, target } => {
17051            let inner_value = literal_expr_to_value(*expr)?;
17052            crate::eval::cast_value(inner_value, target).map_err(EngineError::Eval)
17053        }
17054        Expr::Unary {
17055            op: UnOp::Neg,
17056            expr,
17057        } => match *expr {
17058            Expr::Literal(Literal::Integer(n)) => {
17059                // Fold to i32 if it fits, else BigInt. Parser emits Integer(i64)
17060                // — overflow on negate of i64::MIN is the one edge case.
17061                let neg = n.checked_neg().ok_or_else(|| {
17062                    EngineError::Unsupported("integer literal overflow on negation".into())
17063                })?;
17064                Ok(int_value_for(neg))
17065            }
17066            Expr::Literal(Literal::Float(x)) => Ok(Value::Float(-x)),
17067            other => Err(EngineError::Unsupported(alloc::format!(
17068                "unary minus over non-literal expression: {other:?}"
17069            ))),
17070        },
17071        // v7.10.10 — `ARRAY[lit, lit, …]` constructor accepted at
17072        // INSERT-time. Each element must reduce to a Value through
17073        // `literal_expr_to_value`; NULL elements become `None`.
17074        // v7.11.13 — deduce shape from element values: all Int →
17075        // IntArray; any BigInt → BigIntArray (widening); any Text
17076        // → TextArray. Cast targets (`ARRAY[]::INT[]`) flow through
17077        // the outer Cast arm before reaching here and re-coerce.
17078        Expr::Array(items) => {
17079            let mut materialised: alloc::vec::Vec<Value> =
17080                alloc::vec::Vec::with_capacity(items.len());
17081            for elem in items {
17082                materialised.push(literal_expr_to_value(elem)?);
17083            }
17084            Ok(array_literal_widen(materialised))
17085        }
17086        // Any other Expr shape — fall back to a general evaluation
17087        // against an empty row + empty schema. This unblocks the
17088        // app-common patterns where INSERT VALUES carries a
17089        // non-correlated function call:
17090        //   INSERT INTO t VALUES (concat('U-', 42))
17091        //   INSERT INTO t VALUES (now())
17092        //   INSERT INTO t VALUES (format('%s-%s', 'a', 'b'))
17093        // Any expression that references a column or `$N`
17094        // placeholder fails cleanly inside `eval_expr` with a
17095        // descriptive error; literals + casts + ARRAY[…] continue
17096        // to take the fast paths above so the hot INSERT path is
17097        // unchanged on the common case.
17098        other => {
17099            let empty_schema: alloc::vec::Vec<spg_storage::ColumnSchema> = alloc::vec::Vec::new();
17100            let ctx = EvalContext::new(&empty_schema, None);
17101            let empty_row = spg_storage::Row::new(alloc::vec::Vec::new());
17102            crate::eval::eval_expr(&other, &empty_row, &ctx).map_err(EngineError::Eval)
17103        }
17104    }
17105}
17106
17107fn literal_to_value(l: Literal) -> Value {
17108    match l {
17109        Literal::Integer(n) => int_value_for(n),
17110        Literal::Float(x) => Value::Float(x),
17111        Literal::String(s) => Value::Text(s),
17112        Literal::Bool(b) => Value::Bool(b),
17113        Literal::Null => Value::Null,
17114        Literal::Vector(v) => Value::Vector(v),
17115        Literal::TextArray(items) => Value::TextArray(items),
17116        Literal::IntArray(items) => Value::IntArray(items),
17117        Literal::BigIntArray(items) => Value::BigIntArray(items),
17118        Literal::Interval { months, micros, .. } => Value::Interval { months, micros },
17119    }
17120}
17121
17122/// Pick `Int` (`i32`) when the literal fits, else `BigInt`. `INT` vs `BIGINT`
17123/// columns will still enforce the right tag downstream — this is just the
17124/// default we synthesise from an unannotated integer literal.
17125fn int_value_for(n: i64) -> Value {
17126    if let Ok(small) = i32::try_from(n) {
17127        Value::Int(small)
17128    } else {
17129        Value::BigInt(n)
17130    }
17131}
17132
17133/// Widen / narrow `v` to fit `expected`. Numerics permit safe widening
17134/// (`Int → BigInt`, `Int/BigInt → Float`) and best-effort narrowing
17135/// (`BigInt → Int` succeeds only when the value fits in `i32`). Everything
17136/// else returns `TypeMismatch` carrying the column name for caller diagnostics.
17137/// `NULL` is always permitted; the nullability check happens later in storage.
17138#[allow(clippy::too_many_lines)]
17139/// v7.17.0 Phase 4.4 — reject negative integer values on UNSIGNED
17140/// columns. Called after `coerce_value` at each INSERT / UPDATE
17141/// site that has ColumnSchema context. NULL passes through (a
17142/// nullable UNSIGNED column can legitimately hold NULL).
17143fn check_unsigned_range(
17144    v: &Value,
17145    schema: &ColumnSchema,
17146    position: usize,
17147) -> Result<(), EngineError> {
17148    if !schema.is_unsigned {
17149        return Ok(());
17150    }
17151    let n = match v {
17152        Value::SmallInt(x) => i64::from(*x),
17153        Value::Int(x) => i64::from(*x),
17154        Value::BigInt(x) => *x,
17155        _ => return Ok(()), // non-integer cells (NULL, default) skip
17156    };
17157    if n < 0 {
17158        return Err(EngineError::Unsupported(alloc::format!(
17159            "column {:?} is UNSIGNED but got negative value {n} at position {position}",
17160            schema.name
17161        )));
17162    }
17163    Ok(())
17164}
17165
17166fn coerce_value(
17167    v: Value,
17168    expected: DataType,
17169    col_name: &str,
17170    position: usize,
17171) -> Result<Value, EngineError> {
17172    if v.is_null() {
17173        return Ok(Value::Null);
17174    }
17175    let actual = v.data_type().expect("non-null");
17176    if actual == expected {
17177        return Ok(v);
17178    }
17179    let coerced = match (v, expected) {
17180        (Value::Int(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
17181        (Value::Int(n), DataType::Float) => Some(Value::Float(f64::from(n))),
17182        (Value::Int(n), DataType::SmallInt) => i16::try_from(n).ok().map(Value::SmallInt),
17183        (Value::Int(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
17184            i128::from(n),
17185            precision,
17186            scale,
17187            col_name,
17188        )?),
17189        (Value::SmallInt(n), DataType::Int) => Some(Value::Int(i32::from(n))),
17190        (Value::SmallInt(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
17191        (Value::SmallInt(n), DataType::Float) => Some(Value::Float(f64::from(n))),
17192        (Value::SmallInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
17193            i128::from(n),
17194            precision,
17195            scale,
17196            col_name,
17197        )?),
17198        (Value::BigInt(n), DataType::Int) => i32::try_from(n).ok().map(Value::Int),
17199        (Value::BigInt(n), DataType::SmallInt) => i16::try_from(n).ok().map(Value::SmallInt),
17200        #[allow(clippy::cast_precision_loss)]
17201        (Value::BigInt(n), DataType::Float) => Some(Value::Float(n as f64)),
17202        (Value::BigInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
17203            i128::from(n),
17204            precision,
17205            scale,
17206            col_name,
17207        )?),
17208        (Value::Float(x), DataType::Numeric { precision, scale }) => {
17209            Some(numeric_from_float(x, precision, scale, col_name)?)
17210        }
17211        // v7.17.0 Phase 3.P0-67 — Text → NUMERIC. Parse a
17212        // canonical decimal text (`"-1234.56"` / `"42"` /
17213        // `"0.0001"`) into `(mantissa, source_scale)` and rescale
17214        // to the column's declared scale. Required for prepared
17215        // binds: `value_to_literal` flattens a Value::Numeric
17216        // into a TEXT literal because Literal carries no native
17217        // Numeric variant, so the placeholder substitution path
17218        // reaches coerce_value as Text → Numeric. Without this
17219        // arm the round-trip surfaces a TypeMismatch even though
17220        // the cell already left the engine as a valid Numeric.
17221        (Value::Text(s), DataType::Numeric { precision, scale }) => {
17222            let Some((mantissa, src_scale)) = parse_numeric_text(&s) else {
17223                return Err(EngineError::Eval(EvalError::TypeMismatch {
17224                    detail: alloc::format!("cannot parse {s:?} as NUMERIC for column `{col_name}`"),
17225                }));
17226            };
17227            Some(numeric_rescale(
17228                mantissa, src_scale, precision, scale, col_name,
17229            )?)
17230        }
17231        // Text → DATE / TIMESTAMP: parse canonical text forms.
17232        (Value::Text(s), DataType::Date) => {
17233            let d = eval::parse_date_literal(&s).ok_or_else(|| {
17234                EngineError::Eval(EvalError::TypeMismatch {
17235                    detail: alloc::format!("cannot parse {s:?} as DATE for column `{col_name}`"),
17236                })
17237            })?;
17238            Some(Value::Date(d))
17239        }
17240        // v7.14.0 — MySQL DEFAULT clauses quote integer / float
17241        // / boolean literals (`DEFAULT '0'`, `DEFAULT '1'`,
17242        // `DEFAULT '3.14'`, `DEFAULT 'true'`). Coerce the text
17243        // form to the column's numeric / bool type at DEFAULT-
17244        // installation time so the storage check sees a typed
17245        // value. Parse failures fall through to TypeMismatch.
17246        (Value::Text(s), DataType::SmallInt) => s.parse::<i16>().ok().map(Value::SmallInt),
17247        (Value::Text(s), DataType::Int) => s.parse::<i32>().ok().map(Value::Int),
17248        (Value::Text(s), DataType::BigInt) => s.parse::<i64>().ok().map(Value::BigInt),
17249        (Value::Text(s), DataType::Float) => s.parse::<f64>().ok().map(Value::Float),
17250        (Value::Text(s), DataType::Bool) => match s.to_ascii_lowercase().as_str() {
17251            "0" | "false" | "f" | "no" | "off" => Some(Value::Bool(false)),
17252            "1" | "true" | "t" | "yes" | "on" => Some(Value::Bool(true)),
17253            _ => None,
17254        },
17255        // v7.17.0 Phase 3.P0-46 — MySQL TINYINT(1) (which Phase 4.3
17256        // classifies as DataType::Bool) is the storage shape every
17257        // mysqldump-restored boolean column lands in. mysqldump emits
17258        // the values as integer `0` / `1` literals, so int → bool
17259        // coerce on INSERT is required for a 0-change cutover. MySQL's
17260        // rule is "any non-zero is truthy"; we follow that for all
17261        // signed int widths so the same coerce path serves an
17262        // explicit `BOOLEAN` column too.
17263        (Value::Int(n), DataType::Bool) => Some(Value::Bool(n != 0)),
17264        (Value::SmallInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
17265        (Value::BigInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
17266        // v4.9: Text ↔ JSON coercion. No structural validation —
17267        // any text literal is accepted; the responsibility for
17268        // valid JSON lies with the producer.
17269        (Value::Text(s), DataType::Json | DataType::Jsonb) => Some(Value::Json(s)),
17270        (Value::Json(s), DataType::Text) => Some(Value::Text(s)),
17271        // v7.13.3 — mailrs round-7 S10. SPG's storage represents
17272        // both JSON and JSONB on-disk as `Value::Json(String)` —
17273        // they share the underlying text payload. The cast
17274        // `'<text>'::jsonb` produces a Value::Json that needs to
17275        // satisfy a DataType::Jsonb column. Identity coerce in
17276        // both directions so JSON ↔ JSONB assignments work at all
17277        // INSERT / ALTER COLUMN TYPE / DEFAULT contexts.
17278        (Value::Json(s), DataType::Jsonb | DataType::Json) => Some(Value::Json(s)),
17279        // v7.10.4 — Text → BYTEA. Decode PG-style literal forms:
17280        //   - Hex:    `\x48656c6c6f`  (case-insensitive hex pairs)
17281        //   - Escape: `Hello\\000world`  (backslash + octal triples)
17282        //   - Plain:  any string → raw UTF-8 bytes (PG also accepts)
17283        // Errors surface as TypeMismatch so the operator gets a
17284        // clear "this literal isn't a bytea literal" hint.
17285        (Value::Text(s), DataType::Bytes) => {
17286            let bytes = decode_bytea_literal(&s).map_err(|e| {
17287                EngineError::Eval(EvalError::TypeMismatch {
17288                    detail: alloc::format!(
17289                        "cannot parse {s:?} as BYTEA for column `{col_name}`: {e}"
17290                    ),
17291                })
17292            })?;
17293            Some(Value::Bytes(bytes))
17294        }
17295        // v7.10.4 — BYTEA → Text round-trip uses the PG hex
17296        // output (lowercase, `\x` prefix). Important when a
17297        // SELECT pulls a bytea cell through a Text column path.
17298        (Value::Bytes(b), DataType::Text) => Some(Value::Text(encode_bytea_hex(&b))),
17299        // v7.17.0 — Text → UUID. PG accepts canonical hyphenated,
17300        // unhyphenated, uppercase, and `{...}`-braced forms; we
17301        // funnel all four through `spg_storage::parse_uuid_str`.
17302        // A malformed literal surfaces as a SQL TypeMismatch
17303        // rather than silently inserting garbage — `0-change
17304        // cutover` requires that an app inserting bad UUID text
17305        // sees the same hard error PG would raise.
17306        (Value::Text(s), DataType::Uuid) => match spg_storage::parse_uuid_str(&s) {
17307            Some(b) => Some(Value::Uuid(b)),
17308            None => {
17309                return Err(EngineError::Eval(EvalError::TypeMismatch {
17310                    detail: alloc::format!(
17311                        "invalid input syntax for type uuid: {s:?} (column `{col_name}`)"
17312                    ),
17313                }));
17314            }
17315        },
17316        // v7.17.0 — UUID → Text canonical 8-4-4-4-12 lowercase.
17317        // Surfaces when a SELECT plucks a uuid cell through a
17318        // Text column path (e.g. INSERT INTO log SELECT id::text
17319        // FROM other_table).
17320        (Value::Uuid(b), DataType::Text) => Some(Value::Text(spg_storage::format_uuid(&b))),
17321        // v7.17.0 Phase 3.P0-32 — Text → TIME. Accepts
17322        // `HH:MM:SS` and `HH:MM:SS.ffffff` (1-6 fractional digits).
17323        // Out-of-range hour/min/sec is a hard SQL error (no
17324        // silent truncation — same 0-change-cutover discipline
17325        // we apply to UUID).
17326        (Value::Text(s), DataType::Time) => match parse_time_str(&s) {
17327            Some(us) => Some(Value::Time(us)),
17328            None => {
17329                return Err(EngineError::Eval(EvalError::TypeMismatch {
17330                    detail: alloc::format!(
17331                        "invalid input syntax for type time: {s:?} (column `{col_name}`)"
17332                    ),
17333                }));
17334            }
17335        },
17336        // v7.17.0 Phase 3.P0-32 — TIME → Text canonical `HH:MM:SS[.ffffff]`.
17337        (Value::Time(us), DataType::Text) => Some(Value::Text(eval::format_time(us))),
17338        // v7.17.0 Phase 3.P0-33 — int / bigint → YEAR. Range
17339        // check enforces the MySQL canonical 1901..=2155 + 0
17340        // sentinel; out-of-range is a hard SQL error (no silent
17341        // truncation, mirrors P0-32 / P0-25 discipline).
17342        (Value::SmallInt(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
17343        (Value::Int(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
17344        (Value::BigInt(n), DataType::Year) => Some(coerce_int_to_year(n, col_name)?),
17345        // Text → YEAR. Accepts the 4-digit decimal form only;
17346        // two-digit YEAR (`'99'` → 1999) was deprecated in MySQL
17347        // 5.7 and is out of scope for v7.17.0.
17348        (Value::Text(s), DataType::Year) => match s.trim().parse::<i64>() {
17349            Ok(n) => Some(coerce_int_to_year(n, col_name)?),
17350            Err(_) => {
17351                return Err(EngineError::Eval(EvalError::TypeMismatch {
17352                    detail: alloc::format!(
17353                        "invalid input syntax for type year: {s:?} (column `{col_name}`)"
17354                    ),
17355                }));
17356            }
17357        },
17358        // YEAR → Text 4-digit zero-padded.
17359        (Value::Year(y), DataType::Text) => Some(Value::Text(alloc::format!("{y:04}"))),
17360        // v7.17.0 Phase 3.P0-34 — Text → TIMETZ. Mandatory
17361        // signed offset suffix; missing offset is a hard error
17362        // (SPG has no session TZ wired into eval, unlike PG).
17363        (Value::Text(s), DataType::TimeTz) => match parse_timetz_str(&s) {
17364            Some((us, offset_secs)) => Some(Value::TimeTz { us, offset_secs }),
17365            None => {
17366                return Err(EngineError::Eval(EvalError::TypeMismatch {
17367                    detail: alloc::format!(
17368                        "invalid input syntax for type time with time zone: \
17369                         {s:?} (column `{col_name}`)"
17370                    ),
17371                }));
17372            }
17373        },
17374        // TIMETZ → Text canonical `HH:MM:SS[.ffffff]±HH[:MM]`.
17375        (Value::TimeTz { us, offset_secs }, DataType::Text) => {
17376            Some(Value::Text(eval::format_timetz(us, offset_secs)))
17377        }
17378        // v7.17.0 Phase 3.P0-35 — Text → MONEY. Accepts `$N.NN`,
17379        // `$N,NNN.NN`, optional leading `-`. Bare numeric literals
17380        // arrive via the Int/BigInt/Float/Numeric arms below.
17381        (Value::Text(s), DataType::Money) => match parse_money_str(&s) {
17382            Some(c) => Some(Value::Money(c)),
17383            None => {
17384                return Err(EngineError::Eval(EvalError::TypeMismatch {
17385                    detail: alloc::format!(
17386                        "invalid input syntax for type money: {s:?} (column `{col_name}`)"
17387                    ),
17388                }));
17389            }
17390        },
17391        // Int / BigInt / SmallInt / Float / Numeric → MONEY.
17392        // Bare numeric literal is interpreted as a major-unit
17393        // amount (matches PG: `100`::money → $100.00 = 10000 cents).
17394        (Value::SmallInt(n), DataType::Money) => {
17395            Some(Value::Money(i64::from(n).saturating_mul(100)))
17396        }
17397        (Value::Int(n), DataType::Money) => Some(Value::Money(i64::from(n).saturating_mul(100))),
17398        (Value::BigInt(n), DataType::Money) => Some(Value::Money(n.saturating_mul(100))),
17399        (Value::Float(x), DataType::Money) => {
17400            // Round half-away-from-zero to cents (no_std — no
17401            // `f64::round`, so hand-roll via biased truncation).
17402            let scaled = x * 100.0;
17403            let cents = if scaled >= 0.0 {
17404                (scaled + 0.5) as i64
17405            } else {
17406                (scaled - 0.5) as i64
17407            };
17408            Some(Value::Money(cents))
17409        }
17410        (Value::Numeric { scaled, scale }, DataType::Money) => {
17411            // Convert exact decimal to cents (scale 2). If scale > 2,
17412            // round half-away-from-zero. If scale < 2, multiply up.
17413            let cents = if scale == 2 {
17414                scaled
17415            } else if scale < 2 {
17416                let mult = 10_i128.pow(u32::from(2 - scale));
17417                scaled.saturating_mul(mult)
17418            } else {
17419                let div = 10_i128.pow(u32::from(scale - 2));
17420                let half = div / 2;
17421                let bias = if scaled >= 0 { half } else { -half };
17422                (scaled + bias) / div
17423            };
17424            Some(Value::Money(i64::try_from(cents).unwrap_or(i64::MAX)))
17425        }
17426        // MONEY → Text canonical `$N,NNN.CC`.
17427        (Value::Money(c), DataType::Text) => Some(Value::Text(eval::format_money(c))),
17428        // v7.17.0 Phase 3.P0-38 — Text → Range. Accepts canonical
17429        // PG forms: `'empty'`, `'[a,b)'`, `'(a,b]'`, `'[a,b]'`,
17430        // `'(a,b)'`, with empty lower or upper for unbounded.
17431        (Value::Text(s), DataType::Range(kind)) => match parse_range_str(&s, kind) {
17432            Some(v) => Some(v),
17433            None => {
17434                return Err(EngineError::Eval(EvalError::TypeMismatch {
17435                    detail: alloc::format!(
17436                        "invalid input syntax for range type: {s:?} (column `{col_name}`)"
17437                    ),
17438                }));
17439            }
17440        },
17441        // Range → Text canonical form (`[a,b)`, `'empty'`, etc).
17442        (v @ Value::Range { .. }, DataType::Text) => Some(Value::Text(format_range_str(&v))),
17443        // v7.17.0 Phase 3.P0-39 — Text → Hstore.
17444        (Value::Text(s), DataType::Hstore) => match parse_hstore_str(&s) {
17445            Some(pairs) => Some(Value::Hstore(pairs)),
17446            None => {
17447                return Err(EngineError::Eval(EvalError::TypeMismatch {
17448                    detail: alloc::format!(
17449                        "invalid input syntax for type hstore: {s:?} (column `{col_name}`)"
17450                    ),
17451                }));
17452            }
17453        },
17454        // Hstore → Text canonical `"k"=>"v"` form.
17455        (Value::Hstore(pairs), DataType::Text) => Some(Value::Text(format_hstore_str(&pairs))),
17456        // v7.17.0 Phase 3.P0-40 — Text → 2D arrays via PG
17457        // external `'{{a,b},{c,d}}'` literal.
17458        (Value::Text(s), DataType::IntArray2D) => match parse_int_2d_literal(&s) {
17459            Ok(m) => Some(Value::IntArray2D(m)),
17460            Err(e) => {
17461                return Err(EngineError::Eval(EvalError::TypeMismatch {
17462                    detail: alloc::format!(
17463                        "invalid input syntax for INT[][]: {s:?} (column `{col_name}`): {e}"
17464                    ),
17465                }));
17466            }
17467        },
17468        (Value::Text(s), DataType::BigIntArray2D) => match parse_bigint_2d_literal(&s) {
17469            Ok(m) => Some(Value::BigIntArray2D(m)),
17470            Err(e) => {
17471                return Err(EngineError::Eval(EvalError::TypeMismatch {
17472                    detail: alloc::format!(
17473                        "invalid input syntax for BIGINT[][]: {s:?} (column `{col_name}`): {e}"
17474                    ),
17475                }));
17476            }
17477        },
17478        (Value::Text(s), DataType::TextArray2D) => match parse_text_2d_literal(&s) {
17479            Ok(m) => Some(Value::TextArray2D(m)),
17480            Err(e) => {
17481                return Err(EngineError::Eval(EvalError::TypeMismatch {
17482                    detail: alloc::format!(
17483                        "invalid input syntax for TEXT[][]: {s:?} (column `{col_name}`): {e}"
17484                    ),
17485                }));
17486            }
17487        },
17488        // 2D arrays → Text canonical nested form.
17489        (Value::IntArray2D(rows), DataType::Text) => Some(Value::Text(format_int_2d_text(&rows))),
17490        (Value::BigIntArray2D(rows), DataType::Text) => {
17491            Some(Value::Text(format_bigint_2d_text(&rows)))
17492        }
17493        (Value::TextArray2D(rows), DataType::Text) => Some(Value::Text(format_text_2d_text(&rows))),
17494        // v7.10.11 — Text → TEXT[]. Decode PG's external array
17495        // form `'{a,b,NULL}'`. NULL element token (case-insensitive)
17496        // is the literal `NULL`; everything else is a quoted or
17497        // unquoted text element. mailrs `'{label1,label2}'::TEXT[]`.
17498        (Value::Text(s), DataType::TextArray) => {
17499            let arr = decode_text_array_literal(&s).map_err(|e| {
17500                EngineError::Eval(EvalError::TypeMismatch {
17501                    detail: alloc::format!(
17502                        "cannot parse {s:?} as TEXT[] for column `{col_name}`: {e}"
17503                    ),
17504                })
17505            })?;
17506            Some(Value::TextArray(arr))
17507        }
17508        // v7.16.0 — Text → IntArray / BigIntArray for the
17509        // spg-sqlx Bind path. Decode the PG external form
17510        // `{1,2,3}` as a TEXT array first, then parse each
17511        // element as int. Same shape as the TextArray decode
17512        // above with an element-wise narrow.
17513        (Value::Text(s), DataType::IntArray) => {
17514            let arr = decode_text_array_literal(&s).map_err(|e| {
17515                EngineError::Eval(EvalError::TypeMismatch {
17516                    detail: alloc::format!(
17517                        "cannot parse {s:?} as INT[] for column `{col_name}`: {e}"
17518                    ),
17519                })
17520            })?;
17521            let mut out: Vec<Option<i32>> = Vec::with_capacity(arr.len());
17522            for elem in arr {
17523                match elem {
17524                    None => out.push(None),
17525                    Some(t) => {
17526                        let n: i32 = t.parse().map_err(|_| {
17527                            EngineError::Eval(EvalError::TypeMismatch {
17528                                detail: alloc::format!(
17529                                    "cannot parse {t:?} as INT element for `{col_name}`"
17530                                ),
17531                            })
17532                        })?;
17533                        out.push(Some(n));
17534                    }
17535                }
17536            }
17537            Some(Value::IntArray(out))
17538        }
17539        (Value::Text(s), DataType::BigIntArray) => {
17540            let arr = decode_text_array_literal(&s).map_err(|e| {
17541                EngineError::Eval(EvalError::TypeMismatch {
17542                    detail: alloc::format!(
17543                        "cannot parse {s:?} as BIGINT[] for column `{col_name}`: {e}"
17544                    ),
17545                })
17546            })?;
17547            let mut out: Vec<Option<i64>> = Vec::with_capacity(arr.len());
17548            for elem in arr {
17549                match elem {
17550                    None => out.push(None),
17551                    Some(t) => {
17552                        let n: i64 = t.parse().map_err(|_| {
17553                            EngineError::Eval(EvalError::TypeMismatch {
17554                                detail: alloc::format!(
17555                                    "cannot parse {t:?} as BIGINT element for `{col_name}`"
17556                                ),
17557                            })
17558                        })?;
17559                        out.push(Some(n));
17560                    }
17561                }
17562            }
17563            Some(Value::BigIntArray(out))
17564        }
17565        // v7.10.11 — TEXT[] → Text round-trip uses PG's
17566        // external array form (`{a,b,NULL}`). Lets a SELECT
17567        // pull an array column through any Text-side codepath.
17568        (Value::TextArray(items), DataType::Text) => Some(Value::Text(encode_text_array(&items))),
17569        // v7.17.0 Phase 3.P0-68 — Text → VECTOR auto-coerce.
17570        // Matches the existing Text → TsVector arm and the
17571        // `::vector` cast: PG-canonical pgvector external form
17572        // (`'[1, 2, -3]'`) becomes a typed Vector value at the
17573        // column boundary. Dim mismatch surfaces as TypeMismatch.
17574        // For SQ8 / HALF encodings we chain through the standard
17575        // quantise helpers so the storage shape matches the
17576        // declared encoding without a second coerce pass.
17577        (Value::Text(s), DataType::Vector { dim, encoding }) => {
17578            let parsed = eval::parse_vector_text(&s).ok_or_else(|| {
17579                EngineError::Eval(EvalError::TypeMismatch {
17580                    detail: alloc::format!("cannot parse {s:?} as VECTOR for column `{col_name}`"),
17581                })
17582            })?;
17583            if parsed.len() != dim as usize {
17584                return Err(EngineError::Eval(EvalError::TypeMismatch {
17585                    detail: alloc::format!(
17586                        "VECTOR({dim}) column `{col_name}` rejects literal of length {}",
17587                        parsed.len()
17588                    ),
17589                }));
17590            }
17591            Some(match encoding {
17592                VecEncoding::F32 => Value::Vector(parsed),
17593                VecEncoding::Sq8 => Value::Sq8Vector(spg_storage::quantize::quantize(&parsed)),
17594                VecEncoding::F16 => {
17595                    Value::HalfVector(spg_storage::halfvec::HalfVector::from_f32_slice(&parsed))
17596                }
17597            })
17598        }
17599        // v7.16.1 — Text → TSVECTOR auto-coerce for the
17600        // INSERT-side wire path (mailrs round-9 A.2.a). PG
17601        // implicitly promotes the TEXT literal at INSERT into a
17602        // TSVECTOR column; SPG previously rejected with a hard
17603        // type mismatch, blocking 23,276 pg_dump rows into
17604        // `messages.search_vector`. We route through the same
17605        // `decode_tsvector_external` the `::tsvector` cast
17606        // already uses, so PG-canonical forms (`'word'`,
17607        // `'word:1A,2B'`, multi-lexeme, empty `''`) all parse.
17608        (Value::Text(s), DataType::TsVector) => {
17609            let lexs = eval::decode_tsvector_external(&s).map_err(|e| {
17610                EngineError::Eval(EvalError::TypeMismatch {
17611                    detail: alloc::format!(
17612                        "cannot parse {s:?} as TSVECTOR for column `{col_name}`: {e}"
17613                    ),
17614                })
17615            })?;
17616            Some(Value::TsVector(lexs))
17617        }
17618        (Value::Text(s), DataType::Timestamp | DataType::Timestamptz) => {
17619            let t = eval::parse_timestamp_literal(&s).ok_or_else(|| {
17620                EngineError::Eval(EvalError::TypeMismatch {
17621                    detail: alloc::format!(
17622                        "cannot parse {s:?} as TIMESTAMP for column `{col_name}`"
17623                    ),
17624                })
17625            })?;
17626            Some(Value::Timestamp(t))
17627        }
17628        // DATE ↔ TIMESTAMP convertibility (DATE → midnight,
17629        // TIMESTAMP → day truncation).
17630        (Value::Date(d), DataType::Timestamp | DataType::Timestamptz) => {
17631            Some(Value::Timestamp(i64::from(d) * 86_400_000_000))
17632        }
17633        // v7.9.21 — Value::Timestamp lands in either Timestamp
17634        // or Timestamptz columns; the on-disk layout is the
17635        // same i64 microseconds UTC.
17636        (Value::Timestamp(t), DataType::Timestamptz) => Some(Value::Timestamp(t)),
17637        (Value::Timestamp(t), DataType::Date) => {
17638            let days = t.div_euclid(86_400_000_000);
17639            i32::try_from(days).ok().map(Value::Date)
17640        }
17641        (
17642            Value::Numeric {
17643                scaled,
17644                scale: src_scale,
17645            },
17646            DataType::Numeric { precision, scale },
17647        ) => Some(numeric_rescale(
17648            scaled, src_scale, precision, scale, col_name,
17649        )?),
17650        #[allow(clippy::cast_precision_loss)]
17651        (Value::Numeric { scaled, scale }, DataType::Float) => {
17652            let mut div = 1.0_f64;
17653            for _ in 0..scale {
17654                div *= 10.0;
17655            }
17656            Some(Value::Float((scaled as f64) / div))
17657        }
17658        (Value::Numeric { scaled, scale }, DataType::Int) => {
17659            let truncated = numeric_truncate_to_integer(scaled, scale);
17660            i32::try_from(truncated).ok().map(Value::Int)
17661        }
17662        (Value::Numeric { scaled, scale }, DataType::BigInt) => {
17663            let truncated = numeric_truncate_to_integer(scaled, scale);
17664            i64::try_from(truncated).ok().map(Value::BigInt)
17665        }
17666        (Value::Numeric { scaled, scale }, DataType::SmallInt) => {
17667            let truncated = numeric_truncate_to_integer(scaled, scale);
17668            i16::try_from(truncated).ok().map(Value::SmallInt)
17669        }
17670        // VARCHAR(n) enforces an upper bound on character count.
17671        (Value::Text(s), DataType::Varchar(max)) => {
17672            if u32::try_from(s.chars().count()).unwrap_or(u32::MAX) <= max {
17673                Some(Value::Text(s))
17674            } else {
17675                return Err(EngineError::Unsupported(alloc::format!(
17676                    "value for VARCHAR({max}) column `{col_name}` exceeds length: \
17677                     {} chars",
17678                    s.chars().count()
17679                )));
17680            }
17681        }
17682        // v6.0.1: f32 → SQ8 INSERT-time quantisation. Triggered
17683        // when the column declares `VECTOR(N) USING SQ8` and
17684        // the INSERT VALUES expression yields a raw f32 vector
17685        // (the normal pgvector-shape literal). Dim mismatch
17686        // falls through the `_ => None` arm and surfaces as
17687        // `TypeMismatch` with the expected SQ8 column type —
17688        // matching the F32 path's existing error.
17689        (
17690            Value::Vector(v),
17691            DataType::Vector {
17692                dim,
17693                encoding: VecEncoding::Sq8,
17694            },
17695        ) if v.len() == dim as usize => Some(Value::Sq8Vector(spg_storage::quantize::quantize(&v))),
17696        // v6.0.3: f32 → f16 INSERT-time conversion for HALF
17697        // columns. Bit-exact at the storage layer (modulo
17698        // half-precision rounding); no rerank pass needed at
17699        // search time.
17700        (
17701            Value::Vector(v),
17702            DataType::Vector {
17703                dim,
17704                encoding: VecEncoding::F16,
17705            },
17706        ) if v.len() == dim as usize => Some(Value::HalfVector(
17707            spg_storage::halfvec::HalfVector::from_f32_slice(&v),
17708        )),
17709        // CHAR(n) right-pads with U+0020 to exactly n chars; if the input
17710        // is already longer we reject (PG truncates trailing-space-only;
17711        // staying strict for v1).
17712        (Value::Text(s), DataType::Char(size)) => {
17713            let len = u32::try_from(s.chars().count()).unwrap_or(u32::MAX);
17714            if len > size {
17715                return Err(EngineError::Unsupported(alloc::format!(
17716                    "value for CHAR({size}) column `{col_name}` exceeds length: \
17717                     {len} chars"
17718                )));
17719            }
17720            let need = (size - len) as usize;
17721            let mut padded = s;
17722            padded.reserve(need);
17723            for _ in 0..need {
17724                padded.push(' ');
17725            }
17726            Some(Value::Text(padded))
17727        }
17728        _ => None,
17729    };
17730    coerced.ok_or(EngineError::Storage(StorageError::TypeMismatch {
17731        column: col_name.into(),
17732        expected,
17733        actual,
17734        position,
17735    }))
17736}
17737
17738/// v7.12.4 — render a function arg list into the
17739/// canonical form the storage layer caches as
17740/// [`spg_storage::FunctionDef::args_repr`]. The catalogue uses
17741/// this string for both display + as a coarse signature key
17742/// for the (deferred) overload resolution v7.12.5+ adds.
17743fn render_function_args(args: &[spg_sql::ast::FunctionArg]) -> alloc::string::String {
17744    use core::fmt::Write;
17745    let mut out = alloc::string::String::from("(");
17746    for (i, a) in args.iter().enumerate() {
17747        if i > 0 {
17748            out.push_str(", ");
17749        }
17750        match a.mode {
17751            spg_sql::ast::FunctionArgMode::In => {}
17752            spg_sql::ast::FunctionArgMode::Out => out.push_str("OUT "),
17753            spg_sql::ast::FunctionArgMode::InOut => out.push_str("INOUT "),
17754        }
17755        if let Some(n) = &a.name {
17756            out.push_str(n);
17757            out.push(' ');
17758        }
17759        match &a.ty {
17760            spg_sql::ast::FunctionArgType::Typed(t) => {
17761                let _ = write!(out, "{t}");
17762            }
17763            spg_sql::ast::FunctionArgType::Raw(s) => out.push_str(s),
17764        }
17765    }
17766    out.push(')');
17767    out
17768}
17769
17770/// v7.19 P5 — true iff `expr` is `unnest(arg)` at the top level
17771/// (case-insensitive). Used by `exec_select_cancel`'s
17772/// projection loop to detect Set-Returning-Function rows that
17773/// need per-row expansion. Only the top-level call counts —
17774/// `coalesce(unnest(arr), 'x')` is NOT a SRF row from the
17775/// projection's perspective; it would surface as an "unknown
17776/// function" mismatch downstream, which is what we want
17777/// (multi-SRF / nested SRF is documented carve-out for v7.19).
17778fn is_top_level_unnest(expr: &spg_sql::ast::Expr) -> bool {
17779    match expr {
17780        spg_sql::ast::Expr::FunctionCall { name, args } => {
17781            name.eq_ignore_ascii_case("unnest") && args.len() == 1
17782        }
17783        _ => false,
17784    }
17785}
17786
17787/// v7.19 P5 — extract the array argument out of a top-level
17788/// `unnest(arg)` call. `None` if `expr` isn't a `unnest` call
17789/// of arity 1 (mirrors `is_top_level_unnest`).
17790fn top_level_unnest_arg(expr: &spg_sql::ast::Expr) -> Option<&spg_sql::ast::Expr> {
17791    match expr {
17792        spg_sql::ast::Expr::FunctionCall { name, args }
17793            if name.eq_ignore_ascii_case("unnest") && args.len() == 1 =>
17794        {
17795            Some(&args[0])
17796        }
17797        _ => None,
17798    }
17799}
17800
17801/// v7.19 P5 — turn an array-typed `Value` into the element list
17802/// `unnest()` projection emits. NULL → empty list (PG: `unnest(NULL)
17803/// = (no rows)`). Non-array values fall through to a type-mismatch
17804/// error.
17805fn array_value_to_elements(v: &Value) -> Result<Vec<Value>, EngineError> {
17806    match v {
17807        Value::Null => Ok(Vec::new()),
17808        Value::TextArray(items) => Ok(items
17809            .iter()
17810            .map(|opt| {
17811                opt.as_ref()
17812                    .map(|s| Value::Text(s.clone()))
17813                    .unwrap_or(Value::Null)
17814            })
17815            .collect()),
17816        Value::IntArray(items) => Ok(items
17817            .iter()
17818            .map(|opt| opt.map(Value::Int).unwrap_or(Value::Null))
17819            .collect()),
17820        Value::BigIntArray(items) => Ok(items
17821            .iter()
17822            .map(|opt| opt.map(Value::BigInt).unwrap_or(Value::Null))
17823            .collect()),
17824        other => Err(EngineError::Eval(EvalError::TypeMismatch {
17825            detail: alloc::format!(
17826                "unnest() expects an array argument, got {:?}",
17827                other.data_type()
17828            ),
17829        })),
17830    }
17831}
17832
17833#[cfg(test)]
17834mod tests {
17835    use super::*;
17836    use alloc::vec;
17837
17838    fn unwrap_command_ok(r: &QueryResult) -> usize {
17839        match r {
17840            QueryResult::CommandOk { affected, .. } => *affected,
17841            QueryResult::Rows { .. } => panic!("expected CommandOk, got Rows"),
17842        }
17843    }
17844
17845    #[test]
17846    fn update_seek_positions_engages_on_indexed_eq() {
17847        let mut e = Engine::new();
17848        e.execute("CREATE TABLE b (id INT NOT NULL, v INT NOT NULL)")
17849            .unwrap();
17850        e.execute("CREATE INDEX b_id ON b (id)").unwrap();
17851        for i in 0..100 {
17852            e.execute(&alloc::format!("INSERT INTO b VALUES ({i}, {i})"))
17853                .unwrap();
17854        }
17855        let stmt = spg_sql::parser::parse_statement("UPDATE b SET v = v + 1 WHERE id = 42")
17856            .expect("parse");
17857        let Statement::Update(u) = stmt else {
17858            panic!("expected Update, got {stmt:?}");
17859        };
17860        let w = u.where_.as_ref().expect("where");
17861        let table = e.catalog().get("b").unwrap();
17862        let schema_cols = table.schema().columns.clone();
17863        // step-by-step: each sub-resolution must succeed.
17864        let Expr::Binary { lhs, op, rhs } = w else {
17865            panic!("WHERE not Binary: {w:?}");
17866        };
17867        assert_eq!(*op, BinOp::Eq, "op not Eq");
17868        let pair = resolve_col_literal_pair(lhs, rhs, &schema_cols, "b");
17869        assert!(
17870            pair.is_some(),
17871            "resolve_col_literal_pair None: lhs={lhs:?} rhs={rhs:?}"
17872        );
17873        let (col_pos, value) = pair.unwrap();
17874        assert!(
17875            table.index_on(col_pos).is_some(),
17876            "no index on col {col_pos}"
17877        );
17878        assert!(
17879            IndexKey::from_value(&value).is_some(),
17880            "IndexKey::from_value None for {value:?}"
17881        );
17882        let positions = try_index_seek_positions(w, &schema_cols, table, "b");
17883        assert_eq!(positions, Some(vec![42]), "seek did not engage");
17884    }
17885
17886    #[test]
17887    fn create_table_registers_schema() {
17888        let mut e = Engine::new();
17889        e.execute("CREATE TABLE foo (a INT NOT NULL, b TEXT)")
17890            .unwrap();
17891        assert_eq!(e.catalog().table_count(), 1);
17892        let t = e.catalog().get("foo").unwrap();
17893        assert_eq!(t.schema().columns.len(), 2);
17894        assert_eq!(t.schema().columns[0].ty, DataType::Int);
17895        assert!(!t.schema().columns[0].nullable);
17896        assert_eq!(t.schema().columns[1].ty, DataType::Text);
17897    }
17898
17899    #[test]
17900    fn create_table_vector_default_is_f32_encoded() {
17901        let mut e = Engine::new();
17902        e.execute("CREATE TABLE t (v VECTOR(8))").unwrap();
17903        let t = e.catalog().get("t").unwrap();
17904        assert_eq!(
17905            t.schema().columns[0].ty,
17906            DataType::Vector {
17907                dim: 8,
17908                encoding: VecEncoding::F32,
17909            },
17910        );
17911    }
17912
17913    #[test]
17914    fn create_table_vector_using_sq8_succeeds() {
17915        // v6.0.1 step 3: the step-1 fence in `column_def_to_schema`
17916        // is lifted. CREATE TABLE persists an SQ8 column type in
17917        // the catalog; INSERT (next test) quantises raw f32 input.
17918        let mut e = Engine::new();
17919        e.execute("CREATE TABLE t (v VECTOR(8) USING SQ8)").unwrap();
17920        let t = e.catalog().get("t").unwrap();
17921        assert_eq!(
17922            t.schema().columns[0].ty,
17923            DataType::Vector {
17924                dim: 8,
17925                encoding: VecEncoding::Sq8,
17926            },
17927        );
17928    }
17929
17930    #[test]
17931    fn insert_into_sq8_column_quantises_f32_payload() {
17932        // v6.0.1 step 3: INSERT-time `coerce_value` rewrites a raw
17933        // `Value::Vector(Vec<f32>)` literal into the column's
17934        // quantised representation. The row that lands in the
17935        // catalog must therefore hold a `Value::Sq8Vector`, not the
17936        // original f32 buffer — that's the bit that delivers the
17937        // 4× compression target.
17938        let mut e = Engine::new();
17939        e.execute("CREATE TABLE t (v VECTOR(4) USING SQ8)").unwrap();
17940        e.execute("INSERT INTO t VALUES ([0.0, 0.25, 0.5, 1.0])")
17941            .unwrap();
17942        let t = e.catalog().get("t").unwrap();
17943        assert_eq!(t.rows().len(), 1);
17944        match &t.rows()[0].values[0] {
17945            Value::Sq8Vector(q) => {
17946                assert_eq!(q.bytes.len(), 4);
17947                // min/max are derived from the payload: min=0.0, max=1.0.
17948                assert!((q.min - 0.0).abs() < 1e-6);
17949                assert!((q.max - 1.0).abs() < 1e-6);
17950            }
17951            other => panic!("expected Sq8Vector cell, got {other:?}"),
17952        }
17953    }
17954
17955    #[test]
17956    fn create_table_vector_using_half_succeeds_and_insert_converts_to_f16() {
17957        // v6.0.3: CREATE TABLE accepts USING HALF; INSERT path
17958        // converts the incoming `Value::Vector(Vec<f32>)` cell
17959        // into `Value::HalfVector(HalfVector)` via the new
17960        // `coerce_value` arm. The dequantised round-trip is
17961        // bit-exact for f16-representable values, so 0.0 / 0.25
17962        // / 0.5 / 1.0 hit their grid points exactly.
17963        let mut e = Engine::new();
17964        e.execute("CREATE TABLE t (v VECTOR(4) USING HALF)")
17965            .unwrap();
17966        e.execute("INSERT INTO t VALUES ([0.0, 0.25, 0.5, 1.0])")
17967            .unwrap();
17968        let t = e.catalog().get("t").unwrap();
17969        assert_eq!(t.rows().len(), 1);
17970        match &t.rows()[0].values[0] {
17971            Value::HalfVector(h) => {
17972                assert_eq!(h.dim(), 4);
17973                let back = h.to_f32_vec();
17974                let expected = alloc::vec![0.0_f32, 0.25, 0.5, 1.0];
17975                for (g, e) in back.iter().zip(expected.iter()) {
17976                    assert!(
17977                        (g - e).abs() < 1e-6,
17978                        "{g} vs {e} should be exact on f16 grid"
17979                    );
17980                }
17981            }
17982            other => panic!("expected HalfVector cell, got {other:?}"),
17983        }
17984    }
17985
17986    #[test]
17987    fn alter_index_rebuild_in_place_succeeds() {
17988        // v6.0.4: bare REBUILD (no encoding switch) walks every
17989        // row again to rebuild the NSW graph. Verifies the engine
17990        // dispatch + storage helper plumbing without changing any
17991        // cell encoding.
17992        let mut e = Engine::new();
17993        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(3) NOT NULL)")
17994            .unwrap();
17995        for i in 0..8_i32 {
17996            #[allow(clippy::cast_precision_loss)]
17997            let base = (i as f32) * 0.1;
17998            e.execute(&alloc::format!(
17999                "INSERT INTO t VALUES ({i}, [{base}, {b1}, {b2}])",
18000                b1 = base + 0.01,
18001                b2 = base + 0.02,
18002            ))
18003            .unwrap();
18004        }
18005        e.execute("CREATE INDEX t_idx ON t USING hnsw (v)").unwrap();
18006        e.execute("ALTER INDEX t_idx REBUILD").unwrap();
18007        // Schema encoding stays F32 (no encoding clause).
18008        assert_eq!(
18009            e.catalog().get("t").unwrap().schema().columns[1].ty,
18010            DataType::Vector {
18011                dim: 3,
18012                encoding: VecEncoding::F32,
18013            },
18014        );
18015    }
18016
18017    #[test]
18018    fn alter_index_rebuild_with_encoding_switches_cell_type() {
18019        // v6.0.4: REBUILD WITH (encoding = SQ8) recodes every
18020        // stored cell from F32 → SQ8 + rebuilds the graph atop the
18021        // new encoding. Post-rebuild, cells must be Sq8Vector and
18022        // the schema must report encoding = Sq8.
18023        let mut e = Engine::new();
18024        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(4) NOT NULL)")
18025            .unwrap();
18026        e.execute("INSERT INTO t VALUES (1, [0.0, 0.25, 0.5, 1.0])")
18027            .unwrap();
18028        e.execute("CREATE INDEX t_idx ON t USING hnsw (v)").unwrap();
18029        e.execute("ALTER INDEX t_idx REBUILD WITH (encoding = SQ8)")
18030            .unwrap();
18031        let t = e.catalog().get("t").unwrap();
18032        assert_eq!(
18033            t.schema().columns[1].ty,
18034            DataType::Vector {
18035                dim: 4,
18036                encoding: VecEncoding::Sq8,
18037            },
18038        );
18039        assert!(matches!(t.rows()[0].values[1], Value::Sq8Vector(_)));
18040    }
18041
18042    #[test]
18043    fn alter_index_rebuild_unknown_index_errors() {
18044        let mut e = Engine::new();
18045        let err = e.execute("ALTER INDEX nope REBUILD").unwrap_err();
18046        assert!(
18047            matches!(
18048                &err,
18049                EngineError::Storage(StorageError::IndexNotFound { name }) if name == "nope"
18050            ),
18051            "got: {err}"
18052        );
18053    }
18054
18055    #[test]
18056    fn alter_index_rebuild_on_btree_index_errors() {
18057        // REBUILD on a B-tree index has no semantic meaning in
18058        // v6.0.4 — rejected at the storage layer with `Unsupported`.
18059        let mut e = Engine::new();
18060        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18061        e.execute("INSERT INTO t VALUES (1)").unwrap();
18062        e.execute("CREATE INDEX t_idx ON t (id)").unwrap();
18063        let err = e.execute("ALTER INDEX t_idx REBUILD").unwrap_err();
18064        assert!(
18065            matches!(&err, EngineError::Storage(StorageError::Unsupported(_))),
18066            "got: {err}"
18067        );
18068    }
18069
18070    #[test]
18071    fn prepared_insert_substitutes_placeholders() {
18072        // v6.1.1: prepare() parses once; execute_prepared() walks the
18073        // AST and replaces $1/$2 with the param Values BEFORE the
18074        // dispatch sees them. Same logical result as a simple-query
18075        // INSERT, but parse happens once per *statement*, not per
18076        // execution.
18077        let mut e = Engine::new();
18078        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)")
18079            .unwrap();
18080        let stmt = e.prepare("INSERT INTO t VALUES ($1, $2)").unwrap();
18081        for (id, name) in [(1, "alice"), (2, "bob"), (3, "carol")] {
18082            e.execute_prepared(stmt.clone(), &[Value::Int(id), Value::Text(name.into())])
18083                .unwrap();
18084        }
18085        // Read back via simple-query SELECT.
18086        let rows_result = e.execute("SELECT id, name FROM t").unwrap();
18087        let QueryResult::Rows { rows, .. } = rows_result else {
18088            panic!("expected Rows")
18089        };
18090        assert_eq!(rows.len(), 3);
18091    }
18092
18093    #[test]
18094    fn prepared_select_with_placeholder_filters_rows() {
18095        let mut e = Engine::new();
18096        e.execute("CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)")
18097            .unwrap();
18098        for i in 0..10_i32 {
18099            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, {})", i * 7))
18100                .unwrap();
18101        }
18102        let stmt = e.prepare("SELECT id FROM t WHERE v = $1").unwrap();
18103        let QueryResult::Rows { rows, .. } = e.execute_prepared(stmt, &[Value::Int(35)]).unwrap()
18104        else {
18105            panic!("expected Rows")
18106        };
18107        // v = 35 means i*7 = 35 → i = 5.
18108        assert_eq!(rows.len(), 1);
18109        assert_eq!(rows[0].values[0], Value::Int(5));
18110    }
18111
18112    #[test]
18113    fn prepared_too_few_params_errors() {
18114        let mut e = Engine::new();
18115        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18116        let stmt = e.prepare("INSERT INTO t VALUES ($1)").unwrap();
18117        let err = e.execute_prepared(stmt, &[]).unwrap_err();
18118        assert!(
18119            matches!(
18120                &err,
18121                EngineError::Eval(EvalError::PlaceholderOutOfRange { n: 1, bound: 0 })
18122            ),
18123            "got: {err}"
18124        );
18125    }
18126
18127    #[test]
18128    fn bytea_cast_round_trips_text_input() {
18129        // v7.18 — `'hello'::bytea` produces the raw bytes. Closes
18130        // the mailrs D-pre #3 reverse-acceptance gap.
18131        let e = Engine::new();
18132        let r = e.execute_readonly("SELECT 'hello'::bytea").unwrap();
18133        let QueryResult::Rows { rows, .. } = r else {
18134            panic!("expected Rows")
18135        };
18136        assert_eq!(rows.len(), 1);
18137        assert_eq!(rows[0].values[0], Value::Bytes(b"hello".to_vec()));
18138    }
18139
18140    #[test]
18141    fn bytea_cast_pg_escape_hex_form() {
18142        // E'\\xdeadbeef'::bytea — E-string decodes to `\xdeadbeef`
18143        // (literal 10 chars), then ::bytea reads it as PG hex
18144        // form bytea literal → 4 bytes.
18145        let e = Engine::new();
18146        let r = e.execute_readonly(r"SELECT E'\\xdeadbeef'::bytea").unwrap();
18147        let QueryResult::Rows { rows, .. } = r else {
18148            panic!("expected Rows")
18149        };
18150        assert_eq!(
18151            rows[0].values[0],
18152            Value::Bytes(vec![0xde, 0xad, 0xbe, 0xef])
18153        );
18154    }
18155
18156    #[test]
18157    fn bytea_cast_chains_through_octet_length() {
18158        // octet_length('hello'::bytea) → 5. Confirms the cast
18159        // composes inside larger expressions, not just at top
18160        // level.
18161        let e = Engine::new();
18162        let r = e
18163            .execute_readonly("SELECT octet_length('hello'::bytea)")
18164            .unwrap();
18165        let QueryResult::Rows { rows, .. } = r else {
18166            panic!("expected Rows")
18167        };
18168        match &rows[0].values[0] {
18169            Value::Int(n) => assert_eq!(*n, 5),
18170            Value::BigInt(n) => assert_eq!(*n, 5),
18171            other => panic!("expected integer length, got {other:?}"),
18172        }
18173    }
18174
18175    #[test]
18176    fn readonly_prepared_on_snapshot_select_with_placeholder() {
18177        // v7.18 — sqlx Pool fan-out relies on running prepared
18178        // SELECTs against a frozen snapshot without re-entering
18179        // the writer engine. Mirrors the simple-query SELECT path
18180        // in `execute_readonly_on_snapshot` but takes a Statement
18181        // + bound params (the shape sqlx's Execute path produces).
18182        let mut e = Engine::new();
18183        e.execute("CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)")
18184            .unwrap();
18185        for i in 0..10_i32 {
18186            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, {})", i * 7))
18187                .unwrap();
18188        }
18189        let snapshot = e.clone_snapshot();
18190        let stmt = e.prepare("SELECT id FROM t WHERE v = $1").unwrap();
18191        let QueryResult::Rows { rows, .. } =
18192            Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(35)])
18193                .unwrap()
18194        else {
18195            panic!("expected Rows")
18196        };
18197        assert_eq!(rows.len(), 1);
18198        assert_eq!(rows[0].values[0], Value::Int(5));
18199    }
18200
18201    #[test]
18202    fn readonly_prepared_on_snapshot_rejects_writes() {
18203        // DDL / DML prepared statements on the readonly path must
18204        // surface `WriteRequired` so the spg-sqlx connection layer
18205        // routes them to the writer mutex instead of the snapshot.
18206        let mut e = Engine::new();
18207        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18208        let snapshot = e.clone_snapshot();
18209        let stmt = e.prepare("INSERT INTO t VALUES ($1)").unwrap();
18210        let err = Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(1)])
18211            .unwrap_err();
18212        assert!(matches!(&err, EngineError::WriteRequired), "got: {err}");
18213    }
18214
18215    #[test]
18216    fn readonly_prepared_on_snapshot_frozen_view() {
18217        // The snapshot reflects engine state at clone_snapshot()
18218        // time. Writes after the snapshot are NOT visible — caller
18219        // takes a fresh snapshot (or `AsyncReadHandle::refresh()`)
18220        // to see them. This is the contract the per-statement
18221        // refresh in spg-sqlx relies on.
18222        let mut e = Engine::new();
18223        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18224        e.execute("INSERT INTO t VALUES (1)").unwrap();
18225        let snapshot = e.clone_snapshot();
18226        e.execute("INSERT INTO t VALUES (2)").unwrap();
18227        let stmt = e.prepare("SELECT id FROM t WHERE id = $1").unwrap();
18228        let QueryResult::Rows { rows, .. } =
18229            Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(2)])
18230                .unwrap()
18231        else {
18232            panic!("expected Rows")
18233        };
18234        assert!(rows.is_empty(), "id=2 was inserted after snapshot");
18235    }
18236
18237    #[test]
18238    fn describe_prepared_on_snapshot_resolves_columns() {
18239        // v7.18 — sqlx's Executor::describe path on the readonly
18240        // fan-out needs to resolve column names + types against
18241        // the snapshot's catalog (not the live engine's catalog,
18242        // which may have moved on).
18243        let mut e = Engine::new();
18244        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)")
18245            .unwrap();
18246        let snapshot = e.clone_snapshot();
18247        let stmt = e.prepare("SELECT id, name FROM t WHERE id = $1").unwrap();
18248        let (_params, cols) = Engine::describe_prepared_on_snapshot(&snapshot, &stmt);
18249        assert_eq!(cols.len(), 2);
18250        assert_eq!(cols[0].name, "id");
18251        assert_eq!(cols[0].ty, DataType::Int);
18252        assert_eq!(cols[1].name, "name");
18253        assert_eq!(cols[1].ty, DataType::Text);
18254    }
18255
18256    #[test]
18257    fn insert_into_half_column_dim_mismatch_errors() {
18258        let mut e = Engine::new();
18259        e.execute("CREATE TABLE t (v VECTOR(4) USING HALF)")
18260            .unwrap();
18261        let err = e.execute("INSERT INTO t VALUES ([1.0, 2.0])").unwrap_err();
18262        assert!(matches!(
18263            &err,
18264            EngineError::Storage(StorageError::TypeMismatch { .. })
18265        ));
18266    }
18267
18268    #[test]
18269    fn insert_into_sq8_column_dim_mismatch_errors() {
18270        // Dim mismatch falls through the `coerce_value` Vector→Sq8
18271        // arm's guard and surfaces as `TypeMismatch` — the same
18272        // error the F32 path produces today, so client error
18273        // handling stays uniform across encodings.
18274        let mut e = Engine::new();
18275        e.execute("CREATE TABLE t (v VECTOR(4) USING SQ8)").unwrap();
18276        let err = e.execute("INSERT INTO t VALUES ([1.0, 2.0])").unwrap_err();
18277        assert!(
18278            matches!(
18279                &err,
18280                EngineError::Storage(StorageError::TypeMismatch { .. })
18281            ),
18282            "got: {err}",
18283        );
18284    }
18285
18286    #[test]
18287    fn create_table_duplicate_errors() {
18288        let mut e = Engine::new();
18289        e.execute("CREATE TABLE foo (a INT)").unwrap();
18290        let err = e.execute("CREATE TABLE foo (a INT)").unwrap_err();
18291        assert!(matches!(
18292            err,
18293            EngineError::Storage(StorageError::DuplicateTable { ref name }) if name == "foo"
18294        ));
18295    }
18296
18297    #[test]
18298    fn insert_into_unknown_table_errors() {
18299        let mut e = Engine::new();
18300        let err = e.execute("INSERT INTO ghost VALUES (1)").unwrap_err();
18301        assert!(matches!(
18302            err,
18303            EngineError::Storage(StorageError::TableNotFound { ref name }) if name == "ghost"
18304        ));
18305    }
18306
18307    #[test]
18308    fn insert_happy_path_reports_one_affected() {
18309        let mut e = Engine::new();
18310        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
18311        let r = e.execute("INSERT INTO foo VALUES (42)").unwrap();
18312        assert_eq!(unwrap_command_ok(&r), 1);
18313        assert_eq!(e.catalog().get("foo").unwrap().row_count(), 1);
18314    }
18315
18316    #[test]
18317    fn insert_arity_mismatch_propagates() {
18318        let mut e = Engine::new();
18319        e.execute("CREATE TABLE foo (a INT, b TEXT)").unwrap();
18320        let err = e.execute("INSERT INTO foo VALUES (1)").unwrap_err();
18321        assert!(matches!(
18322            err,
18323            EngineError::Storage(StorageError::ArityMismatch { .. })
18324        ));
18325    }
18326
18327    #[test]
18328    fn insert_negative_integer_via_unary_minus() {
18329        let mut e = Engine::new();
18330        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
18331        e.execute("INSERT INTO foo VALUES (-7)").unwrap();
18332        let rows = e.catalog().get("foo").unwrap().rows();
18333        assert_eq!(rows[0].values[0], Value::Int(-7));
18334    }
18335
18336    #[test]
18337    fn insert_expression_evaluated_against_empty_context() {
18338        // PG-canonical: INSERT VALUES accepts an arbitrary scalar
18339        // expression. The engine evaluates against an empty row
18340        // context — column references would error, but pure
18341        // arithmetic / function calls are fine.
18342        let mut e = Engine::new();
18343        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
18344        e.execute("INSERT INTO foo VALUES (1 + 2)").unwrap();
18345        let rows = e.catalog().get("foo").unwrap().rows();
18346        assert_eq!(rows[0].values[0], Value::Int(3));
18347    }
18348
18349    #[test]
18350    fn select_star_returns_all_rows_in_insertion_order() {
18351        let mut e = Engine::new();
18352        e.execute("CREATE TABLE foo (a INT NOT NULL, b TEXT NOT NULL)")
18353            .unwrap();
18354        e.execute("INSERT INTO foo VALUES (1, 'one')").unwrap();
18355        e.execute("INSERT INTO foo VALUES (2, 'two')").unwrap();
18356        e.execute("INSERT INTO foo VALUES (3, 'three')").unwrap();
18357
18358        let r = e.execute("SELECT * FROM foo").unwrap();
18359        let QueryResult::Rows { columns, rows } = r else {
18360            panic!("expected Rows")
18361        };
18362        assert_eq!(columns.len(), 2);
18363        assert_eq!(columns[0].name, "a");
18364        assert_eq!(rows.len(), 3);
18365        assert_eq!(
18366            rows[1].values,
18367            vec![Value::Int(2), Value::Text("two".into())]
18368        );
18369    }
18370
18371    #[test]
18372    fn select_star_on_empty_table_returns_zero_rows() {
18373        let mut e = Engine::new();
18374        e.execute("CREATE TABLE foo (a INT)").unwrap();
18375        let r = e.execute("SELECT * FROM foo").unwrap();
18376        match r {
18377            QueryResult::Rows { rows, .. } => assert!(rows.is_empty()),
18378            QueryResult::CommandOk { .. } => panic!("expected Rows"),
18379        }
18380    }
18381
18382    // --- v0.4: WHERE + projection ------------------------------------------
18383
18384    fn make_three_row_users(e: &mut Engine) {
18385        e.execute("CREATE TABLE users (id INT NOT NULL, name TEXT NOT NULL, score INT)")
18386            .unwrap();
18387        e.execute("INSERT INTO users VALUES (1, 'alice', 90)")
18388            .unwrap();
18389        e.execute("INSERT INTO users VALUES (2, 'bob', NULL)")
18390            .unwrap();
18391        e.execute("INSERT INTO users VALUES (3, 'cara', 70)")
18392            .unwrap();
18393    }
18394
18395    fn unwrap_rows(r: QueryResult) -> (Vec<ColumnSchema>, Vec<Row>) {
18396        match r {
18397            QueryResult::Rows { columns, rows } => (columns, rows),
18398            QueryResult::CommandOk { .. } => panic!("expected Rows"),
18399        }
18400    }
18401
18402    #[test]
18403    fn where_filter_passes_only_true_rows() {
18404        let mut e = Engine::new();
18405        make_three_row_users(&mut e);
18406        let r = e.execute("SELECT * FROM users WHERE id > 1").unwrap();
18407        let (_, rows) = unwrap_rows(r);
18408        assert_eq!(rows.len(), 2);
18409        assert_eq!(rows[0].values[0], Value::Int(2));
18410        assert_eq!(rows[1].values[0], Value::Int(3));
18411    }
18412
18413    #[test]
18414    fn where_with_null_result_filters_out_row() {
18415        let mut e = Engine::new();
18416        make_three_row_users(&mut e);
18417        // score is NULL for bob → score > 80 is NULL → row excluded
18418        let r = e.execute("SELECT * FROM users WHERE score > 80").unwrap();
18419        let (_, rows) = unwrap_rows(r);
18420        assert_eq!(rows.len(), 1);
18421        assert_eq!(rows[0].values[1], Value::Text("alice".into()));
18422    }
18423
18424    #[test]
18425    fn projection_named_columns() {
18426        let mut e = Engine::new();
18427        make_three_row_users(&mut e);
18428        let r = e.execute("SELECT name, score FROM users").unwrap();
18429        let (cols, rows) = unwrap_rows(r);
18430        assert_eq!(cols.len(), 2);
18431        assert_eq!(cols[0].name, "name");
18432        assert_eq!(cols[1].name, "score");
18433        assert_eq!(rows.len(), 3);
18434        assert_eq!(
18435            rows[0].values,
18436            vec![Value::Text("alice".into()), Value::Int(90)]
18437        );
18438    }
18439
18440    #[test]
18441    fn projection_with_column_alias() {
18442        let mut e = Engine::new();
18443        make_three_row_users(&mut e);
18444        let r = e
18445            .execute("SELECT name AS who FROM users WHERE id = 1")
18446            .unwrap();
18447        let (cols, rows) = unwrap_rows(r);
18448        assert_eq!(cols[0].name, "who");
18449        assert_eq!(rows.len(), 1);
18450        assert_eq!(rows[0].values[0], Value::Text("alice".into()));
18451    }
18452
18453    #[test]
18454    fn qualified_column_with_table_alias_resolves() {
18455        let mut e = Engine::new();
18456        make_three_row_users(&mut e);
18457        let r = e
18458            .execute("SELECT u.id, u.name FROM users AS u WHERE u.id < 3")
18459            .unwrap();
18460        let (cols, rows) = unwrap_rows(r);
18461        assert_eq!(cols.len(), 2);
18462        assert_eq!(rows.len(), 2);
18463    }
18464
18465    #[test]
18466    fn qualified_column_with_wrong_alias_errors() {
18467        let mut e = Engine::new();
18468        make_three_row_users(&mut e);
18469        let err = e.execute("SELECT x.id FROM users AS u").unwrap_err();
18470        assert!(matches!(
18471            err,
18472            EngineError::Eval(EvalError::UnknownQualifier { ref qualifier }) if qualifier == "x"
18473        ));
18474    }
18475
18476    #[test]
18477    fn select_unknown_column_errors_in_projection() {
18478        let mut e = Engine::new();
18479        make_three_row_users(&mut e);
18480        let err = e.execute("SELECT ghost FROM users").unwrap_err();
18481        assert!(matches!(
18482            err,
18483            EngineError::Eval(EvalError::ColumnNotFound { ref name }) if name == "ghost"
18484        ));
18485    }
18486
18487    #[test]
18488    fn where_unknown_column_errors() {
18489        let mut e = Engine::new();
18490        make_three_row_users(&mut e);
18491        let err = e
18492            .execute("SELECT * FROM users WHERE ghost = 1")
18493            .unwrap_err();
18494        assert!(matches!(
18495            err,
18496            EngineError::Eval(EvalError::ColumnNotFound { .. })
18497        ));
18498    }
18499
18500    #[test]
18501    fn expression_projection_evaluates_and_renders() {
18502        // Compound expressions in the SELECT list are evaluated per row;
18503        // the output column is typed TEXT, name defaults to the expression.
18504        let mut e = Engine::new();
18505        e.execute("CREATE TABLE t (a INT NOT NULL)").unwrap();
18506        e.execute("INSERT INTO t VALUES (3)").unwrap();
18507        let (_, rows) = unwrap_rows(e.execute("SELECT 1 + 2 FROM t").unwrap());
18508        assert_eq!(rows.len(), 1);
18509        // The expression evaluates to integer 3; rendered as the cell value
18510        // (storage::Value::Int(3) since arithmetic kept ints).
18511        assert_eq!(rows[0].values[0], Value::Int(3));
18512    }
18513
18514    #[test]
18515    fn select_unknown_table_errors() {
18516        let mut e = Engine::new();
18517        let err = e.execute("SELECT * FROM ghost").unwrap_err();
18518        assert!(matches!(
18519            err,
18520            EngineError::Storage(StorageError::TableNotFound { .. })
18521        ));
18522    }
18523
18524    #[test]
18525    fn invalid_sql_returns_parse_error() {
18526        // v4.4: UPDATE is now real SQL, so use a true syntactic
18527        // garbage payload for the parse-error path.
18528        let mut e = Engine::new();
18529        let err = e.execute("THIS_IS_NOT_A_KEYWORD foo bar baz").unwrap_err();
18530        assert!(matches!(err, EngineError::Parse(_)));
18531    }
18532
18533    // --- v0.8 CREATE INDEX + index seek ------------------------------------
18534
18535    #[test]
18536    fn create_index_registers_on_table() {
18537        let mut e = Engine::new();
18538        make_three_row_users(&mut e);
18539        e.execute("CREATE INDEX by_name ON users (name)").unwrap();
18540        let t = e.catalog().get("users").unwrap();
18541        assert_eq!(t.indices().len(), 1);
18542        assert_eq!(t.indices()[0].name, "by_name");
18543    }
18544
18545    #[test]
18546    fn create_index_on_unknown_table_errors() {
18547        let mut e = Engine::new();
18548        let err = e.execute("CREATE INDEX i ON ghost (a)").unwrap_err();
18549        assert!(matches!(
18550            err,
18551            EngineError::Storage(StorageError::TableNotFound { .. })
18552        ));
18553    }
18554
18555    #[test]
18556    fn create_index_on_unknown_column_errors() {
18557        let mut e = Engine::new();
18558        make_three_row_users(&mut e);
18559        let err = e.execute("CREATE INDEX i ON users (ghost)").unwrap_err();
18560        assert!(matches!(
18561            err,
18562            EngineError::Storage(StorageError::ColumnNotFound { .. })
18563        ));
18564    }
18565
18566    #[test]
18567    fn select_eq_uses_index_returns_same_rows_as_scan() {
18568        // Build two engines: one with an index, one without. Same query →
18569        // same row set (index is a planner optimisation, not a semantic
18570        // change).
18571        let mut without = Engine::new();
18572        make_three_row_users(&mut without);
18573        let mut with = Engine::new();
18574        make_three_row_users(&mut with);
18575        with.execute("CREATE INDEX by_id ON users (id)").unwrap();
18576
18577        let q = "SELECT * FROM users WHERE id = 2";
18578        let (_, no_idx_rows) = unwrap_rows(without.execute(q).unwrap());
18579        let (_, idx_rows) = unwrap_rows(with.execute(q).unwrap());
18580        assert_eq!(no_idx_rows, idx_rows);
18581        assert_eq!(idx_rows.len(), 1);
18582    }
18583
18584    #[test]
18585    fn select_eq_with_no_matching_index_value_returns_empty() {
18586        let mut e = Engine::new();
18587        make_three_row_users(&mut e);
18588        e.execute("CREATE INDEX by_id ON users (id)").unwrap();
18589        let (_, rows) = unwrap_rows(e.execute("SELECT * FROM users WHERE id = 999").unwrap());
18590        assert_eq!(rows.len(), 0);
18591    }
18592
18593    // --- v0.9 transactions -------------------------------------------------
18594
18595    #[test]
18596    fn begin_sets_in_transaction_flag() {
18597        let mut e = Engine::new();
18598        assert!(!e.in_transaction());
18599        e.execute("BEGIN").unwrap();
18600        assert!(e.in_transaction());
18601    }
18602
18603    #[test]
18604    fn double_begin_errors() {
18605        let mut e = Engine::new();
18606        e.execute("BEGIN").unwrap();
18607        let err = e.execute("BEGIN").unwrap_err();
18608        assert_eq!(err, EngineError::TransactionAlreadyOpen);
18609    }
18610
18611    #[test]
18612    fn commit_without_begin_errors() {
18613        let mut e = Engine::new();
18614        let err = e.execute("COMMIT").unwrap_err();
18615        assert_eq!(err, EngineError::NoActiveTransaction);
18616    }
18617
18618    #[test]
18619    fn rollback_without_begin_errors() {
18620        let mut e = Engine::new();
18621        let err = e.execute("ROLLBACK").unwrap_err();
18622        assert_eq!(err, EngineError::NoActiveTransaction);
18623    }
18624
18625    #[test]
18626    fn commit_applies_shadow_to_committed_catalog() {
18627        let mut e = Engine::new();
18628        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
18629        e.execute("BEGIN").unwrap();
18630        e.execute("INSERT INTO t VALUES (1)").unwrap();
18631        e.execute("INSERT INTO t VALUES (2)").unwrap();
18632        e.execute("COMMIT").unwrap();
18633        assert!(!e.in_transaction());
18634        assert_eq!(e.catalog().get("t").unwrap().row_count(), 2);
18635    }
18636
18637    #[test]
18638    fn rollback_discards_shadow() {
18639        let mut e = Engine::new();
18640        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
18641        e.execute("BEGIN").unwrap();
18642        e.execute("INSERT INTO t VALUES (1)").unwrap();
18643        e.execute("INSERT INTO t VALUES (2)").unwrap();
18644        e.execute("ROLLBACK").unwrap();
18645        assert!(!e.in_transaction());
18646        assert_eq!(e.catalog().get("t").unwrap().row_count(), 0);
18647    }
18648
18649    #[test]
18650    fn select_during_tx_sees_uncommitted_writes_own_session() {
18651        // The shadow catalog is read by SELECTs while a TX is open — the
18652        // session can see its own pending writes.
18653        let mut e = Engine::new();
18654        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
18655        e.execute("BEGIN").unwrap();
18656        e.execute("INSERT INTO t VALUES (42)").unwrap();
18657        let (_, rows) = unwrap_rows(e.execute("SELECT * FROM t").unwrap());
18658        assert_eq!(rows.len(), 1);
18659        assert_eq!(rows[0].values[0], Value::Int(42));
18660    }
18661
18662    #[test]
18663    fn snapshot_with_no_users_is_bare_catalog_format() {
18664        let mut e = Engine::new();
18665        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18666        let bytes = e.snapshot();
18667        assert_eq!(
18668            &bytes[..8],
18669            b"SPGDB001",
18670            "must be the bare v3.x catalog magic"
18671        );
18672        let e2 = Engine::restore_envelope(&bytes).unwrap();
18673        assert!(e2.users().is_empty());
18674        assert_eq!(e2.catalog().table_count(), 1);
18675    }
18676
18677    #[test]
18678    fn snapshot_with_users_round_trips_both_via_envelope() {
18679        let mut e = Engine::new();
18680        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18681        e.create_user("alice", "pw1", Role::Admin, [9; 16]).unwrap();
18682        e.create_user("bob", "pw2", Role::ReadOnly, [5; 16])
18683            .unwrap();
18684        let bytes = e.snapshot();
18685        assert_eq!(&bytes[..8], b"SPGENV01", "must be the v4.1 envelope magic");
18686        let e2 = Engine::restore_envelope(&bytes).unwrap();
18687        assert_eq!(e2.users().len(), 2);
18688        assert_eq!(e2.verify_user("alice", "pw1"), Some(Role::Admin));
18689        assert_eq!(e2.verify_user("bob", "pw2"), Some(Role::ReadOnly));
18690        assert_eq!(e2.verify_user("alice", "wrong"), None);
18691        assert_eq!(e2.catalog().table_count(), 1);
18692    }
18693
18694    #[test]
18695    fn ddl_inside_tx_also_rolled_back() {
18696        let mut e = Engine::new();
18697        e.execute("BEGIN").unwrap();
18698        e.execute("CREATE TABLE t (v INT)").unwrap();
18699        // Visible inside the TX.
18700        e.execute("SELECT * FROM t").unwrap();
18701        e.execute("ROLLBACK").unwrap();
18702        // Gone after rollback.
18703        let err = e.execute("SELECT * FROM t").unwrap_err();
18704        assert!(matches!(
18705            err,
18706            EngineError::Storage(StorageError::TableNotFound { .. })
18707        ));
18708    }
18709
18710    // ── v6.1.2: CREATE / DROP PUBLICATION (engine-side) ──────
18711
18712    #[test]
18713    fn create_publication_lands_in_catalog() {
18714        let mut e = Engine::new();
18715        assert!(e.publications().is_empty());
18716        e.execute("CREATE PUBLICATION pub_a").unwrap();
18717        assert_eq!(e.publications().len(), 1);
18718        assert!(e.publications().contains("pub_a"));
18719    }
18720
18721    #[test]
18722    fn create_publication_duplicate_errors() {
18723        let mut e = Engine::new();
18724        e.execute("CREATE PUBLICATION pub_a").unwrap();
18725        let err = e.execute("CREATE PUBLICATION pub_a").unwrap_err();
18726        assert!(
18727            alloc::format!("{err:?}").contains("DuplicateName"),
18728            "got {err:?}"
18729        );
18730    }
18731
18732    #[test]
18733    fn drop_publication_silent_when_absent() {
18734        let mut e = Engine::new();
18735        // PG-compatible: DROP a publication that doesn't exist
18736        // succeeds (no-op) but reports zero affected.
18737        let r = e.execute("DROP PUBLICATION nope").unwrap();
18738        match r {
18739            QueryResult::CommandOk { affected, .. } => assert_eq!(affected, 0),
18740            other => panic!("expected CommandOk, got {other:?}"),
18741        }
18742    }
18743
18744    #[test]
18745    fn drop_publication_present_reports_one_affected() {
18746        let mut e = Engine::new();
18747        e.execute("CREATE PUBLICATION pub_a").unwrap();
18748        let r = e.execute("DROP PUBLICATION pub_a").unwrap();
18749        match r {
18750            QueryResult::CommandOk {
18751                affected,
18752                modified_catalog,
18753            } => {
18754                assert_eq!(affected, 1);
18755                assert!(modified_catalog);
18756            }
18757            other => panic!("expected CommandOk, got {other:?}"),
18758        }
18759        assert!(e.publications().is_empty());
18760    }
18761
18762    #[test]
18763    fn publications_persist_across_snapshot_restore() {
18764        // The persist-across-restart ship-gate at the engine layer —
18765        // snapshot → restore_envelope round trip must preserve the
18766        // publication catalog. The spg-server e2e covers the
18767        // process-restart variant.
18768        let mut e = Engine::new();
18769        e.execute("CREATE PUBLICATION pub_a").unwrap();
18770        e.execute("CREATE PUBLICATION pub_b FOR ALL TABLES")
18771            .unwrap();
18772        let snap = e.snapshot();
18773        let e2 = Engine::restore_envelope(&snap).unwrap();
18774        assert_eq!(e2.publications().len(), 2);
18775        assert!(e2.publications().contains("pub_a"));
18776        assert!(e2.publications().contains("pub_b"));
18777    }
18778
18779    #[test]
18780    fn create_publication_allowed_inside_transaction() {
18781        // v6.1.4 dropped the v6.1.2 in-TX guard — PG allows
18782        // CREATE PUBLICATION inside a TX and the auto-commit
18783        // wrap path needs the same allowance.
18784        let mut e = Engine::new();
18785        e.execute("BEGIN").unwrap();
18786        e.execute("CREATE PUBLICATION pub_a").unwrap();
18787        e.execute("COMMIT").unwrap();
18788        assert!(e.publications().contains("pub_a"));
18789    }
18790
18791    // ── v6.1.3: SHOW PUBLICATIONS + FOR-list variants ───────
18792
18793    #[test]
18794    fn create_publication_for_table_list_lands_with_scope() {
18795        let mut e = Engine::new();
18796        e.execute("CREATE TABLE t1 (id INT NOT NULL)").unwrap();
18797        e.execute("CREATE TABLE t2 (id INT NOT NULL)").unwrap();
18798        e.execute("CREATE PUBLICATION pub_a FOR TABLE t1, t2")
18799            .unwrap();
18800        let scope = e.publications().get("pub_a").cloned();
18801        let Some(spg_sql::ast::PublicationScope::ForTables(ts)) = scope else {
18802            panic!("expected ForTables scope, got {scope:?}")
18803        };
18804        assert_eq!(ts, alloc::vec!["t1".to_string(), "t2".to_string()]);
18805    }
18806
18807    #[test]
18808    fn create_publication_all_tables_except_lands_with_scope() {
18809        let mut e = Engine::new();
18810        e.execute("CREATE PUBLICATION pub_a FOR ALL TABLES EXCEPT t3")
18811            .unwrap();
18812        let scope = e.publications().get("pub_a").cloned();
18813        let Some(spg_sql::ast::PublicationScope::AllTablesExcept(ts)) = scope else {
18814            panic!("expected AllTablesExcept scope, got {scope:?}")
18815        };
18816        assert_eq!(ts, alloc::vec!["t3".to_string()]);
18817    }
18818
18819    #[test]
18820    fn show_publications_empty_returns_zero_rows() {
18821        let e = Engine::new();
18822        let r = e.execute_readonly("SHOW PUBLICATIONS").unwrap();
18823        let QueryResult::Rows { rows, columns } = r else {
18824            panic!()
18825        };
18826        assert!(rows.is_empty());
18827        assert_eq!(columns.len(), 3);
18828        assert_eq!(columns[0].name, "name");
18829        assert_eq!(columns[1].name, "scope");
18830        assert_eq!(columns[2].name, "table_count");
18831    }
18832
18833    #[test]
18834    fn show_publications_returns_one_row_per_publication_ordered_by_name() {
18835        let mut e = Engine::new();
18836        e.execute("CREATE PUBLICATION z_pub").unwrap();
18837        e.execute("CREATE PUBLICATION a_pub FOR TABLE t1, t2")
18838            .unwrap();
18839        e.execute("CREATE PUBLICATION m_pub FOR ALL TABLES EXCEPT bad")
18840            .unwrap();
18841        let r = e.execute_readonly("SHOW PUBLICATIONS").unwrap();
18842        let QueryResult::Rows { rows, .. } = r else {
18843            panic!()
18844        };
18845        assert_eq!(rows.len(), 3);
18846        // Alphabetical order: a_pub, m_pub, z_pub.
18847        let names: Vec<&str> = rows
18848            .iter()
18849            .map(|r| {
18850                if let Value::Text(s) = &r.values[0] {
18851                    s.as_str()
18852                } else {
18853                    panic!()
18854                }
18855            })
18856            .collect();
18857        assert_eq!(names, alloc::vec!["a_pub", "m_pub", "z_pub"]);
18858        // Row 0 — a_pub scope summary + table_count = 2.
18859        match &rows[0].values[1] {
18860            Value::Text(s) => assert_eq!(s, "FOR TABLE t1, t2"),
18861            other => panic!("expected Text, got {other:?}"),
18862        }
18863        assert_eq!(rows[0].values[2], Value::Int(2));
18864        // Row 1 — m_pub.
18865        match &rows[1].values[1] {
18866            Value::Text(s) => assert_eq!(s, "FOR ALL TABLES EXCEPT bad"),
18867            other => panic!("expected Text, got {other:?}"),
18868        }
18869        assert_eq!(rows[1].values[2], Value::Int(1));
18870        // Row 2 — z_pub (AllTables → NULL count).
18871        match &rows[2].values[1] {
18872            Value::Text(s) => assert_eq!(s, "FOR ALL TABLES"),
18873            other => panic!("expected Text, got {other:?}"),
18874        }
18875        assert_eq!(rows[2].values[2], Value::Null);
18876    }
18877
18878    #[test]
18879    fn for_list_scopes_persist_across_snapshot() {
18880        // The v6.1.2 envelope-v3 round-trip exercised AllTables;
18881        // v6.1.3 needs the scope-1 / scope-2 tags to survive too.
18882        let mut e = Engine::new();
18883        e.execute("CREATE PUBLICATION p1 FOR TABLE t1, t2").unwrap();
18884        e.execute("CREATE PUBLICATION p2 FOR ALL TABLES EXCEPT bad, worse")
18885            .unwrap();
18886        let snap = e.snapshot();
18887        let e2 = Engine::restore_envelope(&snap).unwrap();
18888        assert_eq!(e2.publications().len(), 2);
18889        let p1 = e2.publications().get("p1").cloned();
18890        let Some(spg_sql::ast::PublicationScope::ForTables(ts)) = p1 else {
18891            panic!("p1 scope lost: {p1:?}")
18892        };
18893        assert_eq!(ts, alloc::vec!["t1".to_string(), "t2".to_string()]);
18894        let p2 = e2.publications().get("p2").cloned();
18895        let Some(spg_sql::ast::PublicationScope::AllTablesExcept(ts)) = p2 else {
18896            panic!("p2 scope lost: {p2:?}")
18897        };
18898        assert_eq!(ts, alloc::vec!["bad".to_string(), "worse".to_string()]);
18899    }
18900
18901    // ── v6.1.4: CREATE / DROP SUBSCRIPTION + SHOW + envelope v4 ─
18902
18903    #[test]
18904    fn create_subscription_lands_in_catalog_with_defaults() {
18905        let mut e = Engine::new();
18906        e.execute(
18907            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=127.0.0.1 port=20002' PUBLICATION pub_a",
18908        )
18909        .unwrap();
18910        let s = e.subscriptions().get("sub_a").cloned().expect("present");
18911        assert_eq!(s.conn_str, "host=127.0.0.1 port=20002");
18912        assert_eq!(s.publications, alloc::vec!["pub_a".to_string()]);
18913        assert!(s.enabled);
18914        assert_eq!(s.last_received_pos, 0);
18915    }
18916
18917    #[test]
18918    fn create_subscription_duplicate_name_errors() {
18919        let mut e = Engine::new();
18920        e.execute("CREATE SUBSCRIPTION s CONNECTION 'host=x' PUBLICATION p")
18921            .unwrap();
18922        let err = e
18923            .execute("CREATE SUBSCRIPTION s CONNECTION 'host=y' PUBLICATION p")
18924            .unwrap_err();
18925        assert!(
18926            alloc::format!("{err:?}").contains("DuplicateName"),
18927            "got {err:?}"
18928        );
18929    }
18930
18931    #[test]
18932    fn drop_subscription_silent_when_absent() {
18933        let mut e = Engine::new();
18934        let r = e.execute("DROP SUBSCRIPTION never").unwrap();
18935        match r {
18936            QueryResult::CommandOk { affected, .. } => assert_eq!(affected, 0),
18937            other => panic!("expected CommandOk, got {other:?}"),
18938        }
18939    }
18940
18941    #[test]
18942    fn subscription_advance_updates_last_pos_monotone() {
18943        let mut e = Engine::new();
18944        e.execute("CREATE SUBSCRIPTION s CONNECTION 'h=x' PUBLICATION p")
18945            .unwrap();
18946        assert!(e.subscription_advance("s", 100));
18947        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 100);
18948        assert!(e.subscription_advance("s", 50)); // stale → ignored
18949        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 100);
18950        assert!(e.subscription_advance("s", 200));
18951        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 200);
18952        assert!(!e.subscription_advance("missing", 1));
18953    }
18954
18955    #[test]
18956    fn show_subscriptions_returns_rows_ordered_by_name() {
18957        let mut e = Engine::new();
18958        e.execute("CREATE SUBSCRIPTION z_sub CONNECTION 'h=x' PUBLICATION p1, p2")
18959            .unwrap();
18960        e.execute("CREATE SUBSCRIPTION a_sub CONNECTION 'h=y' PUBLICATION p3")
18961            .unwrap();
18962        let r = e.execute_readonly("SHOW SUBSCRIPTIONS").unwrap();
18963        let QueryResult::Rows { rows, columns } = r else {
18964            panic!()
18965        };
18966        assert_eq!(rows.len(), 2);
18967        assert_eq!(columns.len(), 5);
18968        assert_eq!(columns[0].name, "name");
18969        assert_eq!(columns[4].name, "last_received_pos");
18970        // Alphabetical: a_sub, z_sub.
18971        let names: Vec<&str> = rows
18972            .iter()
18973            .map(|r| {
18974                if let Value::Text(s) = &r.values[0] {
18975                    s.as_str()
18976                } else {
18977                    panic!()
18978                }
18979            })
18980            .collect();
18981        assert_eq!(names, alloc::vec!["a_sub", "z_sub"]);
18982        // Row 0: a_sub
18983        assert_eq!(rows[0].values[1], Value::Text("h=y".to_string()));
18984        assert_eq!(rows[0].values[2], Value::Text("p3".to_string()));
18985        assert_eq!(rows[0].values[3], Value::Bool(true));
18986        assert_eq!(rows[0].values[4], Value::BigInt(0));
18987        // Row 1: z_sub — publications join with ", "
18988        assert_eq!(rows[1].values[2], Value::Text("p1, p2".to_string()));
18989    }
18990
18991    #[test]
18992    fn subscriptions_persist_across_snapshot_envelope_v4() {
18993        let mut e = Engine::new();
18994        e.execute("CREATE SUBSCRIPTION s1 CONNECTION 'h=A' PUBLICATION p1, p2")
18995            .unwrap();
18996        e.execute("CREATE SUBSCRIPTION s2 CONNECTION 'h=B' PUBLICATION p3")
18997            .unwrap();
18998        e.subscription_advance("s2", 42);
18999        let snap = e.snapshot();
19000        let e2 = Engine::restore_envelope(&snap).unwrap();
19001        assert_eq!(e2.subscriptions().len(), 2);
19002        let s1 = e2.subscriptions().get("s1").unwrap();
19003        assert_eq!(s1.conn_str, "h=A");
19004        assert_eq!(
19005            s1.publications,
19006            alloc::vec!["p1".to_string(), "p2".to_string()]
19007        );
19008        assert_eq!(s1.last_received_pos, 0);
19009        let s2 = e2.subscriptions().get("s2").unwrap();
19010        assert_eq!(s2.last_received_pos, 42);
19011    }
19012
19013    #[test]
19014    fn v3_envelope_loads_with_empty_subscriptions() {
19015        // v3 snapshot (publications-only). Forge it by hand so we
19016        // verify v6.1.4 readers don't panic — they must surface
19017        // empty subscriptions and a populated publication table.
19018        let mut e = Engine::new();
19019        e.execute("CREATE PUBLICATION pub_legacy").unwrap();
19020        let catalog = e.catalog.serialize();
19021        let users = crate::users::serialize_users(&e.users);
19022        let pubs = e.publications.serialize();
19023        let mut buf = Vec::new();
19024        buf.extend_from_slice(b"SPGENV01");
19025        buf.push(3u8); // v3
19026        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
19027        buf.extend_from_slice(&catalog);
19028        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
19029        buf.extend_from_slice(&users);
19030        buf.extend_from_slice(&u32::try_from(pubs.len()).unwrap().to_le_bytes());
19031        buf.extend_from_slice(&pubs);
19032        let crc = spg_crypto::crc32::crc32(&buf);
19033        buf.extend_from_slice(&crc.to_le_bytes());
19034
19035        let e2 = Engine::restore_envelope(&buf).expect("v3 envelope restores under v4 reader");
19036        assert!(e2.subscriptions().is_empty());
19037        assert!(e2.publications().contains("pub_legacy"));
19038    }
19039
19040    #[test]
19041    fn create_subscription_allowed_inside_transaction() {
19042        let mut e = Engine::new();
19043        e.execute("BEGIN").unwrap();
19044        e.execute("CREATE SUBSCRIPTION s CONNECTION 'h=x' PUBLICATION p")
19045            .unwrap();
19046        e.execute("COMMIT").unwrap();
19047        assert!(e.subscriptions().contains("s"));
19048    }
19049
19050    // ── v6.2.0: ANALYZE + spg_statistic + envelope v5 ──────────
19051    #[test]
19052    fn analyze_populates_histogram_bounds() {
19053        let mut e = Engine::new();
19054        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT)")
19055            .unwrap();
19056        for i in 0..50 {
19057            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, 'name{i}')"))
19058                .unwrap();
19059        }
19060        e.execute("ANALYZE t").unwrap();
19061        let stats = e.statistics();
19062        let id_stats = stats.get("t", "id").unwrap();
19063        assert!(id_stats.histogram_bounds.len() >= 2);
19064        assert_eq!(id_stats.histogram_bounds.first().unwrap(), "0");
19065        assert_eq!(id_stats.histogram_bounds.last().unwrap(), "49");
19066        assert!((id_stats.null_frac - 0.0).abs() < 1e-6);
19067        assert_eq!(id_stats.n_distinct, 50);
19068    }
19069
19070    #[test]
19071    fn reanalyze_overwrites_prior_stats() {
19072        let mut e = Engine::new();
19073        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19074        for i in 0..10 {
19075            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19076                .unwrap();
19077        }
19078        e.execute("ANALYZE t").unwrap();
19079        let n1 = e.statistics().get("t", "id").unwrap().n_distinct;
19080        assert_eq!(n1, 10);
19081        for i in 10..30 {
19082            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19083                .unwrap();
19084        }
19085        e.execute("ANALYZE t").unwrap();
19086        let n2 = e.statistics().get("t", "id").unwrap().n_distinct;
19087        assert_eq!(n2, 30);
19088    }
19089
19090    #[test]
19091    fn analyze_unknown_table_errors() {
19092        let mut e = Engine::new();
19093        let err = e.execute("ANALYZE nonexistent").unwrap_err();
19094        assert!(matches!(
19095            err,
19096            EngineError::Storage(StorageError::TableNotFound { .. })
19097        ));
19098    }
19099
19100    #[test]
19101    fn bare_analyze_covers_all_user_tables() {
19102        let mut e = Engine::new();
19103        e.execute("CREATE TABLE t1 (id INT NOT NULL)").unwrap();
19104        e.execute("CREATE TABLE t2 (name TEXT NOT NULL)").unwrap();
19105        e.execute("INSERT INTO t1 VALUES (1)").unwrap();
19106        e.execute("INSERT INTO t2 VALUES ('alice')").unwrap();
19107        let r = e.execute("ANALYZE").unwrap();
19108        match r {
19109            QueryResult::CommandOk {
19110                affected,
19111                modified_catalog,
19112            } => {
19113                assert_eq!(affected, 2);
19114                assert!(modified_catalog);
19115            }
19116            other => panic!("expected CommandOk, got {other:?}"),
19117        }
19118        assert!(e.statistics().get("t1", "id").is_some());
19119        assert!(e.statistics().get("t2", "name").is_some());
19120    }
19121
19122    #[test]
19123    fn select_from_spg_statistic_returns_rows_per_column() {
19124        let mut e = Engine::new();
19125        e.execute("CREATE TABLE t (id INT NOT NULL, label TEXT)")
19126            .unwrap();
19127        e.execute("INSERT INTO t VALUES (1, 'a')").unwrap();
19128        e.execute("INSERT INTO t VALUES (2, 'b')").unwrap();
19129        e.execute("ANALYZE t").unwrap();
19130        let r = e.execute_readonly("SELECT * FROM spg_statistic").unwrap();
19131        let QueryResult::Rows { rows, columns } = r else {
19132            panic!()
19133        };
19134        // v6.7.0 — spg_statistic gained a `cold_row_count` column.
19135        assert_eq!(columns.len(), 6);
19136        assert_eq!(columns[0].name, "table_name");
19137        assert_eq!(columns[4].name, "histogram_bounds");
19138        assert_eq!(columns[5].name, "cold_row_count");
19139        assert_eq!(rows.len(), 2, "one row per column of t");
19140        // Sorted by (table_name, column_name).
19141        match (&rows[0].values[0], &rows[0].values[1]) {
19142            (Value::Text(t), Value::Text(c)) => {
19143                assert_eq!(t, "t");
19144                // BTreeMap orders (table, column); columns "id" < "label".
19145                assert_eq!(c, "id");
19146            }
19147            _ => panic!(),
19148        }
19149    }
19150
19151    #[test]
19152    fn analyze_skips_vector_columns() {
19153        // Vector columns have their own stats shape (HNSW graph);
19154        // ANALYZE leaves them out of spg_statistic.
19155        let mut e = Engine::new();
19156        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(3) NOT NULL)")
19157            .unwrap();
19158        e.execute("INSERT INTO t VALUES (1, [1, 2, 3])").unwrap();
19159        e.execute("ANALYZE t").unwrap();
19160        assert!(e.statistics().get("t", "id").is_some());
19161        assert!(e.statistics().get("t", "v").is_none());
19162    }
19163
19164    #[test]
19165    fn statistics_persist_across_envelope_v5_round_trip() {
19166        let mut e = Engine::new();
19167        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19168        for i in 0..20 {
19169            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19170                .unwrap();
19171        }
19172        e.execute("ANALYZE").unwrap();
19173        let snap = e.snapshot();
19174        let e2 = Engine::restore_envelope(&snap).unwrap();
19175        let s = e2.statistics().get("t", "id").unwrap();
19176        assert_eq!(s.n_distinct, 20);
19177    }
19178
19179    // ── v6.2.1 auto-analyze threshold ───────────────────────────
19180
19181    #[test]
19182    fn auto_analyze_threshold_fires_after_10pct_of_min_rows_on_small_table() {
19183        // For a table with 0 rows then 10 inserts → modified=10,
19184        // row_count=10. Threshold = 0.1 × max(10, 100) = 10. So
19185        // after the 10th INSERT the threshold is met.
19186        let mut e = Engine::new();
19187        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19188        for i in 0..9 {
19189            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19190                .unwrap();
19191        }
19192        assert!(e.tables_needing_analyze().is_empty(), "9 < threshold");
19193        e.execute("INSERT INTO t VALUES (9)").unwrap();
19194        let needs = e.tables_needing_analyze();
19195        assert_eq!(needs, alloc::vec!["t".to_string()]);
19196    }
19197
19198    #[test]
19199    fn auto_analyze_threshold_uses_10pct_of_row_count_for_large_tables() {
19200        // After ANALYZE on 1000 rows, threshold = 0.1 × row_count.
19201        // Each new INSERT bumps both modified and row_count, so to
19202        // trigger from N=1000 we need modifications ≥ 0.1 × (1000+M),
19203        // i.e. M ≥ 112. The test inserts 50 (no fire), then 150
19204        // more (200 total mods, row_count=1200, threshold=120 → fire).
19205        let mut e = Engine::new();
19206        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19207        for i in 0..1000 {
19208            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19209                .unwrap();
19210        }
19211        e.execute("ANALYZE t").unwrap();
19212        assert!(e.tables_needing_analyze().is_empty(), "fresh ANALYZE");
19213        for i in 1000..1050 {
19214            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19215                .unwrap();
19216        }
19217        assert!(
19218            e.tables_needing_analyze().is_empty(),
19219            "50 inserts < threshold of ~105"
19220        );
19221        for i in 1050..1200 {
19222            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19223                .unwrap();
19224        }
19225        assert_eq!(
19226            e.tables_needing_analyze(),
19227            alloc::vec!["t".to_string()],
19228            "200 inserts > 0.1 × 1200 threshold"
19229        );
19230    }
19231
19232    #[test]
19233    fn auto_analyze_threshold_resets_after_analyze() {
19234        let mut e = Engine::new();
19235        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19236        for i in 0..200 {
19237            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19238                .unwrap();
19239        }
19240        assert!(!e.tables_needing_analyze().is_empty());
19241        e.execute("ANALYZE").unwrap();
19242        assert!(
19243            e.tables_needing_analyze().is_empty(),
19244            "ANALYZE must reset the counter"
19245        );
19246    }
19247
19248    #[test]
19249    fn auto_analyze_threshold_tracks_updates_and_deletes() {
19250        let mut e = Engine::new();
19251        e.execute("CREATE TABLE t (id INT NOT NULL, label TEXT)")
19252            .unwrap();
19253        for i in 0..50 {
19254            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, 'x')"))
19255                .unwrap();
19256        }
19257        e.execute("ANALYZE t").unwrap();
19258        // UPDATE 20 rows + DELETE 5 → modified=25. Threshold = 0.1
19259        // × max(50, 100) = 10. So 25 >= 10 → trigger.
19260        e.execute("UPDATE t SET label = 'y' WHERE id < 20").unwrap();
19261        e.execute("DELETE FROM t WHERE id >= 45").unwrap();
19262        assert_eq!(e.tables_needing_analyze(), alloc::vec!["t".to_string()]);
19263    }
19264
19265    #[test]
19266    fn v4_envelope_loads_with_empty_statistics() {
19267        // Forge a v4 envelope by hand: catalog + users + pubs +
19268        // subs trailer, no statistics. A v6.2.0 reader must accept
19269        // it and surface an empty Statistics.
19270        let mut e = Engine::new();
19271        e.create_user("alice", "secret", crate::users::Role::ReadOnly, [0u8; 16])
19272            .unwrap();
19273        let catalog = e.catalog.serialize();
19274        let users = crate::users::serialize_users(&e.users);
19275        let pubs = e.publications.serialize();
19276        let subs = e.subscriptions.serialize();
19277        let mut buf = Vec::new();
19278        buf.extend_from_slice(b"SPGENV01");
19279        buf.push(4u8);
19280        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
19281        buf.extend_from_slice(&catalog);
19282        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
19283        buf.extend_from_slice(&users);
19284        buf.extend_from_slice(&u32::try_from(pubs.len()).unwrap().to_le_bytes());
19285        buf.extend_from_slice(&pubs);
19286        buf.extend_from_slice(&u32::try_from(subs.len()).unwrap().to_le_bytes());
19287        buf.extend_from_slice(&subs);
19288        let crc = spg_crypto::crc32::crc32(&buf);
19289        buf.extend_from_slice(&crc.to_le_bytes());
19290        let e2 = Engine::restore_envelope(&buf).expect("v4 envelope restores");
19291        assert!(e2.statistics().is_empty());
19292    }
19293
19294    #[test]
19295    fn v1_v2_envelope_loads_with_empty_publications() {
19296        // A snapshot taken before v6.1.2 (no publication trailer,
19297        // envelope v2) must still deserialise — and the resulting
19298        // engine must report zero publications. Use the engine's own
19299        // round-trip with no publications: that emits v3 but with an
19300        // empty pubs block. Then forge a v2 envelope by hand to lock
19301        // the back-compat path.
19302        let mut e = Engine::new();
19303        // Force users to be non-empty so the snapshot takes the
19304        // envelope path rather than the bare-catalog fallback.
19305        e.create_user("alice", "secret", crate::users::Role::ReadOnly, [0u8; 16])
19306            .unwrap();
19307
19308        // Forge an envelope v2: same shape as v3 but no pubs trailer.
19309        let catalog = e.catalog.serialize();
19310        let users = crate::users::serialize_users(&e.users);
19311        let mut buf = Vec::new();
19312        buf.extend_from_slice(b"SPGENV01");
19313        buf.push(2u8); // v2
19314        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
19315        buf.extend_from_slice(&catalog);
19316        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
19317        buf.extend_from_slice(&users);
19318        let crc = spg_crypto::crc32::crc32(&buf);
19319        buf.extend_from_slice(&crc.to_le_bytes());
19320
19321        let e2 = Engine::restore_envelope(&buf).expect("v2 envelope restores");
19322        assert!(e2.publications().is_empty());
19323    }
19324}