Skip to main content

spg_engine/
lib.rs

1//! SPG execution engine — v0.3 wires the SQL front-end to the in-memory
2//! storage layer. Implements `CREATE TABLE`, single-row `INSERT VALUES`, and
3//! `SELECT * FROM <table>` (no WHERE yet — that lands in v0.4 alongside
4//! expression evaluation against rows).
5#![no_std]
6
7extern crate alloc;
8
9pub mod aggregate;
10pub mod copy;
11pub mod describe;
12pub mod eval;
13pub mod fts;
14pub mod json;
15pub mod memoize;
16pub mod plan_cache;
17pub mod publications;
18pub mod query_stats;
19pub mod reorder;
20pub mod selectivity;
21pub mod statistics;
22pub mod subscriptions;
23pub mod triggers;
24pub mod users;
25
26pub use crate::users::{Role, ScramSecrets, UserError, UserStore};
27
28use alloc::borrow::Cow;
29use alloc::boxed::Box;
30use alloc::collections::BTreeMap;
31use alloc::string::{String, ToString};
32use alloc::vec::Vec;
33use core::fmt;
34
35use spg_sql::ast::{
36    BinOp, ColumnDef, ColumnName, ColumnTypeName, CreateIndexStatement, CreatePublicationStatement,
37    CreateSubscriptionStatement, CreateTableStatement, CreateUserStatement, Expr, FrameBound,
38    FrameKind, FromClause, IndexMethod, InsertStatement, JoinKind, Literal, OrderBy, SelectItem,
39    SelectStatement, Statement, TableRef, UnOp, UnionKind, VecEncoding as SqlVecEncoding,
40    WindowFrame,
41};
42// v7.16.0 — re-export the parsed-statement AST so downstream
43// crates (spg-embedded → spg-sqlx) don't need a direct dep on
44// spg-sql for the prepare/bind handle.
45pub use spg_sql::ast::Statement as ParsedStatement;
46use spg_sql::parser::{self, ParseError};
47use spg_storage::{
48    Catalog, ColumnSchema, CompactReport, DataType, IndexKey, IndexKind, Row, StorageError, Table,
49    TableSchema, Value, VecEncoding,
50};
51
52use crate::eval::{EvalContext, EvalError};
53
54/// Result of executing one statement.
55#[derive(Debug, Clone, PartialEq)]
56#[non_exhaustive]
57pub enum QueryResult {
58    /// DDL or DML succeeded.
59    ///
60    /// `affected` is the row count for `INSERT` and 0 elsewhere.
61    /// `modified_catalog` tells the server whether this statement
62    /// caused the *committed* catalog to change — it's the signal to
63    /// snapshot/audit. False for `BEGIN`/`ROLLBACK`, false for writeful
64    /// statements executed inside a transaction (those only touch the
65    /// shadow), and true for `COMMIT` and for writes outside a TX.
66    CommandOk {
67        affected: usize,
68        modified_catalog: bool,
69    },
70    /// `SELECT` returned a (possibly empty) row set.
71    Rows {
72        columns: Vec<ColumnSchema>,
73        rows: Vec<Row>,
74    },
75}
76
77/// All errors the engine can return.
78///
79/// Marked `#[non_exhaustive]` from v7.5.0 onward: external `match`
80/// must include a `_` arm so new variants in subsequent v7.x releases
81/// are not breaking changes.
82#[derive(Debug, Clone, PartialEq)]
83#[non_exhaustive]
84pub enum EngineError {
85    Parse(ParseError),
86    Storage(StorageError),
87    Eval(EvalError),
88    /// Front-end accepted a construct that the v0.x executor doesn't support.
89    Unsupported(String),
90    /// `BEGIN` while another transaction is already open.
91    TransactionAlreadyOpen,
92    /// `COMMIT` / `ROLLBACK` with no active transaction.
93    NoActiveTransaction,
94    /// v4.0 sentinel: `execute_readonly` got a statement that
95    /// mutates engine state (INSERT / CREATE / BEGIN / COMMIT / …).
96    /// The caller should retake the write lock and dispatch through
97    /// `execute(&mut self)` instead.
98    WriteRequired,
99    /// v4.2: a SELECT would have returned more rows than the
100    /// configured `max_query_rows` cap. Carries the cap.
101    RowLimitExceeded(usize),
102    /// v4.5: cooperative cancellation — the host (server's
103    /// per-query watchdog) set the cancel flag while a long-running
104    /// SELECT / UPDATE / DELETE was scanning rows. The partial work
105    /// is discarded; the caller should surface this as a timeout
106    /// to the client.
107    Cancelled,
108}
109
110impl fmt::Display for EngineError {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        match self {
113            Self::Parse(e) => write!(f, "parse: {e}"),
114            Self::Storage(e) => write!(f, "storage: {e}"),
115            Self::Eval(e) => write!(f, "eval: {e}"),
116            Self::Unsupported(s) => write!(f, "unsupported: {s}"),
117            Self::TransactionAlreadyOpen => f.write_str("a transaction is already open"),
118            Self::NoActiveTransaction => f.write_str("no active transaction"),
119            Self::WriteRequired => {
120                f.write_str("statement requires a write lock (use execute, not execute_readonly)")
121            }
122            Self::RowLimitExceeded(n) => {
123                write!(f, "query exceeded max_query_rows={n}")
124            }
125            Self::Cancelled => f.write_str("query cancelled (timeout or client request)"),
126        }
127    }
128}
129
130impl From<ParseError> for EngineError {
131    fn from(e: ParseError) -> Self {
132        Self::Parse(e)
133    }
134}
135impl From<StorageError> for EngineError {
136    fn from(e: StorageError) -> Self {
137        Self::Storage(e)
138    }
139}
140impl From<EvalError> for EngineError {
141    fn from(e: EvalError) -> Self {
142        Self::Eval(e)
143    }
144}
145
146/// The execution engine. Holds the catalog and (later) other server-scope
147/// state. `Engine::new()` is intentionally cheap so callers can construct one
148/// per database, per test.
149/// Function pointer that returns "now" as microseconds since Unix
150/// epoch. The engine is `no_std`, so it can't reach for `std::time`
151/// itself — callers (`spg-server`, the sqllogictest runner) inject a
152/// concrete implementation. `None` means `NOW()` / `CURRENT_*` raise
153/// `Unsupported`.
154pub type ClockFn = fn() -> i64;
155
156/// Function pointer that produces 16 cryptographically random bytes.
157/// Like `ClockFn`, the engine is `no_std` and can't reach for /dev/urandom
158/// itself — host (`spg-server`) injects an OS-backed source. `None`
159/// means SQL-driven `CREATE USER` falls back to a deterministic salt
160/// derived from the username (acceptable in tests; the server always
161/// installs a real RNG so production paths never see this).
162pub type SaltFn = fn() -> [u8; 16];
163
164/// v4.5 cooperative cancellation token. A long-running SELECT /
165/// UPDATE / DELETE checks `is_cancelled` at row-loop checkpoints
166/// and bails with `EngineError::Cancelled`. The host
167/// (`spg-server`) creates an `AtomicBool` per query, spawns a
168/// watchdog thread that sets it after `SPG_QUERY_TIMEOUT_MS`,
169/// and passes it via `execute_with_cancel` / `execute_readonly_with_cancel`.
170///
171/// `CancelToken::none()` is a no-op — used by the legacy `execute`
172/// and `execute_readonly` entry points so existing callers don't
173/// change.
174/// v7.17.0 Phase 2.3 — monotonic time source for deadline-aware
175/// cancellation (PG `statement_timeout`). Returns microseconds
176/// since some host-stable monotonic origin (typically the first
177/// call into `Instant::now()` on the server). The engine never
178/// calls `Instant::now()` directly so the crate stays `#![no_std]`.
179pub type MonotonicNowFn = fn() -> u64;
180
181#[derive(Debug, Clone, Copy)]
182struct Deadline {
183    now_fn: MonotonicNowFn,
184    /// Absolute deadline in `now_fn()` units (microseconds).
185    deadline_us: u64,
186}
187
188#[derive(Debug, Clone, Copy)]
189pub struct CancelToken<'a> {
190    flag: Option<&'a core::sync::atomic::AtomicBool>,
191    // v7.17.0 Phase 2.3 — when set, every existing `cancel.check()`
192    // checkpoint also fires `EngineError::Cancelled` once
193    // `(now_fn)() >= deadline_us`. No new check sites, no thread
194    // spawn per query — the monotonic now-fn read is a vDSO
195    // `clock_gettime(CLOCK_MONOTONIC)` (~20ns) and only runs when
196    // the host actually wired a deadline (statement_timeout > 0).
197    deadline: Option<Deadline>,
198}
199
200impl<'a> CancelToken<'a> {
201    #[must_use]
202    pub const fn none() -> Self {
203        Self {
204            flag: None,
205            deadline: None,
206        }
207    }
208
209    #[must_use]
210    pub const fn from_flag(f: &'a core::sync::atomic::AtomicBool) -> Self {
211        Self {
212            flag: Some(f),
213            deadline: None,
214        }
215    }
216
217    /// v7.17.0 Phase 2.3 — attach a monotonic deadline. `now_fn`
218    /// must return microseconds since a stable origin; the token
219    /// trips when `now_fn() >= deadline_us`. Compose with
220    /// `from_flag(...)` when both a watchdog flag and a per-statement
221    /// timeout are in play (e.g. server-wide `SPG_QUERY_TIMEOUT_MS`
222    /// plus session `statement_timeout`); the tighter of the two
223    /// wins by virtue of either signaling first.
224    #[must_use]
225    pub const fn with_deadline(mut self, now_fn: MonotonicNowFn, deadline_us: u64) -> Self {
226        self.deadline = Some(Deadline {
227            now_fn,
228            deadline_us,
229        });
230        self
231    }
232
233    #[must_use]
234    pub fn is_cancelled(self) -> bool {
235        if self
236            .flag
237            .is_some_and(|f| f.load(core::sync::atomic::Ordering::Relaxed))
238        {
239            return true;
240        }
241        // Deadline check is the second branch so the "no timeout"
242        // hot path (`deadline: None`) elides the now-fn call —
243        // predicted-not-taken on the SLO INSERT loop.
244        if let Some(d) = self.deadline
245            && (d.now_fn)() >= d.deadline_us
246        {
247            return true;
248        }
249        false
250    }
251
252    /// Returns `Err(Cancelled)` if the token has been tripped.
253    /// Used at row-loop checkpoints to bail cooperatively without
254    /// scattering raw `is_cancelled` checks across the executor.
255    #[inline]
256    pub fn check(self) -> Result<(), EngineError> {
257        if self.is_cancelled() {
258            Err(EngineError::Cancelled)
259        } else {
260            Ok(())
261        }
262    }
263}
264
265// ---- snapshot envelope (v4.1, extended with CRC32 in v4.37,  ----
266// ----   publications in v6.1.2 v3, subscriptions in v6.1.4 v4) ----
267//
268// Wraps a catalog blob + a user blob behind a small header so the
269// server can persist both atomically without inventing a new file.
270// Bare catalog blobs (v3.x) still load via `restore_envelope` since
271// the magic check fails fast and the function falls back to
272// `Catalog::deserialize`.
273//
274// Layout — v1 (v4.1, no CRC):
275//   [8 bytes magic "SPGENV01"]
276//   [u8 version = 1]
277//   [u32 catalog_len][catalog bytes]
278//   [u32 users_len][users bytes]
279//
280// Layout — v2 (v4.37, CRC32 of body):
281//   [8 bytes magic "SPGENV01"]
282//   [u8 version = 2]
283//   [u32 catalog_len][catalog bytes]
284//   [u32 users_len][users bytes]
285//   [u32 crc32]                      ← CRC32 of every byte before it.
286//
287// Layout — v3 (v6.1.2, publications trailer):
288//   [8 bytes magic "SPGENV01"]
289//   [u8 version = 3]
290//   [u32 catalog_len][catalog bytes]
291//   [u32 users_len][users bytes]
292//   [u32 pubs_len][publications bytes]
293//   [u32 crc32]
294//
295// Layout — v4 (v6.1.4, subscriptions trailer):
296//   [8 bytes magic "SPGENV01"]
297//   [u8 version = 4]
298//   [u32 catalog_len][catalog bytes]
299//   [u32 users_len][users bytes]
300//   [u32 pubs_len][publications bytes]
301//   [u32 subs_len][subscriptions bytes]
302//   [u32 crc32]
303//
304// Layout — v5 (v6.2.0, statistics trailer):
305//   [8 bytes magic "SPGENV01"]
306//   [u8 version = 5]
307//   [u32 catalog_len][catalog bytes]
308//   [u32 users_len][users bytes]
309//   [u32 pubs_len][publications bytes]
310//   [u32 subs_len][subscriptions bytes]
311//   [u32 stats_len][statistics bytes]      ← NEW
312//   [u32 crc32]
313//
314// Writers emit v5 from v6.2.0 on. Readers accept all of {v1, v2,
315// v3, v4, v5}: v1/v2 load with empty publications / subscriptions /
316// statistics; v3 loads with empty subscriptions + statistics; v4
317// loads with empty statistics; v5 deserialises all three. Older
318// SPG versions reading a v5 envelope fall through the version
319// match to `EnvelopeParse::Bare` — pre-v6.2.0 binaries cannot
320// open v6.2.0+ snapshots (matches the v6.1.2 / v6.1.4 breaks).
321
322const ENVELOPE_MAGIC: &[u8; 8] = b"SPGENV01";
323const ENVELOPE_VERSION_V1: u8 = 1;
324const ENVELOPE_VERSION_V2: u8 = 2;
325const ENVELOPE_VERSION_V3: u8 = 3;
326const ENVELOPE_VERSION_V4: u8 = 4;
327const ENVELOPE_VERSION_V5: u8 = 5;
328
329fn build_envelope(catalog: &[u8], users: &[u8], pubs: &[u8], subs: &[u8], stats: &[u8]) -> Vec<u8> {
330    let mut out = Vec::with_capacity(
331        8 + 1
332            + 4
333            + catalog.len()
334            + 4
335            + users.len()
336            + 4
337            + pubs.len()
338            + 4
339            + subs.len()
340            + 4
341            + stats.len()
342            + 4,
343    );
344    out.extend_from_slice(ENVELOPE_MAGIC);
345    out.push(ENVELOPE_VERSION_V5);
346    out.extend_from_slice(
347        &u32::try_from(catalog.len())
348            .expect("≤ 4G catalog")
349            .to_le_bytes(),
350    );
351    out.extend_from_slice(catalog);
352    out.extend_from_slice(
353        &u32::try_from(users.len())
354            .expect("≤ 4G users")
355            .to_le_bytes(),
356    );
357    out.extend_from_slice(users);
358    out.extend_from_slice(
359        &u32::try_from(pubs.len())
360            .expect("≤ 4G publications")
361            .to_le_bytes(),
362    );
363    out.extend_from_slice(pubs);
364    out.extend_from_slice(
365        &u32::try_from(subs.len())
366            .expect("≤ 4G subscriptions")
367            .to_le_bytes(),
368    );
369    out.extend_from_slice(subs);
370    out.extend_from_slice(
371        &u32::try_from(stats.len())
372            .expect("≤ 4G statistics")
373            .to_le_bytes(),
374    );
375    out.extend_from_slice(stats);
376    let crc = spg_crypto::crc32::crc32(&out);
377    out.extend_from_slice(&crc.to_le_bytes());
378    out
379}
380
381/// Outcome of envelope parsing: either bare-catalog fallback, a
382/// successfully split section trio from a v1/v2/v3 envelope, or an
383/// explicit corruption error from a v2/v3 CRC mismatch. `Bare`
384/// (catalog-only fallback) preserves v3.x readability. v1/v2
385/// envelopes set `publications` to `None`; v3 sets it to the
386/// publications byte slice.
387enum EnvelopeParse<'a> {
388    Bare,
389    Pair {
390        catalog: &'a [u8],
391        users: &'a [u8],
392        publications: Option<&'a [u8]>,
393        subscriptions: Option<&'a [u8]>,
394        statistics: Option<&'a [u8]>,
395    },
396    CrcMismatch {
397        expected: u32,
398        computed: u32,
399    },
400}
401
402/// Returns `EnvelopeParse::Pair` for a valid v1 / v2 / v3 envelope,
403/// `Bare` for a buffer that doesn't look like an envelope (v3.x
404/// bare catalog fallback), and `CrcMismatch` for a v2/v3 envelope
405/// whose trailing CRC32 doesn't match the body.
406fn split_envelope(buf: &[u8]) -> EnvelopeParse<'_> {
407    if buf.len() < 8 + 1 + 4 || &buf[..8] != ENVELOPE_MAGIC {
408        return EnvelopeParse::Bare;
409    }
410    let version = buf[8];
411    if !matches!(
412        version,
413        ENVELOPE_VERSION_V1
414            | ENVELOPE_VERSION_V2
415            | ENVELOPE_VERSION_V3
416            | ENVELOPE_VERSION_V4
417            | ENVELOPE_VERSION_V5
418    ) {
419        return EnvelopeParse::Bare;
420    }
421    let mut p = 9usize;
422    let Some(cat_len_bytes) = buf.get(p..p + 4) else {
423        return EnvelopeParse::Bare;
424    };
425    let Ok(cat_len_arr) = cat_len_bytes.try_into() else {
426        return EnvelopeParse::Bare;
427    };
428    let cat_len = u32::from_le_bytes(cat_len_arr) as usize;
429    p += 4;
430    if p + cat_len + 4 > buf.len() {
431        return EnvelopeParse::Bare;
432    }
433    let catalog = &buf[p..p + cat_len];
434    p += cat_len;
435    let Some(user_len_bytes) = buf.get(p..p + 4) else {
436        return EnvelopeParse::Bare;
437    };
438    let Ok(user_len_arr) = user_len_bytes.try_into() else {
439        return EnvelopeParse::Bare;
440    };
441    let user_len = u32::from_le_bytes(user_len_arr) as usize;
442    p += 4;
443    if p + user_len > buf.len() {
444        return EnvelopeParse::Bare;
445    }
446    let users = &buf[p..p + user_len];
447    p += user_len;
448    let publications = if matches!(
449        version,
450        ENVELOPE_VERSION_V3 | ENVELOPE_VERSION_V4 | ENVELOPE_VERSION_V5
451    ) {
452        // [u32 pubs_len][publications bytes]
453        let Some(pubs_len_bytes) = buf.get(p..p + 4) else {
454            return EnvelopeParse::Bare;
455        };
456        let Ok(pubs_len_arr) = pubs_len_bytes.try_into() else {
457            return EnvelopeParse::Bare;
458        };
459        let pubs_len = u32::from_le_bytes(pubs_len_arr) as usize;
460        p += 4;
461        if p + pubs_len > buf.len() {
462            return EnvelopeParse::Bare;
463        }
464        let pubs_slice = &buf[p..p + pubs_len];
465        p += pubs_len;
466        Some(pubs_slice)
467    } else {
468        None
469    };
470    let subscriptions = if matches!(version, ENVELOPE_VERSION_V4 | ENVELOPE_VERSION_V5) {
471        // [u32 subs_len][subscriptions bytes]
472        let Some(subs_len_bytes) = buf.get(p..p + 4) else {
473            return EnvelopeParse::Bare;
474        };
475        let Ok(subs_len_arr) = subs_len_bytes.try_into() else {
476            return EnvelopeParse::Bare;
477        };
478        let subs_len = u32::from_le_bytes(subs_len_arr) as usize;
479        p += 4;
480        if p + subs_len > buf.len() {
481            return EnvelopeParse::Bare;
482        }
483        let subs_slice = &buf[p..p + subs_len];
484        p += subs_len;
485        Some(subs_slice)
486    } else {
487        None
488    };
489    let statistics = if version == ENVELOPE_VERSION_V5 {
490        // [u32 stats_len][statistics bytes]
491        let Some(stats_len_bytes) = buf.get(p..p + 4) else {
492            return EnvelopeParse::Bare;
493        };
494        let Ok(stats_len_arr) = stats_len_bytes.try_into() else {
495            return EnvelopeParse::Bare;
496        };
497        let stats_len = u32::from_le_bytes(stats_len_arr) as usize;
498        p += 4;
499        if p + stats_len > buf.len() {
500            return EnvelopeParse::Bare;
501        }
502        let stats_slice = &buf[p..p + stats_len];
503        p += stats_len;
504        Some(stats_slice)
505    } else {
506        None
507    };
508    if matches!(
509        version,
510        ENVELOPE_VERSION_V2 | ENVELOPE_VERSION_V3 | ENVELOPE_VERSION_V4 | ENVELOPE_VERSION_V5
511    ) {
512        if p + 4 != buf.len() {
513            return EnvelopeParse::Bare;
514        }
515        let Ok(crc_arr) = buf[p..p + 4].try_into() else {
516            return EnvelopeParse::Bare;
517        };
518        let expected = u32::from_le_bytes(crc_arr);
519        let computed = spg_crypto::crc32::crc32(&buf[..p]);
520        if expected != computed {
521            return EnvelopeParse::CrcMismatch { expected, computed };
522        }
523    } else if p != buf.len() {
524        // v1: must end exactly at the users section.
525        return EnvelopeParse::Bare;
526    }
527    EnvelopeParse::Pair {
528        catalog,
529        users,
530        publications,
531        subscriptions,
532        statistics,
533    }
534}
535
536/// v4.41.1 opaque transaction handle. Returned by `Engine::alloc_tx_id`,
537/// threaded through `Engine::execute_in` so dispatch can identify which
538/// in-flight TX a statement belongs to. `IMPLICIT_TX` is the reserved
539/// slot every legacy caller — engine self-tests, spg-cli, spg-embedded,
540/// startup replay — implicitly uses through the unchanged
541/// `Engine::execute(sql)` API. v4.41.1 keeps at most one active slot at
542/// runtime (dispatch holds `engine.write()` across the wrap, same as
543/// v4.34); the map shape is here to let v4.42 turn on N in-flight
544/// implicit TXs without reshuffling the engine internals.
545#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
546pub struct TxId(pub u64);
547
548/// Reserved slot used by `Engine::execute(sql)` — the legacy single-
549/// global-shadow path. New `alloc_tx_id` handles start at 1.
550pub const IMPLICIT_TX: TxId = TxId(0);
551
552/// v6.7.3 — default segment-size threshold used by `COMPACT COLD
553/// SEGMENTS` when no explicit target is supplied. Segments whose
554/// `OwnedSegment::bytes().len()` is **strictly** less than this
555/// value are eligible to merge. spg-server reads
556/// `SPG_COMPACTION_TARGET_SEGMENT_BYTES` to override.
557pub const COMPACTION_TARGET_DEFAULT_BYTES: u64 = 4 * 1024 * 1024;
558
559/// Per-slot transaction state. Held inside `tx_catalogs[tx_id]` for the
560/// lifetime of a BEGIN..COMMIT (or BEGIN..ROLLBACK) window. Drops when
561/// the TX commits (its `catalog` is moved over `Engine.catalog`) or
562/// rolls back (slot removed, catalog discarded).
563#[derive(Debug, Default, Clone)]
564struct TxState {
565    /// The TX's shadow copy of the catalog. Started as a clone of
566    /// `Engine.catalog` at BEGIN time; writes flow into it; COMMIT
567    /// installs it over `Engine.catalog`. `Catalog::clone()` is O(1)
568    /// since v4.40 (`PersistentVec` rows + `PersistentBTreeMap` indices).
569    catalog: Catalog,
570    /// Per-TX savepoint stack. Each entry pairs the savepoint name with
571    /// a clone of `catalog` at the moment `SAVEPOINT <name>` fired.
572    /// `ROLLBACK TO <name>` restores from the entry and pops everything
573    /// after it; `RELEASE <name>` discards the entry and everything
574    /// after; COMMIT/ROLLBACK clears the whole stack.
575    savepoints: Vec<(String, Catalog)>,
576}
577
578/// v7.11.0 — frozen read-only view of the engine's committed state.
579/// Constructed via [`Engine::clone_snapshot`]. Holds clones of the
580/// catalog, statistics, clock function, and row-cap config — the
581/// four fields the `execute_readonly` path actually reads. Cheap to
582/// `Clone` (each clone shares the underlying `PersistentVec` row
583/// storage; only the trie root pointers copy). Send + Sync so a
584/// snapshot can be moved across `tokio::task::spawn_blocking`
585/// boundaries without coordination.
586///
587/// The contract: a snapshot reflects the engine's state at the
588/// moment `clone_snapshot()` returned. Subsequent writes to the
589/// engine are NOT visible. Callers who need fresher data take a
590/// new snapshot.
591#[derive(Debug, Clone)]
592pub struct CatalogSnapshot {
593    catalog: Catalog,
594    statistics: statistics::Statistics,
595    clock: Option<ClockFn>,
596    max_query_rows: Option<usize>,
597}
598
599#[derive(Debug, Default)]
600pub struct Engine {
601    /// Committed catalog — what survives `Engine::snapshot()` and what
602    /// outside-TX `SELECT`s read.
603    catalog: Catalog,
604    /// Active TX slots, keyed by `TxId`. Empty when no TX is in flight.
605    /// v4.41.1 runtime invariant: at most one entry (single-writer
606    /// model unchanged). v4.42 will let dispatch hold multiple entries
607    /// concurrently for group commit + engine MVCC.
608    tx_catalogs: BTreeMap<TxId, TxState>,
609    /// Which slot the next exec_* call should mutate. Set by
610    /// `execute_in(sql, tx_id)` at the entry point; legacy `execute(sql)`
611    /// sets it to `IMPLICIT_TX`. None when no TX is in flight (read /
612    /// write goes straight against `catalog`).
613    current_tx: Option<TxId>,
614    /// Monotonic counter for `alloc_tx_id`. Starts at 1 — slot 0 is
615    /// reserved for `IMPLICIT_TX`.
616    next_tx_id: u64,
617    /// v7.22 (round-13 T3) — session string-literal dialect. `false`
618    /// (default) = PG semantics (backslash literal, `''` escape);
619    /// `true` = MySQL semantics (`\'` etc.). Flipped by the
620    /// deterministic session signals each dump emits: `SET sql_mode`
621    /// (only MySQL clients/dumps send it) turns it on,
622    /// `SET standard_conforming_strings = on` (every pg_dump
623    /// preamble) turns it off. The plan cache is cleared on every
624    /// flip — the same SQL text lexes differently per dialect.
625    backslash_escapes: bool,
626    /// Optional wall clock used to satisfy `NOW()` / `CURRENT_TIMESTAMP`
627    /// / `CURRENT_DATE`. Set by the host environment.
628    clock: Option<ClockFn>,
629    /// v4.1 cryptographic RNG for per-user password salt. Set by the
630    /// host. `None` means SQL-driven `CREATE USER` uses a
631    /// deterministic fallback — see `SaltFn`.
632    salt_fn: Option<SaltFn>,
633    /// v4.2 per-query row cap. `None` = unlimited. When set, a
634    /// SELECT that materialises more than `n` rows returns
635    /// `EngineError::RowLimitExceeded`. Enforced before the result
636    /// is shaped into wire frames so a runaway scan can't blow the
637    /// server's heap.
638    max_query_rows: Option<usize>,
639    /// v4.1 RBAC user table. Empty means "no RBAC configured yet" —
640    /// the server decides what that means at the auth boundary
641    /// (open mode vs legacy single-password mode). User CRUD goes
642    /// through `create_user`/`drop_user`/`verify_user`; persistence
643    /// rides the snapshot envelope alongside the catalog.
644    users: UserStore,
645    /// v6.1.2 logical-replication publication catalog. Empty until
646    /// `CREATE PUBLICATION` runs. Persistence rides the v3 envelope
647    /// trailer (see `build_envelope`).
648    publications: publications::Publications,
649    /// v6.1.4 logical-replication subscription catalog. Empty until
650    /// `CREATE SUBSCRIPTION` runs. Persistence rides the v4 envelope
651    /// trailer.
652    subscriptions: subscriptions::Subscriptions,
653    /// v6.2.0 — per-column statistics for the cost-based optimizer.
654    /// Populated by `ANALYZE`; queried via `spg_statistic` virtual
655    /// table. Persistence rides the v5 envelope trailer.
656    statistics: statistics::Statistics,
657    /// v6.3.0 — engine-level plan cache. Caches the post-`prepare()`
658    /// `Statement` keyed on SQL text. In-memory only — does NOT ride
659    /// the snapshot envelope (rebuilt on demand after restart).
660    plan_cache: plan_cache::PlanCache,
661    /// v6.5.1 — per-distinct-SQL execution stats. In-memory only,
662    /// surfaced via `spg_stat_query` virtual table. Updated by the
663    /// `execute_*` paths after a successful execute.
664    query_stats: query_stats::QueryStats,
665    /// v6.5.2 — connection-state provider callback. spg-server
666    /// registers a function at startup that snapshots its
667    /// per-pgwire-connection registry into `ActivityRow`s; engine
668    /// reads through it on every `SELECT * FROM spg_stat_activity`.
669    /// `None` ⇒ no-data (returns empty rows; matches the no_std
670    /// embedded callers that don't run pgwire).
671    activity_provider: Option<ActivityProvider>,
672    /// v6.5.3 — audit-chain provider + verifier. Same pattern as
673    /// activity_provider: spg-server registers both at startup;
674    /// engine reads through on `SELECT * FROM spg_audit_chain` and
675    /// `SELECT * FROM spg_audit_verify`. `None` ⇒ no-data.
676    audit_chain_provider: Option<AuditChainProvider>,
677    audit_verifier: Option<AuditVerifier>,
678    /// v6.5.6 — slow-query log threshold in microseconds. When set,
679    /// every successful execute whose elapsed exceeds the threshold
680    /// gets fed to the registered slow-query log callback (so
681    /// spg-server can emit a structured log line). Default `None`
682    /// = no slow-query logging.
683    slow_query_threshold_us: Option<u64>,
684    slow_query_logger: Option<SlowQueryLogger>,
685    /// v7.12.1 — session parameters set via `SET <name> = <value>`.
686    /// Only `default_text_search_config` is consumed by the engine
687    /// today (the FTS function dispatcher reads it when
688    /// `to_tsvector(text)` is called without an explicit config).
689    /// All other names are accepted + recorded so PG-dump output
690    /// loads, but have no behavioural effect.
691    session_params: BTreeMap<String, String>,
692    /// v7.12.7 — depth counter for trigger-emitted embedded SQL.
693    /// Each time the engine executes a `DeferredEmbeddedStmt` it
694    /// increments this; the recursive `execute_stmt_with_cancel`
695    /// inside that path checks against [`MAX_TRIGGER_RECURSION`]
696    /// to bound runaway cascades (trigger A's UPDATE on table B
697    /// fires trigger B which UPDATEs table A which fires trigger
698    /// A again…). Reset to 0 once the original DML returns.
699    trigger_recursion_depth: u32,
700    /// v7.14.0 — when `SET FOREIGN_KEY_CHECKS=0` is in effect
701    /// (mysqldump preamble), the FK existence + arity check at
702    /// CREATE TABLE time is deferred. FKs referencing a
703    /// not-yet-existing parent land in `pending_foreign_keys`
704    /// keyed by child table; `SET FOREIGN_KEY_CHECKS=1` drains
705    /// the queue and resolves each FK against the now-complete
706    /// catalog. Empty by default; the queue is drained on every
707    /// `RESET ALL` too.
708    foreign_key_checks: bool,
709    /// v7.16.2 — true on the temp Engine an outer
710    /// `exec_select_with_meta_views` builds, telling that
711    /// temp engine "stop short-circuiting into the meta-view
712    /// path — your catalog already has the materialised
713    /// tables; just run the regular SELECT." Without this we'd
714    /// infinite-loop since the meta-view name (e.g.
715    /// `__spg_info_columns`) still triggers
716    /// `select_references_meta_view`.
717    meta_views_materialised: bool,
718    pending_foreign_keys: Vec<(alloc::string::String, spg_sql::ast::ForeignKeyConstraint)>,
719}
720
721/// v7.12.7 — hard cap on nested trigger-emitted embedded SQL
722/// fires. 16 deep is well past anything a normal trigger graph
723/// uses while still preventing infinite-loop wedging.
724const MAX_TRIGGER_RECURSION: u32 = 16;
725
726/// v6.5.6 — callback signature for slow-query log emission. Called
727/// with `(sql, elapsed_us)` once per successful execute that crosses
728/// the threshold.
729pub type SlowQueryLogger = fn(&str, u64);
730
731/// v6.5.4 — synthesise a `CREATE TABLE` statement from catalog
732/// state. Round-trips through `Engine::execute` to recreate the
733/// same schema (sans data + indexes — indexes are emitted as a
734/// separate `CREATE INDEX` chain in `spg_database_ddl`).
735fn render_create_table(name: &str, columns: &[ColumnSchema]) -> String {
736    let mut out = alloc::format!("CREATE TABLE {name} (");
737    for (i, col) in columns.iter().enumerate() {
738        if i > 0 {
739            out.push_str(", ");
740        }
741        out.push_str(&col.name);
742        out.push(' ');
743        out.push_str(&render_data_type(col.ty));
744        if !col.nullable {
745            out.push_str(" NOT NULL");
746        }
747        if col.auto_increment {
748            out.push_str(" AUTO_INCREMENT");
749        }
750    }
751    out.push(')');
752    out
753}
754
755fn render_data_type(ty: DataType) -> String {
756    match ty {
757        DataType::SmallInt => "SMALLINT".into(),
758        DataType::Int => "INT".into(),
759        DataType::BigInt => "BIGINT".into(),
760        DataType::Float => "FLOAT".into(),
761        DataType::Text => "TEXT".into(),
762        DataType::Varchar(n) => alloc::format!("VARCHAR({n})"),
763        DataType::Char(n) => alloc::format!("CHAR({n})"),
764        DataType::Bool => "BOOL".into(),
765        DataType::Vector { dim, encoding } => match encoding {
766            spg_storage::VecEncoding::F32 => alloc::format!("VECTOR({dim})"),
767            spg_storage::VecEncoding::Sq8 => alloc::format!("VECTOR({dim}) USING SQ8"),
768            spg_storage::VecEncoding::F16 => alloc::format!("VECTOR({dim}) USING HALF"),
769        },
770        DataType::Numeric { precision, scale } => {
771            alloc::format!("NUMERIC({precision},{scale})")
772        }
773        DataType::Date => "DATE".into(),
774        DataType::Timestamp => "TIMESTAMP".into(),
775        DataType::Interval => "INTERVAL".into(),
776        DataType::Json => "JSON".into(),
777        DataType::Jsonb => "JSONB".into(),
778        DataType::Timestamptz => "TIMESTAMPTZ".into(),
779        DataType::Bytes => "BYTEA".into(),
780        DataType::TextArray => "TEXT[]".into(),
781        DataType::IntArray => "INT[]".into(),
782        DataType::BigIntArray => "BIGINT[]".into(),
783        DataType::TsVector => "TSVECTOR".into(),
784        DataType::TsQuery => "TSQUERY".into(),
785        DataType::Uuid => "UUID".into(),
786        DataType::Time => "TIME".into(),
787        DataType::Year => "YEAR".into(),
788        DataType::TimeTz => "TIMETZ".into(),
789        DataType::Money => "MONEY".into(),
790        DataType::Range(k) => k.keyword().into(),
791        DataType::Hstore => "HSTORE".into(),
792        DataType::IntArray2D => "INT[][]".into(),
793        DataType::BigIntArray2D => "BIGINT[][]".into(),
794        DataType::TextArray2D => "TEXT[][]".into(),
795    }
796}
797
798/// v6.5.2 — one row of `spg_stat_activity`. Engine-public so
799/// spg-server can construct rows without re-exporting internal
800/// dispatch types.
801#[derive(Debug, Clone)]
802pub struct ActivityRow {
803    pub pid: u32,
804    pub user: String,
805    pub started_at_us: i64,
806    pub current_sql: String,
807    pub wait_event: String,
808    pub elapsed_us: i64,
809    pub in_transaction: bool,
810    /// v7.17 Phase 2.4 — startup-param `application_name` (or the
811    /// last value the client sent via `SET application_name = '...'`).
812    /// Empty when the client never declared one.
813    pub application_name: String,
814}
815
816/// v6.5.2 — provider callback type. Fresh snapshot returned each
817/// call; engine doesn't cache the slice.
818pub type ActivityProvider = fn() -> Vec<ActivityRow>;
819
820/// v6.5.3 — one row of `spg_audit_chain`. Engine-public so
821/// spg-server can construct rows directly from `AuditEntry`.
822#[derive(Debug, Clone)]
823pub struct AuditRow {
824    pub seq: i64,
825    pub ts_ms: i64,
826    pub prev_hash_hex: String,
827    pub entry_hash_hex: String,
828    pub sql: String,
829}
830
831/// v6.5.3 — chain-table provider + verifier. spg-server registers
832/// fn pointers that snapshot / verify the audit log. `verify`
833/// returns `(verified_count, broken_at_seq)` — `broken_at_seq` is
834/// `-1` on a clean chain.
835pub type AuditChainProvider = fn() -> Vec<AuditRow>;
836pub type AuditVerifier = fn() -> (i64, i64);
837
838impl Engine {
839    pub fn new() -> Self {
840        Self {
841            catalog: Catalog::new(),
842            tx_catalogs: BTreeMap::new(),
843            current_tx: None,
844            backslash_escapes: false,
845            next_tx_id: 1,
846            clock: None,
847            salt_fn: None,
848            max_query_rows: None,
849            users: UserStore::new(),
850            publications: publications::Publications::new(),
851            subscriptions: subscriptions::Subscriptions::new(),
852            statistics: statistics::Statistics::new(),
853            plan_cache: plan_cache::PlanCache::new(),
854            query_stats: query_stats::QueryStats::new(),
855            activity_provider: None,
856            audit_chain_provider: None,
857            audit_verifier: None,
858            slow_query_threshold_us: None,
859            slow_query_logger: None,
860            session_params: BTreeMap::new(),
861            trigger_recursion_depth: 0,
862            foreign_key_checks: true,
863            meta_views_materialised: false,
864            pending_foreign_keys: Vec::new(),
865        }
866    }
867
868    /// v7.11.0 — clone the engine's committed catalog + read-time
869    /// state into a frozen `CatalogSnapshot`. Cheap (`Catalog` is
870    /// backed by `PersistentVec`; cloning is O(log n) per table).
871    /// Subsequent writes to this engine are invisible to the
872    /// snapshot; the snapshot is self-contained and can be moved
873    /// to another thread for concurrent `execute_readonly_on_snapshot`
874    /// calls. The basis for [`AsyncReadHandle`] in spg-embedded-tokio
875    /// and any other read-fanout pattern.
876    #[must_use]
877    pub fn clone_snapshot(&self) -> CatalogSnapshot {
878        CatalogSnapshot {
879            catalog: self.active_catalog().clone(),
880            statistics: self.statistics.clone(),
881            clock: self.clock,
882            max_query_rows: self.max_query_rows,
883        }
884    }
885
886    /// v7.11.1 — execute a read-only SQL statement against a
887    /// `CatalogSnapshot` without touching this engine. Same
888    /// semantics as `execute_readonly` but parameterised on the
889    /// snapshot's catalog. Reject DDL/DML the same way
890    /// `execute_readonly` does. Static-on-Self so the caller can
891    /// dispatch without holding an `Engine` borrow alongside the
892    /// snapshot.
893    pub fn execute_readonly_on_snapshot(
894        snapshot: &CatalogSnapshot,
895        sql: &str,
896    ) -> Result<QueryResult, EngineError> {
897        Self::execute_readonly_on_snapshot_with_cancel(snapshot, sql, CancelToken::none())
898    }
899
900    /// v7.11.1 — `execute_readonly_on_snapshot` with cooperative
901    /// cancellation. Builds a transient `Engine` over the snapshot
902    /// state, runs `execute_readonly_with_cancel`, drops. The
903    /// transient engine is cheap to construct (no I/O; everything
904    /// is just struct moves) and lets the existing read path stay
905    /// untouched.
906    pub fn execute_readonly_on_snapshot_with_cancel(
907        snapshot: &CatalogSnapshot,
908        sql: &str,
909        cancel: CancelToken<'_>,
910    ) -> Result<QueryResult, EngineError> {
911        let transient = Engine {
912            catalog: snapshot.catalog.clone(),
913            statistics: snapshot.statistics.clone(),
914            clock: snapshot.clock,
915            max_query_rows: snapshot.max_query_rows,
916            ..Engine::default()
917        };
918        transient.execute_readonly_with_cancel(sql, cancel)
919    }
920
921    /// v7.18 — execute a previously-prepared `Statement` against a
922    /// `CatalogSnapshot` in read-only mode. Mirror of
923    /// [`Engine::execute_prepared`] for the fan-out read path:
924    /// substitutes `Expr::Placeholder(n)` nodes from `params`, then
925    /// dispatches through [`Engine::execute_readonly_stmt_with_cancel`]
926    /// (writes / DDL hit `EngineError::WriteRequired`). Static-on-Self
927    /// so multiple readonly threads can dispatch against the same
928    /// snapshot concurrently without an `Engine` borrow.
929    ///
930    /// **Schema drift contract**. The `Statement` was prepared against
931    /// some prior catalog. If the snapshot's catalog has since
932    /// diverged (DDL renamed / dropped a referenced column / table),
933    /// execution surfaces the normal `EngineError` — same shape as
934    /// PG's "cached plan must not change result type". Caller decides
935    /// whether to re-prepare; engine does NOT auto-retry.
936    pub fn execute_readonly_prepared_on_snapshot(
937        snapshot: &CatalogSnapshot,
938        stmt: Statement,
939        params: &[Value],
940    ) -> Result<QueryResult, EngineError> {
941        Self::execute_readonly_prepared_on_snapshot_with_cancel(
942            snapshot,
943            stmt,
944            params,
945            CancelToken::none(),
946        )
947    }
948
949    /// v7.18 — cancellable variant of
950    /// [`Engine::execute_readonly_prepared_on_snapshot`].
951    pub fn execute_readonly_prepared_on_snapshot_with_cancel(
952        snapshot: &CatalogSnapshot,
953        mut stmt: Statement,
954        params: &[Value],
955        cancel: CancelToken<'_>,
956    ) -> Result<QueryResult, EngineError> {
957        cancel.check()?;
958        substitute_placeholders(&mut stmt, params)?;
959        let transient = Engine {
960            catalog: snapshot.catalog.clone(),
961            statistics: snapshot.statistics.clone(),
962            clock: snapshot.clock,
963            max_query_rows: snapshot.max_query_rows,
964            ..Engine::default()
965        };
966        transient.execute_readonly_stmt_with_cancel(stmt, cancel)
967    }
968
969    /// v7.18 — describe a prepared `Statement` against a
970    /// `CatalogSnapshot`. Same `(parameter_oids, output_columns)`
971    /// shape as [`Engine::describe_prepared`]; resolves names
972    /// against the snapshot's catalog instead of `self`. Pure
973    /// function — no engine state read.
974    pub fn describe_prepared_on_snapshot(
975        snapshot: &CatalogSnapshot,
976        stmt: &Statement,
977    ) -> (Vec<u32>, Vec<ColumnSchema>) {
978        describe::describe_prepared(stmt, &snapshot.catalog)
979    }
980
981    /// v7.18 — does this SQL string classify as read-only? Parses
982    /// `sql` with the engine parser and consults
983    /// `Statement::is_readonly()`. A parse error returns `false`
984    /// (route to the writer path so the user sees the canonical
985    /// parse error from the writer's simple-query dispatch).
986    /// Static-on-Self so the spg-sqlx connection layer can ask
987    /// without an `Engine` borrow.
988    #[must_use]
989    pub fn is_readonly_sql(sql: &str) -> bool {
990        parser::parse_statement(sql)
991            .as_ref()
992            .map(spg_sql::ast::Statement::is_readonly)
993            .unwrap_or(false)
994    }
995
996    /// v7.18 — parse + plan a SQL string against a
997    /// `CatalogSnapshot`. Mirror of [`Engine::prepare`] for the
998    /// readonly fan-out path: applies the same prepare-time
999    /// transforms (clock rewrite, `GROUP BY ALL` expansion, ORDER
1000    /// BY position resolve, cost-based JOIN reorder) but resolves
1001    /// catalog + statistics against the snapshot, not a live
1002    /// engine. Static-on-Self — `AsyncReadHandle::prepare` calls
1003    /// this without taking the writer lock so multiple read
1004    /// handles can prepare concurrently against frozen views.
1005    ///
1006    /// # Errors
1007    /// Propagates [`ParseError`] from the parser. Schema
1008    /// validation deferred to execute time, same as
1009    /// [`Engine::prepare`].
1010    pub fn prepare_on_snapshot(
1011        snapshot: &CatalogSnapshot,
1012        sql: &str,
1013    ) -> Result<Statement, ParseError> {
1014        let mut stmt = parser::parse_statement(sql)?;
1015        let now_micros = snapshot.clock.map(|f| f());
1016        rewrite_clock_calls(&mut stmt, now_micros);
1017        if let Statement::Select(s) = &mut stmt {
1018            expand_group_by_all(s);
1019            resolve_order_by_position(s);
1020            reorder::reorder_joins(s, &snapshot.catalog, &snapshot.statistics);
1021        }
1022        Ok(stmt)
1023    }
1024
1025    /// Construct an engine restored from a previously-snapshotted catalog
1026    /// (see `snapshot()`).
1027    pub fn restore(catalog: Catalog) -> Self {
1028        Self {
1029            catalog,
1030            tx_catalogs: BTreeMap::new(),
1031            current_tx: None,
1032            backslash_escapes: false,
1033            next_tx_id: 1,
1034            clock: None,
1035            salt_fn: None,
1036            max_query_rows: None,
1037            users: UserStore::new(),
1038            publications: publications::Publications::new(),
1039            subscriptions: subscriptions::Subscriptions::new(),
1040            statistics: statistics::Statistics::new(),
1041            plan_cache: plan_cache::PlanCache::new(),
1042            query_stats: query_stats::QueryStats::new(),
1043            activity_provider: None,
1044            audit_chain_provider: None,
1045            audit_verifier: None,
1046            slow_query_threshold_us: None,
1047            slow_query_logger: None,
1048            session_params: BTreeMap::new(),
1049            trigger_recursion_depth: 0,
1050            foreign_key_checks: true,
1051            meta_views_materialised: false,
1052            pending_foreign_keys: Vec::new(),
1053        }
1054    }
1055
1056    /// Restore an engine + user table from a v4.1 envelope produced
1057    /// by `snapshot_with_users()`. Falls back to plain catalog-only
1058    /// restore if the envelope magic isn't present (so v3.x snapshot
1059    /// files still load). v6.1.2 adds the optional publications
1060    /// trailer (envelope v3); a v1/v2 envelope deserialises to an
1061    /// empty publication table.
1062    pub fn restore_envelope(buf: &[u8]) -> Result<Self, EngineError> {
1063        match split_envelope(buf) {
1064            EnvelopeParse::Pair {
1065                catalog: catalog_bytes,
1066                users: user_bytes,
1067                publications: pub_bytes,
1068                subscriptions: sub_bytes,
1069                statistics: stats_bytes,
1070            } => {
1071                let catalog = Catalog::deserialize(catalog_bytes).map_err(EngineError::Storage)?;
1072                let users = users::deserialize_users(user_bytes)
1073                    .map_err(|e| EngineError::Unsupported(alloc::format!("users restore: {e}")))?;
1074                let publications = match pub_bytes {
1075                    Some(b) => publications::Publications::deserialize(b).map_err(|e| {
1076                        EngineError::Unsupported(alloc::format!("publications restore: {e:?}"))
1077                    })?,
1078                    None => publications::Publications::new(),
1079                };
1080                let subscriptions = match sub_bytes {
1081                    Some(b) => subscriptions::Subscriptions::deserialize(b).map_err(|e| {
1082                        EngineError::Unsupported(alloc::format!("subscriptions restore: {e:?}"))
1083                    })?,
1084                    None => subscriptions::Subscriptions::new(),
1085                };
1086                let statistics = match stats_bytes {
1087                    Some(b) => statistics::Statistics::deserialize(b).map_err(|e| {
1088                        EngineError::Unsupported(alloc::format!("statistics restore: {e:?}"))
1089                    })?,
1090                    None => statistics::Statistics::new(),
1091                };
1092                Ok(Self {
1093                    catalog,
1094                    tx_catalogs: BTreeMap::new(),
1095                    current_tx: None,
1096                    backslash_escapes: false,
1097                    next_tx_id: 1,
1098                    clock: None,
1099                    salt_fn: None,
1100                    max_query_rows: None,
1101                    users,
1102                    publications,
1103                    subscriptions,
1104                    statistics,
1105                    plan_cache: plan_cache::PlanCache::new(),
1106                    query_stats: query_stats::QueryStats::new(),
1107                    activity_provider: None,
1108                    audit_chain_provider: None,
1109                    audit_verifier: None,
1110                    slow_query_threshold_us: None,
1111                    slow_query_logger: None,
1112                    session_params: BTreeMap::new(),
1113                    trigger_recursion_depth: 0,
1114                    foreign_key_checks: true,
1115                    meta_views_materialised: false,
1116                    pending_foreign_keys: Vec::new(),
1117                })
1118            }
1119            EnvelopeParse::CrcMismatch { expected, computed } => {
1120                Err(EngineError::Storage(StorageError::Corrupt(alloc::format!(
1121                    "snapshot envelope CRC32 mismatch (expected={expected:#010x}, computed={computed:#010x})"
1122                ))))
1123            }
1124            EnvelopeParse::Bare => {
1125                let catalog = Catalog::deserialize(buf).map_err(EngineError::Storage)?;
1126                Ok(Self::restore(catalog))
1127            }
1128        }
1129    }
1130
1131    pub const fn users(&self) -> &UserStore {
1132        &self.users
1133    }
1134
1135    /// `salt` is supplied by the caller (the host has a random
1136    /// source; the engine is `no_std`). Caller should pass a fresh
1137    /// 16-byte random value per user.
1138    pub fn create_user(
1139        &mut self,
1140        name: &str,
1141        password: &str,
1142        role: Role,
1143        salt: [u8; 16],
1144    ) -> Result<(), UserError> {
1145        self.users.create(name, password, role, salt)?;
1146        // v4.8: also derive SCRAM-SHA-256 secrets so PG-wire SASL
1147        // auth can verify without re-running PBKDF2 per attempt.
1148        // Uses a fresh salt from the host RNG (falls back to a
1149        // deterministic per-username salt when no RNG is wired, same
1150        // as the legacy hash path).
1151        let scram_salt = self.salt_fn.map_or_else(
1152            || {
1153                let mut s = [0u8; users::SCRAM_SALT_LEN];
1154                let digest = spg_crypto::hash(name.as_bytes());
1155                // Use bytes 16..32 of BLAKE3 so we don't reuse the
1156                // exact same fallback salt as the BLAKE3 hash path.
1157                s.copy_from_slice(&digest[16..32]);
1158                s
1159            },
1160            |f| f(),
1161        );
1162        self.users
1163            .enable_scram(name, password, scram_salt, users::SCRAM_DEFAULT_ITERS)?;
1164        Ok(())
1165    }
1166
1167    pub fn drop_user(&mut self, name: &str) -> Result<(), UserError> {
1168        self.users.drop(name)
1169    }
1170
1171    pub fn verify_user(&self, name: &str, password: &str) -> Option<Role> {
1172        self.users.verify(name, password)
1173    }
1174
1175    /// Builder: attach a wall clock so `NOW()` / `CURRENT_TIMESTAMP` /
1176    /// `CURRENT_DATE` evaluate to a real value instead of erroring out.
1177    #[must_use]
1178    pub const fn with_clock(mut self, clock: ClockFn) -> Self {
1179        self.clock = Some(clock);
1180        self
1181    }
1182
1183    /// Builder: attach an OS-backed RNG for per-user password salts.
1184    /// The host (`spg-server`) typically wires this to `/dev/urandom`.
1185    #[must_use]
1186    pub const fn with_salt_fn(mut self, f: SaltFn) -> Self {
1187        self.salt_fn = Some(f);
1188        self
1189    }
1190
1191    /// Builder: cap the number of rows a single SELECT may return.
1192    /// Exceeding the cap raises `EngineError::RowLimitExceeded` —
1193    /// the bound is checked inside the executor so a runaway
1194    /// catalog scan can't allocate millions of rows before the
1195    /// server gets a chance to reject the result.
1196    #[must_use]
1197    pub const fn with_max_query_rows(mut self, n: usize) -> Self {
1198        self.max_query_rows = Some(n);
1199        self
1200    }
1201
1202    /// The *committed* catalog. Note: during a transaction this returns the
1203    /// pre-TX state — `SELECT` inside a TX goes through `execute()` and reads
1204    /// the shadow. Tests that inspect outside-TX state should use this.
1205    pub const fn catalog(&self) -> &Catalog {
1206        &self.catalog
1207    }
1208
1209    /// Serialize the *committed* catalog to bytes. v0.6 was full-snapshot; v0.9
1210    /// adds the rule that an open TX's shadow is never snapshotted — only the
1211    /// post-COMMIT state is persisted. v4.1 wraps the catalog in an envelope
1212    /// when there are users to persist; an empty user table snapshots as the
1213    /// bare catalog format (backwards-compat with v3.x readers). v6.1.2
1214    /// adds publications to the envelope condition: either non-empty
1215    /// users OR non-empty publications now triggers the envelope path.
1216    pub fn snapshot(&self) -> Vec<u8> {
1217        if self.users.is_empty()
1218            && self.publications.is_empty()
1219            && self.subscriptions.is_empty()
1220            && self.statistics.is_empty()
1221        {
1222            self.catalog.serialize()
1223        } else {
1224            build_envelope(
1225                &self.catalog.serialize(),
1226                &users::serialize_users(&self.users),
1227                &self.publications.serialize(),
1228                &self.subscriptions.serialize(),
1229                &self.statistics.serialize(),
1230            )
1231        }
1232    }
1233
1234    /// True when at least one TX slot is in flight. v4.41.1 runtime
1235    /// invariant: at most one slot active at a time (dispatch holds
1236    /// `engine.write()` across the entire wrap). v4.42 will let this
1237    /// return true with multiple slots concurrently.
1238    pub fn in_transaction(&self) -> bool {
1239        !self.tx_catalogs.is_empty()
1240    }
1241
1242    /// v4.41.1 allocate a fresh TX handle. Used by spg-server dispatch
1243    /// to scope each implicit-wrap BEGIN..stmt..COMMIT to its own slot
1244    /// in `tx_catalogs`. v4.42 — the commit-barrier leader allocates
1245    /// one of these per task in its group, runs `BEGIN`+sql+`COMMIT`
1246    /// sequentially under a single `engine.write()` so each task's
1247    /// mutations accumulate into shared state, then either keeps the
1248    /// accumulated state (fsync OK) or restores the pre-image via
1249    /// `replace_catalog` (fsync err).
1250    pub fn alloc_tx_id(&mut self) -> TxId {
1251        let id = TxId(self.next_tx_id);
1252        self.next_tx_id = self.next_tx_id.saturating_add(1);
1253        id
1254    }
1255
1256    /// v4.42 — atomically replace the live catalog. Used by the
1257    /// commit-barrier leader to roll back a group whose batched
1258    /// fsync failed: the leader snapshots `engine.catalog().clone()`
1259    /// (O(1) Arc bump after the v4.39/v4.40 persistent migration)
1260    /// at group start, sequentially applies each task's BEGIN+sql+
1261    /// COMMIT under the same write lock to accumulate mutations
1262    /// into shared state, batches the WAL bytes, fsyncs once, and
1263    /// on failure calls this with the pre-image to undo every
1264    /// task in the group at once.
1265    ///
1266    /// **Does NOT touch `tx_catalogs` / `current_tx`.** Any
1267    /// explicit-TX slot from a concurrent client (created via the
1268    /// legacy `IMPLICIT_TX`-less dispatch path or via the future
1269    /// MVCC-readers v5+ work) has its own snapshot baked into the
1270    /// slot — restoring `self.catalog` to the pre-image leaves
1271    /// those slots untouched, exactly as they were when the leader
1272    /// took the lock. The leader's own implicit-TX slots are all
1273    /// already discarded (`exec_commit` removed them as each
1274    /// task's COMMIT ran) by the time this is reached.
1275    pub fn replace_catalog(&mut self, catalog: Catalog) {
1276        self.catalog = catalog;
1277    }
1278
1279    /// v6.7.0 — public shim around `Catalog::freeze_oldest_to_cold`
1280    /// so tests + the spg-server freezer can drive a freeze without
1281    /// reaching into the private `active_catalog_mut`. v6.7.4
1282    /// parallel freezer will build on this surface.
1283    ///
1284    /// Marks the table's cached `cold_row_count` stale because the
1285    /// freeze added cold locators that ANALYZE hasn't yet refreshed.
1286    pub fn freeze_oldest_to_cold(
1287        &mut self,
1288        table_name: &str,
1289        index_name: &str,
1290        max_rows: usize,
1291    ) -> Result<spg_storage::FreezeReport, EngineError> {
1292        let report = self
1293            .active_catalog_mut()
1294            .freeze_oldest_to_cold(table_name, index_name, max_rows)
1295            .map_err(EngineError::Storage)?;
1296        if let Some(t) = self.active_catalog_mut().get_mut(table_name) {
1297            t.mark_cold_row_count_stale();
1298        }
1299        Ok(report)
1300    }
1301
1302    /// v6.7.5 — public shim used by the spg-server follower's
1303    /// segment-forwarding receiver. Registers a cold-tier segment
1304    /// at a specific id (the master's id, as transmitted on the
1305    /// wire) so the follower's BTree-Cold locators stay byte-
1306    /// identical with the master's. Wraps
1307    /// `Catalog::load_segment_bytes_at` under the standard
1308    /// clone-mutate-replace pattern.
1309    ///
1310    /// Returns `Ok(())` on success **and** on the "slot already
1311    /// occupied" case — a follower mid-reconnect may receive a
1312    /// segment chunk for a segment_id it already has on disk
1313    /// (forwarded last session); the caller should treat that
1314    /// path as a no-op rather than a fatal error.
1315    pub fn receive_cold_segment(
1316        &mut self,
1317        segment_id: u32,
1318        bytes: Vec<u8>,
1319    ) -> Result<(), EngineError> {
1320        let mut new_cat = self.catalog.clone();
1321        match new_cat.load_segment_bytes_at(segment_id, bytes) {
1322            Ok(()) => {
1323                self.replace_catalog(new_cat);
1324                Ok(())
1325            }
1326            Err(StorageError::Corrupt(msg)) if msg.contains("already occupied") => Ok(()),
1327            Err(e) => Err(EngineError::Storage(e)),
1328        }
1329    }
1330
1331    /// v6.7.3 — public shim around `Catalog::compact_cold_segments`
1332    /// driving every BTree index on every user table. Returns one
1333    /// `(table, index, report)` triple for each merge that
1334    /// actually happened (no-op (table, index) pairs are filtered
1335    /// out so callers can size persist-side work to the live
1336    /// merges). Caller is responsible for persisting each
1337    /// `report.merged_segment_bytes` and updating the on-disk
1338    /// segment registry; engine layer is no_std and never
1339    /// touches disk.
1340    ///
1341    /// Marks every touched table's cached `cold_row_count` stale
1342    /// — compaction GC'd some shadowed rows, so the count must be
1343    /// re-derived on the next ANALYZE.
1344    pub fn compact_cold_segments_with_target(
1345        &mut self,
1346        target_segment_bytes: u64,
1347    ) -> Result<Vec<(String, String, CompactReport)>, EngineError> {
1348        let table_names = self.active_catalog().table_names();
1349        let mut reports: Vec<(String, String, CompactReport)> = Vec::new();
1350        for tname in table_names {
1351            if is_internal_table_name(&tname) {
1352                continue;
1353            }
1354            let idx_names: Vec<String> = {
1355                let Some(t) = self.active_catalog().get(&tname) else {
1356                    continue;
1357                };
1358                t.indices()
1359                    .iter()
1360                    .filter(|i| matches!(i.kind, IndexKind::BTree(_)))
1361                    .map(|i| i.name.clone())
1362                    .collect()
1363            };
1364            for iname in idx_names {
1365                let report = self
1366                    .active_catalog_mut()
1367                    .compact_cold_segments(&tname, &iname, target_segment_bytes)
1368                    .map_err(EngineError::Storage)?;
1369                if report.merged_segment_id.is_some() {
1370                    if let Some(t) = self.active_catalog_mut().get_mut(&tname) {
1371                        t.mark_cold_row_count_stale();
1372                    }
1373                    reports.push((tname.clone(), iname, report));
1374                }
1375            }
1376        }
1377        Ok(reports)
1378    }
1379
1380    fn active_catalog(&self) -> &Catalog {
1381        match self.current_tx {
1382            Some(t) => self
1383                .tx_catalogs
1384                .get(&t)
1385                .map_or(&self.catalog, |s| &s.catalog),
1386            None => &self.catalog,
1387        }
1388    }
1389
1390    /// v7.12.4 — snapshot every row-level trigger on `table` that
1391    /// fires for `event` (`"INSERT"` / `"UPDATE"` / `"DELETE"`) at
1392    /// the given `timing` (`"BEFORE"` / `"AFTER"`), and clone its
1393    /// referenced function definition. Returned as a vec of owned
1394    /// `FunctionDef` so the row-write loop can fire them without
1395    /// holding a borrow on the catalog (which would conflict with
1396    /// the table.insert / update_row / delete mutable borrows).
1397    /// v7.16.2 — top-level DO block executor. Walks the
1398    /// PlPgSqlBlock via [`triggers::execute_do_block_top_level`],
1399    /// then runs each collected EmbeddedSql statement through
1400    /// the engine's regular execute path (NOT deferred — DO is
1401    /// outside any row-write borrow). Errors from any step
1402    /// abort the block and propagate verbatim.
1403    /// v7.16.2 — resolve every subquery inside a PlPgSqlBlock's
1404    /// expression slots so the downstream trigger-flavoured
1405    /// evaluator (which expects pre-resolved Expr::Literal /
1406    /// Binary chains) doesn't trip on raw Exists/ScalarSubquery
1407    /// nodes. Walks IF conditions, Assign values, RAISE args.
1408    /// EmbeddedSql statements re-enter the engine for execution
1409    /// later so their subqueries get the normal SELECT-side
1410    /// resolution.
1411    fn resolve_plpgsql_block_subqueries(
1412        &self,
1413        block: &mut spg_sql::ast::PlPgSqlBlock,
1414        cancel: CancelToken<'_>,
1415    ) -> Result<(), EngineError> {
1416        for d in &mut block.declarations {
1417            if let Some(e) = &mut d.default {
1418                self.resolve_expr_subqueries(e, cancel)?;
1419            }
1420        }
1421        self.resolve_plpgsql_stmts_subqueries(&mut block.statements, cancel)
1422    }
1423
1424    fn resolve_plpgsql_stmts_subqueries(
1425        &self,
1426        stmts: &mut [spg_sql::ast::PlPgSqlStmt],
1427        cancel: CancelToken<'_>,
1428    ) -> Result<(), EngineError> {
1429        use spg_sql::ast::PlPgSqlStmt;
1430        for stmt in stmts {
1431            match stmt {
1432                PlPgSqlStmt::Assign { value, .. } => {
1433                    self.resolve_expr_subqueries(value, cancel)?;
1434                }
1435                PlPgSqlStmt::Return(spg_sql::ast::ReturnTarget::Expr(e)) => {
1436                    self.resolve_expr_subqueries(e, cancel)?;
1437                }
1438                PlPgSqlStmt::Return(_) => {}
1439                PlPgSqlStmt::If {
1440                    branches,
1441                    else_branch,
1442                } => {
1443                    for (cond, body) in branches.iter_mut() {
1444                        self.resolve_expr_subqueries(cond, cancel)?;
1445                        self.resolve_plpgsql_stmts_subqueries(body, cancel)?;
1446                    }
1447                    self.resolve_plpgsql_stmts_subqueries(else_branch, cancel)?;
1448                }
1449                PlPgSqlStmt::Raise { args, .. } => {
1450                    for a in args {
1451                        self.resolve_expr_subqueries(a, cancel)?;
1452                    }
1453                }
1454                PlPgSqlStmt::EmbeddedSql(_) => {
1455                    // Embedded SQL goes back through execute_stmt
1456                    // _with_cancel which runs the SELECT-side
1457                    // resolver itself; nothing to do here.
1458                }
1459                PlPgSqlStmt::SelectInto { body, .. } => {
1460                    // SELECT INTO runs through Engine::execute
1461                    // when reached, so subquery resolution
1462                    // happens via the normal SELECT-side path.
1463                    // Still walk for nested subqueries inside
1464                    // the SELECT body so eval doesn't trip.
1465                    self.resolve_select_subqueries(body, cancel)?;
1466                }
1467            }
1468        }
1469        Ok(())
1470    }
1471
1472    fn exec_do_block(
1473        &mut self,
1474        body: spg_sql::ast::PlPgSqlBlock,
1475    ) -> Result<QueryResult, EngineError> {
1476        // v7.16.2 — pre-resolve every subquery the body's
1477        // expressions reach. `eval::eval_expr` errors on
1478        // unresolved Exists/ScalarSubquery/InSubquery; the
1479        // top-level SELECT path runs `resolve_select_subqueries`
1480        // for the caller — for plpgsql we have to do the
1481        // equivalent before the body walker runs. Catches the
1482        // mailrs idiom `IF EXISTS (SELECT 1 FROM
1483        // information_schema.columns WHERE …) THEN …`.
1484        let mut body = body;
1485        self.resolve_plpgsql_block_subqueries(&mut body, CancelToken::none())?;
1486        let dts = self
1487            .session_param("default_text_search_config")
1488            .map(String::from);
1489        // v7.16.2 — SELECT … INTO resolver. The walker calls
1490        // this synchronously when it hits a SelectInto stmt
1491        // so the IF / locals scope sees the result before the
1492        // next statement. Body walks for trigger paths (no
1493        // resolver) error loudly on SelectInto.
1494        // SAFETY: the closure shares this engine borrow with
1495        // the walker, but the walker only borrows for the
1496        // duration of `execute_do_block_top_level` and doesn't
1497        // reach back into the engine through any other path —
1498        // so the recursive `&mut` is sound. We use a `RefCell`
1499        // for interior mutability since the closure is
1500        // Fn-shaped.
1501        let engine_cell = core::cell::RefCell::new(&mut *self);
1502        let resolver_fn =
1503            |stmt: &spg_sql::ast::Statement| -> Result<Value, triggers::TriggerError> {
1504                let mut eng = engine_cell.borrow_mut();
1505                let r = eng
1506                    .execute_stmt_with_cancel(stmt.clone(), CancelToken::none())
1507                    .map_err(|e| triggers::TriggerError::EvalFailed {
1508                        function: "DO".into(),
1509                        cause: eval::EvalError::TypeMismatch {
1510                            detail: alloc::format!("SELECT … INTO failed: {e}"),
1511                        },
1512                    })?;
1513                match r {
1514                    QueryResult::Rows { rows, .. } => match rows.into_iter().next() {
1515                        Some(row) => Ok(row.values.into_iter().next().unwrap_or(Value::Null)),
1516                        None => Ok(Value::Null),
1517                    },
1518                    _ => Err(triggers::TriggerError::EvalFailed {
1519                        function: "DO".into(),
1520                        cause: eval::EvalError::TypeMismatch {
1521                            detail: "SELECT … INTO body must be a SELECT".into(),
1522                        },
1523                    }),
1524                }
1525            };
1526        let collected =
1527            triggers::execute_do_block_top_level(&body, dts.as_deref(), Some(&resolver_fn))
1528                .map_err(|e| {
1529                    EngineError::Storage(StorageError::Corrupt(alloc::format!("DO: {e}")))
1530                })?;
1531        // engine_cell goes out of scope here, releasing the &mut self borrow
1532        // Run each embedded statement against the engine. The
1533        // statements were already substitute-walked for NEW/OLD/
1534        // locals (those evaluate to engine literals before they
1535        // land here) so dispatch is plain execute_stmt_with_cancel.
1536        for stmt in collected {
1537            // v7.16.2 — preserve current_tx wrap so an outer
1538            // BEGIN/COMMIT around a DO block keeps the
1539            // EmbeddedSql writes inside that same tx slot.
1540            self.execute_stmt_with_cancel(stmt, CancelToken::none())?;
1541        }
1542        Ok(QueryResult::CommandOk {
1543            affected: 0,
1544            modified_catalog: !self.in_transaction(),
1545        })
1546    }
1547
1548    fn snapshot_row_triggers(
1549        &self,
1550        table: &str,
1551        event: &str,
1552        timing: &str,
1553    ) -> Vec<spg_storage::FunctionDef> {
1554        let cat = self.active_catalog();
1555        cat.triggers()
1556            .iter()
1557            .filter(|t| {
1558                // v7.16.1 — skip disabled triggers (mailrs
1559                // round-9 A.2.b — pg_dump --disable-triggers).
1560                t.enabled
1561                    && t.table == table
1562                    && t.timing.eq_ignore_ascii_case(timing)
1563                    && t.for_each.eq_ignore_ascii_case("row")
1564                    && t.events.iter().any(|e| e.eq_ignore_ascii_case(event))
1565            })
1566            .filter_map(|t| cat.functions().get(&t.function).cloned())
1567            .collect()
1568    }
1569
1570    /// v7.13.0 — UPDATE-side snapshot that pairs each trigger's
1571    /// function with its `UPDATE OF cols` filter (mailrs round-5
1572    /// G7). Empty filter Vec means "fire unconditionally", matching
1573    /// the v7.12 behaviour.
1574    fn snapshot_update_row_triggers(
1575        &self,
1576        table: &str,
1577        timing: &str,
1578    ) -> Vec<(spg_storage::FunctionDef, Vec<String>)> {
1579        let cat = self.active_catalog();
1580        cat.triggers()
1581            .iter()
1582            .filter(|t| {
1583                // v7.16.1 — skip disabled triggers.
1584                t.enabled
1585                    && t.table == table
1586                    && t.timing.eq_ignore_ascii_case(timing)
1587                    && t.for_each.eq_ignore_ascii_case("row")
1588                    && t.events.iter().any(|e| e.eq_ignore_ascii_case("UPDATE"))
1589            })
1590            .filter_map(|t| {
1591                cat.functions()
1592                    .get(&t.function)
1593                    .cloned()
1594                    .map(|fd| (fd, t.update_columns.clone()))
1595            })
1596            .collect()
1597    }
1598
1599    /// v7.12.7 — drain the trigger-emitted embedded SQL queue.
1600    /// Called by the INSERT / UPDATE / DELETE executors after
1601    /// their main row-write loop returns. Each statement runs
1602    /// inside the same cancel scope as the firing DML and bumps
1603    /// the recursion counter; nested embedded SQL beyond
1604    /// [`MAX_TRIGGER_RECURSION`] errors with a clear message so
1605    /// a trigger-graph cycle surfaces as a query failure instead
1606    /// of stack-blowing the engine.
1607    fn execute_deferred_trigger_stmts(
1608        &mut self,
1609        deferred: Vec<triggers::DeferredEmbeddedStmt>,
1610        cancel: CancelToken<'_>,
1611    ) -> Result<(), EngineError> {
1612        for d in deferred {
1613            if self.trigger_recursion_depth >= MAX_TRIGGER_RECURSION {
1614                return Err(EngineError::Storage(StorageError::Corrupt(alloc::format!(
1615                    "trigger embedded SQL recursion depth {} exceeded (trigger function \
1616                     {:?} would push past the {} cap — check for trigger cycles)",
1617                    self.trigger_recursion_depth,
1618                    d.function,
1619                    MAX_TRIGGER_RECURSION,
1620                ))));
1621            }
1622            self.trigger_recursion_depth += 1;
1623            let res = self.execute_stmt_with_cancel(d.stmt, cancel);
1624            self.trigger_recursion_depth -= 1;
1625            res?;
1626        }
1627        Ok(())
1628    }
1629
1630    fn active_catalog_mut(&mut self) -> &mut Catalog {
1631        let tx = self.current_tx;
1632        match tx {
1633            Some(t) => match self.tx_catalogs.get_mut(&t) {
1634                Some(s) => &mut s.catalog,
1635                None => &mut self.catalog,
1636            },
1637            None => &mut self.catalog,
1638        }
1639    }
1640
1641    /// Read-only execute path. Succeeds for `SELECT` / `SHOW TABLES`
1642    /// / `SHOW COLUMNS`; returns `EngineError::WriteRequired` for
1643    /// every other statement, so the caller can fall through to the
1644    /// `&mut self` `execute` path under a write lock. Engine state is
1645    /// not mutated even on the success path (`rewrite_clock_calls`
1646    /// and `resolve_order_by_position` both mutate the locally-owned
1647    /// AST, not `self`).
1648    ///
1649    /// **v4.0 concurrency**: this is the entry point the server takes
1650    /// under an `RwLock::read()` so multiple `SELECT` clients run in
1651    /// parallel without serialising on a single mutex.
1652    pub fn execute_readonly(&self, sql: &str) -> Result<QueryResult, EngineError> {
1653        self.execute_readonly_with_cancel(sql, CancelToken::none())
1654    }
1655
1656    /// v4.5 — read path with cooperative cancellation. Token's
1657    /// `is_cancelled` is checked at the start (so a watchdog that
1658    /// already fired returns Cancelled immediately) and at row-loop
1659    /// checkpoints inside `exec_select`. SHOW paths are O(small) and
1660    /// don't bother checking.
1661    pub fn execute_readonly_with_cancel(
1662        &self,
1663        sql: &str,
1664        cancel: CancelToken<'_>,
1665    ) -> Result<QueryResult, EngineError> {
1666        cancel.check()?;
1667        let mut stmt = parser::parse_statement_with(sql, self.backslash_escapes)?;
1668        let now_micros = self.clock.map(|f| f());
1669        rewrite_clock_calls(&mut stmt, now_micros);
1670        if let Statement::Select(s) = &mut stmt {
1671            resolve_order_by_position(s);
1672            // v6.2.3 — cost-based JOIN reorder (read path).
1673            reorder::reorder_joins(s, &self.catalog, &self.statistics);
1674        }
1675        self.execute_readonly_stmt_with_cancel(stmt, cancel)
1676    }
1677
1678    /// v7.18 — readonly dispatch on a pre-parsed `Statement`.
1679    /// Internal helper shared by the SQL-string path
1680    /// ([`Engine::execute_readonly_with_cancel`]) and the prepared-
1681    /// statement path ([`Engine::execute_readonly_prepared_on_snapshot_with_cancel`]).
1682    /// Statement-level transforms (clock rewrite, ORDER BY position,
1683    /// JOIN reorder, placeholder substitution) are the caller's
1684    /// responsibility — this helper assumes the AST is already
1685    /// execution-ready. Writes / DDL hit
1686    /// [`EngineError::WriteRequired`] the same way the SQL path does.
1687    fn execute_readonly_stmt_with_cancel(
1688        &self,
1689        stmt: Statement,
1690        cancel: CancelToken<'_>,
1691    ) -> Result<QueryResult, EngineError> {
1692        let result = match stmt {
1693            Statement::Select(s) => self.exec_select_cancel(&s, cancel),
1694            Statement::ShowTables => Ok(self.exec_show_tables()),
1695            Statement::ShowDatabases => Ok(self.exec_show_databases()),
1696            Statement::ShowCreateTable(name) => self.exec_show_create_table(&name),
1697            Statement::ShowIndexes(name) => self.exec_show_indexes(&name),
1698            Statement::ShowStatus => Ok(self.exec_show_status()),
1699            Statement::ShowVariables => Ok(self.exec_show_variables()),
1700            Statement::ShowProcesslist => Ok(self.exec_show_processlist()),
1701            Statement::ShowColumns(table) => self.exec_show_columns(&table),
1702            Statement::ShowUsers => Ok(self.exec_show_users()),
1703            Statement::ShowPublications => Ok(self.exec_show_publications()),
1704            Statement::ShowSubscriptions => Ok(self.exec_show_subscriptions()),
1705            Statement::WaitForWalPosition { .. } => Err(EngineError::Unsupported(
1706                "WAIT FOR WAL POSITION must be handled by the server layer".into(),
1707            )),
1708            Statement::Explain(e) => self.exec_explain(&e, cancel),
1709            _ => Err(EngineError::WriteRequired),
1710        };
1711        self.enforce_row_limit(result)
1712    }
1713
1714    /// v4.2: cap result-set size. Applied after the executor
1715    /// materialises rows but before they leave the engine — wrapping
1716    /// every Rows-returning exec_* function would scatter the check.
1717    fn enforce_row_limit(
1718        &self,
1719        result: Result<QueryResult, EngineError>,
1720    ) -> Result<QueryResult, EngineError> {
1721        if let (Ok(QueryResult::Rows { rows, .. }), Some(cap)) = (&result, self.max_query_rows)
1722            && rows.len() > cap
1723        {
1724            return Err(EngineError::RowLimitExceeded(cap));
1725        }
1726        result
1727    }
1728
1729    pub fn execute(&mut self, sql: &str) -> Result<QueryResult, EngineError> {
1730        self.execute_in_with_cancel(sql, IMPLICIT_TX, CancelToken::none())
1731    }
1732
1733    /// v4.5 — write path with cooperative cancellation. Same dispatch
1734    /// as `execute_in_with_cancel(sql, IMPLICIT_TX, cancel)`. Kept as
1735    /// a separate entry point for backward-compat with the v4.5
1736    /// public API.
1737    pub fn execute_with_cancel(
1738        &mut self,
1739        sql: &str,
1740        cancel: CancelToken<'_>,
1741    ) -> Result<QueryResult, EngineError> {
1742        self.execute_in_with_cancel(sql, IMPLICIT_TX, cancel)
1743    }
1744
1745    /// v4.41.1 multi-slot write entry. Routes `sql` through the TX
1746    /// slot identified by `tx_id` so spg-server dispatch can scope
1747    /// each implicit-wrap BEGIN..stmt..COMMIT to its own slot in
1748    /// `tx_catalogs`. `IMPLICIT_TX` is the legacy single-slot path
1749    /// every other caller (engine self-tests, replay, spg-embedded)
1750    /// implicitly takes via `execute()` / `execute_with_cancel()`.
1751    pub fn execute_in(&mut self, sql: &str, tx_id: TxId) -> Result<QueryResult, EngineError> {
1752        self.execute_in_with_cancel(sql, tx_id, CancelToken::none())
1753    }
1754
1755    /// v4.41.1 write path with cooperative cancellation + explicit TX
1756    /// scope. Sets `self.current_tx` for the duration of the call so
1757    /// every `exec_*` helper transparently sees its TX's shadow
1758    /// catalog and savepoint stack; restores on exit so the field is
1759    /// only valid mid-call (no leakage across calls).
1760    pub fn execute_in_with_cancel(
1761        &mut self,
1762        sql: &str,
1763        tx_id: TxId,
1764        cancel: CancelToken<'_>,
1765    ) -> Result<QueryResult, EngineError> {
1766        let saved = self.current_tx;
1767        self.current_tx = Some(tx_id);
1768        let result = self.execute_inner_with_cancel(sql, cancel);
1769        self.current_tx = saved;
1770        result
1771    }
1772
1773    /// v6.1.1 — parse and pre-process a SQL string ONCE so the
1774    /// resulting [`Statement`] can be cached and re-executed via
1775    /// [`Engine::execute_prepared`]. Returns the same `Statement`
1776    /// the simple-query path would synthesise internally (clock
1777    /// rewrites + ORDER BY position-ref resolution applied at
1778    /// prepare time, since both are session-independent). The
1779    /// `$N` placeholders in the SQL stay as `Expr::Placeholder(n)`
1780    /// nodes; they're resolved to concrete values per-call by
1781    /// `execute_prepared`'s substitution walk.
1782    ///
1783    /// Pgwire's `Parse` (P) message lands here.
1784    pub fn prepare(&self, sql: &str) -> Result<Statement, ParseError> {
1785        let mut stmt = parser::parse_statement_with(sql, self.backslash_escapes)?;
1786        let now_micros = self.clock.map(|f| f());
1787        rewrite_clock_calls(&mut stmt, now_micros);
1788        if let Statement::Select(s) = &mut stmt {
1789            // v6.4.1 — expand `GROUP BY ALL` to every non-aggregate
1790            // SELECT-list item BEFORE position / alias resolution so
1791            // downstream passes see the explicit list.
1792            expand_group_by_all(s);
1793            resolve_order_by_position(s);
1794            // v6.2.3 — cost-based JOIN reorder. No-op for
1795            // single-table FROMs or any non-INNER join shape.
1796            reorder::reorder_joins(s, &self.catalog, &self.statistics);
1797        }
1798        Ok(stmt)
1799    }
1800
1801    /// v6.3.0 — cached prepare. Returns a cloned `Statement` from
1802    /// the plan cache on hit, runs the full `prepare()` path on miss
1803    /// and inserts the resulting plan before returning. Skipping the
1804    /// parse + JOIN-reorder pipeline on hit is the dominant win for
1805    /// JDBC / sqlx / pgx clients that reuse the same SQL string.
1806    ///
1807    /// Returns a cloned `Statement` (not a borrow) because the
1808    /// pgwire layer owns its `PreparedStmt` map per-session and the
1809    /// engine-level cache must stay available for other sessions.
1810    /// Clone cost on a 5-table JOIN AST is well under the parse cost
1811    /// it replaces.
1812    pub fn prepare_cached(&mut self, sql: &str) -> Result<Statement, ParseError> {
1813        // v6.3.1 — version-aware lookup. If the cached plan was
1814        // prepared before the most recent ANALYZE, evict and replan.
1815        let current_version = self.statistics.version();
1816        if let Some(plan) = self.plan_cache.get(sql) {
1817            if plan.statistics_version == current_version {
1818                return Ok(plan.stmt.clone());
1819            }
1820            // Stale entry — fall through to evict + re-prepare.
1821        }
1822        self.plan_cache.evict(sql);
1823        let stmt = self.prepare(sql)?;
1824        let source_tables = plan_cache::collect_source_tables(&stmt);
1825        let plan = plan_cache::PreparedPlan {
1826            stmt: stmt.clone(),
1827            statistics_version: current_version,
1828            source_tables,
1829            describe_columns: alloc::vec::Vec::new(),
1830        };
1831        self.plan_cache.insert(String::from(sql), plan);
1832        Ok(stmt)
1833    }
1834
1835    /// v6.3.0 — read-only accessor for tests and v6.3.1 invalidation.
1836    pub fn plan_cache(&self) -> &plan_cache::PlanCache {
1837        &self.plan_cache
1838    }
1839
1840    /// v6.3.0 — mutable accessor for v6.3.1 invalidation hooks.
1841    pub fn plan_cache_mut(&mut self) -> &mut plan_cache::PlanCache {
1842        &mut self.plan_cache
1843    }
1844
1845    /// v6.3.3 — Describe a prepared `Statement` without executing.
1846    /// Returns `(parameter_oids, output_columns)`. Empty
1847    /// `output_columns` means the statement has no row-producing
1848    /// shape we could resolve here (JOIN, subquery, non-SELECT, …)
1849    /// — pgwire layer maps that to a `NoData` reply.
1850    pub fn describe_prepared(&self, stmt: &Statement) -> (Vec<u32>, Vec<ColumnSchema>) {
1851        describe::describe_prepared(stmt, self.active_catalog())
1852    }
1853
1854    /// v6.1.1 — execute a [`Statement`] previously returned by
1855    /// [`Engine::prepare`], substituting `Expr::Placeholder(n)`
1856    /// nodes for the corresponding [`Value`] in `params` (1-based
1857    /// per PG: `$1` → `params[0]`). Bind-time string parameters
1858    /// are decoded into typed `Value`s by the pgwire layer before
1859    /// this call so the resulting AST hits the same execution
1860    /// path as a simple query — no SQL re-parse.
1861    ///
1862    /// Pgwire's `Execute` (E) message after a `Bind` (B) lands here.
1863    pub fn execute_prepared(
1864        &mut self,
1865        stmt: Statement,
1866        params: &[Value],
1867    ) -> Result<QueryResult, EngineError> {
1868        self.execute_prepared_with_cancel(stmt, params, CancelToken::none())
1869    }
1870
1871    /// v7.17.0 Phase 2.3 — prepared-statement entry that honors a
1872    /// caller-supplied `CancelToken`. Mirrors `execute_prepared`'s
1873    /// `current_tx` save/restore so the extended-query path stays
1874    /// transactionally consistent with the simple-query path.
1875    pub fn execute_prepared_with_cancel(
1876        &mut self,
1877        mut stmt: Statement,
1878        params: &[Value],
1879        cancel: CancelToken<'_>,
1880    ) -> Result<QueryResult, EngineError> {
1881        substitute_placeholders(&mut stmt, params)?;
1882        // v7.16.0 — set `current_tx` for the duration of the
1883        // dispatch so the `exec_*` helpers see the right TX
1884        // slot (matches what `execute_in_with_cancel` does for
1885        // simple-query). Pre-v7.16 the simple-query path
1886        // worked because every public entry point routed
1887        // through `execute_in_with_cancel`; the prepared path
1888        // skipped the wrap and so its INSERTs/UPDATEs landed
1889        // in the no-tx default slot, silently invisible to a
1890        // BEGIN/COMMIT-bracketed flow. Caught by spg-sqlx's
1891        // first transaction-visibility test.
1892        let saved = self.current_tx;
1893        self.current_tx = Some(IMPLICIT_TX);
1894        let result = self.execute_stmt_with_cancel(stmt, cancel);
1895        self.current_tx = saved;
1896        result
1897    }
1898
1899    fn execute_inner_with_cancel(
1900        &mut self,
1901        sql: &str,
1902        cancel: CancelToken<'_>,
1903    ) -> Result<QueryResult, EngineError> {
1904        cancel.check()?;
1905        let stmt = self.prepare(sql)?;
1906        // v6.5.1 — wrap the executor with a wall-clock window so we
1907        // can record into spg_stat_query. Skip when the engine has
1908        // no clock attached (no_std embedded callers).
1909        let start_us = self.clock.map(|f| f());
1910        let result = self.execute_stmt_with_cancel(stmt, cancel);
1911        if let (Some(t0), Ok(_)) = (start_us, &result) {
1912            let now = self.clock.map_or(t0, |f| f());
1913            let elapsed = now.saturating_sub(t0).max(0) as u64;
1914            self.query_stats.record(sql, elapsed, now as u64);
1915            // v6.5.6 — slow-query log: fire callback when elapsed
1916            // exceeds the configured floor.
1917            if let (Some(threshold), Some(logger)) =
1918                (self.slow_query_threshold_us, self.slow_query_logger)
1919                && elapsed >= threshold
1920            {
1921                logger(sql, elapsed);
1922            }
1923        }
1924        result
1925    }
1926
1927    fn execute_stmt_with_cancel(
1928        &mut self,
1929        stmt: Statement,
1930        cancel: CancelToken<'_>,
1931    ) -> Result<QueryResult, EngineError> {
1932        cancel.check()?;
1933        // v7.17.0 Phase 1.1 — pre-resolve nextval / currval /
1934        // setval calls in the statement tree. Walks SELECT
1935        // projection, INSERT VALUES, UPDATE SET, DELETE WHERE,
1936        // and DEFAULT exprs; replaces sequence FunctionCall
1937        // nodes with concrete Literal values minted against the
1938        // catalog. This is the only place that mutates sequence
1939        // state from a SELECT-shaped path (exec_select_cancel is
1940        // `&self` and can't reach the catalog mutably).
1941        //
1942        // Fast-path: when no sequences exist anywhere in the
1943        // catalog (the typical hot-path INSERT load), skip the
1944        // walker entirely. Single map-emptiness check on the
1945        // catalog beats walking every expression on every call.
1946        let mut stmt = stmt;
1947        // v7.17 dump-compat — the fast-path check
1948        // `sequences().is_empty()` skips pre-resolve when no
1949        // sequence exists in the *currently active* catalog
1950        // snapshot. The committed catalog or the implicit-TX
1951        // catalog may legitimately disagree on this between
1952        // CREATE SEQUENCE and a later setval(): always run the
1953        // resolver — the walk is O(expr-count) and dwarfed by
1954        // the parse cost we just paid.
1955        self.pre_resolve_sequence_calls_in_statement(&mut stmt)?;
1956        let result = match stmt {
1957            Statement::CreateTable(s) => self.exec_create_table(s),
1958            // v7.9.15 — CREATE EXTENSION is a no-op on SPG. Returns
1959            // CommandOk with affected=0; modified_catalog=false so
1960            // the WAL doesn't grow a useless entry. mailrs F3.
1961            Statement::CreateExtension(_) => Ok(QueryResult::CommandOk {
1962                affected: 0,
1963                modified_catalog: false,
1964            }),
1965            // v7.16.2 — DO $$ ... $$ block. mailrs round-10 A.2
1966            // — the pre-v7.9.27 no-op SILENTLY swallowed every
1967            // mailrs migrate-038/-040/-042 idempotent rename
1968            // (the IF EXISTS … THEN ALTER … END block never
1969            // ran). v7.16.2 dispatches to exec_do_block which
1970            // runs the PlPgSqlBlock at top level via the same
1971            // execute_stmts machinery the trigger executor
1972            // uses (NEW=None, OLD=None — DO blocks have no
1973            // row context).
1974            Statement::DoBlock(body) => self.exec_do_block(body),
1975            // v7.14.0 — empty-statement no-op for pg_dump /
1976            // mysqldump preamble lines that collapse to nothing
1977            // after comment-stripping.
1978            Statement::Empty => Ok(QueryResult::CommandOk {
1979                affected: 0,
1980                modified_catalog: false,
1981            }),
1982            Statement::DropTable { names, if_exists } => self.exec_drop_table(names, if_exists),
1983            Statement::DropIndex { name, if_exists } => self.exec_drop_index(name, if_exists),
1984            Statement::CreateIndex(s) => self.exec_create_index(s),
1985            Statement::Insert(s) => self.exec_insert(s),
1986            Statement::Update(mut s) => {
1987                // Materialise uncorrelated subqueries in SET / WHERE
1988                // before the row walk — the SELECT path has done this
1989                // since v4.10; UPDATE gained it for mailrs's
1990                // `UPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP
1991                // LOCKED)` claim pattern (embed round-12).
1992                for (_, e) in &mut s.assignments {
1993                    self.resolve_expr_subqueries(e, cancel)?;
1994                }
1995                if let Some(w) = &mut s.where_ {
1996                    self.resolve_expr_subqueries(w, cancel)?;
1997                }
1998                self.exec_update_cancel(&s, cancel)
1999            }
2000            Statement::Delete(mut s) => {
2001                if let Some(w) = &mut s.where_ {
2002                    self.resolve_expr_subqueries(w, cancel)?;
2003                }
2004                self.exec_delete_cancel(&s, cancel)
2005            }
2006            Statement::Merge(s) => self.exec_merge_cancel(&s, cancel),
2007            Statement::Select(s) => self.exec_select_cancel(&s, cancel),
2008            Statement::Begin => self.exec_begin(),
2009            Statement::Commit => self.exec_commit(),
2010            Statement::Rollback => self.exec_rollback(),
2011            Statement::Savepoint(name) => self.exec_savepoint(name),
2012            Statement::RollbackToSavepoint(name) => self.exec_rollback_to_savepoint(&name),
2013            Statement::ReleaseSavepoint(name) => self.exec_release_savepoint(&name),
2014            Statement::ShowTables => Ok(self.exec_show_tables()),
2015            Statement::ShowDatabases => Ok(self.exec_show_databases()),
2016            Statement::ShowCreateTable(name) => self.exec_show_create_table(&name),
2017            Statement::ShowIndexes(name) => self.exec_show_indexes(&name),
2018            Statement::ShowStatus => Ok(self.exec_show_status()),
2019            Statement::ShowVariables => Ok(self.exec_show_variables()),
2020            Statement::ShowProcesslist => Ok(self.exec_show_processlist()),
2021            Statement::ShowColumns(table) => self.exec_show_columns(&table),
2022            Statement::ShowUsers => Ok(self.exec_show_users()),
2023            Statement::ShowPublications => Ok(self.exec_show_publications()),
2024            Statement::ShowSubscriptions => Ok(self.exec_show_subscriptions()),
2025            Statement::CreateUser(s) => self.exec_create_user(&s),
2026            Statement::DropUser(name) => self.exec_drop_user(&name),
2027            Statement::Explain(e) => self.exec_explain(&e, cancel),
2028            Statement::AlterIndex(s) => self.exec_alter_index(s),
2029            Statement::AlterTable(s) => self.exec_alter_table(s),
2030            Statement::CreatePublication(s) => self.exec_create_publication(s),
2031            Statement::DropPublication(name) => self.exec_drop_publication(&name),
2032            Statement::CreateSubscription(s) => self.exec_create_subscription(s),
2033            Statement::DropSubscription(name) => self.exec_drop_subscription(&name),
2034            // v6.1.7 — WAIT FOR WAL POSITION needs `lag_state`,
2035            // which lives in spg-server's ServerState. The engine
2036            // surfaces a clear error; the server-layer dispatch
2037            // intercepts the SQL before it reaches the engine on
2038            // a server build, so this arm only fires for
2039            // engine-only callers (spg-embedded, lib tests).
2040            Statement::WaitForWalPosition { .. } => Err(EngineError::Unsupported(
2041                "WAIT FOR WAL POSITION must be handled by the server layer".into(),
2042            )),
2043            // v6.2.0 — ANALYZE recomputes per-column histograms.
2044            Statement::Analyze(target) => self.exec_analyze(target.as_deref()),
2045            // v6.7.3 — COMPACT COLD SEGMENTS.
2046            Statement::CompactColdSegments => self.exec_compact_cold_segments(),
2047            // v7.12.1 — SET / RESET session parameter. Engine
2048            // tracks the value in `session_params`; FTS dispatcher
2049            // reads `default_text_search_config`. Everything else
2050            // is a recorded no-op (PG dump compat).
2051            Statement::SetParameter { name, value } => {
2052                self.set_session_param(name, value);
2053                Ok(QueryResult::CommandOk {
2054                    affected: 0,
2055                    modified_catalog: false,
2056                })
2057            }
2058            // v7.14.0 — MySQL multi-assignment SET. Each pair runs
2059            // through `set_session_param` so engine-known params
2060            // (FOREIGN_KEY_CHECKS, session_replication_role, …) take
2061            // effect; unknown pairs (including `@VAR` LHS from the
2062            // mysqldump preamble) are recorded then ignored.
2063            Statement::SetParameterList(pairs) => {
2064                for (name, value) in pairs {
2065                    self.set_session_param(name, value);
2066                }
2067                Ok(QueryResult::CommandOk {
2068                    affected: 0,
2069                    modified_catalog: false,
2070                })
2071            }
2072            // v7.12.4 — CREATE FUNCTION / CREATE TRIGGER / DROP …
2073            // for the PL/pgSQL trigger surface. exec_* methods are
2074            // defined alongside the existing CREATE handlers below.
2075            Statement::CreateFunction(s) => self.exec_create_function(s),
2076            Statement::CreateTrigger(s) => self.exec_create_trigger(s),
2077            Statement::DropTrigger {
2078                name,
2079                table,
2080                if_exists,
2081            } => self.exec_drop_trigger(&name, &table, if_exists),
2082            Statement::DropFunction { name, if_exists } => {
2083                self.exec_drop_function(&name, if_exists)
2084            }
2085            Statement::CreateSequence(s) => self.exec_create_sequence(s),
2086            Statement::AlterSequence(s) => self.exec_alter_sequence(s),
2087            Statement::DropSequence { names, if_exists } => {
2088                self.exec_drop_sequence(&names, if_exists)
2089            }
2090            Statement::CreateView(s) => self.exec_create_view(s),
2091            Statement::DropView { names, if_exists } => self.exec_drop_view(&names, if_exists),
2092            Statement::CreateMaterializedView(s) => self.exec_create_materialized_view(s),
2093            Statement::RefreshMaterializedView { name, with_data } => {
2094                self.exec_refresh_materialized_view(&name, with_data)
2095            }
2096            Statement::DropMaterializedView { names, if_exists } => {
2097                self.exec_drop_materialized_view(&names, if_exists)
2098            }
2099            Statement::CreateType(s) => self.exec_create_type(s),
2100            Statement::DropType { names, if_exists } => self.exec_drop_type(&names, if_exists),
2101            Statement::CreateDomain(s) => self.exec_create_domain(s),
2102            Statement::DropDomain { names, if_exists } => self.exec_drop_domain(&names, if_exists),
2103            Statement::CreateSchema {
2104                name,
2105                if_not_exists,
2106            } => self.exec_create_schema(name, if_not_exists),
2107            Statement::DropSchema { names, if_exists } => self.exec_drop_schema(&names, if_exists),
2108            Statement::ResetParameter(target) => {
2109                match target {
2110                    None => self.session_params.clear(),
2111                    Some(name) => {
2112                        self.session_params.remove(&name.to_ascii_lowercase());
2113                    }
2114                }
2115                Ok(QueryResult::CommandOk {
2116                    affected: 0,
2117                    modified_catalog: false,
2118                })
2119            }
2120        };
2121        self.enforce_row_limit(result)
2122    }
2123
2124    /// v6.1.2 — `CREATE PUBLICATION` runtime path. Duplicate names
2125    /// surface as `EngineError::Unsupported` so the existing PG-wire
2126    /// error mapping stays uniform; the message carries the name so
2127    /// operators can grep replication-log noise. Inside-transaction
2128    /// invocation is rejected (matches `CREATE USER` / `DROP USER`
2129    /// stance) — replication-catalog mutation is a connection-level
2130    /// administrative op, not a transactional one.
2131    fn exec_create_publication(
2132        &mut self,
2133        s: CreatePublicationStatement,
2134    ) -> Result<QueryResult, EngineError> {
2135        // v6.1.4 — the v6.1.2 "no DDL inside a transaction" guard
2136        // was over-cautious: it also blocked the auto-commit wrap
2137        // path (which begins an internal TX around every WAL-
2138        // logged statement). PG itself allows CREATE PUBLICATION
2139        // inside a transaction (it rolls back with the TX).
2140        self.publications
2141            .create(s.name, s.scope)
2142            .map_err(|e| EngineError::Unsupported(alloc::format!("CREATE PUBLICATION: {e:?}")))?;
2143        Ok(QueryResult::CommandOk {
2144            affected: 1,
2145            modified_catalog: true,
2146        })
2147    }
2148
2149    /// v6.1.2 — `DROP PUBLICATION` runtime path. PG-compatible silent
2150    /// no-op when the publication doesn't exist (returns `affected=0`
2151    /// in that case so the wire-level command tag distinguishes
2152    /// "dropped" from "no-op", though both succeed).
2153    fn exec_drop_publication(&mut self, name: &str) -> Result<QueryResult, EngineError> {
2154        let removed = self.publications.drop(name);
2155        Ok(QueryResult::CommandOk {
2156            affected: usize::from(removed),
2157            modified_catalog: removed,
2158        })
2159    }
2160
2161    /// v6.1.2 — read access to the publication catalog. Used by
2162    /// the v6.1.5 publisher-side WAL filter, by `SHOW PUBLICATIONS`
2163    /// (v6.1.3+), and by e2e tests that need to assert state without
2164    /// going through the wire.
2165    pub const fn publications(&self) -> &publications::Publications {
2166        &self.publications
2167    }
2168
2169    /// v6.1.4 — `CREATE SUBSCRIPTION` runtime path. Defaults
2170    /// `enabled = true` and `last_received_pos = 0` for a freshly-
2171    /// created subscription. The actual worker thread is spawned
2172    /// by spg-server once the engine returns success.
2173    fn exec_create_subscription(
2174        &mut self,
2175        s: CreateSubscriptionStatement,
2176    ) -> Result<QueryResult, EngineError> {
2177        // See exec_create_publication — the in_transaction gate
2178        // was over-cautious; the auto-commit wrap path holds an
2179        // internal TX that this check was incorrectly blocking.
2180        let sub = subscriptions::Subscription {
2181            conn_str: s.conn_str,
2182            publications: s.publications,
2183            enabled: true,
2184            last_received_pos: 0,
2185        };
2186        self.subscriptions
2187            .create(s.name, sub)
2188            .map_err(|e| EngineError::Unsupported(alloc::format!("CREATE SUBSCRIPTION: {e:?}")))?;
2189        Ok(QueryResult::CommandOk {
2190            affected: 1,
2191            modified_catalog: true,
2192        })
2193    }
2194
2195    /// v6.1.4 — `DROP SUBSCRIPTION`. Silent no-op when the name
2196    /// doesn't exist (PG-compatible). The associated worker is
2197    /// torn down by spg-server when it observes the catalog
2198    /// change at the next snapshot or via the engine's
2199    /// subscriptions accessor (the worker polls the catalog on
2200    /// reconnect; v6.1.5's filter-side will tighten this to an
2201    /// explicit signal).
2202    fn exec_drop_subscription(&mut self, name: &str) -> Result<QueryResult, EngineError> {
2203        let removed = self.subscriptions.drop(name);
2204        Ok(QueryResult::CommandOk {
2205            affected: usize::from(removed),
2206            modified_catalog: removed,
2207        })
2208    }
2209
2210    /// v6.1.4 — read access to the subscription catalog. Used by
2211    /// the subscription worker (read its own row to find its
2212    /// publications + last applied position), by SHOW SUBSCRIPTIONS,
2213    /// and by e2e tests asserting state directly.
2214    pub const fn subscriptions(&self) -> &subscriptions::Subscriptions {
2215        &self.subscriptions
2216    }
2217
2218    /// v6.1.4 — write access to `last_received_pos`. Worker
2219    /// calls this after each apply batch (under the engine's
2220    /// write-lock). Returns `false` when the subscription was
2221    /// dropped between when the worker received the record and
2222    /// when this call landed.
2223    pub fn subscription_advance(&mut self, name: &str, pos: u64) -> bool {
2224        self.subscriptions.update_last_received_pos(name, pos)
2225    }
2226
2227    /// v6.1.4 — `SHOW SUBSCRIPTIONS` row materialisation. Returns
2228    /// `(name, conn_str, publications, enabled, last_received_pos)`
2229    /// ordered by subscription name. The `publications` column is
2230    /// the comma-joined list ("p1, p2") for ergonomic SHOW output;
2231    /// callers wanting structured access read `Engine::subscriptions`.
2232    fn exec_show_subscriptions(&self) -> QueryResult {
2233        let columns = alloc::vec![
2234            ColumnSchema::new("name", DataType::Text, false),
2235            ColumnSchema::new("conn_str", DataType::Text, false),
2236            ColumnSchema::new("publications", DataType::Text, false),
2237            ColumnSchema::new("enabled", DataType::Bool, false),
2238            ColumnSchema::new("last_received_pos", DataType::BigInt, false),
2239        ];
2240        let rows: Vec<Row> = self
2241            .subscriptions
2242            .iter()
2243            .map(|(name, sub)| {
2244                Row::new(alloc::vec![
2245                    Value::Text(name.clone()),
2246                    Value::Text(sub.conn_str.clone()),
2247                    Value::Text(sub.publications.join(", ")),
2248                    Value::Bool(sub.enabled),
2249                    Value::BigInt(i64::try_from(sub.last_received_pos).unwrap_or(i64::MAX)),
2250                ])
2251            })
2252            .collect();
2253        QueryResult::Rows { columns, rows }
2254    }
2255
2256    /// v6.2.0 — materialise `spg_statistic` rows. One row per
2257    /// `(table, column)` pair tracked in `Statistics`, with
2258    /// `histogram_bounds` rendered as a `[v0, v1, ...]` string —
2259    /// the same canonical form vector literals use for round-trip.
2260    fn exec_spg_statistic(&self) -> QueryResult {
2261        let columns = alloc::vec![
2262            ColumnSchema::new("table_name", DataType::Text, false),
2263            ColumnSchema::new("column_name", DataType::Text, false),
2264            ColumnSchema::new("null_frac", DataType::Float, false),
2265            ColumnSchema::new("n_distinct", DataType::BigInt, false),
2266            ColumnSchema::new("histogram_bounds", DataType::Text, false),
2267            // v6.7.0 — appended column (v6.2.0 stability contract
2268            // allows APPEND to spg_statistic, not reorder/rename).
2269            // Reports the cached per-table cold-row count; same
2270            // value across every column row of the same table.
2271            ColumnSchema::new("cold_row_count", DataType::BigInt, false),
2272        ];
2273        let rows: Vec<Row> = self
2274            .statistics
2275            .iter()
2276            .map(|((t, c), s)| {
2277                let cold = self
2278                    .catalog
2279                    .get(t)
2280                    .map_or(0, |table| table.cold_row_count());
2281                Row::new(alloc::vec![
2282                    Value::Text(t.clone()),
2283                    Value::Text(c.clone()),
2284                    Value::Float(f64::from(s.null_frac)),
2285                    Value::BigInt(i64::try_from(s.n_distinct).unwrap_or(i64::MAX)),
2286                    Value::Text(render_histogram_bounds(&s.histogram_bounds)),
2287                    Value::BigInt(i64::try_from(cold).unwrap_or(i64::MAX)),
2288                ])
2289            })
2290            .collect();
2291        QueryResult::Rows { columns, rows }
2292    }
2293
2294    /// v6.5.0 — materialise `spg_stat_replication` rows. One row
2295    /// per subscription with `(name, conn_str, publications,
2296    /// last_received_pos, enabled)`. Surface mirrors
2297    /// `SHOW SUBSCRIPTIONS` but follows the virtual-table dispatch
2298    /// shape so it composes with SELECT clauses (WHERE, projection
2299    /// onto specific columns, etc).
2300    fn exec_spg_stat_replication(&self) -> QueryResult {
2301        let columns = alloc::vec![
2302            ColumnSchema::new("name", DataType::Text, false),
2303            ColumnSchema::new("conn_str", DataType::Text, false),
2304            ColumnSchema::new("publications", DataType::Text, false),
2305            ColumnSchema::new("last_received_pos", DataType::BigInt, false),
2306            ColumnSchema::new("enabled", DataType::Bool, false),
2307        ];
2308        let rows: Vec<Row> = self
2309            .subscriptions
2310            .iter()
2311            .map(|(name, sub)| {
2312                Row::new(alloc::vec![
2313                    Value::Text(name.clone()),
2314                    Value::Text(sub.conn_str.clone()),
2315                    Value::Text(sub.publications.join(",")),
2316                    Value::BigInt(i64::try_from(sub.last_received_pos).unwrap_or(i64::MAX)),
2317                    Value::Bool(sub.enabled),
2318                ])
2319            })
2320            .collect();
2321        QueryResult::Rows { columns, rows }
2322    }
2323
2324    /// v6.5.0 — materialise `spg_stat_segment` rows. One row per
2325    /// cold-tier segment with `(segment_id, num_rows, num_pages,
2326    /// total_bytes)`.
2327    ///
2328    /// v6.7.0 — appended `table_name` column resolves the v6.5.0
2329    /// carve-out. Walks every user table's BTree indices to find
2330    /// which table's Cold locators point at each segment. Empty
2331    /// string for orphan segments (loaded via SPG_PRELOAD_COLD_SEGMENT
2332    /// before any index registered a locator). The walk is
2333    /// O(tables × indices × keys); cached per call, not across
2334    /// calls — re-walked on every `SELECT * FROM spg_stat_segment`.
2335    fn exec_spg_stat_segment(&self) -> QueryResult {
2336        let columns = alloc::vec![
2337            ColumnSchema::new("segment_id", DataType::BigInt, false),
2338            ColumnSchema::new("table_name", DataType::Text, false),
2339            ColumnSchema::new("num_rows", DataType::BigInt, false),
2340            ColumnSchema::new("num_pages", DataType::BigInt, false),
2341            ColumnSchema::new("total_bytes", DataType::BigInt, false),
2342        ];
2343        // v6.7.0 — build a segment_id → table_name map by walking
2344        // every user table's BTree indices once. O(tables × indices
2345        // × keys) for the v6.5.0 carve-out resolution; acceptable
2346        // because spg_stat_segment is operator-facing (not on a
2347        // hot-loop path).
2348        let mut segment_owners: alloc::collections::BTreeMap<u32, String> = BTreeMap::new();
2349        for tname in self.catalog.table_names() {
2350            if is_internal_table_name(&tname) {
2351                continue;
2352            }
2353            let Some(t) = self.catalog.get(&tname) else {
2354                continue;
2355            };
2356            for idx in t.indices() {
2357                if let spg_storage::IndexKind::BTree(map) = &idx.kind {
2358                    for (_, locs) in map.iter() {
2359                        for loc in locs {
2360                            if let spg_storage::RowLocator::Cold { segment_id, .. } = loc {
2361                                segment_owners
2362                                    .entry(*segment_id)
2363                                    .or_insert_with(|| tname.clone());
2364                            }
2365                        }
2366                    }
2367                }
2368            }
2369        }
2370        let rows: Vec<Row> = self
2371            .catalog
2372            .cold_segment_ids_global()
2373            .iter()
2374            .filter_map(|&id| {
2375                let seg = self.catalog.cold_segment(id)?;
2376                let meta = seg.meta();
2377                let owner = segment_owners.get(&id).cloned().unwrap_or_default();
2378                Some(Row::new(alloc::vec![
2379                    Value::BigInt(i64::from(id)),
2380                    Value::Text(owner),
2381                    Value::BigInt(i64::try_from(meta.num_rows).unwrap_or(i64::MAX)),
2382                    Value::BigInt(i64::from(meta.num_pages)),
2383                    Value::BigInt(i64::try_from(meta.total_bytes).unwrap_or(i64::MAX)),
2384                ]))
2385            })
2386            .collect();
2387        QueryResult::Rows { columns, rows }
2388    }
2389
2390    /// v6.5.1 — materialise `spg_stat_query` rows. One row per
2391    /// distinct SQL text recorded since the engine booted, capped
2392    /// at `QUERY_STATS_MAX` (1024). Columns:
2393    ///   sql, exec_count, total_us, mean_us, max_us, last_seen_us
2394    /// mean_us = total_us / exec_count (saturating).
2395    fn exec_spg_stat_query(&self) -> QueryResult {
2396        let columns = alloc::vec![
2397            ColumnSchema::new("sql", DataType::Text, false),
2398            ColumnSchema::new("exec_count", DataType::BigInt, false),
2399            ColumnSchema::new("total_us", DataType::BigInt, false),
2400            ColumnSchema::new("mean_us", DataType::BigInt, false),
2401            ColumnSchema::new("max_us", DataType::BigInt, false),
2402            ColumnSchema::new("last_seen_us", DataType::BigInt, false),
2403        ];
2404        let rows: Vec<Row> = self
2405            .query_stats
2406            .snapshot()
2407            .into_iter()
2408            .map(|(sql, s)| {
2409                let mean = if s.exec_count == 0 {
2410                    0
2411                } else {
2412                    s.total_us / s.exec_count
2413                };
2414                Row::new(alloc::vec![
2415                    Value::Text(sql),
2416                    Value::BigInt(i64::try_from(s.exec_count).unwrap_or(i64::MAX)),
2417                    Value::BigInt(i64::try_from(s.total_us).unwrap_or(i64::MAX)),
2418                    Value::BigInt(i64::try_from(mean).unwrap_or(i64::MAX)),
2419                    Value::BigInt(i64::try_from(s.max_us).unwrap_or(i64::MAX)),
2420                    Value::BigInt(i64::try_from(s.last_seen_us).unwrap_or(i64::MAX)),
2421                ])
2422            })
2423            .collect();
2424        QueryResult::Rows { columns, rows }
2425    }
2426
2427    /// v6.5.2 — register a connection-state provider. spg-server
2428    /// calls this at startup with a function that snapshots its
2429    /// per-pgwire-connection registry. Engine reads through the
2430    /// callback on `SELECT * FROM spg_stat_activity`.
2431    #[must_use]
2432    pub const fn with_activity_provider(mut self, f: ActivityProvider) -> Self {
2433        self.activity_provider = Some(f);
2434        self
2435    }
2436
2437    /// v6.5.3 — register audit chain provider + verifier.
2438    #[must_use]
2439    pub const fn with_audit_providers(
2440        mut self,
2441        chain: AuditChainProvider,
2442        verify: AuditVerifier,
2443    ) -> Self {
2444        self.audit_chain_provider = Some(chain);
2445        self.audit_verifier = Some(verify);
2446        self
2447    }
2448
2449    /// v6.5.6 — register a slow-query log callback. `threshold_us`
2450    /// is the floor (in microseconds); only executes above the floor
2451    /// fire the callback. spg-server wires this from
2452    /// `SPG_SLOW_QUERY_THRESHOLD_MS` (default 100 ms).
2453    #[must_use]
2454    pub const fn with_slow_query_log(mut self, threshold_us: u64, logger: SlowQueryLogger) -> Self {
2455        self.slow_query_threshold_us = Some(threshold_us);
2456        self.slow_query_logger = Some(logger);
2457        self
2458    }
2459
2460    /// v6.5.6 — operator knob for plan cache cap. spg-server reads
2461    /// `SPG_PLAN_CACHE_MAX` env at startup; uses this to override
2462    /// the compile-time default of 256.
2463    pub fn set_plan_cache_max(&mut self, n: usize) {
2464        self.plan_cache.set_max_entries(n);
2465    }
2466
2467    /// v6.5.2 — materialise `spg_stat_activity` rows. Pulls a fresh
2468    /// snapshot from the registered `ActivityProvider`. Returns an
2469    /// empty result set when no provider is registered (the no_std
2470    /// embedded path with no pgwire layer).
2471    fn exec_spg_stat_activity(&self) -> QueryResult {
2472        let columns = alloc::vec![
2473            ColumnSchema::new("pid", DataType::Int, false),
2474            ColumnSchema::new("user", DataType::Text, false),
2475            ColumnSchema::new("started_at_us", DataType::BigInt, false),
2476            ColumnSchema::new("current_sql", DataType::Text, false),
2477            ColumnSchema::new("wait_event", DataType::Text, false),
2478            ColumnSchema::new("elapsed_us", DataType::BigInt, false),
2479            ColumnSchema::new("in_transaction", DataType::Bool, false),
2480            ColumnSchema::new("application_name", DataType::Text, false),
2481        ];
2482        let rows: Vec<Row> = self
2483            .activity_provider
2484            .map(|f| f())
2485            .unwrap_or_default()
2486            .into_iter()
2487            .map(|r| {
2488                Row::new(alloc::vec![
2489                    Value::Int(i32::try_from(r.pid).unwrap_or(i32::MAX)),
2490                    Value::Text(r.user),
2491                    Value::BigInt(r.started_at_us),
2492                    Value::Text(r.current_sql),
2493                    Value::Text(r.wait_event),
2494                    Value::BigInt(r.elapsed_us),
2495                    Value::Bool(r.in_transaction),
2496                    Value::Text(r.application_name),
2497                ])
2498            })
2499            .collect();
2500        QueryResult::Rows { columns, rows }
2501    }
2502
2503    /// v6.5.4 — materialise `spg_table_ddl` rows. One row per user
2504    /// table with `(table_name, ddl)`. Reconstructed from catalog
2505    /// state on demand.
2506    fn exec_spg_table_ddl(&self) -> QueryResult {
2507        let columns = alloc::vec![
2508            ColumnSchema::new("table_name", DataType::Text, false),
2509            ColumnSchema::new("ddl", DataType::Text, false),
2510        ];
2511        let rows: Vec<Row> = self
2512            .catalog
2513            .table_names()
2514            .into_iter()
2515            .filter(|n| !is_internal_table_name(n))
2516            .filter_map(|name| {
2517                let table = self.catalog.get(&name)?;
2518                let ddl = render_create_table(&name, &table.schema().columns);
2519                Some(Row::new(alloc::vec![Value::Text(name), Value::Text(ddl),]))
2520            })
2521            .collect();
2522        QueryResult::Rows { columns, rows }
2523    }
2524
2525    /// v6.5.4 — materialise `spg_role_ddl` rows. One row per user
2526    /// with `(role_name, ddl)`. Password is redacted (matches the
2527    /// `Statement::CreateUser` Display which prints `'<redacted>'`).
2528    fn exec_spg_role_ddl(&self) -> QueryResult {
2529        let columns = alloc::vec![
2530            ColumnSchema::new("role_name", DataType::Text, false),
2531            ColumnSchema::new("ddl", DataType::Text, false),
2532        ];
2533        let rows: Vec<Row> = self
2534            .users
2535            .iter()
2536            .map(|(name, rec)| {
2537                let ddl = alloc::format!(
2538                    "CREATE USER {name} WITH PASSWORD '<redacted>' ROLE '{}'",
2539                    rec.role.as_str(),
2540                );
2541                Row::new(alloc::vec![
2542                    Value::Text(String::from(name)),
2543                    Value::Text(ddl)
2544                ])
2545            })
2546            .collect();
2547        QueryResult::Rows { columns, rows }
2548    }
2549
2550    /// v6.5.4 — materialise `spg_database_ddl`: single row whose
2551    /// `ddl` column concatenates every user table's CREATE +
2552    /// every role's CREATE in deterministic catalog order. Suitable
2553    /// for piping back through `Engine::execute` to recreate a
2554    /// schema-equivalent database.
2555    fn exec_spg_database_ddl(&self) -> QueryResult {
2556        let columns = alloc::vec![ColumnSchema::new("ddl", DataType::Text, false)];
2557        let mut out = String::new();
2558        for (name, rec) in self.users.iter() {
2559            out.push_str(&alloc::format!(
2560                "CREATE USER {name} WITH PASSWORD '<redacted>' ROLE '{}';\n",
2561                rec.role.as_str(),
2562            ));
2563        }
2564        for name in self.catalog.table_names() {
2565            if is_internal_table_name(&name) {
2566                continue;
2567            }
2568            if let Some(table) = self.catalog.get(&name) {
2569                out.push_str(&render_create_table(&name, &table.schema().columns));
2570                out.push_str(";\n");
2571            }
2572        }
2573        QueryResult::Rows {
2574            columns,
2575            rows: alloc::vec![Row::new(alloc::vec![Value::Text(out)])],
2576        }
2577    }
2578
2579    /// v6.5.3 — materialise `spg_audit_chain` rows. Pulls a fresh
2580    /// snapshot from the registered provider; empty when no
2581    /// provider is set.
2582    fn exec_spg_audit_chain(&self) -> QueryResult {
2583        let columns = alloc::vec![
2584            ColumnSchema::new("seq", DataType::BigInt, false),
2585            ColumnSchema::new("ts_ms", DataType::BigInt, false),
2586            ColumnSchema::new("prev_hash", DataType::Text, false),
2587            ColumnSchema::new("entry_hash", DataType::Text, false),
2588            ColumnSchema::new("sql", DataType::Text, false),
2589        ];
2590        let rows: Vec<Row> = self
2591            .audit_chain_provider
2592            .map(|f| f())
2593            .unwrap_or_default()
2594            .into_iter()
2595            .map(|r| {
2596                Row::new(alloc::vec![
2597                    Value::BigInt(r.seq),
2598                    Value::BigInt(r.ts_ms),
2599                    Value::Text(r.prev_hash_hex),
2600                    Value::Text(r.entry_hash_hex),
2601                    Value::Text(r.sql),
2602                ])
2603            })
2604            .collect();
2605        QueryResult::Rows { columns, rows }
2606    }
2607
2608    /// v6.5.3 — materialise `spg_audit_verify` single-row result.
2609    /// `(verified_count, broken_at_seq)` — broken_at_seq is `-1`
2610    /// on a clean chain. Returns one row with both values 0 when
2611    /// no verifier is registered (no-data fallback for embedded
2612    /// callers).
2613    fn exec_spg_audit_verify(&self) -> QueryResult {
2614        let columns = alloc::vec![
2615            ColumnSchema::new("verified_count", DataType::BigInt, false),
2616            ColumnSchema::new("broken_at_seq", DataType::BigInt, false),
2617        ];
2618        let (verified, broken) = self.audit_verifier.map(|f| f()).unwrap_or((0, -1));
2619        let row = Row::new(alloc::vec![Value::BigInt(verified), Value::BigInt(broken),]);
2620        QueryResult::Rows {
2621            columns,
2622            rows: alloc::vec![row],
2623        }
2624    }
2625
2626    /// v6.5.1 — read-only accessor for tests + v6.5.6 ops resets.
2627    pub fn query_stats(&self) -> &query_stats::QueryStats {
2628        &self.query_stats
2629    }
2630
2631    /// v6.5.1 — mutable accessor (clear, etc).
2632    pub fn query_stats_mut(&mut self) -> &mut query_stats::QueryStats {
2633        &mut self.query_stats
2634    }
2635
2636    /// v6.2.0 — read access to the per-column statistics table.
2637    /// Used by the planner (v6.2.2 selectivity functions read this),
2638    /// by `SELECT * FROM spg_statistic`, and by e2e tests.
2639    pub const fn statistics(&self) -> &statistics::Statistics {
2640        &self.statistics
2641    }
2642
2643    /// v6.2.1 — return tables whose modified-row count crossed the
2644    /// auto-analyze threshold since the last ANALYZE on that table.
2645    /// The threshold is `0.1 × max(row_count, MIN_ROWS_FOR_AUTO_
2646    /// ANALYZE)` — combines PG-style fractional + absolute lower
2647    /// bound so a fresh / tiny table doesn't get hammered on every
2648    /// INSERT.
2649    ///
2650    /// Designed to be cheap: walks every user table's
2651    /// `Catalog::table_names()` + reads `statistics::modified_
2652    /// since_last_analyze()` (BTreeMap lookup). The background
2653    /// worker calls this under `engine.read()` then drops the lock
2654    /// before re-acquiring `engine.write()` for the actual ANALYZE.
2655    pub fn tables_needing_analyze(&self) -> Vec<String> {
2656        const MIN_ROWS: u64 = 100;
2657        let mut out = Vec::new();
2658        for name in self.catalog.table_names() {
2659            if is_internal_table_name(&name) {
2660                continue;
2661            }
2662            let Some(table) = self.catalog.get(&name) else {
2663                continue;
2664            };
2665            let row_count = table.rows().len() as u64;
2666            let modified = self.statistics.modified_since_last_analyze(&name);
2667            // Threshold: ceil(0.1 × max(row_count, MIN_ROWS)),
2668            // computed in integer arithmetic so spg-engine stays
2669            // no_std without pulling in libm. `(n + 9) / 10` is
2670            // `ceil(n / 10)` for non-negative `n`.
2671            let base = row_count.max(MIN_ROWS);
2672            let threshold = base.saturating_add(9) / 10;
2673            if modified >= threshold {
2674                out.push(name);
2675            }
2676        }
2677        out
2678    }
2679
2680    /// v6.2.0 — `ANALYZE [<table>]` runtime. Bare `ANALYZE` walks
2681    /// every user table; `ANALYZE <name>` re-stats one. For each
2682    /// target table, single-pass scan + per-column histogram +
2683    /// `null_frac` + `n_distinct`. Replaces the table's prior
2684    /// stats; resets the modified-row counter.
2685    ///
2686    /// v6.2.0 doesn't sample — it scans the full table. v6.2.x
2687    /// can add reservoir sampling at the > 100 K-row mark; not a
2688    /// scope blocker for the current commit since rows ≤ 100 K
2689    /// analyse in milliseconds.
2690    fn exec_analyze(&mut self, target: Option<&str>) -> Result<QueryResult, EngineError> {
2691        let names: Vec<String> = if let Some(name) = target {
2692            // Verify the table exists; surface a clear error if not.
2693            if self.catalog.get(name).is_none() {
2694                return Err(EngineError::Storage(StorageError::TableNotFound {
2695                    name: name.to_string(),
2696                }));
2697            }
2698            alloc::vec![name.to_string()]
2699        } else {
2700            self.catalog
2701                .table_names()
2702                .into_iter()
2703                .filter(|n| !is_internal_table_name(n))
2704                .collect()
2705        };
2706        let mut analysed = 0usize;
2707        for table_name in &names {
2708            self.analyze_one_table(table_name)?;
2709            analysed += 1;
2710        }
2711        // v6.3.1 — plan cache invalidation. Bump stats version so
2712        // future lookups see the new generation, and selectively
2713        // evict every plan whose `source_tables` overlap with the
2714        // ANALYZE target set. Bare ANALYZE (all tables) clears the
2715        // whole cache.
2716        if analysed > 0 {
2717            self.statistics.bump_version();
2718            if target.is_some() {
2719                for t in &names {
2720                    self.plan_cache.evict_referencing(t);
2721                }
2722            } else {
2723                self.plan_cache.clear();
2724            }
2725        }
2726        Ok(QueryResult::CommandOk {
2727            affected: analysed,
2728            modified_catalog: true,
2729        })
2730    }
2731
2732    /// v6.7.3 — `COMPACT COLD SEGMENTS` runtime path. Drives the
2733    /// engine-layer compaction shim with the default
2734    /// 4 MiB segment-size threshold. spg-server intercepts the
2735    /// SQL before it reaches the engine on a server build —
2736    /// it reads `SPG_COMPACTION_TARGET_SEGMENT_BYTES`, calls
2737    /// `Engine::compact_cold_segments_with_target` directly with
2738    /// the env value, and persists every merged segment to
2739    /// v7.12.1 — record a `SET <name> = <value>` parameter. Names
2740    /// are case-folded to lowercase to match PG; values keep their
2741    /// caller-supplied form so observability paths see what was
2742    /// requested. Only `default_text_search_config` is consulted by
2743    /// the engine today.
2744    fn set_session_param(&mut self, name: String, value: spg_sql::ast::SetValue) {
2745        let normalised = match value {
2746            spg_sql::ast::SetValue::String(s) => s,
2747            spg_sql::ast::SetValue::Ident(s) => s,
2748            spg_sql::ast::SetValue::Number(s) => s,
2749            spg_sql::ast::SetValue::Default => String::new(),
2750        };
2751        let key = name.to_ascii_lowercase();
2752        // v7.14.0 — mysqldump preamble emits
2753        // `SET FOREIGN_KEY_CHECKS=0` so it can CREATE TABLE in any
2754        // order despite cross-table FK references; the closing
2755        // section emits `SET FOREIGN_KEY_CHECKS=1` (or
2756        // `=@OLD_FOREIGN_KEY_CHECKS` which resolves to "ON" in our
2757        // session-variable-aware path). Match both shapes.
2758        // Also accept PG's `session_replication_role = 'replica'`
2759        // which suppresses trigger + FK enforcement during a
2760        // logical replication apply (pg_dump preserves this for
2761        // schema-only mode but it shows up in some restores).
2762        let value_off = matches!(
2763            normalised.to_ascii_lowercase().as_str(),
2764            "0" | "off" | "false"
2765        );
2766        let value_on = matches!(
2767            normalised.to_ascii_lowercase().as_str(),
2768            "1" | "on" | "true"
2769        );
2770        if key == "foreign_key_checks"
2771            || key == "session_replication_role" && normalised.eq_ignore_ascii_case("replica")
2772        {
2773            if value_off || key == "session_replication_role" {
2774                self.foreign_key_checks = false;
2775            } else if value_on
2776                || (key == "session_replication_role" && normalised.eq_ignore_ascii_case("origin"))
2777            {
2778                self.foreign_key_checks = true;
2779                // Drain pending FK queue against the now-complete
2780                // catalog. Errors here surface as the SET reply —
2781                // caller knows enabling checks revealed orphans.
2782                let _ = self.drain_pending_foreign_keys();
2783            }
2784        }
2785        // v7.22 (round-13 T3) — string-literal dialect signals.
2786        // `SET sql_mode = …` is something only MySQL clients and
2787        // mysqldump preambles emit → MySQL escape semantics.
2788        // `SET standard_conforming_strings = on|off` is PG's own
2789        // switch for exactly this behaviour (every pg_dump preamble
2790        // sets it to on). The same SQL text lexes differently per
2791        // dialect, so a flip invalidates the plan cache.
2792        let new_escapes = if key == "sql_mode" {
2793            Some(true)
2794        } else if key == "standard_conforming_strings" {
2795            Some(value_off)
2796        } else {
2797            None
2798        };
2799        if let Some(flag) = new_escapes
2800            && flag != self.backslash_escapes
2801        {
2802            self.backslash_escapes = flag;
2803            self.plan_cache.clear();
2804        }
2805        self.session_params.insert(key, normalised);
2806    }
2807
2808    /// v7.14.0 — resolve every queued FK whose installation was
2809    /// deferred (`SET FOREIGN_KEY_CHECKS=0` window). Called by
2810    /// `set_session_param` when checks flip back on and by the
2811    /// drop-import release gate. Each FK is resolved against the
2812    /// current catalog; remaining missing-parent errors propagate
2813    /// up so the caller knows the import was incomplete.
2814    fn drain_pending_foreign_keys(&mut self) -> Result<(), EngineError> {
2815        let pending = core::mem::take(&mut self.pending_foreign_keys);
2816        for (child, fk) in pending {
2817            // Resolve against the current catalog. Skip silently
2818            // when the child table itself was dropped between
2819            // queue + drain.
2820            let cols_snapshot = match self.active_catalog().get(&child) {
2821                Some(t) => t.schema().columns.clone(),
2822                None => continue,
2823            };
2824            let storage_fk =
2825                resolve_foreign_key(&child, &cols_snapshot, fk, self.active_catalog())?;
2826            let table = self
2827                .active_catalog_mut()
2828                .get_mut(&child)
2829                .expect("checked above");
2830            table.schema_mut().foreign_keys.push(storage_fk);
2831        }
2832        Ok(())
2833    }
2834
2835    /// v7.12.1 — read a session parameter set via `SET`. Used by
2836    /// the FTS function dispatcher to resolve the default config
2837    /// for `to_tsvector(text)` / `plainto_tsquery(text)` etc.
2838    #[must_use]
2839    pub fn session_param(&self, name: &str) -> Option<&str> {
2840        self.session_params
2841            .get(&name.to_ascii_lowercase())
2842            .map(String::as_str)
2843    }
2844
2845    /// v7.12.1 — build an `EvalContext` chained with the session's
2846    /// `default_text_search_config`. Engine-internal callers use
2847    /// this instead of `EvalContext::new` so the FTS function
2848    /// dispatcher sees the SET configuration.
2849    fn ev_ctx<'a>(
2850        &'a self,
2851        columns: &'a [ColumnSchema],
2852        alias: Option<&'a str>,
2853    ) -> EvalContext<'a> {
2854        EvalContext::new(columns, alias)
2855            .with_default_text_search_config(self.session_param("default_text_search_config"))
2856    }
2857
2858    /// `<db>.spg/segments/`. This arm only fires for engine-only
2859    /// callers (spg-embedded, lib tests); in that mode merged
2860    /// segments live in memory and are dropped at process exit.
2861    fn exec_compact_cold_segments(&mut self) -> Result<QueryResult, EngineError> {
2862        let target = COMPACTION_TARGET_DEFAULT_BYTES;
2863        let reports = self.compact_cold_segments_with_target(target)?;
2864        let columns = alloc::vec![
2865            ColumnSchema::new("table_name", DataType::Text, false),
2866            ColumnSchema::new("index_name", DataType::Text, false),
2867            ColumnSchema::new("sources_merged", DataType::BigInt, false),
2868            ColumnSchema::new("merged_segment_id", DataType::BigInt, false),
2869            ColumnSchema::new("merged_rows", DataType::BigInt, false),
2870            ColumnSchema::new("deleted_rows_pruned", DataType::BigInt, false),
2871            ColumnSchema::new("bytes_reclaimed_estimate", DataType::BigInt, false),
2872        ];
2873        let rows: Vec<Row> = reports
2874            .into_iter()
2875            .map(|(tname, iname, report)| {
2876                Row::new(alloc::vec![
2877                    Value::Text(tname),
2878                    Value::Text(iname),
2879                    Value::BigInt(i64::try_from(report.sources.len()).unwrap_or(i64::MAX)),
2880                    Value::BigInt(i64::from(report.merged_segment_id.unwrap_or(0))),
2881                    Value::BigInt(i64::try_from(report.merged_rows).unwrap_or(i64::MAX)),
2882                    Value::BigInt(i64::try_from(report.deleted_rows_pruned).unwrap_or(i64::MAX),),
2883                    Value::BigInt(
2884                        i64::try_from(report.bytes_reclaimed_estimate).unwrap_or(i64::MAX),
2885                    ),
2886                ])
2887            })
2888            .collect();
2889        Ok(QueryResult::Rows { columns, rows })
2890    }
2891
2892    /// Walk a single table's rows once and (re-)populate per-column
2893    /// stats. Drops the existing stats for `table` first so columns
2894    /// that have been DROP-ed between ANALYZEs don't leave stale
2895    /// rows.
2896    fn analyze_one_table(&mut self, table_name: &str) -> Result<(), EngineError> {
2897        let table = self.catalog.get(table_name).ok_or_else(|| {
2898            EngineError::Storage(StorageError::TableNotFound {
2899                name: table_name.to_string(),
2900            })
2901        })?;
2902        let schema = table.schema().clone();
2903        let row_count = table.rows().len();
2904        // For each column, collect (sorted) non-NULL textual values
2905        // + count NULLs; then ask `statistics::build_histogram` to
2906        // produce the 101 bounds and `estimate_n_distinct` the
2907        // distinct count.
2908        self.statistics.clear_table(table_name);
2909        for (col_pos, col_schema) in schema.columns.iter().enumerate() {
2910            // v6.2.0 skip: vector columns have their own stats
2911            // shape (HNSW graph topology). v6.2 deliberation #1.
2912            if matches!(col_schema.ty, DataType::Vector { .. }) {
2913                continue;
2914            }
2915            let mut non_null_values: Vec<Value> = Vec::with_capacity(row_count);
2916            let mut nulls: u64 = 0;
2917            for row in table.rows() {
2918                match row.values.get(col_pos) {
2919                    Some(Value::Null) | None => nulls += 1,
2920                    Some(v) => non_null_values.push(v.clone()),
2921                }
2922            }
2923            // Sort by type-aware ordering (Int as int, Text as
2924            // lex, etc.) so histogram bounds reflect the column's
2925            // natural order — not lexicographic on the string
2926            // representation, which would put "9" after "49".
2927            non_null_values.sort_by(|a, b| sort_values_for_histogram(a, b));
2928            let non_null: Vec<String> = non_null_values.iter().map(canonical_value_repr).collect();
2929            let null_frac = if row_count == 0 {
2930                0.0
2931            } else {
2932                #[allow(clippy::cast_precision_loss)]
2933                let f = nulls as f32 / row_count as f32;
2934                f
2935            };
2936            let n_distinct = statistics::estimate_n_distinct(&non_null);
2937            let histogram_bounds = statistics::build_histogram(&non_null);
2938            self.statistics.set(
2939                table_name.to_string(),
2940                col_schema.name.clone(),
2941                statistics::ColumnStats {
2942                    null_frac,
2943                    n_distinct,
2944                    histogram_bounds,
2945                },
2946            );
2947        }
2948        self.statistics.reset_modified(table_name);
2949        // v6.7.0 — refresh the per-table cold_rows cache. Walk the
2950        // BTree indices and count Cold locators (MAX across
2951        // indices); store the result on the table. Surfaced via
2952        // `spg_statistic.cold_row_count` (new column) and
2953        // `spg_stat_segment.table_name` (new column).
2954        let cold_count = {
2955            let table = self
2956                .active_catalog()
2957                .get(table_name)
2958                .expect("table still present");
2959            table.count_cold_locators()
2960        };
2961        let table_mut = self
2962            .active_catalog_mut()
2963            .get_mut(table_name)
2964            .expect("table still present");
2965        table_mut.set_cold_row_count(cold_count);
2966        Ok(())
2967    }
2968
2969    /// v6.1.3 — `SHOW PUBLICATIONS` row materialisation. Returns
2970    /// `(name, scope, table_count)` ordered by publication name.
2971    ///   - `scope` is the human-readable string:
2972    ///       `"FOR ALL TABLES"` /
2973    ///       `"FOR TABLE t1, t2"` /
2974    ///       `"FOR ALL TABLES EXCEPT t1, t2"`.
2975    ///   - `table_count` is NULL for `AllTables`, the list length
2976    ///     otherwise. NULLability lets clients distinguish "publish
2977    ///     everything" from "publish exactly 0 tables" (the v6.1.3
2978    ///     parser forbids the empty list, but the column shape is
2979    ///     ready for the v6.1.5 publisher-side semantics).
2980    fn exec_show_publications(&self) -> QueryResult {
2981        let columns = alloc::vec![
2982            ColumnSchema::new("name", DataType::Text, false),
2983            ColumnSchema::new("scope", DataType::Text, false),
2984            ColumnSchema::new("table_count", DataType::Int, true),
2985        ];
2986        let rows: Vec<Row> = self
2987            .publications
2988            .iter()
2989            .map(|(name, scope)| {
2990                let (scope_str, count_val) = match scope {
2991                    spg_sql::ast::PublicationScope::AllTables => {
2992                        ("FOR ALL TABLES".to_string(), Value::Null)
2993                    }
2994                    spg_sql::ast::PublicationScope::ForTables(ts) => (
2995                        alloc::format!("FOR TABLE {}", ts.join(", ")),
2996                        Value::Int(i32::try_from(ts.len()).unwrap_or(i32::MAX)),
2997                    ),
2998                    spg_sql::ast::PublicationScope::AllTablesExcept(ts) => (
2999                        alloc::format!("FOR ALL TABLES EXCEPT {}", ts.join(", ")),
3000                        Value::Int(i32::try_from(ts.len()).unwrap_or(i32::MAX)),
3001                    ),
3002                };
3003                Row::new(alloc::vec![
3004                    Value::Text(name.clone()),
3005                    Value::Text(scope_str),
3006                    count_val,
3007                ])
3008            })
3009            .collect();
3010        QueryResult::Rows { columns, rows }
3011    }
3012
3013    /// v4.1 `SHOW USERS` — `(name, role)` per row, ordered by name.
3014    fn exec_show_users(&self) -> QueryResult {
3015        let columns = alloc::vec![
3016            ColumnSchema::new("name", DataType::Text, false),
3017            ColumnSchema::new("role", DataType::Text, false),
3018        ];
3019        let rows: Vec<Row> = self
3020            .users
3021            .iter()
3022            .map(|(name, rec)| {
3023                Row::new(alloc::vec![
3024                    Value::Text(name.to_string()),
3025                    Value::Text(rec.role.as_str().to_string()),
3026                ])
3027            })
3028            .collect();
3029        QueryResult::Rows { columns, rows }
3030    }
3031
3032    fn exec_create_user(&mut self, s: &CreateUserStatement) -> Result<QueryResult, EngineError> {
3033        if self.in_transaction() {
3034            return Err(EngineError::Unsupported(
3035                "CREATE USER is not allowed inside a transaction".into(),
3036            ));
3037        }
3038        let role = users::Role::parse(&s.role).ok_or_else(|| {
3039            EngineError::Unsupported(alloc::format!("invalid role: {:?}", s.role))
3040        })?;
3041        // Prefer the host-injected RNG. Falls back to a deterministic
3042        // salt derived from the username only when no RNG is wired —
3043        // acceptable for tests; the server always installs one.
3044        let salt = self.salt_fn.map_or_else(
3045            || {
3046                let mut s_bytes = [0u8; 16];
3047                let digest = spg_crypto::hash(s.name.as_bytes());
3048                s_bytes.copy_from_slice(&digest[..16]);
3049                s_bytes
3050            },
3051            |f| f(),
3052        );
3053        self.users
3054            .create(&s.name, &s.password, role, salt)
3055            .map_err(|e| EngineError::Unsupported(alloc::format!("CREATE USER: {e}")))?;
3056        Ok(QueryResult::CommandOk {
3057            affected: 1,
3058            modified_catalog: true,
3059        })
3060    }
3061
3062    fn exec_drop_user(&mut self, name: &str) -> Result<QueryResult, EngineError> {
3063        if self.in_transaction() {
3064            return Err(EngineError::Unsupported(
3065                "DROP USER is not allowed inside a transaction".into(),
3066            ));
3067        }
3068        self.users
3069            .drop(name)
3070            .map_err(|e| EngineError::Unsupported(alloc::format!("DROP USER: {e}")))?;
3071        Ok(QueryResult::CommandOk {
3072            affected: 1,
3073            modified_catalog: true,
3074        })
3075    }
3076
3077    /// v7.12.4 — `CREATE [OR REPLACE] FUNCTION`. Stores the
3078    /// function metadata in the catalog. PL/pgSQL bodies are
3079    /// already parsed by the SQL parser; we re-canonicalise the
3080    /// body to source text for storage (the executor re-parses
3081    /// it at trigger fire time — see the trigger fire path).
3082    fn exec_create_function(
3083        &mut self,
3084        s: spg_sql::ast::CreateFunctionStatement,
3085    ) -> Result<QueryResult, EngineError> {
3086        let args_repr = render_function_args(&s.args);
3087        let returns = match &s.returns {
3088            spg_sql::ast::FunctionReturn::Trigger => alloc::string::String::from("TRIGGER"),
3089            spg_sql::ast::FunctionReturn::Void => alloc::string::String::from("VOID"),
3090            spg_sql::ast::FunctionReturn::Type(t) => alloc::format!("{t}"),
3091            spg_sql::ast::FunctionReturn::Other(s) => s.clone(),
3092        };
3093        let body_text = match &s.body {
3094            spg_sql::ast::FunctionBody::PlPgSql(b) => alloc::format!("{b}"),
3095            spg_sql::ast::FunctionBody::Raw(s) => s.clone(),
3096        };
3097        let def = spg_storage::FunctionDef {
3098            name: s.name.clone(),
3099            args_repr,
3100            returns,
3101            language: s.language.clone(),
3102            body: body_text,
3103        };
3104        self.active_catalog_mut()
3105            .create_function(def, s.or_replace)
3106            .map_err(EngineError::Storage)?;
3107        Ok(QueryResult::CommandOk {
3108            affected: 0,
3109            modified_catalog: true,
3110        })
3111    }
3112
3113    /// v7.12.4 — `CREATE [OR REPLACE] TRIGGER`. The referenced
3114    /// function must already exist in the catalog (forward
3115    /// references defer to a later release). Persists the
3116    /// trigger metadata for the row-write hooks below to consult.
3117    fn exec_create_trigger(
3118        &mut self,
3119        s: spg_sql::ast::CreateTriggerStatement,
3120    ) -> Result<QueryResult, EngineError> {
3121        let timing = match s.timing {
3122            spg_sql::ast::TriggerTiming::Before => "BEFORE",
3123            spg_sql::ast::TriggerTiming::After => "AFTER",
3124            spg_sql::ast::TriggerTiming::InsteadOf => "INSTEAD OF",
3125        };
3126        let events: Vec<alloc::string::String> = s
3127            .events
3128            .iter()
3129            .map(|e| match e {
3130                spg_sql::ast::TriggerEvent::Insert => alloc::string::String::from("INSERT"),
3131                spg_sql::ast::TriggerEvent::Update => alloc::string::String::from("UPDATE"),
3132                spg_sql::ast::TriggerEvent::Delete => alloc::string::String::from("DELETE"),
3133                spg_sql::ast::TriggerEvent::Truncate => alloc::string::String::from("TRUNCATE"),
3134            })
3135            .collect();
3136        let for_each = match s.for_each {
3137            spg_sql::ast::TriggerForEach::Row => "ROW",
3138            spg_sql::ast::TriggerForEach::Statement => "STATEMENT",
3139        };
3140        let def = spg_storage::TriggerDef {
3141            name: s.name.clone(),
3142            table: s.table.clone(),
3143            timing: alloc::string::String::from(timing),
3144            events,
3145            for_each: alloc::string::String::from(for_each),
3146            function: s.function.clone(),
3147            update_columns: s.update_columns.clone(),
3148            // v7.16.1 — every trigger is born enabled. Toggled
3149            // by ALTER TABLE … { ENABLE | DISABLE } TRIGGER.
3150            enabled: true,
3151        };
3152        self.active_catalog_mut()
3153            .create_trigger(def, s.or_replace)
3154            .map_err(EngineError::Storage)?;
3155        Ok(QueryResult::CommandOk {
3156            affected: 0,
3157            modified_catalog: true,
3158        })
3159    }
3160
3161    fn exec_drop_trigger(
3162        &mut self,
3163        name: &str,
3164        table: &str,
3165        if_exists: bool,
3166    ) -> Result<QueryResult, EngineError> {
3167        let removed = self.active_catalog_mut().drop_trigger(name, table);
3168        if !removed && !if_exists {
3169            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3170                alloc::format!("trigger {name:?} on {table:?} does not exist"),
3171            )));
3172        }
3173        Ok(QueryResult::CommandOk {
3174            affected: usize::from(removed),
3175            modified_catalog: removed,
3176        })
3177    }
3178
3179    fn exec_drop_function(
3180        &mut self,
3181        name: &str,
3182        if_exists: bool,
3183    ) -> Result<QueryResult, EngineError> {
3184        let removed = self.active_catalog_mut().drop_function(name);
3185        if !removed && !if_exists {
3186            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3187                alloc::format!("function {name:?} does not exist"),
3188            )));
3189        }
3190        Ok(QueryResult::CommandOk {
3191            affected: usize::from(removed),
3192            modified_catalog: removed,
3193        })
3194    }
3195
3196    /// v7.17.0 — `CREATE SEQUENCE` engine path. Resolves
3197    /// `min_value` / `max_value` / `start` against PG defaults
3198    /// when omitted, then installs the SequenceDef in the catalog.
3199    fn exec_create_sequence(
3200        &mut self,
3201        s: spg_sql::ast::CreateSequenceStatement,
3202    ) -> Result<QueryResult, EngineError> {
3203        use spg_sql::ast::{SeqBound, SequenceDataType as AstDt};
3204        use spg_storage::{SequenceDataType, SequenceDef};
3205        let dt = match s.data_type {
3206            None => SequenceDataType::BigInt,
3207            Some(AstDt::SmallInt) => SequenceDataType::SmallInt,
3208            Some(AstDt::Int) => SequenceDataType::Int,
3209            Some(AstDt::BigInt) => SequenceDataType::BigInt,
3210        };
3211        let increment = s.options.increment.unwrap_or(1);
3212        if increment == 0 {
3213            return Err(EngineError::Unsupported(
3214                "INCREMENT must not be zero".into(),
3215            ));
3216        }
3217        let (def_min, def_max) = dt.default_bounds(increment > 0);
3218        let min_value = match s.options.min_value {
3219            None | Some(SeqBound::NoBound) => def_min,
3220            Some(SeqBound::Value(n)) => n,
3221        };
3222        let max_value = match s.options.max_value {
3223            None | Some(SeqBound::NoBound) => def_max,
3224            Some(SeqBound::Value(n)) => n,
3225        };
3226        if min_value > max_value {
3227            return Err(EngineError::Unsupported(alloc::format!(
3228                "MINVALUE ({min_value}) must be <= MAXVALUE ({max_value})"
3229            )));
3230        }
3231        let start = s
3232            .options
3233            .start
3234            .unwrap_or(if increment > 0 { min_value } else { max_value });
3235        if start < min_value || start > max_value {
3236            return Err(EngineError::Unsupported(alloc::format!(
3237                "START WITH ({start}) is outside MINVALUE..MAXVALUE ({min_value}..{max_value})"
3238            )));
3239        }
3240        let cache = s.options.cache.unwrap_or(1);
3241        if cache < 1 {
3242            return Err(EngineError::Unsupported("CACHE must be >= 1".into()));
3243        }
3244        let cycle = s.options.cycle.unwrap_or(false);
3245        let owned_by = match s.options.owned_by {
3246            None | Some(spg_sql::ast::SequenceOwnedBy::None) => None,
3247            Some(spg_sql::ast::SequenceOwnedBy::Column { table, column }) => Some((table, column)),
3248        };
3249        let def = SequenceDef {
3250            name: s.name.clone(),
3251            data_type: dt,
3252            start,
3253            increment,
3254            min_value,
3255            max_value,
3256            cache,
3257            cycle,
3258            owned_by,
3259            last_value: start,
3260            is_called: false,
3261        };
3262        self.active_catalog_mut()
3263            .create_sequence(def, s.if_not_exists)
3264            .map_err(EngineError::Storage)?;
3265        Ok(QueryResult::CommandOk {
3266            affected: 0,
3267            modified_catalog: !self.in_transaction(),
3268        })
3269    }
3270
3271    /// v7.17.0 — `ALTER SEQUENCE` engine path. Re-uses the catalog
3272    /// `alter_sequence` merge helper.
3273    fn exec_alter_sequence(
3274        &mut self,
3275        s: spg_sql::ast::AlterSequenceStatement,
3276    ) -> Result<QueryResult, EngineError> {
3277        use spg_sql::ast::SeqBound;
3278        let cat = self.active_catalog_mut();
3279        if !cat.sequences().contains_key(&s.name) {
3280            if s.if_exists {
3281                return Ok(QueryResult::CommandOk {
3282                    affected: 0,
3283                    modified_catalog: false,
3284                });
3285            }
3286            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3287                alloc::format!("sequence {:?} does not exist", s.name),
3288            )));
3289        }
3290        let min_value = match s.options.min_value {
3291            None => None,
3292            Some(SeqBound::NoBound) => None, // NO MINVALUE → keep current
3293            Some(SeqBound::Value(n)) => Some(n),
3294        };
3295        let max_value = match s.options.max_value {
3296            None => None,
3297            Some(SeqBound::NoBound) => None,
3298            Some(SeqBound::Value(n)) => Some(n),
3299        };
3300        let owned_by = s.options.owned_by.map(|ob| match ob {
3301            spg_sql::ast::SequenceOwnedBy::None => None,
3302            spg_sql::ast::SequenceOwnedBy::Column { table, column } => Some((table, column)),
3303        });
3304        cat.alter_sequence(
3305            &s.name,
3306            s.options.increment,
3307            min_value,
3308            max_value,
3309            s.options.start,
3310            s.options.restart,
3311            s.options.cache,
3312            s.options.cycle,
3313            owned_by,
3314        )
3315        .map_err(EngineError::Storage)?;
3316        Ok(QueryResult::CommandOk {
3317            affected: 0,
3318            modified_catalog: !self.in_transaction(),
3319        })
3320    }
3321
3322    /// v7.17.0 Phase 1.1 — walk a Statement tree and pre-resolve
3323    /// any sequence FunctionCall nodes inside its Expr slots.
3324    /// Delegates per-statement-kind: SELECT projection +
3325    /// WHERE, INSERT VALUES, UPDATE SET, DELETE WHERE.
3326    fn pre_resolve_sequence_calls_in_statement(
3327        &mut self,
3328        stmt: &mut Statement,
3329    ) -> Result<(), EngineError> {
3330        match stmt {
3331            Statement::Select(s) => self.pre_resolve_sequence_calls_in_select(s),
3332            Statement::Insert(s) => {
3333                for tuple in &mut s.rows {
3334                    for cell in tuple.iter_mut() {
3335                        self.resolve_sequence_calls_in_expr(cell)?;
3336                    }
3337                }
3338                Ok(())
3339            }
3340            Statement::Update(s) => {
3341                for (_col, expr) in &mut s.assignments {
3342                    self.resolve_sequence_calls_in_expr(expr)?;
3343                }
3344                if let Some(w) = &mut s.where_ {
3345                    self.resolve_sequence_calls_in_expr(w)?;
3346                }
3347                Ok(())
3348            }
3349            Statement::Delete(s) => {
3350                if let Some(w) = &mut s.where_ {
3351                    self.resolve_sequence_calls_in_expr(w)?;
3352                }
3353                Ok(())
3354            }
3355            _ => Ok(()),
3356        }
3357    }
3358
3359    fn pre_resolve_sequence_calls_in_select(
3360        &mut self,
3361        s: &mut spg_sql::ast::SelectStatement,
3362    ) -> Result<(), EngineError> {
3363        for item in &mut s.items {
3364            match item {
3365                spg_sql::ast::SelectItem::Expr { expr, .. } => {
3366                    self.resolve_sequence_calls_in_expr(expr)?;
3367                }
3368                spg_sql::ast::SelectItem::Wildcard => {}
3369            }
3370        }
3371        if let Some(w) = &mut s.where_ {
3372            self.resolve_sequence_calls_in_expr(w)?;
3373        }
3374        Ok(())
3375    }
3376
3377    /// v7.17.0 Phase 1.1 — walk an Expr tree and pre-resolve any
3378    /// `nextval(name)` / `currval(name)` / `setval(name, value[,
3379    /// is_called])` FunctionCall nodes by calling the catalog and
3380    /// replacing the node with the resulting `Expr::Literal`.
3381    /// Used by INSERT VALUES / UPDATE SET / DEFAULT eval so the
3382    /// row-eval path sees pre-computed sequence values instead of
3383    /// needing mutable catalog access mid-eval.
3384    #[allow(clippy::too_many_lines)]
3385    fn resolve_sequence_calls_in_expr(&mut self, expr: &mut Expr) -> Result<(), EngineError> {
3386        match expr {
3387            Expr::Literal(_) | Expr::Column(_) | Expr::Placeholder(_) => Ok(()),
3388            Expr::FunctionCall { name, args } => {
3389                // Descend first so nested calls — e.g.
3390                // setval('seq', currval('other')) — resolve
3391                // innermost-first.
3392                for a in args.iter_mut() {
3393                    self.resolve_sequence_calls_in_expr(a)?;
3394                }
3395                let lc = name.to_ascii_lowercase();
3396                if lc == "nextval" || lc == "currval" || lc == "setval" {
3397                    let v = self.eval_sequence_call(&lc, args)?;
3398                    *expr = Expr::Literal(value_to_literal(v));
3399                }
3400                Ok(())
3401            }
3402            Expr::Binary { lhs, rhs, .. } => {
3403                self.resolve_sequence_calls_in_expr(lhs)?;
3404                self.resolve_sequence_calls_in_expr(rhs)
3405            }
3406            Expr::Unary { expr, .. } => self.resolve_sequence_calls_in_expr(expr),
3407            Expr::Cast { expr, .. } => self.resolve_sequence_calls_in_expr(expr),
3408            Expr::IsNull { expr, .. } => self.resolve_sequence_calls_in_expr(expr),
3409            Expr::Like { expr, pattern, .. } => {
3410                self.resolve_sequence_calls_in_expr(expr)?;
3411                self.resolve_sequence_calls_in_expr(pattern)
3412            }
3413            Expr::Extract { source, .. } => self.resolve_sequence_calls_in_expr(source),
3414            Expr::Array(items) => {
3415                for it in items.iter_mut() {
3416                    self.resolve_sequence_calls_in_expr(it)?;
3417                }
3418                Ok(())
3419            }
3420            // Window / subquery / etc — sequence calls inside these
3421            // are uncommon and require separate row-eval; leave
3422            // untouched for now and rely on the eval-time error
3423            // (no sequence_resolver attached).
3424            _ => Ok(()),
3425        }
3426    }
3427
3428    /// v7.17.0 Phase 1.1 — evaluate a single nextval/currval/
3429    /// setval call. `args` are already pre-resolved Expr nodes
3430    /// (literals) — we extract their constant values.
3431    fn eval_sequence_call(&mut self, op: &str, args: &[Expr]) -> Result<Value, EngineError> {
3432        if args.is_empty() {
3433            return Err(EngineError::Unsupported(alloc::format!(
3434                "{op}() takes at least one argument"
3435            )));
3436        }
3437        let seq_name = match &args[0] {
3438            Expr::Literal(spg_sql::ast::Literal::String(s)) => {
3439                // v7.17 dump-compat — pg_dump emits sequence
3440                // names schema-qualified (`'public.posts_id_seq'`).
3441                // SPG is single-schema; strip a leading
3442                // `public.` / `pg_catalog.` so the catalog lookup
3443                // matches the bare-name CREATE SEQUENCE used.
3444                let trimmed = s
3445                    .strip_prefix("public.")
3446                    .or_else(|| s.strip_prefix("pg_catalog."))
3447                    .unwrap_or(s);
3448                trimmed.to_string()
3449            }
3450            // v7.17 dump-compat — pg_dump also emits
3451            // `nextval('public.posts_id_seq'::regclass)`
3452            // where the cast wraps the literal. Peel the cast
3453            // and continue.
3454            Expr::Cast { expr, .. } => {
3455                if let Expr::Literal(spg_sql::ast::Literal::String(s)) = expr.as_ref() {
3456                    let trimmed = s
3457                        .strip_prefix("public.")
3458                        .or_else(|| s.strip_prefix("pg_catalog."))
3459                        .unwrap_or(s);
3460                    trimmed.to_string()
3461                } else {
3462                    return Err(EngineError::Unsupported(alloc::format!(
3463                        "{op}() first argument must be a literal sequence name"
3464                    )));
3465                }
3466            }
3467            other => {
3468                return Err(EngineError::Unsupported(alloc::format!(
3469                    "{op}() first argument must be a literal sequence name, got {other:?}"
3470                )));
3471            }
3472        };
3473        match op {
3474            "nextval" => {
3475                let v = self
3476                    .active_catalog_mut()
3477                    .sequence_next_value(&seq_name)
3478                    .map_err(EngineError::Storage)?;
3479                Ok(Value::BigInt(v))
3480            }
3481            "currval" => {
3482                let v = self
3483                    .active_catalog()
3484                    .sequence_current_value(&seq_name)
3485                    .map_err(EngineError::Storage)?;
3486                Ok(Value::BigInt(v))
3487            }
3488            "setval" => {
3489                if args.len() < 2 || args.len() > 3 {
3490                    return Err(EngineError::Unsupported(alloc::format!(
3491                        "setval() takes 2 or 3 arguments, got {}",
3492                        args.len()
3493                    )));
3494                }
3495                let value = match &args[1] {
3496                    Expr::Literal(spg_sql::ast::Literal::Integer(n)) => *n,
3497                    other => {
3498                        return Err(EngineError::Unsupported(alloc::format!(
3499                            "setval() value argument must be a literal integer, got {other:?}"
3500                        )));
3501                    }
3502                };
3503                let is_called = if args.len() == 3 {
3504                    match &args[2] {
3505                        Expr::Literal(spg_sql::ast::Literal::Bool(b)) => *b,
3506                        other => {
3507                            return Err(EngineError::Unsupported(alloc::format!(
3508                                "setval() is_called argument must be a literal BOOL, got {other:?}"
3509                            )));
3510                        }
3511                    }
3512                } else {
3513                    true
3514                };
3515                let v = self
3516                    .active_catalog_mut()
3517                    .sequence_set_value(&seq_name, value, is_called)
3518                    .map_err(EngineError::Storage)?;
3519                Ok(Value::BigInt(v))
3520            }
3521            other => Err(EngineError::Unsupported(alloc::format!(
3522                "unknown sequence op {other:?}"
3523            ))),
3524        }
3525    }
3526
3527    /// v7.17.0 Phase 1.2 — find every catalog VIEW referenced in
3528    /// the SELECT's FROM / JOIN graph, re-parse each view's body
3529    /// source, and prepend it as a synthetic CTE on the
3530    /// returned SelectStatement. Returns `None` when no view
3531    /// references are found (caller proceeds with the original
3532    /// statement); returns `Some(rewritten)` otherwise (caller
3533    /// re-runs exec_select_cancel on the rewritten form so the
3534    /// regular CTE materialiser handles it).
3535    fn expand_views_in_select(
3536        &self,
3537        stmt: &SelectStatement,
3538    ) -> Result<Option<SelectStatement>, EngineError> {
3539        let cat = self.active_catalog();
3540        let mut referenced: Vec<String> = Vec::new();
3541        if let Some(from) = &stmt.from {
3542            collect_view_refs(&from.primary, cat, &mut referenced);
3543            for j in &from.joins {
3544                collect_view_refs(&j.table, cat, &mut referenced);
3545            }
3546        }
3547        // Don't expand a view name that's already shadowed by a
3548        // CTE on the same SELECT — the CTE wins per PG.
3549        referenced.retain(|n| !stmt.ctes.iter().any(|c| c.name == *n));
3550        if referenced.is_empty() {
3551            return Ok(None);
3552        }
3553        let mut new_ctes: Vec<spg_sql::ast::Cte> = Vec::with_capacity(referenced.len());
3554        for name in &referenced {
3555            let view = cat.views().get(name).ok_or_else(|| {
3556                EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
3557                    "view {name:?} disappeared mid-expansion"
3558                )))
3559            })?;
3560            let parsed = spg_sql::parser::parse_statement(&view.body).map_err(|e| {
3561                EngineError::Unsupported(alloc::format!("view {name:?} body re-parse failed: {e}"))
3562            })?;
3563            let Statement::Select(body) = parsed else {
3564                return Err(EngineError::Unsupported(alloc::format!(
3565                    "view {name:?} body is not a SELECT (catalog corruption)"
3566                )));
3567            };
3568            new_ctes.push(spg_sql::ast::Cte {
3569                name: name.clone(),
3570                body,
3571                recursive: false,
3572                column_overrides: view.columns.clone(),
3573            });
3574        }
3575        let mut out = stmt.clone();
3576        // Prepend so view CTEs are visible to caller-supplied CTEs.
3577        new_ctes.extend(out.ctes);
3578        out.ctes = new_ctes;
3579        Ok(Some(out))
3580    }
3581
3582    /// v7.17.0 Phase 1.2 — `CREATE VIEW` engine path. Stores the
3583    /// Display-rendered body verbatim in the catalog; SELECT-from-
3584    /// view at exec time re-parses + prepends as a synthetic CTE.
3585    fn exec_create_view(
3586        &mut self,
3587        s: spg_sql::ast::CreateViewStatement,
3588    ) -> Result<QueryResult, EngineError> {
3589        // Render the SELECT body to canonical form so the catalog
3590        // round-trips a deterministic source (no whitespace /
3591        // comment surprises in the on-disk snapshot).
3592        let body_repr = alloc::format!("{}", spg_sql::ast::Statement::Select(s.body));
3593        let def = spg_storage::ViewDef {
3594            name: s.name.clone(),
3595            columns: s.columns,
3596            body: body_repr,
3597        };
3598        self.active_catalog_mut()
3599            .create_view(def, s.or_replace, s.if_not_exists)
3600            .map_err(EngineError::Storage)?;
3601        Ok(QueryResult::CommandOk {
3602            affected: 0,
3603            modified_catalog: !self.in_transaction(),
3604        })
3605    }
3606
3607    /// v7.17.0 Phase 1.4 — `CREATE TYPE name AS ENUM (…)` engine
3608    /// path. Registers the enum in the catalog with order-
3609    /// preserving labels. PG semantics: CREATE TYPE errors if the
3610    /// name is taken (no IF NOT EXISTS).
3611    fn exec_create_type(
3612        &mut self,
3613        s: spg_sql::ast::CreateTypeStatement,
3614    ) -> Result<QueryResult, EngineError> {
3615        // Name-collision check against tables / sequences / views /
3616        // materialized views.
3617        let cat = self.active_catalog();
3618        if cat.get(&s.name).is_some() {
3619            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3620                alloc::format!("type {:?} would shadow an existing table", s.name),
3621            )));
3622        }
3623        if cat.sequences().contains_key(&s.name) {
3624            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3625                alloc::format!("type {:?} would shadow an existing sequence", s.name),
3626            )));
3627        }
3628        if cat.views().contains_key(&s.name) {
3629            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3630                alloc::format!("type {:?} would shadow an existing view", s.name),
3631            )));
3632        }
3633        let def = match s.kind {
3634            spg_sql::ast::TypeKind::Enum { labels } => {
3635                if labels.is_empty() {
3636                    return Err(EngineError::Unsupported(
3637                        "CREATE TYPE … AS ENUM requires at least one label".into(),
3638                    ));
3639                }
3640                // Reject duplicate labels per PG.
3641                for i in 0..labels.len() {
3642                    for j in (i + 1)..labels.len() {
3643                        if labels[i] == labels[j] {
3644                            return Err(EngineError::Unsupported(alloc::format!(
3645                                "CREATE TYPE {:?}: duplicate ENUM label {:?}",
3646                                s.name,
3647                                labels[i]
3648                            )));
3649                        }
3650                    }
3651                }
3652                spg_storage::EnumDef {
3653                    name: s.name.clone(),
3654                    labels,
3655                }
3656            }
3657        };
3658        self.active_catalog_mut()
3659            .create_enum_type(def)
3660            .map_err(EngineError::Storage)?;
3661        Ok(QueryResult::CommandOk {
3662            affected: 0,
3663            modified_catalog: !self.in_transaction(),
3664        })
3665    }
3666
3667    /// v7.17.0 Phase 1.5 — `CREATE DOMAIN name AS base [DEFAULT
3668    /// expr] [NOT NULL] [CHECK (expr)]*` engine path. Stores the
3669    /// base type + Display-rendered CHECK / DEFAULT sources so
3670    /// INSERT/UPDATE on bound columns can re-eval the checks.
3671    fn exec_create_domain(
3672        &mut self,
3673        s: spg_sql::ast::CreateDomainStatement,
3674    ) -> Result<QueryResult, EngineError> {
3675        let cat = self.active_catalog();
3676        if cat.domain_types().contains_key(&s.name) {
3677            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3678                alloc::format!("domain {:?} already exists", s.name),
3679            )));
3680        }
3681        if cat.get(&s.name).is_some()
3682            || cat.sequences().contains_key(&s.name)
3683            || cat.views().contains_key(&s.name)
3684            || cat.enum_types().contains_key(&s.name)
3685        {
3686            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3687                alloc::format!("domain {:?} would shadow an existing object", s.name),
3688            )));
3689        }
3690        let base_type = column_type_to_data_type(s.base_type);
3691        let default = s.default.as_ref().map(|e| alloc::format!("{e}"));
3692        let checks = s
3693            .checks
3694            .iter()
3695            .map(|e| alloc::format!("{e}"))
3696            .collect::<Vec<_>>();
3697        let def = spg_storage::DomainDef {
3698            name: s.name.clone(),
3699            base_type,
3700            nullable: !s.not_null,
3701            default,
3702            checks,
3703        };
3704        self.active_catalog_mut()
3705            .create_domain_type(def)
3706            .map_err(EngineError::Storage)?;
3707        Ok(QueryResult::CommandOk {
3708            affected: 0,
3709            modified_catalog: !self.in_transaction(),
3710        })
3711    }
3712
3713    /// v7.17.0 Phase 1.5 — `DROP DOMAIN [IF EXISTS] names`.
3714    fn exec_drop_domain(
3715        &mut self,
3716        names: &[String],
3717        if_exists: bool,
3718    ) -> Result<QueryResult, EngineError> {
3719        let mut removed = 0usize;
3720        for name in names {
3721            let was_present = self.active_catalog_mut().drop_domain_type(name);
3722            if was_present {
3723                removed += 1;
3724            } else if !if_exists {
3725                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3726                    alloc::format!("domain {name:?} does not exist"),
3727                )));
3728            }
3729        }
3730        Ok(QueryResult::CommandOk {
3731            affected: removed,
3732            modified_catalog: removed > 0 && !self.in_transaction(),
3733        })
3734    }
3735
3736    /// v7.17.0 Phase 1.6 — `CREATE SCHEMA [IF NOT EXISTS] name`.
3737    /// Registers the schema in the catalog. Schema-qualified
3738    /// table references continue to strip the prefix at lookup
3739    /// time (prefix routing, not isolation — see project-next-
3740    /// docket for the v7.18+ real-isolation tracking).
3741    fn exec_create_schema(
3742        &mut self,
3743        name: String,
3744        if_not_exists: bool,
3745    ) -> Result<QueryResult, EngineError> {
3746        self.active_catalog_mut()
3747            .create_schema(name, if_not_exists)
3748            .map_err(EngineError::Storage)?;
3749        Ok(QueryResult::CommandOk {
3750            affected: 0,
3751            modified_catalog: !self.in_transaction(),
3752        })
3753    }
3754
3755    /// v7.17.0 Phase 1.6 — `DROP SCHEMA [IF EXISTS] names`.
3756    /// Built-in schemas always reject the drop with a clear
3757    /// error.
3758    fn exec_drop_schema(
3759        &mut self,
3760        names: &[String],
3761        if_exists: bool,
3762    ) -> Result<QueryResult, EngineError> {
3763        let mut removed = 0usize;
3764        for name in names {
3765            let was_present = self
3766                .active_catalog_mut()
3767                .drop_schema(name)
3768                .map_err(EngineError::Storage)?;
3769            if was_present {
3770                removed += 1;
3771            } else if !if_exists {
3772                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3773                    alloc::format!("schema {name:?} does not exist"),
3774                )));
3775            }
3776        }
3777        Ok(QueryResult::CommandOk {
3778            affected: removed,
3779            modified_catalog: removed > 0 && !self.in_transaction(),
3780        })
3781    }
3782
3783    /// v7.17.0 Phase 1.4 — `DROP TYPE [IF EXISTS] names`. Only
3784    /// ENUM types are catalogued today; other types silently
3785    /// no-op even outside IF EXISTS to mirror the prior
3786    /// "everything's text" lax stance.
3787    fn exec_drop_type(
3788        &mut self,
3789        names: &[String],
3790        if_exists: bool,
3791    ) -> Result<QueryResult, EngineError> {
3792        let mut removed = 0usize;
3793        for name in names {
3794            let was_present = self.active_catalog_mut().drop_enum_type(name);
3795            if was_present {
3796                removed += 1;
3797            } else if !if_exists {
3798                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3799                    alloc::format!("type {name:?} does not exist"),
3800                )));
3801            }
3802        }
3803        Ok(QueryResult::CommandOk {
3804            affected: removed,
3805            modified_catalog: removed > 0 && !self.in_transaction(),
3806        })
3807    }
3808
3809    /// v7.17.0 Phase 1.3 — `CREATE MATERIALIZED VIEW` engine path.
3810    /// Materialises the body at CREATE time (unless WITH NO DATA),
3811    /// stores the result as a regular `Table`, and registers the
3812    /// body source in the catalog so REFRESH can re-run it.
3813    fn exec_create_materialized_view(
3814        &mut self,
3815        s: spg_sql::ast::CreateMaterializedViewStatement,
3816    ) -> Result<QueryResult, EngineError> {
3817        // Name-collision check (table / view / sequence / mat-view).
3818        let cat = self.active_catalog();
3819        if cat.materialized_views().contains_key(&s.name) || cat.get(&s.name).is_some() {
3820            if s.if_not_exists {
3821                return Ok(QueryResult::CommandOk {
3822                    affected: 0,
3823                    modified_catalog: false,
3824                });
3825            }
3826            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3827                alloc::format!("materialized view {:?} already exists", s.name),
3828            )));
3829        }
3830        if cat.views().contains_key(&s.name) {
3831            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3832                alloc::format!(
3833                    "materialized view {:?} would shadow an existing view",
3834                    s.name
3835                ),
3836            )));
3837        }
3838        if cat.sequences().contains_key(&s.name) {
3839            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3840                alloc::format!(
3841                    "materialized view {:?} would shadow an existing sequence",
3842                    s.name
3843                ),
3844            )));
3845        }
3846        // Render the body to canonical form for the registry.
3847        let body_repr = alloc::format!("{}", spg_sql::ast::Statement::Select(s.body.clone()));
3848        // Execute the body to learn the columns. With WITH DATA we
3849        // also materialise the rows; with WITH NO DATA we only need
3850        // the schema, so re-use a LIMIT 0 wrap to keep the column
3851        // inference path uniform without paying for the rows.
3852        let result = self.exec_select_cancel(&s.body, CancelToken::none())?;
3853        let (mut cols, rows) = match result {
3854            QueryResult::Rows { columns, rows } => (columns, rows),
3855            other => {
3856                return Err(EngineError::Unsupported(alloc::format!(
3857                    "CREATE MATERIALIZED VIEW body did not return rows: {other:?}"
3858                )));
3859            }
3860        };
3861        // Apply the column-rename list per PG semantics.
3862        if !s.columns.is_empty() {
3863            if s.columns.len() != cols.len() {
3864                return Err(EngineError::Unsupported(alloc::format!(
3865                    "CREATE MATERIALIZED VIEW {:?}: column list has {} names but body returns {}",
3866                    s.name,
3867                    s.columns.len(),
3868                    cols.len()
3869                )));
3870            }
3871            for (c, name) in cols.iter_mut().zip(s.columns.iter()) {
3872                c.name.clone_from(name);
3873            }
3874        }
3875        // Promote any synthetic-Text projections to their actual
3876        // observed types so the backing table accepts the rows.
3877        cols = infer_column_types(&cols, &rows);
3878        let schema = spg_storage::TableSchema::new(s.name.clone(), cols);
3879        let cat = self.active_catalog_mut();
3880        cat.create_table(schema).map_err(EngineError::Storage)?;
3881        if s.with_data {
3882            let table = cat
3883                .get_mut(&s.name)
3884                .expect("just-created materialized-view backing table must exist");
3885            for row in rows {
3886                table.insert(row).map_err(EngineError::Storage)?;
3887            }
3888        }
3889        cat.register_materialized_view(s.name.clone(), body_repr);
3890        Ok(QueryResult::CommandOk {
3891            affected: 0,
3892            modified_catalog: !self.in_transaction(),
3893        })
3894    }
3895
3896    /// v7.17.0 Phase 1.3 — `REFRESH MATERIALIZED VIEW name [WITH
3897    /// [NO] DATA]`. Looks up the source, re-runs it, replaces the
3898    /// backing table's rows.
3899    fn exec_refresh_materialized_view(
3900        &mut self,
3901        name: &str,
3902        with_data: bool,
3903    ) -> Result<QueryResult, EngineError> {
3904        let source = self
3905            .active_catalog()
3906            .materialized_views()
3907            .get(name)
3908            .cloned()
3909            .ok_or_else(|| {
3910                EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
3911                    "materialized view {name:?} does not exist"
3912                )))
3913            })?;
3914        // Wipe the existing rows first (PG truncates the matview
3915        // and rebuilds; we approximate with an empty INSERT loop).
3916        {
3917            let cat = self.active_catalog_mut();
3918            let table = cat.get_mut(name).ok_or_else(|| {
3919                EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
3920                    "materialized view {name:?} backing table missing"
3921                )))
3922            })?;
3923            table.truncate();
3924        }
3925        if !with_data {
3926            return Ok(QueryResult::CommandOk {
3927                affected: 0,
3928                modified_catalog: !self.in_transaction(),
3929            });
3930        }
3931        let parsed = spg_sql::parser::parse_statement(&source).map_err(|e| {
3932            EngineError::Unsupported(alloc::format!(
3933                "materialized view {name:?} body re-parse failed: {e}"
3934            ))
3935        })?;
3936        let Statement::Select(body) = parsed else {
3937            return Err(EngineError::Unsupported(alloc::format!(
3938                "materialized view {name:?} body is not a SELECT (catalog corruption)"
3939            )));
3940        };
3941        let rows = match self.exec_select_cancel(&body, CancelToken::none())? {
3942            QueryResult::Rows { rows, .. } => rows,
3943            other => {
3944                return Err(EngineError::Unsupported(alloc::format!(
3945                    "REFRESH MATERIALIZED VIEW {name:?} body did not return rows: {other:?}"
3946                )));
3947            }
3948        };
3949        let cat = self.active_catalog_mut();
3950        let table = cat.get_mut(name).expect("backing table verified above");
3951        let affected = rows.len();
3952        for row in rows {
3953            table.insert(row).map_err(EngineError::Storage)?;
3954        }
3955        Ok(QueryResult::CommandOk {
3956            affected,
3957            modified_catalog: !self.in_transaction(),
3958        })
3959    }
3960
3961    /// v7.17.0 Phase 1.3 — `DROP MATERIALIZED VIEW [IF EXISTS]
3962    /// names`. Drops the backing table + unregisters the source.
3963    fn exec_drop_materialized_view(
3964        &mut self,
3965        names: &[String],
3966        if_exists: bool,
3967    ) -> Result<QueryResult, EngineError> {
3968        let mut removed = 0usize;
3969        for name in names {
3970            let was_present = self
3971                .active_catalog_mut()
3972                .drop_materialized_view_source(name);
3973            if was_present {
3974                // Drop the backing table too.
3975                self.active_catalog_mut().drop_table(name);
3976                removed += 1;
3977            } else if !if_exists {
3978                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3979                    alloc::format!("materialized view {name:?} does not exist"),
3980                )));
3981            }
3982        }
3983        Ok(QueryResult::CommandOk {
3984            affected: removed,
3985            modified_catalog: removed > 0 && !self.in_transaction(),
3986        })
3987    }
3988
3989    /// v7.17.0 Phase 1.2 — `DROP VIEW [IF EXISTS] name [, name…]`.
3990    fn exec_drop_view(
3991        &mut self,
3992        names: &[String],
3993        if_exists: bool,
3994    ) -> Result<QueryResult, EngineError> {
3995        let mut removed = 0usize;
3996        for name in names {
3997            let was_present = self.active_catalog_mut().drop_view(name);
3998            if !was_present && !if_exists {
3999                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
4000                    alloc::format!("view {name:?} does not exist"),
4001                )));
4002            }
4003            if was_present {
4004                removed += 1;
4005            }
4006        }
4007        Ok(QueryResult::CommandOk {
4008            affected: removed,
4009            modified_catalog: removed > 0 && !self.in_transaction(),
4010        })
4011    }
4012
4013    /// v7.17.0 — `DROP SEQUENCE [IF EXISTS] name [, name…]`.
4014    fn exec_drop_sequence(
4015        &mut self,
4016        names: &[String],
4017        if_exists: bool,
4018    ) -> Result<QueryResult, EngineError> {
4019        let mut removed = 0usize;
4020        for name in names {
4021            let was_present = self.active_catalog_mut().drop_sequence(name);
4022            if !was_present && !if_exists {
4023                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
4024                    alloc::format!("sequence {name:?} does not exist"),
4025                )));
4026            }
4027            if was_present {
4028                removed += 1;
4029            }
4030        }
4031        Ok(QueryResult::CommandOk {
4032            affected: removed,
4033            modified_catalog: removed > 0 && !self.in_transaction(),
4034        })
4035    }
4036
4037    /// v4.4 `UPDATE <table> SET col = expr [, ...] [WHERE cond]`.
4038    /// Filter pass uses the same WHERE eval as `exec_select`. Per
4039    /// matched row, evaluate each RHS expression against the *old*
4040    /// row, then call `Table::update_row` which rebuilds indices.
4041    /// Indexed columns are correctly reflected because rebuild
4042    /// happens after the cell rewrite.
4043    fn exec_update_cancel(
4044        &mut self,
4045        stmt: &spg_sql::ast::UpdateStatement,
4046        cancel: CancelToken<'_>,
4047    ) -> Result<QueryResult, EngineError> {
4048        // v7.12.5 — snapshot BEFORE/AFTER UPDATE row triggers + the
4049        // session FTS config before the table mut-borrow opens (the
4050        // INSERT path uses the same pattern). Empty vecs are the
4051        // common "no triggers on this table" fast path.
4052        // v7.13.0 — UPDATE triggers carry an optional `UPDATE OF
4053        // cols` filter. The filter is paired with each function so
4054        // the per-row fire loop can skip when no listed column
4055        // actually differs between OLD and NEW.
4056        let before_update_triggers = self.snapshot_update_row_triggers(&stmt.table, "BEFORE");
4057        let after_update_triggers = self.snapshot_update_row_triggers(&stmt.table, "AFTER");
4058        let trigger_session_cfg: Option<String> = self
4059            .session_params
4060            .get("default_text_search_config")
4061            .cloned();
4062        // v5.2.3: if the WHERE is a PK equality and matches a cold-
4063        // tier row, promote it back to the hot tier *before* the
4064        // hot-row walk. The promote pushes the row to the end of
4065        // `table.rows`, where the upcoming SET-evaluation loop will
4066        // pick it up and apply the assignments. Lookups for the key
4067        // never observe a gap because `promote_cold_row` inserts the
4068        // hot row before retiring the cold locator.
4069        if let Some(w) = &stmt.where_ {
4070            let schema_cols = self
4071                .active_catalog()
4072                .get(&stmt.table)
4073                .ok_or_else(|| {
4074                    EngineError::Storage(StorageError::TableNotFound {
4075                        name: stmt.table.clone(),
4076                    })
4077                })?
4078                .schema()
4079                .columns
4080                .clone();
4081            if let Some((col_pos, key)) = try_pk_predicate(w, &schema_cols, stmt.table.as_str())
4082                && let Some(idx_name) = self
4083                    .active_catalog()
4084                    .get(&stmt.table)
4085                    .and_then(|t| t.index_on(col_pos).map(|i| i.name.clone()))
4086            {
4087                // Promote may be a no-op (key is hot-only or absent);
4088                // we don't care about the return value here — the
4089                // subsequent hot walk will either match or not.
4090                let _ = self
4091                    .active_catalog_mut()
4092                    .promote_cold_row(&stmt.table, &idx_name, &key);
4093            }
4094        }
4095
4096        // v7.12.1 — cache session FTS config before the table
4097        // mut-borrow (same reason as exec_delete).
4098        let ts_cfg: Option<String> = self
4099            .session_param("default_text_search_config")
4100            .map(String::from);
4101        // v7.17.0 Phase 2.1 — snapshot the clock pointer before
4102        // we hold the catalog mutably so ON UPDATE runtime
4103        // overrides see the engine wall clock.
4104        let clock_for_on_update = self.clock;
4105        let table = self
4106            .active_catalog_mut()
4107            .get_mut(&stmt.table)
4108            .ok_or_else(|| {
4109                EngineError::Storage(StorageError::TableNotFound {
4110                    name: stmt.table.clone(),
4111                })
4112            })?;
4113        let schema_cols: Vec<ColumnSchema> = table.schema().columns.clone();
4114        // Resolve each SET target to a column position once, validate
4115        // up front so a typo'd column doesn't leave a partial mutation
4116        // behind.
4117        let mut targets: Vec<(usize, &Expr)> = Vec::with_capacity(stmt.assignments.len());
4118        for (col, expr) in &stmt.assignments {
4119            let pos = schema_cols
4120                .iter()
4121                .position(|c| c.name == *col)
4122                .ok_or_else(|| {
4123                    EngineError::Eval(EvalError::ColumnNotFound { name: col.clone() })
4124                })?;
4125            targets.push((pos, expr));
4126        }
4127        // v7.17.0 Phase 2.1 — for every column with an
4128        // `ON UPDATE CURRENT_TIMESTAMP` binding that the caller
4129        // did NOT explicitly set, schedule an automatic override.
4130        // Reuses `eval_runtime_default_free` so the same
4131        // canonical runtime-expression whitelist (now /
4132        // current_timestamp / current_date / …) governs both
4133        // DEFAULT and ON UPDATE.
4134        let mut on_update_overrides: Vec<(usize, String)> = Vec::new();
4135        for (i, col) in schema_cols.iter().enumerate() {
4136            if targets.iter().any(|(p, _)| *p == i) {
4137                continue;
4138            }
4139            if let Some(src) = &col.on_update_runtime {
4140                on_update_overrides.push((i, src.clone()));
4141            }
4142        }
4143        let ctx = EvalContext::new(&schema_cols, Some(stmt.table.as_str()))
4144            .with_default_text_search_config(ts_cfg.as_deref());
4145        // Walk candidate rows, evaluate WHERE then SET
4146        // expressions. We gather (position, new_values) tuples
4147        // first and apply them afterwards so the WHERE/RHS
4148        // evaluation reads the original row state — matches PG
4149        // semantics (UPDATE doesn't see its own writes).
4150        //
4151        // v7.20 P4 — index seek: a single-column equality WHERE
4152        // on an indexed column narrows the walk from
4153        // O(table.rows()) to O(matches). The full WHERE still
4154        // re-evaluates per candidate (the seek may be an
4155        // over-approximation under AND-composites), so semantics
4156        // are unchanged. profile: the bench's `UPDATE … WHERE
4157        // id = $1` on a 5 000-row table was a ~1.3 ms full scan
4158        // per statement; with the seek it's ~2 µs.
4159        let seek_positions: Option<Vec<usize>> = stmt
4160            .where_
4161            .as_ref()
4162            .and_then(|w| try_index_seek_positions(w, &schema_cols, table, stmt.table.as_str()));
4163        let mut planned: Vec<(usize, Vec<Value>)> = Vec::new();
4164        let candidate_positions: Vec<usize> = match &seek_positions {
4165            Some(list) => list.clone(),
4166            None => (0..table.row_count()).collect(),
4167        };
4168        for (loop_n, &i) in candidate_positions.iter().enumerate() {
4169            // v4.5: cooperative cancel checkpoint every 256 rows so
4170            // a runaway UPDATE without WHERE doesn't drag past the
4171            // server's query-timeout watchdog.
4172            if loop_n.is_multiple_of(256) {
4173                cancel.check()?;
4174            }
4175            let Some(row) = table.rows().get(i) else {
4176                continue;
4177            };
4178            if let Some(w) = &stmt.where_ {
4179                let cond = eval::eval_expr(w, row, &ctx)?;
4180                if !matches!(cond, Value::Bool(true)) {
4181                    continue;
4182                }
4183            }
4184            let mut new_vals = row.values.clone();
4185            for (pos, expr) in &targets {
4186                let v = eval::eval_expr(expr, row, &ctx)?;
4187                let coerced = coerce_value(v, schema_cols[*pos].ty, &schema_cols[*pos].name, *pos)?;
4188                check_unsigned_range(&coerced, &schema_cols[*pos], *pos)?;
4189                new_vals[*pos] = coerced;
4190            }
4191            // v7.17.0 Phase 2.1 — apply ON UPDATE overrides for
4192            // any column the SET clause didn't touch.
4193            for (pos, src) in &on_update_overrides {
4194                let v = eval_runtime_default_free(src, schema_cols[*pos].ty, clock_for_on_update)?;
4195                new_vals[*pos] = v;
4196            }
4197            planned.push((i, new_vals));
4198        }
4199        // planned must stay position-sorted: downstream passes
4200        // (FK pairing, trigger walks, the apply loop) iterate it
4201        // assuming ascending row order, which the full-scan path
4202        // guaranteed implicitly.
4203        planned.sort_by_key(|(i, _)| *i);
4204        // v7.6.6 — capture pre-update row values for the FK
4205        // enforcement passes below. `planned` carries new values
4206        // only; pair them with the old row.
4207        let plan_with_old: Vec<(usize, Vec<Value>, Vec<Value>)> = planned
4208            .iter()
4209            .map(|(pos, new_vals)| (*pos, table.rows()[*pos].values.clone(), new_vals.clone()))
4210            .collect();
4211        let self_fks = table.schema().foreign_keys.clone();
4212        // v7.12.5 — `affected` is computed post-BEFORE-trigger
4213        // below (triggers may RETURN NULL to skip individual
4214        // rows). The pre-trigger len shape is no longer accurate.
4215        // Release mutable borrow on `table` for the FK passes.
4216        let _ = table;
4217        // v7.6.6 — Stage 2a: outbound FK check. For every row whose
4218        // local FK columns changed, the new value must exist in the
4219        // parent.
4220        if !self_fks.is_empty() {
4221            let new_rows: Vec<Vec<Value>> = planned
4222                .iter()
4223                .map(|(_pos, new_vals)| new_vals.clone())
4224                .collect();
4225            enforce_fk_inserts(self.active_catalog(), &stmt.table, &self_fks, &new_rows)?;
4226        }
4227        // v7.13.0 — CHECK constraint enforcement on UPDATE
4228        // (mailrs round-5 G3). Predicates evaluated against the
4229        // candidate post-UPDATE row; false rejects the UPDATE.
4230        {
4231            let new_rows: Vec<Vec<Value>> = planned
4232                .iter()
4233                .map(|(_pos, new_vals)| new_vals.clone())
4234                .collect();
4235            enforce_check_constraints(self.active_catalog(), &stmt.table, &new_rows)?;
4236        }
4237        // v7.6.6 — Stage 2b: inbound FK check. For every row that
4238        // changed value in a column that *some other table* uses as
4239        // a FK parent column, react per `on_update` action.
4240        let child_plan =
4241            plan_fk_parent_updates(self.active_catalog(), &stmt.table, &plan_with_old)?;
4242        // Stage 3a — apply each child-side action.
4243        for step in &child_plan {
4244            apply_fk_child_step(self.active_catalog_mut(), step)?;
4245        }
4246        // Stage 3b — apply the original UPDATE.
4247        let table = self
4248            .active_catalog_mut()
4249            .get_mut(&stmt.table)
4250            .ok_or_else(|| {
4251                EngineError::Storage(StorageError::TableNotFound {
4252                    name: stmt.table.clone(),
4253                })
4254            })?;
4255        // v7.12.5 — fire BEFORE/AFTER UPDATE row-level triggers
4256        // around the apply loop. BEFORE sees NEW=candidate +
4257        // OLD=current; may rewrite NEW or RETURN NULL to skip.
4258        // AFTER sees NEW=post-write + OLD=pre-write (both read-
4259        // only).
4260        //
4261        // Filter `planned` through the BEFORE pass first so the
4262        // RETURNING snapshot reflects what actually got written
4263        // (triggers may rewrite cells, including a cancellation).
4264        let mut applied_after_before: Vec<(usize, Row, Row)> = Vec::with_capacity(planned.len());
4265        // v7.12.7 — embedded SQL queue.
4266        let mut deferred_embedded: Vec<triggers::DeferredEmbeddedStmt> = Vec::new();
4267        for (pos, new_vals) in &planned {
4268            let old_row = table.rows()[*pos].clone();
4269            let mut new_row = Row::new(new_vals.clone());
4270            let mut skip = false;
4271            for (fd, filter) in &before_update_triggers {
4272                // v7.13.0 — `UPDATE OF cols` filter (mailrs round-5
4273                // G7). Skip this trigger when the filter is set and
4274                // no listed column actually differs between OLD and
4275                // NEW for this row.
4276                if !filter.is_empty()
4277                    && !any_column_changed(filter, &schema_cols, &old_row, &new_row)
4278                {
4279                    continue;
4280                }
4281                let (outcome, deferred) = triggers::fire_row_trigger(
4282                    fd,
4283                    Some(new_row.clone()),
4284                    Some(&old_row),
4285                    &stmt.table,
4286                    &schema_cols,
4287                    &[],
4288                    trigger_session_cfg.as_deref(),
4289                    false,
4290                )
4291                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
4292                deferred_embedded.extend(deferred);
4293                match outcome {
4294                    triggers::TriggerOutcome::Row(r) => new_row = r,
4295                    triggers::TriggerOutcome::Skip => {
4296                        skip = true;
4297                        break;
4298                    }
4299                }
4300            }
4301            if !skip {
4302                applied_after_before.push((*pos, new_row, old_row));
4303            }
4304        }
4305        // v7.9.4 — snapshot post-update values for RETURNING (post-
4306        // BEFORE-trigger because triggers can rewrite cells).
4307        let updated_for_returning: Vec<Vec<Value>> = if stmt.returning.is_some() {
4308            applied_after_before
4309                .iter()
4310                .map(|(_pos, new_row, _old)| new_row.values.clone())
4311                .collect()
4312        } else {
4313            Vec::new()
4314        };
4315        let affected = applied_after_before.len();
4316        // Apply, then fire AFTER triggers per row. AFTER runs read-
4317        // only against the freshly-written row; v7.12.4-shape
4318        // assignment errors with a clear message.
4319        for (pos, new_row, old_row) in applied_after_before {
4320            table.update_row(pos, new_row.values.clone())?;
4321            for (fd, filter) in &after_update_triggers {
4322                if !filter.is_empty()
4323                    && !any_column_changed(filter, &schema_cols, &old_row, &new_row)
4324                {
4325                    continue;
4326                }
4327                let (_outcome, deferred) = triggers::fire_row_trigger(
4328                    fd,
4329                    Some(new_row.clone()),
4330                    Some(&old_row),
4331                    &stmt.table,
4332                    &schema_cols,
4333                    &[],
4334                    trigger_session_cfg.as_deref(),
4335                    true,
4336                )
4337                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
4338                deferred_embedded.extend(deferred);
4339            }
4340        }
4341        let _ = table;
4342        // v7.12.7 — drain trigger-emitted embedded SQL for this UPDATE.
4343        self.execute_deferred_trigger_stmts(deferred_embedded, cancel)?;
4344        // v6.2.1 — auto-analyze modified-row tracking for UPDATE.
4345        if !self.in_transaction() && affected > 0 {
4346            self.statistics
4347                .record_modifications(&stmt.table, affected as u64);
4348        }
4349        // v7.9.4 — RETURNING projection.
4350        if let Some(items) = &stmt.returning {
4351            return self.build_returning_rows(&stmt.table, items, updated_for_returning);
4352        }
4353        Ok(QueryResult::CommandOk {
4354            affected,
4355            modified_catalog: !self.in_transaction(),
4356        })
4357    }
4358
4359    /// v4.4 `DELETE FROM <table> [WHERE cond]`. Collects matching
4360    /// positions then delegates to `Table::delete_rows` (single index
4361    /// rebuild for the batch).
4362    /// v7.17.0 Phase 3.P0-42 — SQL:2003 / PG 15+ `MERGE` execution.
4363    ///
4364    /// Semantics:
4365    ///   * Resolve `target` and `source` tables (catalog reads).
4366    ///   * Build a combined `(target_alias.col, source_alias.col)`
4367    ///     schema so the ON / WHEN AND / SET / VALUES expressions
4368    ///     resolve through the standard qualifier-aware resolver.
4369    ///   * Pass 1: walk every source row × every target hot row,
4370    ///     evaluate ON, then pick the first WHEN clause that fits
4371    ///     (`Matched` if any target row matched, `NotMatched`
4372    ///     otherwise; AND-condition must hold). Collect the action
4373    ///     plan as `(deletes, updates, inserts)` so the apply pass
4374    ///     reads the original target row state.
4375    ///   * Pass 2: apply the plan against the target's mutable row
4376    ///     vector. Deletes execute by index in descending order so
4377    ///     earlier indices remain stable; updates next; inserts
4378    ///     last (matching PG's "INSERT branch sees the post-delete
4379    ///     state" behaviour for the common upsert shape).
4380    ///
4381    /// v7.17 simplifications (documented limitations):
4382    ///   * No triggers / WAL plumbing (MVP); MERGE rows don't fire
4383    ///     INSERT / UPDATE / DELETE row triggers in v7.17.
4384    ///   * No cardinality check (PG-canonical: "MERGE command
4385    ///     cannot affect row a second time" — SPG silently applies
4386    ///     the last action for a target row covered twice).
4387    ///   * Source must be a catalog-resolvable table (no subquery
4388    ///     source); RETURNING / BY SOURCE / BY TARGET unsupported.
4389    fn exec_merge_cancel(
4390        &mut self,
4391        stmt: &spg_sql::ast::MergeStatement,
4392        cancel: CancelToken<'_>,
4393    ) -> Result<QueryResult, EngineError> {
4394        let target_alias = stmt
4395            .target_alias
4396            .clone()
4397            .unwrap_or_else(|| stmt.target.clone());
4398        let source_alias = stmt
4399            .source_alias
4400            .clone()
4401            .unwrap_or_else(|| stmt.source.clone());
4402        let (target_cols, target_rows_snapshot) = {
4403            let t = self.active_catalog().get(&stmt.target).ok_or_else(|| {
4404                EngineError::Storage(StorageError::TableNotFound {
4405                    name: stmt.target.clone(),
4406                })
4407            })?;
4408            (
4409                t.schema().columns.clone(),
4410                t.rows().iter().cloned().collect::<Vec<Row>>(),
4411            )
4412        };
4413        let (source_cols, source_rows) = {
4414            let s = self.active_catalog().get(&stmt.source).ok_or_else(|| {
4415                EngineError::Storage(StorageError::TableNotFound {
4416                    name: stmt.source.clone(),
4417                })
4418            })?;
4419            (
4420                s.schema().columns.clone(),
4421                s.rows().iter().cloned().collect::<Vec<Row>>(),
4422            )
4423        };
4424        // Composite schema: target_alias.col ... source_alias.col ...
4425        let mut combined_schema: Vec<ColumnSchema> = Vec::new();
4426        for col in &target_cols {
4427            combined_schema.push(ColumnSchema::new(
4428                alloc::format!("{target_alias}.{}", col.name),
4429                col.ty,
4430                col.nullable,
4431            ));
4432        }
4433        for col in &source_cols {
4434            combined_schema.push(ColumnSchema::new(
4435                alloc::format!("{source_alias}.{}", col.name),
4436                col.ty,
4437                col.nullable,
4438            ));
4439        }
4440        let combined_ctx = EvalContext::new(&combined_schema, None);
4441        // Source-only context for WHEN NOT MATCHED actions (no
4442        // matched target row exists — the source-side qualified
4443        // columns must still resolve).
4444        let mut source_only_schema: Vec<ColumnSchema> = Vec::new();
4445        for col in &target_cols {
4446            source_only_schema.push(ColumnSchema::new(
4447                alloc::format!("{target_alias}.{}", col.name),
4448                col.ty,
4449                col.nullable,
4450            ));
4451        }
4452        for col in &source_cols {
4453            source_only_schema.push(ColumnSchema::new(
4454                alloc::format!("{source_alias}.{}", col.name),
4455                col.ty,
4456                col.nullable,
4457            ));
4458        }
4459        let source_only_ctx = EvalContext::new(&source_only_schema, None);
4460        let target_arity = target_cols.len();
4461        let source_arity = source_cols.len();
4462
4463        // Resolve INSERT column positions once (validate names).
4464        // For each clause that's an INSERT, map column names → target positions.
4465        let mut delete_indices: Vec<usize> = Vec::new();
4466        let mut updates: Vec<(usize, Vec<Value>)> = Vec::new();
4467        let mut inserts: Vec<Vec<Value>> = Vec::new();
4468        let mut affected: usize = 0;
4469
4470        for (src_idx, src_row) in source_rows.iter().enumerate() {
4471            if src_idx.is_multiple_of(256) {
4472                cancel.check()?;
4473            }
4474            // Find every matched target index (per the ON predicate).
4475            let mut matched_targets: Vec<usize> = Vec::new();
4476            for (t_idx, t_row) in target_rows_snapshot.iter().enumerate() {
4477                let mut combined_vals = t_row.values.clone();
4478                combined_vals.extend(src_row.values.iter().cloned());
4479                let combined_row = Row::new(combined_vals);
4480                let cond = eval::eval_expr(&stmt.on, &combined_row, &combined_ctx)?;
4481                if matches!(cond, Value::Bool(true)) {
4482                    matched_targets.push(t_idx);
4483                }
4484            }
4485            let is_matched = !matched_targets.is_empty();
4486            // Pick the first WHEN clause whose kind agrees with
4487            // `is_matched` and whose AND condition (if any) holds.
4488            // AND condition for MATCHED: evaluated against the
4489            // first matched target row × source. For NOT MATCHED:
4490            // evaluated with target side NULL-padded.
4491            let fired_clause = stmt.clauses.iter().find(|c| {
4492                let kind_ok = match c.matched {
4493                    spg_sql::ast::MergeMatched::Matched => is_matched,
4494                    spg_sql::ast::MergeMatched::NotMatched => !is_matched,
4495                };
4496                if !kind_ok {
4497                    return false;
4498                }
4499                let Some(cond_expr) = &c.condition else {
4500                    return true;
4501                };
4502                let row = if is_matched {
4503                    let t = &target_rows_snapshot[matched_targets[0]];
4504                    let mut vals = t.values.clone();
4505                    vals.extend(src_row.values.iter().cloned());
4506                    Row::new(vals)
4507                } else {
4508                    let mut vals: Vec<Value> = (0..target_arity).map(|_| Value::Null).collect();
4509                    vals.extend(src_row.values.iter().cloned());
4510                    Row::new(vals)
4511                };
4512                let ctx_ref = if is_matched {
4513                    &combined_ctx
4514                } else {
4515                    &source_only_ctx
4516                };
4517                matches!(
4518                    eval::eval_expr(cond_expr, &row, ctx_ref),
4519                    Ok(Value::Bool(true))
4520                )
4521            });
4522            let Some(clause) = fired_clause else { continue };
4523            match &clause.action {
4524                spg_sql::ast::MergeAction::DoNothing => {}
4525                spg_sql::ast::MergeAction::Delete => {
4526                    for &t_idx in &matched_targets {
4527                        if !delete_indices.contains(&t_idx) {
4528                            delete_indices.push(t_idx);
4529                            affected += 1;
4530                        }
4531                    }
4532                }
4533                spg_sql::ast::MergeAction::Update { assignments } => {
4534                    // Pre-resolve SET targets to target column positions.
4535                    let mut planned_sets: Vec<(usize, &Expr)> =
4536                        Vec::with_capacity(assignments.len());
4537                    for (col, expr) in assignments {
4538                        let pos =
4539                            target_cols
4540                                .iter()
4541                                .position(|c| c.name == *col)
4542                                .ok_or_else(|| {
4543                                    EngineError::Eval(EvalError::ColumnNotFound {
4544                                        name: col.clone(),
4545                                    })
4546                                })?;
4547                        planned_sets.push((pos, expr));
4548                    }
4549                    for &t_idx in &matched_targets {
4550                        let t_row = &target_rows_snapshot[t_idx];
4551                        let mut new_values = t_row.values.clone();
4552                        let mut combined_vals = t_row.values.clone();
4553                        combined_vals.extend(src_row.values.iter().cloned());
4554                        let combined_row = Row::new(combined_vals);
4555                        for (pos, expr) in &planned_sets {
4556                            let raw = eval::eval_expr(expr, &combined_row, &combined_ctx)?;
4557                            let coerced = coerce_value(
4558                                raw,
4559                                target_cols[*pos].ty,
4560                                &target_cols[*pos].name,
4561                                *pos,
4562                            )?;
4563                            new_values[*pos] = coerced;
4564                        }
4565                        updates.push((t_idx, new_values));
4566                        affected += 1;
4567                    }
4568                }
4569                spg_sql::ast::MergeAction::Insert { columns, values } => {
4570                    // For INSERT NOT MATCHED, target side is NULL-padded.
4571                    let mut vals: Vec<Value> = (0..target_arity).map(|_| Value::Null).collect();
4572                    vals.extend(src_row.values.iter().cloned());
4573                    let synth_row = Row::new(vals);
4574                    let mut new_row_values: Vec<Value> =
4575                        (0..target_arity).map(|_| Value::Null).collect();
4576                    for (col, expr) in columns.iter().zip(values.iter()) {
4577                        let pos =
4578                            target_cols
4579                                .iter()
4580                                .position(|c| c.name == *col)
4581                                .ok_or_else(|| {
4582                                    EngineError::Eval(EvalError::ColumnNotFound {
4583                                        name: col.clone(),
4584                                    })
4585                                })?;
4586                        let raw = eval::eval_expr(expr, &synth_row, &source_only_ctx)?;
4587                        let coerced =
4588                            coerce_value(raw, target_cols[pos].ty, &target_cols[pos].name, pos)?;
4589                        new_row_values[pos] = coerced;
4590                    }
4591                    inserts.push(new_row_values);
4592                    affected += 1;
4593                }
4594            }
4595        }
4596        let _ = source_arity; // captured for symmetry; cancellation cost negligible.
4597
4598        // Apply the plan to the target table.
4599        let table = self
4600            .active_catalog_mut()
4601            .get_mut(&stmt.target)
4602            .ok_or_else(|| {
4603                EngineError::Storage(StorageError::TableNotFound {
4604                    name: stmt.target.clone(),
4605                })
4606            })?;
4607        // Apply updates first (in-place), then deletes (one batch),
4608        // then inserts. The storage API uses `update_row(pos,
4609        // new_values)`, `delete_rows(&[positions])`, and `insert(row)`.
4610        for (idx, new_vals) in &updates {
4611            table
4612                .update_row(*idx, new_vals.clone())
4613                .map_err(EngineError::Storage)?;
4614        }
4615        if !delete_indices.is_empty() {
4616            table.delete_rows(&delete_indices);
4617        }
4618        for vals in inserts {
4619            table.insert(Row::new(vals)).map_err(EngineError::Storage)?;
4620        }
4621        Ok(QueryResult::CommandOk {
4622            affected,
4623            modified_catalog: affected > 0,
4624        })
4625    }
4626
4627    fn exec_delete_cancel(
4628        &mut self,
4629        stmt: &spg_sql::ast::DeleteStatement,
4630        cancel: CancelToken<'_>,
4631    ) -> Result<QueryResult, EngineError> {
4632        // v7.12.5 — snapshot BEFORE/AFTER DELETE row triggers + the
4633        // session FTS config before the mut borrow (same shape as
4634        // INSERT / UPDATE).
4635        let before_delete_triggers = self.snapshot_row_triggers(&stmt.table, "DELETE", "BEFORE");
4636        let after_delete_triggers = self.snapshot_row_triggers(&stmt.table, "DELETE", "AFTER");
4637        let trigger_session_cfg: Option<String> = self
4638            .session_params
4639            .get("default_text_search_config")
4640            .cloned();
4641        // v5.2.3: PK-targeted DELETE → first retire any cold-tier
4642        // locator for the key. The cold row body stays in the
4643        // segment (becoming shadowed garbage that a future
4644        // compaction pass reclaims) but the index no longer
4645        // resolves it. The shadow count contributes to the
4646        // affected total; the subsequent hot walk handles any hot
4647        // rows for the same key.
4648        let mut cold_shadow_count: usize = 0;
4649        if let Some(w) = &stmt.where_ {
4650            let schema_cols = self
4651                .active_catalog()
4652                .get(&stmt.table)
4653                .ok_or_else(|| {
4654                    EngineError::Storage(StorageError::TableNotFound {
4655                        name: stmt.table.clone(),
4656                    })
4657                })?
4658                .schema()
4659                .columns
4660                .clone();
4661            if let Some((col_pos, key)) = try_pk_predicate(w, &schema_cols, stmt.table.as_str())
4662                && let Some(idx_name) = self
4663                    .active_catalog()
4664                    .get(&stmt.table)
4665                    .and_then(|t| t.index_on(col_pos).map(|i| i.name.clone()))
4666            {
4667                cold_shadow_count = self
4668                    .active_catalog_mut()
4669                    .shadow_cold_row(&stmt.table, &idx_name, &key)
4670                    .unwrap_or(0);
4671            }
4672        }
4673
4674        // v7.12.1 — cache the session FTS config as an owned
4675        // String before the mutable table borrow below; the
4676        // ctx-builder then references it via `as_deref` so the
4677        // immutable read of `session_params` doesn't conflict
4678        // with the mut borrow chain.
4679        let ts_cfg: Option<String> = self
4680            .session_param("default_text_search_config")
4681            .map(String::from);
4682        let table = self
4683            .active_catalog_mut()
4684            .get_mut(&stmt.table)
4685            .ok_or_else(|| {
4686                EngineError::Storage(StorageError::TableNotFound {
4687                    name: stmt.table.clone(),
4688                })
4689            })?;
4690        let schema_cols: Vec<ColumnSchema> = table.schema().columns.clone();
4691        let ctx = EvalContext::new(&schema_cols, Some(stmt.table.as_str()))
4692            .with_default_text_search_config(ts_cfg.as_deref());
4693        let mut positions: Vec<usize> = Vec::new();
4694        // v7.6.3 — collect every to-delete row's full Value tuple
4695        // alongside its position, so the FK enforcement pass can
4696        // run after the mut borrow drops.
4697        let mut to_delete_rows: Vec<Vec<Value>> = Vec::new();
4698        // v7.20 P4 — index seek (same shape as exec_update_cancel):
4699        // an equality WHERE on an indexed column narrows the walk
4700        // to the matching hot positions; the full WHERE still
4701        // re-evaluates per candidate. Downstream passes assume
4702        // ascending position order, so the seek result is sorted.
4703        let seek_positions: Option<Vec<usize>> = stmt
4704            .where_
4705            .as_ref()
4706            .and_then(|w| try_index_seek_positions(w, &schema_cols, table, stmt.table.as_str()));
4707        let candidate_positions: Vec<usize> = match seek_positions {
4708            Some(mut list) => {
4709                list.sort_unstable();
4710                list
4711            }
4712            None => (0..table.row_count()).collect(),
4713        };
4714        for (loop_n, &i) in candidate_positions.iter().enumerate() {
4715            if loop_n.is_multiple_of(256) {
4716                cancel.check()?;
4717            }
4718            let Some(row) = table.rows().get(i) else {
4719                continue;
4720            };
4721            let keep = if let Some(w) = &stmt.where_ {
4722                let cond = eval::eval_expr(w, row, &ctx)?;
4723                !matches!(cond, Value::Bool(true))
4724            } else {
4725                false
4726            };
4727            if !keep {
4728                positions.push(i);
4729                to_delete_rows.push(row.values.clone());
4730            }
4731        }
4732        // v7.6.3 / v7.6.4 — Stage 2: FK enforcement on the immutable
4733        // catalog. Release the mut borrow and run reverse-scan
4734        // against every child table whose FK targets this table.
4735        // RESTRICT / NoAction raise an error; CASCADE returns a
4736        // cascade plan that stage 3 applies after the primary delete.
4737        // SET NULL / SET DEFAULT remain Unsupported until v7.6.5.
4738        let _ = table;
4739        // v7.12.5 — BEFORE DELETE row-level triggers. Each fires
4740        // with NEW=None / OLD=pre-delete row; RETURN OLD (or NEW)
4741        // = proceed, RETURN NULL = skip the row entirely. The
4742        // filter must run BEFORE the FK cascade plan so cascaded
4743        // child rows track the trigger's skip-decision on the
4744        // parent.
4745        // v7.12.7 — embedded SQL queue.
4746        let mut deferred_embedded: Vec<triggers::DeferredEmbeddedStmt> = Vec::new();
4747        if !before_delete_triggers.is_empty() {
4748            let mut filtered_positions: Vec<usize> = Vec::with_capacity(positions.len());
4749            let mut filtered_old_rows: Vec<Vec<Value>> = Vec::with_capacity(to_delete_rows.len());
4750            for (pos, old_vals) in positions.iter().zip(to_delete_rows.iter()) {
4751                let old_row = Row::new(old_vals.clone());
4752                let mut cancel_this = false;
4753                for fd in &before_delete_triggers {
4754                    let (outcome, deferred) = triggers::fire_row_trigger(
4755                        fd,
4756                        None,
4757                        Some(&old_row),
4758                        &stmt.table,
4759                        &schema_cols,
4760                        &[],
4761                        trigger_session_cfg.as_deref(),
4762                        false,
4763                    )
4764                    .map_err(|e| {
4765                        EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}")))
4766                    })?;
4767                    deferred_embedded.extend(deferred);
4768                    if matches!(outcome, triggers::TriggerOutcome::Skip) {
4769                        cancel_this = true;
4770                        break;
4771                    }
4772                }
4773                if !cancel_this {
4774                    filtered_positions.push(*pos);
4775                    filtered_old_rows.push(old_vals.clone());
4776                }
4777            }
4778            positions = filtered_positions;
4779            to_delete_rows = filtered_old_rows;
4780        }
4781        let cascade_plan = plan_fk_parent_deletions(
4782            self.active_catalog(),
4783            &stmt.table,
4784            &positions,
4785            &to_delete_rows,
4786        )?;
4787        // Stage 3a — apply each FK child step (SET NULL / SET
4788        // DEFAULT / CASCADE delete) before deleting the parent.
4789        // The plan is already ordered: nulls/defaults first, then
4790        // cascade deletes (so a row mutated and later deleted
4791        // surfaces as deleted — though v7.6.5 doesn't produce
4792        // that overlap today).
4793        for step in &cascade_plan {
4794            apply_fk_child_step(self.active_catalog_mut(), step)?;
4795        }
4796        // Stage 3b — actually delete the original target rows.
4797        let table = self
4798            .active_catalog_mut()
4799            .get_mut(&stmt.table)
4800            .ok_or_else(|| {
4801                EngineError::Storage(StorageError::TableNotFound {
4802                    name: stmt.table.clone(),
4803                })
4804            })?;
4805        let affected = table.delete_rows(&positions) + cold_shadow_count;
4806        let _ = table;
4807        // v7.12.5 — AFTER DELETE row-level triggers fire post-write
4808        // with NEW=None / OLD=pre-delete row (each from the
4809        // already-snapshotted to_delete_rows). Return value is
4810        // ignored (matches PG AFTER semantics).
4811        if !after_delete_triggers.is_empty() {
4812            for old_vals in &to_delete_rows {
4813                let old_row = Row::new(old_vals.clone());
4814                for fd in &after_delete_triggers {
4815                    let (_outcome, deferred) = triggers::fire_row_trigger(
4816                        fd,
4817                        None,
4818                        Some(&old_row),
4819                        &stmt.table,
4820                        &schema_cols,
4821                        &[],
4822                        trigger_session_cfg.as_deref(),
4823                        true,
4824                    )
4825                    .map_err(|e| {
4826                        EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}")))
4827                    })?;
4828                    deferred_embedded.extend(deferred);
4829                }
4830            }
4831        }
4832        // v7.12.7 — drain trigger-emitted embedded SQL for this DELETE.
4833        self.execute_deferred_trigger_stmts(deferred_embedded, cancel)?;
4834        // v6.2.1 — auto-analyze modified-row tracking for DELETE.
4835        if !self.in_transaction() && affected > 0 {
4836            self.statistics
4837                .record_modifications(&stmt.table, affected as u64);
4838        }
4839        // v7.9.4 — RETURNING projection over the soon-to-be-gone
4840        // rows. `to_delete_rows` was snapshotted in stage 1 before
4841        // mutation, so the projection sees the pre-delete state
4842        // (matches PG semantics: DELETE RETURNING returns the row
4843        // as it was just before removal).
4844        if let Some(items) = &stmt.returning {
4845            return self.build_returning_rows(&stmt.table, items, to_delete_rows);
4846        }
4847        Ok(QueryResult::CommandOk {
4848            affected,
4849            modified_catalog: !self.in_transaction(),
4850        })
4851    }
4852
4853    /// `SHOW TABLES` — one row per table in the active catalog.
4854    /// Column name is `name` so result-set consumers can downstream
4855    /// `SELECT name FROM ...` style logic if needed.
4856    /// v4.26: `EXPLAIN [ANALYZE] <select>`. Returns a single-column
4857    /// `QUERY PLAN` text table — first line names the top operator
4858    /// (Scan / Aggregate / Window / etc.), indented children list
4859    /// FROM joins, WHERE filters, ORDER BY / LIMIT, projection
4860    /// shape, and any active index hits. `ANALYZE` execs the inner
4861    /// SELECT and appends actual-row + elapsed-micros annotations.
4862    #[allow(clippy::format_push_string)]
4863    fn exec_explain(
4864        &self,
4865        e: &spg_sql::ast::ExplainStatement,
4866        cancel: CancelToken<'_>,
4867    ) -> Result<QueryResult, EngineError> {
4868        let mut lines = Vec::<String>::new();
4869        explain_select(&e.inner, self, 0, &mut lines);
4870        if e.suggest {
4871            // v6.8.3 — index advisor. Walks the SELECT's FROM
4872            // tables + WHERE column refs; for each (table, column)
4873            // pair that lacks an index, append a SUGGEST line with
4874            // a copy-pastable `CREATE INDEX` statement. This is a
4875            // pure-syntax heuristic — no cardinality estimation —
4876            // matching the v6.8.3 design intent of "tell the
4877            // operator where indexes are missing", not "give the
4878            // mathematically optimal index set".
4879            let suggestions = build_index_suggestions(&e.inner, self);
4880            for s in suggestions {
4881                lines.push(s);
4882            }
4883        } else if e.analyze {
4884            // v6.2.4 — EXPLAIN ANALYZE annotates each operator line
4885            // with `(rows=N)` where the row count is computable
4886            // without re-executing the full query:
4887            //   - Top-level operator (first non-indented line):
4888            //     rows = final result.len()
4889            //   - "From: <table> [full scan]" lines: rows =
4890            //     table.rows().len() (catalog read; no execution)
4891            //   - "From: <table> [index seek]": indeterminate —
4892            //     the index step would need re-execution; v6.2.5
4893            //     adds per-operator wall-clock + hot/cold rows
4894            //     instrumentation that makes this concrete.
4895            //   - Everything else: marked `(—)` so the surface
4896            //     stays well-defined without silently dropping
4897            //     stats. v6.2.5 fills in via inline executor
4898            //     instrumentation.
4899            // Total elapsed lands on a trailing `Total: …` line.
4900            let started = self.clock.map(|f| f());
4901            let exec = self.exec_select_cancel(&e.inner, cancel)?;
4902            let elapsed_micros = match (self.clock, started) {
4903                (Some(f), Some(s)) => Some(f().saturating_sub(s)),
4904                _ => None,
4905            };
4906            let row_count = if let QueryResult::Rows { rows, .. } = &exec {
4907                rows.len()
4908            } else {
4909                0
4910            };
4911            annotate_explain_lines(&mut lines, row_count, self);
4912            let mut total = alloc::format!("Total: rows={row_count}");
4913            if let Some(us) = elapsed_micros {
4914                total.push_str(&alloc::format!(" elapsed={us}us"));
4915            }
4916            lines.push(total);
4917        }
4918        let columns = alloc::vec![ColumnSchema::new("QUERY PLAN", DataType::Text, false)];
4919        let rows: Vec<Row> = lines
4920            .into_iter()
4921            .map(|l| Row::new(alloc::vec![Value::Text(l)]))
4922            .collect();
4923        Ok(QueryResult::Rows { columns, rows })
4924    }
4925
4926    fn exec_show_tables(&self) -> QueryResult {
4927        let columns = alloc::vec![ColumnSchema::new("name", DataType::Text, false)];
4928        let rows: Vec<Row> = self
4929            .active_catalog()
4930            .table_names()
4931            .into_iter()
4932            .map(|n| Row::new(alloc::vec![Value::Text(n)]))
4933            .collect();
4934        QueryResult::Rows { columns, rows }
4935    }
4936
4937    /// v7.17.0 Phase 3.P0-59 — `SHOW CREATE TABLE <t>`. Synthesise
4938    /// a minimal MySQL-flavoured CREATE TABLE DDL from the
4939    /// catalog's TableSchema so mysqldump round-trips load against
4940    /// SPG without splitting init scripts.
4941    fn exec_show_create_table(&self, name: &str) -> Result<QueryResult, EngineError> {
4942        let t = self.active_catalog().get(name).ok_or_else(|| {
4943            EngineError::Storage(StorageError::TableNotFound { name: name.into() })
4944        })?;
4945        let cols: Vec<String> = t
4946            .schema()
4947            .columns
4948            .iter()
4949            .map(|c| {
4950                let ty = render_data_type(c.ty);
4951                let nullable = if c.nullable { "" } else { " NOT NULL" };
4952                alloc::format!("  `{}` {}{}", c.name, ty, nullable)
4953            })
4954            .collect();
4955        let mut body = cols.join(",\n");
4956        // Append UNIQUE / PRIMARY KEY clauses.
4957        for uc in &t.schema().uniqueness_constraints {
4958            let col_names: Vec<String> = uc
4959                .columns
4960                .iter()
4961                .map(|&p| {
4962                    t.schema().columns.get(p).map_or_else(
4963                        || alloc::format!("col{p}"),
4964                        |c| alloc::format!("`{}`", c.name),
4965                    )
4966                })
4967                .collect();
4968            let kw = if uc.is_primary_key {
4969                "PRIMARY KEY"
4970            } else {
4971                "UNIQUE KEY"
4972            };
4973            body.push_str(",\n  ");
4974            body.push_str(&alloc::format!("{kw} ({})", col_names.join(", ")));
4975        }
4976        // Foreign keys.
4977        for fk in &t.schema().foreign_keys {
4978            let local: Vec<String> = fk
4979                .local_columns
4980                .iter()
4981                .map(|&p| {
4982                    t.schema().columns.get(p).map_or_else(
4983                        || alloc::format!("col{p}"),
4984                        |c| alloc::format!("`{}`", c.name),
4985                    )
4986                })
4987                .collect();
4988            let parent_cols: Vec<String> =
4989                if let Some(parent) = self.active_catalog().get(&fk.parent_table) {
4990                    fk.parent_columns
4991                        .iter()
4992                        .map(|&p| {
4993                            parent.schema().columns.get(p).map_or_else(
4994                                || alloc::format!("col{p}"),
4995                                |c| alloc::format!("`{}`", c.name),
4996                            )
4997                        })
4998                        .collect()
4999                } else {
5000                    fk.parent_columns
5001                        .iter()
5002                        .map(|p| alloc::format!("col{p}"))
5003                        .collect()
5004                };
5005            body.push_str(",\n  ");
5006            body.push_str(&alloc::format!(
5007                "FOREIGN KEY ({}) REFERENCES `{}` ({})",
5008                local.join(", "),
5009                fk.parent_table,
5010                parent_cols.join(", ")
5011            ));
5012        }
5013        let ddl = alloc::format!(
5014            "CREATE TABLE `{}` (\n{}\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
5015            name,
5016            body
5017        );
5018        let columns = alloc::vec![
5019            ColumnSchema::new("Table", DataType::Text, false),
5020            ColumnSchema::new("Create Table", DataType::Text, false),
5021        ];
5022        let rows = alloc::vec![Row::new(alloc::vec![
5023            Value::Text(name.into()),
5024            Value::Text(ddl),
5025        ])];
5026        Ok(QueryResult::Rows { columns, rows })
5027    }
5028
5029    /// v7.17.0 Phase 3.P0-60 — `SHOW INDEXES FROM <t>`. MySQL
5030    /// surface returns one row per (index × column) with 14
5031    /// columns; v7.17 ships the columns admin probes actually
5032    /// filter on: Table, Non_unique, Key_name, Seq_in_index,
5033    /// Column_name, Null, Index_type.
5034    fn exec_show_indexes(&self, name: &str) -> Result<QueryResult, EngineError> {
5035        let t = self.active_catalog().get(name).ok_or_else(|| {
5036            EngineError::Storage(StorageError::TableNotFound { name: name.into() })
5037        })?;
5038        let columns = alloc::vec![
5039            ColumnSchema::new("Table", DataType::Text, false),
5040            ColumnSchema::new("Non_unique", DataType::Int, false),
5041            ColumnSchema::new("Key_name", DataType::Text, false),
5042            ColumnSchema::new("Seq_in_index", DataType::Int, false),
5043            ColumnSchema::new("Column_name", DataType::Text, false),
5044            ColumnSchema::new("Null", DataType::Text, false),
5045            ColumnSchema::new("Index_type", DataType::Text, false),
5046        ];
5047        let mut rows: Vec<Row> = Vec::new();
5048        for idx in t.indices() {
5049            let col = t
5050                .schema()
5051                .columns
5052                .get(idx.column_position)
5053                .map_or("?".into(), |c| c.name.clone());
5054            let nullable = t
5055                .schema()
5056                .columns
5057                .get(idx.column_position)
5058                .map_or(true, |c| c.nullable);
5059            rows.push(Row::new(alloc::vec![
5060                Value::Text(name.into()),
5061                Value::Int(i32::from(!idx.is_unique)),
5062                Value::Text(idx.name.clone()),
5063                Value::Int(1),
5064                Value::Text(col),
5065                Value::Text(if nullable {
5066                    "YES".into()
5067                } else {
5068                    String::new()
5069                }),
5070                Value::Text("BTREE".into()),
5071            ]));
5072        }
5073        Ok(QueryResult::Rows { columns, rows })
5074    }
5075
5076    /// v7.17.0 Phase 3.P0-61 — `SHOW STATUS`. Returns canonical
5077    /// MySQL server-status counters (2-column `(Variable_name,
5078    /// Value)`).
5079    fn exec_show_status(&self) -> QueryResult {
5080        let columns = alloc::vec![
5081            ColumnSchema::new("Variable_name", DataType::Text, false),
5082            ColumnSchema::new("Value", DataType::Text, false),
5083        ];
5084        let pairs: &[(&str, &str)] = &[
5085            ("Uptime", "0"),
5086            ("Threads_connected", "1"),
5087            ("Threads_running", "1"),
5088            ("Questions", "0"),
5089            ("Slow_queries", "0"),
5090            ("Opened_tables", "0"),
5091            ("Innodb_buffer_pool_pages_total", "0"),
5092        ];
5093        let rows: Vec<Row> = pairs
5094            .iter()
5095            .map(|(k, v)| {
5096                Row::new(alloc::vec![
5097                    Value::Text((*k).into()),
5098                    Value::Text((*v).into())
5099                ])
5100            })
5101            .collect();
5102        QueryResult::Rows { columns, rows }
5103    }
5104
5105    /// v7.17.0 Phase 3.P0-61 — `SHOW VARIABLES`. Returns server-side
5106    /// variables MySQL/MariaDB clients probe at connect time.
5107    fn exec_show_variables(&self) -> QueryResult {
5108        let columns = alloc::vec![
5109            ColumnSchema::new("Variable_name", DataType::Text, false),
5110            ColumnSchema::new("Value", DataType::Text, false),
5111        ];
5112        let mut rows: Vec<Row> = Vec::new();
5113        let canonical: &[(&str, &str)] = &[
5114            ("version", "8.0.35-spg"),
5115            ("version_comment", "SPG dual-stack engine"),
5116            ("character_set_server", "utf8mb4"),
5117            ("collation_server", "utf8mb4_0900_ai_ci"),
5118            ("max_allowed_packet", "67108864"),
5119            ("autocommit", "ON"),
5120            ("sql_mode", "STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION"),
5121            ("time_zone", "SYSTEM"),
5122            ("transaction_isolation", "REPEATABLE-READ"),
5123        ];
5124        for &(k, v) in canonical {
5125            rows.push(Row::new(alloc::vec![
5126                Value::Text(k.into()),
5127                Value::Text(v.into()),
5128            ]));
5129        }
5130        // Session-set parameters surface here too.
5131        for (k, v) in &self.session_params {
5132            if !canonical.iter().any(|(n, _)| (*n).eq_ignore_ascii_case(k)) {
5133                rows.push(Row::new(alloc::vec![
5134                    Value::Text(k.clone()),
5135                    Value::Text(v.clone()),
5136                ]));
5137            }
5138        }
5139        QueryResult::Rows { columns, rows }
5140    }
5141
5142    /// v7.17.0 Phase 3.P0-62 — `SHOW PROCESSLIST`. SPG is
5143    /// single-process so the surface returns one synthetic row
5144    /// describing the current connection (Id, User, Host, db,
5145    /// Command, Time, State, Info).
5146    fn exec_show_processlist(&self) -> QueryResult {
5147        let columns = alloc::vec![
5148            ColumnSchema::new("Id", DataType::Int, false),
5149            ColumnSchema::new("User", DataType::Text, false),
5150            ColumnSchema::new("Host", DataType::Text, false),
5151            ColumnSchema::new("db", DataType::Text, true),
5152            ColumnSchema::new("Command", DataType::Text, false),
5153            ColumnSchema::new("Time", DataType::Int, false),
5154            ColumnSchema::new("State", DataType::Text, true),
5155            ColumnSchema::new("Info", DataType::Text, true),
5156        ];
5157        let rows = alloc::vec![Row::new(alloc::vec![
5158            Value::Int(1),
5159            Value::Text("postgres".into()),
5160            Value::Text("localhost".into()),
5161            Value::Text("postgres".into()),
5162            Value::Text("Query".into()),
5163            Value::Int(0),
5164            Value::Text("executing".into()),
5165            Value::Text("SHOW PROCESSLIST".into()),
5166        ])];
5167        QueryResult::Rows { columns, rows }
5168    }
5169
5170    /// v7.17.0 Phase 3.P0-58 — `SHOW DATABASES` / `SHOW SCHEMAS`.
5171    /// SPG is single-database so the result is the canonical MySQL
5172    /// set every mysql/MariaDB client expects at connect time:
5173    /// `information_schema`, `mysql`, `performance_schema`, `sys`,
5174    /// plus a `postgres` slot so dual-stack callers find their
5175    /// PG-compatible database too.
5176    fn exec_show_databases(&self) -> QueryResult {
5177        let columns = alloc::vec![ColumnSchema::new("Database", DataType::Text, false)];
5178        let names = [
5179            "information_schema",
5180            "mysql",
5181            "performance_schema",
5182            "sys",
5183            "postgres",
5184        ];
5185        let rows: Vec<Row> = names
5186            .iter()
5187            .map(|n| Row::new(alloc::vec![Value::Text((*n).into())]))
5188            .collect();
5189        QueryResult::Rows { columns, rows }
5190    }
5191
5192    /// `SHOW COLUMNS FROM <table>` — one row per column with the
5193    /// declared name, SQL type rendering, and nullability flag.
5194    fn exec_show_columns(&self, table_name: &str) -> Result<QueryResult, EngineError> {
5195        let table =
5196            self.active_catalog()
5197                .get(table_name)
5198                .ok_or_else(|| StorageError::TableNotFound {
5199                    name: table_name.into(),
5200                })?;
5201        let columns = alloc::vec![
5202            ColumnSchema::new("name", DataType::Text, false),
5203            ColumnSchema::new("type", DataType::Text, false),
5204            ColumnSchema::new("nullable", DataType::Bool, false),
5205        ];
5206        let rows: Vec<Row> = table
5207            .schema()
5208            .columns
5209            .iter()
5210            .map(|c| {
5211                Row::new(alloc::vec![
5212                    Value::Text(c.name.clone()),
5213                    Value::Text(alloc::format!("{}", c.ty)),
5214                    Value::Bool(c.nullable),
5215                ])
5216            })
5217            .collect();
5218        Ok(QueryResult::Rows { columns, rows })
5219    }
5220
5221    fn exec_begin(&mut self) -> Result<QueryResult, EngineError> {
5222        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5223        if self.tx_catalogs.contains_key(&tx_id) {
5224            return Err(EngineError::TransactionAlreadyOpen);
5225        }
5226        self.tx_catalogs.insert(
5227            tx_id,
5228            TxState {
5229                catalog: self.catalog.clone(),
5230                savepoints: Vec::new(),
5231            },
5232        );
5233        Ok(QueryResult::CommandOk {
5234            affected: 0,
5235            modified_catalog: false,
5236        })
5237    }
5238
5239    fn exec_commit(&mut self) -> Result<QueryResult, EngineError> {
5240        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5241        let state = self
5242            .tx_catalogs
5243            .remove(&tx_id)
5244            .ok_or(EngineError::NoActiveTransaction)?;
5245        self.catalog = state.catalog;
5246        // All savepoints become permanent at COMMIT and the stack
5247        // resets for the next TX (`state.savepoints` is discarded with
5248        // `state`).
5249        Ok(QueryResult::CommandOk {
5250            affected: 0,
5251            modified_catalog: true,
5252        })
5253    }
5254
5255    fn exec_rollback(&mut self) -> Result<QueryResult, EngineError> {
5256        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5257        if self.tx_catalogs.remove(&tx_id).is_none() {
5258            return Err(EngineError::NoActiveTransaction);
5259        }
5260        // savepoints discarded with the TxState
5261        Ok(QueryResult::CommandOk {
5262            affected: 0,
5263            modified_catalog: false,
5264        })
5265    }
5266
5267    fn exec_savepoint(&mut self, name: String) -> Result<QueryResult, EngineError> {
5268        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5269        let state = self
5270            .tx_catalogs
5271            .get_mut(&tx_id)
5272            .ok_or(EngineError::NoActiveTransaction)?;
5273        // PG re-uses an existing savepoint name by dropping the older
5274        // entry and pushing a fresh one — match that behaviour so
5275        // application code can `SAVEPOINT sp; ...; SAVEPOINT sp` freely.
5276        state.savepoints.retain(|(n, _)| n != &name);
5277        let snapshot = state.catalog.clone();
5278        state.savepoints.push((name, snapshot));
5279        Ok(QueryResult::CommandOk {
5280            affected: 0,
5281            modified_catalog: false,
5282        })
5283    }
5284
5285    fn exec_rollback_to_savepoint(&mut self, name: &str) -> Result<QueryResult, EngineError> {
5286        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5287        let state = self
5288            .tx_catalogs
5289            .get_mut(&tx_id)
5290            .ok_or(EngineError::NoActiveTransaction)?;
5291        let pos = state
5292            .savepoints
5293            .iter()
5294            .rposition(|(n, _)| n == name)
5295            .ok_or_else(|| {
5296                EngineError::Unsupported(alloc::format!("savepoint not found: {name}"))
5297            })?;
5298        // The savepoint stays on the stack (PG semantics): a later
5299        // `RELEASE` or further `ROLLBACK TO` is still allowed. Everything
5300        // after it is discarded.
5301        let snapshot = state.savepoints[pos].1.clone();
5302        state.savepoints.truncate(pos + 1);
5303        state.catalog = snapshot;
5304        Ok(QueryResult::CommandOk {
5305            affected: 0,
5306            modified_catalog: false,
5307        })
5308    }
5309
5310    fn exec_release_savepoint(&mut self, name: &str) -> Result<QueryResult, EngineError> {
5311        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5312        let state = self
5313            .tx_catalogs
5314            .get_mut(&tx_id)
5315            .ok_or(EngineError::NoActiveTransaction)?;
5316        let pos = state
5317            .savepoints
5318            .iter()
5319            .rposition(|(n, _)| n == name)
5320            .ok_or_else(|| {
5321                EngineError::Unsupported(alloc::format!("savepoint not found: {name}"))
5322            })?;
5323        // RELEASE keeps the work since the savepoint, just discards the
5324        // bookmark plus everything nested under it.
5325        state.savepoints.truncate(pos);
5326        Ok(QueryResult::CommandOk {
5327            affected: 0,
5328            modified_catalog: false,
5329        })
5330    }
5331
5332    /// v6.0.4 — synchronous `ALTER INDEX <name> REBUILD [WITH
5333    /// (encoding = …)]`. Walks every table in the active catalog
5334    /// looking for an index matching `stmt.name`, then delegates the
5335    /// rebuild (including any encoding switch) to
5336    /// `Table::rebuild_nsw_index`. The "live" non-blocking
5337    /// optimisation is v6.0.4.1 / v6.1.x territory.
5338    /// v6.7.2 — `ALTER TABLE t SET hot_tier_bytes = X`. Dispatch
5339    /// arm. Currently the only setting is `hot_tier_bytes`; later
5340    /// v6.7.x can extend `AlterTableTarget` without touching this
5341    /// arm structure.
5342    fn exec_alter_table(
5343        &mut self,
5344        s: spg_sql::ast::AlterTableStatement,
5345    ) -> Result<QueryResult, EngineError> {
5346        // v7.13.2 — mailrs round-6 S1: apply each subaction in order.
5347        // On first error the statement aborts; subactions already
5348        // applied stay (no transactional rollback in v7.13 — wrap in
5349        // BEGIN/COMMIT if atomicity matters).
5350        let table_name = s.name.clone();
5351        for target in s.targets {
5352            self.exec_alter_table_subaction(&table_name, target)?;
5353        }
5354        Ok(QueryResult::CommandOk {
5355            affected: 0,
5356            modified_catalog: !self.in_transaction(),
5357        })
5358    }
5359
5360    fn exec_alter_table_subaction(
5361        &mut self,
5362        table_name_outer: &str,
5363        target: spg_sql::ast::AlterTableTarget,
5364    ) -> Result<(), EngineError> {
5365        // Inner helper retains the s.name closure shape; alias to `s`
5366        // for minimal diff against the v7.13.0 body.
5367        struct S<'a> {
5368            name: &'a str,
5369        }
5370        let s = S {
5371            name: table_name_outer,
5372        };
5373        match target {
5374            spg_sql::ast::AlterTableTarget::SetHotTierBytes(n) => {
5375                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5376                    EngineError::Storage(StorageError::TableNotFound {
5377                        name: s.name.into(),
5378                    })
5379                })?;
5380                table.schema_mut().hot_tier_bytes = Some(n);
5381            }
5382            spg_sql::ast::AlterTableTarget::AddForeignKey(fk) => {
5383                // v7.6.8 — resolve FK against the live catalog first
5384                // (validates parent table, columns, indices). Then
5385                // verify every existing row in the child table
5386                // satisfies the new constraint. Then install it.
5387                let cols_snapshot = self
5388                    .active_catalog()
5389                    .get(s.name)
5390                    .ok_or_else(|| {
5391                        EngineError::Storage(StorageError::TableNotFound {
5392                            name: s.name.into(),
5393                        })
5394                    })?
5395                    .schema()
5396                    .columns
5397                    .clone();
5398                let storage_fk =
5399                    resolve_foreign_key(s.name, &cols_snapshot, fk, self.active_catalog())?;
5400                // Verify existing rows. Treat them as a virtual
5401                // INSERT batch — reusing the v7.6.2 enforce helper.
5402                let existing_rows: Vec<Vec<Value>> = self
5403                    .active_catalog()
5404                    .get(s.name)
5405                    .expect("checked above")
5406                    .rows()
5407                    .iter()
5408                    .map(|r| r.values.clone())
5409                    .collect();
5410                enforce_fk_inserts(
5411                    self.active_catalog(),
5412                    s.name,
5413                    core::slice::from_ref(&storage_fk),
5414                    &existing_rows,
5415                )?;
5416                // Reject duplicate constraint name.
5417                let table = self
5418                    .active_catalog_mut()
5419                    .get_mut(s.name)
5420                    .expect("checked above");
5421                if let Some(name) = &storage_fk.name
5422                    && table
5423                        .schema()
5424                        .foreign_keys
5425                        .iter()
5426                        .any(|f| f.name.as_ref() == Some(name))
5427                {
5428                    return Err(EngineError::Unsupported(alloc::format!(
5429                        "ALTER TABLE ADD CONSTRAINT: a constraint named {name:?} already exists"
5430                    )));
5431                }
5432                table.schema_mut().foreign_keys.push(storage_fk);
5433            }
5434            spg_sql::ast::AlterTableTarget::DropForeignKey { name, if_exists } => {
5435                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5436                    EngineError::Storage(StorageError::TableNotFound {
5437                        name: s.name.into(),
5438                    })
5439                })?;
5440                let fks = &mut table.schema_mut().foreign_keys;
5441                let before = fks.len();
5442                fks.retain(|f| f.name.as_ref() != Some(&name));
5443                if fks.len() == before && !if_exists {
5444                    return Err(EngineError::Unsupported(alloc::format!(
5445                        "ALTER TABLE DROP CONSTRAINT: no FK named {name:?} on {:?}",
5446                        s.name
5447                    )));
5448                }
5449                // v7.13.2 mailrs round-6 S7: IF EXISTS silences the miss.
5450            }
5451            spg_sql::ast::AlterTableTarget::AddColumn {
5452                column,
5453                if_not_exists,
5454            } => {
5455                // v7.13.0 — mailrs round-5 G1. Append-only column add
5456                // with back-fill of the DEFAULT (or NULL) into every
5457                // existing row. Column positions don't shift, so we
5458                // skip index rebuild.
5459                let clock = self.clock;
5460                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5461                    EngineError::Storage(StorageError::TableNotFound {
5462                        name: s.name.into(),
5463                    })
5464                })?;
5465                if table
5466                    .schema()
5467                    .columns
5468                    .iter()
5469                    .any(|c| c.name.eq_ignore_ascii_case(&column.name))
5470                {
5471                    if if_not_exists {
5472                        return Ok(());
5473                    }
5474                    return Err(EngineError::Unsupported(alloc::format!(
5475                        "ALTER TABLE ADD COLUMN: column {:?} already exists on {:?}",
5476                        column.name,
5477                        s.name
5478                    )));
5479                }
5480                let col_name = column.name.clone();
5481                let nullable = column.nullable;
5482                let has_default = column.default.is_some() || column.auto_increment;
5483                let col_schema = column_def_to_schema(column)?;
5484                let row_count = table.row_count();
5485                // Compute the back-fill value. Literal / runtime DEFAULT
5486                // funnels through the same resolver that INSERT uses
5487                // (v7.9.21 `resolve_column_default_free`). NULL when
5488                // the column is nullable and has no DEFAULT. NOT NULL
5489                // without DEFAULT errors when the table has existing
5490                // rows — same as PG.
5491                let fill_value: Value = if has_default || col_schema.runtime_default.is_some() {
5492                    resolve_column_default_free(&col_schema, clock)?
5493                } else if nullable || row_count == 0 {
5494                    Value::Null
5495                } else {
5496                    return Err(EngineError::Unsupported(alloc::format!(
5497                        "ALTER TABLE ADD COLUMN {col_name:?}: NOT NULL column requires DEFAULT \
5498                         when the table has existing rows"
5499                    )));
5500                };
5501                table.add_column(col_schema, fill_value);
5502            }
5503            spg_sql::ast::AlterTableTarget::AlterColumnType {
5504                column,
5505                new_type,
5506                using,
5507            } => {
5508                // v7.13.0 — mailrs round-5 G8. Re-evaluate each
5509                // row's column value (either through the USING
5510                // expression if supplied, or as a direct CAST of
5511                // the existing value) and re-coerce to the new
5512                // type. Indices on the column get rebuilt.
5513                let new_data_type = column_type_to_data_type(new_type);
5514                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5515                    EngineError::Storage(StorageError::TableNotFound {
5516                        name: s.name.into(),
5517                    })
5518                })?;
5519                let col_pos = table
5520                    .schema()
5521                    .columns
5522                    .iter()
5523                    .position(|c| c.name.eq_ignore_ascii_case(&column))
5524                    .ok_or_else(|| {
5525                        EngineError::Unsupported(alloc::format!(
5526                            "ALTER COLUMN TYPE: column {column:?} not found on {:?}",
5527                            s.name
5528                        ))
5529                    })?;
5530                let schema_cols = table.schema().columns.clone();
5531                let ctx = eval::EvalContext::new(&schema_cols, None);
5532                let mut new_values: alloc::vec::Vec<Value> =
5533                    alloc::vec::Vec::with_capacity(table.row_count());
5534                for row in table.rows().iter() {
5535                    let raw = match &using {
5536                        Some(expr) => eval::eval_expr(expr, row, &ctx).map_err(|e| {
5537                            EngineError::Unsupported(alloc::format!(
5538                                "ALTER COLUMN TYPE: USING expression failed: {e:?}"
5539                            ))
5540                        })?,
5541                        None => row.values.get(col_pos).cloned().unwrap_or(Value::Null),
5542                    };
5543                    let coerced = coerce_value(raw, new_data_type, &column, col_pos)?;
5544                    new_values.push(coerced);
5545                }
5546                table.schema_mut().columns[col_pos].ty = new_data_type;
5547                for (i, v) in new_values.into_iter().enumerate() {
5548                    let mut row_values = table
5549                        .rows()
5550                        .get(i)
5551                        .expect("bounds-checked above")
5552                        .values
5553                        .clone();
5554                    row_values[col_pos] = v;
5555                    table.update_row(i, row_values)?;
5556                }
5557            }
5558            spg_sql::ast::AlterTableTarget::AddTableConstraint(tc) => {
5559                // v7.14.0 — pg_dump emits PKs as a separate
5560                // ALTER TABLE ADD CONSTRAINT post-CREATE-TABLE.
5561                // For PRIMARY KEY / UNIQUE, install a UC entry
5562                // and the implicit BTree index on the leading
5563                // column. CHECK: append predicate to schema.
5564                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5565                    EngineError::Storage(StorageError::TableNotFound {
5566                        name: s.name.into(),
5567                    })
5568                })?;
5569                let is_pk = matches!(tc, spg_sql::ast::TableConstraint::PrimaryKey { .. });
5570                // v7.22 (mailrs round-13 gap 6) — carry the parsed
5571                // NULLS NOT DISTINCT flag through the ALTER path;
5572                // it was hardcoded false here while the CREATE
5573                // TABLE path honoured it since v7.13.
5574                let nnd = matches!(
5575                    tc,
5576                    spg_sql::ast::TableConstraint::Unique {
5577                        nulls_not_distinct: true,
5578                        ..
5579                    }
5580                );
5581                match tc {
5582                    spg_sql::ast::TableConstraint::PrimaryKey { columns, .. }
5583                    | spg_sql::ast::TableConstraint::Unique { columns, .. } => {
5584                        let positions: Vec<usize> = columns
5585                            .iter()
5586                            .map(|c| {
5587                                table
5588                                    .schema()
5589                                    .columns
5590                                    .iter()
5591                                    .position(|sc| sc.name.eq_ignore_ascii_case(c))
5592                                    .ok_or_else(|| {
5593                                        EngineError::Unsupported(alloc::format!(
5594                                            "ALTER TABLE ADD CONSTRAINT: column {c:?} not found on {:?}",
5595                                            s.name
5596                                        ))
5597                                    })
5598                            })
5599                            .collect::<Result<Vec<_>, _>>()?;
5600                        // Skip if an equivalent UC is already there
5601                        // (idempotent — pg_dump's PK + a prior inline
5602                        // PK shouldn't double-install).
5603                        let already = table
5604                            .schema()
5605                            .uniqueness_constraints
5606                            .iter()
5607                            .any(|u| u.columns == positions);
5608                        if !already {
5609                            table.schema_mut().uniqueness_constraints.push(
5610                                spg_storage::UniquenessConstraint {
5611                                    is_primary_key: is_pk,
5612                                    columns: positions.clone(),
5613                                    nulls_not_distinct: nnd,
5614                                },
5615                            );
5616                            // PK implies NOT NULL on referenced cols.
5617                            if is_pk {
5618                                for p in &positions {
5619                                    if let Some(c) = table.schema_mut().columns.get_mut(*p) {
5620                                        c.nullable = false;
5621                                    }
5622                                }
5623                            }
5624                            // Add a BTree index on the leading
5625                            // column for INSERT-side enforcement.
5626                            let leading = &columns[0];
5627                            let already_idx = table.indices().iter().any(|idx| {
5628                                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
5629                                    && table.schema().columns[idx.column_position].name == *leading
5630                            });
5631                            if !already_idx {
5632                                let suffix = if is_pk { "pkey" } else { "key" };
5633                                let idx_name = alloc::format!("{}_{leading}_{suffix}", s.name);
5634                                let _ = table.add_index(idx_name, leading);
5635                            }
5636                        }
5637                    }
5638                    spg_sql::ast::TableConstraint::Check { expr, .. } => {
5639                        table.schema_mut().checks.push(alloc::format!("{expr}"));
5640                    }
5641                    spg_sql::ast::TableConstraint::Index { name, columns } => {
5642                        // v7.15.0 — ALTER TABLE ADD KEY (cols).
5643                        // mysqldump occasionally emits this
5644                        // post-CREATE-TABLE shape; build a BTree
5645                        // on the leading column using the
5646                        // user-supplied or synthesised name.
5647                        let leading = &columns[0];
5648                        let already_idx = table.indices().iter().any(|idx| {
5649                            matches!(idx.kind, spg_storage::IndexKind::BTree(_))
5650                                && table.schema().columns[idx.column_position].name == *leading
5651                        });
5652                        if !already_idx {
5653                            let idx_name = name
5654                                .clone()
5655                                .unwrap_or_else(|| alloc::format!("{}_{leading}_idx", s.name));
5656                            let _ = table.add_index(idx_name, leading);
5657                        }
5658                    }
5659                    spg_sql::ast::TableConstraint::FulltextIndex { name, columns } => {
5660                        // v7.17.0 Phase 2.2 — ALTER TABLE ADD
5661                        // FULLTEXT KEY (cols). Builds one
5662                        // fulltext-GIN per named column so MATCH
5663                        // AGAINST gets a real inverted index.
5664                        // Multi-column declarations expand to
5665                        // per-column GINs (the leading column
5666                        // drives MATCH AGAINST planning).
5667                        for (k, col) in columns.iter().enumerate() {
5668                            let already_idx = table.indices().iter().any(|idx| {
5669                                matches!(idx.kind, spg_storage::IndexKind::GinFulltext(_))
5670                                    && table.schema().columns[idx.column_position].name == *col
5671                            });
5672                            if already_idx {
5673                                continue;
5674                            }
5675                            let idx_name = match (&name, columns.len(), k) {
5676                                (Some(n), 1, _) => n.clone(),
5677                                (Some(n), _, k) => alloc::format!("{n}_{k}"),
5678                                (None, _, _) => {
5679                                    alloc::format!("{}_{col}_ftidx", s.name)
5680                                }
5681                            };
5682                            let _ = table.add_gin_fulltext_index(idx_name, col);
5683                        }
5684                    }
5685                }
5686            }
5687            spg_sql::ast::AlterTableTarget::DropColumn {
5688                column,
5689                if_exists,
5690                cascade,
5691            } => {
5692                // v7.13.3 — mailrs round-7 S8. Remove the column +
5693                // every row's value at that position; drop any index
5694                // on the column. RESTRICT (default) rejects when an
5695                // FK on this table or partial-index predicate
5696                // references the column; CASCADE removes those
5697                // dependents first.
5698                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5699                    EngineError::Storage(StorageError::TableNotFound {
5700                        name: s.name.into(),
5701                    })
5702                })?;
5703                let col_pos = match table
5704                    .schema()
5705                    .columns
5706                    .iter()
5707                    .position(|c| c.name.eq_ignore_ascii_case(&column))
5708                {
5709                    Some(p) => p,
5710                    None => {
5711                        if if_exists {
5712                            return Ok(());
5713                        }
5714                        return Err(EngineError::Unsupported(alloc::format!(
5715                            "ALTER TABLE DROP COLUMN: column {column:?} not found on {:?}",
5716                            s.name
5717                        )));
5718                    }
5719                };
5720                // Dependent check: FKs whose local columns include
5721                // col_pos. CASCADE drops them; otherwise reject.
5722                let dependent_fks: Vec<usize> = table
5723                    .schema()
5724                    .foreign_keys
5725                    .iter()
5726                    .enumerate()
5727                    .filter_map(|(i, fk)| {
5728                        if fk.local_columns.contains(&col_pos) {
5729                            Some(i)
5730                        } else {
5731                            None
5732                        }
5733                    })
5734                    .collect();
5735                if !dependent_fks.is_empty() && !cascade {
5736                    return Err(EngineError::Unsupported(alloc::format!(
5737                        "ALTER TABLE DROP COLUMN {column:?}: column has FK dependents; \
5738                         use DROP COLUMN ... CASCADE to remove them"
5739                    )));
5740                }
5741                // CASCADE the FK removals first.
5742                if cascade {
5743                    // Drop in reverse so indices stay valid.
5744                    let mut sorted = dependent_fks.clone();
5745                    sorted.sort();
5746                    sorted.reverse();
5747                    let fks = &mut table.schema_mut().foreign_keys;
5748                    for i in sorted {
5749                        fks.remove(i);
5750                    }
5751                }
5752                // Drop the column. New helper on Table does the
5753                // row + schema + index shift atomically.
5754                table.drop_column(col_pos);
5755            }
5756            spg_sql::ast::AlterTableTarget::SetTriggerEnabled { which, enabled } => {
5757                // v7.16.1 — mailrs round-9 A.2.b. pg_dump
5758                // --disable-triggers wraps each table's data
5759                // block with `ALTER TABLE … DISABLE TRIGGER ALL`
5760                // / `… ENABLE TRIGGER ALL`. Toggle the enabled
5761                // flag on every matching trigger so the row-
5762                // write paths skip them; the catalog snapshot
5763                // persists the new state across restarts.
5764                let table_name = s.name.to_string();
5765                let trigs = self.active_catalog_mut().triggers_mut();
5766                let mut touched = false;
5767                for t in trigs.iter_mut() {
5768                    if !t.table.eq_ignore_ascii_case(&table_name) {
5769                        continue;
5770                    }
5771                    match &which {
5772                        spg_sql::ast::TriggerSelector::All => {
5773                            t.enabled = enabled;
5774                            touched = true;
5775                        }
5776                        spg_sql::ast::TriggerSelector::Named(name) => {
5777                            if t.name.eq_ignore_ascii_case(name) {
5778                                t.enabled = enabled;
5779                                touched = true;
5780                            }
5781                        }
5782                    }
5783                }
5784                // PG semantics: `ALL` on a table with no
5785                // triggers is a no-op (no error). A `Named`
5786                // form pointing at a non-existent trigger
5787                // raises in PG; v7.16.1 also raises so we
5788                // don't silently lose state.
5789                if !touched {
5790                    if let spg_sql::ast::TriggerSelector::Named(name) = &which {
5791                        return Err(EngineError::Unsupported(alloc::format!(
5792                            "ALTER TABLE {table_name:?} {} TRIGGER {name:?}: no such trigger on table",
5793                            if enabled { "ENABLE" } else { "DISABLE" },
5794                        )));
5795                    }
5796                }
5797            }
5798            spg_sql::ast::AlterTableTarget::SetColumnAutoIncrement { column, seq_name } => {
5799                // pg_dump's identity form names an IMPLICIT sequence
5800                // (`… AS IDENTITY ( SEQUENCE NAME s … )`) that never
5801                // gets its own CREATE SEQUENCE statement, while the
5802                // data section still calls `setval(s, …)`. Make the
5803                // sequence exist (idempotent) so those calls land.
5804                if let Some(seq) = seq_name {
5805                    let _ = self.exec_create_sequence(spg_sql::ast::CreateSequenceStatement {
5806                        name: seq,
5807                        if_not_exists: true,
5808                        temporary: false,
5809                        data_type: None,
5810                        options: spg_sql::ast::SequenceOptions::default(),
5811                    })?;
5812                }
5813                // v7.22 (round-13 T2) — pg_dump's serial/identity
5814                // spellings (`SET DEFAULT nextval(…)` / `ADD
5815                // GENERATED … AS IDENTITY`) lower here: flip the
5816                // column's auto-increment flag so post-import
5817                // INSERTs without an explicit value keep numbering
5818                // (max+1 semantics; the dump's setval() calls are
5819                // no-ops by construction).
5820                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5821                    EngineError::Storage(StorageError::TableNotFound {
5822                        name: s.name.into(),
5823                    })
5824                })?;
5825                let pos = table
5826                    .schema()
5827                    .columns
5828                    .iter()
5829                    .position(|c| c.name.eq_ignore_ascii_case(&column))
5830                    .ok_or_else(|| {
5831                        EngineError::Unsupported(alloc::format!(
5832                            "ALTER COLUMN {column:?}: no such column on {:?}",
5833                            s.name
5834                        ))
5835                    })?;
5836                let col = &table.schema().columns[pos];
5837                if !matches!(
5838                    col.ty,
5839                    spg_storage::DataType::SmallInt
5840                        | spg_storage::DataType::Int
5841                        | spg_storage::DataType::BigInt
5842                ) {
5843                    return Err(EngineError::Unsupported(alloc::format!(
5844                        "auto-increment applies to integer columns only ({column:?} is {:?})",
5845                        col.ty
5846                    )));
5847                }
5848                table.schema_mut().columns[pos].auto_increment = true;
5849            }
5850            spg_sql::ast::AlterTableTarget::RenameTable { new } => {
5851                // v7.16.2 — table-level rename (mailrs round-10
5852                // A.5 — used by migrate-042's `ALTER TABLE
5853                // contacts RENAME TO email_contacts`). Storage
5854                // helper updates the schema + by_name index +
5855                // dangling FK / trigger references in one
5856                // atomic step.
5857                let old = s.name.to_string();
5858                self.active_catalog_mut()
5859                    .rename_table(&old, &new)
5860                    .map_err(EngineError::Storage)?;
5861            }
5862            spg_sql::ast::AlterTableTarget::RenameColumn { old, new } => {
5863                // v7.15.0 — `ALTER TABLE t RENAME [COLUMN] old TO
5864                // new`. Rename the column in the schema; rewrite
5865                // every stored source string on this table that
5866                // references it as a (potentially-qualified)
5867                // column identifier: CHECK predicates, partial-
5868                // index predicates, runtime DEFAULT expressions.
5869                // Then walk catalog triggers on this table and
5870                // patch any `UPDATE OF` column list. Function and
5871                // trigger bodies are NOT auto-rewritten — that
5872                // surface is dynamic SQL territory; users update
5873                // those separately (matches PG plpgsql behavior:
5874                // a column rename invalidates name-referencing
5875                // plpgsql at call time, not rename time).
5876                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5877                    EngineError::Storage(StorageError::TableNotFound {
5878                        name: s.name.into(),
5879                    })
5880                })?;
5881                let col_pos = table
5882                    .schema()
5883                    .columns
5884                    .iter()
5885                    .position(|c| c.name.eq_ignore_ascii_case(&old))
5886                    .ok_or_else(|| {
5887                        EngineError::Unsupported(alloc::format!(
5888                            "ALTER TABLE RENAME COLUMN: column {old:?} not found on {:?}",
5889                            s.name
5890                        ))
5891                    })?;
5892                // Reject same-name (case-insensitive) collision.
5893                if table
5894                    .schema()
5895                    .columns
5896                    .iter()
5897                    .enumerate()
5898                    .any(|(i, c)| i != col_pos && c.name.eq_ignore_ascii_case(&new))
5899                {
5900                    return Err(EngineError::Unsupported(alloc::format!(
5901                        "ALTER TABLE RENAME COLUMN: column {new:?} already exists on {:?}",
5902                        s.name
5903                    )));
5904                }
5905                // Schema rename first — even idempotent same-name
5906                // rename (`ALTER TABLE t RENAME a TO a`) needs to
5907                // be a no-op, not an error.
5908                if old.eq_ignore_ascii_case(&new) {
5909                    return Ok(());
5910                }
5911                table.rename_column(col_pos, &new);
5912                // Rewrite per-column runtime_default sources on
5913                // every column of this table — a DEFAULT expression
5914                // on column X may reference column Y by name (rare,
5915                // but legal in PG when the value is supplied via a
5916                // function that takes the row).
5917                let n_cols = table.schema().columns.len();
5918                for i in 0..n_cols {
5919                    let rt = table.schema().columns[i].runtime_default.clone();
5920                    if let Some(src) = rt {
5921                        let rewritten = rewrite_column_in_source(&src, &old, &new)?;
5922                        table.schema_mut().columns[i].runtime_default = Some(rewritten);
5923                    }
5924                }
5925                // Rewrite table-level CHECK predicates.
5926                let checks = table.schema().checks.clone();
5927                let mut new_checks = Vec::with_capacity(checks.len());
5928                for chk in checks {
5929                    new_checks.push(rewrite_column_in_source(&chk, &old, &new)?);
5930                }
5931                table.schema_mut().checks = new_checks;
5932                // Rewrite per-index partial_predicate sources.
5933                let n_idx = table.indices().len();
5934                for i in 0..n_idx {
5935                    let pred = table.indices()[i].partial_predicate.clone();
5936                    if let Some(src) = pred {
5937                        let rewritten = rewrite_column_in_source(&src, &old, &new)?;
5938                        // SAFETY: indices_mut would be cleanest, but
5939                        // partial_predicate is the only mutable field
5940                        // here; reach in via the public mut accessor.
5941                        table.set_partial_predicate(i, Some(rewritten));
5942                    }
5943                }
5944                // Walk catalog triggers; patch `update_columns` on
5945                // triggers attached to this table.
5946                let table_name = s.name.to_string();
5947                for trig in self.active_catalog_mut().triggers_mut() {
5948                    if !trig.table.eq_ignore_ascii_case(&table_name) {
5949                        continue;
5950                    }
5951                    for c in &mut trig.update_columns {
5952                        if c.eq_ignore_ascii_case(&old) {
5953                            *c = new.clone();
5954                        }
5955                    }
5956                }
5957            }
5958        }
5959        Ok(())
5960    }
5961
5962    fn exec_alter_index(
5963        &mut self,
5964        stmt: spg_sql::ast::AlterIndexStatement,
5965    ) -> Result<QueryResult, EngineError> {
5966        // Translate the optional SQL-side encoding choice into the
5967        // storage-side enum; the same SqlVecEncoding -> VecEncoding
5968        // bridge `column_type_to_data_type` uses.
5969        let spg_sql::ast::AlterIndexStatement {
5970            name: idx_name,
5971            target,
5972        } = stmt;
5973        // v7.16.2 — RENAME TO branch (mailrs round-10 migrate-042).
5974        // IF EXISTS makes a missing index a no-op rather than an
5975        // error, mirroring PG semantics.
5976        if let spg_sql::ast::AlterIndexTarget::Rename { new, if_exists } = target {
5977            let renamed = self.active_catalog_mut().rename_index(&idx_name, &new);
5978            return match renamed {
5979                Ok(()) => Ok(QueryResult::CommandOk {
5980                    affected: 0,
5981                    modified_catalog: !self.in_transaction(),
5982                }),
5983                Err(StorageError::IndexNotFound { .. }) if if_exists => {
5984                    Ok(QueryResult::CommandOk {
5985                        affected: 0,
5986                        modified_catalog: false,
5987                    })
5988                }
5989                Err(e) => Err(EngineError::Storage(e)),
5990            };
5991        }
5992        let spg_sql::ast::AlterIndexTarget::Rebuild { encoding } = target else {
5993            unreachable!("Rename branch returned above");
5994        };
5995        let target = encoding.map(|e| match e {
5996            SqlVecEncoding::F32 => VecEncoding::F32,
5997            SqlVecEncoding::Sq8 => VecEncoding::Sq8,
5998            SqlVecEncoding::F16 => VecEncoding::F16,
5999        });
6000        // Linear scan: index names are globally unique within a
6001        // catalog (enforced by add_nsw_index_inner) so the first
6002        // match is the only one. Save the table name to avoid
6003        // borrowing while we then take a mut borrow.
6004        let table_name = {
6005            let cat = self.active_catalog();
6006            let mut found: Option<String> = None;
6007            for tname in cat.table_names() {
6008                if let Some(t) = cat.get(&tname)
6009                    && t.indices().iter().any(|i| i.name == idx_name)
6010                {
6011                    found = Some(tname);
6012                    break;
6013                }
6014            }
6015            found.ok_or_else(|| {
6016                EngineError::Storage(StorageError::IndexNotFound {
6017                    name: idx_name.clone(),
6018                })
6019            })?
6020        };
6021        let table = self
6022            .active_catalog_mut()
6023            .get_mut(&table_name)
6024            .expect("table found above");
6025        table.rebuild_nsw_index(&idx_name, target)?;
6026        // v6.3.1 — ALTER INDEX REBUILD potentially with new encoding
6027        // changes cost characteristics; evict any cached plans.
6028        self.plan_cache.evict_referencing(&table_name);
6029        Ok(QueryResult::CommandOk {
6030            affected: 0,
6031            modified_catalog: !self.in_transaction(),
6032        })
6033    }
6034
6035    fn exec_create_index(
6036        &mut self,
6037        stmt: CreateIndexStatement,
6038    ) -> Result<QueryResult, EngineError> {
6039        let table = self
6040            .active_catalog_mut()
6041            .get_mut(&stmt.table)
6042            .ok_or_else(|| {
6043                EngineError::Storage(StorageError::TableNotFound {
6044                    name: stmt.table.clone(),
6045                })
6046            })?;
6047        // `IF NOT EXISTS` reduces DuplicateIndex to a no-op CommandOk.
6048        if stmt.if_not_exists && table.indices().iter().any(|i| i.name == stmt.name) {
6049            return Ok(QueryResult::CommandOk {
6050                affected: 0,
6051                modified_catalog: false,
6052            });
6053        }
6054        // v7.9.14 — multi-column index parses through; engine
6055        // builds a single-column BTree on the leading column only.
6056        // The extras live on the AST so spg-server's dispatcher
6057        // can emit a PG-wire NoticeResponse / log line. Composite
6058        // BTree keys land in v7.10.
6059        let _ = &stmt.extra_columns; // intentional drop on engine side
6060        let table_name = stmt.table.clone();
6061        // v6.8.0 — resolve INCLUDE column names to positions. Done
6062        // before `add_index` so a typo error surfaces before any
6063        // catalog mutation lands.
6064        let included_positions: Vec<usize> = if stmt.included_columns.is_empty() {
6065            Vec::new()
6066        } else {
6067            let schema = table.schema();
6068            stmt.included_columns
6069                .iter()
6070                .map(|c| {
6071                    schema.column_position(c).ok_or_else(|| {
6072                        EngineError::Storage(StorageError::ColumnNotFound { column: c.clone() })
6073                    })
6074                })
6075                .collect::<Result<Vec<_>, _>>()?
6076        };
6077        match stmt.method {
6078            IndexMethod::BTree => table.add_index(stmt.name.clone(), &stmt.column)?,
6079            IndexMethod::Hnsw => {
6080                if !included_positions.is_empty() {
6081                    return Err(EngineError::Unsupported(
6082                        "INCLUDE columns are not supported on HNSW indexes".into(),
6083                    ));
6084                }
6085                table.add_nsw_index(stmt.name.clone(), &stmt.column, spg_storage::NSW_DEFAULT_M)?;
6086            }
6087            // v6.7.1 — BRIN. Pure metadata; no in-memory data.
6088            IndexMethod::Brin => {
6089                if !included_positions.is_empty() {
6090                    return Err(EngineError::Unsupported(
6091                        "INCLUDE columns are not supported on BRIN indexes".into(),
6092                    ));
6093                }
6094                table.add_brin_index(stmt.name.clone(), &stmt.column)?;
6095            }
6096            // v7.12.3 — GIN inverted index. Real posting-list-backed
6097            // GIN when the indexed column is `tsvector`; falls back
6098            // to a BTree on the leading column for any other column
6099            // type so v7.9.26b's `pg_dump` compatibility (GIN on
6100            // JSONB etc. silently loading as BTree) is preserved.
6101            // Operators see the real GIN only where it matters; old
6102            // schemas keep loading.
6103            IndexMethod::Gin => {
6104                if !included_positions.is_empty() {
6105                    return Err(EngineError::Unsupported(
6106                        "INCLUDE columns are not supported on GIN indexes".into(),
6107                    ));
6108                }
6109                let col_pos = table
6110                    .schema()
6111                    .column_position(&stmt.column)
6112                    .ok_or_else(|| {
6113                        EngineError::Storage(StorageError::ColumnNotFound {
6114                            column: stmt.column.clone(),
6115                        })
6116                    })?;
6117                let col_ty = table.schema().columns[col_pos].ty;
6118                // v7.15.0 — `gin_trgm_ops` on a TEXT/VARCHAR
6119                // column dispatches to the real trigram-shingle
6120                // GIN build (LIKE / similarity acceleration).
6121                // Other GIN opclasses fall through to the regular
6122                // tsvector-vs-BTree split below.
6123                let is_trgm = stmt
6124                    .opclass
6125                    .as_deref()
6126                    .is_some_and(|op| op.eq_ignore_ascii_case("gin_trgm_ops"));
6127                if is_trgm
6128                    && matches!(
6129                        col_ty,
6130                        spg_storage::DataType::Text | spg_storage::DataType::Varchar(_)
6131                    )
6132                {
6133                    table
6134                        .add_gin_trgm_index(stmt.name.clone(), &stmt.column)
6135                        .map_err(EngineError::Storage)?;
6136                } else if col_ty == spg_storage::DataType::TsVector {
6137                    table
6138                        .add_gin_index(stmt.name.clone(), &stmt.column)
6139                        .map_err(EngineError::Storage)?;
6140                } else {
6141                    // v7.9.26b BTree fallback — the catalog still
6142                    // gets an index entry on the leading column so
6143                    // pg_dump scripts that name GIN on JSONB / etc.
6144                    // load clean; query-time gain stays opt-in for
6145                    // tsvector callers.
6146                    table.add_index(stmt.name.clone(), &stmt.column)?;
6147                }
6148            }
6149        }
6150        if !included_positions.is_empty()
6151            && let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name)
6152        {
6153            idx.included_columns = included_positions;
6154        }
6155        // v6.8.1 — persist partial-index predicate. Stored as the
6156        // expression's Display form so the catalog snapshot stays
6157        // pure (storage has no spg-sql dependency). The runtime
6158        // maintenance path treats partial indexes identically to
6159        // full indexes for v6.8.1 (over-maintenance is safe; the
6160        // planner-side "use partial when query WHERE implies the
6161        // predicate" pass is STABILITY carve-out).
6162        if let Some(pred_expr) = &stmt.partial_predicate {
6163            let canonical = pred_expr.to_string();
6164            // v7.13.2 — mailrs round-6 S2. PG's `pg_trgm` uses
6165            // `CREATE INDEX … USING gin(col gin_trgm_ops) WHERE …`
6166            // routinely to slim trigram indexes. SPG now persists
6167            // the predicate for GIN / BRIN / HNSW the same way it
6168            // already does for BTree — same v6.8.1 "over-maintain
6169            // is safe; planner-side partial routing is STABILITY
6170            // carve-out" semantics. HNSW carries an additional
6171            // caveat: the predicate isn't applied at index build
6172            // time (would require per-row eval inside the NSW
6173            // construction loop), so the index oversamples; query
6174            // time the WHERE clause still filters correctly.
6175            if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
6176                idx.partial_predicate = Some(canonical);
6177            }
6178        }
6179        // v6.8.2 — persist expression index key. Same Display-form
6180        // storage; the runtime maintenance pass evaluates each
6181        // row's expression to derive the index key, but for v6.8.2
6182        // the engine falls through to the bare-column-reference
6183        // path and the expression is preserved for format-layer
6184        // round-trip + future planner work. Carved-out in
6185        // STABILITY § "Out of v6.8".
6186        if let Some(key_expr) = &stmt.expression {
6187            if matches!(
6188                stmt.method,
6189                IndexMethod::Hnsw | IndexMethod::Brin | IndexMethod::Gin
6190            ) {
6191                return Err(EngineError::Unsupported(
6192                    "Expression keys are not supported on HNSW or BRIN indexes".into(),
6193                ));
6194            }
6195            let canonical = key_expr.to_string();
6196            if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
6197                idx.expression = Some(canonical);
6198            }
6199        }
6200        // v7.9.29 — persist `is_unique` flag on the storage Index.
6201        // Combined with `partial_predicate`, INSERT enforcement
6202        // checks that no other row whose predicate evaluates true
6203        // shares the same indexed key. Parser already rejected
6204        // `UNIQUE` on HNSW / BRIN, so plain BTree here.
6205        // For multi-column UNIQUE INDEX the extras matter (the
6206        // full tuple is the uniqueness key), so resolve them to
6207        // column positions and persist on the index too.
6208        if stmt.is_unique {
6209            let mut extra_positions: alloc::vec::Vec<usize> = alloc::vec::Vec::new();
6210            for col_name in &stmt.extra_columns {
6211                let pos = table
6212                    .schema()
6213                    .columns
6214                    .iter()
6215                    .position(|c| c.name.eq_ignore_ascii_case(col_name))
6216                    .ok_or_else(|| {
6217                        EngineError::Unsupported(alloc::format!(
6218                            "UNIQUE INDEX {:?}: extra column {col_name:?} not in table {:?}",
6219                            stmt.name,
6220                            stmt.table
6221                        ))
6222                    })?;
6223                extra_positions.push(pos);
6224            }
6225            if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
6226                idx.is_unique = true;
6227                idx.extra_column_positions = extra_positions;
6228            }
6229            // At index-creation time, check the existing rows for
6230            // pre-existing duplicates that would have violated the
6231            // new constraint — otherwise CREATE UNIQUE INDEX would
6232            // silently leave duplicates in place.
6233            let snapshot_indices = table.indices().to_vec();
6234            let snapshot_rows: alloc::vec::Vec<spg_storage::Row> =
6235                table.rows().iter().cloned().collect();
6236            let snapshot_schema = table.schema().clone();
6237            let idx_ref = snapshot_indices
6238                .iter()
6239                .find(|i| i.name == stmt.name)
6240                .expect("just-added index");
6241            check_existing_unique_violation(idx_ref, &snapshot_schema, &snapshot_rows)?;
6242        }
6243        // v6.3.1 — adding an index can change the optimal plan for
6244        // any cached query that references this table.
6245        self.plan_cache.evict_referencing(&table_name);
6246        Ok(QueryResult::CommandOk {
6247            affected: 0,
6248            modified_catalog: !self.in_transaction(),
6249        })
6250    }
6251
6252    /// v7.13.3 — mailrs round-7 S9. SPG-specific reconciliation
6253    /// for `CREATE TABLE IF NOT EXISTS` when the table already
6254    /// exists. Adds missing columns + inline FKs from the new
6255    /// definition; existing columns / constraints stay untouched.
6256    /// New columns with a `NOT NULL` declaration without a
6257    /// `DEFAULT` are reported as a clear error rather than
6258    /// silently dropped — this is the "fail loud on real
6259    /// incompatibility, fail silent on schema-superset" tradeoff.
6260    fn reconcile_table_if_not_exists(
6261        &mut self,
6262        stmt: CreateTableStatement,
6263    ) -> Result<QueryResult, EngineError> {
6264        let table_name = stmt.name.clone();
6265        let clock = self.clock;
6266        let existing_col_names: alloc::collections::BTreeSet<String> = self
6267            .active_catalog()
6268            .get(&table_name)
6269            .expect("checked above")
6270            .schema()
6271            .columns
6272            .iter()
6273            .map(|c| c.name.to_ascii_lowercase())
6274            .collect();
6275        let row_count = self
6276            .active_catalog()
6277            .get(&table_name)
6278            .expect("checked above")
6279            .row_count();
6280        // Collect missing column defs in source order.
6281        let new_columns: alloc::vec::Vec<spg_sql::ast::ColumnDef> = stmt
6282            .columns
6283            .iter()
6284            .filter(|c| !existing_col_names.contains(&c.name.to_ascii_lowercase()))
6285            .cloned()
6286            .collect();
6287        for col_def in new_columns {
6288            let col_name = col_def.name.clone();
6289            let nullable = col_def.nullable;
6290            let has_default = col_def.default.is_some() || col_def.auto_increment;
6291            let col_schema = column_def_to_schema(col_def)?;
6292            let fill_value: Value = if has_default || col_schema.runtime_default.is_some() {
6293                resolve_column_default_free(&col_schema, clock)?
6294            } else if nullable || row_count == 0 {
6295                Value::Null
6296            } else {
6297                return Err(EngineError::Unsupported(alloc::format!(
6298                    "CREATE TABLE IF NOT EXISTS {table_name:?}: reconciling \
6299                     column {col_name:?} requires DEFAULT (existing rows would violate NOT NULL)"
6300                )));
6301            };
6302            let table = self
6303                .active_catalog_mut()
6304                .get_mut(&table_name)
6305                .expect("checked above");
6306            table.add_column(col_schema, fill_value);
6307        }
6308        // Resolve any newly-added inline FKs (column-level
6309        // REFERENCES forms) and install. Skip FKs whose local
6310        // columns we didn't have in the existing table.
6311        let table_cols_now = self
6312            .active_catalog()
6313            .get(&table_name)
6314            .expect("checked above")
6315            .schema()
6316            .columns
6317            .clone();
6318        for fk in stmt.foreign_keys {
6319            // Only install FKs whose every local column resolves
6320            // — older catalogs may have a column the new FK
6321            // references but not the column the new FK declares.
6322            let all_resolved = fk.columns.iter().all(|c| {
6323                table_cols_now
6324                    .iter()
6325                    .any(|sc| sc.name.eq_ignore_ascii_case(c))
6326            });
6327            if !all_resolved {
6328                continue;
6329            }
6330            let already_present = {
6331                let table = self
6332                    .active_catalog()
6333                    .get(&table_name)
6334                    .expect("checked above");
6335                table.schema().foreign_keys.iter().any(|f| {
6336                    f.parent_table.eq_ignore_ascii_case(&fk.parent_table)
6337                        && f.local_columns.len() == fk.columns.len()
6338                })
6339            };
6340            if already_present {
6341                continue;
6342            }
6343            let storage_fk =
6344                resolve_foreign_key(&table_name, &table_cols_now, fk, self.active_catalog())?;
6345            let table = self
6346                .active_catalog_mut()
6347                .get_mut(&table_name)
6348                .expect("checked above");
6349            table.schema_mut().foreign_keys.push(storage_fk);
6350        }
6351        Ok(QueryResult::CommandOk {
6352            affected: 0,
6353            modified_catalog: !self.in_transaction(),
6354        })
6355    }
6356
6357    /// v7.14.0 — DROP TABLE handler (pg_dump / mysqldump preamble).
6358    fn exec_drop_table(
6359        &mut self,
6360        names: Vec<String>,
6361        if_exists: bool,
6362    ) -> Result<QueryResult, EngineError> {
6363        for name in names {
6364            let dropped = self.active_catalog_mut().drop_table(&name);
6365            if !dropped && !if_exists {
6366                return Err(EngineError::Storage(StorageError::TableNotFound { name }));
6367            }
6368        }
6369        Ok(QueryResult::CommandOk {
6370            affected: 0,
6371            modified_catalog: !self.in_transaction(),
6372        })
6373    }
6374
6375    /// v7.14.0 — DROP INDEX handler.
6376    fn exec_drop_index(
6377        &mut self,
6378        name: String,
6379        if_exists: bool,
6380    ) -> Result<QueryResult, EngineError> {
6381        let dropped = self.active_catalog_mut().drop_named_index(&name);
6382        if !dropped && !if_exists {
6383            return Err(EngineError::Storage(StorageError::IndexNotFound { name }));
6384        }
6385        Ok(QueryResult::CommandOk {
6386            affected: 0,
6387            modified_catalog: !self.in_transaction(),
6388        })
6389    }
6390
6391    fn exec_create_table(
6392        &mut self,
6393        stmt: CreateTableStatement,
6394    ) -> Result<QueryResult, EngineError> {
6395        if stmt.if_not_exists && self.active_catalog().get(&stmt.name).is_some() {
6396            // v7.16.2 — PG-strict silent no-op (mailrs round-10
6397            // surfaced this). v7.13.3's "reconcile by adding
6398            // missing columns" was friendly for mailrs round-7
6399            // where init-schema's `contacts` and migrate-023's
6400            // CardDAV `contacts` collided; but it ALSO silently
6401            // added columns to existing tables when later
6402            // migrations had a duplicate `CREATE TABLE IF NOT
6403            // EXISTS <t> (different-shape-cols)` shape. mailrs's
6404            // migrate-030 has exactly that — re-declares
6405            // system_config with `key` even though init-schema
6406            // already created it with `config_key`. PG's silent
6407            // no-op leaves system_config at `config_key`;
6408            // v7.13.3 added a phantom `key` column that then
6409            // tripped migrate-040's idempotent rename guard.
6410            // mailrs v1.7.106 ships the proper PG-style
6411            // contacts rename via DO + IF EXISTS, so SPG can
6412            // revert to PG-strict here without re-breaking the
6413            // round-7 case.
6414            return Ok(QueryResult::CommandOk {
6415                affected: 0,
6416                modified_catalog: false,
6417            });
6418        }
6419        let table_name = stmt.name.clone();
6420        // v7.9.13 — pluck the names of any columns marked
6421        // `PRIMARY KEY` inline so the post-create-table pass can
6422        // build an implicit BTree index. mailrs F1.
6423        let inline_pk_columns: Vec<String> = stmt
6424            .columns
6425            .iter()
6426            .filter(|c| c.is_primary_key)
6427            .map(|c| c.name.clone())
6428            .collect();
6429        // v7.9.19 — table-level constraints: PRIMARY KEY (a, b, ...)
6430        // and UNIQUE (a, b, ...). Each builds a BTree index on the
6431        // leading column (the existing single-column storage tier)
6432        // and registers a UniquenessConstraint on the schema for
6433        // INSERT-time enforcement of the full tuple. mailrs G1/G6.
6434        let cols = stmt
6435            .columns
6436            .into_iter()
6437            .map(column_def_to_schema)
6438            .collect::<Result<Vec<_>, _>>()?;
6439        // v7.17.0 Phase 1.4 + 1.5 — classify every raw
6440        // user_type_ref (parked as user_enum_type by
6441        // column_def_to_schema) into either an enum binding or a
6442        // domain binding. For domains, also rewrite the column's
6443        // base DataType from the placeholder Text to the domain's
6444        // declared base. Unknown idents are still a hard error
6445        // here (same as Phase 1.4) so silent acceptance never
6446        // happens.
6447        let mut cols = cols;
6448        for col in cols.iter_mut() {
6449            let Some(name) = col.user_enum_type.take() else {
6450                continue;
6451            };
6452            let cat = self.active_catalog();
6453            if cat.enum_types().contains_key(&name) {
6454                col.user_enum_type = Some(name);
6455                continue;
6456            }
6457            if let Some(dom) = cat.domain_types().get(&name) {
6458                col.ty = dom.base_type;
6459                col.user_domain_type = Some(name);
6460                if !dom.nullable {
6461                    col.nullable = false;
6462                }
6463                continue;
6464            }
6465            return Err(EngineError::Unsupported(alloc::format!(
6466                "column {:?}: unknown column type {:?} (not a built-in, ENUM, or DOMAIN)",
6467                col.name,
6468                name
6469            )));
6470        }
6471        for tc in &stmt.table_constraints {
6472            if let spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } = tc {
6473                for col_name in columns {
6474                    if let Some(col) = cols.iter_mut().find(|c| c.name == *col_name) {
6475                        col.nullable = false;
6476                    }
6477                }
6478            }
6479        }
6480        // v7.6.1 — resolve every FK in the statement against the
6481        // already-known catalog. Validates: parent table exists,
6482        // parent column names exist, arity matches, parent columns
6483        // have a PK / UNIQUE index. Self-referencing FKs (parent
6484        // table == this table) resolve against the column list we
6485        // just built — they don't need the catalog yet.
6486        let mut fks: Vec<spg_storage::ForeignKeyConstraint> =
6487            Vec::with_capacity(stmt.foreign_keys.len());
6488        for fk in stmt.foreign_keys {
6489            // v7.14.0 — when SET FOREIGN_KEY_CHECKS=0 is in effect
6490            // (mysqldump preamble + bulk imports), defer FK
6491            // resolution if the parent table isn't in the catalog
6492            // yet. The FK is queued and resolved when checks flip
6493            // back on. Self-references stay in-band (the parent is
6494            // the same as the child we're building).
6495            let needs_parent = !fk.parent_table.eq_ignore_ascii_case(&table_name);
6496            if !self.foreign_key_checks
6497                && needs_parent
6498                && self.active_catalog().get(&fk.parent_table).is_none()
6499            {
6500                self.pending_foreign_keys.push((table_name.clone(), fk));
6501                continue;
6502            }
6503            fks.push(resolve_foreign_key(
6504                &table_name,
6505                &cols,
6506                fk,
6507                self.active_catalog(),
6508            )?);
6509        }
6510        let mut schema = TableSchema::new(table_name.clone(), cols);
6511        schema.foreign_keys = fks;
6512        // v7.9.19 — translate AST table_constraints to storage
6513        // UniquenessConstraints (column name → position) so the
6514        // INSERT enforcement helper sees positions directly.
6515        let mut uc_storage: Vec<spg_storage::UniquenessConstraint> = Vec::new();
6516        let mut check_exprs: Vec<String> = Vec::new();
6517        for tc in &stmt.table_constraints {
6518            let (is_pk, names, nnd) = match tc {
6519                spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } => {
6520                    (true, columns.clone(), false)
6521                }
6522                spg_sql::ast::TableConstraint::Unique {
6523                    columns,
6524                    nulls_not_distinct,
6525                    ..
6526                } => (false, columns.clone(), *nulls_not_distinct),
6527                spg_sql::ast::TableConstraint::Check { expr, .. } => {
6528                    // v7.13.0 — collect CHECK predicate sources;
6529                    // they get attached to the schema below.
6530                    check_exprs.push(alloc::format!("{expr}"));
6531                    continue;
6532                }
6533                // v7.15.0 — plain `KEY (cols)` from MySQL inline
6534                // is NOT a uniqueness constraint; skip the UC
6535                // build path entirely. The BTree index lands in
6536                // the post-create loop below alongside the PK/UQ
6537                // implicit indexes.
6538                spg_sql::ast::TableConstraint::Index { .. } => continue,
6539                // v7.17.0 Phase 2.2 — MySQL FULLTEXT KEY is not
6540                // a uniqueness constraint either; its GIN gets
6541                // built in the post-create loop below.
6542                spg_sql::ast::TableConstraint::FulltextIndex { .. } => continue,
6543            };
6544            let mut positions = Vec::with_capacity(names.len());
6545            for n in &names {
6546                let pos = schema
6547                    .columns
6548                    .iter()
6549                    .position(|c| c.name == *n)
6550                    .ok_or_else(|| {
6551                        EngineError::Unsupported(alloc::format!(
6552                            "table constraint references unknown column {n:?}"
6553                        ))
6554                    })?;
6555                positions.push(pos);
6556            }
6557            uc_storage.push(spg_storage::UniquenessConstraint {
6558                is_primary_key: is_pk,
6559                columns: positions,
6560                nulls_not_distinct: nnd,
6561            });
6562        }
6563        // v7.24 (round-16 collateral) — inline `PRIMARY KEY` column
6564        // constraints used to build only the implicit BTree index;
6565        // uniqueness was NEVER registered, so duplicate keys were
6566        // silently accepted (table-level PRIMARY KEY did enforce).
6567        // Register the same UniquenessConstraint the table-level
6568        // form gets, unless one already covers the column set.
6569        if !inline_pk_columns.is_empty() {
6570            let mut positions = Vec::with_capacity(inline_pk_columns.len());
6571            for n in &inline_pk_columns {
6572                if let Some(pos) = schema.columns.iter().position(|c| c.name == *n) {
6573                    positions.push(pos);
6574                }
6575            }
6576            if !uc_storage
6577                .iter()
6578                .any(|uc| uc.is_primary_key || uc.columns == positions)
6579            {
6580                uc_storage.push(spg_storage::UniquenessConstraint {
6581                    is_primary_key: true,
6582                    columns: positions,
6583                    nulls_not_distinct: false,
6584                });
6585            }
6586        }
6587        schema.uniqueness_constraints = uc_storage.clone();
6588        schema.checks = check_exprs;
6589        self.active_catalog_mut().create_table(schema)?;
6590        // v7.9.13 — implicit BTree per inline PK column +
6591        // v7.9.19 — implicit BTree on the leading column of every
6592        // table-level PRIMARY KEY / UNIQUE constraint.
6593        let table = self
6594            .active_catalog_mut()
6595            .get_mut(&table_name)
6596            .expect("just created");
6597        for (i, col_name) in inline_pk_columns.iter().enumerate() {
6598            let idx_name = if inline_pk_columns.len() == 1 {
6599                alloc::format!("{table_name}_pkey")
6600            } else {
6601                alloc::format!("{table_name}_pkey_{i}")
6602            };
6603            if let Err(e) = table.add_index(idx_name, col_name) {
6604                return Err(EngineError::Storage(e));
6605            }
6606        }
6607        for (i, tc) in stmt.table_constraints.iter().enumerate() {
6608            // v7.17.0 Phase 2.2 — FULLTEXT KEY lands a real
6609            // tsvector-GIN per declared column instead of the
6610            // BTree the PK / UQ / KEY paths build. Branch early
6611            // so the BTree loop never sees the FULLTEXT shape.
6612            if let spg_sql::ast::TableConstraint::FulltextIndex { name, columns } = tc {
6613                for (k, col) in columns.iter().enumerate() {
6614                    let already = table.indices().iter().any(|idx| {
6615                        matches!(idx.kind, spg_storage::IndexKind::GinFulltext(_))
6616                            && table.schema().columns[idx.column_position].name == *col
6617                    });
6618                    if already {
6619                        continue;
6620                    }
6621                    let idx_name = match (name.as_ref(), columns.len(), k) {
6622                        (Some(n), 1, _) => n.clone(),
6623                        (Some(n), _, k) => alloc::format!("{n}_{k}"),
6624                        (None, _, _) => {
6625                            alloc::format!("{table_name}_{col}_ftidx")
6626                        }
6627                    };
6628                    if let Err(e) = table.add_gin_fulltext_index(idx_name, col) {
6629                        return Err(EngineError::Storage(e));
6630                    }
6631                }
6632                continue;
6633            }
6634            // v7.15.0 — plain KEY/INDEX rides this same loop so
6635            // the implicit BTree gets built. It carries its own
6636            // user-supplied name; PK/UQ still synthesise.
6637            let (suffix, names, explicit_name): (&str, &Vec<String>, Option<&String>) = match tc {
6638                spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } => {
6639                    ("pkey", columns, None)
6640                }
6641                spg_sql::ast::TableConstraint::Unique { columns, .. } => ("key", columns, None),
6642                spg_sql::ast::TableConstraint::Index { name, columns } => {
6643                    ("idx", columns, name.as_ref())
6644                }
6645                spg_sql::ast::TableConstraint::Check { .. } => continue,
6646                // Handled by the early-branch above.
6647                spg_sql::ast::TableConstraint::FulltextIndex { .. } => continue,
6648            };
6649            let leading = &names[0];
6650            // Skip if a same-column BTree already exists (e.g.
6651            // inline PK on the leading column).
6652            let already = table.indices().iter().any(|idx| {
6653                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
6654                    && table.schema().columns[idx.column_position].name == *leading
6655            });
6656            if already {
6657                continue;
6658            }
6659            let idx_name = if let Some(n) = explicit_name {
6660                n.clone()
6661            } else if names.len() == 1 {
6662                alloc::format!("{table_name}_{leading}_{suffix}")
6663            } else {
6664                alloc::format!("{table_name}_{leading}_{suffix}_{i}")
6665            };
6666            if let Err(e) = table.add_index(idx_name, leading) {
6667                return Err(EngineError::Storage(e));
6668            }
6669        }
6670        Ok(QueryResult::CommandOk {
6671            affected: 0,
6672            modified_catalog: !self.in_transaction(),
6673        })
6674    }
6675
6676    fn exec_insert(&mut self, mut stmt: InsertStatement) -> Result<QueryResult, EngineError> {
6677        // v7.17.0 Phase 1.1 — pre-resolve any nextval / currval /
6678        // setval calls against the catalog before the row loop. We
6679        // walk each tuple expression and replace matching
6680        // FunctionCall nodes with their concrete Literal. This
6681        // keeps `literal_expr_to_value` free of `&mut self` and
6682        // lets multi-row INSERT VALUES (… nextval('seq') …)
6683        // mint a separate sequence value per row.
6684        for tuple in &mut stmt.rows {
6685            for cell in tuple.iter_mut() {
6686                self.resolve_sequence_calls_in_expr(cell)?;
6687            }
6688        }
6689        // v7.13.0 — `INSERT INTO t [(cols)] SELECT …` (mailrs
6690        // round-5 G4). Execute the inner SELECT first, then route
6691        // back through the regular VALUES code path with the
6692        // materialised rows.
6693        if let Some(select) = stmt.select_source.clone() {
6694            let select_result = self.exec_select_cancel(&select, CancelToken::none())?;
6695            let rows = match select_result {
6696                QueryResult::Rows { rows, .. } => rows,
6697                other => {
6698                    return Err(EngineError::Unsupported(alloc::format!(
6699                        "INSERT … SELECT: inner statement produced {other:?} instead of a row set"
6700                    )));
6701                }
6702            };
6703            let mut materialised: Vec<Vec<Expr>> = Vec::with_capacity(rows.len());
6704            for row in rows {
6705                let mut tuple: Vec<Expr> = Vec::with_capacity(row.values.len());
6706                for v in row.values {
6707                    tuple.push(value_to_literal_expr_permissive(v)?);
6708                }
6709                materialised.push(tuple);
6710            }
6711            let recurse = InsertStatement {
6712                table: stmt.table,
6713                columns: stmt.columns,
6714                rows: materialised,
6715                select_source: None,
6716                on_conflict: stmt.on_conflict,
6717                returning: stmt.returning,
6718            };
6719            return self.exec_insert(recurse);
6720        }
6721        // v7.9.21 — snapshot the clock fn pointer before the mut
6722        // borrow on the catalog opens; runtime DEFAULT eval needs
6723        // it inside the row hot loop.
6724        let clock = self.clock;
6725        // v7.12.4 — snapshot row-level triggers + their referenced
6726        // functions before the mut borrow on the catalog opens.
6727        // Cloned out so the row hot loop can fire them without
6728        // re-borrowing the catalog (which would conflict with
6729        // table.insert's mutable borrow).
6730        let before_insert_triggers = self.snapshot_row_triggers(&stmt.table, "INSERT", "BEFORE");
6731        let after_insert_triggers = self.snapshot_row_triggers(&stmt.table, "INSERT", "AFTER");
6732        let trigger_session_cfg: Option<alloc::string::String> = self
6733            .session_params
6734            .get("default_text_search_config")
6735            .cloned();
6736        // v7.17.0 Phase 1.4 — snapshot the enum label lookup BEFORE
6737        // opening the mutable borrow on the table below. We need
6738        // catalog-level read access (enum_types lives at the
6739        // catalog level, not the table) and the upcoming mutable
6740        // borrow shadows it.
6741        let pre_borrow_column_meta: Vec<ColumnSchema> = {
6742            let preview_table = self.active_catalog().get(&stmt.table).ok_or_else(|| {
6743                EngineError::Storage(StorageError::TableNotFound {
6744                    name: stmt.table.clone(),
6745                })
6746            })?;
6747            preview_table.schema().columns.clone()
6748        };
6749        let enum_label_lookup: alloc::collections::BTreeMap<usize, Vec<String>> =
6750            pre_borrow_column_meta
6751                .iter()
6752                .enumerate()
6753                .filter_map(|(i, col)| {
6754                    // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM
6755                    // variant lists take priority over the PG
6756                    // catalog enum_types lookup (they're
6757                    // column-local and authoritative when set).
6758                    if let Some(inline) = &col.inline_enum_variants {
6759                        return Some((i, inline.clone()));
6760                    }
6761                    col.user_enum_type.as_ref().and_then(|ename| {
6762                        self.active_catalog()
6763                            .enum_types()
6764                            .get(ename)
6765                            .map(|e| (i, e.labels.clone()))
6766                    })
6767                })
6768                .collect();
6769        // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant lists.
6770        // Distinct from enum_label_lookup: SET validates that
6771        // every comma-separated token is in the variant list, and
6772        // canonicalises the cell to definition-order de-duped text.
6773        let set_variant_lookup: alloc::collections::BTreeMap<usize, Vec<String>> =
6774            pre_borrow_column_meta
6775                .iter()
6776                .enumerate()
6777                .filter_map(|(i, col)| col.inline_set_variants.as_ref().map(|vs| (i, vs.clone())))
6778                .collect();
6779        let table = self
6780            .active_catalog_mut()
6781            .get_mut(&stmt.table)
6782            .ok_or_else(|| {
6783                EngineError::Storage(StorageError::TableNotFound {
6784                    name: stmt.table.clone(),
6785                })
6786            })?;
6787        // v3.1.5: clone the columns vector only (not the whole
6788        // TableSchema — saves one String alloc for the table name).
6789        // We need an owned snapshot because we'll call `table.insert`
6790        // (mutable borrow on `table`) inside the row loop while
6791        // reading schema fields.
6792        let column_meta: Vec<ColumnSchema> = table.schema().columns.clone();
6793        let schema_cols_len = column_meta.len();
6794        // Build a permutation `tuple_pos[c] = Some(j)` meaning schema
6795        // column `c` is filled from the `j`-th tuple slot; `None` means
6796        // "fill with NULL". Validated once and reused for every row.
6797        let tuple_pos: Option<Vec<Option<usize>>> = match &stmt.columns {
6798            None => None, // 1-1 mapping, fast path
6799            Some(cols) => {
6800                let mut map = alloc::vec![None; schema_cols_len];
6801                for (j, name) in cols.iter().enumerate() {
6802                    let idx = column_meta
6803                        .iter()
6804                        .position(|c| c.name == *name)
6805                        .ok_or_else(|| {
6806                            EngineError::Eval(EvalError::ColumnNotFound { name: name.clone() })
6807                        })?;
6808                    if map[idx].is_some() {
6809                        return Err(EngineError::Storage(StorageError::ArityMismatch {
6810                            expected: schema_cols_len,
6811                            actual: cols.len(),
6812                        }));
6813                    }
6814                    map[idx] = Some(j);
6815                }
6816                // Omitted columns must either be nullable, carry a
6817                // DEFAULT, or be AUTO_INCREMENT. Catch NOT NULL
6818                // omissions up front so the WAL stays clean.
6819                for (i, col) in column_meta.iter().enumerate() {
6820                    if map[i].is_none()
6821                        && !col.nullable
6822                        && col.default.is_none()
6823                        && col.runtime_default.is_none()
6824                        && !col.auto_increment
6825                    {
6826                        return Err(EngineError::Storage(StorageError::NullInNotNull {
6827                            column: col.name.clone(),
6828                        }));
6829                    }
6830                }
6831                Some(map)
6832            }
6833        };
6834        let expected_tuple_len = stmt.columns.as_ref().map_or(schema_cols_len, Vec::len);
6835        // v7.6.2 — snapshot this table's FK list before the
6836        // mutable-borrow window so we can run parent lookups
6837        // against the immutable catalog after parsing. Empty vec is
6838        // the no-FK fast path; clone cost is O(fks * arity) which
6839        // is < 100 ns for typical schemas.
6840        let fks = table.schema().foreign_keys.clone();
6841        let mut affected = 0usize;
6842        // Stage 1 — parse + AUTO_INC + coerce all rows under the
6843        // single mutable borrow.
6844        let mut all_values: Vec<Vec<Value>> = Vec::with_capacity(stmt.rows.len());
6845        // v7.24 (round-16 collateral) — statement-scoped serial
6846        // cursors. next_auto_value() is a max+1 scan over COMMITTED
6847        // rows; multi-row `INSERT … VALUES (…),(…)` computed it per
6848        // tuple BEFORE any insertion, so every row drew the SAME id
6849        // (then sailed through, compounding with the inline-PK
6850        // enforcement gap). First use per column seeds from the
6851        // table; subsequent rows increment.
6852        let mut auto_cursors: alloc::collections::BTreeMap<usize, i64> =
6853            alloc::collections::BTreeMap::new();
6854        for tuple in stmt.rows {
6855            if tuple.len() != expected_tuple_len {
6856                return Err(EngineError::Storage(StorageError::ArityMismatch {
6857                    expected: expected_tuple_len,
6858                    actual: tuple.len(),
6859                }));
6860            }
6861            // Fast path: no column-list permutation → tuple slot j
6862            // maps to schema column j. We can zip schema with tuple
6863            // and skip the `raw_tuple` staging allocation entirely.
6864            let values: Vec<Value> = if let Some(map) = &tuple_pos {
6865                // Permuted path: still need raw_tuple to index by `map[i]`.
6866                let raw_tuple: Vec<Value> = tuple
6867                    .into_iter()
6868                    .map(literal_expr_to_value)
6869                    .collect::<Result<_, _>>()?;
6870                let mut out = Vec::with_capacity(schema_cols_len);
6871                for (i, col) in column_meta.iter().enumerate() {
6872                    let mut raw = match map[i] {
6873                        Some(j) => raw_tuple[j].clone(),
6874                        None => resolve_column_default_free(col, clock)?,
6875                    };
6876                    if col.auto_increment && raw.is_null() {
6877                        let next = match auto_cursors.get(&i) {
6878                            Some(n) => *n,
6879                            None => table.next_auto_value(i).ok_or_else(|| {
6880                                EngineError::Unsupported(alloc::format!(
6881                                    "AUTO_INCREMENT applies to integer columns only (column `{}`)",
6882                                    col.name
6883                                ))
6884                            })?,
6885                        };
6886                        auto_cursors.insert(i, next + 1);
6887                        raw = Value::BigInt(next);
6888                    }
6889                    let coerced = coerce_value(raw, col.ty, &col.name, i)?;
6890                    enforce_enum_label(&enum_label_lookup, i, &col.name, &coerced)?;
6891                    let coerced =
6892                        canonicalize_set_value(&set_variant_lookup, i, &col.name, coerced)?;
6893                    check_unsigned_range(&coerced, col, i)?;
6894                    out.push(coerced);
6895                }
6896                out
6897            } else {
6898                // 1-1 mapping fast path: single Vec alloc, no raw_tuple.
6899                let mut out = Vec::with_capacity(schema_cols_len);
6900                for (i, (col, expr)) in column_meta.iter().zip(tuple).enumerate() {
6901                    let mut raw = literal_expr_to_value(expr)?;
6902                    if col.auto_increment && raw.is_null() {
6903                        let next = match auto_cursors.get(&i) {
6904                            Some(n) => *n,
6905                            None => table.next_auto_value(i).ok_or_else(|| {
6906                                EngineError::Unsupported(alloc::format!(
6907                                    "AUTO_INCREMENT applies to integer columns only (column `{}`)",
6908                                    col.name
6909                                ))
6910                            })?,
6911                        };
6912                        auto_cursors.insert(i, next + 1);
6913                        raw = Value::BigInt(next);
6914                    }
6915                    let coerced = coerce_value(raw, col.ty, &col.name, i)?;
6916                    enforce_enum_label(&enum_label_lookup, i, &col.name, &coerced)?;
6917                    let coerced =
6918                        canonicalize_set_value(&set_variant_lookup, i, &col.name, coerced)?;
6919                    check_unsigned_range(&coerced, col, i)?;
6920                    out.push(coerced);
6921                }
6922                out
6923            };
6924            all_values.push(values);
6925        }
6926        // Stage 2 — FK enforcement on the immutable catalog.
6927        // Non-lexical lifetimes release the mutable borrow on
6928        // `table` here since stage 1 was the last use. The
6929        // parent-table lookup runs before any row is committed.
6930        let uniqueness = table.schema().uniqueness_constraints.clone();
6931        let _ = table;
6932        if !fks.is_empty() {
6933            enforce_fk_inserts(self.active_catalog(), &stmt.table, &fks, &all_values)?;
6934        }
6935        // v7.13.0 — CHECK constraint enforcement (mailrs round-5 G3).
6936        enforce_check_constraints(self.active_catalog(), &stmt.table, &all_values)?;
6937        // NOTE (mailrs embed round-12): UNIQUE / PRIMARY KEY and
6938        // UNIQUE INDEX enforcement moved BELOW the ON CONFLICT
6939        // resolution pass. Running them first made every
6940        // `ON CONFLICT … DO UPDATE` upsert fail with a uniqueness
6941        // violation before the conflict handler could route the row
6942        // to an UPDATE — PG resolves the conflict action first and
6943        // only errors on rows no arbiter matched.
6944        // v7.9.8 / v7.9.9 — ON CONFLICT handling.
6945        //   - `DO NOTHING` filters `all_values` to non-conflicting
6946        //     rows + drops within-batch duplicates.
6947        //   - `DO UPDATE SET …` ALSO filters, but for each
6948        //     conflicting row it queues an UPDATE on the existing
6949        //     row using the incoming row's values as `EXCLUDED.*`.
6950        let mut pending_updates: Vec<(usize, Vec<Value>)> = Vec::new();
6951        let mut skipped_count = 0usize;
6952        if let Some(clause) = &stmt.on_conflict {
6953            let conflict_cols = resolve_on_conflict_columns(
6954                self.active_catalog(),
6955                &stmt.table,
6956                clause.target_columns.as_slice(),
6957            )?;
6958            let mut kept: Vec<Vec<Value>> = Vec::with_capacity(all_values.len());
6959            let mut seen_keys: Vec<Vec<Value>> = Vec::new();
6960            for values in all_values {
6961                let key_tuple: Vec<&Value> = conflict_cols.iter().map(|&c| &values[c]).collect();
6962                // SQL spec: NULL in any conflict column means "no
6963                // conflict possible" (NULL ≠ NULL for uniqueness).
6964                let has_null_key = key_tuple.iter().any(|v| matches!(v, Value::Null));
6965                let collides_with_table = !has_null_key
6966                    && on_conflict_keys_exist(
6967                        self.active_catalog(),
6968                        &stmt.table,
6969                        &conflict_cols,
6970                        &key_tuple,
6971                    );
6972                let key_tuple_owned: Vec<Value> = key_tuple.iter().map(|v| (*v).clone()).collect();
6973                let collides_with_batch =
6974                    !has_null_key && seen_keys.iter().any(|k| k == &key_tuple_owned);
6975                let collides = collides_with_table || collides_with_batch;
6976                match (&clause.action, collides) {
6977                    (_, false) => {
6978                        seen_keys.push(key_tuple_owned);
6979                        kept.push(values);
6980                    }
6981                    (spg_sql::ast::OnConflictAction::Nothing, true) => {
6982                        skipped_count += 1;
6983                    }
6984                    (
6985                        spg_sql::ast::OnConflictAction::Update {
6986                            assignments,
6987                            where_,
6988                        },
6989                        true,
6990                    ) => {
6991                        if !collides_with_table {
6992                            skipped_count += 1;
6993                            continue;
6994                        }
6995                        let target_pos = lookup_row_position_by_keys(
6996                            self.active_catalog(),
6997                            &stmt.table,
6998                            &conflict_cols,
6999                            &key_tuple,
7000                        )
7001                        .ok_or_else(|| {
7002                            EngineError::Unsupported(
7003                                "ON CONFLICT DO UPDATE: conflict detected but row \
7004                                 position could not be resolved (cold-tier row?)"
7005                                    .into(),
7006                            )
7007                        })?;
7008                        let updated = apply_on_conflict_assignments(
7009                            self.active_catalog(),
7010                            &stmt.table,
7011                            target_pos,
7012                            &values,
7013                            assignments,
7014                            where_.as_ref(),
7015                        )?;
7016                        if let Some(new_row) = updated {
7017                            pending_updates.push((target_pos, new_row));
7018                        } else {
7019                            skipped_count += 1;
7020                        }
7021                    }
7022                }
7023            }
7024            all_values = kept;
7025        }
7026        // v7.9.19 — composite UNIQUE / PRIMARY KEY enforcement.
7027        // v7.9.29 — CREATE UNIQUE INDEX [WHERE pred] enforcement.
7028        // Both run on the post-ON-CONFLICT row set: conflicting rows
7029        // already left `all_values` (DO NOTHING drop / DO UPDATE
7030        // reroute), so what remains must be genuinely unique.
7031        enforce_uniqueness_inserts(self.active_catalog(), &stmt.table, &uniqueness, &all_values)?;
7032        enforce_unique_index_inserts(self.active_catalog(), &stmt.table, &all_values)?;
7033        // Stage 3 — insert all rows under a fresh mutable borrow.
7034        let table = self
7035            .active_catalog_mut()
7036            .get_mut(&stmt.table)
7037            .ok_or_else(|| {
7038                EngineError::Storage(StorageError::TableNotFound {
7039                    name: stmt.table.clone(),
7040                })
7041            })?;
7042        // v7.9.4 — keep RETURNING projection rows separate per
7043        // INSERT and per UPDATE branch so DO UPDATE pushes the new
7044        // post-update state, not the incoming-only values.
7045        let mut returning_rows: Vec<Vec<Value>> = Vec::new();
7046        // v7.12.7 — collect embedded SQL emitted by any trigger
7047        // fire across the row loop; engine drains the queue after
7048        // the table mut borrow drops.
7049        let mut deferred_embedded: Vec<triggers::DeferredEmbeddedStmt> = Vec::new();
7050        'rowloop: for values in all_values {
7051            let mut row = Row::new(values);
7052            // v7.12.4 — BEFORE INSERT row-level triggers. Each
7053            // trigger may rewrite NEW cells (e.g. populate
7054            // `search_vector := to_tsvector(...)`) and may return
7055            // NULL to skip the row entirely.
7056            for fd in &before_insert_triggers {
7057                let (outcome, deferred) = triggers::fire_row_trigger(
7058                    fd,
7059                    Some(row.clone()),
7060                    None,
7061                    &stmt.table,
7062                    &column_meta,
7063                    &[],
7064                    trigger_session_cfg.as_deref(),
7065                    false,
7066                )
7067                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
7068                deferred_embedded.extend(deferred);
7069                match outcome {
7070                    triggers::TriggerOutcome::Row(r) => row = r,
7071                    triggers::TriggerOutcome::Skip => continue 'rowloop,
7072                }
7073            }
7074            if stmt.returning.is_some() {
7075                returning_rows.push(row.values.clone());
7076            }
7077            // v7.12.4 — clone for the AFTER trigger view; insert
7078            // moves the row into the table.
7079            let inserted = row.clone();
7080            table.insert(row)?;
7081            affected += 1;
7082            // v7.12.4 — AFTER INSERT row-level triggers fire post-
7083            // write. Return value is ignored (PG semantics); we
7084            // surface any error from the body up to the caller.
7085            for fd in &after_insert_triggers {
7086                let (_outcome, deferred) = triggers::fire_row_trigger(
7087                    fd,
7088                    Some(inserted.clone()),
7089                    None,
7090                    &stmt.table,
7091                    &column_meta,
7092                    &[],
7093                    trigger_session_cfg.as_deref(),
7094                    true,
7095                )
7096                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
7097                deferred_embedded.extend(deferred);
7098            }
7099        }
7100        // v7.9.9 — apply ON CONFLICT DO UPDATE rewrites collected
7101        // in the conflict-resolution pass. update_row handles
7102        // index maintenance + body re-encoding.
7103        for (pos, new_row) in pending_updates {
7104            if stmt.returning.is_some() {
7105                returning_rows.push(new_row.clone());
7106            }
7107            table.update_row(pos, new_row)?;
7108            affected += 1;
7109        }
7110        let _ = skipped_count;
7111        // v7.12.7 — drop the table mut borrow and drain any
7112        // trigger-emitted embedded SQL queued during this INSERT.
7113        // The borrow has to release first because each deferred
7114        // stmt may UPDATE / INSERT / DELETE the same (or another)
7115        // table — including, in principle, this one.
7116        let _ = table;
7117        self.execute_deferred_trigger_stmts(deferred_embedded, CancelToken::none())?;
7118        // v7.9.4/v7.9.9 — RETURNING streams the rows that ended
7119        // up in the table after this statement (insert or
7120        // post-update on conflict).
7121        if let Some(items) = &stmt.returning {
7122            return self.build_returning_rows(&stmt.table, items, returning_rows);
7123        }
7124        // v6.2.1 — auto-analyze: track per-table modified-row
7125        // counter so the background sweep can decide when to
7126        // re-ANALYZE. Cheap path on the autocommit-wrap hot loop
7127        // — one BTreeMap entry update per INSERT batch.
7128        if !self.in_transaction() && affected > 0 {
7129            self.statistics
7130                .record_modifications(&stmt.table, affected as u64);
7131        }
7132        Ok(QueryResult::CommandOk {
7133            affected,
7134            modified_catalog: !self.in_transaction(),
7135        })
7136    }
7137
7138    /// v4.5: SELECT with cooperative cancellation. The token is
7139    /// honoured between UNION peers and inside the bare-SELECT row
7140    /// loop; HNSW kNN graph walks and the aggregate executor don't
7141    /// honour it yet (deferred — those paths bound their work
7142    /// internally by `LIMIT k` and `GROUP BY` cardinality).
7143    /// v6.10.2 — cold-tier time-travel scan. Resolves the segment
7144    /// by id, decodes each row body against the table's current
7145    /// schema, applies the SELECT's projection + optional WHERE +
7146    /// optional LIMIT, returns a `Rows` result. JOINs / aggregates
7147    /// / ORDER BY are unsupported on this path (STABILITY carve-
7148    /// out); operators wanting them should restore the segment
7149    /// into a regular table first.
7150    fn exec_select_as_of_segment(
7151        &self,
7152        stmt: &SelectStatement,
7153        from: &spg_sql::ast::FromClause,
7154        segment_id: u32,
7155    ) -> Result<QueryResult, EngineError> {
7156        // v6.10.2 scope: no joins, no aggregates, no ORDER BY,
7157        // no GROUP BY / HAVING / UNION / OFFSET / DISTINCT.
7158        if !from.joins.is_empty()
7159            || stmt.group_by.is_some()
7160            || stmt.having.is_some()
7161            || !stmt.unions.is_empty()
7162            || !stmt.order_by.is_empty()
7163            || stmt.offset.is_some()
7164            || stmt.distinct
7165            || aggregate::uses_aggregate(stmt)
7166        {
7167            return Err(EngineError::Unsupported(
7168                "AS OF SEGMENT supports SELECT projection + WHERE + LIMIT only \
7169                 (joins / aggregates / ORDER BY are STABILITY § \"Out of v6.10\")"
7170                    .into(),
7171            ));
7172        }
7173        let table = self
7174            .active_catalog()
7175            .get(&from.primary.name)
7176            .ok_or_else(|| StorageError::TableNotFound {
7177                name: from.primary.name.clone(),
7178            })?;
7179        let schema = table.schema().clone();
7180        let schema_cols = &schema.columns;
7181        let alias = from
7182            .primary
7183            .alias
7184            .as_deref()
7185            .unwrap_or(from.primary.name.as_str());
7186        let ctx = EvalContext::new(schema_cols, Some(alias));
7187        let seg = self
7188            .active_catalog()
7189            .cold_segment(segment_id)
7190            .ok_or_else(|| {
7191                EngineError::Unsupported(alloc::format!(
7192                    "AS OF SEGMENT: cold segment {segment_id} not registered"
7193                ))
7194            })?;
7195        let mut out_rows: Vec<Row> = Vec::new();
7196        let mut limit_remaining: Option<usize> =
7197            stmt.limit_literal().and_then(|n| usize::try_from(n).ok());
7198        for (_key, body) in seg.scan() {
7199            let (row, _consumed) =
7200                spg_storage::decode_row_body_dense(&body, &schema, seg.codec_version())
7201                    .map_err(EngineError::Storage)?;
7202            if let Some(where_expr) = &stmt.where_ {
7203                let cond = self.eval_expr_simple(where_expr, &row, &ctx)?;
7204                if !matches!(cond, Value::Bool(true)) {
7205                    continue;
7206                }
7207            }
7208            // Projection.
7209            let projected = self.project_row_simple(&row, &stmt.items, schema_cols, alias)?;
7210            out_rows.push(projected);
7211            if let Some(rem) = limit_remaining.as_mut() {
7212                if *rem == 0 {
7213                    out_rows.pop();
7214                    break;
7215                }
7216                *rem -= 1;
7217            }
7218        }
7219        // Output column schema: derive from SELECT items.
7220        let columns = self.derive_output_columns(&stmt.items, schema_cols, alias);
7221        Ok(QueryResult::Rows {
7222            columns,
7223            rows: out_rows,
7224        })
7225    }
7226
7227    /// v6.10.2 — simple-path WHERE eval that doesn't go through
7228    /// the correlated-subquery / Memoize machinery. AS OF SEGMENT
7229    /// scan paths predicate against a snapshot frozen segment, no
7230    /// cross-row state.
7231    fn eval_expr_simple(
7232        &self,
7233        expr: &Expr,
7234        row: &Row,
7235        ctx: &EvalContext,
7236    ) -> Result<Value, EngineError> {
7237        let cancel = CancelToken::none();
7238        self.eval_expr_with_correlated(expr, row, ctx, cancel, None)
7239    }
7240
7241    /// v7.9.4 — INSERT / UPDATE / DELETE RETURNING projector.
7242    /// Given the table name, the user-supplied projection items,
7243    /// and the mutated rows (post-insert / post-update values, or
7244    /// pre-delete snapshot), build a `QueryResult::Rows` whose
7245    /// schema describes the projected columns. Mailrs migration
7246    /// blocker #1.
7247    fn build_returning_rows(
7248        &self,
7249        table_name: &str,
7250        items: &[SelectItem],
7251        mutated_rows: Vec<Vec<Value>>,
7252    ) -> Result<QueryResult, EngineError> {
7253        let table = self.active_catalog().get(table_name).ok_or_else(|| {
7254            EngineError::Storage(StorageError::TableNotFound {
7255                name: table_name.into(),
7256            })
7257        })?;
7258        let schema_cols = table.schema().columns.clone();
7259        let columns = self.derive_output_columns(items, &schema_cols, table_name);
7260        let mut out_rows: Vec<Row> = Vec::with_capacity(mutated_rows.len());
7261        for values in mutated_rows {
7262            let row = Row::new(values);
7263            let projected = self.project_row_simple(&row, items, &schema_cols, table_name)?;
7264            out_rows.push(projected);
7265        }
7266        Ok(QueryResult::Rows {
7267            columns,
7268            rows: out_rows,
7269        })
7270    }
7271
7272    /// v6.10.2 — projection for AS OF SEGMENT. Resolves
7273    /// `SelectItem::Wildcard` to all schema columns and
7274    /// `SelectItem::Expr` via the regular eval path.
7275    fn project_row_simple(
7276        &self,
7277        row: &Row,
7278        items: &[SelectItem],
7279        schema_cols: &[ColumnSchema],
7280        alias: &str,
7281    ) -> Result<Row, EngineError> {
7282        let ctx = EvalContext::new(schema_cols, Some(alias));
7283        let cancel = CancelToken::none();
7284        let mut out_vals = Vec::new();
7285        for item in items {
7286            match item {
7287                SelectItem::Wildcard => {
7288                    out_vals.extend(row.values.iter().cloned());
7289                }
7290                SelectItem::Expr { expr, .. } => {
7291                    let v = self.eval_expr_with_correlated(expr, row, &ctx, cancel, None)?;
7292                    out_vals.push(v);
7293                }
7294            }
7295        }
7296        Ok(Row::new(out_vals))
7297    }
7298
7299    /// v6.10.2 — derive the output `ColumnSchema` list for an
7300    /// AS OF SEGMENT projection. Wildcards take the full schema;
7301    /// expressions take the alias if present or a synthetic
7302    /// `?column?` (PG convention) otherwise.
7303    fn derive_output_columns(
7304        &self,
7305        items: &[SelectItem],
7306        schema_cols: &[ColumnSchema],
7307        _alias: &str,
7308    ) -> Vec<ColumnSchema> {
7309        let mut out = Vec::new();
7310        for item in items {
7311            match item {
7312                SelectItem::Wildcard => {
7313                    out.extend(schema_cols.iter().cloned());
7314                }
7315                SelectItem::Expr { expr, alias } => {
7316                    // Bare column references inherit the schema
7317                    // column's name + type — PG names `RETURNING id`
7318                    // "id" and types it BIGINT, and the sqlx embed
7319                    // path type-checks RowDescription against the
7320                    // Rust target (mailrs embed round-12).
7321                    if let Expr::Column(col) = expr
7322                        && let Some(sc) = schema_cols.iter().find(|c| c.name == col.name)
7323                    {
7324                        let name = alias.clone().unwrap_or_else(|| sc.name.clone());
7325                        out.push(ColumnSchema::new(name, sc.ty, sc.nullable));
7326                        continue;
7327                    }
7328                    let name = alias.clone().unwrap_or_else(|| "?column?".to_string());
7329                    // Default to Text; the caller's row values
7330                    // carry the actual type. v6.10.2 scope.
7331                    out.push(ColumnSchema::new(name, DataType::Text, true));
7332                }
7333            }
7334        }
7335        out
7336    }
7337
7338    fn exec_select_cancel(
7339        &self,
7340        stmt: &SelectStatement,
7341        cancel: CancelToken<'_>,
7342    ) -> Result<QueryResult, EngineError> {
7343        cancel.check()?;
7344        // v7.17.0 Phase 1.2 — user-defined VIEW expansion. If the
7345        // FROM / JOIN graph references any catalogued view name,
7346        // re-parse the view body and prepend it as a synthetic
7347        // CTE. Recurses on views-in-views via the regular CTE
7348        // dispatch below. Fast-path: skip the walker entirely when
7349        // the catalog has no views (the typical OLTP load).
7350        if !self.active_catalog().views().is_empty() {
7351            if let Some(rewritten) = self.expand_views_in_select(stmt)? {
7352                return self.exec_select_cancel(&rewritten, cancel);
7353            }
7354        }
7355        // v7.16.2 — information_schema / pg_catalog virtual
7356        // views (mailrs round-10 A.3). If the SELECT touches a
7357        // synthetic meta-table name (`__spg_info_*` /
7358        // `__spg_pg_*` — produced by the parser for
7359        // `information_schema.X` / `pg_catalog.X`), clone the
7360        // catalog, materialise the requested view as a real
7361        // temporary table, and re-execute against an enriched
7362        // engine. Same pattern as `exec_with_ctes` for CTEs.
7363        if !self.meta_views_materialised && select_references_meta_view(stmt) {
7364            return self.exec_select_with_meta_views(stmt, cancel);
7365        }
7366        // v6.10.2 — cold-tier time-travel short-circuit. When the
7367        // primary TableRef carries `AS OF SEGMENT '<id>'`, run a
7368        // dedicated cold-segment scan instead of the regular
7369        // hot+index path. The scope is intentionally narrow for
7370        // v6.10.2 — bare `SELECT * FROM <t> AS OF SEGMENT 'id'`,
7371        // optionally with a single-column-equality WHERE. JOINs /
7372        // aggregates / ORDER BY / subqueries on top of a time-
7373        // travelled scan are STABILITY § "Out of v6.10".
7374        if let Some(from) = &stmt.from
7375            && let Some(seg_id) = from.primary.as_of_segment
7376        {
7377            return self.exec_select_as_of_segment(stmt, from, seg_id);
7378        }
7379        // v6.2.0 / v6.5.0 — virtual-table short-circuits. Detected
7380        // pre-CTE because they don't read from the catalog and
7381        // shouldn't participate in regular FROM resolution.
7382        if let Some(from) = &stmt.from
7383            && from.joins.is_empty()
7384            && stmt.where_.is_none()
7385            && stmt.group_by.is_none()
7386            && stmt.having.is_none()
7387            && stmt.unions.is_empty()
7388            && stmt.order_by.is_empty()
7389            && stmt.limit.is_none()
7390            && stmt.offset.is_none()
7391            && !stmt.distinct
7392            && stmt.items.iter().all(|i| matches!(i, SelectItem::Wildcard))
7393        {
7394            let lower = from.primary.name.to_ascii_lowercase();
7395            match lower.as_str() {
7396                "spg_statistic" => return Ok(self.exec_spg_statistic()),
7397                // v6.5.0 — observability v2 virtual tables.
7398                "spg_stat_replication" => return Ok(self.exec_spg_stat_replication()),
7399                "spg_stat_segment" => return Ok(self.exec_spg_stat_segment()),
7400                "spg_stat_query" => return Ok(self.exec_spg_stat_query()),
7401                "spg_stat_activity" => return Ok(self.exec_spg_stat_activity()),
7402                "spg_audit_chain" => return Ok(self.exec_spg_audit_chain()),
7403                "spg_audit_verify" => return Ok(self.exec_spg_audit_verify()),
7404                "spg_table_ddl" => return Ok(self.exec_spg_table_ddl()),
7405                "spg_role_ddl" => return Ok(self.exec_spg_role_ddl()),
7406                "spg_database_ddl" => return Ok(self.exec_spg_database_ddl()),
7407                _ => {}
7408            }
7409        }
7410        // v4.11: CTEs materialise into a temporary enriched catalog
7411        // *before* anything else — the body SELECT can then refer
7412        // to CTE names via the regular FROM-clause resolution.
7413        // Uncorrelated only: each CTE body runs once against the
7414        // current catalog, not against later CTEs' results (left-
7415        // to-right materialisation would relax this, but we keep
7416        // it simple for v4.11 MVP).
7417        if !stmt.ctes.is_empty() {
7418            return self.exec_with_ctes(stmt, cancel);
7419        }
7420        // v4.10: subqueries (uncorrelated) are resolved here, before
7421        // the executor sees the row loop. We clone the statement so
7422        // we can mutate without disturbing the caller's AST — most
7423        // queries pass through with no subquery nodes and the clone
7424        // is cheap; with subqueries the materialisation cost
7425        // dominates anyway.
7426        let mut stmt_owned;
7427        let stmt_ref: &SelectStatement = if expr_tree_has_subquery(stmt) {
7428            stmt_owned = stmt.clone();
7429            self.resolve_select_subqueries(&mut stmt_owned, cancel)?;
7430            &stmt_owned
7431        } else {
7432            stmt
7433        };
7434        if stmt_ref.unions.is_empty() {
7435            return self.exec_bare_select_cancel(stmt_ref, cancel);
7436        }
7437        // UNION path: clone-strip the head into a bare block (its own
7438        // DISTINCT and any inner ORDER BY are dropped by parser rule —
7439        // the wrapper SelectStatement carries them), execute, then chain
7440        // peers with left-associative dedup semantics.
7441        let mut head = stmt_ref.clone();
7442        head.unions = Vec::new();
7443        head.order_by = Vec::new();
7444        head.limit = None;
7445        let QueryResult::Rows { columns, mut rows } =
7446            self.exec_bare_select_cancel(&head, cancel)?
7447        else {
7448            unreachable!("bare SELECT cannot return CommandOk")
7449        };
7450        for (kind, peer) in &stmt_ref.unions {
7451            let QueryResult::Rows {
7452                columns: peer_cols,
7453                rows: peer_rows,
7454            } = self.exec_bare_select_cancel(peer, cancel)?
7455            else {
7456                unreachable!("bare SELECT cannot return CommandOk")
7457            };
7458            if peer_cols.len() != columns.len() {
7459                return Err(EngineError::Unsupported(alloc::format!(
7460                    "UNION arity mismatch: head has {} columns, peer has {}",
7461                    columns.len(),
7462                    peer_cols.len()
7463                )));
7464            }
7465            rows.extend(peer_rows);
7466            if matches!(kind, UnionKind::Distinct) {
7467                rows = dedup_rows(rows);
7468            }
7469        }
7470        // ORDER BY at the top of a UNION applies to the combined result.
7471        // Eval against the projected schema (NOT the source table).
7472        if !stmt.order_by.is_empty() {
7473            let synth_ctx = EvalContext::new(&columns, None);
7474            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
7475            let mut tagged: Vec<(Vec<f64>, Row)> = Vec::with_capacity(rows.len());
7476            for r in rows {
7477                let keys = build_order_keys(&stmt.order_by, &r, &synth_ctx)?;
7478                tagged.push((keys, r));
7479            }
7480            sort_by_keys(&mut tagged, &descs);
7481            rows = tagged.into_iter().map(|(_, r)| r).collect();
7482        }
7483        apply_offset_and_limit(&mut rows, stmt.offset_literal(), stmt.limit_literal());
7484        Ok(QueryResult::Rows { columns, rows })
7485    }
7486
7487    #[allow(clippy::too_many_lines)]
7488    #[allow(clippy::too_many_lines)] // huge match — splitting fragments the planner
7489    /// v7.11.7 — execute `SELECT … FROM unnest(expr) [AS] alias …`.
7490    /// Synthesises a single-column virtual table whose column type
7491    /// is TEXT and whose rows are the array elements. Routes
7492    /// through the regular projection / WHERE / ORDER BY / LIMIT
7493    /// machinery so set-returning UNNEST composes naturally with
7494    /// the rest of the SELECT surface.
7495    fn exec_select_unnest(
7496        &self,
7497        stmt: &SelectStatement,
7498        primary: &TableRef,
7499        cancel: CancelToken<'_>,
7500    ) -> Result<QueryResult, EngineError> {
7501        let expr = primary
7502            .unnest_expr
7503            .as_deref()
7504            .expect("caller guards unnest_expr.is_some()");
7505        // Evaluate the array expression once. Empty schema / empty
7506        // row — uncorrelated UNNEST cannot reference outer columns.
7507        let empty_schema: alloc::vec::Vec<ColumnSchema> = alloc::vec::Vec::new();
7508        let ctx = EvalContext::new(&empty_schema, None);
7509        let dummy_row = Row::new(alloc::vec::Vec::new());
7510        // v7.11.13 — unnest dispatches per array element type so
7511        // INT[] / BIGINT[] surface their PG types in projection.
7512        let (elem_dtype, rows): (DataType, alloc::vec::Vec<Row>) =
7513            match eval::eval_expr(expr, &dummy_row, &ctx).map_err(EngineError::Eval)? {
7514                Value::Null => (DataType::Text, alloc::vec::Vec::new()),
7515                Value::TextArray(items) => {
7516                    let rows = items
7517                        .into_iter()
7518                        .map(|item| {
7519                            Row::new(alloc::vec![match item {
7520                                Some(s) => Value::Text(s),
7521                                None => Value::Null,
7522                            }])
7523                        })
7524                        .collect();
7525                    (DataType::Text, rows)
7526                }
7527                Value::IntArray(items) => {
7528                    let rows = items
7529                        .into_iter()
7530                        .map(|item| {
7531                            Row::new(alloc::vec![match item {
7532                                Some(n) => Value::Int(n),
7533                                None => Value::Null,
7534                            }])
7535                        })
7536                        .collect();
7537                    (DataType::Int, rows)
7538                }
7539                Value::BigIntArray(items) => {
7540                    let rows = items
7541                        .into_iter()
7542                        .map(|item| {
7543                            Row::new(alloc::vec![match item {
7544                                Some(n) => Value::BigInt(n),
7545                                None => Value::Null,
7546                            }])
7547                        })
7548                        .collect();
7549                    (DataType::BigInt, rows)
7550                }
7551                other => {
7552                    return Err(EngineError::Unsupported(alloc::format!(
7553                        "unnest() expects an array argument, got {:?}",
7554                        other.data_type()
7555                    )));
7556                }
7557            };
7558        let alias = primary
7559            .alias
7560            .clone()
7561            .unwrap_or_else(|| "unnest".to_string());
7562        // v7.13.2 — mailrs round-6 S5. Honour PG-standard
7563        // `UNNEST(arr) AS p(col_name)` column-list aliasing: the
7564        // first entry overrides the projected column's name.
7565        // Without the column list, fall back to the table alias
7566        // (pre-v7.13.2 behaviour).
7567        let col_name = primary
7568            .unnest_column_aliases
7569            .first()
7570            .cloned()
7571            .unwrap_or_else(|| alias.clone());
7572        let col_schema = ColumnSchema::new(col_name, elem_dtype, true);
7573        let schema_cols = alloc::vec![col_schema.clone()];
7574        let scan_ctx = EvalContext::new(&schema_cols, Some(&alias));
7575        // Apply WHERE.
7576        let filtered: alloc::vec::Vec<Row> = if let Some(w) = &stmt.where_ {
7577            let mut out = alloc::vec::Vec::with_capacity(rows.len());
7578            for row in rows {
7579                cancel.check()?;
7580                let v = eval::eval_expr(w, &row, &scan_ctx).map_err(EngineError::Eval)?;
7581                if matches!(v, Value::Bool(true)) {
7582                    out.push(row);
7583                }
7584            }
7585            out
7586        } else {
7587            rows
7588        };
7589        // v7.17.0 Phase 3.P0-48 — aggregate dispatch over the
7590        // unnest source. Same routing the relational scan path
7591        // already takes — without it `SELECT COUNT(*) FROM
7592        // unnest(ARRAY[…])` either errored at projection time or
7593        // returned the wrong shape.
7594        if aggregate::uses_aggregate(stmt) {
7595            let agg_correlated = |e: &Expr, r: &Row, c: &EvalContext<'_>| {
7596                self.eval_expr_with_correlated(e, r, c, cancel, None)
7597                    .map_err(|err| match err {
7598                        EngineError::Eval(ev) => ev,
7599                        other => eval::EvalError::TypeMismatch {
7600                            detail: alloc::format!("{other}"),
7601                        },
7602                    })
7603            };
7604            let filtered_refs: alloc::vec::Vec<&Row> = filtered.iter().collect();
7605            let mut agg = aggregate::run(
7606                stmt,
7607                &filtered_refs,
7608                &schema_cols,
7609                Some(&alias),
7610                Some(&agg_correlated),
7611            )?;
7612            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
7613            return Ok(QueryResult::Rows {
7614                columns: agg.columns,
7615                rows: agg.rows,
7616            });
7617        }
7618        // Projection.
7619        let projection = build_projection(&stmt.items, &schema_cols, &alias)?;
7620        let mut projected_rows: alloc::vec::Vec<Row> =
7621            alloc::vec::Vec::with_capacity(filtered.len());
7622        // v7.19 P5 — Set-Returning-Function in projection
7623        // position (PG `SELECT unnest(arr) FROM t` shape). When a
7624        // SELECT item evaluates to a top-level unnest(arr) call,
7625        // expand it: for each input row, evaluate the array, emit
7626        // one output row per element, broadcasting non-SRF
7627        // projections from the same input row. Multi-SRF + LCM
7628        // padding stays a documented carve-out; mailrs uses
7629        // single-SRF for redirect_uris.
7630        let srf_position = projection.iter().position(|p| is_top_level_unnest(&p.expr));
7631        if let Some(srf_idx) = srf_position {
7632            let srf_arg = top_level_unnest_arg(&projection[srf_idx].expr)
7633                .expect("checked by is_top_level_unnest above");
7634            for row in &filtered {
7635                let arr_val =
7636                    eval::eval_expr(srf_arg, row, &scan_ctx).map_err(EngineError::Eval)?;
7637                let elements = array_value_to_elements(&arr_val)?;
7638                // Empty array → zero rows for this input row (PG
7639                // semantics: `SELECT unnest('{}'::int[])` returns
7640                // 0 rows, not a single NULL row).
7641                for elem in elements {
7642                    let mut vals = alloc::vec::Vec::with_capacity(projection.len());
7643                    for (i, p) in projection.iter().enumerate() {
7644                        if i == srf_idx {
7645                            vals.push(elem.clone());
7646                        } else {
7647                            vals.push(
7648                                eval::eval_expr(&p.expr, row, &scan_ctx)
7649                                    .map_err(EngineError::Eval)?,
7650                            );
7651                        }
7652                    }
7653                    projected_rows.push(Row::new(vals));
7654                }
7655            }
7656        } else {
7657            // v7.24 (round-16 B) — select-list subqueries resolve
7658            // per row (correlated-aware; plain exprs take the fast
7659            // path inside).
7660            let mut proj_memo = memoize::MemoizeCache::default();
7661            for row in &filtered {
7662                let mut vals = alloc::vec::Vec::with_capacity(projection.len());
7663                for p in &projection {
7664                    vals.push(self.eval_expr_with_correlated(
7665                        &p.expr,
7666                        row,
7667                        &scan_ctx,
7668                        cancel,
7669                        Some(&mut proj_memo),
7670                    )?);
7671                }
7672                projected_rows.push(Row::new(vals));
7673            }
7674        }
7675        // ORDER BY / LIMIT — apply on the projected rows (cheap;
7676        // unnest result sets are small by design).
7677        let columns: alloc::vec::Vec<ColumnSchema> = projection
7678            .iter()
7679            .map(|p| ColumnSchema::new(p.output_name.clone(), p.ty, p.nullable))
7680            .collect();
7681        // Re-evaluate ORDER BY against the source schema (pre-projection
7682        // so col refs by name still resolve through `scan_ctx`).
7683        if !stmt.order_by.is_empty() {
7684            let mut indexed: alloc::vec::Vec<(usize, Vec<Value>)> = filtered
7685                .iter()
7686                .enumerate()
7687                .map(|(i, r)| -> Result<_, EngineError> {
7688                    let keys: Result<Vec<Value>, EngineError> = stmt
7689                        .order_by
7690                        .iter()
7691                        .map(|ob| {
7692                            eval::eval_expr(&ob.expr, r, &scan_ctx).map_err(EngineError::Eval)
7693                        })
7694                        .collect();
7695                    Ok((i, keys?))
7696                })
7697                .collect::<Result<_, _>>()?;
7698            indexed.sort_by(|a, b| {
7699                for (idx, (ka, kb)) in a.1.iter().zip(b.1.iter()).enumerate() {
7700                    let o = &stmt.order_by[idx];
7701                    let cmp = order_by_value_cmp(o.desc, o.nulls_first, ka, kb);
7702                    if cmp != core::cmp::Ordering::Equal {
7703                        return cmp;
7704                    }
7705                }
7706                core::cmp::Ordering::Equal
7707            });
7708            projected_rows = indexed
7709                .into_iter()
7710                .map(|(i, _)| projected_rows[i].clone())
7711                .collect();
7712        }
7713        // LIMIT / OFFSET — apply at the tail.
7714        if let Some(offset) = stmt.offset_literal() {
7715            let off = (offset as usize).min(projected_rows.len());
7716            projected_rows.drain(..off);
7717        }
7718        if let Some(limit) = stmt.limit_literal() {
7719            projected_rows.truncate(limit as usize);
7720        }
7721        Ok(QueryResult::Rows {
7722            columns,
7723            rows: projected_rows,
7724        })
7725    }
7726
7727    /// v7.17.0 Phase 3.10 — `FROM generate_series(start, stop [,
7728    /// step])` set-returning source. Mirrors `exec_select_unnest`'s
7729    /// shape: evaluate the arg list once against an empty row,
7730    /// materialise the row stream by stepping start → stop, then
7731    /// route through the standard WHERE / projection / ORDER BY /
7732    /// LIMIT pipeline. Two arg-type combos in v7.17:
7733    ///   * integer / integer [/ integer] — SmallInt, Int, BigInt
7734    ///     (widened to BigInt internally; step defaults to 1)
7735    ///   * timestamp / timestamp / interval — date-range
7736    ///     iteration (mailrs's daily-report pattern)
7737    fn exec_select_generate_series(
7738        &self,
7739        stmt: &SelectStatement,
7740        primary: &TableRef,
7741        cancel: CancelToken<'_>,
7742    ) -> Result<QueryResult, EngineError> {
7743        let args = primary
7744            .generate_series_args
7745            .as_ref()
7746            .expect("caller guards generate_series_args.is_some()");
7747        let empty_schema: alloc::vec::Vec<ColumnSchema> = alloc::vec::Vec::new();
7748        let ctx = EvalContext::new(&empty_schema, None);
7749        let dummy_row = Row::new(alloc::vec::Vec::new());
7750        let mut arg_values: alloc::vec::Vec<Value> = alloc::vec::Vec::with_capacity(args.len());
7751        for a in args {
7752            arg_values.push(eval::eval_expr(a, &dummy_row, &ctx).map_err(EngineError::Eval)?);
7753        }
7754        // Dispatch on the start value's shape. Reject mixed-shape
7755        // calls early (e.g. start = timestamp, stop = integer) so
7756        // the caller gets a clean error rather than a panic.
7757        let (elem_dtype, rows) = match arg_values.as_slice() {
7758            [Value::Timestamp(start), Value::Timestamp(stop), step] => {
7759                let interval_step = match step {
7760                    Value::Interval { .. } => step.clone(),
7761                    other => {
7762                        return Err(EngineError::Unsupported(alloc::format!(
7763                            "generate_series(timestamp, timestamp, …): \
7764                             step must be INTERVAL, got {:?}",
7765                            other.data_type()
7766                        )));
7767                    }
7768                };
7769                let rows = generate_series_timestamps(*start, *stop, interval_step, &cancel)?;
7770                (DataType::Timestamp, rows)
7771            }
7772            [start, stop, step]
7773                if value_is_integer(start) && value_is_integer(stop) && value_is_integer(step) =>
7774            {
7775                let s = value_to_i64(start);
7776                let e = value_to_i64(stop);
7777                let st = value_to_i64(step);
7778                let rows = generate_series_integers(s, e, st, &cancel)?;
7779                (DataType::BigInt, rows)
7780            }
7781            [start, stop] if value_is_integer(start) && value_is_integer(stop) => {
7782                let s = value_to_i64(start);
7783                let e = value_to_i64(stop);
7784                let rows = generate_series_integers(s, e, 1, &cancel)?;
7785                (DataType::BigInt, rows)
7786            }
7787            _ => {
7788                return Err(EngineError::Unsupported(alloc::format!(
7789                    "generate_series(): v7.17 supports integer or (timestamp, timestamp, interval) \
7790                     argument shapes; got {:?}",
7791                    arg_values
7792                        .iter()
7793                        .map(|v| v.data_type())
7794                        .collect::<alloc::vec::Vec<_>>()
7795                )));
7796            }
7797        };
7798        let alias = primary
7799            .alias
7800            .clone()
7801            .unwrap_or_else(|| "generate_series".to_string());
7802        let col_name = alias.clone();
7803        let col_schema = ColumnSchema::new(col_name, elem_dtype, true);
7804        let schema_cols = alloc::vec![col_schema.clone()];
7805        let scan_ctx = EvalContext::new(&schema_cols, Some(&alias));
7806        // WHERE.
7807        let filtered: alloc::vec::Vec<Row> = if let Some(w) = &stmt.where_ {
7808            let mut out = alloc::vec::Vec::with_capacity(rows.len());
7809            for row in rows {
7810                cancel.check()?;
7811                let v = eval::eval_expr(w, &row, &scan_ctx).map_err(EngineError::Eval)?;
7812                if matches!(v, Value::Bool(true)) {
7813                    out.push(row);
7814                }
7815            }
7816            out
7817        } else {
7818            rows
7819        };
7820        // v7.17.0 Phase 3.P0-48 — aggregate dispatch for set-
7821        // returning sources. When the SELECT projection contains
7822        // aggregate functions (COUNT/SUM/MIN/MAX/AVG/string_agg/
7823        // …) we route the filtered row stream through the same
7824        // aggregate executor the relational scan path uses, so
7825        // `SELECT COUNT(*) FROM generate_series(1, 100)` returns
7826        // a single 100 row instead of erroring at projection
7827        // time. GROUP BY / HAVING / ORDER BY over the aggregate
7828        // output all ride through `aggregate::run`.
7829        if aggregate::uses_aggregate(stmt) {
7830            let agg_correlated = |e: &Expr, r: &Row, c: &EvalContext<'_>| {
7831                self.eval_expr_with_correlated(e, r, c, cancel, None)
7832                    .map_err(|err| match err {
7833                        EngineError::Eval(ev) => ev,
7834                        other => eval::EvalError::TypeMismatch {
7835                            detail: alloc::format!("{other}"),
7836                        },
7837                    })
7838            };
7839            let filtered_refs: alloc::vec::Vec<&Row> = filtered.iter().collect();
7840            let mut agg = aggregate::run(
7841                stmt,
7842                &filtered_refs,
7843                &schema_cols,
7844                Some(&alias),
7845                Some(&agg_correlated),
7846            )?;
7847            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
7848            return Ok(QueryResult::Rows {
7849                columns: agg.columns,
7850                rows: agg.rows,
7851            });
7852        }
7853        // Projection.
7854        let projection = build_projection(&stmt.items, &schema_cols, &alias)?;
7855        let mut projected_rows: alloc::vec::Vec<Row> =
7856            alloc::vec::Vec::with_capacity(filtered.len());
7857        let mut proj_memo = memoize::MemoizeCache::default();
7858        for row in &filtered {
7859            let mut vals = alloc::vec::Vec::with_capacity(projection.len());
7860            for p in &projection {
7861                // v7.24 (round-16 B) — correlated-aware.
7862                vals.push(self.eval_expr_with_correlated(
7863                    &p.expr,
7864                    row,
7865                    &scan_ctx,
7866                    cancel,
7867                    Some(&mut proj_memo),
7868                )?);
7869            }
7870            projected_rows.push(Row::new(vals));
7871        }
7872        let columns: alloc::vec::Vec<ColumnSchema> = projection
7873            .iter()
7874            .map(|p| ColumnSchema::new(p.output_name.clone(), p.ty, p.nullable))
7875            .collect();
7876        // ORDER BY against the source schema.
7877        if !stmt.order_by.is_empty() {
7878            let mut indexed: alloc::vec::Vec<(usize, Vec<Value>)> = filtered
7879                .iter()
7880                .enumerate()
7881                .map(|(i, r)| -> Result<_, EngineError> {
7882                    let keys: Result<Vec<Value>, EngineError> = stmt
7883                        .order_by
7884                        .iter()
7885                        .map(|ob| {
7886                            eval::eval_expr(&ob.expr, r, &scan_ctx).map_err(EngineError::Eval)
7887                        })
7888                        .collect();
7889                    Ok((i, keys?))
7890                })
7891                .collect::<Result<_, _>>()?;
7892            indexed.sort_by(|a, b| {
7893                for (idx, (ka, kb)) in a.1.iter().zip(b.1.iter()).enumerate() {
7894                    let o = &stmt.order_by[idx];
7895                    let cmp = order_by_value_cmp(o.desc, o.nulls_first, ka, kb);
7896                    if cmp != core::cmp::Ordering::Equal {
7897                        return cmp;
7898                    }
7899                }
7900                core::cmp::Ordering::Equal
7901            });
7902            projected_rows = indexed
7903                .into_iter()
7904                .map(|(i, _)| projected_rows[i].clone())
7905                .collect();
7906        }
7907        if let Some(offset) = stmt.offset_literal() {
7908            let off = (offset as usize).min(projected_rows.len());
7909            projected_rows.drain(..off);
7910        }
7911        if let Some(limit) = stmt.limit_literal() {
7912            projected_rows.truncate(limit as usize);
7913        }
7914        Ok(QueryResult::Rows {
7915            columns,
7916            rows: projected_rows,
7917        })
7918    }
7919
7920    fn exec_bare_select_cancel(
7921        &self,
7922        stmt: &SelectStatement,
7923        cancel: CancelToken<'_>,
7924    ) -> Result<QueryResult, EngineError> {
7925        // v7.17.0 Phase 3.P0-49 — `FETCH FIRST N ROWS WITH TIES`
7926        // is meaningless without an ORDER BY; PG raises a hard
7927        // error and SPG mirrors the surface so the same DDL/app
7928        // path behaves identically on cutover.
7929        check_with_ties_requires_order_by(stmt)?;
7930        // v7.16.2 — same meta-view dispatch as
7931        // `exec_select_cancel`, applied here too because
7932        // `subquery_replacement` enters this function directly
7933        // for Exists / ScalarSubquery / InSubquery resolution
7934        // (bypassing the top-level entry to avoid double
7935        // subquery walking). Without this dispatch the subquery
7936        // hits `__spg_info_columns` and reports TableNotFound.
7937        if !self.meta_views_materialised && select_references_meta_view(stmt) {
7938            return self.exec_select_with_meta_views(stmt, cancel);
7939        }
7940        // v4.12: window-function path. When the projection contains
7941        // any `name(args) OVER (...)` we route to the dedicated
7942        // executor — partition + sort + per-row window value before
7943        // the regular projection.
7944        if select_has_window(stmt) {
7945            return self.exec_select_with_window(stmt, cancel);
7946        }
7947        // Constant SELECT (no FROM) — evaluate each item once against an
7948        // empty dummy row. Useful for `SELECT 1`, `SELECT coalesce(...)`,
7949        // `SELECT '7'::INT`. Column references will surface as
7950        // ColumnNotFound on eval since the schema is empty.
7951        let Some(from) = &stmt.from else {
7952            let empty_schema: Vec<ColumnSchema> = Vec::new();
7953            let ctx = self.ev_ctx(&empty_schema, None);
7954            let projection = build_projection(&stmt.items, &empty_schema, "")?;
7955            let dummy_row = Row::new(Vec::new());
7956            let mut values = Vec::with_capacity(projection.len());
7957            for p in &projection {
7958                values.push(eval::eval_expr(&p.expr, &dummy_row, &ctx)?);
7959            }
7960            let columns: Vec<ColumnSchema> = projection
7961                .into_iter()
7962                .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
7963                .collect();
7964            return Ok(QueryResult::Rows {
7965                columns,
7966                rows: alloc::vec![Row::new(values)],
7967            });
7968        };
7969        // Multi-table FROM (one or more joined peers) goes through the
7970        // nested-loop join executor. Single-table FROM stays on the
7971        // existing scan + index-seek path.
7972        if !from.joins.is_empty() {
7973            return self.exec_joined_select(stmt, from, cancel);
7974        }
7975        // v7.11.7 — `FROM unnest(<expr>) [AS] <alias>`. Synthesise a
7976        // single-column table at SELECT entry by evaluating the
7977        // expression once against the empty row (UNNEST is
7978        // uncorrelated in v7.11; correlated / LATERAL unnest is a
7979        // v7.12 carve-out). Build a virtual `Table` in a heap-only
7980        // catalog, then route to the regular scan path.
7981        if from.primary.unnest_expr.is_some() {
7982            return self.exec_select_unnest(stmt, &from.primary, cancel);
7983        }
7984        // v7.17.0 Phase 3.10 — `FROM generate_series(start, stop
7985        // [, step])` set-returning source. Dispatch mirrors UNNEST:
7986        // materialise the row stream from a single eval pass, then
7987        // run the regular projection / WHERE / ORDER BY / LIMIT
7988        // pipeline over the synthetic single-column table.
7989        if from.primary.generate_series_args.is_some() {
7990            return self.exec_select_generate_series(stmt, &from.primary, cancel);
7991        }
7992        let primary = &from.primary;
7993        let table = self.active_catalog().get(&primary.name).ok_or_else(|| {
7994            StorageError::TableNotFound {
7995                name: primary.name.clone(),
7996            }
7997        })?;
7998        let schema_cols = &table.schema().columns;
7999        // The qualifier accepted on column refs is the alias (if any) else the
8000        // bare table name.
8001        let alias = primary.alias.as_deref().unwrap_or(primary.name.as_str());
8002        let ctx = self.ev_ctx(schema_cols, Some(alias));
8003
8004        // NSW kNN planner: `ORDER BY col <-> literal LIMIT k` with no
8005        // WHERE and an NSW index on `col` skips the full scan. The
8006        // walk returns rows already in ascending-distance order, so
8007        // ORDER BY / LIMIT are honoured implicitly.
8008        if let Some(nsw_rows) = try_nsw_knn(stmt, table, schema_cols, alias) {
8009            return materialise_in_order(stmt, table, schema_cols, alias, &nsw_rows);
8010        }
8011
8012        // Index seek: if WHERE is `col = literal` (or commuted) and the
8013        // referenced column has an index, dispatch each locator through
8014        // the catalog (hot tier → borrow, cold tier → page-read +
8015        // decode) and iterate just those rows. Otherwise fall back to a
8016        // full scan over the hot tier (cold-tier rows are only reached
8017        // via index seek in v5.1 — full table scans against cold-tier
8018        // data ship in v5.2 with the freezer's per-segment scan API).
8019        let indexed_rows: Option<Vec<Cow<'_, Row>>> = stmt.where_.as_ref().and_then(|w| {
8020            // BTree / col=literal seek first — covers the v7.11.3 multi-
8021            // column AND case and the leading-column equality lookup.
8022            try_index_seek(w, schema_cols, self.active_catalog(), table, alias)
8023                .or_else(|| {
8024                    // v7.12.3 — GIN-accelerated `WHERE col @@
8025                    // tsquery` when the column has a `USING gin`
8026                    // index. Returns an over-approximate candidate
8027                    // set; the WHERE re-eval loop below verifies
8028                    // the full `@@` predicate per row.
8029                    try_gin_seek(w, schema_cols, self.active_catalog(), table, alias, &ctx)
8030                })
8031                .or_else(|| {
8032                    // v7.15.0 — trigram-GIN-accelerated
8033                    // `WHERE col LIKE / ILIKE '<pat>'` when the
8034                    // column has a `gin_trgm_ops` GIN index.
8035                    // Over-approximate candidate set; the WHERE
8036                    // re-eval verifies the LIKE per row.
8037                    try_trgm_seek(w, schema_cols, table, alias)
8038                })
8039        });
8040
8041        // Aggregate path: filter rows first, then hand off to the
8042        // aggregate executor which does its own projection + ORDER BY.
8043        if aggregate::uses_aggregate(stmt) {
8044            let mut filtered: Vec<&Row> = Vec::new();
8045            // v6.2.6 — Memoize: per-query LRU cache for correlated
8046            // scalar subqueries. Fresh per row-loop entry so each
8047            // SELECT execution gets an isolated cache.
8048            let mut memo = memoize::MemoizeCache::new();
8049            if let Some(rows) = &indexed_rows {
8050                for cow in rows {
8051                    let row = cow.as_ref();
8052                    if let Some(where_expr) = &stmt.where_ {
8053                        let cond = self.eval_expr_with_correlated(
8054                            where_expr,
8055                            row,
8056                            &ctx,
8057                            cancel,
8058                            Some(&mut memo),
8059                        )?;
8060                        if !matches!(cond, Value::Bool(true)) {
8061                            continue;
8062                        }
8063                    }
8064                    filtered.push(row);
8065                }
8066            } else {
8067                for i in 0..table.row_count() {
8068                    let row = &table.rows()[i];
8069                    if let Some(where_expr) = &stmt.where_ {
8070                        let cond = self.eval_expr_with_correlated(
8071                            where_expr,
8072                            row,
8073                            &ctx,
8074                            cancel,
8075                            Some(&mut memo),
8076                        )?;
8077                        if !matches!(cond, Value::Bool(true)) {
8078                            continue;
8079                        }
8080                    }
8081                    filtered.push(row);
8082                }
8083            }
8084            let agg_correlated = |e: &Expr, r: &Row, c: &EvalContext<'_>| {
8085                self.eval_expr_with_correlated(e, r, c, cancel, None)
8086                    .map_err(|err| match err {
8087                        EngineError::Eval(ev) => ev,
8088                        other => eval::EvalError::TypeMismatch {
8089                            detail: alloc::format!("{other}"),
8090                        },
8091                    })
8092            };
8093            let mut agg = aggregate::run(
8094                stmt,
8095                &filtered,
8096                schema_cols,
8097                Some(alias),
8098                Some(&agg_correlated),
8099            )?;
8100            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
8101            return Ok(QueryResult::Rows {
8102                columns: agg.columns,
8103                rows: agg.rows,
8104            });
8105        }
8106
8107        let projection = build_projection(&stmt.items, schema_cols, alias)?;
8108        // v7.19 P5 — single-table SELECT path for SRF
8109        // `SELECT unnest(arr) FROM t` shape. Detect a top-level
8110        // unnest in the projection list. When present, the
8111        // per-row processor emits one output row per array
8112        // element (broadcasting non-SRF projections from the
8113        // same input row). Empty / NULL arrays emit zero rows
8114        // for that input — PG semantics.
8115        let srf_position = projection.iter().position(|p| is_top_level_unnest(&p.expr));
8116
8117        // Materialise the filter pass into `(order_key, projected_row)`
8118        // tuples. The order key is `None` when there's no ORDER BY clause.
8119        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::new();
8120        // v6.2.6 — Memoize per-row WHERE eval shares one cache.
8121        let mut memo = memoize::MemoizeCache::new();
8122        // Inline the per-row work in a closure so the indexed and full-
8123        // scan branches share the body.
8124        let mut process_row = |row: &Row, loop_idx: usize| -> Result<(), EngineError> {
8125            if loop_idx.is_multiple_of(256) {
8126                cancel.check()?;
8127            }
8128            if let Some(where_expr) = &stmt.where_ {
8129                let cond =
8130                    self.eval_expr_with_correlated(where_expr, row, &ctx, cancel, Some(&mut memo))?;
8131                if !matches!(cond, Value::Bool(true)) {
8132                    return Ok(());
8133                }
8134            }
8135            let order_keys = if stmt.order_by.is_empty() {
8136                Vec::new()
8137            } else {
8138                build_order_keys(&stmt.order_by, row, &ctx)?
8139            };
8140            if let Some(srf_idx) = srf_position {
8141                let srf_arg = top_level_unnest_arg(&projection[srf_idx].expr)
8142                    .expect("checked by is_top_level_unnest above");
8143                let arr_val = eval::eval_expr(srf_arg, row, &ctx)?;
8144                let elements = array_value_to_elements(&arr_val)?;
8145                for elem in elements {
8146                    let mut values = Vec::with_capacity(projection.len());
8147                    for (i, p) in projection.iter().enumerate() {
8148                        if i == srf_idx {
8149                            values.push(elem.clone());
8150                        } else {
8151                            values.push(eval::eval_expr(&p.expr, row, &ctx)?);
8152                        }
8153                    }
8154                    tagged.push((order_keys.clone(), Row::new(values)));
8155                }
8156            } else {
8157                let mut values = Vec::with_capacity(projection.len());
8158                for p in &projection {
8159                    // v7.24 (round-16 B) — correlated-aware.
8160                    values.push(self.eval_expr_with_correlated(&p.expr, row, &ctx, cancel, None)?);
8161                }
8162                tagged.push((order_keys, Row::new(values)));
8163            }
8164            Ok(())
8165        };
8166        if let Some(rows) = &indexed_rows {
8167            for (loop_idx, cow) in rows.iter().enumerate() {
8168                process_row(cow.as_ref(), loop_idx)?;
8169            }
8170        } else {
8171            for i in 0..table.row_count() {
8172                process_row(&table.rows()[i], i)?;
8173            }
8174        }
8175
8176        if !stmt.order_by.is_empty() {
8177            // Partial-sort fast path: when LIMIT is small relative to
8178            // the row count, select_nth_unstable + sort just the
8179            // prefix is O(n + k log k) instead of O(n log n). DISTINCT
8180            // requires the full sort because de-dup happens after.
8181            // WITH TIES likewise needs the full sort so the tie
8182            // extension can scan past `limit` to find rows that
8183            // share the last-kept row's key.
8184            let keep = if stmt.distinct || stmt.limit_with_ties {
8185                None
8186            } else {
8187                stmt.limit_literal()
8188                    .map(|l| l as usize + stmt.offset_literal().map_or(0, |o| o as usize))
8189            };
8190            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
8191            partial_sort_tagged(&mut tagged, keep, &descs);
8192        }
8193
8194        // v7.17.0 Phase 3.P0-49 — `FETCH FIRST … WITH TIES` extends
8195        // past the truncated tail through every row that shares the
8196        // last-kept row's ORDER BY key. The tie check uses the
8197        // already-computed `(order_keys, row)` pairs so it matches
8198        // the sort comparator exactly. DISTINCT + WITH TIES falls
8199        // through to the no-ties path (PG also disallows their
8200        // combination; SPG silently drops the tie extension here so
8201        // the customer doesn't see a hard error mid-query — the
8202        // user-visible result is still correct, just narrower).
8203        let output_rows: Vec<Row> = if stmt.limit_with_ties && !stmt.distinct {
8204            apply_offset_and_limit_tagged(
8205                &mut tagged,
8206                stmt.offset_literal(),
8207                stmt.limit_literal(),
8208                true,
8209            );
8210            tagged.into_iter().map(|(_, r)| r).collect()
8211        } else {
8212            let mut output_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
8213            if stmt.distinct {
8214                output_rows = dedup_rows(output_rows);
8215            }
8216            apply_offset_and_limit(
8217                &mut output_rows,
8218                stmt.offset_literal(),
8219                stmt.limit_literal(),
8220            );
8221            output_rows
8222        };
8223
8224        let columns: Vec<ColumnSchema> = projection
8225            .into_iter()
8226            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
8227            .collect();
8228
8229        Ok(QueryResult::Rows {
8230            columns,
8231            rows: output_rows,
8232        })
8233    }
8234
8235    /// Multi-table SELECT executor (one or more JOIN peers).
8236    ///
8237    /// v1.10 builds the joined row set up-front via nested-loop joins,
8238    /// then runs WHERE + projection + ORDER BY against the combined
8239    /// rows. No index seek. Aggregates and DISTINCT still work because
8240    /// the executor delegates projection through the same shared paths.
8241    #[allow(clippy::too_many_lines)]
8242    /// v7.13.2 — mailrs round-6 S5. Resolve a TableRef into an
8243    /// owned (rows, schema) pair. Catalog tables clone their hot
8244    /// rows + schema; UNNEST table refs evaluate their array
8245    /// expression once and synthesise a single-column row set
8246    /// using the same dispatch as `exec_select_unnest`. Used by
8247    /// the joined-select path so UNNEST can appear in any FROM
8248    /// position, not just as the primary.
8249    fn materialise_table_ref(
8250        &self,
8251        tref: &TableRef,
8252    ) -> Result<(Vec<Row>, Vec<ColumnSchema>), EngineError> {
8253        if let Some(expr) = tref.unnest_expr.as_deref() {
8254            let empty_schema: Vec<ColumnSchema> = Vec::new();
8255            let ctx = EvalContext::new(&empty_schema, None);
8256            let dummy_row = Row::new(Vec::new());
8257            let (elem_dtype, rows) =
8258                match eval::eval_expr(expr, &dummy_row, &ctx).map_err(EngineError::Eval)? {
8259                    Value::Null => (DataType::Text, Vec::new()),
8260                    Value::TextArray(items) => (
8261                        DataType::Text,
8262                        items
8263                            .into_iter()
8264                            .map(|item| {
8265                                Row::new(alloc::vec![match item {
8266                                    Some(s) => Value::Text(s),
8267                                    None => Value::Null,
8268                                }])
8269                            })
8270                            .collect(),
8271                    ),
8272                    Value::IntArray(items) => (
8273                        DataType::Int,
8274                        items
8275                            .into_iter()
8276                            .map(|item| {
8277                                Row::new(alloc::vec![match item {
8278                                    Some(n) => Value::Int(n),
8279                                    None => Value::Null,
8280                                }])
8281                            })
8282                            .collect(),
8283                    ),
8284                    Value::BigIntArray(items) => (
8285                        DataType::BigInt,
8286                        items
8287                            .into_iter()
8288                            .map(|item| {
8289                                Row::new(alloc::vec![match item {
8290                                    Some(n) => Value::BigInt(n),
8291                                    None => Value::Null,
8292                                }])
8293                            })
8294                            .collect(),
8295                    ),
8296                    other => {
8297                        return Err(EngineError::Unsupported(alloc::format!(
8298                            "unnest() expects an array argument, got {:?}",
8299                            other.data_type()
8300                        )));
8301                    }
8302                };
8303            let alias = tref.alias.clone().unwrap_or_else(|| "unnest".to_string());
8304            let col_name = tref.unnest_column_aliases.first().cloned().unwrap_or(alias);
8305            return Ok((
8306                rows,
8307                alloc::vec![ColumnSchema::new(col_name, elem_dtype, true)],
8308            ));
8309        }
8310        let table =
8311            self.active_catalog()
8312                .get(&tref.name)
8313                .ok_or_else(|| StorageError::TableNotFound {
8314                    name: tref.name.clone(),
8315                })?;
8316        let rows: Vec<Row> = table.rows().iter().cloned().collect();
8317        let cols = table.schema().columns.clone();
8318        Ok((rows, cols))
8319    }
8320
8321    /// v7.28 (round-22) — materialise a plain table ref with
8322    /// single-table predicates pushed BELOW the clone: an indexed
8323    /// `col = literal` narrows to the matching row ids before any
8324    /// row is cloned, the rest filter linearly. A correlated
8325    /// subquery body like `… JOIN messages m2 ON …
8326    /// WHERE m2.thread_id = '<outer>'` runs per GROUP — without
8327    /// this it cloned + scanned the full 24k-row table 23.5k times.
8328    /// Falls back to the plain path for non-table refs.
8329    fn materialise_table_ref_filtered(
8330        &self,
8331        tref: &TableRef,
8332        preds: &[&Expr],
8333    ) -> Result<(Vec<Row>, Vec<ColumnSchema>), EngineError> {
8334        if preds.is_empty()
8335            || tref.unnest_expr.is_some()
8336            || tref.lateral_subquery.is_some()
8337            || tref.as_of_segment.is_some()
8338        {
8339            return self.materialise_table_ref(tref);
8340        }
8341        let Some(table) = self.active_catalog().get(&tref.name) else {
8342            return self.materialise_table_ref(tref);
8343        };
8344        let cols = table.schema().columns.clone();
8345        let alias = tref.alias.as_deref().unwrap_or(tref.name.as_str());
8346        // Index seek on the first `col = literal` predicate with a
8347        // BTree on that column.
8348        let mut seeded: Option<Vec<usize>> = None;
8349        for p in preds {
8350            if let Expr::Binary {
8351                lhs,
8352                op: spg_sql::ast::BinOp::Eq,
8353                rhs,
8354            } = p
8355            {
8356                let pair = match (lhs.as_ref(), rhs.as_ref()) {
8357                    (Expr::Column(c), Expr::Literal(l)) | (Expr::Literal(l), Expr::Column(c)) => {
8358                        Some((c, l))
8359                    }
8360                    _ => None,
8361                };
8362                if let Some((c, l)) = pair
8363                    && c.qualifier
8364                        .as_deref()
8365                        .is_none_or(|q| q.eq_ignore_ascii_case(alias))
8366                    && let Some(pos) = cols.iter().position(|s| s.name == c.name)
8367                    && let Some(idx) = table.index_on(pos)
8368                    && let Some(key) = spg_storage::IndexKey::from_value(&eval::literal_to_value(l))
8369                {
8370                    let mut ids = Vec::new();
8371                    let mut all_hot = true;
8372                    for loc in idx.lookup_eq(&key) {
8373                        match *loc {
8374                            spg_storage::RowLocator::Hot(i) => ids.push(i),
8375                            spg_storage::RowLocator::Cold { .. } => {
8376                                all_hot = false;
8377                                break;
8378                            }
8379                        }
8380                    }
8381                    if all_hot {
8382                        seeded = Some(ids);
8383                        break;
8384                    }
8385                }
8386            }
8387        }
8388        let ctx = EvalContext::new(&cols, Some(alias));
8389        let mut out: Vec<Row> = Vec::new();
8390        let push_if = |row: &Row, out: &mut Vec<Row>| -> Result<(), EngineError> {
8391            for p in preds {
8392                let v = eval::eval_expr(p, row, &ctx).map_err(EngineError::Eval)?;
8393                if !matches!(v, Value::Bool(true)) {
8394                    return Ok(());
8395                }
8396            }
8397            out.push(row.clone());
8398            Ok(())
8399        };
8400        match seeded {
8401            Some(ids) => {
8402                for i in ids {
8403                    if let Some(row) = table.rows().get(i) {
8404                        push_if(row, &mut out)?;
8405                    }
8406                }
8407            }
8408            None => {
8409                for row in table.rows().iter() {
8410                    push_if(row, &mut out)?;
8411                }
8412            }
8413        }
8414        Ok((out, cols))
8415    }
8416
8417    /// v7.17.0 Phase 3.P0-43 — materialise a `FROM` with one or more
8418    /// JOINs into `(combined_schema, filtered_rows)`. The combined
8419    /// schema uses composite `alias.col` column names so the
8420    /// qualifier-aware column resolver finds every join peer by
8421    /// exact match; the filtered rows are the join cross-product
8422    /// after the optional WHERE clause is applied.
8423    ///
8424    /// Shared by `exec_joined_select` and the JOIN branch of
8425    /// `exec_select_with_window`; both paths used to inline the
8426    /// same nested-loop logic and the window path rejected JOIN
8427    /// outright.
8428    /// v7.28 (round-22) — resolve a Column reference against a
8429    /// composite ("alias.col") schema slice. Bare names match a
8430    /// unique ".col" suffix.
8431    fn composite_col_pos(schema: &[ColumnSchema], c: &spg_sql::ast::ColumnName) -> Option<usize> {
8432        if let Some(q) = &c.qualifier {
8433            let composite = alloc::format!("{q}.{}", c.name);
8434            return schema.iter().position(|s| s.name == composite);
8435        }
8436        let suffix = alloc::format!(".{}", c.name);
8437        let mut hits = schema
8438            .iter()
8439            .enumerate()
8440            .filter(|(_, s)| s.name.ends_with(&suffix) || s.name == c.name);
8441        let first = hits.next();
8442        if hits.next().is_some() {
8443            return None; // ambiguous — leave to the residual evaluator
8444        }
8445        first.map(|(i, _)| i)
8446    }
8447
8448    /// v7.28 (round-22) — resolve a Column against ONE peer's own
8449    /// columns (right side of a join): `alias.col` or a bare name.
8450    fn peer_col_pos(
8451        peer_alias: &str,
8452        peer_cols: &[ColumnSchema],
8453        c: &spg_sql::ast::ColumnName,
8454    ) -> Option<usize> {
8455        if let Some(q) = &c.qualifier
8456            && !q.eq_ignore_ascii_case(peer_alias)
8457        {
8458            return None;
8459        }
8460        peer_cols.iter().position(|s| s.name == c.name)
8461    }
8462
8463    /// v7.28 (round-22) — drop the VALUES of columns the statement
8464    /// never references (schema and positions stay; the value
8465    /// becomes NULL, so a 30 KB body column costs nothing through
8466    /// the join pipeline instead of being cloned per row).
8467    fn null_out_unreferenced(
8468        rows: &mut [Row],
8469        cols: &[ColumnSchema],
8470        alias: &str,
8471        needed: &alloc::collections::BTreeSet<(String, String)>,
8472    ) {
8473        let keep: Vec<bool> = cols
8474            .iter()
8475            .map(|c| needed.contains(&(alias.to_string(), c.name.clone())))
8476            .collect();
8477        if keep.iter().all(|k| *k) {
8478            return;
8479        }
8480        for row in rows.iter_mut() {
8481            for (i, k) in keep.iter().enumerate() {
8482                if !*k && i < row.values.len() {
8483                    row.values[i] = Value::Null;
8484                }
8485            }
8486        }
8487    }
8488
8489    fn build_joined_filtered_rows(
8490        &self,
8491        from: &FromClause,
8492        where_: Option<&Expr>,
8493        cancel: CancelToken<'_>,
8494        needed: Option<&alloc::collections::BTreeSet<(String, String)>>,
8495    ) -> Result<(Vec<ColumnSchema>, Vec<Row>), EngineError> {
8496        let primary_alias = from
8497            .primary
8498            .alias
8499            .as_deref()
8500            .unwrap_or(from.primary.name.as_str())
8501            .to_string();
8502        // v7.28 (round-22) — single-table predicate pushdown. WHERE
8503        // conjuncts whose every column is QUALIFIED with one table's
8504        // alias filter that table BEFORE the join (with an index
8505        // seek when one matches `col = literal`). Only the primary
8506        // and INNER peers are eligible — pre-filtering a LEFT peer
8507        // would change which rows NULL-extend. Pushed conjuncts stay
8508        // in WHERE too (idempotent), so correctness never depends on
8509        // the pushdown.
8510        let mut primary_preds: Vec<&Expr> = Vec::new();
8511        let mut peer_preds: Vec<Vec<&Expr>> = alloc::vec![Vec::new(); from.joins.len()];
8512        if let Some(w) = where_ {
8513            for sub in reorder::split_and_conjunctions(w) {
8514                if expr_has_subquery(sub) || aggregate::contains_aggregate(sub) {
8515                    continue;
8516                }
8517                let mut quals: Vec<&str> = Vec::new();
8518                let mut all_qualified = true;
8519                collect_column_qualifiers(sub, &mut quals, &mut all_qualified);
8520                if !all_qualified || quals.is_empty() {
8521                    continue;
8522                }
8523                let q0 = quals[0];
8524                if !quals.iter().all(|q| q.eq_ignore_ascii_case(q0)) {
8525                    continue;
8526                }
8527                if q0.eq_ignore_ascii_case(&primary_alias) {
8528                    primary_preds.push(sub);
8529                    continue;
8530                }
8531                for (i, j) in from.joins.iter().enumerate() {
8532                    if matches!(j.kind, JoinKind::Inner)
8533                        && j.table.lateral_subquery.is_none()
8534                        && q0.eq_ignore_ascii_case(
8535                            j.table.alias.as_deref().unwrap_or(j.table.name.as_str()),
8536                        )
8537                    {
8538                        peer_preds[i].push(sub);
8539                        break;
8540                    }
8541                }
8542            }
8543        }
8544        // v7.28 (round-22) — table-order swap: when the primary has
8545        // no pushed predicate but an INNER peer does, start from the
8546        // filtered peer instead. Equi-joins commute; output columns
8547        // resolve by composite name, so downstream projection is
8548        // order-independent. (A correlated subquery body like
8549        // `FROM email_analysis e2 JOIN messages m2 … WHERE
8550        // m2.thread_id = '<outer>'` otherwise clones the whole
8551        // unfiltered primary once per outer group.)
8552        let mut from_owned;
8553        let mut from = from;
8554        // Safety: swapping reorders which table joins FIRST, so it is
8555        // only legal when the FIRST join's ON references no table
8556        // beyond {primary, first peer} (a later peer's ON may name
8557        // the original primary, which must already be in the
8558        // combined row when that peer joins). Restrict to i == 0 AND
8559        // an ON whose qualifiers all live in those two tables.
8560        if primary_preds.is_empty()
8561            && let Some(j0) = from.joins.first()
8562            && matches!(j0.kind, JoinKind::Inner)
8563            && j0.table.lateral_subquery.is_none()
8564            && !peer_preds[0].is_empty()
8565        {
8566            let peer_alias = j0.table.alias.as_deref().unwrap_or(j0.table.name.as_str());
8567            let on_safe = j0.on.as_ref().is_some_and(|on| {
8568                let mut quals: Vec<&str> = Vec::new();
8569                let mut all_q = true;
8570                collect_column_qualifiers(on, &mut quals, &mut all_q);
8571                all_q
8572                    && quals.iter().all(|q| {
8573                        q.eq_ignore_ascii_case(&primary_alias) || q.eq_ignore_ascii_case(peer_alias)
8574                    })
8575            });
8576            if on_safe {
8577                from_owned = from.clone();
8578                core::mem::swap(&mut from_owned.primary, &mut from_owned.joins[0].table);
8579                primary_preds = peer_preds[0].drain(..).collect();
8580                from = &from_owned;
8581            }
8582        }
8583        let primary_alias = from
8584            .primary
8585            .alias
8586            .as_deref()
8587            .unwrap_or(from.primary.name.as_str())
8588            .to_string();
8589        let (mut primary_rows, primary_cols) =
8590            self.materialise_table_ref_filtered(&from.primary, &primary_preds)?;
8591        if let Some(needed) = needed {
8592            Self::null_out_unreferenced(&mut primary_rows, &primary_cols, &primary_alias, needed);
8593        }
8594        // v7.17.0 Phase 3.P0-41 — LATERAL peers can't be
8595        // pre-materialised because their rows depend on outer
8596        // columns. For each peer, build either an eager
8597        // (rows, schema) pair or a "lateral" sentinel carrying
8598        // just the schema and the inner SELECT to re-run per
8599        // outer row.
8600        #[allow(clippy::type_complexity)]
8601        let mut joined: Vec<JoinedPeer<'_>> = Vec::new();
8602        for j in &from.joins {
8603            let a = j
8604                .table
8605                .alias
8606                .as_deref()
8607                .unwrap_or(j.table.name.as_str())
8608                .to_string();
8609            if let Some(inner_box) = &j.table.lateral_subquery {
8610                // Probe schema by running the inner SELECT against a
8611                // NULL-padded outer context. The probe gives us the
8612                // projection's column shape; rows materialise per
8613                // left-row below.
8614                let schema = self.lateral_probe_schema(inner_box)?;
8615                joined.push(JoinedPeer {
8616                    eager_rows: None,
8617                    cols: schema,
8618                    alias: a,
8619                    kind: j.kind,
8620                    on: j.on.as_ref(),
8621                    lateral: Some(inner_box.as_ref()),
8622                    join_table: None,
8623                });
8624            } else {
8625                let pidx = from
8626                    .joins
8627                    .iter()
8628                    .position(|jj| core::ptr::eq(jj, j))
8629                    .unwrap_or(0);
8630                // v7.28 - defer materialisation for plain tables with
8631                // no pushed predicate: the index-nested-loop path may
8632                // avoid cloning the table entirely.
8633                let plain = j.table.unnest_expr.is_none() && j.table.as_of_segment.is_none();
8634                if plain
8635                    && peer_preds[pidx].is_empty()
8636                    && let Some(t) = self.active_catalog().get(&j.table.name)
8637                {
8638                    joined.push(JoinedPeer {
8639                        eager_rows: None,
8640                        cols: t.schema().columns.clone(),
8641                        alias: a,
8642                        kind: j.kind,
8643                        on: j.on.as_ref(),
8644                        lateral: None,
8645                        join_table: Some(j.table.name.clone()),
8646                    });
8647                    continue;
8648                }
8649                let (mut rows, cols) =
8650                    self.materialise_table_ref_filtered(&j.table, &peer_preds[pidx])?;
8651                if let Some(needed) = needed {
8652                    Self::null_out_unreferenced(&mut rows, &cols, &a, needed);
8653                }
8654                joined.push(JoinedPeer {
8655                    eager_rows: Some(rows),
8656                    cols,
8657                    alias: a,
8658                    kind: j.kind,
8659                    on: j.on.as_ref(),
8660                    lateral: None,
8661                    join_table: Some(j.table.name.clone()),
8662                });
8663            }
8664        }
8665        let mut combined_schema: Vec<ColumnSchema> = Vec::new();
8666        for col in &primary_cols {
8667            combined_schema.push(ColumnSchema::new(
8668                alloc::format!("{primary_alias}.{}", col.name),
8669                col.ty,
8670                col.nullable,
8671            ));
8672        }
8673        for peer in &joined {
8674            for col in &peer.cols {
8675                combined_schema.push(ColumnSchema::new(
8676                    alloc::format!("{}.{}", peer.alias, col.name),
8677                    col.ty,
8678                    col.nullable,
8679                ));
8680            }
8681        }
8682        let ctx = EvalContext::new(&combined_schema, None);
8683        // v7.28 (round-22) - intermediate-row ceiling: a join whose
8684        // working set explodes errors instead of eating the host
8685        // (mailrs watched RSS climb to 7 GiB of 15 before a manual
8686        // restart). The ceiling is per join STAGE, not per query.
8687        const MAX_JOIN_INTERMEDIATE_ROWS: usize = 4_000_000;
8688        let mut working: Vec<Row> = primary_rows;
8689        // Track the per-row width consumed by the outer left side so
8690        // each lateral evaluation sees the correct schema slice.
8691        let mut consumed_cols = primary_cols.len();
8692        for peer in &joined {
8693            if working.len() > MAX_JOIN_INTERMEDIATE_ROWS {
8694                return Err(EngineError::Unsupported(alloc::format!(
8695                    "join intermediate result exceeds {MAX_JOIN_INTERMEDIATE_ROWS} rows ({} so far) - add join predicates",
8696                    working.len()
8697                )));
8698            }
8699            let right_arity = peer.cols.len();
8700            let mut next: Vec<Row> = Vec::new();
8701            // v7.28 (round-22) — hash equi-join. The old path CLONED
8702            // the full combined row for EVERY (left, right) pair and
8703            // then evaluated ON — O(L×R) row materialisations (a
8704            // 24k × 6k LEFT JOIN = 1.5e8 multi-KB clones; the inbox
8705            // query never returned). Extract `left_col = right_col`
8706            // conjuncts from ON, build a hash on the (smaller,
8707            // already-materialised) right side, and only materialise
8708            // matching pairs. Residual ON conjuncts evaluate on the
8709            // candidates. NULL keys never match (SQL equality).
8710            let mut eq_pairs: Vec<(usize, usize)> = Vec::new(); // (left combined pos, right peer pos)
8711            let mut residual: Vec<&Expr> = Vec::new();
8712            if let (Some(on_expr), None) = (peer.on, peer.lateral) {
8713                for sub in reorder::split_and_conjunctions(on_expr) {
8714                    let mut matched = None;
8715                    if let Expr::Binary {
8716                        lhs,
8717                        op: spg_sql::ast::BinOp::Eq,
8718                        rhs,
8719                    } = sub
8720                        && let (Expr::Column(a), Expr::Column(b)) = (lhs.as_ref(), rhs.as_ref())
8721                    {
8722                        let left_slice = &combined_schema[..consumed_cols];
8723                        if let (Some(l), Some(r)) = (
8724                            Self::composite_col_pos(left_slice, a),
8725                            Self::peer_col_pos(&peer.alias, &peer.cols, b),
8726                        ) {
8727                            matched = Some((l, r));
8728                        } else if let (Some(l), Some(r)) = (
8729                            Self::composite_col_pos(left_slice, b),
8730                            Self::peer_col_pos(&peer.alias, &peer.cols, a),
8731                        ) {
8732                            matched = Some((l, r));
8733                        }
8734                    }
8735                    match matched {
8736                        Some(pair) => eq_pairs.push(pair),
8737                        None => residual.push(sub),
8738                    }
8739                }
8740            }
8741            // v7.28 (round-22) - index-nested-loop: when the working
8742            // set is small and the peer's join column has a BTree,
8743            // seek per left row instead of materialising the whole
8744            // peer table (a correlated subquery body otherwise
8745            // clones the full table once per outer group).
8746            const INL_MAX_LEFT: usize = 1024;
8747            if let Some(tname) = &peer.join_table
8748                && peer.eager_rows.is_none()
8749                && !eq_pairs.is_empty()
8750                && working.len() <= INL_MAX_LEFT
8751                && let Some(table) = self.active_catalog().get(tname)
8752                && let Some(idx) = peer
8753                    .cols
8754                    .iter()
8755                    .position(|c| c.name == peer.cols[eq_pairs[0].1].name)
8756                    .and_then(|pos| table.index_on(pos))
8757            {
8758                let (lpos0, _) = eq_pairs[0];
8759                for left in &working {
8760                    cancel.check()?;
8761                    let mut left_matched = false;
8762                    let key_v = left.values.get(lpos0).cloned().unwrap_or(Value::Null);
8763                    if !matches!(key_v, Value::Null)
8764                        && let Some(key) = spg_storage::IndexKey::from_value(&key_v)
8765                    {
8766                        for loc in idx.lookup_eq(&key) {
8767                            let right = match *loc {
8768                                spg_storage::RowLocator::Hot(i) => match table.rows().get(i) {
8769                                    Some(r) => r,
8770                                    None => continue,
8771                                },
8772                                spg_storage::RowLocator::Cold { .. } => continue,
8773                            };
8774                            // Remaining eq pairs + residual ON check on
8775                            // the candidate only.
8776                            let mut ok = true;
8777                            for (lp, rp) in eq_pairs.iter().skip(1) {
8778                                let lv = left.values.get(*lp);
8779                                let rv = right.values.get(*rp);
8780                                let eq = match (lv, rv) {
8781                                    (Some(a), Some(b)) => {
8782                                        !matches!(a, Value::Null)
8783                                            && !matches!(b, Value::Null)
8784                                            && value_cmp(a, b) == core::cmp::Ordering::Equal
8785                                    }
8786                                    _ => false,
8787                                };
8788                                if !eq {
8789                                    ok = false;
8790                                    break;
8791                                }
8792                            }
8793                            if !ok {
8794                                continue;
8795                            }
8796                            let mut combined_vals = left.values.clone();
8797                            combined_vals.extend(right.values.iter().cloned());
8798                            let combined = Row::new(combined_vals);
8799                            let keep = if residual.is_empty() {
8800                                true
8801                            } else {
8802                                let mut k = true;
8803                                for r in &residual {
8804                                    let cond = self.eval_expr_with_correlated(
8805                                        r, &combined, &ctx, cancel, None,
8806                                    )?;
8807                                    if !matches!(cond, Value::Bool(true)) {
8808                                        k = false;
8809                                        break;
8810                                    }
8811                                }
8812                                k
8813                            };
8814                            if keep {
8815                                next.push(combined);
8816                                left_matched = true;
8817                            }
8818                        }
8819                    }
8820                    if !left_matched && matches!(peer.kind, JoinKind::Left) {
8821                        let mut combined_vals = left.values.clone();
8822                        for _ in 0..right_arity {
8823                            combined_vals.push(Value::Null);
8824                        }
8825                        next.push(Row::new(combined_vals));
8826                    }
8827                }
8828                working = next;
8829                consumed_cols += right_arity;
8830                continue;
8831            }
8832            // Deferred peer that didn't take the INL path: materialise
8833            // now (no pushed predicate, full table).
8834            let lazy_rows: Option<Vec<Row>> = if peer.eager_rows.is_none() && peer.lateral.is_none()
8835            {
8836                let tname = peer.join_table.as_deref().unwrap_or("");
8837                let mut rows: Vec<Row> = self
8838                    .active_catalog()
8839                    .get(tname)
8840                    .map(|t| t.rows().iter().cloned().collect())
8841                    .unwrap_or_default();
8842                if let Some(needed) = needed {
8843                    Self::null_out_unreferenced(&mut rows, &peer.cols, &peer.alias, needed);
8844                }
8845                Some(rows)
8846            } else {
8847                None
8848            };
8849            let eager_view: Option<&Vec<Row>> = peer.eager_rows.as_ref().or(lazy_rows.as_ref());
8850            if !eq_pairs.is_empty() && peer.lateral.is_none() {
8851                let rights = eager_view.expect("non-lateral peer eager");
8852                let mut table: alloc::collections::BTreeMap<String, Vec<usize>> =
8853                    alloc::collections::BTreeMap::new();
8854                let mut keybuf: Vec<Value> = Vec::with_capacity(eq_pairs.len());
8855                'build: for (ri, right) in rights.iter().enumerate() {
8856                    keybuf.clear();
8857                    for (_, rpos) in &eq_pairs {
8858                        let v = right.values.get(*rpos).cloned().unwrap_or(Value::Null);
8859                        if matches!(v, Value::Null) {
8860                            continue 'build;
8861                        }
8862                        keybuf.push(v);
8863                    }
8864                    table
8865                        .entry(aggregate::encode_key(&keybuf))
8866                        .or_default()
8867                        .push(ri);
8868                }
8869                for left in &working {
8870                    cancel.check()?;
8871                    let mut left_matched = false;
8872                    keybuf.clear();
8873                    let mut left_has_null = false;
8874                    for (lpos, _) in &eq_pairs {
8875                        let v = left.values.get(*lpos).cloned().unwrap_or(Value::Null);
8876                        if matches!(v, Value::Null) {
8877                            left_has_null = true;
8878                            break;
8879                        }
8880                        keybuf.push(v);
8881                    }
8882                    if !left_has_null
8883                        && let Some(cands) = table.get(&aggregate::encode_key(&keybuf))
8884                    {
8885                        for &ri in cands {
8886                            let right = &rights[ri];
8887                            let mut combined_vals = left.values.clone();
8888                            combined_vals.extend(right.values.iter().cloned());
8889                            let combined = Row::new(combined_vals);
8890                            let keep = if residual.is_empty() {
8891                                true
8892                            } else {
8893                                let mut ok = true;
8894                                for r in &residual {
8895                                    let cond = self.eval_expr_with_correlated(
8896                                        r, &combined, &ctx, cancel, None,
8897                                    )?;
8898                                    if !matches!(cond, Value::Bool(true)) {
8899                                        ok = false;
8900                                        break;
8901                                    }
8902                                }
8903                                ok
8904                            };
8905                            if keep {
8906                                next.push(combined);
8907                                left_matched = true;
8908                            }
8909                        }
8910                    }
8911                    if !left_matched && matches!(peer.kind, JoinKind::Left) {
8912                        let mut combined_vals = left.values.clone();
8913                        for _ in 0..right_arity {
8914                            combined_vals.push(Value::Null);
8915                        }
8916                        next.push(Row::new(combined_vals));
8917                    }
8918                }
8919                working = next;
8920                consumed_cols += right_arity;
8921                debug_assert!(consumed_cols <= combined_schema.len());
8922                continue;
8923            }
8924            // Fallback: nested loop (lateral peers, non-equi ON).
8925            for left in &working {
8926                cancel.check()?;
8927                let mut left_matched = false;
8928                let per_left_rrows: alloc::borrow::Cow<'_, [Row]> = match peer.lateral {
8929                    Some(inner) => {
8930                        // Substitute outer columns and run the inner
8931                        // SELECT against the current left row's slice
8932                        // of the combined schema.
8933                        let outer_schema = &combined_schema[..consumed_cols];
8934                        let rows = self.materialise_lateral_for_outer(inner, outer_schema, left)?;
8935                        alloc::borrow::Cow::Owned(rows)
8936                    }
8937                    None => {
8938                        let r = eager_view.expect("non-lateral peer eager");
8939                        alloc::borrow::Cow::Borrowed(r.as_slice())
8940                    }
8941                };
8942                for right in per_left_rrows.as_ref() {
8943                    let mut combined_vals = left.values.clone();
8944                    combined_vals.extend(right.values.iter().cloned());
8945                    let combined = Row::new(combined_vals);
8946                    let keep = if let Some(on_expr) = peer.on {
8947                        // v7.24.1 — correlated-aware (subqueries in
8948                        // ON referencing earlier join columns).
8949                        let cond =
8950                            self.eval_expr_with_correlated(on_expr, &combined, &ctx, cancel, None)?;
8951                        matches!(cond, Value::Bool(true))
8952                    } else {
8953                        true
8954                    };
8955                    if keep {
8956                        next.push(combined);
8957                        left_matched = true;
8958                    }
8959                }
8960                if !left_matched && matches!(peer.kind, JoinKind::Left) {
8961                    let mut combined_vals = left.values.clone();
8962                    for _ in 0..right_arity {
8963                        combined_vals.push(Value::Null);
8964                    }
8965                    next.push(Row::new(combined_vals));
8966                }
8967            }
8968            working = next;
8969            if working.len() > MAX_JOIN_INTERMEDIATE_ROWS {
8970                return Err(EngineError::Unsupported(alloc::format!(
8971                    "join intermediate result exceeds {MAX_JOIN_INTERMEDIATE_ROWS} rows ({} so far) - add join predicates",
8972                    working.len()
8973                )));
8974            }
8975            consumed_cols += right_arity;
8976            debug_assert!(consumed_cols <= combined_schema.len());
8977        }
8978        let mut filtered: Vec<Row> = Vec::new();
8979        // v7.24 (round-16 B) — the joined WHERE filter ran the plain
8980        // row evaluator, so a correlated EXISTS/IN/scalar subquery
8981        // under a JOIN hit "subquery reached row eval". Route through
8982        // the correlated-aware evaluator (memoized, same as the
8983        // single-table path).
8984        let mut memo = memoize::MemoizeCache::default();
8985        for row in working {
8986            if let Some(where_expr) = where_ {
8987                let cond = self.eval_expr_with_correlated(
8988                    where_expr,
8989                    &row,
8990                    &ctx,
8991                    cancel,
8992                    Some(&mut memo),
8993                )?;
8994                if !matches!(cond, Value::Bool(true)) {
8995                    continue;
8996                }
8997            }
8998            filtered.push(row);
8999        }
9000        Ok((combined_schema, filtered))
9001    }
9002
9003    /// v7.17.0 Phase 3.P0-41 — probe a LATERAL subquery's projection
9004    /// schema by running it once with a NULL-padded outer context.
9005    /// The probe never materialises real outer rows; it just executes
9006    /// the inner SELECT with `outer_alias.col` references substituted
9007    /// to NULL so the projection's type inference is exercised.
9008    fn lateral_probe_schema(
9009        &self,
9010        inner: &SelectStatement,
9011    ) -> Result<Vec<ColumnSchema>, EngineError> {
9012        // Substitute every qualified column reference whose qualifier
9013        // does NOT match an in-subquery FROM alias with NULL. The
9014        // safest probe is to walk the inner SELECT and replace any
9015        // `<qual>.<col>` whose qual isn't bound inside the subquery
9016        // with a Null literal. For the v7.17 probe we just run the
9017        // unmodified subquery and surface the columns; if it fails
9018        // (e.g. references an outer column the probe can't resolve),
9019        // we synthesise a best-effort schema from the SELECT items
9020        // by inferring a single Text-typed column per projection.
9021        match self.execute_readonly_select_for_lateral_probe(inner) {
9022            Ok(QueryResult::Rows { columns, .. }) => Ok(columns),
9023            // Best-effort fallback: each SELECT item becomes a TEXT
9024            // column. Real schemas only differ when the inner SELECT
9025            // references outer columns at projection-time; those
9026            // queries surface via the substitution path during
9027            // per-row execution and still return the right values.
9028            _ => {
9029                let mut out: Vec<ColumnSchema> = Vec::new();
9030                for (i, item) in inner.items.iter().enumerate() {
9031                    let name = match item {
9032                        SelectItem::Expr { alias: Some(a), .. } => a.clone(),
9033                        SelectItem::Expr { expr, .. } => synth_lateral_col_name(expr, i),
9034                        SelectItem::Wildcard => alloc::format!("col{i}"),
9035                    };
9036                    out.push(ColumnSchema::new(name, DataType::Text, true));
9037                }
9038                Ok(out)
9039            }
9040        }
9041    }
9042
9043    /// v7.17.0 Phase 3.P0-41 — try the inner LATERAL subquery against
9044    /// the engine in read-only mode for schema-probe purposes. Failure
9045    /// is expected when the subquery references an outer column the
9046    /// probe can't resolve; the caller falls back to a best-effort
9047    /// schema based on the SELECT items.
9048    fn execute_readonly_select_for_lateral_probe(
9049        &self,
9050        inner: &SelectStatement,
9051    ) -> Result<QueryResult, EngineError> {
9052        self.exec_bare_select_cancel(inner, CancelToken::none())
9053    }
9054
9055    /// v7.17.0 Phase 3.P0-41 — materialise a LATERAL subquery's rows
9056    /// for one outer-row context. Walks the inner SELECT, replaces
9057    /// every `<outer_alias>.<col>` reference whose alias appears in
9058    /// the outer schema with the literal value from the outer row,
9059    /// then runs the rewritten SELECT against the engine.
9060    fn materialise_lateral_for_outer(
9061        &self,
9062        inner: &SelectStatement,
9063        outer_schema: &[ColumnSchema],
9064        outer_row: &Row,
9065    ) -> Result<Vec<Row>, EngineError> {
9066        let mut substituted = inner.clone();
9067        substitute_outer_columns_multi(&mut substituted, outer_row, outer_schema);
9068        let result = self.exec_bare_select_cancel(&substituted, CancelToken::none())?;
9069        match result {
9070            QueryResult::Rows { rows, .. } => Ok(rows),
9071            _ => Err(EngineError::Unsupported(
9072                "LATERAL subquery must be a SELECT (cannot be a write statement)".into(),
9073            )),
9074        }
9075    }
9076
9077    fn exec_joined_select(
9078        &self,
9079        stmt: &SelectStatement,
9080        from: &FromClause,
9081        cancel: CancelToken<'_>,
9082    ) -> Result<QueryResult, EngineError> {
9083        // v7.17.0 Phase 3.P0-43 + P0-41 — delegate the join +
9084        // WHERE materialisation to the shared helper so the LATERAL
9085        // / UNNEST / regular-catalog paths route through one place.
9086        // (`build_joined_filtered_rows` carries LATERAL support as
9087        // of Phase 3.P0-41.) Downstream we still handle aggregate /
9088        // projection / ORDER BY / DISTINCT / LIMIT inline because
9089        // those depend on the SelectStatement's items list.
9090        let (combined_schema, filtered) = {
9091            let mut needed = alloc::collections::BTreeSet::new();
9092            let prunable = collect_qualified_refs(stmt, &mut needed).is_some();
9093            self.build_joined_filtered_rows(
9094                from,
9095                stmt.where_.as_ref(),
9096                cancel,
9097                if prunable { Some(&needed) } else { None },
9098            )?
9099        };
9100        let ctx = EvalContext::new(&combined_schema, None);
9101        // Aggregate path: handle GROUP BY / aggregate calls over the
9102        // joined+filtered rows.
9103        if aggregate::uses_aggregate(stmt) {
9104            let refs: Vec<&Row> = filtered.iter().collect();
9105            let agg_correlated = |e: &Expr, r: &Row, c: &EvalContext<'_>| {
9106                self.eval_expr_with_correlated(e, r, c, cancel, None)
9107                    .map_err(|err| match err {
9108                        EngineError::Eval(ev) => ev,
9109                        other => eval::EvalError::TypeMismatch {
9110                            detail: alloc::format!("{other}"),
9111                        },
9112                    })
9113            };
9114            let mut agg =
9115                aggregate::run(stmt, &refs, &combined_schema, None, Some(&agg_correlated))?;
9116            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
9117            return Ok(QueryResult::Rows {
9118                columns: agg.columns,
9119                rows: agg.rows,
9120            });
9121        }
9122
9123        let projection = build_projection(&stmt.items, &combined_schema, "")?;
9124        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::new();
9125        let mut proj_memo = memoize::MemoizeCache::default();
9126        for row in &filtered {
9127            let mut values = Vec::with_capacity(projection.len());
9128            for p in &projection {
9129                // v7.24 (round-16 B) — select-list subqueries under a
9130                // JOIN go through the correlated-aware evaluator too.
9131                values.push(self.eval_expr_with_correlated(
9132                    &p.expr,
9133                    row,
9134                    &ctx,
9135                    cancel,
9136                    Some(&mut proj_memo),
9137                )?);
9138            }
9139            let order_keys = if stmt.order_by.is_empty() {
9140                Vec::new()
9141            } else {
9142                build_order_keys(&stmt.order_by, row, &ctx)?
9143            };
9144            tagged.push((order_keys, Row::new(values)));
9145        }
9146        if !stmt.order_by.is_empty() {
9147            let keep = if stmt.distinct {
9148                None
9149            } else {
9150                stmt.limit_literal()
9151                    .map(|l| l as usize + stmt.offset_literal().map_or(0, |o| o as usize))
9152            };
9153            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
9154            partial_sort_tagged(&mut tagged, keep, &descs);
9155        }
9156        let mut output_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
9157        if stmt.distinct {
9158            output_rows = dedup_rows(output_rows);
9159        }
9160        apply_offset_and_limit(
9161            &mut output_rows,
9162            stmt.offset_literal(),
9163            stmt.limit_literal(),
9164        );
9165        let columns: Vec<ColumnSchema> = projection
9166            .into_iter()
9167            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
9168            .collect();
9169        Ok(QueryResult::Rows {
9170            columns,
9171            rows: output_rows,
9172        })
9173    }
9174}
9175
9176/// One row-producing projection: an expression to evaluate, the resulting
9177/// column's user-visible name, its inferred type, and nullability.
9178#[derive(Debug, Clone)]
9179struct ProjectedItem {
9180    expr: Expr,
9181    output_name: String,
9182    ty: DataType,
9183    nullable: bool,
9184}
9185
9186/// Dedupe a row set, preserving first-seen order. `Row`'s `PartialEq` is
9187/// structural (`Vec<Value>` ⇒ pairwise `Value` equality), which gives SQL
9188/// `NULL = NULL → TRUE` and `NaN = NaN → FALSE`. The first agrees with
9189/// the spec's "two NULLs are not distinct"; the second is a tolerated
9190/// quirk for v1 (no NaN literals are reachable from the SQL surface).
9191fn dedup_rows(rows: Vec<Row>) -> Vec<Row> {
9192    let mut out: Vec<Row> = Vec::with_capacity(rows.len());
9193    for r in rows {
9194        if !out.iter().any(|seen| seen == &r) {
9195            out.push(r);
9196        }
9197    }
9198    out
9199}
9200
9201/// Coerce a `Value` to an `f64` sort key for ORDER BY. Numbers map directly;
9202/// NULL sorts last (treated as `+∞`); booleans are 0.0 / 1.0; text uses lex
9203/// order via the byte values; vectors are not sortable.
9204fn value_to_order_key(v: &Value) -> Result<f64, EngineError> {
9205    match v {
9206        Value::Null => Ok(f64::INFINITY),
9207        Value::SmallInt(n) => Ok(f64::from(*n)),
9208        Value::Int(n) => Ok(f64::from(*n)),
9209        Value::Date(d) => Ok(f64::from(*d)),
9210        #[allow(clippy::cast_precision_loss)]
9211        Value::Timestamp(t) => Ok(*t as f64),
9212        // v7.17.0 Phase 3.P0-32 — PG TIME ordered by underlying
9213        // i64 microseconds (matches wall-clock ordering).
9214        #[allow(clippy::cast_precision_loss)]
9215        Value::Time(us) => Ok(*us as f64),
9216        // v7.17.0 Phase 3.P0-33 — MySQL YEAR ordered by underlying
9217        // u16 (matches calendar ordering; zero-year sentinel
9218        // sorts before 1901).
9219        Value::Year(y) => Ok(f64::from(*y)),
9220        // v7.17.0 Phase 3.P0-34 — PG TIMETZ ordered by the
9221        // UTC-equivalent microseconds (local wall - offset). Two
9222        // values for the same physical instant in different zones
9223        // sort equal — matches PG TIMETZ index behaviour.
9224        #[allow(clippy::cast_precision_loss)]
9225        Value::TimeTz { us, offset_secs } => Ok((us - i64::from(*offset_secs) * 1_000_000) as f64),
9226        // v7.17.0 Phase 3.P0-35 — PG MONEY ordered by i64 cents.
9227        #[allow(clippy::cast_precision_loss)]
9228        Value::Money(c) => Ok(*c as f64),
9229        // v7.17.0 Phase 3.P0-38 — range ordering is not supported
9230        // in v7.17.0 (needs lex-then-inclusivity tiebreak).
9231        Value::Range { .. } => Err(EngineError::Unsupported(
9232            "ORDER BY of a range value is not supported in v7.17.0".into(),
9233        )),
9234        // v7.17.0 Phase 3.P0-39 — hstore is not orderable.
9235        Value::Hstore(_) => Err(EngineError::Unsupported(
9236            "ORDER BY of a hstore value is not supported".into(),
9237        )),
9238        // v7.17.0 Phase 3.P0-40 — 2D arrays not orderable.
9239        Value::IntArray2D(_) | Value::BigIntArray2D(_) | Value::TextArray2D(_) => Err(
9240            EngineError::Unsupported("ORDER BY of a 2D array is not supported in v7.17.0".into()),
9241        ),
9242        #[allow(clippy::cast_precision_loss)]
9243        Value::Numeric { scaled, scale } => {
9244            // Scaled integer / 10^scale, computed via f64 for sort
9245            // ordering only. Precision losses here only matter for
9246            // ORDER BY tie-breaks well past 15 significant digits.
9247            // `f64::powi` lives in std; we hand-roll the loop so the
9248            // no_std engine crate doesn't need it.
9249            let mut divisor = 1.0_f64;
9250            for _ in 0..*scale {
9251                divisor *= 10.0;
9252            }
9253            Ok((*scaled as f64) / divisor)
9254        }
9255        #[allow(clippy::cast_precision_loss)]
9256        Value::BigInt(n) => Ok(*n as f64),
9257        Value::Float(x) => Ok(*x),
9258        Value::Bool(b) => Ok(if *b { 1.0 } else { 0.0 }),
9259        Value::Text(s) => {
9260            // Lex order by codepoints — good enough for ORDER BY name.
9261            // Map first 8 bytes packed into u64 as a coarse key; ties fall to
9262            // partial_cmp Equal. v1.x can swap in a real string comparator.
9263            let mut key: u64 = 0;
9264            for &b in s.as_bytes().iter().take(8) {
9265                key = (key << 8) | u64::from(b);
9266            }
9267            #[allow(clippy::cast_precision_loss)]
9268            Ok(key as f64)
9269        }
9270        Value::Vector(_) | Value::Sq8Vector(_) | Value::HalfVector(_) => {
9271            Err(EngineError::Unsupported(
9272                "ORDER BY of a raw vector column is not meaningful — use `<->`".into(),
9273            ))
9274        }
9275        Value::Interval { .. } => Err(EngineError::Unsupported(
9276            "ORDER BY of an INTERVAL is not supported in v2.11 \
9277             (months vs micros has no single canonical ordering)"
9278                .into(),
9279        )),
9280        Value::Json(_) => Err(EngineError::Unsupported(
9281            "ORDER BY of a JSON value is not supported — cast the document to text first".into(),
9282        )),
9283        // v7.5.0 — Value is #[non_exhaustive]; future variants need
9284        // an explicit ORDER BY mapping. Surface as Unsupported until
9285        // engine support is added.
9286        _ => Err(EngineError::Unsupported(
9287            "ORDER BY of this value type is not supported".into(),
9288        )),
9289    }
9290}
9291
9292/// Try to plan a WHERE clause as an equality lookup against an existing
9293/// index. Returns the candidate row indices on success; `None` means the
9294/// caller should fall back to a full scan.
9295///
9296/// v0.8 recognises a single top-level `col = literal` (in either operand
9297/// order). AND chains and range scans land in later milestones.
9298/// Look for `ORDER BY col <dist-op> literal LIMIT k` against an
9299/// NSW-indexed vector column. Recognised distance ops: `<->` (L2),
9300/// `<#>` (inner product), `<=>` (cosine). When a WHERE clause is
9301/// present, the planner does an "over-fetch and filter" pass — it
9302/// asks the graph for `k * over_fetch` candidates, evaluates WHERE
9303/// against each, and trims back to `k`. Returns the row indices in
9304/// ascending-distance order when the plan applies.
9305fn try_nsw_knn(
9306    stmt: &SelectStatement,
9307    table: &Table,
9308    schema_cols: &[ColumnSchema],
9309    table_alias: &str,
9310) -> Option<Vec<usize>> {
9311    if stmt.distinct {
9312        return None;
9313    }
9314    let limit = usize::try_from(stmt.limit_literal()?).ok()?;
9315    if limit == 0 {
9316        return None;
9317    }
9318    // v6.4.0 — NSW kNN dispatch needs a single ORDER BY key on the
9319    // distance metric. Multi-key ORDER BY falls through to the
9320    // generic sort path.
9321    if stmt.order_by.len() != 1 {
9322        return None;
9323    }
9324    let order = &stmt.order_by[0];
9325    // NSW kNN returns rows ascending by distance — DESC inverts the
9326    // natural order, so the planner can't handle it without a sort
9327    // pass. Fall back to the generic ORDER BY path.
9328    if order.desc {
9329        return None;
9330    }
9331    let Expr::Binary { lhs, op, rhs } = &order.expr else {
9332        return None;
9333    };
9334    let metric = match op {
9335        BinOp::L2Distance => spg_storage::NswMetric::L2,
9336        BinOp::InnerProduct => spg_storage::NswMetric::InnerProduct,
9337        BinOp::CosineDistance => spg_storage::NswMetric::Cosine,
9338        _ => return None,
9339    };
9340    // Accept both `col <op> literal` and `literal <op> col`.
9341    let ((Expr::Column(col), literal) | (literal, Expr::Column(col))) =
9342        (lhs.as_ref(), rhs.as_ref())
9343    else {
9344        return None;
9345    };
9346    if let Some(q) = &col.qualifier
9347        && q != table_alias
9348    {
9349        return None;
9350    }
9351    let col_pos = schema_cols.iter().position(|s| s.name == col.name)?;
9352    let query = literal_to_vector(literal)?;
9353    let idx = spg_storage::nsw_index_on(table, col_pos)?;
9354    if let Some(where_expr) = &stmt.where_ {
9355        // Over-fetch and filter. The factor (10×) is a heuristic that
9356        // covers typical selectivity for the corpus tests; v2.x will
9357        // make it configurable.
9358        let over_fetch = limit.saturating_mul(10).max(NSW_OVER_FETCH_FLOOR);
9359        let candidates = spg_storage::nsw_query(table, &idx.name, &query, over_fetch, metric);
9360        let ctx = EvalContext::new(schema_cols, Some(table_alias));
9361        let mut kept: Vec<usize> = Vec::with_capacity(limit);
9362        for i in candidates {
9363            let row = &table.rows()[i];
9364            let cond = eval::eval_expr(where_expr, row, &ctx).ok()?;
9365            if matches!(cond, Value::Bool(true)) {
9366                kept.push(i);
9367                if kept.len() >= limit {
9368                    break;
9369                }
9370            }
9371        }
9372        Some(kept)
9373    } else {
9374        Some(spg_storage::nsw_query(
9375            table, &idx.name, &query, limit, metric,
9376        ))
9377    }
9378}
9379
9380/// Lower bound on the over-fetch pool when WHERE is present — even
9381/// for tiny `LIMIT 1` queries we keep enough candidates to absorb a
9382/// few WHERE rejections.
9383const NSW_OVER_FETCH_FLOOR: usize = 32;
9384
9385/// Pull a `Vec<f32>` out of a literal-or-cast expression. Returns
9386/// `None` for anything we can't fold at plan time.
9387fn literal_to_vector(e: &Expr) -> Option<Vec<f32>> {
9388    match e {
9389        Expr::Literal(Literal::Vector(v)) => Some(v.clone()),
9390        Expr::Cast { expr, .. } => literal_to_vector(expr),
9391        _ => None,
9392    }
9393}
9394
9395/// Materialise rows in a planner-supplied order (used by the NSW path)
9396/// without re-running ORDER BY. The projection + LIMIT slot mirror the
9397/// equivalent block in `exec_bare_select`.
9398fn materialise_in_order(
9399    stmt: &SelectStatement,
9400    table: &Table,
9401    schema_cols: &[ColumnSchema],
9402    table_alias: &str,
9403    ordered_rows: &[usize],
9404) -> Result<QueryResult, EngineError> {
9405    let ctx = EvalContext::new(schema_cols, Some(table_alias));
9406    let projection = build_projection(&stmt.items, schema_cols, table_alias)?;
9407    let mut output_rows: Vec<Row> = Vec::with_capacity(ordered_rows.len());
9408    for &i in ordered_rows {
9409        let row = &table.rows()[i];
9410        let mut values = Vec::with_capacity(projection.len());
9411        for p in &projection {
9412            values.push(eval::eval_expr(&p.expr, row, &ctx)?);
9413        }
9414        output_rows.push(Row::new(values));
9415    }
9416    apply_offset_and_limit(
9417        &mut output_rows,
9418        stmt.offset_literal(),
9419        stmt.limit_literal(),
9420    );
9421    let columns: Vec<ColumnSchema> = projection
9422        .into_iter()
9423        .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
9424        .collect();
9425    Ok(QueryResult::Rows {
9426        columns,
9427        rows: output_rows,
9428    })
9429}
9430
9431/// v7.20 P4 — hot-row POSITION seek for the mutation paths
9432/// (UPDATE / DELETE index their planned writes by position in
9433/// `table.rows()`, so the Cow-row shape `try_index_seek`
9434/// returns doesn't fit). Same top-level-AND recursion and
9435/// col=literal resolution; the caller re-applies the full WHERE
9436/// to every returned row so the index only narrows candidates.
9437///
9438/// Returns `None` (→ caller full-scans) when no equality leaf
9439/// hits an index OR any matching locator lives in the cold tier
9440/// — the mutation paths operate on hot rows, and the PK
9441/// promote-then-walk upstream already handles the
9442/// cold-single-row case.
9443fn try_index_seek_positions(
9444    where_expr: &Expr,
9445    schema_cols: &[ColumnSchema],
9446    table: &Table,
9447    table_alias: &str,
9448) -> Option<Vec<usize>> {
9449    if let Expr::Binary {
9450        lhs,
9451        op: BinOp::And,
9452        rhs,
9453    } = where_expr
9454    {
9455        if let Some(p) = try_index_seek_positions(lhs, schema_cols, table, table_alias) {
9456            return Some(p);
9457        }
9458        return try_index_seek_positions(rhs, schema_cols, table, table_alias);
9459    }
9460    let Expr::Binary {
9461        lhs,
9462        op: BinOp::Eq,
9463        rhs,
9464    } = where_expr
9465    else {
9466        return None;
9467    };
9468    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
9469        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
9470    let idx = table.index_on(col_pos)?;
9471    let key = IndexKey::from_value(&value)?;
9472    let locators = idx.lookup_eq(&key);
9473    let mut out = Vec::with_capacity(locators.len());
9474    for loc in locators {
9475        match *loc {
9476            spg_storage::RowLocator::Hot(i) => out.push(i),
9477            spg_storage::RowLocator::Cold { .. } => return None,
9478        }
9479    }
9480    Some(out)
9481}
9482
9483fn try_index_seek<'a>(
9484    where_expr: &Expr,
9485    schema_cols: &[ColumnSchema],
9486    catalog: &'a Catalog,
9487    table: &'a Table,
9488    table_alias: &str,
9489) -> Option<Vec<Cow<'a, Row>>> {
9490    // v7.11.3 — recurse through top-level `AND` so a PG-style
9491    // composite predicate like `WHERE id = 1 AND created_at > $1`
9492    // still hits the index on `id`. The caller re-applies the
9493    // full WHERE expression to each returned row, so dropping the
9494    // residual conjuncts here is correct — the index just narrows
9495    // the candidate set.
9496    if let Expr::Binary {
9497        lhs,
9498        op: BinOp::And,
9499        rhs,
9500    } = where_expr
9501    {
9502        // Try LHS first (typical convention: leading equality on
9503        // the indexed column comes first in user-written SQL).
9504        if let Some(rows) = try_index_seek(lhs, schema_cols, catalog, table, table_alias) {
9505            return Some(rows);
9506        }
9507        return try_index_seek(rhs, schema_cols, catalog, table, table_alias);
9508    }
9509    let Expr::Binary {
9510        lhs,
9511        op: BinOp::Eq,
9512        rhs,
9513    } = where_expr
9514    else {
9515        return None;
9516    };
9517    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
9518        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
9519    let idx = table.index_on(col_pos)?;
9520    let key = IndexKey::from_value(&value)?;
9521    let locators = idx.lookup_eq(&key);
9522    let table_name = table.schema().name.as_str();
9523    // v5.1: each locator dispatches to either the hot tier (zero-
9524    // copy borrow of `table.rows()[i]`) or a cold-tier segment
9525    // (one page read + dense row decode, ~µs scale). Cold rows are
9526    // returned as `Cow::Owned` so the caller's `&Row` iteration
9527    // doesn't see a tier distinction; pre-freezer (no cold
9528    // segments loaded) every locator is `Hot` and every entry is
9529    // `Cow::Borrowed` — identical cost to the pre-v5.1 path.
9530    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(locators.len());
9531    for loc in locators {
9532        match *loc {
9533            spg_storage::RowLocator::Hot(i) => {
9534                if let Some(row) = table.rows().get(i) {
9535                    out.push(Cow::Borrowed(row));
9536                }
9537            }
9538            spg_storage::RowLocator::Cold { segment_id, .. } => {
9539                if let Some(row) = catalog.resolve_cold_locator(table_name, segment_id, &key) {
9540                    out.push(Cow::Owned(row));
9541                }
9542            }
9543        }
9544    }
9545    Some(out)
9546}
9547
9548/// v7.12.3 — GIN-accelerated candidate seek for `WHERE col @@ <ts_query>`.
9549///
9550/// Recurses through top-level `AND` like [`try_index_seek`] so a
9551/// composite predicate `WHERE search_vector @@ q AND id > $1` still
9552/// hits the GIN index on `search_vector` — the caller re-applies the
9553/// full WHERE expression to each returned candidate, so dropping the
9554/// `id > $1` residual here stays semantically correct.
9555///
9556/// Returns `None` when:
9557///   - no leaf is a `col @@ <rhs>` shape on a GIN-indexed column;
9558///   - the RHS can't be const-evaluated to a `Value::TsQuery`
9559///     (typically because it references row columns);
9560///   - the resolved `TsQuery` uses query shapes the MVP doesn't
9561///     accelerate (`Not`, `Phrase` — those fall through to full scan).
9562///
9563/// On `Some(rows)` the caller iterates only `rows` and re-evaluates
9564/// the full `@@` predicate per row, so an over-approximate candidate
9565/// set is safe.
9566fn try_gin_seek<'a>(
9567    where_expr: &Expr,
9568    schema_cols: &[ColumnSchema],
9569    catalog: &'a Catalog,
9570    table: &'a Table,
9571    table_alias: &str,
9572    ctx: &eval::EvalContext<'_>,
9573) -> Option<Vec<Cow<'a, Row>>> {
9574    if let Expr::Binary {
9575        lhs,
9576        op: BinOp::And,
9577        rhs,
9578    } = where_expr
9579    {
9580        if let Some(rows) = try_gin_seek(lhs, schema_cols, catalog, table, table_alias, ctx) {
9581            return Some(rows);
9582        }
9583        return try_gin_seek(rhs, schema_cols, catalog, table, table_alias, ctx);
9584    }
9585    // v7.17.0 Phase 3.P0-44 — MySQL `MATCH(col1, col2) AGAINST (...)`
9586    // desugars into `(to_tsvector(col1) @@ q) OR (to_tsvector(col2) @@ q)`
9587    // in the parser. To accelerate the multi-column case, walk OR the same
9588    // way we walk AND: only emit a candidate set if BOTH sides can seek
9589    // (otherwise the OR result is unbounded and we must fall through to
9590    // the full scan). Candidates are union'd; the caller's WHERE re-eval
9591    // verifies the full predicate per row, so duplicates / supersets stay
9592    // semantically safe.
9593    if let Expr::Binary {
9594        lhs,
9595        op: BinOp::Or,
9596        rhs,
9597    } = where_expr
9598    {
9599        let left = try_gin_seek(lhs, schema_cols, catalog, table, table_alias, ctx)?;
9600        let right = try_gin_seek(rhs, schema_cols, catalog, table, table_alias, ctx)?;
9601        let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(left.len() + right.len());
9602        out.extend(left);
9603        out.extend(right);
9604        return Some(out);
9605    }
9606    let Expr::Binary {
9607        lhs,
9608        op: BinOp::TsMatch,
9609        rhs,
9610    } = where_expr
9611    else {
9612        return None;
9613    };
9614    // Either side can be the column; pgvector idiom (`vec @@ q`)
9615    // hits the first arm, FROM-clause-derived (`plainto_tsquery($1)
9616    // q ... WHERE search_vector @@ q`) the same. CROSS JOIN derived
9617    // tables resolve `q` to a Column too.
9618    let (col_pos, query) = resolve_gin_col_query(lhs, rhs, schema_cols, table_alias, ctx)
9619        .or_else(|| resolve_gin_col_query(rhs, lhs, schema_cols, table_alias, ctx))?;
9620    // v7.17.0 Phase 3.P0-44 — MySQL `FULLTEXT KEY` builds a
9621    // `IndexKind::GinFulltext` posting list (Phase 2.2). It shares
9622    // the same `gin_lookup_word` shape as the tsvector-typed GIN,
9623    // so the MATCH-AGAINST `@@` predicate (desugared by the parser
9624    // into `to_tsvector(col) @@ plainto_tsquery('term')`) routes
9625    // through the same candidate-set seek.
9626    let idx = table
9627        .indices()
9628        .iter()
9629        .find(|i| i.column_position == col_pos && (i.is_gin() || i.is_gin_fulltext()))?;
9630    let candidates = gin_query_candidates(idx, &query)?;
9631    let _ = catalog; // cold-tier row resolution unused in MVP; see below.
9632    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(candidates.len());
9633    for loc in candidates {
9634        match loc {
9635            spg_storage::RowLocator::Hot(i) => {
9636                if let Some(row) = table.rows().get(i) {
9637                    out.push(Cow::Borrowed(row));
9638                }
9639            }
9640            // GIN cold-tier rows in the MVP: skipped, matching the
9641            // full-scan `@@` path which itself only iterates
9642            // `table.rows()` (hot tier). When v7.13+ adds cold-tier
9643            // scan-time materialisation for `@@`, the parallel
9644            // resolution lands here; until then both paths see the
9645            // same hot-only candidate set so correctness is preserved.
9646            spg_storage::RowLocator::Cold { .. } => {}
9647        }
9648    }
9649    Some(out)
9650}
9651
9652/// v7.15.0 — trigram-GIN-accelerated candidate seek for
9653/// `WHERE col LIKE '<pat>'` and `WHERE col ILIKE '<pat>'` when
9654/// the column has a `gin_trgm_ops` GIN index.
9655///
9656/// Walks top-level `AND` so multi-predicate WHEREs (`col LIKE
9657/// 'foo%' AND id > 1`) still hit the trigram index; the caller
9658/// re-evaluates the full WHERE per candidate row, so dropping
9659/// non-LIKE conjuncts here stays semantically correct.
9660///
9661/// Returns `None` when:
9662///   - no leaf is `col LIKE/ILIKE <literal>` on a trigram-GIN-
9663///     indexed column;
9664///   - the pattern's literal runs are too short to constrain
9665///     (pattern decomposes into `< 3`-char runs, e.g. `%ab%`);
9666///   - the pattern doesn't const-evaluate to a TEXT.
9667fn try_trgm_seek<'a>(
9668    where_expr: &Expr,
9669    schema_cols: &[ColumnSchema],
9670    table: &'a Table,
9671    table_alias: &str,
9672) -> Option<Vec<Cow<'a, Row>>> {
9673    if let Expr::Binary {
9674        lhs,
9675        op: BinOp::And,
9676        rhs,
9677    } = where_expr
9678    {
9679        if let Some(rows) = try_trgm_seek(lhs, schema_cols, table, table_alias) {
9680            return Some(rows);
9681        }
9682        return try_trgm_seek(rhs, schema_cols, table, table_alias);
9683    }
9684    // LIKE node is what carries the column reference + pattern.
9685    // ILIKE is the same AST node — PG's LIKE/ILIKE both lower
9686    // through `Expr::Like { expr, pattern, negated }`. The trigram
9687    // index posting-list keys are already lower-cased and
9688    // case-folded, so we only need the pattern's literal runs.
9689    let Expr::Like { expr, pattern, .. } = where_expr else {
9690        return None;
9691    };
9692    // Column side.
9693    let Expr::Column(c) = expr.as_ref() else {
9694        return None;
9695    };
9696    if let Some(q) = &c.qualifier
9697        && q != table_alias
9698    {
9699        return None;
9700    }
9701    let col_pos = schema_cols
9702        .iter()
9703        .position(|s| s.name.eq_ignore_ascii_case(&c.name))?;
9704    // Index must exist on that column AND be a trigram-GIN.
9705    let idx = table
9706        .indices()
9707        .iter()
9708        .find(|i| i.column_position == col_pos && i.is_gin_trgm())?;
9709    // Pattern side must be a literal TEXT — anything else (column
9710    // ref, function call, parameter that hasn't been bound yet)
9711    // falls through to full scan.
9712    let Expr::Literal(spg_sql::ast::Literal::String(pat)) = pattern.as_ref() else {
9713        return None;
9714    };
9715    let trigrams = spg_storage::trgm::trigrams_from_like_pattern(pat)?;
9716    // Intersect every trigram's posting list. Empty intersection
9717    // → empty candidate set (caller short-circuits its row loop).
9718    let mut iter = trigrams.iter();
9719    let first = iter.next()?;
9720    let mut acc: Vec<spg_storage::RowLocator> = {
9721        let mut v = idx.gin_trgm_lookup(first).to_vec();
9722        v.sort_by_key(locator_sort_key);
9723        v.dedup_by_key(|l| locator_sort_key(l));
9724        v
9725    };
9726    for tri in iter {
9727        let mut next: Vec<spg_storage::RowLocator> = idx.gin_trgm_lookup(tri).to_vec();
9728        next.sort_by_key(locator_sort_key);
9729        next.dedup_by_key(|l| locator_sort_key(l));
9730        // Sorted-merge intersection.
9731        let mut merged: Vec<spg_storage::RowLocator> =
9732            Vec::with_capacity(acc.len().min(next.len()));
9733        let (mut i, mut j) = (0usize, 0usize);
9734        while i < acc.len() && j < next.len() {
9735            let lk = locator_sort_key(&acc[i]);
9736            let rk = locator_sort_key(&next[j]);
9737            match lk.cmp(&rk) {
9738                core::cmp::Ordering::Less => i += 1,
9739                core::cmp::Ordering::Greater => j += 1,
9740                core::cmp::Ordering::Equal => {
9741                    merged.push(acc[i]);
9742                    i += 1;
9743                    j += 1;
9744                }
9745            }
9746        }
9747        acc = merged;
9748        if acc.is_empty() {
9749            break;
9750        }
9751    }
9752    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(acc.len());
9753    for loc in acc {
9754        if let spg_storage::RowLocator::Hot(i) = loc
9755            && let Some(row) = table.rows().get(i)
9756        {
9757            out.push(Cow::Borrowed(row));
9758        }
9759        // Cold-tier rows: skipped in MVP (same as try_gin_seek).
9760    }
9761    Some(out)
9762}
9763
9764/// v7.12.3 — extract `(column_position, TsQueryAst)` when one side of
9765/// the binary is a column reference to a GIN-indexed tsvector column
9766/// and the other side const-evaluates to a `Value::TsQuery`. Returns
9767/// `None` if the column reference is for the wrong table alias, or if
9768/// the RHS expression depends on row data.
9769fn resolve_gin_col_query(
9770    col_side: &Expr,
9771    query_side: &Expr,
9772    schema_cols: &[ColumnSchema],
9773    table_alias: &str,
9774    ctx: &eval::EvalContext<'_>,
9775) -> Option<(usize, spg_storage::TsQueryAst)> {
9776    // v7.17.0 Phase 3.P0-44 — the MATCH AGAINST desugar wraps the
9777    // column in `to_tsvector('simple', col)`, so we peel that wrapper
9778    // before the column lookup. Direct `col @@ tsquery` paths (the
9779    // tsvector-typed v7.12 surface) skip the wrapper entirely.
9780    let column = match col_side {
9781        Expr::Column(c) => c,
9782        Expr::FunctionCall { name, args }
9783            if name.eq_ignore_ascii_case("to_tsvector") && !args.is_empty() =>
9784        {
9785            // PG `to_tsvector` accepts either `to_tsvector(col)` or
9786            // `to_tsvector(config, col)`. In both shapes the column
9787            // we care about is the final argument.
9788            if let Expr::Column(c) = args.last().unwrap() {
9789                c
9790            } else {
9791                return None;
9792            }
9793        }
9794        _ => return None,
9795    };
9796    let c = column;
9797    if let Some(q) = &c.qualifier
9798        && q != table_alias
9799    {
9800        return None;
9801    }
9802    let pos = schema_cols.iter().position(|s| s.name == c.name)?;
9803    // Const-evaluate the query side with an empty row — fails fast
9804    // (with a `ColumnNotFound` / similar) if the expression actually
9805    // depends on row data, which is exactly the bail signal we want.
9806    let empty_row = Row::new(Vec::new());
9807    let v = eval::eval_expr(query_side, &empty_row, ctx).ok()?;
9808    let Value::TsQuery(q) = v else { return None };
9809    Some((pos, q))
9810}
9811
9812/// v7.12.3 — walk a `TsQueryAst` against an [`IndexKind::Gin`] index
9813/// to produce a candidate row-locator set. Returns `None` for query
9814/// shapes the MVP doesn't accelerate (`Not` / `Phrase` — both bail to
9815/// full scan since their semantics need either complementation across
9816/// the whole row set or positional verification beyond what the
9817/// posting list carries).
9818///
9819/// Candidate sets are over-approximate — the caller re-applies the
9820/// full `@@` predicate per row, so reporting "row was in some
9821/// posting list" without verifying positions / weights stays correct.
9822fn gin_query_candidates(
9823    idx: &spg_storage::Index,
9824    query: &spg_storage::TsQueryAst,
9825) -> Option<Vec<spg_storage::RowLocator>> {
9826    use spg_storage::TsQueryAst;
9827    match query {
9828        TsQueryAst::Term { word, .. } => {
9829            let mut v: Vec<spg_storage::RowLocator> = idx.gin_lookup_word(word).to_vec();
9830            v.sort_by_key(locator_sort_key);
9831            v.dedup_by_key(|l| locator_sort_key(l));
9832            Some(v)
9833        }
9834        TsQueryAst::And(l, r) => {
9835            let mut left = gin_query_candidates(idx, l)?;
9836            let mut right = gin_query_candidates(idx, r)?;
9837            left.sort_by_key(locator_sort_key);
9838            right.sort_by_key(locator_sort_key);
9839            // Sorted-merge intersection.
9840            let mut out: Vec<spg_storage::RowLocator> = Vec::new();
9841            let (mut i, mut j) = (0usize, 0usize);
9842            while i < left.len() && j < right.len() {
9843                let lk = locator_sort_key(&left[i]);
9844                let rk = locator_sort_key(&right[j]);
9845                match lk.cmp(&rk) {
9846                    core::cmp::Ordering::Less => i += 1,
9847                    core::cmp::Ordering::Greater => j += 1,
9848                    core::cmp::Ordering::Equal => {
9849                        out.push(left[i]);
9850                        i += 1;
9851                        j += 1;
9852                    }
9853                }
9854            }
9855            Some(out)
9856        }
9857        TsQueryAst::Or(l, r) => {
9858            let mut out = gin_query_candidates(idx, l)?;
9859            out.extend(gin_query_candidates(idx, r)?);
9860            out.sort_by_key(locator_sort_key);
9861            out.dedup_by_key(|l| locator_sort_key(l));
9862            Some(out)
9863        }
9864        // Not / Phrase bail to full scan in the MVP. Not needs
9865        // complementation against the whole row set (not represented
9866        // in the posting-list view); Phrase needs positional
9867        // verification beyond what `word → rows` carries.
9868        TsQueryAst::Not(_) | TsQueryAst::Phrase { .. } => None,
9869    }
9870}
9871
9872/// v7.12.3 — total ordering on `RowLocator` for sort/dedup purposes
9873/// inside the GIN intersection / union loops. Hot rows order by their
9874/// row index; Cold rows order after all Hot rows, then by
9875/// `(segment_id, the cold sub-key)`.
9876fn locator_sort_key(l: &spg_storage::RowLocator) -> (u8, u64, u64) {
9877    match *l {
9878        spg_storage::RowLocator::Hot(i) => (0, i as u64, 0),
9879        spg_storage::RowLocator::Cold {
9880            segment_id,
9881            page_offset,
9882        } => (1, u64::from(segment_id), u64::from(page_offset)),
9883    }
9884}
9885
9886/// v5.2.3: extract `(column_position, IndexKey)` when `where_expr`
9887/// is a simple `col = literal` predicate suitable for a `BTree` index
9888/// seek. Used by `exec_update_cancel` / `exec_delete_cancel` to
9889/// decide whether a write touches a cold-tier row (which requires
9890/// promote-on-write / shadow-on-delete) before falling through to
9891/// the hot-tier row walk.
9892///
9893/// Returns `None` for any predicate shape the planner can't push
9894/// down to an index seek — complex WHERE clauses always take the
9895/// hot-only path (cold rows are immutable to non-indexed writes
9896/// until a future scan-fanout sub-version).
9897fn try_pk_predicate(
9898    where_expr: &Expr,
9899    schema_cols: &[ColumnSchema],
9900    table_alias: &str,
9901) -> Option<(usize, IndexKey)> {
9902    let Expr::Binary {
9903        lhs,
9904        op: BinOp::Eq,
9905        rhs,
9906    } = where_expr
9907    else {
9908        return None;
9909    };
9910    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
9911        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
9912    let key = IndexKey::from_value(&value)?;
9913    Some((col_pos, key))
9914}
9915
9916fn resolve_col_literal_pair(
9917    col_side: &Expr,
9918    lit_side: &Expr,
9919    schema_cols: &[ColumnSchema],
9920    table_alias: &str,
9921) -> Option<(usize, Value)> {
9922    let Expr::Column(c) = col_side else {
9923        return None;
9924    };
9925    if let Some(q) = &c.qualifier
9926        && q != table_alias
9927    {
9928        return None;
9929    }
9930    let pos = schema_cols.iter().position(|s| s.name == c.name)?;
9931    let Expr::Literal(l) = lit_side else {
9932        return None;
9933    };
9934    let v = match l {
9935        Literal::Integer(n) => {
9936            if let Ok(small) = i32::try_from(*n) {
9937                Value::Int(small)
9938            } else {
9939                Value::BigInt(*n)
9940            }
9941        }
9942        Literal::Float(x) => Value::Float(*x),
9943        Literal::String(s) => Value::Text(s.clone()),
9944        Literal::Bool(b) => Value::Bool(*b),
9945        Literal::Null => Value::Null,
9946        // Vector, array and Interval literals can't be used as B-tree
9947        // index keys. Tell the planner to fall back to full-scan.
9948        Literal::Vector(_)
9949        | Literal::Interval { .. }
9950        | Literal::TextArray(_)
9951        | Literal::IntArray(_)
9952        | Literal::BigIntArray(_) => return None,
9953    };
9954    Some((pos, v))
9955}
9956
9957/// Find the schema entry that a SELECT-list `Expr::Column` refers to.
9958/// Mirrors `resolve_column` in `eval.rs`, but returns a proper
9959/// `EngineError` so the projection-build path keeps `UnknownQualifier`
9960/// vs `ColumnNotFound` distinct.
9961fn resolve_projection_column<'a>(
9962    c: &ColumnName,
9963    schema_cols: &'a [ColumnSchema],
9964    table_alias: &str,
9965) -> Result<&'a ColumnSchema, EngineError> {
9966    if let Some(q) = &c.qualifier {
9967        let composite = alloc::format!("{q}.{name}", name = c.name);
9968        if let Some(s) = schema_cols.iter().find(|s| s.name == composite) {
9969            return Ok(s);
9970        }
9971        // Single-table case: the qualifier may equal the active alias —
9972        // then look for the bare column name.
9973        if q == table_alias
9974            && let Some(s) = schema_cols.iter().find(|s| s.name == c.name)
9975        {
9976            return Ok(s);
9977        }
9978        // For multi-table schemas the qualifier is unknown only if no
9979        // column bears the "<q>." prefix. For single-table, the alias
9980        // mismatch alone is enough.
9981        let prefix = alloc::format!("{q}.");
9982        let qualifier_known =
9983            q == table_alias || schema_cols.iter().any(|s| s.name.starts_with(&prefix));
9984        if !qualifier_known {
9985            return Err(EngineError::Eval(EvalError::UnknownQualifier {
9986                qualifier: q.clone(),
9987            }));
9988        }
9989        return Err(EngineError::Eval(EvalError::ColumnNotFound {
9990            name: c.name.clone(),
9991        }));
9992    }
9993    if let Some(s) = schema_cols.iter().find(|s| s.name == c.name) {
9994        return Ok(s);
9995    }
9996    let suffix = alloc::format!(".{name}", name = c.name);
9997    let mut matches = schema_cols.iter().filter(|s| s.name.ends_with(&suffix));
9998    let first = matches.next();
9999    let extra = matches.next();
10000    match (first, extra) {
10001        (Some(s), None) => Ok(s),
10002        (Some(_), Some(_)) => Err(EngineError::Eval(EvalError::TypeMismatch {
10003            detail: alloc::format!("ambiguous column reference: {}", c.name),
10004        })),
10005        _ => Err(EngineError::Eval(EvalError::ColumnNotFound {
10006            name: c.name.clone(),
10007        })),
10008    }
10009}
10010
10011fn build_projection(
10012    items: &[SelectItem],
10013    schema_cols: &[ColumnSchema],
10014    table_alias: &str,
10015) -> Result<Vec<ProjectedItem>, EngineError> {
10016    let mut out = Vec::new();
10017    for item in items {
10018        match item {
10019            SelectItem::Wildcard => {
10020                for col in schema_cols {
10021                    out.push(ProjectedItem {
10022                        expr: Expr::Column(ColumnName {
10023                            qualifier: None,
10024                            name: col.name.clone(),
10025                        }),
10026                        output_name: col.name.clone(),
10027                        ty: col.ty,
10028                        nullable: col.nullable,
10029                    });
10030                }
10031            }
10032            SelectItem::Expr { expr, alias } => {
10033                // Plain column ref keeps full schema info (real type +
10034                // nullability). For compound expressions try the
10035                // describe-side function-return-type table first
10036                // (e.g. `SELECT now()` → Timestamptz, `SELECT
10037                // concat(…)` → Text). Falls back to nullable Text
10038                // for shapes the describe path can't resolve.
10039                if let Expr::Column(c) = expr {
10040                    let sch = resolve_projection_column(c, schema_cols, table_alias)?;
10041                    let output_name = alias.clone().unwrap_or_else(|| c.name.clone());
10042                    out.push(ProjectedItem {
10043                        expr: expr.clone(),
10044                        output_name,
10045                        ty: sch.ty,
10046                        nullable: sch.nullable,
10047                    });
10048                } else if let Some(shape) = describe::describe_expr(expr, schema_cols) {
10049                    let output_name = alias.clone().unwrap_or_else(|| expr.to_string());
10050                    out.push(ProjectedItem {
10051                        expr: expr.clone(),
10052                        output_name,
10053                        ty: shape.ty,
10054                        nullable: shape.nullable,
10055                    });
10056                } else {
10057                    let output_name = alias.clone().unwrap_or_else(|| expr.to_string());
10058                    out.push(ProjectedItem {
10059                        expr: expr.clone(),
10060                        output_name,
10061                        ty: DataType::Text,
10062                        nullable: true,
10063                    });
10064                }
10065            }
10066        }
10067    }
10068    Ok(out)
10069}
10070
10071/// Promote an integer to a NUMERIC value at the requested scale.
10072/// Rejects values that, after scaling, would overflow the column's
10073/// precision budget.
10074fn numeric_from_integer(
10075    n: i128,
10076    precision: u8,
10077    scale: u8,
10078    col_name: &str,
10079) -> Result<Value, EngineError> {
10080    let factor = pow10_i128(scale);
10081    let scaled = n.checked_mul(factor).ok_or_else(|| {
10082        EngineError::Unsupported(alloc::format!(
10083            "integer overflow scaling value for column `{col_name}` to scale {scale}"
10084        ))
10085    })?;
10086    check_precision(scaled, precision, col_name)?;
10087    Ok(Value::Numeric { scaled, scale })
10088}
10089
10090/// Float → NUMERIC. Uses round-half-away-from-zero on `x * 10^scale`,
10091/// then verifies the result fits the column's precision.
10092#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
10093fn numeric_from_float(
10094    x: f64,
10095    precision: u8,
10096    scale: u8,
10097    col_name: &str,
10098) -> Result<Value, EngineError> {
10099    if !x.is_finite() {
10100        return Err(EngineError::Unsupported(alloc::format!(
10101            "cannot store non-finite float in NUMERIC column `{col_name}`"
10102        )));
10103    }
10104    let mut factor = 1.0_f64;
10105    for _ in 0..scale {
10106        factor *= 10.0;
10107    }
10108    // Round half-away-from-zero by biasing then casting (`as i128`
10109    // truncates toward zero, so the bias + truncation gives the
10110    // desired rounding). `f64::floor` / `ceil` live in std; we don't
10111    // need them — the cast handles the truncation step.
10112    let shifted = x * factor;
10113    let biased = if shifted >= 0.0 {
10114        shifted + 0.5
10115    } else {
10116        shifted - 0.5
10117    };
10118    // Range-check before casting back to i128 — the cast itself is
10119    // saturating in Rust, which would silently truncate huge inputs.
10120    if !(-1e38..=1e38).contains(&biased) {
10121        return Err(EngineError::Unsupported(alloc::format!(
10122            "value {x} overflows NUMERIC range for column `{col_name}`"
10123        )));
10124    }
10125    let scaled = biased as i128;
10126    check_precision(scaled, precision, col_name)?;
10127    Ok(Value::Numeric { scaled, scale })
10128}
10129
10130/// v7.17.0 Phase 3.P0-67 — parse PG-canonical decimal text into
10131/// `(mantissa: i128, source_scale: u8)`. Accepts optional sign,
10132/// optional integer part, optional fractional part. Rejects
10133/// scientific notation, embedded spaces, locale-specific
10134/// thousand separators. Returns None on bad input — coerce_value
10135/// turns that into a TypeMismatch error.
10136fn parse_numeric_text(s: &str) -> Option<(i128, u8)> {
10137    let s = s.trim();
10138    if s.is_empty() {
10139        return None;
10140    }
10141    let (negative, rest) = match s.as_bytes()[0] {
10142        b'-' => (true, &s[1..]),
10143        b'+' => (false, &s[1..]),
10144        _ => (false, s),
10145    };
10146    if rest.is_empty() {
10147        return None;
10148    }
10149    // Reject scientific notation — bigdecimal collapses it before
10150    // hitting the wire, and we want a clear error if a stray `e`
10151    // sneaks in.
10152    if rest.bytes().any(|b| b == b'e' || b == b'E') {
10153        return None;
10154    }
10155    let (int_part, frac_part) = match rest.find('.') {
10156        Some(idx) => (&rest[..idx], &rest[idx + 1..]),
10157        None => (rest, ""),
10158    };
10159    if int_part.is_empty() && frac_part.is_empty() {
10160        return None;
10161    }
10162    if int_part.bytes().any(|b| !b.is_ascii_digit()) {
10163        return None;
10164    }
10165    if frac_part.bytes().any(|b| !b.is_ascii_digit()) {
10166        return None;
10167    }
10168    let scale_u32 = u32::try_from(frac_part.len()).ok()?;
10169    if scale_u32 > u32::from(u8::MAX) {
10170        return None;
10171    }
10172    let scale = scale_u32 as u8;
10173    let mut digits = alloc::string::String::with_capacity(int_part.len() + frac_part.len() + 1);
10174    if negative {
10175        digits.push('-');
10176    }
10177    digits.push_str(int_part);
10178    digits.push_str(frac_part);
10179    // Strip a leading "+0..0" so parse doesn't choke on "00" etc.
10180    let digits = if digits == "-" {
10181        return None;
10182    } else if digits.is_empty() {
10183        "0"
10184    } else {
10185        digits.as_str()
10186    };
10187    let mantissa: i128 = digits.parse().ok()?;
10188    Some((mantissa, scale))
10189}
10190
10191/// Move a Numeric value from `src_scale` to `dst_scale`. Going up
10192/// multiplies by 10; going down rounds half-away-from-zero.
10193fn numeric_rescale(
10194    scaled: i128,
10195    src_scale: u8,
10196    precision: u8,
10197    dst_scale: u8,
10198    col_name: &str,
10199) -> Result<Value, EngineError> {
10200    let new_scaled = if dst_scale >= src_scale {
10201        let bump = pow10_i128(dst_scale - src_scale);
10202        scaled.checked_mul(bump).ok_or_else(|| {
10203            EngineError::Unsupported(alloc::format!(
10204                "overflow rescaling NUMERIC for column `{col_name}`"
10205            ))
10206        })?
10207    } else {
10208        let drop = pow10_i128(src_scale - dst_scale);
10209        let half = drop / 2;
10210        if scaled >= 0 {
10211            (scaled + half) / drop
10212        } else {
10213            (scaled - half) / drop
10214        }
10215    };
10216    check_precision(new_scaled, precision, col_name)?;
10217    Ok(Value::Numeric {
10218        scaled: new_scaled,
10219        scale: dst_scale,
10220    })
10221}
10222
10223/// Drop the fractional part of a scaled integer, returning the integer
10224/// portion (toward zero). Used for NUMERIC → INT casts.
10225const fn numeric_truncate_to_integer(scaled: i128, scale: u8) -> i128 {
10226    if scale == 0 {
10227        return scaled;
10228    }
10229    let factor = pow10_i128_const(scale);
10230    scaled / factor
10231}
10232
10233/// Verify a scaled NUMERIC value fits the column's declared precision.
10234/// `precision == 0` is the "unconstrained" form (bare `NUMERIC`); we
10235/// skip the check there.
10236fn check_precision(scaled: i128, precision: u8, col_name: &str) -> Result<(), EngineError> {
10237    if precision == 0 {
10238        return Ok(());
10239    }
10240    let limit = pow10_i128(precision);
10241    if scaled.unsigned_abs() >= limit.unsigned_abs() {
10242        return Err(EngineError::Unsupported(alloc::format!(
10243            "NUMERIC value exceeds precision {precision} for column `{col_name}`"
10244        )));
10245    }
10246    Ok(())
10247}
10248
10249const fn pow10_i128_const(p: u8) -> i128 {
10250    let mut acc: i128 = 1;
10251    let mut i = 0;
10252    while i < p {
10253        acc *= 10;
10254        i += 1;
10255    }
10256    acc
10257}
10258
10259fn pow10_i128(p: u8) -> i128 {
10260    pow10_i128_const(p)
10261}
10262
10263/// Walk a parsed `Statement`, swapping any `NOW()` /
10264/// `CURRENT_TIMESTAMP()` / `CURRENT_DATE()` function calls for a
10265/// literal cast that wraps the engine's per-statement clock reading.
10266/// When `now_micros` is `None`, calls stay as-is and surface as
10267/// `unknown function` at eval time — keeps the error path explicit.
10268/// v4.10: pre-walk the WHERE / projection / etc. of a SELECT and
10269/// replace every subquery node with a materialised literal. SPG
10270/// only supports uncorrelated subqueries — the inner SELECT does
10271/// not see outer-row columns, so the result is the same for every
10272/// outer row and can be evaluated once.
10273///
10274/// Returns the rewritten statement; the caller passes this to the
10275/// regular row-loop executor which no longer sees Subquery nodes
10276/// in its tree.
10277impl Engine {
10278    /// v4.12 window executor. Implements `ROW_NUMBER` / `RANK` /
10279    /// `DENSE_RANK` and the partition-aware aggregates `SUM` /
10280    /// `AVG` / `COUNT` / `MIN` / `MAX`. The plan is:
10281    /// 1. Apply the WHERE filter.
10282    /// 2. For each unique `WindowFunction` node in the projection,
10283    ///    partition + sort, compute the per-row value.
10284    /// 3. Append the window values as synthetic columns (`__win_N`)
10285    ///    to the row schema.
10286    /// 4. Rewrite the projection to read those columns.
10287    /// 5. Hand off to the regular project / ORDER BY / LIMIT pipe.
10288    #[allow(
10289        clippy::too_many_lines,
10290        clippy::type_complexity,
10291        clippy::needless_range_loop
10292    )] // window-eval is one cohesive pipe; splitting fragments
10293    fn exec_select_with_window(
10294        &self,
10295        stmt: &SelectStatement,
10296        cancel: CancelToken<'_>,
10297    ) -> Result<QueryResult, EngineError> {
10298        let from = stmt.from.as_ref().ok_or_else(|| {
10299            EngineError::Unsupported("window functions require a FROM clause".into())
10300        })?;
10301        // v7.17.0 Phase 3.P0-43 — JOIN + window functions. Phase
10302        // 3.6 rejected this combination outright ("queued for
10303        // v5.x"); P0-43 materialises the join + WHERE through the
10304        // existing nested-loop helper and runs the window pipeline
10305        // on the joined row set with the combined `alias.col`
10306        // schema. The window expressions resolve through the
10307        // qualifier-aware column resolver same as the aggregate /
10308        // projection paths on JOIN.
10309        let (schema_cols_owned, alias_opt): (Vec<ColumnSchema>, Option<&str>);
10310        let filtered: Vec<Row>;
10311        if from.joins.is_empty() {
10312            let primary = &from.primary;
10313            let table = self.active_catalog().get(&primary.name).ok_or_else(|| {
10314                StorageError::TableNotFound {
10315                    name: primary.name.clone(),
10316                }
10317            })?;
10318            let alias = primary.alias.as_deref().unwrap_or(primary.name.as_str());
10319            schema_cols_owned = table.schema().columns.clone();
10320            alias_opt = Some(alias);
10321            // Materialise WHERE-filtered rows owned so the JOIN
10322            // and single-table paths share a single downstream
10323            // shape. The clone is cheap relative to the window
10324            // computation that follows.
10325            let ctx = self.ev_ctx(&schema_cols_owned, alias_opt);
10326            let mut owned: Vec<Row> = Vec::new();
10327            for (i, row) in table.rows().iter().enumerate() {
10328                if i.is_multiple_of(256) {
10329                    cancel.check()?;
10330                }
10331                if let Some(w) = &stmt.where_ {
10332                    let cond = eval::eval_expr(w, row, &ctx)?;
10333                    if !matches!(cond, Value::Bool(true)) {
10334                        continue;
10335                    }
10336                }
10337                owned.push(row.clone());
10338            }
10339            filtered = owned;
10340        } else {
10341            let (combined_schema, rows) =
10342                self.build_joined_filtered_rows(from, stmt.where_.as_ref(), cancel, None)?;
10343            schema_cols_owned = combined_schema;
10344            alias_opt = None;
10345            filtered = rows;
10346        }
10347        let schema_cols = &schema_cols_owned;
10348        let ctx = self.ev_ctx(schema_cols, alias_opt);
10349        let alias = alias_opt.unwrap_or("");
10350        let n_rows = filtered.len();
10351        // Borrow refs into the owned row vec once so the downstream
10352        // `compute_window_partition` call (which takes `&[&Row]`) and
10353        // the per-row eval loops share a single backing buffer.
10354        let filtered_refs: Vec<&Row> = filtered.iter().collect();
10355
10356        // 2) Collect unique window function nodes from projection.
10357        let mut window_nodes: Vec<Expr> = Vec::new();
10358        for item in &stmt.items {
10359            if let SelectItem::Expr { expr, .. } = item {
10360                collect_window_nodes(expr, &mut window_nodes);
10361            }
10362        }
10363
10364        // 3) For each window, compute per-row value.
10365        // Index: same order as window_nodes; for row i, win_vals[w][i].
10366        let mut win_vals: Vec<Vec<Value>> = Vec::with_capacity(window_nodes.len());
10367        for wnode in &window_nodes {
10368            let Expr::WindowFunction {
10369                name,
10370                args,
10371                partition_by,
10372                order_by,
10373                frame,
10374                null_treatment,
10375            } = wnode
10376            else {
10377                unreachable!("collect_window_nodes pushes only WindowFunction");
10378            };
10379            // Compute (partition_key, order_key, original_index) for each row.
10380            let mut indexed: Vec<(Vec<Value>, Vec<(Value, bool, Option<bool>)>, usize)> =
10381                Vec::with_capacity(n_rows);
10382            for (i, row) in filtered.iter().enumerate() {
10383                let pkey: Vec<Value> = partition_by
10384                    .iter()
10385                    .map(|p| eval::eval_expr(p, row, &ctx))
10386                    .collect::<Result<_, _>>()?;
10387                let okey: Vec<(Value, bool, Option<bool>)> = order_by
10388                    .iter()
10389                    .map(|(e, desc, nf)| eval::eval_expr(e, row, &ctx).map(|v| (v, *desc, *nf)))
10390                    .collect::<Result<_, _>>()?;
10391                indexed.push((pkey, okey, i));
10392            }
10393            // Sort by (partition_key, order_key). Partition key uses
10394            // a stable encoded form; order key respects ASC/DESC.
10395            indexed.sort_by(|a, b| {
10396                let p_cmp = partition_key_cmp(&a.0, &b.0);
10397                if p_cmp != core::cmp::Ordering::Equal {
10398                    return p_cmp;
10399                }
10400                order_key_cmp(&a.1, &b.1)
10401            });
10402            // Per-partition compute.
10403            let mut out_vals: Vec<Value> = alloc::vec![Value::Null; n_rows];
10404            let mut p_start = 0;
10405            while p_start < indexed.len() {
10406                let mut p_end = p_start + 1;
10407                while p_end < indexed.len()
10408                    && partition_key_cmp(&indexed[p_start].0, &indexed[p_end].0)
10409                        == core::cmp::Ordering::Equal
10410                {
10411                    p_end += 1;
10412                }
10413                // Compute the function within this partition slice.
10414                compute_window_partition(
10415                    name,
10416                    args,
10417                    !order_by.is_empty(),
10418                    frame.as_ref(),
10419                    *null_treatment,
10420                    &indexed[p_start..p_end],
10421                    &filtered_refs,
10422                    &ctx,
10423                    &mut out_vals,
10424                )?;
10425                p_start = p_end;
10426            }
10427            win_vals.push(out_vals);
10428        }
10429
10430        // 4) Build extended schema: original columns + synthetic.
10431        let mut ext_cols = schema_cols.clone();
10432        for i in 0..window_nodes.len() {
10433            ext_cols.push(ColumnSchema::new(
10434                alloc::format!("__win_{i}"),
10435                DataType::Text, // type doesn't matter for projection eval
10436                true,
10437            ));
10438        }
10439        // 5) Build extended rows: each row gets its window values appended.
10440        let mut ext_rows: Vec<Row> = Vec::with_capacity(n_rows);
10441        for i in 0..n_rows {
10442            let mut values = filtered[i].values.clone();
10443            for w in 0..window_nodes.len() {
10444                values.push(win_vals[w][i].clone());
10445            }
10446            ext_rows.push(Row::new(values));
10447        }
10448        // 6) Rewrite the projection: WindowFunction nodes → Column(__win_N).
10449        let mut rewritten_items: Vec<SelectItem> = Vec::with_capacity(stmt.items.len());
10450        for item in &stmt.items {
10451            let new_item = match item {
10452                SelectItem::Wildcard => SelectItem::Wildcard,
10453                SelectItem::Expr { expr, alias } => {
10454                    let mut e = expr.clone();
10455                    rewrite_window_to_columns(&mut e, &window_nodes);
10456                    SelectItem::Expr {
10457                        expr: e,
10458                        alias: alias.clone(),
10459                    }
10460                }
10461            };
10462            rewritten_items.push(new_item);
10463        }
10464
10465        // 7) Project into final rows. JOIN case uses None so the
10466        // qualifier check in `resolve_column` falls through to the
10467        // composite `alias.col` schema lookup; single-table case
10468        // keeps the bare alias so `bare_col` resolution still
10469        // works for the projection's per-row column references.
10470        let ext_ctx = EvalContext::new(&ext_cols, alias_opt);
10471        let projection = build_projection(&rewritten_items, &ext_cols, alias)?;
10472        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::with_capacity(n_rows);
10473        for (i, row) in ext_rows.iter().enumerate() {
10474            if i.is_multiple_of(256) {
10475                cancel.check()?;
10476            }
10477            let mut values = Vec::with_capacity(projection.len());
10478            for p in &projection {
10479                values.push(eval::eval_expr(&p.expr, row, &ext_ctx)?);
10480            }
10481            let order_keys = if stmt.order_by.is_empty() {
10482                Vec::new()
10483            } else {
10484                let mut keys = Vec::with_capacity(stmt.order_by.len());
10485                for o in &stmt.order_by {
10486                    let mut e = o.expr.clone();
10487                    rewrite_window_to_columns(&mut e, &window_nodes);
10488                    let key = eval::eval_expr(&e, row, &ext_ctx)?;
10489                    keys.push(value_to_order_key(&key)?);
10490                }
10491                keys
10492            };
10493            tagged.push((order_keys, Row::new(values)));
10494        }
10495        // ORDER BY + LIMIT/OFFSET on the projected rows.
10496        if !stmt.order_by.is_empty() {
10497            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
10498            sort_by_keys(&mut tagged, &descs);
10499        }
10500        let mut out_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
10501        apply_offset_and_limit(&mut out_rows, stmt.offset_literal(), stmt.limit_literal());
10502        let final_cols: Vec<ColumnSchema> = projection
10503            .into_iter()
10504            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
10505            .collect();
10506        Ok(QueryResult::Rows {
10507            columns: final_cols,
10508            rows: out_rows,
10509        })
10510    }
10511
10512    /// v4.11: materialise each CTE into a temp table inside a
10513    /// cloned catalog, then run the body SELECT against a fresh
10514    /// engine instance that owns the enriched catalog. The clone
10515    /// is moderately expensive — only paid by CTE-bearing queries.
10516    /// Subqueries inside CTE bodies / the main body resolve as
10517    /// usual; `clock_fn` is propagated so `NOW()` lines up.
10518    /// v7.16.2 — mailrs round-10 A.3. Materialise the
10519    /// `information_schema.*` / `pg_catalog.*` virtual views
10520    /// the SELECT references, then re-execute the SELECT
10521    /// against an enriched catalog where those views are real
10522    /// tables. Same pattern as `exec_with_ctes`. The temp
10523    /// engine carries `meta_views_materialised = true` so its
10524    /// own meta-dispatch short-circuits — without that we'd
10525    /// infinite-recurse since the temp catalog's view name
10526    /// still starts with `__spg_info_` and re-triggers the
10527    /// check.
10528    fn exec_select_with_meta_views(
10529        &self,
10530        stmt: &SelectStatement,
10531        cancel: CancelToken<'_>,
10532    ) -> Result<QueryResult, EngineError> {
10533        let mut needed: alloc::collections::BTreeSet<String> = alloc::collections::BTreeSet::new();
10534        collect_meta_view_names(stmt, &mut needed);
10535        let mut catalog = self.active_catalog().clone();
10536        for view in &needed {
10537            if catalog.get(view).is_some() {
10538                continue;
10539            }
10540            match view.as_str() {
10541                "__spg_info_columns" => {
10542                    let (schema, rows) = synth_information_schema_columns(self.active_catalog());
10543                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10544                }
10545                "__spg_info_tables" => {
10546                    let (schema, rows) = synth_information_schema_tables(self.active_catalog());
10547                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10548                }
10549                "__spg_pg_class" => {
10550                    let (schema, rows) = synth_pg_class(self.active_catalog());
10551                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10552                }
10553                "__spg_pg_attribute" => {
10554                    let (schema, rows) = synth_pg_attribute(self.active_catalog());
10555                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10556                }
10557                // v7.17.0 Phase 3.P0-50 — pg_catalog.pg_type for
10558                // sqlx / SQLAlchemy / Diesel / pgAdmin lookups.
10559                "__spg_pg_type" => {
10560                    let (schema, rows) = synth_pg_type(self.active_catalog());
10561                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10562                }
10563                // v7.17.0 Phase 3.P0-51 — pg_catalog.pg_proc for
10564                // function-name introspection (ORM / pgAdmin).
10565                "__spg_pg_proc" => {
10566                    let (schema, rows) = synth_pg_proc(self.active_catalog());
10567                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10568                }
10569                // v7.24 (round-16 D) — pg_catalog.pg_trigger. The
10570                // round-16 "why doesn't prod fire the trigger"
10571                // question was unanswerable because triggers had NO
10572                // introspection surface; tgname/tgenabled plus the
10573                // pragmatic relname/timing/events/function columns
10574                // make "is it registered and enabled" a one-liner.
10575                "__spg_pg_trigger" => {
10576                    let (schema, rows) = synth_pg_trigger(self.active_catalog());
10577                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10578                }
10579                // v7.17.0 Phase 3.P0-52 — pg_catalog.pg_namespace
10580                // (schema list for admin tools' tree views).
10581                "__spg_pg_namespace" => {
10582                    let (schema, rows) = synth_pg_namespace(self.active_catalog());
10583                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10584                }
10585                // v7.17.0 Phase 3.P0-53 — pg_catalog.pg_indexes view
10586                // for pgAdmin / DataGrip "indexes per table" listings.
10587                "__spg_pg_indexes" => {
10588                    let (schema, rows) = synth_pg_indexes(self.active_catalog());
10589                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10590                }
10591                // v7.17.0 Phase 3.P0-53 — pg_catalog.pg_index (raw)
10592                // for index introspection by ORM compilers.
10593                "__spg_pg_index" => {
10594                    let (schema, rows) = synth_pg_index_raw(self.active_catalog());
10595                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10596                }
10597                // v7.17.0 Phase 3.P0-54 — pg_catalog.pg_constraint
10598                // for FK / UNIQUE / PK / CHECK introspection.
10599                "__spg_pg_constraint" => {
10600                    let (schema, rows) = synth_pg_constraint(self.active_catalog());
10601                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10602                }
10603                // v7.17.0 Phase 3.P0-55 — pg_catalog.pg_database /
10604                // pg_roles / pg_user. SPG is single-database so
10605                // pg_database surfaces just `postgres`; pg_roles
10606                // / pg_user walk the engine's UserStore.
10607                "__spg_pg_database" => {
10608                    let (schema, rows) = synth_pg_database(self.active_catalog());
10609                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10610                }
10611                "__spg_pg_roles" | "__spg_pg_user" => {
10612                    let (schema, rows) = synth_pg_roles(self);
10613                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10614                }
10615                // v7.17.0 Phase 3.P0-56 — pg_catalog.pg_views. PG's
10616                // pg_views surfaces every CREATE VIEW result; SPG
10617                // ships one row per declared view from the catalog.
10618                "__spg_pg_views" => {
10619                    let (schema, rows) = synth_pg_views(self.active_catalog());
10620                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10621                }
10622                // v7.17.0 Phase 3.P0-56 — pg_catalog.pg_matviews.
10623                // SPG has no materialised view surface yet so the
10624                // table shares pg_views's schema but stays empty.
10625                "__spg_pg_matviews" => {
10626                    let (schema, _) = synth_pg_views(self.active_catalog());
10627                    materialise_meta_view(&mut catalog, view, schema, Vec::new())?;
10628                }
10629                // pg_catalog.pg_extension — native capability list
10630                // (mailrs embed round-12).
10631                "__spg_pg_extension" => {
10632                    let (schema, rows) = synth_pg_extension();
10633                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10634                }
10635                // v7.17.0 Phase 3.P0-57 — pg_catalog.pg_settings.
10636                "__spg_pg_settings" => {
10637                    let (schema, rows) = synth_pg_settings(self);
10638                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10639                }
10640                // v7.17.0 Phase 3.P0-63 — information_schema.KEY_COLUMN_USAGE.
10641                "__spg_info_key_column_usage" => {
10642                    let (schema, rows) = synth_info_key_column_usage(self.active_catalog());
10643                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10644                }
10645                // v7.17.0 Phase 3.P0-64 — information_schema.REFERENTIAL_CONSTRAINTS.
10646                "__spg_info_referential_constraints" => {
10647                    let (schema, rows) = synth_info_referential_constraints(self.active_catalog());
10648                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10649                }
10650                // v7.17.0 Phase 3.P0-64 — information_schema.STATISTICS.
10651                "__spg_info_statistics" => {
10652                    let (schema, rows) = synth_info_statistics(self.active_catalog());
10653                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10654                }
10655                // v7.17.0 Phase 3.P0-64 — information_schema.ROUTINES.
10656                "__spg_info_routines" => {
10657                    let (schema, rows) = synth_info_routines();
10658                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10659                }
10660                // v7.17.0 Phase 3.P0-65 — mysql.user / mysql.db.
10661                "__spg_mysql_user" => {
10662                    let (schema, rows) = synth_mysql_user(self);
10663                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10664                }
10665                "__spg_mysql_db" => {
10666                    let (schema, rows) = synth_mysql_db();
10667                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10668                }
10669                _ => {
10670                    return Err(EngineError::Unsupported(alloc::format!(
10671                        "meta view {view:?} is not yet materialisable; \
10672                         v7.16.2 covers information_schema.columns / .tables \
10673                         and pg_catalog.pg_class / pg_attribute; \
10674                         v7.17.0 P0-50..P0-57 add pg_type / pg_proc / pg_namespace / \
10675                         pg_indexes / pg_index / pg_constraint / pg_database / pg_roles / \
10676                         pg_user / pg_views / pg_matviews / pg_settings"
10677                    )));
10678                }
10679            }
10680        }
10681        let mut temp = Engine::restore(catalog);
10682        if let Some(c) = self.clock {
10683            temp = temp.with_clock(c);
10684        }
10685        if let Some(f) = self.salt_fn {
10686            temp = temp.with_salt_fn(f);
10687        }
10688        temp.meta_views_materialised = true;
10689        temp.exec_select_cancel(stmt, cancel)
10690    }
10691
10692    fn exec_with_ctes(
10693        &self,
10694        stmt: &SelectStatement,
10695        cancel: CancelToken<'_>,
10696    ) -> Result<QueryResult, EngineError> {
10697        cancel.check()?;
10698        let mut catalog = self.active_catalog().clone();
10699        for cte in &stmt.ctes {
10700            if catalog.get(&cte.name).is_some() {
10701                return Err(EngineError::Unsupported(alloc::format!(
10702                    "CTE name {:?} shadows an existing table; rename the CTE",
10703                    cte.name
10704                )));
10705            }
10706            let (columns, rows) = if cte.recursive {
10707                self.materialise_recursive_cte(cte, &catalog, cancel)?
10708            } else {
10709                // v7.25 (round-17) — run the body against the
10710                // ACCUMULATED catalog so a CTE can reference every
10711                // CTE declared before it (`WITH a AS (…), b AS
10712                // (SELECT … FROM a)`). Executing on `self` lost the
10713                // already-materialised CTE tables.
10714                let mut cte_engine = Engine::restore(catalog.clone());
10715                if let Some(c) = self.clock {
10716                    cte_engine = cte_engine.with_clock(c);
10717                }
10718                if let Some(f) = self.salt_fn {
10719                    cte_engine = cte_engine.with_salt_fn(f);
10720                }
10721                let body_result = cte_engine.exec_select_cancel(&cte.body, cancel)?;
10722                let QueryResult::Rows { columns, rows } = body_result else {
10723                    return Err(EngineError::Unsupported(alloc::format!(
10724                        "CTE {:?} body did not return rows",
10725                        cte.name
10726                    )));
10727                };
10728                (columns, rows)
10729            };
10730            // v4.22: the projection builder labels any non-column
10731            // expression as Text — including literal SELECT 1.
10732            // Promote each column's type to whatever the rows
10733            // actually carry so the CTE storage table accepts them.
10734            let inferred = infer_column_types(&columns, &rows);
10735            let mut columns = inferred;
10736            // v4.22: apply optional `WITH name(a, b, c)` overrides.
10737            if !cte.column_overrides.is_empty() {
10738                if cte.column_overrides.len() != columns.len() {
10739                    return Err(EngineError::Unsupported(alloc::format!(
10740                        "CTE {:?} column list has {} names but body returns {} columns",
10741                        cte.name,
10742                        cte.column_overrides.len(),
10743                        columns.len()
10744                    )));
10745                }
10746                for (col, name) in columns.iter_mut().zip(cte.column_overrides.iter()) {
10747                    col.name.clone_from(name);
10748                }
10749            }
10750            let schema = TableSchema::new(cte.name.clone(), columns);
10751            catalog.create_table(schema).map_err(EngineError::Storage)?;
10752            let table = catalog
10753                .get_mut(&cte.name)
10754                .expect("just-created CTE table must exist");
10755            for row in rows {
10756                table.insert(row).map_err(EngineError::Storage)?;
10757            }
10758        }
10759        // Strip CTEs from the body before running on the temp engine
10760        // so we don't recurse forever.
10761        let mut body = stmt.clone();
10762        body.ctes = Vec::new();
10763        let mut temp = Engine::restore(catalog);
10764        if let Some(c) = self.clock {
10765            temp = temp.with_clock(c);
10766        }
10767        if let Some(f) = self.salt_fn {
10768            temp = temp.with_salt_fn(f);
10769        }
10770        temp.exec_select_cancel(&body, cancel)
10771    }
10772
10773    /// v4.22: materialise a WITH RECURSIVE CTE. The body must be a
10774    /// UNION (or UNION ALL) of an anchor that does not reference
10775    /// the CTE name, and one or more recursive terms that do. The
10776    /// anchor runs first; each subsequent iteration runs the
10777    /// recursive term against a temp catalog where the CTE name is
10778    /// bound to the *previous* iteration's output. Iteration stops
10779    /// when the recursive term yields no rows; UNION (DISTINCT)
10780    /// deduplicates against the accumulated result, UNION ALL does
10781    /// not. A hard cap on total rows prevents runaway queries.
10782    #[allow(clippy::too_many_lines)]
10783    fn materialise_recursive_cte(
10784        &self,
10785        cte: &spg_sql::ast::Cte,
10786        base_catalog: &Catalog,
10787        cancel: CancelToken<'_>,
10788    ) -> Result<(Vec<ColumnSchema>, Vec<Row>), EngineError> {
10789        const MAX_TOTAL_ROWS: usize = 1_000_000;
10790        const MAX_ITERATIONS: usize = 100_000;
10791        cancel.check()?;
10792        if cte.body.unions.is_empty() {
10793            return Err(EngineError::Unsupported(alloc::format!(
10794                "WITH RECURSIVE {:?} body must be a UNION of an anchor and a recursive term",
10795                cte.name
10796            )));
10797        }
10798        // Anchor: the body's leading SELECT, with unions stripped.
10799        let mut anchor = cte.body.clone();
10800        let union_terms = core::mem::take(&mut anchor.unions);
10801        anchor.ctes = Vec::new();
10802        // Anchor must not reference the CTE name.
10803        if select_refers_to(&anchor, &cte.name) {
10804            return Err(EngineError::Unsupported(alloc::format!(
10805                "WITH RECURSIVE {:?}: the anchor must not reference the CTE itself",
10806                cte.name
10807            )));
10808        }
10809        let anchor_result = self.exec_select_cancel(&anchor, cancel)?;
10810        let QueryResult::Rows {
10811            columns: anchor_cols,
10812            rows: anchor_rows,
10813        } = anchor_result
10814        else {
10815            return Err(EngineError::Unsupported(alloc::format!(
10816                "WITH RECURSIVE {:?}: anchor did not return rows",
10817                cte.name
10818            )));
10819        };
10820        // The projection builder labels non-column expressions Text;
10821        // refine column types from the anchor's actual values so the
10822        // intermediate iter-catalog tables accept them.
10823        let mut columns = infer_column_types(&anchor_cols, &anchor_rows);
10824        if !cte.column_overrides.is_empty() {
10825            if cte.column_overrides.len() != columns.len() {
10826                return Err(EngineError::Unsupported(alloc::format!(
10827                    "CTE {:?} column list has {} names but anchor returns {} columns",
10828                    cte.name,
10829                    cte.column_overrides.len(),
10830                    columns.len()
10831                )));
10832            }
10833            for (col, name) in columns.iter_mut().zip(cte.column_overrides.iter()) {
10834                col.name.clone_from(name);
10835            }
10836        }
10837        let mut all_rows: Vec<Row> = anchor_rows.clone();
10838        let mut working_set: Vec<Row> = anchor_rows;
10839        let mut seen: alloc::collections::BTreeSet<Vec<u8>> = alloc::collections::BTreeSet::new();
10840        // Track at least one "all UNION ALL" flag — if every union
10841        // kind is ALL we skip the dedup step (faster + matches PG).
10842        let all_union_all = union_terms.iter().all(|(k, _)| matches!(k, UnionKind::All));
10843        if !all_union_all {
10844            for r in &all_rows {
10845                seen.insert(encode_row_key(r));
10846            }
10847        }
10848        for iter in 0..MAX_ITERATIONS {
10849            cancel.check()?;
10850            if working_set.is_empty() {
10851                break;
10852            }
10853            // Build a fresh catalog: base + CTE bound to working_set.
10854            let mut iter_catalog = base_catalog.clone();
10855            let schema = TableSchema::new(cte.name.clone(), columns.clone());
10856            iter_catalog
10857                .create_table(schema)
10858                .map_err(EngineError::Storage)?;
10859            {
10860                let table = iter_catalog.get_mut(&cte.name).expect("just-created");
10861                for row in &working_set {
10862                    table.insert(row.clone()).map_err(EngineError::Storage)?;
10863                }
10864            }
10865            let mut iter_engine = Engine::restore(iter_catalog);
10866            if let Some(c) = self.clock {
10867                iter_engine = iter_engine.with_clock(c);
10868            }
10869            if let Some(f) = self.salt_fn {
10870                iter_engine = iter_engine.with_salt_fn(f);
10871            }
10872            // Run each recursive term in sequence and collect new rows.
10873            let mut next_set: Vec<Row> = Vec::new();
10874            for (_, term) in &union_terms {
10875                let mut term = term.clone();
10876                term.ctes = Vec::new();
10877                let r = iter_engine.exec_select_cancel(&term, cancel)?;
10878                let QueryResult::Rows {
10879                    columns: rc,
10880                    rows: rs,
10881                } = r
10882                else {
10883                    return Err(EngineError::Unsupported(alloc::format!(
10884                        "WITH RECURSIVE {:?}: recursive term did not return rows",
10885                        cte.name
10886                    )));
10887                };
10888                if rc.len() != columns.len() {
10889                    return Err(EngineError::Unsupported(alloc::format!(
10890                        "WITH RECURSIVE {:?}: column count of recursive term ({}) does not match anchor ({})",
10891                        cte.name,
10892                        rc.len(),
10893                        columns.len()
10894                    )));
10895                }
10896                for row in rs {
10897                    if !all_union_all {
10898                        let key = encode_row_key(&row);
10899                        if !seen.insert(key) {
10900                            continue;
10901                        }
10902                    }
10903                    next_set.push(row);
10904                }
10905            }
10906            if next_set.is_empty() {
10907                break;
10908            }
10909            all_rows.extend(next_set.iter().cloned());
10910            working_set = next_set;
10911            if all_rows.len() > MAX_TOTAL_ROWS {
10912                return Err(EngineError::Unsupported(alloc::format!(
10913                    "WITH RECURSIVE {:?}: produced more than {MAX_TOTAL_ROWS} rows — likely runaway recursion",
10914                    cte.name
10915                )));
10916            }
10917            if iter + 1 == MAX_ITERATIONS {
10918                return Err(EngineError::Unsupported(alloc::format!(
10919                    "WITH RECURSIVE {:?}: exceeded {MAX_ITERATIONS} iterations",
10920                    cte.name
10921                )));
10922            }
10923        }
10924        Ok((columns, all_rows))
10925    }
10926
10927    fn resolve_select_subqueries(
10928        &self,
10929        stmt: &mut SelectStatement,
10930        cancel: CancelToken<'_>,
10931    ) -> Result<(), EngineError> {
10932        for item in &mut stmt.items {
10933            if let SelectItem::Expr { expr, .. } = item {
10934                self.resolve_expr_subqueries(expr, cancel)?;
10935            }
10936        }
10937        if let Some(w) = &mut stmt.where_ {
10938            self.resolve_expr_subqueries(w, cancel)?;
10939        }
10940        // v7.24.1 — JOIN ON conditions can carry subqueries too;
10941        // they were never walked, so even an UNCORRELATED subquery
10942        // in ON hit "subquery reached row eval".
10943        if let Some(from) = &mut stmt.from {
10944            for j in &mut from.joins {
10945                if let Some(on) = &mut j.on {
10946                    self.resolve_expr_subqueries(on, cancel)?;
10947                }
10948            }
10949        }
10950        if let Some(gs) = &mut stmt.group_by {
10951            for g in gs {
10952                self.resolve_expr_subqueries(g, cancel)?;
10953            }
10954        }
10955        if let Some(h) = &mut stmt.having {
10956            self.resolve_expr_subqueries(h, cancel)?;
10957        }
10958        for o in &mut stmt.order_by {
10959            self.resolve_expr_subqueries(&mut o.expr, cancel)?;
10960        }
10961        for (_, peer) in &mut stmt.unions {
10962            self.resolve_select_subqueries(peer, cancel)?;
10963        }
10964        Ok(())
10965    }
10966
10967    #[allow(clippy::only_used_in_recursion)] // engine handle reads aren't really pure
10968    fn resolve_expr_subqueries(
10969        &self,
10970        e: &mut Expr,
10971        cancel: CancelToken<'_>,
10972    ) -> Result<(), EngineError> {
10973        // Replace-on-this-node cases first.
10974        if let Some(replacement) = self.subquery_replacement(e, cancel)? {
10975            *e = replacement;
10976            return Ok(());
10977        }
10978        match e {
10979            Expr::AggregateOrdered { call, order_by, .. } => {
10980                self.resolve_expr_subqueries(call, cancel)?;
10981                for o in order_by.iter_mut() {
10982                    self.resolve_expr_subqueries(&mut o.expr, cancel)?;
10983                }
10984            }
10985            Expr::Binary { lhs, rhs, .. } => {
10986                self.resolve_expr_subqueries(lhs, cancel)?;
10987                self.resolve_expr_subqueries(rhs, cancel)?;
10988            }
10989            Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
10990                self.resolve_expr_subqueries(expr, cancel)?;
10991            }
10992            Expr::FunctionCall { args, .. } => {
10993                for a in args {
10994                    self.resolve_expr_subqueries(a, cancel)?;
10995                }
10996            }
10997            Expr::Like { expr, pattern, .. } => {
10998                self.resolve_expr_subqueries(expr, cancel)?;
10999                self.resolve_expr_subqueries(pattern, cancel)?;
11000            }
11001            Expr::Extract { source, .. } => self.resolve_expr_subqueries(source, cancel)?,
11002            // v4.12 window functions — recurse into args + ORDER BY
11003            // + PARTITION BY in case they carry inner subqueries.
11004            Expr::WindowFunction {
11005                args,
11006                partition_by,
11007                order_by,
11008                ..
11009            } => {
11010                for a in args {
11011                    self.resolve_expr_subqueries(a, cancel)?;
11012                }
11013                for p in partition_by {
11014                    self.resolve_expr_subqueries(p, cancel)?;
11015                }
11016                for (e, _, _) in order_by {
11017                    self.resolve_expr_subqueries(e, cancel)?;
11018                }
11019            }
11020            // Subquery nodes are handled in subquery_replacement
11021            // (which returned None — defensive no-op); Literal /
11022            // Column are leaves.
11023            Expr::ScalarSubquery(_)
11024            | Expr::Exists { .. }
11025            | Expr::InSubquery { .. }
11026            | Expr::Literal(_)
11027            | Expr::Placeholder(_)
11028            | Expr::Column(_) => {}
11029            // v7.10.10 — recurse children.
11030            Expr::Array(items) => {
11031                for elem in items {
11032                    self.resolve_expr_subqueries(elem, cancel)?;
11033                }
11034            }
11035            Expr::ArraySubscript { target, index } => {
11036                self.resolve_expr_subqueries(target, cancel)?;
11037                self.resolve_expr_subqueries(index, cancel)?;
11038            }
11039            Expr::AnyAll { expr, array, .. } => {
11040                self.resolve_expr_subqueries(expr, cancel)?;
11041                self.resolve_expr_subqueries(array, cancel)?;
11042            }
11043            Expr::Case {
11044                operand,
11045                branches,
11046                else_branch,
11047            } => {
11048                if let Some(o) = operand {
11049                    self.resolve_expr_subqueries(o, cancel)?;
11050                }
11051                for (w, t) in branches {
11052                    self.resolve_expr_subqueries(w, cancel)?;
11053                    self.resolve_expr_subqueries(t, cancel)?;
11054                }
11055                if let Some(e) = else_branch {
11056                    self.resolve_expr_subqueries(e, cancel)?;
11057                }
11058            }
11059        }
11060        Ok(())
11061    }
11062
11063    /// v4.23: per-row eval that handles correlated subqueries.
11064    /// Equivalent to `eval::eval_expr` when the expression has no
11065    /// subqueries; otherwise clones the expression, substitutes
11066    /// outer-row columns into each surviving subquery node, runs
11067    /// the inner SELECT, and replaces the node with the literal
11068    /// result. Only the WHERE-filter call sites use this path so
11069    /// the uncorrelated fast path is preserved everywhere else.
11070    fn eval_expr_with_correlated(
11071        &self,
11072        expr: &Expr,
11073        row: &Row,
11074        ctx: &EvalContext<'_>,
11075        cancel: CancelToken<'_>,
11076        memo: Option<&mut memoize::MemoizeCache>,
11077    ) -> Result<Value, EngineError> {
11078        if !expr_has_subquery(expr) {
11079            return eval::eval_expr(expr, row, ctx).map_err(EngineError::Eval);
11080        }
11081        let mut e = expr.clone();
11082        self.resolve_correlated_in_expr(&mut e, row, ctx, cancel, memo)?;
11083        eval::eval_expr(&e, row, ctx).map_err(EngineError::Eval)
11084    }
11085
11086    fn resolve_correlated_in_expr(
11087        &self,
11088        e: &mut Expr,
11089        row: &Row,
11090        ctx: &EvalContext<'_>,
11091        cancel: CancelToken<'_>,
11092        mut memo: Option<&mut memoize::MemoizeCache>,
11093    ) -> Result<(), EngineError> {
11094        match e {
11095            Expr::AggregateOrdered { call, order_by, .. } => {
11096                self.resolve_correlated_in_expr(call, row, ctx, cancel, memo.as_deref_mut())?;
11097                for o in order_by.iter_mut() {
11098                    self.resolve_correlated_in_expr(
11099                        &mut o.expr,
11100                        row,
11101                        ctx,
11102                        cancel,
11103                        memo.as_deref_mut(),
11104                    )?;
11105                }
11106            }
11107            Expr::ScalarSubquery(inner) => {
11108                // v6.2.6 — Memoize: build the cache key from the
11109                // pre-substitution subquery repr + the outer row's
11110                // values. Two outer rows with identical correlated
11111                // values hit the same entry.
11112                let cache_key = memo.as_ref().map(|_| memoize::CacheKey {
11113                    subquery_repr: alloc::format!("{}", **inner),
11114                    outer_values: row.values.clone(),
11115                });
11116                if let (Some(cache), Some(k)) = (memo.as_deref_mut(), cache_key.as_ref())
11117                    && let Some(cached) = cache.get(k)
11118                {
11119                    *e = value_to_literal_expr(cached)?;
11120                    return Ok(());
11121                }
11122                let mut s = (**inner).clone();
11123                substitute_outer_columns(&mut s, row, ctx);
11124                let r = self.exec_select_cancel(&s, cancel)?;
11125                let QueryResult::Rows { rows, .. } = r else {
11126                    return Err(EngineError::Unsupported(
11127                        "scalar subquery: inner did not return rows".into(),
11128                    ));
11129                };
11130                let value = match rows.as_slice() {
11131                    [] => Value::Null,
11132                    [r0] => r0.values.first().cloned().unwrap_or(Value::Null),
11133                    _ => {
11134                        return Err(EngineError::Unsupported(alloc::format!(
11135                            "scalar subquery returned {} rows; expected 0 or 1",
11136                            rows.len()
11137                        )));
11138                    }
11139                };
11140                if let (Some(cache), Some(k)) = (memo.as_deref_mut(), cache_key) {
11141                    cache.insert(k, value.clone());
11142                }
11143                *e = value_to_literal_expr(value)?;
11144            }
11145            Expr::Exists { subquery, negated } => {
11146                let mut s = (**subquery).clone();
11147                substitute_outer_columns(&mut s, row, ctx);
11148                let r = self.exec_select_cancel(&s, cancel)?;
11149                let exists = matches!(r, QueryResult::Rows { rows, .. } if !rows.is_empty());
11150                let bit = if *negated { !exists } else { exists };
11151                *e = Expr::Literal(Literal::Bool(bit));
11152            }
11153            Expr::InSubquery {
11154                expr: lhs,
11155                subquery,
11156                negated,
11157            } => {
11158                self.resolve_correlated_in_expr(lhs, row, ctx, cancel, memo.as_deref_mut())?;
11159                let lhs_val = eval::eval_expr(lhs, row, ctx).map_err(EngineError::Eval)?;
11160                let mut s = (**subquery).clone();
11161                substitute_outer_columns(&mut s, row, ctx);
11162                let r = self.exec_select_cancel(&s, cancel)?;
11163                let QueryResult::Rows { columns, rows, .. } = r else {
11164                    return Err(EngineError::Unsupported(
11165                        "IN-subquery: inner did not return rows".into(),
11166                    ));
11167                };
11168                if columns.len() != 1 {
11169                    return Err(EngineError::Unsupported(alloc::format!(
11170                        "IN-subquery must project exactly one column; got {}",
11171                        columns.len()
11172                    )));
11173                }
11174                let mut found = false;
11175                let mut any_null = false;
11176                for r0 in rows {
11177                    let v = r0.values.into_iter().next().unwrap_or(Value::Null);
11178                    if v.is_null() {
11179                        any_null = true;
11180                        continue;
11181                    }
11182                    if value_cmp(&v, &lhs_val) == core::cmp::Ordering::Equal {
11183                        found = true;
11184                        break;
11185                    }
11186                }
11187                let bit = if found {
11188                    !*negated
11189                } else if any_null {
11190                    return Err(EngineError::Unsupported(
11191                        "IN-subquery with NULL in result and no match: NULL semantics not yet implemented".into(),
11192                    ));
11193                } else {
11194                    *negated
11195                };
11196                *e = Expr::Literal(Literal::Bool(bit));
11197            }
11198            Expr::Binary { lhs, rhs, .. } => {
11199                self.resolve_correlated_in_expr(lhs, row, ctx, cancel, memo.as_deref_mut())?;
11200                self.resolve_correlated_in_expr(rhs, row, ctx, cancel, memo.as_deref_mut())?;
11201            }
11202            Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
11203                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
11204            }
11205            Expr::Like { expr, pattern, .. } => {
11206                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
11207                self.resolve_correlated_in_expr(pattern, row, ctx, cancel, memo.as_deref_mut())?;
11208            }
11209            Expr::FunctionCall { args, .. } => {
11210                for a in args {
11211                    self.resolve_correlated_in_expr(a, row, ctx, cancel, memo.as_deref_mut())?;
11212                }
11213            }
11214            Expr::Extract { source, .. } => {
11215                self.resolve_correlated_in_expr(source, row, ctx, cancel, memo.as_deref_mut())?;
11216            }
11217            Expr::WindowFunction { .. }
11218            | Expr::Literal(_)
11219            | Expr::Placeholder(_)
11220            | Expr::Column(_) => {}
11221            // v7.10.10 — recurse children.
11222            Expr::Array(items) => {
11223                for elem in items {
11224                    self.resolve_correlated_in_expr(elem, row, ctx, cancel, memo.as_deref_mut())?;
11225                }
11226            }
11227            Expr::ArraySubscript { target, index } => {
11228                self.resolve_correlated_in_expr(target, row, ctx, cancel, memo.as_deref_mut())?;
11229                self.resolve_correlated_in_expr(index, row, ctx, cancel, memo.as_deref_mut())?;
11230            }
11231            Expr::AnyAll { expr, array, .. } => {
11232                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
11233                self.resolve_correlated_in_expr(array, row, ctx, cancel, memo.as_deref_mut())?;
11234            }
11235            Expr::Case {
11236                operand,
11237                branches,
11238                else_branch,
11239            } => {
11240                if let Some(o) = operand {
11241                    self.resolve_correlated_in_expr(o, row, ctx, cancel, memo.as_deref_mut())?;
11242                }
11243                for (w, t) in branches {
11244                    self.resolve_correlated_in_expr(w, row, ctx, cancel, memo.as_deref_mut())?;
11245                    self.resolve_correlated_in_expr(t, row, ctx, cancel, memo.as_deref_mut())?;
11246                }
11247                if let Some(e) = else_branch {
11248                    self.resolve_correlated_in_expr(e, row, ctx, cancel, memo.as_deref_mut())?;
11249                }
11250            }
11251        }
11252        Ok(())
11253    }
11254
11255    fn subquery_replacement(
11256        &self,
11257        e: &Expr,
11258        cancel: CancelToken<'_>,
11259    ) -> Result<Option<Expr>, EngineError> {
11260        match e {
11261            Expr::ScalarSubquery(inner) => {
11262                let mut s = (**inner).clone();
11263                // Recurse into the inner SELECT first so nested
11264                // subqueries materialise bottom-up.
11265                self.resolve_select_subqueries(&mut s, cancel)?;
11266                let r = match self.exec_bare_select_cancel(&s, cancel) {
11267                    Ok(r) => r,
11268                    Err(e) if is_correlation_error(&e) => return Ok(None),
11269                    Err(e) => return Err(e),
11270                };
11271                let QueryResult::Rows { rows, .. } = r else {
11272                    return Err(EngineError::Unsupported(
11273                        "scalar subquery: inner statement did not return rows".into(),
11274                    ));
11275                };
11276                let value = match rows.as_slice() {
11277                    [] => Value::Null,
11278                    [row] => row.values.first().cloned().unwrap_or(Value::Null),
11279                    _ => {
11280                        return Err(EngineError::Unsupported(alloc::format!(
11281                            "scalar subquery returned {} rows; expected 0 or 1",
11282                            rows.len()
11283                        )));
11284                    }
11285                };
11286                Ok(Some(value_to_literal_expr(value)?))
11287            }
11288            Expr::Exists { subquery, negated } => {
11289                let mut s = (**subquery).clone();
11290                self.resolve_select_subqueries(&mut s, cancel)?;
11291                let r = match self.exec_bare_select_cancel(&s, cancel) {
11292                    Ok(r) => r,
11293                    Err(e) if is_correlation_error(&e) => return Ok(None),
11294                    Err(e) => return Err(e),
11295                };
11296                let exists = match r {
11297                    QueryResult::Rows { rows, .. } => !rows.is_empty(),
11298                    QueryResult::CommandOk { .. } => false,
11299                };
11300                let bit = if *negated { !exists } else { exists };
11301                Ok(Some(Expr::Literal(Literal::Bool(bit))))
11302            }
11303            Expr::InSubquery {
11304                expr,
11305                subquery,
11306                negated,
11307            } => {
11308                let mut s = (**subquery).clone();
11309                self.resolve_select_subqueries(&mut s, cancel)?;
11310                let r = match self.exec_bare_select_cancel(&s, cancel) {
11311                    Ok(r) => r,
11312                    Err(e) if is_correlation_error(&e) => return Ok(None),
11313                    Err(e) => return Err(e),
11314                };
11315                let QueryResult::Rows { columns, rows, .. } = r else {
11316                    return Err(EngineError::Unsupported(
11317                        "IN-subquery: inner statement did not return rows".into(),
11318                    ));
11319                };
11320                if columns.len() != 1 {
11321                    return Err(EngineError::Unsupported(alloc::format!(
11322                        "IN-subquery must project exactly one column; got {}",
11323                        columns.len()
11324                    )));
11325                }
11326                // Build the same OR-Eq chain the parse-time literal-list
11327                // path constructs, with each value lifted into a Literal.
11328                let mut acc: Option<Expr> = None;
11329                for row in rows {
11330                    let v = row.values.into_iter().next().unwrap_or(Value::Null);
11331                    let lit = value_to_literal_expr(v)?;
11332                    let cmp = Expr::Binary {
11333                        lhs: expr.clone(),
11334                        op: BinOp::Eq,
11335                        rhs: Box::new(lit),
11336                    };
11337                    acc = Some(match acc {
11338                        None => cmp,
11339                        Some(prev) => Expr::Binary {
11340                            lhs: Box::new(prev),
11341                            op: BinOp::Or,
11342                            rhs: Box::new(cmp),
11343                        },
11344                    });
11345                }
11346                let combined = acc.unwrap_or(Expr::Literal(Literal::Bool(false)));
11347                let final_expr = if *negated {
11348                    Expr::Unary {
11349                        op: UnOp::Not,
11350                        expr: Box::new(combined),
11351                    }
11352                } else {
11353                    combined
11354                };
11355                Ok(Some(final_expr))
11356            }
11357            _ => Ok(None),
11358        }
11359    }
11360}
11361
11362// ---- v4.12 window-function helpers ----
11363// The (partition-key, order-key, original-index) tuple shape used
11364// across these helpers is intrinsic to the planner. Factoring it
11365// into a typedef adds indirection without making the code clearer,
11366// so several lints are allowed inline on the affected functions
11367// rather than module-wide.
11368
11369/// v4.22: cheap structural scan for `FROM <name>` (qualified or
11370/// not) inside a SELECT — used to verify the anchor of a WITH
11371/// RECURSIVE CTE doesn't recurse into itself. Conservative: walks
11372/// FROM joins, subqueries, and unions.
11373fn select_refers_to(stmt: &SelectStatement, target: &str) -> bool {
11374    if let Some(from) = &stmt.from
11375        && from_refers_to(from, target)
11376    {
11377        return true;
11378    }
11379    for (_, peer) in &stmt.unions {
11380        if select_refers_to(peer, target) {
11381            return true;
11382        }
11383    }
11384    for item in &stmt.items {
11385        if let SelectItem::Expr { expr, .. } = item
11386            && expr_refers_to(expr, target)
11387        {
11388            return true;
11389        }
11390    }
11391    if let Some(w) = &stmt.where_
11392        && expr_refers_to(w, target)
11393    {
11394        return true;
11395    }
11396    false
11397}
11398
11399fn from_refers_to(from: &FromClause, target: &str) -> bool {
11400    if from.primary.name.eq_ignore_ascii_case(target) {
11401        return true;
11402    }
11403    from.joins
11404        .iter()
11405        .any(|j| j.table.name.eq_ignore_ascii_case(target))
11406}
11407
11408/// v7.28 (round-22) — collect every QUALIFIED column referenced
11409/// anywhere in a SELECT (subquery bodies included). Returns None
11410/// when a wildcard or a bare column name makes static attribution
11411/// unsafe — callers then keep every column.
11412fn collect_qualified_refs(
11413    stmt: &SelectStatement,
11414    out: &mut alloc::collections::BTreeSet<(String, String)>,
11415) -> Option<()> {
11416    for item in &stmt.items {
11417        match item {
11418            SelectItem::Wildcard => return None,
11419            SelectItem::Expr { expr, .. } => collect_qualified_refs_expr(expr, out)?,
11420        }
11421    }
11422    if let Some(w) = &stmt.where_ {
11423        collect_qualified_refs_expr(w, out)?;
11424    }
11425    if let Some(from) = &stmt.from {
11426        for j in &from.joins {
11427            if let Some(on) = &j.on {
11428                collect_qualified_refs_expr(on, out)?;
11429            }
11430            if j.table.lateral_subquery.is_some() {
11431                return None;
11432            }
11433        }
11434    }
11435    if let Some(gs) = &stmt.group_by {
11436        for g in gs {
11437            collect_qualified_refs_expr(g, out)?;
11438        }
11439    }
11440    if let Some(h) = &stmt.having {
11441        collect_qualified_refs_expr(h, out)?;
11442    }
11443    for o in &stmt.order_by {
11444        collect_qualified_refs_expr(&o.expr, out)?;
11445    }
11446    for (_, peer) in &stmt.unions {
11447        collect_qualified_refs(peer, out)?;
11448    }
11449    for cte in &stmt.ctes {
11450        collect_qualified_refs(&cte.body, out)?;
11451    }
11452    Some(())
11453}
11454
11455fn collect_qualified_refs_expr(
11456    e: &Expr,
11457    out: &mut alloc::collections::BTreeSet<(String, String)>,
11458) -> Option<()> {
11459    // Two passes so the column and subquery visitors don't both
11460    // capture `out` mutably.
11461    let mut cols: Vec<spg_sql::ast::ColumnName> = Vec::new();
11462    let mut subs: Vec<&SelectStatement> = Vec::new();
11463    visit_expr_columns_and_subqueries(
11464        e,
11465        &mut |c: &spg_sql::ast::ColumnName| cols.push(c.clone()),
11466        &mut |sub| subs.push(sub),
11467    );
11468    for c in cols {
11469        match c.qualifier {
11470            Some(q) => {
11471                out.insert((q, c.name));
11472            }
11473            None => return None,
11474        }
11475    }
11476    for sub in subs {
11477        collect_qualified_refs(sub, out)?;
11478    }
11479    Some(())
11480}
11481
11482/// Immutable walk over an Expr visiting every Column and every
11483/// nested SelectStatement (v7.28).
11484fn visit_expr_columns_and_subqueries<'a>(
11485    e: &'a Expr,
11486    on_col: &mut impl FnMut(&'a spg_sql::ast::ColumnName),
11487    on_sub: &mut impl FnMut(&'a SelectStatement),
11488) {
11489    match e {
11490        Expr::Column(c) => on_col(c),
11491        Expr::ScalarSubquery(s) => on_sub(s),
11492        Expr::Exists { subquery, .. } => on_sub(subquery),
11493        Expr::InSubquery { expr, subquery, .. } => {
11494            visit_expr_columns_and_subqueries(expr, on_col, on_sub);
11495            on_sub(subquery);
11496        }
11497        Expr::Binary { lhs, rhs, .. } => {
11498            visit_expr_columns_and_subqueries(lhs, on_col, on_sub);
11499            visit_expr_columns_and_subqueries(rhs, on_col, on_sub);
11500        }
11501        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
11502            visit_expr_columns_and_subqueries(expr, on_col, on_sub);
11503        }
11504        Expr::Like { expr, pattern, .. } => {
11505            visit_expr_columns_and_subqueries(expr, on_col, on_sub);
11506            visit_expr_columns_and_subqueries(pattern, on_col, on_sub);
11507        }
11508        Expr::FunctionCall { args, .. } => {
11509            for a in args {
11510                visit_expr_columns_and_subqueries(a, on_col, on_sub);
11511            }
11512        }
11513        Expr::AggregateOrdered { call, order_by, .. } => {
11514            visit_expr_columns_and_subqueries(call, on_col, on_sub);
11515            for o in order_by {
11516                visit_expr_columns_and_subqueries(&o.expr, on_col, on_sub);
11517            }
11518        }
11519        Expr::Case {
11520            operand,
11521            branches,
11522            else_branch,
11523        } => {
11524            if let Some(op) = operand {
11525                visit_expr_columns_and_subqueries(op, on_col, on_sub);
11526            }
11527            for (w, t) in branches {
11528                visit_expr_columns_and_subqueries(w, on_col, on_sub);
11529                visit_expr_columns_and_subqueries(t, on_col, on_sub);
11530            }
11531            if let Some(eb) = else_branch {
11532                visit_expr_columns_and_subqueries(eb, on_col, on_sub);
11533            }
11534        }
11535        Expr::ArraySubscript { target, index } => {
11536            visit_expr_columns_and_subqueries(target, on_col, on_sub);
11537            visit_expr_columns_and_subqueries(index, on_col, on_sub);
11538        }
11539        Expr::Literal(_) | Expr::Placeholder(_) => {}
11540        // Exotic nodes (window etc.) — visit nothing extra; their
11541        // columns are caught when the caller bails on bare names
11542        // elsewhere, and window queries skip pruning entirely at
11543        // the call sites.
11544        _ => {
11545            // Exotic node (window function etc.): report an
11546            // unattributable marker so callers disable pruning.
11547            static BAIL: spg_sql::ast::ColumnName = spg_sql::ast::ColumnName {
11548                qualifier: None,
11549                name: String::new(),
11550            };
11551            on_col(&BAIL);
11552        }
11553    }
11554}
11555
11556/// v7.28 (round-22) — collect every Column qualifier in an expr;
11557/// `all_qualified` flips false on any bare column (those can't be
11558/// attributed to one table safely, so the pushdown skips them).
11559fn collect_column_qualifiers<'e>(e: &'e Expr, out: &mut Vec<&'e str>, all_qualified: &mut bool) {
11560    if let Expr::Column(c) = e {
11561        match &c.qualifier {
11562            Some(q) => out.push(q.as_str()),
11563            None => *all_qualified = false,
11564        }
11565        return;
11566    }
11567    // Reuse the canonical immutable walk via describe's walker shape:
11568    // recurse the common containers.
11569    match e {
11570        Expr::Binary { lhs, rhs, .. } => {
11571            collect_column_qualifiers(lhs, out, all_qualified);
11572            collect_column_qualifiers(rhs, out, all_qualified);
11573        }
11574        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
11575            collect_column_qualifiers(expr, out, all_qualified);
11576        }
11577        Expr::Like { expr, pattern, .. } => {
11578            collect_column_qualifiers(expr, out, all_qualified);
11579            collect_column_qualifiers(pattern, out, all_qualified);
11580        }
11581        Expr::FunctionCall { args, .. } => {
11582            for a in args {
11583                collect_column_qualifiers(a, out, all_qualified);
11584            }
11585        }
11586        Expr::Literal(_) | Expr::Placeholder(_) => {}
11587        // Anything exotic (CASE, subquery, window, arrays…):
11588        // conservatively mark unattributable.
11589        _ => *all_qualified = false,
11590    }
11591}
11592
11593fn expr_refers_to(e: &Expr, target: &str) -> bool {
11594    match e {
11595        Expr::AggregateOrdered { call, order_by, .. } => {
11596            expr_refers_to(call, target) || order_by.iter().any(|o| expr_refers_to(&o.expr, target))
11597        }
11598        Expr::ScalarSubquery(s) => select_refers_to(s, target),
11599        Expr::Exists { subquery, .. } | Expr::InSubquery { subquery, .. } => {
11600            select_refers_to(subquery, target)
11601        }
11602        Expr::Binary { lhs, rhs, .. } => expr_refers_to(lhs, target) || expr_refers_to(rhs, target),
11603        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
11604            expr_refers_to(expr, target)
11605        }
11606        Expr::Like { expr, pattern, .. } => {
11607            expr_refers_to(expr, target) || expr_refers_to(pattern, target)
11608        }
11609        Expr::FunctionCall { args, .. } => args.iter().any(|a| expr_refers_to(a, target)),
11610        Expr::Extract { source, .. } => expr_refers_to(source, target),
11611        Expr::WindowFunction {
11612            args,
11613            partition_by,
11614            order_by,
11615            ..
11616        } => {
11617            args.iter().any(|a| expr_refers_to(a, target))
11618                || partition_by.iter().any(|p| expr_refers_to(p, target))
11619                || order_by.iter().any(|(o, _, _)| expr_refers_to(o, target))
11620        }
11621        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => false,
11622        Expr::Array(items) => items.iter().any(|e| expr_refers_to(e, target)),
11623        Expr::ArraySubscript { target: t, index } => {
11624            expr_refers_to(t, target) || expr_refers_to(index, target)
11625        }
11626        Expr::AnyAll { expr, array, .. } => {
11627            expr_refers_to(expr, target) || expr_refers_to(array, target)
11628        }
11629        Expr::Case {
11630            operand,
11631            branches,
11632            else_branch,
11633        } => {
11634            operand
11635                .as_deref()
11636                .is_some_and(|o| expr_refers_to(o, target))
11637                || branches
11638                    .iter()
11639                    .any(|(w, t)| expr_refers_to(w, target) || expr_refers_to(t, target))
11640                || else_branch
11641                    .as_deref()
11642                    .is_some_and(|e| expr_refers_to(e, target))
11643        }
11644    }
11645}
11646
11647/// v4.22: pick more specific column types from observed rows when
11648/// the projection builder defaulted to Text (the v1.x behavior for
11649/// non-column expressions). Lets `WITH t(n) AS (SELECT 1 ...)`
11650/// land an Int column in the CTE storage table rather than failing
11651/// the insert with "expected TEXT, got INT".
11652/// v7.16.2 — map an SPG [`DataType`] to the PG-canonical
11653/// `information_schema.columns.data_type` text. Covers the
11654/// values mailrs's migrations probe (`'ARRAY'`, `'integer'`,
11655/// `'text'`, …). Unknown variants fall back to the SPG name
11656/// downcased — better than panicking on a future DataType.
11657fn pg_data_type_text(ty: DataType) -> alloc::string::String {
11658    let s = match ty {
11659        DataType::Int => "integer",
11660        DataType::BigInt => "bigint",
11661        DataType::SmallInt => "smallint",
11662        DataType::Float => "double precision",
11663        DataType::Bool => "boolean",
11664        DataType::Text => "text",
11665        DataType::Varchar(_) => "character varying",
11666        DataType::Date => "date",
11667        DataType::Timestamp => "timestamp without time zone",
11668        DataType::Timestamptz => "timestamp with time zone",
11669        DataType::Json => "jsonb",
11670        DataType::Bytes => "bytea",
11671        DataType::TextArray | DataType::IntArray | DataType::BigIntArray => "ARRAY",
11672        DataType::TsVector => "tsvector",
11673        DataType::TsQuery => "tsquery",
11674        DataType::Vector { .. } => "USER-DEFINED",
11675        // Non-exhaustive — fall back to "USER-DEFINED" the way
11676        // PG labels any pg_type it doesn't recognise.
11677        _ => "USER-DEFINED",
11678    };
11679    alloc::string::String::from(s)
11680}
11681
11682/// v7.16.2 — synthesise `information_schema.columns`. mailrs
11683/// queries are of shape `SELECT 1 FROM information_schema.columns
11684/// WHERE table_name = … AND column_name = … AND data_type = …` —
11685/// the v7.16.2 view returns the columns mailrs probes; broader
11686/// PG-spec parity (ordinal_position, is_nullable, character_
11687/// maximum_length, udt_name, …) lands as needed.
11688fn synth_information_schema_columns(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11689    let schema = alloc::vec![
11690        ColumnSchema::new("table_catalog", DataType::Text, false),
11691        ColumnSchema::new("table_schema", DataType::Text, false),
11692        ColumnSchema::new("table_name", DataType::Text, false),
11693        ColumnSchema::new("column_name", DataType::Text, false),
11694        ColumnSchema::new("ordinal_position", DataType::Int, false),
11695        ColumnSchema::new("is_nullable", DataType::Text, false),
11696        ColumnSchema::new("data_type", DataType::Text, false),
11697    ];
11698    let mut rows: Vec<Row> = Vec::new();
11699    for tname in cat.table_names() {
11700        let Some(t) = cat.get(&tname) else { continue };
11701        for (i, col) in t.schema().columns.iter().enumerate() {
11702            #[allow(clippy::cast_possible_wrap)]
11703            let ordinal = (i + 1) as i32;
11704            rows.push(Row::new(alloc::vec![
11705                Value::Text("spg".into()),
11706                Value::Text("public".into()),
11707                Value::Text(tname.clone()),
11708                Value::Text(col.name.clone()),
11709                Value::Int(ordinal),
11710                Value::Text(if col.nullable {
11711                    "YES".into()
11712                } else {
11713                    "NO".into()
11714                }),
11715                Value::Text(pg_data_type_text(col.ty)),
11716            ]));
11717        }
11718    }
11719    (schema, rows)
11720}
11721
11722/// v7.16.2 — synthesise `information_schema.tables`.
11723fn synth_information_schema_tables(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11724    let schema = alloc::vec![
11725        ColumnSchema::new("table_catalog", DataType::Text, false),
11726        ColumnSchema::new("table_schema", DataType::Text, false),
11727        ColumnSchema::new("table_name", DataType::Text, false),
11728        ColumnSchema::new("table_type", DataType::Text, false),
11729    ];
11730    let mut rows: Vec<Row> = Vec::new();
11731    for tname in cat.table_names() {
11732        rows.push(Row::new(alloc::vec![
11733            Value::Text("spg".into()),
11734            Value::Text("public".into()),
11735            Value::Text(tname.clone()),
11736            Value::Text("BASE TABLE".into()),
11737        ]));
11738    }
11739    (schema, rows)
11740}
11741
11742/// v7.16.2 — synthesise `pg_catalog.pg_class`. Minimum shape
11743/// for psql `\d` / ORM probes: `relname` + `relkind`. Each
11744/// user table emits one row.
11745fn synth_pg_class(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11746    let schema = alloc::vec![
11747        ColumnSchema::new("relname", DataType::Text, false),
11748        ColumnSchema::new("relkind", DataType::Text, false),
11749        ColumnSchema::new("relnamespace", DataType::BigInt, false),
11750    ];
11751    let mut rows: Vec<Row> = Vec::new();
11752    for tname in cat.table_names() {
11753        rows.push(Row::new(alloc::vec![
11754            Value::Text(tname.clone()),
11755            Value::Text("r".into()),
11756            Value::BigInt(2200), // PG's `public` namespace OID
11757        ]));
11758    }
11759    (schema, rows)
11760}
11761
11762/// v7.16.2 — synthesise `pg_catalog.pg_attribute`. Minimum
11763/// shape: `attrelid` (text — SPG has no OID), `attname`,
11764/// `attnum`, `atttypid` (text), `attnotnull`.
11765fn synth_pg_attribute(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11766    let schema = alloc::vec![
11767        ColumnSchema::new("attrelid", DataType::Text, false),
11768        ColumnSchema::new("attname", DataType::Text, false),
11769        ColumnSchema::new("attnum", DataType::Int, false),
11770        ColumnSchema::new("atttypid", DataType::Text, false),
11771        ColumnSchema::new("attnotnull", DataType::Bool, false),
11772    ];
11773    let mut rows: Vec<Row> = Vec::new();
11774    for tname in cat.table_names() {
11775        let Some(t) = cat.get(&tname) else { continue };
11776        for (i, col) in t.schema().columns.iter().enumerate() {
11777            #[allow(clippy::cast_possible_wrap)]
11778            let ordinal = (i + 1) as i32;
11779            rows.push(Row::new(alloc::vec![
11780                Value::Text(tname.clone()),
11781                Value::Text(col.name.clone()),
11782                Value::Int(ordinal),
11783                Value::Text(pg_data_type_text(col.ty)),
11784                Value::Bool(!col.nullable),
11785            ]));
11786        }
11787    }
11788    (schema, rows)
11789}
11790
11791/// v7.17.0 Phase 3.P0-50 — synthesise `pg_catalog.pg_type`. The
11792/// returned rows cover every built-in scalar / array type sqlx,
11793/// SQLAlchemy, Diesel and pgAdmin look up at compile / connect
11794/// time. PG-canonical schema columns we expose:
11795///   * oid           — type OID (the lookup key sqlx uses)
11796///   * typname       — canonical type name (`int4`, `text`, …)
11797///   * typlen        — width in bytes (-1 for var-length)
11798///   * typtype       — `b`ase / `c`omposite / `e`num / etc.
11799///   * typcategory   — PG type category single-char
11800///   * typelem       — element OID for arrays (0 otherwise)
11801///   * typarray      — array-type OID (0 if no array type)
11802///   * typnamespace  — schema OID (always `public` = 2200)
11803///
11804/// Other pg_type columns (typowner, typinput/typoutput, etc.)
11805/// land in follow-up work — sqlx encoders don't query them at
11806/// connect time.
11807fn synth_pg_type(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11808    let schema = alloc::vec![
11809        ColumnSchema::new("oid", DataType::BigInt, false),
11810        ColumnSchema::new("typname", DataType::Text, false),
11811        ColumnSchema::new("typlen", DataType::SmallInt, false),
11812        ColumnSchema::new("typtype", DataType::Text, false),
11813        ColumnSchema::new("typcategory", DataType::Text, false),
11814        ColumnSchema::new("typelem", DataType::BigInt, false),
11815        ColumnSchema::new("typarray", DataType::BigInt, false),
11816        ColumnSchema::new("typnamespace", DataType::BigInt, false),
11817    ];
11818    // (oid, name, len, type, cat, elem, array_oid). PG OID
11819    // numbers come straight from `pg_type.dat`.
11820    let scalars: &[(i64, &str, i16, &str, &str, i64, i64)] = &[
11821        // bool
11822        (16, "bool", 1, "b", "B", 0, 1000),
11823        (17, "bytea", -1, "b", "U", 0, 1001),
11824        (18, "char", 1, "b", "S", 0, 1002),
11825        (19, "name", 64, "b", "S", 0, 1003),
11826        (20, "int8", 8, "b", "N", 0, 1016),
11827        (21, "int2", 2, "b", "N", 0, 1005),
11828        (23, "int4", 4, "b", "N", 0, 1007),
11829        (24, "regproc", 4, "b", "N", 0, 1008),
11830        (25, "text", -1, "b", "S", 0, 1009),
11831        (26, "oid", 4, "b", "N", 0, 1028),
11832        (114, "json", -1, "b", "U", 0, 199),
11833        (142, "xml", -1, "b", "U", 0, 143),
11834        (700, "float4", 4, "b", "N", 0, 1021),
11835        (701, "float8", 8, "b", "N", 0, 1022),
11836        (650, "cidr", -1, "b", "I", 0, 651),
11837        (869, "inet", -1, "b", "I", 0, 1041),
11838        (829, "macaddr", 6, "b", "U", 0, 1040),
11839        (1042, "bpchar", -1, "b", "S", 0, 1014),
11840        (1043, "varchar", -1, "b", "S", 0, 1015),
11841        (1082, "date", 4, "b", "D", 0, 1182),
11842        (1083, "time", 8, "b", "D", 0, 1183),
11843        (1114, "timestamp", 8, "b", "D", 0, 1115),
11844        (1184, "timestamptz", 8, "b", "D", 0, 1185),
11845        (1186, "interval", 16, "b", "T", 0, 1187),
11846        (1266, "timetz", 12, "b", "D", 0, 1270),
11847        (1700, "numeric", -1, "b", "N", 0, 1231),
11848        (790, "money", 8, "b", "N", 0, 791),
11849        (2950, "uuid", 16, "b", "U", 0, 2951),
11850        (3802, "jsonb", -1, "b", "U", 0, 3807),
11851        (3614, "tsvector", -1, "b", "U", 0, 3643),
11852        (3615, "tsquery", -1, "b", "U", 0, 3645),
11853        // hstore + range types — typcategory 'U' (user) / 'R' (range).
11854        (3908, "tstzrange", -1, "r", "R", 0, 3909),
11855        (3910, "tsrange", -1, "r", "R", 0, 3911),
11856        (3904, "int4range", -1, "r", "R", 0, 3905),
11857        (3926, "int8range", -1, "r", "R", 0, 3927),
11858        (3906, "numrange", -1, "r", "R", 0, 3907),
11859        (3912, "daterange", -1, "r", "R", 0, 3913),
11860    ];
11861    // Array companion types share the typelem / typcategory='A'.
11862    // We emit just the array OIDs the scalars reference.
11863    let arrays: &[(i64, &str, i64)] = &[
11864        (1000, "_bool", 16),
11865        (1001, "_bytea", 17),
11866        (1002, "_char", 18),
11867        (1003, "_name", 19),
11868        (1016, "_int8", 20),
11869        (1005, "_int2", 21),
11870        (1007, "_int4", 23),
11871        (1008, "_regproc", 24),
11872        (1009, "_text", 25),
11873        (1028, "_oid", 26),
11874        (199, "_json", 114),
11875        (143, "_xml", 142),
11876        (1021, "_float4", 700),
11877        (1022, "_float8", 701),
11878        (651, "_cidr", 650),
11879        (1041, "_inet", 869),
11880        (1040, "_macaddr", 829),
11881        (1014, "_bpchar", 1042),
11882        (1015, "_varchar", 1043),
11883        (1182, "_date", 1082),
11884        (1183, "_time", 1083),
11885        (1115, "_timestamp", 1114),
11886        (1185, "_timestamptz", 1184),
11887        (1187, "_interval", 1186),
11888        (1270, "_timetz", 1266),
11889        (1231, "_numeric", 1700),
11890        (791, "_money", 790),
11891        (2951, "_uuid", 2950),
11892        (3807, "_jsonb", 3802),
11893        (3643, "_tsvector", 3614),
11894        (3645, "_tsquery", 3615),
11895    ];
11896    let mut rows: Vec<Row> = Vec::with_capacity(scalars.len() + arrays.len());
11897    for &(oid, name, len, ty, cat, elem, arr) in scalars {
11898        rows.push(Row::new(alloc::vec![
11899            Value::BigInt(oid),
11900            Value::Text(name.into()),
11901            Value::SmallInt(len),
11902            Value::Text(ty.into()),
11903            Value::Text(cat.into()),
11904            Value::BigInt(elem),
11905            Value::BigInt(arr),
11906            Value::BigInt(2200),
11907        ]));
11908    }
11909    for &(oid, name, elem) in arrays {
11910        rows.push(Row::new(alloc::vec![
11911            Value::BigInt(oid),
11912            Value::Text(name.into()),
11913            Value::SmallInt(-1),
11914            Value::Text("b".into()),
11915            Value::Text("A".into()),
11916            Value::BigInt(elem),
11917            Value::BigInt(0),
11918            Value::BigInt(2200),
11919        ]));
11920    }
11921    (schema, rows)
11922}
11923
11924/// v7.17.0 Phase 3.P0-51 — synthesise `pg_catalog.pg_proc`. ORM /
11925/// pgAdmin probes look up functions by name; SPG synthesises rows
11926/// for the built-in scalar functions / aggregates / window funcs
11927/// the engine actually dispatches. SPG has no user-defined
11928/// functions yet so the table is a stable static list.
11929///
11930/// Schema columns exposed:
11931///   * oid (BigInt) — function OID from PG's pg_proc.dat
11932///   * proname (Text) — function name (lowercase)
11933///   * pronamespace (BigInt) — 11 (`pg_catalog`)
11934///   * prokind (Text) — 'f' function, 'a' aggregate, 'w' window
11935///   * pronargs (SmallInt) — declared arg count (-1 for variadic)
11936///   * prorettype (BigInt) — return type OID (matches synth_pg_type)
11937/// v7.24 (round-16 D) — synthesise `pg_catalog.pg_trigger` from the
11938/// live catalog. PG-shaped core columns (tgname, tgenabled with
11939/// 'O'/'D') plus pragmatic text columns PG keeps relational
11940/// (relname, timing, events, function) so health checks don't need
11941/// oid joins.
11942fn synth_pg_trigger(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11943    let schema = alloc::vec![
11944        ColumnSchema::new("tgname", DataType::Text, false),
11945        ColumnSchema::new("relname", DataType::Text, false),
11946        ColumnSchema::new("tgenabled", DataType::Text, false),
11947        ColumnSchema::new("timing", DataType::Text, false),
11948        ColumnSchema::new("events", DataType::Text, false),
11949        ColumnSchema::new("function", DataType::Text, false),
11950    ];
11951    let rows: Vec<Row> = cat
11952        .triggers()
11953        .iter()
11954        .map(|t| {
11955            Row::new(alloc::vec![
11956                Value::Text(t.name.clone()),
11957                Value::Text(t.table.clone()),
11958                Value::Text(if t.enabled { "O".into() } else { "D".into() }),
11959                Value::Text(t.timing.clone()),
11960                Value::Text(t.events.join(" OR ")),
11961                Value::Text(t.function.clone()),
11962            ])
11963        })
11964        .collect();
11965    (schema, rows)
11966}
11967
11968fn synth_pg_proc(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11969    let schema = alloc::vec![
11970        ColumnSchema::new("oid", DataType::BigInt, false),
11971        ColumnSchema::new("proname", DataType::Text, false),
11972        ColumnSchema::new("pronamespace", DataType::BigInt, false),
11973        ColumnSchema::new("prokind", DataType::Text, false),
11974        ColumnSchema::new("pronargs", DataType::Int, false),
11975        ColumnSchema::new("prorettype", DataType::BigInt, false),
11976    ];
11977    // (oid, name, kind, nargs, rettype). OIDs taken from PG's
11978    // pg_proc.dat for the common subset.
11979    let funcs: &[(i64, &str, &str, i32, i64)] = &[
11980        // Scalar functions.
11981        (1318, "length", "f", 1, 23),
11982        (871, "upper", "f", 1, 25),
11983        (870, "lower", "f", 1, 25),
11984        (936, "substring", "f", 3, 25),
11985        (937, "substring", "f", 2, 25),
11986        (3055, "btrim", "f", 1, 25),
11987        (885, "btrim", "f", 2, 25),
11988        (3056, "ltrim", "f", 1, 25),
11989        (875, "ltrim", "f", 2, 25),
11990        (3057, "rtrim", "f", 1, 25),
11991        (876, "rtrim", "f", 2, 25),
11992        (1397, "abs", "f", 1, 23),
11993        (1396, "abs", "f", 1, 20),
11994        (1606, "round", "f", 1, 1700),
11995        (1707, "round", "f", 2, 1700),
11996        (2308, "ceil", "f", 1, 701),
11997        (2309, "ceiling", "f", 1, 701),
11998        (2310, "floor", "f", 1, 701),
11999        (1376, "sqrt", "f", 1, 701),
12000        (1369, "ln", "f", 1, 701),
12001        (1373, "exp", "f", 1, 701),
12002        (1368, "power", "f", 2, 701),
12003        (2228, "random", "f", 0, 701),
12004        // Date / time.
12005        (1299, "now", "f", 0, 1184),
12006        (1274, "current_timestamp", "f", 0, 1184),
12007        (1140, "current_date", "f", 0, 1082),
12008        (2050, "current_time", "f", 0, 1083),
12009        (1158, "date_trunc", "f", 2, 1184),
12010        (1171, "date_part", "f", 2, 701),
12011        (1172, "age", "f", 1, 1186),
12012        (936, "to_char", "f", 2, 25),
12013        // Session / introspection.
12014        (861, "current_database", "f", 0, 19),
12015        (745, "current_user", "f", 0, 19),
12016        (745, "session_user", "f", 0, 19),
12017        (1402, "current_schema", "f", 0, 19),
12018        // String concat / format.
12019        (3058, "concat", "f", -1, 25),
12020        (3059, "concat_ws", "f", -1, 25),
12021        (3539, "format", "f", -1, 25),
12022        // Type introspection.
12023        (2877, "pg_typeof", "f", 1, 2206),
12024        // JSON.
12025        (3198, "json_build_object", "f", -1, 114),
12026        (3199, "jsonb_build_object", "f", -1, 3802),
12027        (3271, "json_build_array", "f", -1, 114),
12028        (3272, "jsonb_build_array", "f", -1, 3802),
12029        // UUID.
12030        (3253, "gen_random_uuid", "f", 0, 2950),
12031        (3252, "uuid_generate_v4", "f", 0, 2950),
12032        // Aggregates.
12033        (2147, "count", "a", 0, 20),
12034        (2803, "count", "a", -1, 20),
12035        (2116, "max", "a", 1, 23),
12036        (2132, "min", "a", 1, 23),
12037        (2108, "sum", "a", 1, 20),
12038        (2100, "avg", "a", 1, 1700),
12039        (2517, "string_agg", "a", 2, 25),
12040        (2747, "array_agg", "a", 1, 1009),
12041        (2517, "bool_and", "a", 1, 16),
12042        (2518, "bool_or", "a", 1, 16),
12043        (2519, "every", "a", 1, 16),
12044        // Window functions.
12045        (3100, "row_number", "w", 0, 20),
12046        (3101, "rank", "w", 0, 20),
12047        (3102, "dense_rank", "w", 0, 20),
12048        (3103, "percent_rank", "w", 0, 701),
12049        (3104, "cume_dist", "w", 0, 701),
12050        (3105, "lag", "w", -1, 2283),
12051        (3106, "lead", "w", -1, 2283),
12052        (3107, "first_value", "w", 1, 2283),
12053        (3108, "last_value", "w", 1, 2283),
12054        (3109, "nth_value", "w", 2, 2283),
12055    ];
12056    let mut rows: Vec<Row> = Vec::with_capacity(funcs.len());
12057    for &(oid, name, kind, nargs, rettype) in funcs {
12058        rows.push(Row::new(alloc::vec![
12059            Value::BigInt(oid),
12060            Value::Text(name.into()),
12061            Value::BigInt(11),
12062            Value::Text(kind.into()),
12063            Value::Int(nargs),
12064            Value::BigInt(rettype),
12065        ]));
12066    }
12067    (schema, rows)
12068}
12069
12070/// v7.17.0 Phase 3.P0-65 — synthesise `mysql.user`. MySQL admin
12071/// queries (`SELECT user, host FROM mysql.user`) probe this at
12072/// connect time to list accounts. SPG ships one row per
12073/// UserStore entry plus a synthetic `root` superuser row for
12074/// MySQL bootstrap compat.
12075fn synth_mysql_user(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
12076    let schema = alloc::vec![
12077        ColumnSchema::new("user", DataType::Text, false),
12078        ColumnSchema::new("host", DataType::Text, false),
12079        ColumnSchema::new("select_priv", DataType::Text, false),
12080    ];
12081    let mut rows: Vec<Row> = Vec::new();
12082    rows.push(Row::new(alloc::vec![
12083        Value::Text("root".into()),
12084        Value::Text("localhost".into()),
12085        Value::Text("Y".into()),
12086    ]));
12087    for (name, _) in engine.users.iter() {
12088        if name != "root" {
12089            rows.push(Row::new(alloc::vec![
12090                Value::Text(name.to_string()),
12091                Value::Text("%".into()),
12092                Value::Text("Y".into()),
12093            ]));
12094        }
12095    }
12096    (schema, rows)
12097}
12098
12099/// v7.17.0 Phase 3.P0-65 — synthesise `mysql.db`. The
12100/// per-database privileges table. SPG is single-database so the
12101/// table surfaces one row per declared user with full privileges
12102/// on the canonical `postgres` database.
12103fn synth_mysql_db() -> (Vec<ColumnSchema>, Vec<Row>) {
12104    let schema = alloc::vec![
12105        ColumnSchema::new("host", DataType::Text, false),
12106        ColumnSchema::new("db", DataType::Text, false),
12107        ColumnSchema::new("user", DataType::Text, false),
12108        ColumnSchema::new("select_priv", DataType::Text, false),
12109    ];
12110    let rows = alloc::vec![Row::new(alloc::vec![
12111        Value::Text("localhost".into()),
12112        Value::Text("postgres".into()),
12113        Value::Text("root".into()),
12114        Value::Text("Y".into()),
12115    ])];
12116    (schema, rows)
12117}
12118
12119/// v7.17.0 Phase 3.P0-63 — synthesise
12120/// `information_schema.KEY_COLUMN_USAGE`. ORM migration tools
12121/// (Alembic, Sequelize, TypeORM) walk this view to discover FK
12122/// relationships in MySQL-flavoured introspection queries.
12123///
12124/// Schema columns exposed:
12125///   * CONSTRAINT_NAME (Text)
12126///   * TABLE_NAME (Text)
12127///   * COLUMN_NAME (Text)
12128///   * ORDINAL_POSITION (Int)
12129///   * REFERENCED_TABLE_NAME (Text) — empty for non-FK rows
12130///   * REFERENCED_COLUMN_NAME (Text) — empty for non-FK rows
12131fn synth_info_key_column_usage(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12132    let schema = alloc::vec![
12133        ColumnSchema::new("constraint_name", DataType::Text, false),
12134        ColumnSchema::new("table_name", DataType::Text, false),
12135        ColumnSchema::new("column_name", DataType::Text, false),
12136        ColumnSchema::new("ordinal_position", DataType::Int, false),
12137        ColumnSchema::new("referenced_table_name", DataType::Text, false),
12138        ColumnSchema::new("referenced_column_name", DataType::Text, false),
12139    ];
12140    let mut rows: Vec<Row> = Vec::new();
12141    for tname in cat.table_names() {
12142        let Some(t) = cat.get(&tname) else { continue };
12143        let cols = &t.schema().columns;
12144        let col_name_at = |pos: usize| -> String {
12145            cols.get(pos)
12146                .map_or_else(|| alloc::format!("col{pos}"), |c| c.name.clone())
12147        };
12148        // FKs.
12149        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
12150            let conname = fk
12151                .name
12152                .clone()
12153                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
12154            for (i, (&local, &parent)) in fk
12155                .local_columns
12156                .iter()
12157                .zip(fk.parent_columns.iter())
12158                .enumerate()
12159            {
12160                let parent_name = cat
12161                    .get(&fk.parent_table)
12162                    .and_then(|pt| pt.schema().columns.get(parent).map(|c| c.name.clone()))
12163                    .unwrap_or_else(|| alloc::format!("col{parent}"));
12164                #[allow(clippy::cast_possible_wrap)]
12165                let ordinal = (i + 1) as i32;
12166                rows.push(Row::new(alloc::vec![
12167                    Value::Text(conname.clone()),
12168                    Value::Text(tname.clone()),
12169                    Value::Text(col_name_at(local)),
12170                    Value::Int(ordinal),
12171                    Value::Text(fk.parent_table.clone()),
12172                    Value::Text(parent_name),
12173                ]));
12174            }
12175        }
12176        // PK / composite UC entries.
12177        for (ci, uc) in t.schema().uniqueness_constraints.iter().enumerate() {
12178            let conname = if uc.is_primary_key {
12179                alloc::format!("{}_pkey", tname)
12180            } else {
12181                alloc::format!("{}_uniq{ci}", tname)
12182            };
12183            for (i, &local) in uc.columns.iter().enumerate() {
12184                #[allow(clippy::cast_possible_wrap)]
12185                let ordinal = (i + 1) as i32;
12186                rows.push(Row::new(alloc::vec![
12187                    Value::Text(conname.clone()),
12188                    Value::Text(tname.clone()),
12189                    Value::Text(col_name_at(local)),
12190                    Value::Int(ordinal),
12191                    Value::Text(String::new()),
12192                    Value::Text(String::new()),
12193                ]));
12194            }
12195        }
12196    }
12197    (schema, rows)
12198}
12199
12200/// v7.17.0 Phase 3.P0-64 — synthesise
12201/// `information_schema.REFERENTIAL_CONSTRAINTS`. One row per FK.
12202fn synth_info_referential_constraints(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12203    let schema = alloc::vec![
12204        ColumnSchema::new("constraint_name", DataType::Text, false),
12205        ColumnSchema::new("table_name", DataType::Text, false),
12206        ColumnSchema::new("referenced_table_name", DataType::Text, false),
12207        ColumnSchema::new("update_rule", DataType::Text, false),
12208        ColumnSchema::new("delete_rule", DataType::Text, false),
12209    ];
12210    fn rule_name(a: spg_storage::FkAction) -> &'static str {
12211        match a {
12212            spg_storage::FkAction::Cascade => "CASCADE",
12213            spg_storage::FkAction::SetNull => "SET NULL",
12214            spg_storage::FkAction::SetDefault => "SET DEFAULT",
12215            spg_storage::FkAction::Restrict => "RESTRICT",
12216            spg_storage::FkAction::NoAction => "NO ACTION",
12217        }
12218    }
12219    let mut rows: Vec<Row> = Vec::new();
12220    for tname in cat.table_names() {
12221        let Some(t) = cat.get(&tname) else { continue };
12222        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
12223            let conname = fk
12224                .name
12225                .clone()
12226                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
12227            rows.push(Row::new(alloc::vec![
12228                Value::Text(conname),
12229                Value::Text(tname.clone()),
12230                Value::Text(fk.parent_table.clone()),
12231                Value::Text(rule_name(fk.on_update).into()),
12232                Value::Text(rule_name(fk.on_delete).into()),
12233            ]));
12234        }
12235    }
12236    (schema, rows)
12237}
12238
12239/// v7.17.0 Phase 3.P0-64 — synthesise `information_schema.STATISTICS`.
12240/// One row per (index × column) — admin tools walk this to
12241/// surface index-cardinality estimates.
12242fn synth_info_statistics(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12243    let schema = alloc::vec![
12244        ColumnSchema::new("table_name", DataType::Text, false),
12245        ColumnSchema::new("index_name", DataType::Text, false),
12246        ColumnSchema::new("column_name", DataType::Text, false),
12247        ColumnSchema::new("seq_in_index", DataType::Int, false),
12248        ColumnSchema::new("non_unique", DataType::Int, false),
12249        ColumnSchema::new("index_type", DataType::Text, false),
12250    ];
12251    let mut rows: Vec<Row> = Vec::new();
12252    for tname in cat.table_names() {
12253        let Some(t) = cat.get(&tname) else { continue };
12254        for idx in t.indices() {
12255            let col = t
12256                .schema()
12257                .columns
12258                .get(idx.column_position)
12259                .map_or("?".into(), |c| c.name.clone());
12260            rows.push(Row::new(alloc::vec![
12261                Value::Text(tname.clone()),
12262                Value::Text(idx.name.clone()),
12263                Value::Text(col),
12264                Value::Int(1),
12265                Value::Int(i32::from(!idx.is_unique)),
12266                Value::Text("BTREE".into()),
12267            ]));
12268        }
12269    }
12270    (schema, rows)
12271}
12272
12273/// v7.17.0 Phase 3.P0-64 — synthesise `information_schema.ROUTINES`.
12274/// SPG has no user-defined functions in v7.17 so the surface is
12275/// always empty; admin tools just need the table to exist.
12276fn synth_info_routines() -> (Vec<ColumnSchema>, Vec<Row>) {
12277    let schema = alloc::vec![
12278        ColumnSchema::new("routine_name", DataType::Text, false),
12279        ColumnSchema::new("routine_type", DataType::Text, false),
12280        ColumnSchema::new("data_type", DataType::Text, false),
12281    ];
12282    (schema, Vec::new())
12283}
12284
12285/// v7.17.0 Phase 3.P0-54 — synthesise `pg_catalog.pg_constraint`.
12286/// ORM compilers (Diesel, sea-orm) and admin tools probe this for
12287/// FK / UNIQUE / PK / CHECK definitions to surface relationship
12288/// graphs and validation rules. SPG ships one row per
12289/// uniqueness constraint + foreign key declared in the catalog.
12290///
12291/// Schema columns exposed:
12292///   * conname (Text) — constraint name (synthetic when anonymous)
12293///   * contype (Text) — `p` PK, `u` UNIQUE, `f` FK, `c` CHECK
12294///   * conrelid (Text) — owner table name
12295///   * confrelid (Text) — referenced parent table (FK only;
12296///     empty string otherwise)
12297///   * conkey (Text) — comma-separated column names
12298///   * confkey (Text) — comma-separated parent column names (FK only)
12299fn synth_pg_constraint(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12300    let schema = alloc::vec![
12301        ColumnSchema::new("conname", DataType::Text, false),
12302        ColumnSchema::new("contype", DataType::Text, false),
12303        ColumnSchema::new("conrelid", DataType::Text, false),
12304        ColumnSchema::new("confrelid", DataType::Text, false),
12305        ColumnSchema::new("conkey", DataType::Text, false),
12306        ColumnSchema::new("confkey", DataType::Text, false),
12307    ];
12308    let mut rows: Vec<Row> = Vec::new();
12309    for tname in cat.table_names() {
12310        let Some(t) = cat.get(&tname) else { continue };
12311        let cols = &t.schema().columns;
12312        let col_name_at = |pos: usize| -> String {
12313            cols.get(pos)
12314                .map_or_else(|| alloc::format!("col{pos}"), |c| c.name.clone())
12315        };
12316        // Uniqueness constraints (composite UNIQUE / PRIMARY KEY).
12317        for (ci, uc) in t.schema().uniqueness_constraints.iter().enumerate() {
12318            let kind = if uc.is_primary_key { "p" } else { "u" };
12319            let conname = if uc.is_primary_key {
12320                alloc::format!("{}_pkey", tname)
12321            } else {
12322                alloc::format!("{}_uniq{ci}", tname)
12323            };
12324            let conkey: Vec<String> = uc.columns.iter().map(|&p| col_name_at(p)).collect();
12325            rows.push(Row::new(alloc::vec![
12326                Value::Text(conname),
12327                Value::Text(kind.into()),
12328                Value::Text(tname.clone()),
12329                Value::Text(String::new()),
12330                Value::Text(conkey.join(",")),
12331                Value::Text(String::new()),
12332            ]));
12333        }
12334        // Single-column PK / UNIQUE indexes that have no
12335        // matching entry in `uniqueness_constraints` (the engine
12336        // creates only the BTree index for the bare-column case;
12337        // composite forms ride the UC path above).
12338        for idx in t.indices() {
12339            if !idx.is_unique {
12340                continue;
12341            }
12342            let is_primary = idx.name.ends_with("_pkey");
12343            let conname = idx.name.clone();
12344            let kind = if is_primary { "p" } else { "u" };
12345            let col_name = col_name_at(idx.column_position);
12346            // Skip if already emitted via the UC loop above (same
12347            // tuple shape — single-column).
12348            let already = t
12349                .schema()
12350                .uniqueness_constraints
12351                .iter()
12352                .any(|uc| uc.columns.len() == 1 && uc.columns[0] == idx.column_position);
12353            if already {
12354                continue;
12355            }
12356            rows.push(Row::new(alloc::vec![
12357                Value::Text(conname),
12358                Value::Text(kind.into()),
12359                Value::Text(tname.clone()),
12360                Value::Text(String::new()),
12361                Value::Text(col_name),
12362                Value::Text(String::new()),
12363            ]));
12364        }
12365        // Foreign keys.
12366        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
12367            let conname = fk
12368                .name
12369                .clone()
12370                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
12371            let conkey: Vec<String> = fk.local_columns.iter().map(|&p| col_name_at(p)).collect();
12372            // Parent column names: look up the parent table's
12373            // schema if it exists; otherwise emit positions.
12374            let confkey: Vec<String> = if let Some(parent) = cat.get(&fk.parent_table) {
12375                fk.parent_columns
12376                    .iter()
12377                    .map(|&p| {
12378                        parent
12379                            .schema()
12380                            .columns
12381                            .get(p)
12382                            .map_or_else(|| alloc::format!("col{p}"), |c| c.name.clone())
12383                    })
12384                    .collect()
12385            } else {
12386                fk.parent_columns
12387                    .iter()
12388                    .map(|p| alloc::format!("col{p}"))
12389                    .collect()
12390            };
12391            rows.push(Row::new(alloc::vec![
12392                Value::Text(conname),
12393                Value::Text("f".into()),
12394                Value::Text(tname.clone()),
12395                Value::Text(fk.parent_table.clone()),
12396                Value::Text(conkey.join(",")),
12397                Value::Text(confkey.join(",")),
12398            ]));
12399        }
12400    }
12401    (schema, rows)
12402}
12403
12404/// v7.17.0 Phase 3.P0-55 — synthesise `pg_catalog.pg_database`.
12405/// SPG is single-database so we surface a single row keyed on the
12406/// canonical `postgres` database name (matching what every PG
12407/// admin tool's startup screen expects to find).
12408fn synth_pg_database(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12409    let schema = alloc::vec![
12410        ColumnSchema::new("oid", DataType::BigInt, false),
12411        ColumnSchema::new("datname", DataType::Text, false),
12412        ColumnSchema::new("datdba", DataType::BigInt, false),
12413        ColumnSchema::new("encoding", DataType::Int, false),
12414        ColumnSchema::new("datcollate", DataType::Text, false),
12415    ];
12416    let rows = alloc::vec![Row::new(alloc::vec![
12417        Value::BigInt(16384),
12418        Value::Text("postgres".into()),
12419        Value::BigInt(10),
12420        Value::Int(6), // UTF8
12421        Value::Text("en_US.UTF-8".into()),
12422    ])];
12423    (schema, rows)
12424}
12425
12426/// v7.17.0 Phase 3.P0-55 — synthesise `pg_catalog.pg_roles`. PG's
12427/// pg_roles is a view over pg_authid showing all roles. SPG ships
12428/// one row per declared user from the engine's UserStore so admin
12429/// tool startup screens can populate.
12430fn synth_pg_roles(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
12431    let schema = alloc::vec![
12432        ColumnSchema::new("oid", DataType::BigInt, false),
12433        ColumnSchema::new("rolname", DataType::Text, false),
12434        ColumnSchema::new("rolsuper", DataType::Bool, false),
12435        ColumnSchema::new("rolinherit", DataType::Bool, false),
12436        ColumnSchema::new("rolcanlogin", DataType::Bool, false),
12437    ];
12438    let mut rows: Vec<Row> = Vec::new();
12439    let oid: i64 = 10;
12440    for (i, (name, _)) in engine.users.iter().enumerate() {
12441        rows.push(Row::new(alloc::vec![
12442            Value::BigInt(oid + (i as i64) + 1),
12443            Value::Text(name.to_string()),
12444            Value::Bool(false),
12445            Value::Bool(true),
12446            Value::Bool(true),
12447        ]));
12448    }
12449    // Always include `postgres` as the bootstrap superuser if not
12450    // already present — admin tools probe for it.
12451    if !rows
12452        .iter()
12453        .any(|r| matches!(&r.values[1], Value::Text(s) if s == "postgres"))
12454    {
12455        rows.insert(
12456            0,
12457            Row::new(alloc::vec![
12458                Value::BigInt(10),
12459                Value::Text("postgres".into()),
12460                Value::Bool(true),
12461                Value::Bool(true),
12462                Value::Bool(true),
12463            ]),
12464        );
12465    }
12466    (schema, rows)
12467}
12468
12469/// v7.17.0 Phase 3.P0-56 — synthesise `pg_catalog.pg_views`. PG's
12470/// pg_views is a view listing every catalog view; SPG ships one
12471/// row per declared view + its definition text.
12472/// Synthesise `pg_catalog.pg_extension`. SPG ships its "extension"
12473/// surfaces natively (vector, pg_trgm, plpgsql-shaped DO blocks), so
12474/// the table lists those as installed — `SELECT … FROM pg_extension
12475/// WHERE extname = 'vector'` probes from PG clients (mailrs embed
12476/// round-12) answer truthfully about capability presence.
12477fn synth_pg_extension() -> (Vec<ColumnSchema>, Vec<Row>) {
12478    let schema = alloc::vec![
12479        ColumnSchema::new("oid", DataType::BigInt, false),
12480        ColumnSchema::new("extname", DataType::Text, false),
12481        ColumnSchema::new("extversion", DataType::Text, false),
12482        ColumnSchema::new("extnamespace", DataType::Text, false),
12483    ];
12484    let exts: &[(&str, &str)] = &[("plpgsql", "1.0"), ("vector", "0.8.0"), ("pg_trgm", "1.6")];
12485    let rows = exts
12486        .iter()
12487        .enumerate()
12488        .map(|(i, (name, ver))| {
12489            Row::new(alloc::vec![
12490                Value::BigInt(16384 + i as i64),
12491                Value::Text((*name).into()),
12492                Value::Text((*ver).into()),
12493                Value::Text("pg_catalog".into()),
12494            ])
12495        })
12496        .collect();
12497    (schema, rows)
12498}
12499
12500fn synth_pg_views(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12501    let schema = alloc::vec![
12502        ColumnSchema::new("schemaname", DataType::Text, false),
12503        ColumnSchema::new("viewname", DataType::Text, false),
12504        ColumnSchema::new("definition", DataType::Text, false),
12505    ];
12506    let mut rows: Vec<Row> = Vec::new();
12507    for (name, def) in cat.views() {
12508        rows.push(Row::new(alloc::vec![
12509            Value::Text("public".into()),
12510            Value::Text(name.clone()),
12511            Value::Text(def.body.clone()),
12512        ]));
12513    }
12514    (schema, rows)
12515}
12516
12517/// v7.17.0 Phase 3.P0-57 — synthesise `pg_catalog.pg_settings`. ORM
12518/// connection-checkers (sqlx pre-flight, Diesel migrator) and admin
12519/// tools read `pg_settings` to discover server-side configuration.
12520/// SPG surfaces every session_param + a small set of canonical PG
12521/// defaults so the pre-flight queries match.
12522fn synth_pg_settings(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
12523    let schema = alloc::vec![
12524        ColumnSchema::new("name", DataType::Text, false),
12525        ColumnSchema::new("setting", DataType::Text, false),
12526        ColumnSchema::new("category", DataType::Text, false),
12527    ];
12528    let mut rows: Vec<Row> = Vec::new();
12529    // Canonical defaults every admin tool expects to find.
12530    let defaults: &[(&str, &str, &str)] = &[
12531        ("server_version", "16.0 (spg)", "Preset Options"),
12532        ("server_encoding", "UTF8", "Client Connection Defaults"),
12533        ("client_encoding", "UTF8", "Client Connection Defaults"),
12534        ("DateStyle", "ISO, MDY", "Client Connection Defaults"),
12535        ("TimeZone", "UTC", "Client Connection Defaults"),
12536        ("standard_conforming_strings", "on", "Compatibility"),
12537        ("integer_datetimes", "on", "Compatibility"),
12538        ("max_connections", "100", "Connections and Authentication"),
12539    ];
12540    for &(name, val, cat) in defaults {
12541        rows.push(Row::new(alloc::vec![
12542            Value::Text(name.into()),
12543            Value::Text(val.into()),
12544            Value::Text(cat.into()),
12545        ]));
12546    }
12547    // Session-set params override the static defaults.
12548    for (k, v) in &engine.session_params {
12549        if !defaults
12550            .iter()
12551            .any(|(n, _, _)| (*n).eq_ignore_ascii_case(k))
12552        {
12553            rows.push(Row::new(alloc::vec![
12554                Value::Text(k.clone()),
12555                Value::Text(v.clone()),
12556                Value::Text("Session".into()),
12557            ]));
12558        }
12559    }
12560    (schema, rows)
12561}
12562
12563/// v7.17.0 Phase 3.P0-53 — synthesise `pg_catalog.pg_indexes`.
12564/// PG's pg_indexes is a real view on pg_index + pg_class + pg_attribute.
12565/// SPG ships it as a synthesised flat table so admin tools (pgAdmin,
12566/// DataGrip) can list indexes by tablename without joining four catalogs.
12567///
12568/// Schema columns exposed:
12569///   * schemaname (Text) — always `public`
12570///   * tablename (Text)
12571///   * indexname (Text)
12572///   * indexdef (Text) — best-effort CREATE INDEX DDL
12573fn synth_pg_indexes(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12574    let schema = alloc::vec![
12575        ColumnSchema::new("schemaname", DataType::Text, false),
12576        ColumnSchema::new("tablename", DataType::Text, false),
12577        ColumnSchema::new("indexname", DataType::Text, false),
12578        ColumnSchema::new("indexdef", DataType::Text, false),
12579    ];
12580    let mut rows: Vec<Row> = Vec::new();
12581    for tname in cat.table_names() {
12582        let Some(t) = cat.get(&tname) else { continue };
12583        for idx in t.indices() {
12584            let col_name = t
12585                .schema()
12586                .columns
12587                .get(idx.column_position)
12588                .map_or("?".into(), |c| c.name.clone());
12589            let unique_kw = if idx.is_unique { "UNIQUE " } else { "" };
12590            let indexdef = alloc::format!(
12591                "CREATE {unique_kw}INDEX {} ON public.{} ({})",
12592                idx.name,
12593                tname,
12594                col_name
12595            );
12596            rows.push(Row::new(alloc::vec![
12597                Value::Text("public".into()),
12598                Value::Text(tname.clone()),
12599                Value::Text(idx.name.clone()),
12600                Value::Text(indexdef),
12601            ]));
12602        }
12603    }
12604    (schema, rows)
12605}
12606
12607/// v7.17.0 Phase 3.P0-53 — synthesise `pg_catalog.pg_index`. The
12608/// "raw" pg_index catalog used by PG-internal tooling for index
12609/// flags and ordinal information. SPG ships the columns ORM probes
12610/// actually filter on.
12611///
12612/// Schema columns exposed:
12613///   * indexrelid (BigInt) — index OID (synthetic = position+1)
12614///   * indrelid (BigInt) — table OID (synthetic = position+1)
12615///   * indnatts (Int) — number of indexed columns
12616///   * indisunique (Bool)
12617///   * indisprimary (Bool)
12618fn synth_pg_index_raw(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12619    let schema = alloc::vec![
12620        ColumnSchema::new("indexrelid", DataType::BigInt, false),
12621        ColumnSchema::new("indrelid", DataType::BigInt, false),
12622        ColumnSchema::new("indnatts", DataType::Int, false),
12623        ColumnSchema::new("indisunique", DataType::Bool, false),
12624        ColumnSchema::new("indisprimary", DataType::Bool, false),
12625    ];
12626    let mut rows: Vec<Row> = Vec::new();
12627    let mut idx_oid: i64 = 100_000;
12628    for (table_idx, tname) in cat.table_names().iter().enumerate() {
12629        let Some(t) = cat.get(tname) else { continue };
12630        for idx in t.indices() {
12631            idx_oid += 1;
12632            #[allow(clippy::cast_possible_wrap)]
12633            let nattrs = (1 + idx.extra_column_positions.len()) as i32;
12634            // is_primary: SPG / PG flag the primary via the
12635            // index name convention `<table>_pkey`.
12636            let is_primary = idx.name.ends_with("_pkey");
12637            rows.push(Row::new(alloc::vec![
12638                Value::BigInt(idx_oid),
12639                Value::BigInt((table_idx + 1) as i64),
12640                Value::Int(nattrs),
12641                Value::Bool(idx.is_unique),
12642                Value::Bool(is_primary),
12643            ]));
12644        }
12645    }
12646    (schema, rows)
12647}
12648
12649/// v7.17.0 Phase 3.P0-52 — synthesise `pg_catalog.pg_namespace`.
12650/// SPG is single-schema so we expose the canonical PG schemas:
12651/// `public` (user-facing), `pg_catalog` (built-in), and
12652/// `information_schema` (PG meta).
12653fn synth_pg_namespace(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
12654    let schema = alloc::vec![
12655        ColumnSchema::new("oid", DataType::BigInt, false),
12656        ColumnSchema::new("nspname", DataType::Text, false),
12657        ColumnSchema::new("nspowner", DataType::BigInt, false),
12658    ];
12659    let rows = alloc::vec![
12660        Row::new(alloc::vec![
12661            Value::BigInt(11),
12662            Value::Text("pg_catalog".into()),
12663            Value::BigInt(10),
12664        ]),
12665        Row::new(alloc::vec![
12666            Value::BigInt(2200),
12667            Value::Text("public".into()),
12668            Value::BigInt(10),
12669        ]),
12670        Row::new(alloc::vec![
12671            Value::BigInt(13000),
12672            Value::Text("information_schema".into()),
12673            Value::BigInt(10),
12674        ]),
12675    ];
12676    (schema, rows)
12677}
12678
12679/// v7.16.2 — drop the synthesised meta view into the enriched
12680/// catalog so the regular FROM-resolution path can see it.
12681fn materialise_meta_view(
12682    catalog: &mut Catalog,
12683    name: &str,
12684    columns: Vec<ColumnSchema>,
12685    rows: Vec<Row>,
12686) -> Result<(), EngineError> {
12687    let schema = TableSchema::new(name.to_string(), columns);
12688    catalog.create_table(schema).map_err(EngineError::Storage)?;
12689    let table = catalog
12690        .get_mut(name)
12691        .expect("just-created meta view must exist");
12692    for row in rows {
12693        table.insert(row).map_err(EngineError::Storage)?;
12694    }
12695    Ok(())
12696}
12697
12698/// v7.16.2 — true when the SELECT statement references any
12699/// `__spg_info_*` or `__spg_pg_*` synthetic table name (the
12700/// parser produces these for `information_schema.X` /
12701/// `pg_catalog.X`). Used by `exec_select_cancel` to short-
12702/// circuit into the meta-view materialisation path.
12703/// v7.17.0 Phase 1.2 — append the names of any catalog-known
12704/// views referenced by `tref` to `into`. Helper for
12705/// `Engine::expand_views_in_select`. A view that's been already
12706/// materialised as a table (e.g. via the synthetic CTE pass for
12707/// SELECT FROM v) is skipped — the table form wins so the
12708/// recursive exec_select_cancel call inside exec_with_ctes
12709/// doesn't re-expand and trigger the CTE-shadow guard.
12710fn collect_view_refs(
12711    tref: &spg_sql::ast::TableRef,
12712    cat: &spg_storage::Catalog,
12713    into: &mut Vec<String>,
12714) {
12715    if cat.views().contains_key(&tref.name)
12716        && cat.get(&tref.name).is_none()
12717        && !into.iter().any(|n| n == &tref.name)
12718    {
12719        into.push(tref.name.clone());
12720    }
12721}
12722
12723fn select_references_meta_view(stmt: &SelectStatement) -> bool {
12724    fn is_meta(name: &str) -> bool {
12725        name.starts_with("__spg_info_")
12726            || name.starts_with("__spg_pg_")
12727            || name.starts_with("__spg_mysql_")
12728    }
12729    if let Some(from) = &stmt.from {
12730        if is_meta(&from.primary.name) {
12731            return true;
12732        }
12733        for j in &from.joins {
12734            if is_meta(&j.table.name) {
12735                return true;
12736            }
12737        }
12738    }
12739    for cte in &stmt.ctes {
12740        if select_references_meta_view(&cte.body) {
12741            return true;
12742        }
12743    }
12744    false
12745}
12746
12747/// v7.16.2 — collect every meta-view name a SELECT touches.
12748/// Returns a deduplicated, sorted list. Caller materialises
12749/// each one into the enriched catalog before re-running the
12750/// SELECT. Walks JOINs, CTEs, and the primary FROM.
12751fn collect_meta_view_names(
12752    stmt: &SelectStatement,
12753    into: &mut alloc::collections::BTreeSet<String>,
12754) {
12755    fn is_meta(name: &str) -> bool {
12756        name.starts_with("__spg_info_")
12757            || name.starts_with("__spg_pg_")
12758            || name.starts_with("__spg_mysql_")
12759    }
12760    if let Some(from) = &stmt.from {
12761        if is_meta(&from.primary.name) {
12762            into.insert(from.primary.name.clone());
12763        }
12764        for j in &from.joins {
12765            if is_meta(&j.table.name) {
12766                into.insert(j.table.name.clone());
12767            }
12768        }
12769    }
12770    for cte in &stmt.ctes {
12771        collect_meta_view_names(&cte.body, into);
12772    }
12773}
12774
12775fn infer_column_types(columns: &[ColumnSchema], rows: &[Row]) -> Vec<ColumnSchema> {
12776    let mut out = columns.to_vec();
12777    for (col_idx, col) in out.iter_mut().enumerate() {
12778        if col.ty != DataType::Text {
12779            continue;
12780        }
12781        let mut inferred: Option<DataType> = None;
12782        let mut all_null = true;
12783        for row in rows {
12784            let Some(v) = row.values.get(col_idx) else {
12785                continue;
12786            };
12787            let ty = match v {
12788                Value::Null => continue,
12789                Value::SmallInt(_) => DataType::SmallInt,
12790                Value::Int(_) => DataType::Int,
12791                Value::BigInt(_) => DataType::BigInt,
12792                Value::Float(_) => DataType::Float,
12793                Value::Bool(_) => DataType::Bool,
12794                Value::Vector(_) => DataType::Vector {
12795                    dim: 0,
12796                    encoding: VecEncoding::F32,
12797                },
12798                _ => DataType::Text,
12799            };
12800            all_null = false;
12801            inferred = Some(match inferred {
12802                None => ty,
12803                Some(prev) if prev == ty => prev,
12804                Some(_) => DataType::Text,
12805            });
12806        }
12807        if let Some(t) = inferred {
12808            col.ty = t;
12809            col.nullable = true;
12810        } else if all_null {
12811            col.nullable = true;
12812        }
12813    }
12814    out
12815}
12816
12817/// v4.26: render a human-readable plan tree for `EXPLAIN <select>`.
12818/// Lines are pushed into `out`; `depth` controls indentation. We
12819/// describe the rewritten SELECT — what the executor *would* do —
12820/// using the engine handle to spot indexed lookups and table shapes.
12821#[allow(clippy::too_many_lines, clippy::format_push_string)]
12822/// v6.2.4 — Walk every line of the rendered plan tree and append
12823/// per-operator stats. Lines that name a known operator get
12824/// `(rows=N)` (`actual_rows` of the top-level operator equals the
12825/// final result row count; scans report their catalog row count
12826/// as the rows-considered metric). Other lines — Filter / Join /
12827/// GroupBy / OrderBy etc. — are marked `(—)` so the surface is
12828/// complete-by-construction; v6.2.5 fills these in via inline
12829/// executor counters.
12830/// v6.8.3 — surface "CREATE INDEX …" suggestions for every
12831/// `(table, column)` pair the query touches via WHERE / JOIN
12832/// that doesn't already have an index on the owning table.
12833/// Walks the SELECT's FROM clauses + WHERE expression tree;
12834/// returns one line per missing index. Deterministic order:
12835/// FROM-clause iteration order, then column-reference walk
12836/// order inside each WHERE. Each suggestion is a copy-pastable
12837/// DDL string.
12838fn build_index_suggestions(stmt: &SelectStatement, engine: &Engine) -> Vec<String> {
12839    use alloc::collections::BTreeSet;
12840    let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
12841    let mut out: Vec<String> = Vec::new();
12842    let cat = engine.active_catalog();
12843    // Build a (table, qualifier-or-alias) list from the FROM clause
12844    // so unqualified column refs in WHERE resolve to the correct
12845    // table.
12846    let Some(from) = &stmt.from else {
12847        return out;
12848    };
12849    let mut tables: Vec<String> = Vec::new();
12850    tables.push(from.primary.name.clone());
12851    for j in &from.joins {
12852        tables.push(j.table.name.clone());
12853    }
12854    // Collect column refs from the WHERE expression. JOIN ON
12855    // predicates also feed in.
12856    let mut col_refs: Vec<spg_sql::ast::ColumnName> = Vec::new();
12857    if let Some(w) = &stmt.where_ {
12858        collect_column_refs(w, &mut col_refs);
12859    }
12860    for j in &from.joins {
12861        if let Some(on) = &j.on {
12862            collect_column_refs(on, &mut col_refs);
12863        }
12864    }
12865    for cn in &col_refs {
12866        // Resolve owner table: explicit qualifier first, else
12867        // first table in FROM that has a column of this name.
12868        let owner: Option<String> = if let Some(q) = &cn.qualifier {
12869            tables.iter().find(|t| t == &q).cloned()
12870        } else {
12871            tables.iter().find_map(|t| {
12872                cat.get(t).and_then(|tbl| {
12873                    if tbl.schema().column_position(&cn.name).is_some() {
12874                        Some(t.clone())
12875                    } else {
12876                        None
12877                    }
12878                })
12879            })
12880        };
12881        let Some(owner) = owner else {
12882            continue;
12883        };
12884        let Some(tbl) = cat.get(&owner) else {
12885            continue;
12886        };
12887        let Some(col_pos) = tbl.schema().column_position(&cn.name) else {
12888            continue;
12889        };
12890        // Skip if any BTree index already covers this column as
12891        // its key.
12892        let already_indexed = tbl.indices().iter().any(|i| {
12893            matches!(i.kind, spg_storage::IndexKind::BTree(_))
12894                && i.column_position == col_pos
12895                && i.expression.is_none()
12896                && i.partial_predicate.is_none()
12897        });
12898        if already_indexed {
12899            continue;
12900        }
12901        if seen.insert((owner.clone(), cn.name.clone())) {
12902            out.push(alloc::format!(
12903                "SUGGEST: CREATE INDEX ix_{}_{} ON {} ({})",
12904                owner,
12905                cn.name,
12906                owner,
12907                cn.name
12908            ));
12909        }
12910    }
12911    out
12912}
12913
12914/// Walks an `Expr` and pushes every `ColumnName` it references.
12915/// Order is depth-first, left-to-right.
12916fn collect_column_refs(expr: &Expr, out: &mut Vec<spg_sql::ast::ColumnName>) {
12917    match expr {
12918        Expr::Column(cn) => out.push(cn.clone()),
12919        Expr::FunctionCall { args, .. } => {
12920            for a in args {
12921                collect_column_refs(a, out);
12922            }
12923        }
12924        Expr::Binary { lhs, rhs, .. } => {
12925            collect_column_refs(lhs, out);
12926            collect_column_refs(rhs, out);
12927        }
12928        Expr::Unary { expr: e, .. } => collect_column_refs(e, out),
12929        _ => {}
12930    }
12931}
12932
12933fn annotate_explain_lines(lines: &mut [String], total_rows: usize, engine: &Engine) {
12934    let catalog = engine.active_catalog();
12935    let cold_ids = catalog.cold_segment_ids_global();
12936    let any_cold = !cold_ids.is_empty();
12937    let cold_ids_repr = if any_cold {
12938        let mut s = alloc::string::String::from("[");
12939        for (i, id) in cold_ids.iter().enumerate() {
12940            if i > 0 {
12941                s.push(',');
12942            }
12943            s.push_str(&alloc::format!("{id}"));
12944        }
12945        s.push(']');
12946        s
12947    } else {
12948        alloc::string::String::new()
12949    };
12950    for (idx, line) in lines.iter_mut().enumerate() {
12951        let trimmed = line.trim_start();
12952        let is_top_level = idx == 0;
12953        if is_top_level {
12954            line.push_str(&alloc::format!(" (rows={total_rows})"));
12955            continue;
12956        }
12957        if let Some(rest) = trimmed.strip_prefix("From: ") {
12958            let (name, scan_kind) = match rest.split_once(" [") {
12959                Some((n, k)) => (n.trim(), k.trim_end_matches(']')),
12960                None => (rest.trim(), ""),
12961            };
12962            let bare = name.split_whitespace().next().unwrap_or(name);
12963            let hot = catalog.get(bare).map(|t| t.rows().len());
12964            // v6.2.7 — `cold_segments=[id0,id1,…]` enumerates every
12965            // cold-tier segment the scan COULD have walked. v6.2.x
12966            // can tighten to per-table by walking the table's
12967            // BTree-index cold locators.
12968            let annot = match (hot, scan_kind) {
12969                (Some(h), "full scan") => {
12970                    let mut s = alloc::format!(" (hot_rows={h}");
12971                    if any_cold {
12972                        s.push_str(&alloc::format!(
12973                            ", cold_tier=present, cold_segments={cold_ids_repr}"
12974                        ));
12975                    }
12976                    s.push(')');
12977                    s
12978                }
12979                (Some(h), "index seek") => {
12980                    let mut s = alloc::format!(" (hot_rows≤{h}");
12981                    if any_cold {
12982                        s.push_str(&alloc::format!(
12983                            ", cold_tier=present, cold_segments={cold_ids_repr}"
12984                        ));
12985                    }
12986                    s.push(')');
12987                    s
12988                }
12989                _ => " (rows=—)".to_string(),
12990            };
12991            line.push_str(&annot);
12992            continue;
12993        }
12994        // Filter / GroupBy / Having / OrderBy / Limit / Join etc.
12995        line.push_str(" (rows=—)");
12996    }
12997}
12998
12999fn explain_select(stmt: &SelectStatement, engine: &Engine, depth: usize, out: &mut Vec<String>) {
13000    let pad = "  ".repeat(depth);
13001    // 1) Top-level operator label.
13002    let top = if !stmt.ctes.is_empty() {
13003        if stmt.ctes.iter().any(|c| c.recursive) {
13004            "CTEScan (WITH RECURSIVE)"
13005        } else {
13006            "CTEScan (WITH)"
13007        }
13008    } else if !stmt.unions.is_empty() {
13009        "UnionScan"
13010    } else if select_has_window(stmt) {
13011        "WindowAgg"
13012    } else if aggregate::uses_aggregate(stmt) {
13013        "Aggregate"
13014    } else if stmt.distinct {
13015        "Distinct"
13016    } else if stmt.from.is_some() {
13017        "TableScan"
13018    } else {
13019        "Result"
13020    };
13021    out.push(alloc::format!("{pad}{top}"));
13022    let child = "  ".repeat(depth + 1);
13023    // 2) CTE bodies.
13024    for cte in &stmt.ctes {
13025        let head = if cte.recursive {
13026            alloc::format!("{child}CTE (recursive): {}", cte.name)
13027        } else {
13028            alloc::format!("{child}CTE: {}", cte.name)
13029        };
13030        out.push(head);
13031        explain_select(&cte.body, engine, depth + 2, out);
13032    }
13033    // 3) FROM details — primary table + joins, index hits.
13034    if let Some(from) = &stmt.from {
13035        let mut tag = alloc::format!("{child}From: {}", from.primary.name);
13036        if let Some(alias) = &from.primary.alias {
13037            tag.push_str(&alloc::format!(" AS {alias}"));
13038        }
13039        // Try to detect an index-seek opportunity on WHERE against
13040        // the primary table — same heuristic the executor uses.
13041        if let Some(w) = &stmt.where_
13042            && let Some(table) = engine.active_catalog().get(&from.primary.name)
13043        {
13044            let alias = from.primary.alias.as_deref().unwrap_or(&from.primary.name);
13045            let cols = &table.schema().columns;
13046            if try_index_seek(w, cols, engine.active_catalog(), table, alias).is_some() {
13047                tag.push_str(" [index seek]");
13048            } else {
13049                tag.push_str(" [full scan]");
13050            }
13051        } else {
13052            tag.push_str(" [full scan]");
13053        }
13054        out.push(tag);
13055        for j in &from.joins {
13056            let kind = match j.kind {
13057                spg_sql::ast::JoinKind::Inner => "INNER JOIN",
13058                spg_sql::ast::JoinKind::Left => "LEFT JOIN",
13059                spg_sql::ast::JoinKind::Cross => "CROSS JOIN",
13060            };
13061            let mut s = alloc::format!("{child}{kind}: {}", j.table.name);
13062            if let Some(alias) = &j.table.alias {
13063                s.push_str(&alloc::format!(" AS {alias}"));
13064            }
13065            if j.on.is_some() {
13066                s.push_str(" (ON …)");
13067            }
13068            out.push(s);
13069        }
13070    }
13071    // 4) WHERE / GROUP BY / HAVING / ORDER BY / LIMIT / OFFSET.
13072    if let Some(w) = &stmt.where_ {
13073        let mut s = alloc::format!("{child}Filter: {w}");
13074        if expr_has_subquery(w) {
13075            s.push_str(" [subquery]");
13076        }
13077        out.push(s);
13078    }
13079    if let Some(gs) = &stmt.group_by {
13080        let mut parts = Vec::new();
13081        for g in gs {
13082            parts.push(alloc::format!("{g}"));
13083        }
13084        out.push(alloc::format!("{child}GroupBy: {}", parts.join(", ")));
13085    }
13086    if let Some(h) = &stmt.having {
13087        out.push(alloc::format!("{child}Having: {h}"));
13088    }
13089    for o in &stmt.order_by {
13090        let dir = if o.desc { "DESC" } else { "ASC" };
13091        out.push(alloc::format!("{child}OrderBy: {} {dir}", o.expr));
13092    }
13093    if let Some(lim) = stmt.limit {
13094        out.push(alloc::format!("{child}Limit: {lim}"));
13095    }
13096    if let Some(off) = stmt.offset {
13097        out.push(alloc::format!("{child}Offset: {off}"));
13098    }
13099    // 5) Projection — collapse Wildcard or render N items.
13100    if stmt
13101        .items
13102        .iter()
13103        .any(|it| matches!(it, SelectItem::Wildcard))
13104    {
13105        out.push(alloc::format!("{child}Project: *"));
13106    } else {
13107        out.push(alloc::format!(
13108            "{child}Project: {} item(s)",
13109            stmt.items.len()
13110        ));
13111    }
13112    // 6) Recurse into UNION peers.
13113    for (kind, peer) in &stmt.unions {
13114        let label = match kind {
13115            UnionKind::All => "UNION ALL",
13116            UnionKind::Distinct => "UNION",
13117        };
13118        out.push(alloc::format!("{child}{label}"));
13119        explain_select(peer, engine, depth + 2, out);
13120    }
13121}
13122
13123/// v4.23: recognise the engine errors that indicate the inner
13124/// SELECT couldn't be evaluated in isolation because it references
13125/// an outer column — used by `subquery_replacement` to skip
13126/// materialisation and let row-eval handle it instead.
13127fn is_correlation_error(e: &EngineError) -> bool {
13128    matches!(
13129        e,
13130        EngineError::Eval(
13131            eval::EvalError::ColumnNotFound { .. } | eval::EvalError::UnknownQualifier { .. }
13132        )
13133    )
13134}
13135
13136/// v4.23: walk every Expr in `stmt` and replace each Column ref
13137/// that targets the outer scope (qualifier matches the outer
13138/// table alias) with a Literal carrying the outer row's value.
13139/// Conservative: only qualified refs are substituted, so the user
13140/// must write `outer_alias.col` to reference an outer column. This
13141/// matches PG's lexical scoping for correlated subqueries and
13142/// avoids accidentally rebinding inner columns of the same name.
13143/// v7.17.0 Phase 3.P0-41 — LATERAL peer descriptor. Either eagerly
13144/// materialised (every regular table / unnest / generate_series) or
13145/// lateral (subquery re-evaluated per outer row).
13146struct JoinedPeer<'a> {
13147    eager_rows: Option<Vec<Row>>,
13148    cols: Vec<ColumnSchema>,
13149    alias: String,
13150    kind: JoinKind,
13151    on: Option<&'a Expr>,
13152    lateral: Option<&'a SelectStatement>,
13153    /// v7.28 (round-22) — plain-table name for the index-nested-loop
13154    /// path. None for unnest/lateral.
13155    join_table: Option<String>,
13156}
13157
13158/// v7.17.0 Phase 3.P0-41 — synthesise a column name for a LATERAL
13159/// projection item that has no explicit alias. PG names anonymous
13160/// projection items by the function call's name or by `column<i>`.
13161/// SPG mirrors the latter (lower-overhead than walking arbitrary
13162/// Expr shapes) so the probe-schema fallback path produces stable
13163/// names for the lateral peer's columns.
13164fn synth_lateral_col_name(expr: &Expr, idx: usize) -> String {
13165    match expr {
13166        // Bare column reference — use the column's own name.
13167        Expr::Column(c) => c.name.clone(),
13168        // Function call — use the function name (PG canonical:
13169        // `count` / `max` / `lower` …).
13170        Expr::FunctionCall { name, .. } => name.clone(),
13171        // Cast — drill into the inner expression.
13172        Expr::Cast { expr: inner, .. } => synth_lateral_col_name(inner, idx),
13173        // Everything else falls back to PG's `column<N>` placeholder.
13174        _ => alloc::format!("column{}", idx + 1),
13175    }
13176}
13177
13178/// v7.17.0 Phase 3.P0-41 — substitute every `<alias>.<col>` Expr
13179/// reference whose `<alias>.<col>` exists in the outer composite
13180/// schema with the matching value from the outer row. Walks the
13181/// entire SELECT body (items, WHERE, GROUP BY, HAVING, ORDER BY,
13182/// UNION peers) so any depth of outer reference inside the
13183/// LATERAL subquery resolves before execution.
13184fn substitute_outer_columns_multi(
13185    stmt: &mut SelectStatement,
13186    outer_row: &Row,
13187    outer_schema: &[ColumnSchema],
13188) {
13189    substitute_outer_in_select(stmt, outer_row, outer_schema);
13190}
13191
13192fn substitute_outer_in_select(
13193    stmt: &mut SelectStatement,
13194    outer_row: &Row,
13195    outer_schema: &[ColumnSchema],
13196) {
13197    for item in &mut stmt.items {
13198        if let SelectItem::Expr { expr, .. } = item {
13199            substitute_outer_in_expr(expr, outer_row, outer_schema);
13200        }
13201    }
13202    if let Some(w) = &mut stmt.where_ {
13203        substitute_outer_in_expr(w, outer_row, outer_schema);
13204    }
13205    if let Some(gs) = &mut stmt.group_by {
13206        for g in gs {
13207            substitute_outer_in_expr(g, outer_row, outer_schema);
13208        }
13209    }
13210    if let Some(h) = &mut stmt.having {
13211        substitute_outer_in_expr(h, outer_row, outer_schema);
13212    }
13213    for o in &mut stmt.order_by {
13214        substitute_outer_in_expr(&mut o.expr, outer_row, outer_schema);
13215    }
13216    for (_, peer) in &mut stmt.unions {
13217        substitute_outer_in_select(peer, outer_row, outer_schema);
13218    }
13219}
13220
13221fn substitute_outer_in_expr(e: &mut Expr, outer_row: &Row, outer_schema: &[ColumnSchema]) {
13222    if let Expr::Column(c) = e
13223        && let Some(qual) = &c.qualifier
13224    {
13225        let composite = alloc::format!("{qual}.{}", c.name);
13226        if let Some(idx) = outer_schema
13227            .iter()
13228            .position(|sc| sc.name.eq_ignore_ascii_case(&composite))
13229        {
13230            let v = outer_row.values.get(idx).cloned().unwrap_or(Value::Null);
13231            if let Ok(lit) = value_to_literal_expr(v) {
13232                *e = lit;
13233                return;
13234            }
13235        }
13236    }
13237    match e {
13238        Expr::Binary { lhs, rhs, .. } => {
13239            substitute_outer_in_expr(lhs, outer_row, outer_schema);
13240            substitute_outer_in_expr(rhs, outer_row, outer_schema);
13241        }
13242        Expr::Unary { expr: inner, .. } => {
13243            substitute_outer_in_expr(inner, outer_row, outer_schema);
13244        }
13245        Expr::FunctionCall { args, .. } => {
13246            for a in args {
13247                substitute_outer_in_expr(a, outer_row, outer_schema);
13248            }
13249        }
13250        Expr::Cast { expr: inner, .. } => {
13251            substitute_outer_in_expr(inner, outer_row, outer_schema);
13252        }
13253        Expr::Case {
13254            operand,
13255            branches,
13256            else_branch,
13257        } => {
13258            if let Some(op) = operand {
13259                substitute_outer_in_expr(op, outer_row, outer_schema);
13260            }
13261            for (cond, val) in branches {
13262                substitute_outer_in_expr(cond, outer_row, outer_schema);
13263                substitute_outer_in_expr(val, outer_row, outer_schema);
13264            }
13265            if let Some(e) = else_branch {
13266                substitute_outer_in_expr(e, outer_row, outer_schema);
13267            }
13268        }
13269        _ => {}
13270    }
13271}
13272
13273fn substitute_outer_columns(stmt: &mut SelectStatement, row: &Row, ctx: &EvalContext<'_>) {
13274    // v7.24 (round-16 B) — joined outer contexts carry no single
13275    // table alias; their schemas use composite "alias.column" names
13276    // instead. Pass an unmatchable alias and let the composite
13277    // lookup in substitute_in_expr do the work (a correlated EXISTS
13278    // under a JOIN previously skipped substitution entirely and
13279    // died with "unknown table qualifier").
13280    let outer_alias = ctx.table_alias.unwrap_or("");
13281    substitute_in_select(stmt, row, ctx, outer_alias);
13282}
13283
13284fn substitute_in_select(
13285    stmt: &mut SelectStatement,
13286    row: &Row,
13287    ctx: &EvalContext<'_>,
13288    outer_alias: &str,
13289) {
13290    for item in &mut stmt.items {
13291        if let SelectItem::Expr { expr, .. } = item {
13292            substitute_in_expr(expr, row, ctx, outer_alias);
13293        }
13294    }
13295    if let Some(w) = &mut stmt.where_ {
13296        substitute_in_expr(w, row, ctx, outer_alias);
13297    }
13298    if let Some(gs) = &mut stmt.group_by {
13299        for g in gs {
13300            substitute_in_expr(g, row, ctx, outer_alias);
13301        }
13302    }
13303    if let Some(h) = &mut stmt.having {
13304        substitute_in_expr(h, row, ctx, outer_alias);
13305    }
13306    for o in &mut stmt.order_by {
13307        substitute_in_expr(&mut o.expr, row, ctx, outer_alias);
13308    }
13309    for (_, peer) in &mut stmt.unions {
13310        substitute_in_select(peer, row, ctx, outer_alias);
13311    }
13312}
13313
13314fn substitute_in_expr(e: &mut Expr, row: &Row, ctx: &EvalContext<'_>, outer_alias: &str) {
13315    // v7.25.2 (round-19 A) — bare synthetic columns. The aggregate
13316    // rewriter replaces group-key references INSIDE subquery bodies
13317    // with `__grp_N` so a correlated subquery in a GROUP BY select
13318    // list can resolve against the synthesised group row. The names
13319    // are engine-generated, so they can't shadow user columns.
13320    if let Expr::Column(c) = e
13321        && c.qualifier.is_none()
13322        && (c.name.starts_with("__grp_") || c.name.starts_with("__agg_"))
13323        && let Some(idx) = ctx.columns.iter().position(|sc| sc.name == c.name)
13324    {
13325        let v = row.values.get(idx).cloned().unwrap_or(Value::Null);
13326        if let Ok(lit) = value_to_literal_expr(v) {
13327            *e = lit;
13328            return;
13329        }
13330    }
13331    if let Expr::Column(c) = e
13332        && let Some(qual) = &c.qualifier
13333    {
13334        // Look up the column's index in the outer schema: plain name
13335        // when the qualifier is the outer table's alias, composite
13336        // "alias.column" for joined outer schemas (v7.24).
13337        let idx = if !outer_alias.is_empty() && qual.eq_ignore_ascii_case(outer_alias) {
13338            ctx.columns
13339                .iter()
13340                .position(|sc| sc.name.eq_ignore_ascii_case(&c.name))
13341        } else {
13342            None
13343        }
13344        .or_else(|| {
13345            let composite = alloc::format!("{qual}.{name}", name = c.name);
13346            ctx.columns
13347                .iter()
13348                .position(|sc| sc.name.eq_ignore_ascii_case(&composite))
13349        });
13350        if let Some(idx) = idx {
13351            let v = row.values.get(idx).cloned().unwrap_or(Value::Null);
13352            if let Ok(lit) = value_to_literal_expr(v) {
13353                *e = lit;
13354                return;
13355            }
13356        }
13357    }
13358    match e {
13359        Expr::AggregateOrdered { call, order_by, .. } => {
13360            substitute_in_expr(call, row, ctx, outer_alias);
13361            for o in order_by.iter_mut() {
13362                substitute_in_expr(&mut o.expr, row, ctx, outer_alias);
13363            }
13364        }
13365        Expr::Binary { lhs, rhs, .. } => {
13366            substitute_in_expr(lhs, row, ctx, outer_alias);
13367            substitute_in_expr(rhs, row, ctx, outer_alias);
13368        }
13369        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13370            substitute_in_expr(expr, row, ctx, outer_alias);
13371        }
13372        Expr::Like { expr, pattern, .. } => {
13373            substitute_in_expr(expr, row, ctx, outer_alias);
13374            substitute_in_expr(pattern, row, ctx, outer_alias);
13375        }
13376        Expr::FunctionCall { args, .. } => {
13377            for a in args {
13378                substitute_in_expr(a, row, ctx, outer_alias);
13379            }
13380        }
13381        Expr::Extract { source, .. } => substitute_in_expr(source, row, ctx, outer_alias),
13382        Expr::WindowFunction {
13383            args,
13384            partition_by,
13385            order_by,
13386            ..
13387        } => {
13388            for a in args {
13389                substitute_in_expr(a, row, ctx, outer_alias);
13390            }
13391            for p in partition_by {
13392                substitute_in_expr(p, row, ctx, outer_alias);
13393            }
13394            for (o, _, _) in order_by {
13395                substitute_in_expr(o, row, ctx, outer_alias);
13396            }
13397        }
13398        Expr::ScalarSubquery(s) => substitute_in_select(s, row, ctx, outer_alias),
13399        Expr::Exists { subquery, .. } | Expr::InSubquery { subquery, .. } => {
13400            substitute_in_select(subquery, row, ctx, outer_alias);
13401        }
13402        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => {}
13403        Expr::Array(items) => {
13404            for elem in items {
13405                substitute_in_expr(elem, row, ctx, outer_alias);
13406            }
13407        }
13408        Expr::ArraySubscript { target, index } => {
13409            substitute_in_expr(target, row, ctx, outer_alias);
13410            substitute_in_expr(index, row, ctx, outer_alias);
13411        }
13412        Expr::AnyAll { expr, array, .. } => {
13413            substitute_in_expr(expr, row, ctx, outer_alias);
13414            substitute_in_expr(array, row, ctx, outer_alias);
13415        }
13416        Expr::Case {
13417            operand,
13418            branches,
13419            else_branch,
13420        } => {
13421            if let Some(o) = operand {
13422                substitute_in_expr(o, row, ctx, outer_alias);
13423            }
13424            for (w, t) in branches {
13425                substitute_in_expr(w, row, ctx, outer_alias);
13426                substitute_in_expr(t, row, ctx, outer_alias);
13427            }
13428            if let Some(e) = else_branch {
13429                substitute_in_expr(e, row, ctx, outer_alias);
13430            }
13431        }
13432    }
13433}
13434
13435/// v4.22: encode a Row to a comparable byte key for UNION-DISTINCT
13436/// dedup inside the recursive iteration. Crude but deterministic
13437/// — Debug prints embed type discriminants so NULL ≠ "" ≠ 0.
13438fn encode_row_key(row: &Row) -> Vec<u8> {
13439    let mut out = Vec::new();
13440    for v in &row.values {
13441        let s = alloc::format!("{v:?}|");
13442        out.extend_from_slice(s.as_bytes());
13443    }
13444    out
13445}
13446
13447fn select_has_window(stmt: &SelectStatement) -> bool {
13448    for item in &stmt.items {
13449        if let SelectItem::Expr { expr, .. } = item
13450            && expr_has_window(expr)
13451        {
13452            return true;
13453        }
13454    }
13455    false
13456}
13457
13458fn expr_has_window(e: &Expr) -> bool {
13459    match e {
13460        Expr::WindowFunction { .. } => true,
13461        Expr::AggregateOrdered { call, order_by, .. } => {
13462            expr_has_window(call) || order_by.iter().any(|o| expr_has_window(&o.expr))
13463        }
13464        Expr::Binary { lhs, rhs, .. } => expr_has_window(lhs) || expr_has_window(rhs),
13465        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13466            expr_has_window(expr)
13467        }
13468        Expr::FunctionCall { args, .. } => args.iter().any(expr_has_window),
13469        Expr::Like { expr, pattern, .. } => expr_has_window(expr) || expr_has_window(pattern),
13470        Expr::Extract { source, .. } => expr_has_window(source),
13471        Expr::ScalarSubquery(_)
13472        | Expr::Exists { .. }
13473        | Expr::InSubquery { .. }
13474        | Expr::Literal(_)
13475        | Expr::Placeholder(_)
13476        | Expr::Column(_) => false,
13477        Expr::Array(items) => items.iter().any(expr_has_window),
13478        Expr::ArraySubscript { target, index } => expr_has_window(target) || expr_has_window(index),
13479        Expr::AnyAll { expr, array, .. } => expr_has_window(expr) || expr_has_window(array),
13480        Expr::Case {
13481            operand,
13482            branches,
13483            else_branch,
13484        } => {
13485            operand.as_deref().is_some_and(expr_has_window)
13486                || branches
13487                    .iter()
13488                    .any(|(w, t)| expr_has_window(w) || expr_has_window(t))
13489                || else_branch.as_deref().is_some_and(expr_has_window)
13490        }
13491    }
13492}
13493
13494fn collect_window_nodes(e: &Expr, out: &mut Vec<Expr>) {
13495    if let Expr::WindowFunction { .. } = e {
13496        // Deduplicate by structural equality on the expression
13497        // (cheap because window args + partition + order are
13498        // small). Without dedup we'd recompute identical windows
13499        // once per occurrence in the projection.
13500        if !out.iter().any(|x| x == e) {
13501            out.push(e.clone());
13502        }
13503        return;
13504    }
13505    match e {
13506        // Already handled by the early-return at the top.
13507        Expr::WindowFunction { .. } => unreachable!(),
13508        Expr::Binary { lhs, rhs, .. } => {
13509            collect_window_nodes(lhs, out);
13510            collect_window_nodes(rhs, out);
13511        }
13512        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13513            collect_window_nodes(expr, out);
13514        }
13515        Expr::FunctionCall { args, .. } => {
13516            for a in args {
13517                collect_window_nodes(a, out);
13518            }
13519        }
13520        Expr::Like { expr, pattern, .. } => {
13521            collect_window_nodes(expr, out);
13522            collect_window_nodes(pattern, out);
13523        }
13524        Expr::Extract { source, .. } => collect_window_nodes(source, out),
13525        _ => {}
13526    }
13527}
13528
13529fn rewrite_window_to_columns(e: &mut Expr, window_nodes: &[Expr]) {
13530    if let Expr::WindowFunction { .. } = e
13531        && let Some(idx) = window_nodes.iter().position(|w| w == e)
13532    {
13533        *e = Expr::Column(spg_sql::ast::ColumnName {
13534            qualifier: None,
13535            name: alloc::format!("__win_{idx}"),
13536        });
13537        return;
13538    }
13539    match e {
13540        Expr::Binary { lhs, rhs, .. } => {
13541            rewrite_window_to_columns(lhs, window_nodes);
13542            rewrite_window_to_columns(rhs, window_nodes);
13543        }
13544        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13545            rewrite_window_to_columns(expr, window_nodes);
13546        }
13547        Expr::FunctionCall { args, .. } => {
13548            for a in args {
13549                rewrite_window_to_columns(a, window_nodes);
13550            }
13551        }
13552        Expr::Like { expr, pattern, .. } => {
13553            rewrite_window_to_columns(expr, window_nodes);
13554            rewrite_window_to_columns(pattern, window_nodes);
13555        }
13556        Expr::Extract { source, .. } => rewrite_window_to_columns(source, window_nodes),
13557        _ => {}
13558    }
13559}
13560
13561/// Total order over partition-key tuples. NULL sorts as the
13562/// lowest value (matches the `<` partial order's NULL-last
13563/// behaviour with `INFINITY` flipped).
13564fn partition_key_cmp(a: &[Value], b: &[Value]) -> core::cmp::Ordering {
13565    for (x, y) in a.iter().zip(b.iter()) {
13566        let c = value_cmp(x, y);
13567        if c != core::cmp::Ordering::Equal {
13568            return c;
13569        }
13570    }
13571    a.len().cmp(&b.len())
13572}
13573
13574fn order_key_cmp(
13575    a: &[(Value, bool, Option<bool>)],
13576    b: &[(Value, bool, Option<bool>)],
13577) -> core::cmp::Ordering {
13578    // v7.24.1 — per-key DESC + effective NULLS placement (shared
13579    // contract with order_by_value_cmp).
13580    for ((va, desc, nf), (vb, _, _)) in a.iter().zip(b.iter()) {
13581        let c = order_by_value_cmp(*desc, *nf, va, vb);
13582        if c != core::cmp::Ordering::Equal {
13583            return c;
13584        }
13585    }
13586    a.len().cmp(&b.len())
13587}
13588
13589/// v7.17.0 Phase 3.10 — true when the Value is one of the
13590/// integer-shaped variants `generate_series` accepts as a start
13591/// / stop / step component. Float / NUMERIC are rejected — PG's
13592/// `generate_series(numeric, numeric)` overload is out of v7.17
13593/// scope.
13594const fn value_is_integer(v: &Value) -> bool {
13595    matches!(v, Value::SmallInt(_) | Value::Int(_) | Value::BigInt(_))
13596}
13597
13598/// v7.17.0 Phase 3.10 — widen any integer-shaped Value to i64 for
13599/// the generate_series iteration loop. Non-integer inputs panic;
13600/// caller guards via `value_is_integer`.
13601const fn value_to_i64(v: &Value) -> i64 {
13602    match v {
13603        Value::SmallInt(n) => *n as i64,
13604        Value::Int(n) => *n as i64,
13605        Value::BigInt(n) => *n,
13606        _ => panic!("value_to_i64 called on non-integer Value"),
13607    }
13608}
13609
13610/// v7.17.0 Phase 3.10 — integer-mode generate_series materialiser.
13611/// Step direction follows the sign: positive step iterates upward
13612/// (stops when current > stop); negative iterates downward; zero
13613/// errors. Caller-facing row stream is `BigInt`-typed so a single
13614/// projection schema covers SmallInt / Int / BigInt callers.
13615fn generate_series_integers(
13616    start: i64,
13617    stop: i64,
13618    step: i64,
13619    cancel: &CancelToken<'_>,
13620) -> Result<alloc::vec::Vec<Row>, EngineError> {
13621    if step == 0 {
13622        return Err(EngineError::Unsupported(
13623            "generate_series(): step argument cannot be zero".into(),
13624        ));
13625    }
13626    let mut out = alloc::vec::Vec::new();
13627    let mut cur = start;
13628    // Hard cap to keep a runaway call from eating all memory. PG
13629    // has no such cap but does honour query timeout; SPG's cancel
13630    // token will fire too — this is a defense-in-depth backstop.
13631    const MAX_ROWS: usize = 10_000_000;
13632    loop {
13633        cancel.check()?;
13634        if step > 0 && cur > stop {
13635            break;
13636        }
13637        if step < 0 && cur < stop {
13638            break;
13639        }
13640        out.push(Row::new(alloc::vec![Value::BigInt(cur)]));
13641        if out.len() > MAX_ROWS {
13642            return Err(EngineError::Unsupported(alloc::format!(
13643                "generate_series(): exceeded {MAX_ROWS} rows; \
13644                 narrow start/stop or use a larger step"
13645            )));
13646        }
13647        cur = match cur.checked_add(step) {
13648            Some(n) => n,
13649            None => break,
13650        };
13651    }
13652    Ok(out)
13653}
13654
13655/// v7.17.0 Phase 3.10 — timestamp-mode generate_series. step is a
13656/// `Value::Interval { months, micros }` per the caller's guard;
13657/// each iteration adds the interval via `apply_binary_interval`
13658/// so month-shifting handles short-month rollover (PG semantics).
13659fn generate_series_timestamps(
13660    start: i64,
13661    stop: i64,
13662    step: Value,
13663    cancel: &CancelToken<'_>,
13664) -> Result<alloc::vec::Vec<Row>, EngineError> {
13665    let (months, micros) = match &step {
13666        Value::Interval { months, micros } => (*months, *micros),
13667        _ => unreachable!("caller guards step.is_interval"),
13668    };
13669    if months == 0 && micros == 0 {
13670        return Err(EngineError::Unsupported(
13671            "generate_series(): INTERVAL step cannot be zero".into(),
13672        ));
13673    }
13674    let ascending = months > 0 || micros > 0;
13675    let mut out = alloc::vec::Vec::new();
13676    let mut cur = Value::Timestamp(start);
13677    const MAX_ROWS: usize = 10_000_000;
13678    loop {
13679        cancel.check()?;
13680        let cur_t = match cur {
13681            Value::Timestamp(t) => t,
13682            _ => unreachable!("loop invariant: cur is Timestamp"),
13683        };
13684        if ascending && cur_t > stop {
13685            break;
13686        }
13687        if !ascending && cur_t < stop {
13688            break;
13689        }
13690        out.push(Row::new(alloc::vec![Value::Timestamp(cur_t)]));
13691        if out.len() > MAX_ROWS {
13692            return Err(EngineError::Unsupported(alloc::format!(
13693                "generate_series(): exceeded {MAX_ROWS} rows; \
13694                 narrow start/stop or use a larger step"
13695            )));
13696        }
13697        let next = eval::apply_binary_interval(
13698            spg_sql::ast::BinOp::Add,
13699            &cur,
13700            &Value::Interval { months, micros },
13701        )
13702        .map_err(EngineError::Eval)?;
13703        cur = match next {
13704            Some(v) => v,
13705            None => break,
13706        };
13707    }
13708    Ok(out)
13709}
13710
13711#[allow(clippy::match_same_arms)] // explicit arms per type document the supported pairs
13712/// v7.24 (round-16 A) — per-key ORDER BY comparator honouring DESC
13713/// and the effective NULLS placement (explicit NULLS FIRST/LAST,
13714/// else the PG default: NULLS LAST for ASC, NULLS FIRST for DESC).
13715/// NULL placement is absolute — it does not flip with DESC.
13716pub(crate) fn order_by_value_cmp(
13717    desc: bool,
13718    nulls_first: Option<bool>,
13719    a: &Value,
13720    b: &Value,
13721) -> core::cmp::Ordering {
13722    use core::cmp::Ordering;
13723    let nf = nulls_first.unwrap_or(desc);
13724    match (matches!(a, Value::Null), matches!(b, Value::Null)) {
13725        (true, true) => Ordering::Equal,
13726        (true, false) => {
13727            if nf {
13728                Ordering::Less
13729            } else {
13730                Ordering::Greater
13731            }
13732        }
13733        (false, true) => {
13734            if nf {
13735                Ordering::Greater
13736            } else {
13737                Ordering::Less
13738            }
13739        }
13740        (false, false) => {
13741            let c = value_cmp(a, b);
13742            if desc { c.reverse() } else { c }
13743        }
13744    }
13745}
13746
13747fn value_cmp(a: &Value, b: &Value) -> core::cmp::Ordering {
13748    use core::cmp::Ordering;
13749    match (a, b) {
13750        (Value::Null, Value::Null) => Ordering::Equal,
13751        (Value::Null, _) => Ordering::Less,
13752        (_, Value::Null) => Ordering::Greater,
13753        (Value::Int(x), Value::Int(y)) => x.cmp(y),
13754        (Value::BigInt(x), Value::BigInt(y)) => x.cmp(y),
13755        (Value::SmallInt(x), Value::SmallInt(y)) => x.cmp(y),
13756        (Value::Text(x), Value::Text(y)) => x.cmp(y),
13757        (Value::Bool(x), Value::Bool(y)) => x.cmp(y),
13758        (Value::Float(x), Value::Float(y)) => x.partial_cmp(y).unwrap_or(Ordering::Equal),
13759        (Value::Date(x), Value::Date(y)) => x.cmp(y),
13760        (Value::Timestamp(x), Value::Timestamp(y)) => x.cmp(y),
13761        // Cross-type compare: fall back to the debug rendering —
13762        // same-partition is the goal, exact order is irrelevant.
13763        _ => alloc::format!("{a:?}").cmp(&alloc::format!("{b:?}")),
13764    }
13765}
13766
13767/// Compute the window function's per-row output for one partition.
13768/// `slice` has (partition key, order key, original-row-index)
13769/// tuples already sorted by order key. `filtered_rows` is the
13770/// full row list indexed by original-row-index. `out_vals` is
13771/// the destination, also indexed by original-row-index.
13772#[allow(
13773    clippy::too_many_arguments,
13774    clippy::cast_possible_truncation,
13775    clippy::cast_possible_wrap,
13776    clippy::cast_precision_loss,
13777    clippy::cast_sign_loss,
13778    clippy::doc_markdown,
13779    clippy::too_many_lines,
13780    clippy::type_complexity,
13781    clippy::match_same_arms
13782)]
13783fn compute_window_partition(
13784    name: &str,
13785    args: &[Expr],
13786    ordered: bool,
13787    frame: Option<&WindowFrame>,
13788    null_treatment: spg_sql::ast::NullTreatment,
13789    slice: &[(Vec<Value>, Vec<(Value, bool, Option<bool>)>, usize)],
13790    filtered_rows: &[&Row],
13791    ctx: &EvalContext<'_>,
13792    out_vals: &mut [Value],
13793) -> Result<(), EngineError> {
13794    let ignore_nulls = matches!(null_treatment, spg_sql::ast::NullTreatment::Ignore);
13795    let lower = name.to_ascii_lowercase();
13796    match lower.as_str() {
13797        "row_number" => {
13798            for (rank, (_, _, idx)) in slice.iter().enumerate() {
13799                out_vals[*idx] = Value::BigInt((rank + 1) as i64);
13800            }
13801            Ok(())
13802        }
13803        "rank" => {
13804            let mut prev_key: Option<&[(Value, bool, Option<bool>)]> = None;
13805            let mut current_rank: i64 = 1;
13806            for (i, (_, okey, idx)) in slice.iter().enumerate() {
13807                if let Some(p) = prev_key
13808                    && order_key_cmp(p, okey) != core::cmp::Ordering::Equal
13809                {
13810                    current_rank = (i + 1) as i64;
13811                }
13812                if prev_key.is_none() {
13813                    current_rank = 1;
13814                }
13815                out_vals[*idx] = Value::BigInt(current_rank);
13816                prev_key = Some(okey.as_slice());
13817            }
13818            Ok(())
13819        }
13820        "dense_rank" => {
13821            let mut prev_key: Option<&[(Value, bool, Option<bool>)]> = None;
13822            let mut current_rank: i64 = 0;
13823            for (_, okey, idx) in slice {
13824                if prev_key.is_none_or(|p| order_key_cmp(p, okey) != core::cmp::Ordering::Equal) {
13825                    current_rank += 1;
13826                }
13827                out_vals[*idx] = Value::BigInt(current_rank);
13828                prev_key = Some(okey.as_slice());
13829            }
13830            Ok(())
13831        }
13832        "sum" | "avg" | "min" | "max" | "count" | "count_star" => {
13833            // Pre-evaluate the function arg per row in the slice
13834            // (count_star has no arg).
13835            let arg_values: Vec<Value> = if lower == "count_star" || args.is_empty() {
13836                slice.iter().map(|_| Value::Null).collect()
13837            } else {
13838                slice
13839                    .iter()
13840                    .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
13841                    .collect::<Result<_, _>>()
13842                    .map_err(EngineError::Eval)?
13843            };
13844            // v4.20: pick the effective frame. Explicit frame
13845            // overrides the implicit default (running for ordered,
13846            // whole-partition for unordered).
13847            let eff = effective_frame(frame, ordered)?;
13848            #[allow(clippy::needless_range_loop)]
13849            for i in 0..slice.len() {
13850                let (lo, hi) = frame_bounds_for_row(&eff, i, slice);
13851                let mut sum: f64 = 0.0;
13852                let mut count: i64 = 0;
13853                let mut min_v: Option<f64> = None;
13854                let mut max_v: Option<f64> = None;
13855                let mut row_count: i64 = 0;
13856                if lo <= hi {
13857                    for j in lo..=hi {
13858                        let v = &arg_values[j];
13859                        match lower.as_str() {
13860                            "count_star" => row_count += 1,
13861                            "count" => {
13862                                if !v.is_null() {
13863                                    count += 1;
13864                                }
13865                            }
13866                            _ => {
13867                                if let Some(x) = value_to_f64(v) {
13868                                    sum += x;
13869                                    count += 1;
13870                                    min_v = Some(min_v.map_or(x, |m| m.min(x)));
13871                                    max_v = Some(max_v.map_or(x, |m| m.max(x)));
13872                                }
13873                            }
13874                        }
13875                    }
13876                }
13877                let value = match lower.as_str() {
13878                    "count_star" => Value::BigInt(row_count),
13879                    "count" => Value::BigInt(count),
13880                    "sum" => Value::Float(sum),
13881                    "avg" => {
13882                        if count == 0 {
13883                            Value::Null
13884                        } else {
13885                            Value::Float(sum / count as f64)
13886                        }
13887                    }
13888                    "min" => min_v.map_or(Value::Null, Value::Float),
13889                    "max" => max_v.map_or(Value::Null, Value::Float),
13890                    _ => unreachable!(),
13891                };
13892                let (_, _, idx) = &slice[i];
13893                out_vals[*idx] = value;
13894            }
13895            Ok(())
13896        }
13897        "lag" | "lead" => {
13898            // lag(expr [, offset [, default]])
13899            // lead(expr [, offset [, default]])
13900            if args.is_empty() {
13901                return Err(EngineError::Unsupported(alloc::format!(
13902                    "{lower}() requires at least one argument"
13903                )));
13904            }
13905            let offset: i64 = if args.len() >= 2 {
13906                let v = eval::eval_expr(&args[1], filtered_rows[slice[0].2], ctx)
13907                    .map_err(EngineError::Eval)?;
13908                match v {
13909                    Value::SmallInt(n) => i64::from(n),
13910                    Value::Int(n) => i64::from(n),
13911                    Value::BigInt(n) => n,
13912                    _ => {
13913                        return Err(EngineError::Unsupported(alloc::format!(
13914                            "{lower}() offset must be integer"
13915                        )));
13916                    }
13917                }
13918            } else {
13919                1
13920            };
13921            let default: Value = if args.len() >= 3 {
13922                eval::eval_expr(&args[2], filtered_rows[slice[0].2], ctx)
13923                    .map_err(EngineError::Eval)?
13924            } else {
13925                Value::Null
13926            };
13927            let values: Vec<Value> = slice
13928                .iter()
13929                .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
13930                .collect::<Result<_, _>>()
13931                .map_err(EngineError::Eval)?;
13932            let n = slice.len();
13933            for (i, (_, _, idx)) in slice.iter().enumerate() {
13934                let signed_offset = if lower == "lag" { -offset } else { offset };
13935                let v = if ignore_nulls {
13936                    // v6.4.2 — IGNORE NULLS: walk in the offset direction
13937                    // skipping NULL values; the `offset`-th non-NULL
13938                    // encountered is the result.
13939                    let step: i64 = if signed_offset >= 0 { 1 } else { -1 };
13940                    let needed: i64 = signed_offset.abs();
13941                    if needed == 0 {
13942                        values[i].clone()
13943                    } else {
13944                        let mut j: i64 = i as i64;
13945                        let mut hits: i64 = 0;
13946                        let mut found: Option<Value> = None;
13947                        loop {
13948                            j += step;
13949                            if j < 0 || j >= n as i64 {
13950                                break;
13951                            }
13952                            #[allow(clippy::cast_sign_loss)]
13953                            let v = &values[j as usize];
13954                            if !v.is_null() {
13955                                hits += 1;
13956                                if hits == needed {
13957                                    found = Some(v.clone());
13958                                    break;
13959                                }
13960                            }
13961                        }
13962                        found.unwrap_or_else(|| default.clone())
13963                    }
13964                } else {
13965                    let target_signed = i64::try_from(i).unwrap_or(i64::MAX) + signed_offset;
13966                    if target_signed < 0 || target_signed >= i64::try_from(n).unwrap_or(i64::MAX) {
13967                        default.clone()
13968                    } else {
13969                        #[allow(clippy::cast_sign_loss)]
13970                        {
13971                            values[target_signed as usize].clone()
13972                        }
13973                    }
13974                };
13975                out_vals[*idx] = v;
13976            }
13977            Ok(())
13978        }
13979        "first_value" | "last_value" | "nth_value" => {
13980            if args.is_empty() {
13981                return Err(EngineError::Unsupported(alloc::format!(
13982                    "{lower}() requires at least one argument"
13983                )));
13984            }
13985            let values: Vec<Value> = slice
13986                .iter()
13987                .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
13988                .collect::<Result<_, _>>()
13989                .map_err(EngineError::Eval)?;
13990            let nth: usize = if lower == "nth_value" {
13991                if args.len() < 2 {
13992                    return Err(EngineError::Unsupported(
13993                        "nth_value() requires (expr, n)".into(),
13994                    ));
13995                }
13996                let v = eval::eval_expr(&args[1], filtered_rows[slice[0].2], ctx)
13997                    .map_err(EngineError::Eval)?;
13998                let raw = match v {
13999                    Value::SmallInt(n) => i64::from(n),
14000                    Value::Int(n) => i64::from(n),
14001                    Value::BigInt(n) => n,
14002                    _ => {
14003                        return Err(EngineError::Unsupported(
14004                            "nth_value() n must be integer".into(),
14005                        ));
14006                    }
14007                };
14008                if raw < 1 {
14009                    return Err(EngineError::Unsupported(
14010                        "nth_value() n must be >= 1".into(),
14011                    ));
14012                }
14013                #[allow(clippy::cast_sign_loss)]
14014                {
14015                    raw as usize
14016                }
14017            } else {
14018                0
14019            };
14020            let eff = effective_frame(frame, ordered)?;
14021            for i in 0..slice.len() {
14022                let (lo, hi) = frame_bounds_for_row(&eff, i, slice);
14023                let (_, _, idx) = &slice[i];
14024                let v = if lo > hi {
14025                    Value::Null
14026                } else if ignore_nulls && matches!(lower.as_str(), "first_value" | "last_value") {
14027                    // v6.4.2 — IGNORE NULLS: skip NULL cells when
14028                    // selecting the boundary value within the frame.
14029                    if lower == "first_value" {
14030                        (lo..=hi)
14031                            .find_map(|j| {
14032                                let v = &values[j];
14033                                (!v.is_null()).then(|| v.clone())
14034                            })
14035                            .unwrap_or(Value::Null)
14036                    } else {
14037                        (lo..=hi)
14038                            .rev()
14039                            .find_map(|j| {
14040                                let v = &values[j];
14041                                (!v.is_null()).then(|| v.clone())
14042                            })
14043                            .unwrap_or(Value::Null)
14044                    }
14045                } else {
14046                    match lower.as_str() {
14047                        "first_value" => values[lo].clone(),
14048                        "last_value" => values[hi].clone(),
14049                        "nth_value" => {
14050                            let pos = lo + nth - 1;
14051                            if pos > hi {
14052                                Value::Null
14053                            } else {
14054                                values[pos].clone()
14055                            }
14056                        }
14057                        _ => unreachable!(),
14058                    }
14059                };
14060                out_vals[*idx] = v;
14061            }
14062            Ok(())
14063        }
14064        "ntile" => {
14065            if args.is_empty() {
14066                return Err(EngineError::Unsupported(
14067                    "ntile(n) requires an integer argument".into(),
14068                ));
14069            }
14070            let v = eval::eval_expr(&args[0], filtered_rows[slice[0].2], ctx)
14071                .map_err(EngineError::Eval)?;
14072            let bucket_count: i64 = match v {
14073                Value::SmallInt(n) => i64::from(n),
14074                Value::Int(n) => i64::from(n),
14075                Value::BigInt(n) => n,
14076                _ => {
14077                    return Err(EngineError::Unsupported(
14078                        "ntile() argument must be integer".into(),
14079                    ));
14080                }
14081            };
14082            if bucket_count < 1 {
14083                return Err(EngineError::Unsupported(
14084                    "ntile() argument must be >= 1".into(),
14085                ));
14086            }
14087            #[allow(clippy::cast_sign_loss)]
14088            let buckets = bucket_count as usize;
14089            let n = slice.len();
14090            // Each bucket gets `base` rows; the first `extras` buckets
14091            // get one extra. PG semantics.
14092            let base = n / buckets;
14093            let extras = n % buckets;
14094            let mut bucket: usize = 1;
14095            let mut remaining_in_bucket = if extras > 0 { base + 1 } else { base };
14096            let mut buckets_with_extra_remaining = extras;
14097            for (_, _, idx) in slice {
14098                if remaining_in_bucket == 0 {
14099                    bucket += 1;
14100                    buckets_with_extra_remaining = buckets_with_extra_remaining.saturating_sub(1);
14101                    remaining_in_bucket = if buckets_with_extra_remaining > 0 {
14102                        base + 1
14103                    } else {
14104                        base
14105                    };
14106                    // Edge: if base==0 and extras==0, all rows fit;
14107                    // shouldn't reach here, but guard anyway.
14108                    if remaining_in_bucket == 0 {
14109                        remaining_in_bucket = 1;
14110                    }
14111                }
14112                out_vals[*idx] = Value::BigInt(i64::try_from(bucket).unwrap_or(i64::MAX));
14113                remaining_in_bucket -= 1;
14114            }
14115            Ok(())
14116        }
14117        "percent_rank" => {
14118            // (rank - 1) / (n - 1) where rank is the standard RANK().
14119            // Single-row partitions get 0.
14120            let n = slice.len();
14121            let mut prev_key: Option<&[(Value, bool, Option<bool>)]> = None;
14122            let mut current_rank: i64 = 1;
14123            for (i, (_, okey, idx)) in slice.iter().enumerate() {
14124                if let Some(p) = prev_key
14125                    && order_key_cmp(p, okey) != core::cmp::Ordering::Equal
14126                {
14127                    current_rank = i64::try_from(i + 1).unwrap_or(i64::MAX);
14128                }
14129                if prev_key.is_none() {
14130                    current_rank = 1;
14131                }
14132                #[allow(clippy::cast_precision_loss)]
14133                let pr = if n <= 1 {
14134                    0.0
14135                } else {
14136                    (current_rank - 1) as f64 / (n - 1) as f64
14137                };
14138                out_vals[*idx] = Value::Float(pr);
14139                prev_key = Some(okey.as_slice());
14140            }
14141            Ok(())
14142        }
14143        "cume_dist" => {
14144            // # rows up to and including this row's peer group / n.
14145            let n = slice.len();
14146            // First pass: find peer-group-end rank for each row.
14147            for i in 0..slice.len() {
14148                let peer_end = peer_group_end(slice, i);
14149                #[allow(clippy::cast_precision_loss)]
14150                let cd = (peer_end + 1) as f64 / n as f64;
14151                let (_, _, idx) = &slice[i];
14152                out_vals[*idx] = Value::Float(cd);
14153            }
14154            Ok(())
14155        }
14156        other => Err(EngineError::Unsupported(alloc::format!(
14157            "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)"
14158        ))),
14159    }
14160}
14161
14162/// v4.20: resolve the user-provided frame down to a normalised
14163/// `(kind, start, end)`. `None` means default — derive from
14164/// `ordered`: ordered ⇒ RANGE UNBOUNDED PRECEDING AND CURRENT ROW,
14165/// unordered ⇒ ROWS UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
14166/// Single-bound shorthand (e.g. `ROWS 5 PRECEDING`) normalises
14167/// end → CURRENT ROW per the PG spec.
14168fn effective_frame(
14169    frame: Option<&WindowFrame>,
14170    ordered: bool,
14171) -> Result<(FrameKind, FrameBound, FrameBound), EngineError> {
14172    match frame {
14173        None => {
14174            if ordered {
14175                Ok((
14176                    FrameKind::Range,
14177                    FrameBound::UnboundedPreceding,
14178                    FrameBound::CurrentRow,
14179                ))
14180            } else {
14181                Ok((
14182                    FrameKind::Rows,
14183                    FrameBound::UnboundedPreceding,
14184                    FrameBound::UnboundedFollowing,
14185                ))
14186            }
14187        }
14188        Some(fr) => {
14189            let end = fr.end.clone().unwrap_or(FrameBound::CurrentRow);
14190            // Reject start > end (a few impossible combinations).
14191            if matches!(fr.start, FrameBound::UnboundedFollowing)
14192                || matches!(end, FrameBound::UnboundedPreceding)
14193            {
14194                return Err(EngineError::Unsupported(alloc::format!(
14195                    "invalid frame: start={:?} end={:?}",
14196                    fr.start,
14197                    end
14198                )));
14199            }
14200            // RANGE OFFSET PRECEDING / FOLLOWING needs value-typed
14201            // arithmetic on the ORDER BY key (e.g. `RANGE BETWEEN
14202            // INTERVAL '1 day' PRECEDING AND CURRENT ROW`). Not
14203            // implemented in v4.20.
14204            if fr.kind == FrameKind::Range
14205                && (matches!(
14206                    fr.start,
14207                    FrameBound::OffsetPreceding(_) | FrameBound::OffsetFollowing(_)
14208                ) || matches!(
14209                    end,
14210                    FrameBound::OffsetPreceding(_) | FrameBound::OffsetFollowing(_)
14211                ))
14212            {
14213                return Err(EngineError::Unsupported(
14214                    "RANGE with explicit offset bounds is not supported (v4.20: only UNBOUNDED / CURRENT ROW for RANGE)".into(),
14215                ));
14216            }
14217            Ok((fr.kind, fr.start.clone(), end))
14218        }
14219    }
14220}
14221
14222/// Compute `(lo, hi)` row-index bounds inside the partition slice
14223/// for the row at position `i`. Inclusive, clamped to
14224/// `[0, slice.len()-1]`. Empty result if `lo > hi`.
14225#[allow(clippy::type_complexity)]
14226fn frame_bounds_for_row(
14227    eff: &(FrameKind, FrameBound, FrameBound),
14228    i: usize,
14229    slice: &[(Vec<Value>, Vec<(Value, bool, Option<bool>)>, usize)],
14230) -> (usize, usize) {
14231    let (kind, start, end) = eff;
14232    let n = slice.len();
14233    let last = n.saturating_sub(1);
14234    let (mut lo, mut hi) = match kind {
14235        FrameKind::Rows => {
14236            let lo = match start {
14237                FrameBound::UnboundedPreceding => 0,
14238                FrameBound::OffsetPreceding(k) => {
14239                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
14240                    i.saturating_sub(k)
14241                }
14242                FrameBound::CurrentRow => i,
14243                FrameBound::OffsetFollowing(k) => {
14244                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
14245                    i.saturating_add(k).min(last)
14246                }
14247                FrameBound::UnboundedFollowing => last,
14248            };
14249            let hi = match end {
14250                FrameBound::UnboundedPreceding => 0,
14251                FrameBound::OffsetPreceding(k) => {
14252                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
14253                    i.saturating_sub(k)
14254                }
14255                FrameBound::CurrentRow => i,
14256                FrameBound::OffsetFollowing(k) => {
14257                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
14258                    i.saturating_add(k).min(last)
14259                }
14260                FrameBound::UnboundedFollowing => last,
14261            };
14262            (lo, hi)
14263        }
14264        FrameKind::Range => {
14265            // RANGE bounds are peer-aware. With only UNBOUNDED and
14266            // CURRENT ROW supported (rejected at effective_frame for
14267            // explicit offsets), the start/end map to the
14268            // partition's full extent at the same-order-key peer
14269            // group boundary.
14270            let lo = match start {
14271                FrameBound::UnboundedPreceding => 0,
14272                FrameBound::CurrentRow => peer_group_start(slice, i),
14273                FrameBound::UnboundedFollowing => last,
14274                _ => unreachable!("offset bounds rejected for RANGE"),
14275            };
14276            let hi = match end {
14277                FrameBound::UnboundedPreceding => 0,
14278                FrameBound::CurrentRow => peer_group_end(slice, i),
14279                FrameBound::UnboundedFollowing => last,
14280                _ => unreachable!("offset bounds rejected for RANGE"),
14281            };
14282            (lo, hi)
14283        }
14284    };
14285    if hi >= n {
14286        hi = last;
14287    }
14288    if lo >= n {
14289        lo = last;
14290    }
14291    (lo, hi)
14292}
14293
14294/// Find the inclusive index of the first row with the same ORDER
14295/// BY key as `slice[i]`. Slice is already sorted by partition then
14296/// order, so peers are contiguous.
14297#[allow(clippy::type_complexity)]
14298fn peer_group_start(
14299    slice: &[(Vec<Value>, Vec<(Value, bool, Option<bool>)>, usize)],
14300    i: usize,
14301) -> usize {
14302    let key = &slice[i].1;
14303    let mut j = i;
14304    while j > 0 && order_key_cmp(&slice[j - 1].1, key) == core::cmp::Ordering::Equal {
14305        j -= 1;
14306    }
14307    j
14308}
14309
14310/// Find the inclusive index of the last row with the same ORDER
14311/// BY key as `slice[i]`.
14312#[allow(clippy::type_complexity)]
14313fn peer_group_end(
14314    slice: &[(Vec<Value>, Vec<(Value, bool, Option<bool>)>, usize)],
14315    i: usize,
14316) -> usize {
14317    let key = &slice[i].1;
14318    let mut j = i;
14319    while j + 1 < slice.len() && order_key_cmp(&slice[j + 1].1, key) == core::cmp::Ordering::Equal {
14320        j += 1;
14321    }
14322    j
14323}
14324
14325fn value_to_f64(v: &Value) -> Option<f64> {
14326    match v {
14327        Value::SmallInt(n) => Some(f64::from(*n)),
14328        Value::Int(n) => Some(f64::from(*n)),
14329        #[allow(clippy::cast_precision_loss)]
14330        Value::BigInt(n) => Some(*n as f64),
14331        Value::Float(x) => Some(*x),
14332        _ => None,
14333    }
14334}
14335
14336/// Quick scan for any subquery-bearing node in a SELECT's WHERE /
14337/// projection / `order_by` — saves cloning the AST when there are
14338/// none (the common case).
14339fn expr_tree_has_subquery(stmt: &SelectStatement) -> bool {
14340    let mut any = false;
14341    for item in &stmt.items {
14342        if let SelectItem::Expr { expr, .. } = item {
14343            any = any || expr_has_subquery(expr);
14344        }
14345    }
14346    if let Some(w) = &stmt.where_ {
14347        any = any || expr_has_subquery(w);
14348    }
14349    if let Some(h) = &stmt.having {
14350        any = any || expr_has_subquery(h);
14351    }
14352    for o in &stmt.order_by {
14353        any = any || expr_has_subquery(&o.expr);
14354    }
14355    for (_, peer) in &stmt.unions {
14356        any = any || expr_tree_has_subquery(peer);
14357    }
14358    any
14359}
14360
14361pub(crate) fn expr_has_subquery(e: &Expr) -> bool {
14362    match e {
14363        Expr::ScalarSubquery(_) | Expr::Exists { .. } | Expr::InSubquery { .. } => true,
14364        Expr::AggregateOrdered { call, order_by, .. } => {
14365            expr_has_subquery(call) || order_by.iter().any(|o| expr_has_subquery(&o.expr))
14366        }
14367        Expr::Binary { lhs, rhs, .. } => expr_has_subquery(lhs) || expr_has_subquery(rhs),
14368        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
14369            expr_has_subquery(expr)
14370        }
14371        Expr::FunctionCall { args, .. } => args.iter().any(expr_has_subquery),
14372        Expr::Like { expr, pattern, .. } => expr_has_subquery(expr) || expr_has_subquery(pattern),
14373        Expr::Extract { source, .. } => expr_has_subquery(source),
14374        Expr::WindowFunction {
14375            args,
14376            partition_by,
14377            order_by,
14378            ..
14379        } => {
14380            args.iter().any(expr_has_subquery)
14381                || partition_by.iter().any(expr_has_subquery)
14382                || order_by.iter().any(|(e, _, _)| expr_has_subquery(e))
14383        }
14384        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => false,
14385        Expr::Array(items) => items.iter().any(expr_has_subquery),
14386        Expr::ArraySubscript { target, index } => {
14387            expr_has_subquery(target) || expr_has_subquery(index)
14388        }
14389        Expr::AnyAll { expr, array, .. } => expr_has_subquery(expr) || expr_has_subquery(array),
14390        Expr::Case {
14391            operand,
14392            branches,
14393            else_branch,
14394        } => {
14395            operand.as_deref().is_some_and(expr_has_subquery)
14396                || branches
14397                    .iter()
14398                    .any(|(w, t)| expr_has_subquery(w) || expr_has_subquery(t))
14399                || else_branch.as_deref().is_some_and(expr_has_subquery)
14400        }
14401    }
14402}
14403
14404/// v4.10 helper: materialise a runtime `Value` back into an AST
14405/// `Expr::Literal` for the subquery-rewrite path. Supports the
14406/// types `Literal` can represent (Integer / Float / Text / Bool /
14407/// Null). Date / Timestamp / Numeric / Vector / Interval / JSON
14408/// would lose precision through Literal and aren't supported in
14409/// uncorrelated-subquery results; they error with a clear hint.
14410fn value_to_literal_expr(v: Value) -> Result<Expr, EngineError> {
14411    let lit = match v {
14412        Value::Null => Literal::Null,
14413        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
14414        Value::Int(n) => Literal::Integer(i64::from(n)),
14415        Value::BigInt(n) => Literal::Integer(n),
14416        Value::Float(x) => Literal::Float(x),
14417        Value::Text(s) | Value::Json(s) => Literal::String(s),
14418        Value::Bool(b) => Literal::Bool(b),
14419        other => {
14420            return Err(EngineError::Unsupported(alloc::format!(
14421                "subquery result type {:?} not yet materialisable; cast to text or integer in the inner SELECT",
14422                other.data_type()
14423            )));
14424        }
14425    };
14426    Ok(Expr::Literal(lit))
14427}
14428
14429/// v7.13.0 — wider helper used by `INSERT … SELECT` (mailrs
14430/// round-5 G4). Covers the most common `Value` variants. Types
14431/// that need lossy textual round-trip (BYTEA, arrays, ts*)
14432/// surface as an Unsupported error so the caller can add a cast
14433/// in the inner SELECT.
14434fn value_to_literal_expr_permissive(v: Value) -> Result<Expr, EngineError> {
14435    let lit = match v {
14436        Value::Null => Literal::Null,
14437        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
14438        Value::Int(n) => Literal::Integer(i64::from(n)),
14439        Value::BigInt(n) => Literal::Integer(n),
14440        Value::Float(x) => Literal::Float(x),
14441        Value::Text(s) | Value::Json(s) => Literal::String(s),
14442        Value::Bool(b) => Literal::Bool(b),
14443        Value::Vector(xs) => Literal::Vector(xs),
14444        // Date / Timestamp / Timestamptz / Numeric round-trip
14445        // through a TEXT literal that `coerce_value` re-parses
14446        // against the target column type.
14447        Value::Date(days) => {
14448            let micros = (i64::from(days)) * 86_400_000_000;
14449            Literal::String(format_timestamp_micros_as_date(micros))
14450        }
14451        Value::Timestamp(us) => Literal::String(format_timestamp_micros(us)),
14452        Value::Numeric { scaled, scale } => Literal::String(format_numeric(scaled, scale)),
14453        other => {
14454            return Err(EngineError::Unsupported(alloc::format!(
14455                "INSERT … SELECT cannot materialise value of type {:?}; \
14456                 add an explicit CAST in the inner SELECT",
14457                other.data_type()
14458            )));
14459        }
14460    };
14461    Ok(Expr::Literal(lit))
14462}
14463
14464fn format_timestamp_micros(us: i64) -> String {
14465    // Same Y/M/D split used by the wire layer; epoch-relative.
14466    let days = us.div_euclid(86_400_000_000);
14467    let intra_day = us.rem_euclid(86_400_000_000);
14468    let date = format_timestamp_micros_as_date(days * 86_400_000_000);
14469    let secs = intra_day / 1_000_000;
14470    let us_rem = intra_day % 1_000_000;
14471    let h = (secs / 3600) % 24;
14472    let m = (secs / 60) % 60;
14473    let s = secs % 60;
14474    if us_rem == 0 {
14475        alloc::format!("{date} {h:02}:{m:02}:{s:02}")
14476    } else {
14477        alloc::format!("{date} {h:02}:{m:02}:{s:02}.{us_rem:06}")
14478    }
14479}
14480
14481fn format_timestamp_micros_as_date(us: i64) -> String {
14482    // Days since 1970-01-01 → calendar Y-M-D via the proleptic
14483    // Gregorian conversion used by spg-engine's date helpers.
14484    let days = us.div_euclid(86_400_000_000);
14485    // 1970-01-01 = JDN 2440588.
14486    let jdn = days + 2_440_588;
14487    let (y, mo, d) = jdn_to_ymd(jdn);
14488    alloc::format!("{y:04}-{mo:02}-{d:02}")
14489}
14490
14491fn jdn_to_ymd(jdn: i64) -> (i64, u32, u32) {
14492    // Fliegel & Van Flandern (1968) — works for all positive JDNs.
14493    let l = jdn + 68569;
14494    let n = (4 * l) / 146_097;
14495    let l = l - (146_097 * n + 3) / 4;
14496    let i = (4000 * (l + 1)) / 1_461_001;
14497    let l = l - (1461 * i) / 4 + 31;
14498    let j = (80 * l) / 2447;
14499    let day = (l - (2447 * j) / 80) as u32;
14500    let l = j / 11;
14501    let month = (j + 2 - 12 * l) as u32;
14502    let year = 100 * (n - 49) + i + l;
14503    (year, month, day)
14504}
14505
14506fn format_numeric(scaled: i128, scale: u8) -> String {
14507    if scale == 0 {
14508        return alloc::format!("{scaled}");
14509    }
14510    let abs = scaled.unsigned_abs();
14511    let divisor = 10u128.pow(u32::from(scale));
14512    let whole = abs / divisor;
14513    let frac = abs % divisor;
14514    let sign = if scaled < 0 { "-" } else { "" };
14515    alloc::format!("{sign}{whole}.{frac:0width$}", width = usize::from(scale))
14516}
14517
14518/// v6.1.1 — walk the prepared `Statement` AST and replace every
14519/// `Expr::Placeholder(n)` with `Expr::Literal(value_to_literal(
14520/// params[n-1]))`. The dispatch downstream sees a `Statement`
14521/// indistinguishable from a simple-query parse, so the exec path
14522/// stays unchanged.
14523///
14524/// Errors fall into one shape: a `$N` references past the bound
14525/// `params.len()`. Out-of-range happens when the Bind didn't
14526/// supply enough values; pgwire surfaces this as a protocol error
14527/// to the client.
14528/// v7.15.0 — rewrite every (potentially-qualified) column
14529/// identifier matching `old` to `new` in a stored SQL source
14530/// string. Used by `ALTER TABLE … RENAME COLUMN` to patch
14531/// CHECK predicate sources, partial-index predicate sources,
14532/// and runtime DEFAULT expression sources before they get
14533/// re-parsed on the next INSERT/UPDATE.
14534///
14535/// Round-trips through the parser, so the rewritten output is
14536/// the canonical Display form (matches what the engine stores
14537/// for fresh predicates). If the source doesn't parse, surfaces
14538/// the parse error — the invariant that stored predicates are
14539/// in canonical Display form means a parse failure here is a
14540/// real bug, not a user mistake to swallow.
14541fn rewrite_column_in_source(
14542    src: &str,
14543    old: &str,
14544    new: &str,
14545) -> Result<alloc::string::String, EngineError> {
14546    let mut expr = spg_sql::parser::parse_expression(src).map_err(|e| {
14547        EngineError::Unsupported(alloc::format!(
14548            "ALTER TABLE RENAME COLUMN: stored predicate source {src:?} \
14549             failed to parse for rewrite ({e})"
14550        ))
14551    })?;
14552    rewrite_column_in_expr(&mut expr, old, new);
14553    Ok(alloc::format!("{expr}"))
14554}
14555
14556/// v7.15.0 — Expr walker that swaps `Expr::Column { name: old, .. }`
14557/// for `Expr::Column { name: new, .. }`. Qualifier is preserved
14558/// (e.g. `t.old` → `t.new`); a foreign-table qualifier still
14559/// gets rewritten because the AST has no way to tell us this
14560/// predicate is on table T versus table T2 — predicate sources
14561/// in SPG are always scoped to the owning table, so any
14562/// qualifier present is either redundant or wrong.
14563fn rewrite_column_in_expr(e: &mut Expr, old: &str, new: &str) {
14564    match e {
14565        Expr::AggregateOrdered { call, order_by, .. } => {
14566            rewrite_column_in_expr(call, old, new);
14567            for o in order_by.iter_mut() {
14568                rewrite_column_in_expr(&mut o.expr, old, new);
14569            }
14570        }
14571        Expr::Column(c) => {
14572            if c.name.eq_ignore_ascii_case(old) {
14573                c.name = new.to_string();
14574            }
14575        }
14576        Expr::Binary { lhs, rhs, .. } => {
14577            rewrite_column_in_expr(lhs, old, new);
14578            rewrite_column_in_expr(rhs, old, new);
14579        }
14580        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
14581            rewrite_column_in_expr(expr, old, new);
14582        }
14583        Expr::FunctionCall { args, .. } => {
14584            for a in args {
14585                rewrite_column_in_expr(a, old, new);
14586            }
14587        }
14588        Expr::Like { expr, pattern, .. } => {
14589            rewrite_column_in_expr(expr, old, new);
14590            rewrite_column_in_expr(pattern, old, new);
14591        }
14592        Expr::Extract { source, .. } => rewrite_column_in_expr(source, old, new),
14593        Expr::WindowFunction {
14594            args,
14595            partition_by,
14596            order_by,
14597            ..
14598        } => {
14599            for a in args {
14600                rewrite_column_in_expr(a, old, new);
14601            }
14602            for p in partition_by {
14603                rewrite_column_in_expr(p, old, new);
14604            }
14605            for (o, _, _) in order_by {
14606                rewrite_column_in_expr(o, old, new);
14607            }
14608        }
14609        Expr::Array(items) => {
14610            for elem in items {
14611                rewrite_column_in_expr(elem, old, new);
14612            }
14613        }
14614        Expr::ArraySubscript { target, index } => {
14615            rewrite_column_in_expr(target, old, new);
14616            rewrite_column_in_expr(index, old, new);
14617        }
14618        Expr::AnyAll { expr, array, .. } => {
14619            rewrite_column_in_expr(expr, old, new);
14620            rewrite_column_in_expr(array, old, new);
14621        }
14622        Expr::Case {
14623            operand,
14624            branches,
14625            else_branch,
14626        } => {
14627            if let Some(o) = operand {
14628                rewrite_column_in_expr(o, old, new);
14629            }
14630            for (w, t) in branches {
14631                rewrite_column_in_expr(w, old, new);
14632                rewrite_column_in_expr(t, old, new);
14633            }
14634            if let Some(e) = else_branch {
14635                rewrite_column_in_expr(e, old, new);
14636            }
14637        }
14638        // Stored predicate sources never contain subqueries —
14639        // CHECK / partial-index / runtime_default are all scalar.
14640        // If a future feature changes that, recurse here.
14641        Expr::ScalarSubquery(_) | Expr::Exists { .. } | Expr::InSubquery { .. } => {}
14642        Expr::Literal(_) | Expr::Placeholder(_) => {}
14643    }
14644}
14645
14646/// v7.16.0 — walks a parsed statement and replaces every
14647/// `Expr::Placeholder(N)` with the corresponding `params[N-1]`
14648/// re-encoded as an `Expr::Literal`. Used internally by
14649/// `Engine::execute_prepared` AND surfaced for the spg-embedded
14650/// WAL path (which needs the bind-final AST so replay sees a
14651/// simple-query-shaped statement, not a `$1`-shaped one). Errors
14652/// when a placeholder references an index past the params slice.
14653pub fn substitute_placeholders(stmt: &mut Statement, params: &[Value]) -> Result<(), EngineError> {
14654    match stmt {
14655        Statement::Select(s) => substitute_select(s, params)?,
14656        Statement::Insert(ins) => {
14657            for row in &mut ins.rows {
14658                for e in row {
14659                    substitute_expr(e, params)?;
14660                }
14661            }
14662            // ON CONFLICT DO UPDATE assignments / WHERE can carry
14663            // placeholders too (`… DO UPDATE SET reason = $2` —
14664            // mailrs embed round-12).
14665            if let Some(clause) = &mut ins.on_conflict
14666                && let spg_sql::ast::OnConflictAction::Update {
14667                    assignments,
14668                    where_,
14669                } = &mut clause.action
14670            {
14671                for (_, e) in assignments.iter_mut() {
14672                    substitute_expr(e, params)?;
14673                }
14674                if let Some(w) = where_ {
14675                    substitute_expr(w, params)?;
14676                }
14677            }
14678        }
14679        Statement::Update(u) => {
14680            for (_, e) in &mut u.assignments {
14681                substitute_expr(e, params)?;
14682            }
14683            if let Some(w) = &mut u.where_ {
14684                substitute_expr(w, params)?;
14685            }
14686        }
14687        Statement::Delete(d) => {
14688            if let Some(w) = &mut d.where_ {
14689                substitute_expr(w, params)?;
14690            }
14691        }
14692        Statement::Explain(e) => substitute_select(&mut e.inner, params)?,
14693        // Other statements (CREATE / BEGIN / SHOW / …) have no
14694        // expression slots; no walk needed.
14695        _ => {}
14696    }
14697    Ok(())
14698}
14699
14700/// v7.25.1 (mailrs round-18) — THE canonical mutable traversal of
14701/// every expression slot in a SelectStatement, including every
14702/// nested SelectStatement (CTE bodies, UNION peers, LATERAL derived
14703/// tables) and the JOIN ON conditions. Round-12 #7b and round-18
14704/// were both "a hand-rolled Select walker forgot one subtree";
14705/// every whole-statement rewrite pass (placeholders, clock) must go
14706/// through here so a new AST slot only needs adding once.
14707/// Expression-INTERNAL recursion (into subquery nodes inside an
14708/// Expr) stays the visitor's own responsibility.
14709pub(crate) fn walk_select_exprs_mut(
14710    s: &mut SelectStatement,
14711    f: &mut impl FnMut(&mut Expr) -> Result<(), EngineError>,
14712) -> Result<(), EngineError> {
14713    for cte in &mut s.ctes {
14714        walk_select_exprs_mut(&mut cte.body, f)?;
14715    }
14716    for item in &mut s.items {
14717        if let SelectItem::Expr { expr, .. } = item {
14718            f(expr)?;
14719        }
14720    }
14721    if let Some(from) = &mut s.from {
14722        if let Some(sub) = &mut from.primary.lateral_subquery {
14723            walk_select_exprs_mut(sub, f)?;
14724        }
14725        for j in &mut from.joins {
14726            if let Some(sub) = &mut j.table.lateral_subquery {
14727                walk_select_exprs_mut(sub, f)?;
14728            }
14729            if let Some(on) = &mut j.on {
14730                f(on)?;
14731            }
14732        }
14733    }
14734    if let Some(w) = &mut s.where_ {
14735        f(w)?;
14736    }
14737    if let Some(gs) = &mut s.group_by {
14738        for g in gs {
14739            f(g)?;
14740        }
14741    }
14742    if let Some(h) = &mut s.having {
14743        f(h)?;
14744    }
14745    for o in &mut s.order_by {
14746        f(&mut o.expr)?;
14747    }
14748    for (_, peer) in &mut s.unions {
14749        walk_select_exprs_mut(peer, f)?;
14750    }
14751    Ok(())
14752}
14753
14754fn substitute_select(s: &mut SelectStatement, params: &[Value]) -> Result<(), EngineError> {
14755    walk_select_exprs_mut(s, &mut |e| substitute_expr(e, params))?;
14756    // v7.25.1 — LIMIT/OFFSET placeholders inside CTE bodies and
14757    // UNION peers resolve through their own recursion (the walker
14758    // above only visits Expr slots), so handle them per nested
14759    // statement here.
14760    for cte in &mut s.ctes {
14761        resolve_limit_offset_placeholders(&mut cte.body, params)?;
14762    }
14763    for (_, peer) in &mut s.unions {
14764        resolve_limit_offset_placeholders(peer, params)?;
14765    }
14766    // v7.9.24 — LIMIT $N / OFFSET $N placeholder resolution.
14767    // mailrs H2. After this pass each LIMIT/OFFSET that was a
14768    // Placeholder is rewritten to Literal so the existing
14769    // `LimitExpr::as_literal` path consumes a concrete u32.
14770    if let Some(le) = s.limit {
14771        s.limit = Some(resolve_limit_placeholder(le, params)?);
14772    }
14773    if let Some(le) = s.offset {
14774        s.offset = Some(resolve_limit_placeholder(le, params)?);
14775    }
14776    Ok(())
14777}
14778
14779/// v7.25.1 — recursive LIMIT/OFFSET placeholder resolution for
14780/// nested statements (CTE bodies / UNION peers).
14781fn resolve_limit_offset_placeholders(
14782    s: &mut SelectStatement,
14783    params: &[Value],
14784) -> Result<(), EngineError> {
14785    if let Some(le) = s.limit {
14786        s.limit = Some(resolve_limit_placeholder(le, params)?);
14787    }
14788    if let Some(le) = s.offset {
14789        s.offset = Some(resolve_limit_placeholder(le, params)?);
14790    }
14791    for cte in &mut s.ctes {
14792        resolve_limit_offset_placeholders(&mut cte.body, params)?;
14793    }
14794    for (_, peer) in &mut s.unions {
14795        resolve_limit_offset_placeholders(peer, params)?;
14796    }
14797    Ok(())
14798}
14799
14800fn resolve_limit_placeholder(
14801    le: spg_sql::ast::LimitExpr,
14802    params: &[Value],
14803) -> Result<spg_sql::ast::LimitExpr, EngineError> {
14804    use spg_sql::ast::LimitExpr;
14805    match le {
14806        LimitExpr::Literal(_) => Ok(le),
14807        LimitExpr::Placeholder(n) => {
14808            let idx = usize::from(n).saturating_sub(1);
14809            let v = params.get(idx).ok_or_else(|| {
14810                EngineError::Eval(EvalError::PlaceholderOutOfRange {
14811                    n,
14812                    bound: u16::try_from(params.len()).unwrap_or(u16::MAX),
14813                })
14814            })?;
14815            let int = match v {
14816                Value::SmallInt(x) => Some(i64::from(*x)),
14817                Value::Int(x) => Some(i64::from(*x)),
14818                Value::BigInt(x) => Some(*x),
14819                _ => None,
14820            }
14821            .ok_or_else(|| {
14822                EngineError::Unsupported(alloc::format!(
14823                    "LIMIT/OFFSET ${n} bound to non-integer {v:?}"
14824                ))
14825            })?;
14826            if int < 0 {
14827                return Err(EngineError::Unsupported(alloc::format!(
14828                    "LIMIT/OFFSET ${n} bound to negative value {int}"
14829                )));
14830            }
14831            let bounded = u32::try_from(int).map_err(|_| {
14832                EngineError::Unsupported(alloc::format!(
14833                    "LIMIT/OFFSET ${n} value {int} exceeds u32 range"
14834                ))
14835            })?;
14836            Ok(LimitExpr::Literal(bounded))
14837        }
14838    }
14839}
14840
14841fn substitute_expr(e: &mut Expr, params: &[Value]) -> Result<(), EngineError> {
14842    if let Expr::Placeholder(n) = e {
14843        let idx = usize::from(*n).saturating_sub(1);
14844        let v = params.get(idx).ok_or_else(|| {
14845            EngineError::Eval(EvalError::PlaceholderOutOfRange {
14846                n: *n,
14847                bound: u16::try_from(params.len()).unwrap_or(u16::MAX),
14848            })
14849        })?;
14850        *e = Expr::Literal(value_to_literal(v.clone()));
14851        return Ok(());
14852    }
14853    match e {
14854        Expr::AggregateOrdered { call, order_by, .. } => {
14855            substitute_expr(call, params)?;
14856            for o in order_by.iter_mut() {
14857                substitute_expr(&mut o.expr, params)?;
14858            }
14859        }
14860        Expr::Binary { lhs, rhs, .. } => {
14861            substitute_expr(lhs, params)?;
14862            substitute_expr(rhs, params)?;
14863        }
14864        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
14865            substitute_expr(expr, params)?;
14866        }
14867        Expr::FunctionCall { args, .. } => {
14868            for a in args {
14869                substitute_expr(a, params)?;
14870            }
14871        }
14872        Expr::Like { expr, pattern, .. } => {
14873            substitute_expr(expr, params)?;
14874            substitute_expr(pattern, params)?;
14875        }
14876        Expr::Extract { source, .. } => substitute_expr(source, params)?,
14877        Expr::ScalarSubquery(s) => substitute_select(s, params)?,
14878        Expr::Exists { subquery, .. } => substitute_select(subquery, params)?,
14879        Expr::InSubquery { expr, subquery, .. } => {
14880            substitute_expr(expr, params)?;
14881            substitute_select(subquery, params)?;
14882        }
14883        Expr::WindowFunction {
14884            args,
14885            partition_by,
14886            order_by,
14887            ..
14888        } => {
14889            for a in args {
14890                substitute_expr(a, params)?;
14891            }
14892            for p in partition_by {
14893                substitute_expr(p, params)?;
14894            }
14895            for (e, _, _) in order_by {
14896                substitute_expr(e, params)?;
14897            }
14898        }
14899        Expr::Literal(_) | Expr::Column(_) => {}
14900        // Already handled above.
14901        Expr::Placeholder(_) => unreachable!("Placeholder handled at top of fn"),
14902        Expr::Array(items) => {
14903            for elem in items {
14904                substitute_expr(elem, params)?;
14905            }
14906        }
14907        Expr::ArraySubscript { target, index } => {
14908            substitute_expr(target, params)?;
14909            substitute_expr(index, params)?;
14910        }
14911        Expr::AnyAll { expr, array, .. } => {
14912            substitute_expr(expr, params)?;
14913            substitute_expr(array, params)?;
14914        }
14915        Expr::Case {
14916            operand,
14917            branches,
14918            else_branch,
14919        } => {
14920            if let Some(o) = operand {
14921                substitute_expr(o, params)?;
14922            }
14923            for (w, t) in branches {
14924                substitute_expr(w, params)?;
14925                substitute_expr(t, params)?;
14926            }
14927            if let Some(e) = else_branch {
14928                substitute_expr(e, params)?;
14929            }
14930        }
14931    }
14932    Ok(())
14933}
14934
14935/// v6.1.1 — convert a runtime `Value` into the closest matching
14936/// `Literal` for the substitute walker. Lossless for the simple
14937/// scalars (Int / Float / Text / Bool); Numeric / Date / Timestamp
14938/// / Json / Interval render as their canonical text form so the
14939/// downstream coerce_value can re-parse against the target column
14940/// type. SQ8 / HalfVector cells are NOT expected as bind params;
14941/// pgwire's Bind decodes vector params to the f32 representation
14942/// before they reach this helper.
14943/// v6.2.0 — total ordering on `Value`s used by ANALYZE to sort a
14944/// column's non-NULL sample before histogram building. Cross-type
14945/// pairs (Int vs Float, Date vs Timestamp, …) compare via the
14946/// same widening the eval-side `compare` operator uses; everything
14947/// else (the genuinely-incompatible pairs) falls back to ordering
14948/// by canonical string form so the sort is still total + stable.
14949/// Vector / SQ8 / Half / Json / Numeric / Interval values reach
14950/// here only via the string-fallback path because vector columns
14951/// are filtered out upstream.
14952fn sort_values_for_histogram(a: &Value, b: &Value) -> core::cmp::Ordering {
14953    use core::cmp::Ordering;
14954    match (a, b) {
14955        (Value::SmallInt(a), Value::SmallInt(b)) => a.cmp(b),
14956        (Value::Int(a), Value::Int(b)) => a.cmp(b),
14957        (Value::BigInt(a), Value::BigInt(b)) => a.cmp(b),
14958        (Value::SmallInt(a), Value::Int(b)) => i32::from(*a).cmp(b),
14959        (Value::Int(a), Value::SmallInt(b)) => a.cmp(&i32::from(*b)),
14960        (Value::Int(a), Value::BigInt(b)) => i64::from(*a).cmp(b),
14961        (Value::BigInt(a), Value::Int(b)) => a.cmp(&i64::from(*b)),
14962        (Value::SmallInt(a), Value::BigInt(b)) => i64::from(*a).cmp(b),
14963        (Value::BigInt(a), Value::SmallInt(b)) => a.cmp(&i64::from(*b)),
14964        (Value::Float(a), Value::Float(b)) => a.partial_cmp(b).unwrap_or(Ordering::Equal),
14965        (Value::Text(a), Value::Text(b)) | (Value::Json(a), Value::Json(b)) => a.cmp(b),
14966        (Value::Bool(a), Value::Bool(b)) => a.cmp(b),
14967        (Value::Date(a), Value::Date(b)) => a.cmp(b),
14968        (Value::Timestamp(a), Value::Timestamp(b)) => a.cmp(b),
14969        // Mixed numeric/float — widen to f64 and compare.
14970        (Value::SmallInt(n), Value::Float(x)) => {
14971            (f64::from(*n)).partial_cmp(x).unwrap_or(Ordering::Equal)
14972        }
14973        (Value::Float(x), Value::SmallInt(n)) => {
14974            x.partial_cmp(&f64::from(*n)).unwrap_or(Ordering::Equal)
14975        }
14976        (Value::Int(n), Value::Float(x)) => {
14977            (f64::from(*n)).partial_cmp(x).unwrap_or(Ordering::Equal)
14978        }
14979        (Value::Float(x), Value::Int(n)) => {
14980            x.partial_cmp(&f64::from(*n)).unwrap_or(Ordering::Equal)
14981        }
14982        (Value::BigInt(n), Value::Float(x)) => {
14983            #[allow(clippy::cast_precision_loss)]
14984            let nf = *n as f64;
14985            nf.partial_cmp(x).unwrap_or(Ordering::Equal)
14986        }
14987        (Value::Float(x), Value::BigInt(n)) => {
14988            #[allow(clippy::cast_precision_loss)]
14989            let nf = *n as f64;
14990            x.partial_cmp(&nf).unwrap_or(Ordering::Equal)
14991        }
14992        // Cross-type fallback: lexicographic on canonical form.
14993        // Total + stable so the sort is well-defined.
14994        _ => canonical_value_repr(a).cmp(&canonical_value_repr(b)),
14995    }
14996}
14997
14998/// v6.2.0 — render the histogram bounds list as a `[v0, v1, ...]`
14999/// string for the `spg_statistic.histogram_bounds` column. Values
15000/// containing `,` or `[` / `]` are JSON-style escaped so the
15001/// rendering round-trips through a future parser; v6.2.0 only
15002/// uses the rendered form for human consumption, so the escaping
15003/// is conservative.
15004fn render_histogram_bounds(bounds: &[alloc::string::String]) -> alloc::string::String {
15005    let mut out = alloc::string::String::with_capacity(bounds.len() * 8 + 2);
15006    out.push('[');
15007    for (i, b) in bounds.iter().enumerate() {
15008        if i > 0 {
15009            out.push_str(", ");
15010        }
15011        let needs_quote = b.contains([',', '[', ']', '"']) || b.is_empty();
15012        if needs_quote {
15013            out.push('"');
15014            for ch in b.chars() {
15015                if ch == '"' || ch == '\\' {
15016                    out.push('\\');
15017                }
15018                out.push(ch);
15019            }
15020            out.push('"');
15021        } else {
15022            out.push_str(b);
15023        }
15024    }
15025    out.push(']');
15026    out
15027}
15028
15029/// v6.2.0 — canonical textual form of a `Value` for histogram
15030/// bound storage. Strings used by ANALYZE for sort + bound output.
15031/// INT / BIGINT → decimal; FLOAT → shortest-round-trip via
15032/// `{:?}`; TEXT pass-through; BOOL → `t` / `f`; DATE / TIMESTAMP →
15033/// the same form `format_date` / `format_timestamp` produce for
15034/// SQL Display. Vector / SQ8 / Half / Json / Numeric / Interval
15035/// reach this only via a non-Vector column (vector columns are
15036/// skipped upstream); they fall back to a Debug-derived form so
15037/// stats still serialise without crashing.
15038pub(crate) fn canonical_value_repr(v: &Value) -> alloc::string::String {
15039    match v {
15040        Value::Null => "NULL".to_string(),
15041        Value::SmallInt(n) => alloc::format!("{n}"),
15042        Value::Int(n) => alloc::format!("{n}"),
15043        Value::BigInt(n) => alloc::format!("{n}"),
15044        Value::Float(x) => alloc::format!("{x:?}"),
15045        Value::Text(s) | Value::Json(s) => s.clone(),
15046        Value::Bool(b) => if *b { "t" } else { "f" }.to_string(),
15047        Value::Date(d) => eval::format_date(*d),
15048        Value::Timestamp(t) => eval::format_timestamp(*t),
15049        // v7.17.0 Phase 3.P0-32 — PG TIME canonical text form.
15050        Value::Time(us) => eval::format_time(*us),
15051        // v7.17.0 Phase 3.P0-33 — MySQL YEAR 4-digit zero-padded.
15052        Value::Year(y) => alloc::format!("{y:04}"),
15053        // v7.17.0 Phase 3.P0-34 — PG TIMETZ canonical text form.
15054        Value::TimeTz { us, offset_secs } => eval::format_timetz(*us, *offset_secs),
15055        // v7.17.0 Phase 3.P0-35 — PG MONEY canonical en_US text form.
15056        Value::Money(c) => eval::format_money(*c),
15057        // v7.17.0 Phase 3.P0-38 — PG range canonical text form.
15058        v @ Value::Range { .. } => format_range_str(v),
15059        // v7.17.0 Phase 3.P0-39 — PG hstore canonical text form.
15060        Value::Hstore(pairs) => format_hstore_str(pairs),
15061        // v7.17.0 Phase 3.P0-40 — 2D array canonical text form.
15062        Value::IntArray2D(rows) => format_int_2d_text(rows),
15063        Value::BigIntArray2D(rows) => format_bigint_2d_text(rows),
15064        Value::TextArray2D(rows) => format_text_2d_text(rows),
15065        Value::Interval { months, micros } => eval::format_interval(*months, *micros),
15066        Value::Numeric { scaled, scale } => eval::format_numeric(*scaled, *scale),
15067        Value::Vector(_) | Value::Sq8Vector(_) | Value::HalfVector(_) => {
15068            // Unreachable in practice (vector columns are filtered
15069            // out before this). Defensive fallback so a future
15070            // vector-stats path doesn't crash.
15071            alloc::format!("{v:?}")
15072        }
15073        // v7.5.0 — Value is #[non_exhaustive] for downstream
15074        // forward-compat. Future variants fall through to Debug
15075        // form here (same shape as the vector fallback above).
15076        _ => alloc::format!("{v:?}"),
15077    }
15078}
15079
15080/// v6.2.0 — true for engine-managed catalog tables that the bare
15081/// `ANALYZE` (no target) should skip. v6.2.0 has no internal
15082/// tables yet (publications / subscriptions / users / statistics
15083/// all live as engine fields, not catalog tables), so this is a
15084/// reserved future-proofing hook — every existing user table is
15085/// analysed.
15086const fn is_internal_table_name(_name: &str) -> bool {
15087    false
15088}
15089
15090fn value_to_literal(v: Value) -> Literal {
15091    match v {
15092        Value::Null => Literal::Null,
15093        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
15094        Value::Int(n) => Literal::Integer(i64::from(n)),
15095        Value::BigInt(n) => Literal::Integer(n),
15096        Value::Float(x) => Literal::Float(x),
15097        Value::Text(s) | Value::Json(s) => Literal::String(s),
15098        Value::Bool(b) => Literal::Bool(b),
15099        Value::Vector(v) => Literal::Vector(v),
15100        Value::Numeric { scaled, scale } => Literal::String(eval::format_numeric(scaled, scale)),
15101        Value::Date(d) => Literal::String(eval::format_date(d)),
15102        Value::Timestamp(t) => Literal::String(eval::format_timestamp(t)),
15103        // v7.17.0 Phase 3.P0-69 — UUID round-trips via canonical
15104        // hyphenated text. Without this arm the fallback below
15105        // renders `Debug` form ("Uuid([85, …])") which the
15106        // engine's Text → Uuid coerce can't parse, breaking
15107        // prepared-bind round-trip from the spg-sqlx adapter.
15108        Value::Uuid(b) => Literal::String(spg_storage::format_uuid(&b)),
15109        // v7.16.0 — BYTEA round-trip for the spg-sqlx Bind path.
15110        // PG-canonical text rep is `\x` + lowercase hex; the
15111        // engine's coerce_value already accepts that on the
15112        // text → bytea direction.
15113        Value::Bytes(b) => Literal::String(eval::format_bytea_hex(&b)),
15114        // Arrays ride the AST natively (mailrs embed round-12) —
15115        // the prior `{a,b,c}` text form only worked where a column
15116        // type drove the re-parse; `= ANY($1)` has no column
15117        // context and saw a bare Text value.
15118        Value::TextArray(items) => Literal::TextArray(items),
15119        Value::IntArray(items) => Literal::IntArray(items),
15120        Value::BigIntArray(items) => Literal::BigIntArray(items),
15121        Value::Interval { months, micros } => Literal::Interval {
15122            months,
15123            micros,
15124            text: eval::format_interval(months, micros),
15125        },
15126        // SQ8 / halfvec cells dequantise to f32 before reaching the
15127        // substitute walker; pgwire's Bind path handles that.
15128        Value::Sq8Vector(q) => Literal::Vector(spg_storage::quantize::dequantize(&q)),
15129        Value::HalfVector(h) => Literal::Vector(h.to_f32_vec()),
15130        // v7.5.0 — Value is #[non_exhaustive]; future variants
15131        // render as Debug-form String literal until explicit
15132        // mapping is added.
15133        v => Literal::String(alloc::format!("{v:?}")),
15134    }
15135}
15136
15137fn rewrite_clock_calls(stmt: &mut Statement, now_micros: Option<i64>) {
15138    let Some(now) = now_micros else {
15139        return;
15140    };
15141    match stmt {
15142        Statement::Select(s) => rewrite_select_clock(s, now),
15143        Statement::Insert(ins) => {
15144            for row in &mut ins.rows {
15145                for e in row {
15146                    rewrite_expr_clock(e, now);
15147                }
15148            }
15149            // `ON CONFLICT … DO UPDATE SET created_at = NOW()` —
15150            // the upsert assignments carry clock calls too (mailrs
15151            // embed round-12).
15152            if let Some(clause) = &mut ins.on_conflict
15153                && let spg_sql::ast::OnConflictAction::Update {
15154                    assignments,
15155                    where_,
15156                } = &mut clause.action
15157            {
15158                for (_, e) in assignments.iter_mut() {
15159                    rewrite_expr_clock(e, now);
15160                }
15161                if let Some(w) = where_ {
15162                    rewrite_expr_clock(w, now);
15163                }
15164            }
15165        }
15166        // `UPDATE … SET seen_at = NOW() WHERE …` / `DELETE … WHERE
15167        // ts < NOW()` (mailrs embed round-12 — previously only
15168        // SELECT / INSERT-rows were walked).
15169        Statement::Update(u) => {
15170            for (_, e) in &mut u.assignments {
15171                rewrite_expr_clock(e, now);
15172            }
15173            if let Some(w) = &mut u.where_ {
15174                rewrite_expr_clock(w, now);
15175            }
15176        }
15177        Statement::Delete(d) => {
15178            if let Some(w) = &mut d.where_ {
15179                rewrite_expr_clock(w, now);
15180            }
15181        }
15182        _ => {}
15183    }
15184}
15185
15186fn rewrite_select_clock(s: &mut SelectStatement, now: i64) {
15187    // v7.25.1 (round-18) — shared traversal: CTE bodies, LATERAL
15188    // subqueries, JOIN ON, and UNION peers all get the clock
15189    // rewrite (NOW() inside a CTE previously survived to eval as
15190    // "unknown function `now`").
15191    let _ = walk_select_exprs_mut(s, &mut |e| {
15192        rewrite_expr_clock(e, now);
15193        Ok(())
15194    });
15195}
15196
15197/// v3.0.3 hot path: every recursion lands in exactly one `match` arm.
15198/// Literal / Column-with-qualifier (the dominant cases on a typical
15199/// AST) take a single pattern dispatch and exit. The clock-rewrite
15200/// targets (zero-arg `NOW` / `CURRENT_TIMESTAMP` / `CURRENT_DATE`
15201/// functions, and bare `CURRENT_TIMESTAMP` / `CURRENT_DATE` column
15202/// refs) sit on their own arms with match guards so the fall-through
15203/// to the recursive arms is unambiguous.
15204fn rewrite_expr_clock(e: &mut Expr, now: i64) {
15205    // Fast-path test on the no-recursion shapes first. We can't fold
15206    // them into the big match below because they need to *replace* `e`
15207    // outright; the recursive arms below match on its sub-fields.
15208    if let Some(replacement) = clock_replacement_for(e, now) {
15209        *e = replacement;
15210        return;
15211    }
15212    match e {
15213        Expr::AggregateOrdered { call, order_by, .. } => {
15214            rewrite_expr_clock(call, now);
15215            for o in order_by.iter_mut() {
15216                rewrite_expr_clock(&mut o.expr, now);
15217            }
15218        }
15219        Expr::Binary { lhs, rhs, .. } => {
15220            rewrite_expr_clock(lhs, now);
15221            rewrite_expr_clock(rhs, now);
15222        }
15223        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
15224            rewrite_expr_clock(expr, now);
15225        }
15226        Expr::FunctionCall { args, .. } => {
15227            for a in args {
15228                rewrite_expr_clock(a, now);
15229            }
15230        }
15231        Expr::Like { expr, pattern, .. } => {
15232            rewrite_expr_clock(expr, now);
15233            rewrite_expr_clock(pattern, now);
15234        }
15235        Expr::Extract { source, .. } => rewrite_expr_clock(source, now),
15236        // v4.10 subquery nodes — recurse into the inner SELECT's
15237        // expression slots so e.g. SELECT NOW() in a scalar
15238        // subquery picks up the same instant as the outer query.
15239        Expr::ScalarSubquery(s) => rewrite_select_clock(s, now),
15240        Expr::Exists { subquery, .. } => rewrite_select_clock(subquery, now),
15241        Expr::InSubquery { expr, subquery, .. } => {
15242            rewrite_expr_clock(expr, now);
15243            rewrite_select_clock(subquery, now);
15244        }
15245        // v4.12 window functions — args + PARTITION BY + ORDER BY
15246        // may all reference clock literals.
15247        Expr::WindowFunction {
15248            args,
15249            partition_by,
15250            order_by,
15251            ..
15252        } => {
15253            for a in args {
15254                rewrite_expr_clock(a, now);
15255            }
15256            for p in partition_by {
15257                rewrite_expr_clock(p, now);
15258            }
15259            for (e, _, _) in order_by {
15260                rewrite_expr_clock(e, now);
15261            }
15262        }
15263        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => {}
15264        Expr::Array(items) => {
15265            for elem in items {
15266                rewrite_expr_clock(elem, now);
15267            }
15268        }
15269        Expr::ArraySubscript { target, index } => {
15270            rewrite_expr_clock(target, now);
15271            rewrite_expr_clock(index, now);
15272        }
15273        Expr::AnyAll { expr, array, .. } => {
15274            rewrite_expr_clock(expr, now);
15275            rewrite_expr_clock(array, now);
15276        }
15277        Expr::Case {
15278            operand,
15279            branches,
15280            else_branch,
15281        } => {
15282            if let Some(o) = operand {
15283                rewrite_expr_clock(o, now);
15284            }
15285            for (w, t) in branches {
15286                rewrite_expr_clock(w, now);
15287                rewrite_expr_clock(t, now);
15288            }
15289            if let Some(e) = else_branch {
15290                rewrite_expr_clock(e, now);
15291            }
15292        }
15293    }
15294}
15295
15296/// Returns `Some(Expr)` when `e` is one of the clock-call shapes that
15297/// must be rewritten; otherwise `None` so the caller falls through to
15298/// the recursive walk. Identifies both function-call forms (`NOW()` /
15299/// `CURRENT_TIMESTAMP()` / `CURRENT_DATE()`) and bare-identifier forms
15300/// (`CURRENT_TIMESTAMP` / `CURRENT_DATE` as unqualified column refs,
15301/// which is how PG accepts them without parens).
15302fn clock_replacement_for(e: &Expr, now: i64) -> Option<Expr> {
15303    let (kind, name) = match e {
15304        Expr::FunctionCall { name, args } if args.is_empty() => (ClockSite::Fn, name.as_str()),
15305        Expr::Column(c) if c.qualifier.is_none() => (ClockSite::BareIdent, c.name.as_str()),
15306        _ => return None,
15307    };
15308    // ASCII case-insensitive name match. Each entry decides what
15309    // synthetic literal the call expands to.
15310    //
15311    // v7.17.0 Phase 3.P0-29 — `unix_timestamp` (no args) joins this
15312    // table as MySQL's epoch-seconds equivalent of `now()`. Folded
15313    // to a BigInt literal here so apply_function never needs a
15314    // clock dependency.
15315    enum ClockShape {
15316        Timestamp,
15317        Date,
15318        UnixSeconds,
15319    }
15320    let shape = match name.len() {
15321        3 if kind == ClockSite::Fn && name.eq_ignore_ascii_case("now") => {
15322            Some(ClockShape::Timestamp)
15323        }
15324        12 if name.eq_ignore_ascii_case("current_date") => Some(ClockShape::Date),
15325        14 if kind == ClockSite::Fn && name.eq_ignore_ascii_case("unix_timestamp") => {
15326            Some(ClockShape::UnixSeconds)
15327        }
15328        17 if name.eq_ignore_ascii_case("current_timestamp") => Some(ClockShape::Timestamp),
15329        _ => None,
15330    };
15331    let shape = shape?;
15332    let payload = match shape {
15333        ClockShape::Timestamp => now,
15334        ClockShape::Date => now.div_euclid(86_400_000_000),
15335        ClockShape::UnixSeconds => now.div_euclid(1_000_000),
15336    };
15337    let target = match shape {
15338        ClockShape::Timestamp => spg_sql::ast::CastTarget::Timestamp,
15339        ClockShape::Date => spg_sql::ast::CastTarget::Date,
15340        ClockShape::UnixSeconds => spg_sql::ast::CastTarget::BigInt,
15341    };
15342    Some(Expr::Cast {
15343        expr: alloc::boxed::Box::new(Expr::Literal(spg_sql::ast::Literal::Integer(payload))),
15344        target,
15345    })
15346}
15347
15348#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15349enum ClockSite {
15350    Fn,
15351    BareIdent,
15352}
15353
15354/// `ORDER BY <integer>` references the N-th SELECT item (1-based).
15355/// Swap the integer literal for the matching item's expression so the
15356/// executor doesn't need a special-case branch. Recurses into UNION
15357/// peers because each peer keeps its own SELECT list.
15358/// v6.4.1 — expand `GROUP BY ALL` to every non-aggregate SELECT-list
15359/// item. Mirrors DuckDB / PG 19 semantics. Wildcards (`SELECT * …`)
15360/// are NOT expanded by GROUP BY ALL (PG 19 leaves the wildcard intact
15361/// and groups by whatever explicit non-aggregates remain — none in
15362/// the wildcard-only case, which still works for non-aggregate
15363/// queries).
15364fn expand_group_by_all(s: &mut SelectStatement) {
15365    if !s.group_by_all {
15366        for (_, peer) in &mut s.unions {
15367            expand_group_by_all(peer);
15368        }
15369        return;
15370    }
15371    let mut groups: Vec<Expr> = Vec::new();
15372    for item in &s.items {
15373        if let SelectItem::Expr { expr, .. } = item
15374            && !aggregate::contains_aggregate(expr)
15375        {
15376            groups.push(expr.clone());
15377        }
15378    }
15379    s.group_by = Some(groups);
15380    s.group_by_all = false;
15381    for (_, peer) in &mut s.unions {
15382        expand_group_by_all(peer);
15383    }
15384}
15385
15386fn resolve_order_by_position(s: &mut SelectStatement) {
15387    // v6.4.0 — iterate every ORDER BY key. Position references
15388    // (`ORDER BY 2`) bind to the 1-based projection index;
15389    // identifier references that match a SELECT-list alias bind to
15390    // the projected expression (Step 4 of L3a).
15391    for order in &mut s.order_by {
15392        match &order.expr {
15393            Expr::Literal(Literal::Integer(n)) if *n >= 1 => {
15394                if let Ok(idx_one_based) = usize::try_from(*n) {
15395                    let idx = idx_one_based - 1;
15396                    if idx < s.items.len()
15397                        && let SelectItem::Expr { expr, .. } = &s.items[idx]
15398                    {
15399                        order.expr = expr.clone();
15400                    }
15401                }
15402            }
15403            Expr::Column(c) if c.qualifier.is_none() => {
15404                // Alias-in-ORDER-BY lookup.
15405                for item in &s.items {
15406                    if let SelectItem::Expr {
15407                        expr,
15408                        alias: Some(a),
15409                    } = item
15410                        && a == &c.name
15411                    {
15412                        order.expr = expr.clone();
15413                        break;
15414                    }
15415                }
15416            }
15417            _ => {}
15418        }
15419    }
15420    for (_, peer) in &mut s.unions {
15421        resolve_order_by_position(peer);
15422    }
15423}
15424
15425/// Sort `tagged` by `f64` key, reversing the comparator under DESC.
15426/// Used by the UNION ORDER BY path; per-block paths inline the same
15427/// comparator because they already hold `&OrderBy` directly.
15428/// v3.1.1: partial-sort helper. When `keep` (= offset + limit) is
15429/// strictly less than `tagged.len()`, run `select_nth_unstable_by` to
15430/// partition the prefix in O(n), then sort just that prefix in O(k
15431/// log k). Total O(n + k log k), vs O(n log n) for a full sort. The
15432/// caller decides what `keep` is; passing `None` (no LIMIT) keeps the
15433/// full-sort behaviour.
15434///
15435/// `tagged` holds `(Option<f64>, Row)` (the SELECT path) — `None` keys
15436/// sort last in ascending order, mirroring NULL-sorts-last in SQL.
15437fn partial_sort_tagged(tagged: &mut Vec<(Vec<f64>, Row)>, keep: Option<usize>, descs: &[bool]) {
15438    let cmp = |a: &(Vec<f64>, Row), b: &(Vec<f64>, Row)| cmp_multi_key(&a.0, &b.0, descs);
15439    match keep {
15440        Some(k) if k < tagged.len() && k > 0 => {
15441            let pivot = k - 1;
15442            tagged.select_nth_unstable_by(pivot, cmp);
15443            tagged[..k].sort_by(cmp);
15444            tagged.truncate(k);
15445        }
15446        _ => {
15447            tagged.sort_by(cmp);
15448        }
15449    }
15450}
15451
15452fn sort_by_keys(tagged: &mut [(Vec<f64>, Row)], descs: &[bool]) {
15453    tagged.sort_by(|a, b| cmp_multi_key(&a.0, &b.0, descs));
15454}
15455
15456/// v6.4.0 — multi-key ORDER BY comparator. Each key's per-key DESC
15457/// flag is honored independently. NULL is encoded as `f64::INFINITY`
15458/// so it sorts last in ASC and first in DESC (matches PG default).
15459fn cmp_multi_key(a: &[f64], b: &[f64], descs: &[bool]) -> core::cmp::Ordering {
15460    use core::cmp::Ordering;
15461    for (i, (ka, kb)) in a.iter().zip(b.iter()).enumerate() {
15462        let ord = ka.partial_cmp(kb).unwrap_or(Ordering::Equal);
15463        let ord = if descs.get(i).copied().unwrap_or(false) {
15464            ord.reverse()
15465        } else {
15466            ord
15467        };
15468        if ord != Ordering::Equal {
15469            return ord;
15470        }
15471    }
15472    Ordering::Equal
15473}
15474
15475/// v6.4.0 — eval every ORDER BY expression for a row and pack the
15476/// resulting keys into a `Vec<f64>`. NULL → `f64::INFINITY`.
15477fn build_order_keys(
15478    order_by: &[OrderBy],
15479    row: &Row,
15480    ctx: &EvalContext,
15481) -> Result<Vec<f64>, EngineError> {
15482    let mut keys = Vec::with_capacity(order_by.len());
15483    for o in order_by {
15484        let v = eval::eval_expr(&o.expr, row, ctx)?;
15485        // v7.24 (round-16 A) — explicit NULLS FIRST/LAST. The f64
15486        // packing sorts ascending THEN applies the per-key DESC
15487        // reverse, so a NULL must land at +INF exactly when the
15488        // effective placement agrees with the reverse direction:
15489        // nf == desc → +INF (ASC default last / DESC default
15490        // first), nf != desc → -INF (the explicit flips).
15491        if matches!(v, Value::Null) {
15492            let nf = o.nulls_first.unwrap_or(o.desc);
15493            keys.push(if nf == o.desc {
15494                f64::INFINITY
15495            } else {
15496                f64::NEG_INFINITY
15497            });
15498        } else {
15499            keys.push(value_to_order_key(&v)?);
15500        }
15501    }
15502    Ok(keys)
15503}
15504
15505/// Drop the first `offset` rows then truncate to `limit`. PG / `MySQL`
15506/// agree: OFFSET applies *after* ORDER BY but *before* LIMIT (so
15507/// `LIMIT 10 OFFSET 5` keeps rows 6..=15).
15508fn apply_offset_and_limit(rows: &mut Vec<Row>, offset: Option<u32>, limit: Option<u32>) {
15509    if let Some(off) = offset {
15510        let off = off as usize;
15511        if off >= rows.len() {
15512            rows.clear();
15513        } else {
15514            rows.drain(..off);
15515        }
15516    }
15517    if let Some(n) = limit {
15518        rows.truncate(n as usize);
15519    }
15520}
15521
15522/// v7.17.0 Phase 3.P0-49 — offset + limit applied to a tagged
15523/// `(order_keys, row)` sequence, with optional SQL:2008 `WITH
15524/// TIES` extension. When `with_ties` is set, the truncated tail
15525/// is extended through every subsequent row whose order keys
15526/// equal the last-kept row's keys (so a "top 3 by score" with
15527/// WITH TIES emits row 4 too when row 4 ties row 3 on `score`).
15528///
15529/// The order-key vector is the per-row sort key the caller already
15530/// computed via `build_order_keys`; equal-key detection therefore
15531/// matches the sort comparator exactly.
15532fn apply_offset_and_limit_tagged(
15533    tagged: &mut Vec<(Vec<f64>, Row)>,
15534    offset: Option<u32>,
15535    limit: Option<u32>,
15536    with_ties: bool,
15537) {
15538    if let Some(off) = offset {
15539        let off = off as usize;
15540        if off >= tagged.len() {
15541            tagged.clear();
15542        } else {
15543            tagged.drain(..off);
15544        }
15545    }
15546    if let Some(n) = limit {
15547        let n = n as usize;
15548        if with_ties && n > 0 && n < tagged.len() {
15549            let cutoff_key = tagged[n - 1].0.clone();
15550            let mut end = n;
15551            while end < tagged.len() && tagged[end].0 == cutoff_key {
15552                end += 1;
15553            }
15554            tagged.truncate(end);
15555        } else {
15556            tagged.truncate(n);
15557        }
15558    }
15559}
15560
15561/// v7.17.0 Phase 3.P0-49 — PG-canonical: `FETCH FIRST <n> ROWS
15562/// WITH TIES` requires an `ORDER BY`. Without one, there's no
15563/// way to identify "ties" deterministically, so PG errors at
15564/// plan time. SPG mirrors that surface so the same DDL / app
15565/// behaviour holds on cutover.
15566fn check_with_ties_requires_order_by(stmt: &SelectStatement) -> Result<(), EngineError> {
15567    if stmt.limit_with_ties && stmt.order_by.is_empty() {
15568        return Err(EngineError::Unsupported(alloc::string::String::from(
15569            "FETCH FIRST … ROWS WITH TIES requires an ORDER BY clause",
15570        )));
15571    }
15572    Ok(())
15573}
15574
15575/// v7.6.1 — resolve a parser-level `ForeignKeyConstraint` (column
15576/// names + parent table name) into the storage-layer shape (column
15577/// indices + same parent table). Validates everything the engine
15578/// needs to know about the FK at CREATE TABLE time:
15579///
15580///   - parent table exists (catalog lookup, unless self-referencing)
15581///   - parent columns exist on the parent table
15582///   - parent column list matches the local arity (defaults to the
15583///     parent's primary index column when omitted)
15584///   - parent columns are covered by a `BTree` UNIQUE-class index
15585///     (SPG's stand-in for `PRIMARY KEY`/`UNIQUE`) — required so
15586///     the v7.6.2 INSERT path can do an O(log n) parent lookup
15587///   - local columns exist on the table being created
15588fn resolve_foreign_key(
15589    local_table_name: &str,
15590    local_cols: &[ColumnSchema],
15591    fk: spg_sql::ast::ForeignKeyConstraint,
15592    catalog: &Catalog,
15593) -> Result<spg_storage::ForeignKeyConstraint, EngineError> {
15594    // Resolve local columns.
15595    let mut local_columns = Vec::with_capacity(fk.columns.len());
15596    for name in &fk.columns {
15597        let pos = local_cols
15598            .iter()
15599            .position(|c| c.name == *name)
15600            .ok_or_else(|| {
15601                EngineError::Unsupported(alloc::format!(
15602                    "FOREIGN KEY references unknown local column {name:?}"
15603                ))
15604            })?;
15605        local_columns.push(pos);
15606    }
15607    // Self-referencing FK: parent table is the one we're creating.
15608    // The parent column resolution uses the local column list since
15609    // the catalog doesn't have this table yet.
15610    let is_self_ref = fk.parent_table == local_table_name;
15611    let (parent_cols_for_lookup, parent_table_str): (&[ColumnSchema], &str) = if is_self_ref {
15612        (local_cols, local_table_name)
15613    } else {
15614        let parent_table = catalog.get(&fk.parent_table).ok_or_else(|| {
15615            EngineError::Storage(StorageError::TableNotFound {
15616                name: fk.parent_table.clone(),
15617            })
15618        })?;
15619        (
15620            parent_table.schema().columns.as_slice(),
15621            fk.parent_table.as_str(),
15622        )
15623    };
15624    // Resolve parent column names → positions. If the FK omitted the
15625    // parent column list, fall back to the parent's primary index
15626    // column (single-column only — composite default is rejected
15627    // because there's no unambiguous "PK" in SPG's index list).
15628    let parent_columns: Vec<usize> = if fk.parent_columns.is_empty() {
15629        if fk.columns.len() != 1 {
15630            return Err(EngineError::Unsupported(
15631                "composite FOREIGN KEY without explicit parent column list is not supported \
15632                 — list the parent columns explicitly"
15633                    .into(),
15634            ));
15635        }
15636        // Find a single BTree index on the parent and use its column.
15637        let pos = pick_pk_index_column(catalog, parent_table_str, is_self_ref, local_cols)
15638            .ok_or_else(|| {
15639                EngineError::Unsupported(alloc::format!(
15640                    "parent table {parent_table_str:?} has no PRIMARY-key / UNIQUE BTree index \
15641                     to default the FOREIGN KEY against"
15642                ))
15643            })?;
15644        alloc::vec![pos]
15645    } else {
15646        let mut out = Vec::with_capacity(fk.parent_columns.len());
15647        for name in &fk.parent_columns {
15648            let pos = parent_cols_for_lookup
15649                .iter()
15650                .position(|c| c.name == *name)
15651                .ok_or_else(|| {
15652                    EngineError::Unsupported(alloc::format!(
15653                        "FOREIGN KEY references unknown parent column \
15654                         {name:?} on table {parent_table_str:?}"
15655                    ))
15656                })?;
15657            out.push(pos);
15658        }
15659        out
15660    };
15661    if parent_columns.len() != local_columns.len() {
15662        return Err(EngineError::Unsupported(alloc::format!(
15663            "FOREIGN KEY arity mismatch: {} local columns vs {} parent columns",
15664            local_columns.len(),
15665            parent_columns.len()
15666        )));
15667    }
15668    // For non-self-referencing FKs, verify the parent column set is
15669    // covered by a BTree index. SPG doesn't have a `PRIMARY KEY`
15670    // declaration; the convention is "the parent column for FK
15671    // purposes must have a BTree index" — which the user creates via
15672    // `CREATE INDEX ... USING btree (col)` (the default). We accept
15673    // any single-column BTree index that covers a parent column;
15674    // composite parent column lists require an index whose `column_position`
15675    // matches the first parent column (multi-column BTree indices
15676    // are not in the v7.x roadmap).
15677    if !is_self_ref {
15678        let parent_table = catalog.get(&fk.parent_table).expect("checked above");
15679        let primary_parent_col = parent_columns[0];
15680        let has_btree = parent_table
15681            .schema()
15682            .columns
15683            .get(primary_parent_col)
15684            .is_some()
15685            && parent_table.indices().iter().any(|idx| {
15686                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
15687                    && idx.column_position == primary_parent_col
15688                    && idx.partial_predicate.is_none()
15689            });
15690        if !has_btree {
15691            return Err(EngineError::Unsupported(alloc::format!(
15692                "FOREIGN KEY parent column on {:?} is not covered by an unconditional BTree \
15693                 index — create one with `CREATE INDEX ... ON {} ({})` first",
15694                parent_table_str,
15695                parent_table_str,
15696                parent_table.schema().columns[primary_parent_col].name,
15697            )));
15698        }
15699    }
15700    let on_delete = fk_action_sql_to_storage(fk.on_delete);
15701    let on_update = fk_action_sql_to_storage(fk.on_update);
15702    Ok(spg_storage::ForeignKeyConstraint {
15703        name: fk.name,
15704        local_columns,
15705        parent_table: fk.parent_table,
15706        parent_columns,
15707        on_delete,
15708        on_update,
15709    })
15710}
15711
15712/// v7.6.1 — pick a sentinel "primary key" column from the parent
15713/// table when the FK didn't name parent columns. Picks the first
15714/// single-column unconditional BTree index — that's the closest
15715/// thing SPG has to a PRIMARY KEY today. Self-referencing FKs use
15716/// `local_cols` as the column source.
15717fn pick_pk_index_column(
15718    catalog: &Catalog,
15719    parent_name: &str,
15720    is_self_ref: bool,
15721    local_cols: &[ColumnSchema],
15722) -> Option<usize> {
15723    if is_self_ref {
15724        // Self-ref FK omitted parent columns: pick column 0 by
15725        // convention (no catalog entry yet). Engine will widen this
15726        // when v7.6.7 lands; v7.6.1 only handles the explicit form.
15727        let _ = local_cols;
15728        return Some(0);
15729    }
15730    let parent = catalog.get(parent_name)?;
15731    parent.indices().iter().find_map(|idx| {
15732        if matches!(idx.kind, spg_storage::IndexKind::BTree(_))
15733            && idx.partial_predicate.is_none()
15734            && idx.included_columns.is_empty()
15735            && idx.expression.is_none()
15736        {
15737            Some(idx.column_position)
15738        } else {
15739            None
15740        }
15741    })
15742}
15743
15744/// v7.9.8 / v7.9.10 — resolve the column positions that
15745/// identify a conflict for ON CONFLICT. Returns a Vec of
15746/// column positions (1 element for single-column form, N for
15747/// composite). When the user wrote bare `ON CONFLICT DO …`,
15748/// falls back to the table's first unconditional BTree index
15749/// (always single-column today).
15750fn resolve_on_conflict_columns(
15751    catalog: &Catalog,
15752    table_name: &str,
15753    target: &[String],
15754) -> Result<Vec<usize>, EngineError> {
15755    let table = catalog.get(table_name).ok_or_else(|| {
15756        EngineError::Storage(StorageError::TableNotFound {
15757            name: table_name.into(),
15758        })
15759    })?;
15760    if target.is_empty() {
15761        // v7.13.2 — mailrs round-6 S5 follow-up. Composite UNIQUE
15762        // constraints carry a multi-column tuple; the prior code
15763        // path picked only the leading column of the first BTree
15764        // index, which caused `ON CONFLICT DO NOTHING` to dedup
15765        // by leading column alone (3 rows with same group_id but
15766        // different permission collapsed to 1). PG semantics use
15767        // the full tuple. Prefer a UniquenessConstraint's full
15768        // column list when one exists; fall back to the leading
15769        // BTree column for legacy single-column UNIQUE.
15770        if let Some(uc) = table.schema().uniqueness_constraints.first() {
15771            return Ok(uc.columns.clone());
15772        }
15773        let pos = table
15774            .indices()
15775            .iter()
15776            .find_map(|idx| {
15777                if matches!(idx.kind, spg_storage::IndexKind::BTree(_))
15778                    && idx.partial_predicate.is_none()
15779                    && idx.included_columns.is_empty()
15780                    && idx.expression.is_none()
15781                {
15782                    Some(idx.column_position)
15783                } else {
15784                    None
15785                }
15786            })
15787            .ok_or_else(|| {
15788                EngineError::Unsupported(alloc::format!(
15789                    "ON CONFLICT without target requires a UNIQUE BTree index on {table_name:?}"
15790                ))
15791            })?;
15792        return Ok(alloc::vec![pos]);
15793    }
15794    let mut out = Vec::with_capacity(target.len());
15795    for name in target {
15796        let pos = table
15797            .schema()
15798            .columns
15799            .iter()
15800            .position(|c| c.name == *name)
15801            .ok_or_else(|| {
15802                EngineError::Unsupported(alloc::format!(
15803                    "ON CONFLICT target column {name:?} not found on {table_name:?}"
15804                ))
15805            })?;
15806        out.push(pos);
15807    }
15808    Ok(out)
15809}
15810
15811/// v7.9.8 — check whether the BTree index on `column_pos` of
15812/// `table_name` already has a row with this key.
15813fn on_conflict_key_exists(
15814    catalog: &Catalog,
15815    table_name: &str,
15816    column_pos: usize,
15817    key: &Value,
15818) -> bool {
15819    let Some(table) = catalog.get(table_name) else {
15820        return false;
15821    };
15822    let Some(idx_key) = spg_storage::IndexKey::from_value(key) else {
15823        return false;
15824    };
15825    table.indices().iter().any(|idx| {
15826        matches!(idx.kind, spg_storage::IndexKind::BTree(_))
15827            && idx.column_position == column_pos
15828            && idx.partial_predicate.is_none()
15829            && !idx.lookup_eq(&idx_key).is_empty()
15830    })
15831}
15832
15833/// v7.9.9 / v7.9.10 — look up an existing row's position by
15834/// matching all `column_positions` against the incoming `key`
15835/// tuple. Single-column shape (one column) reduces to the
15836/// canonical PK lookup; composite shapes scan linearly until
15837/// every position matches.
15838fn lookup_row_position_by_keys(
15839    catalog: &Catalog,
15840    table_name: &str,
15841    column_positions: &[usize],
15842    key: &[&Value],
15843) -> Option<usize> {
15844    let table = catalog.get(table_name)?;
15845    table.rows().iter().position(|r| {
15846        column_positions
15847            .iter()
15848            .enumerate()
15849            .all(|(i, &pos)| r.values.get(pos) == Some(key[i]))
15850    })
15851}
15852
15853/// v7.9.10 — does the table already contain a row whose
15854/// `column_positions` tuple equals `key`? Single-column shape
15855/// uses the existing BTree fast path; composite shapes fall
15856/// back to a row scan.
15857fn on_conflict_keys_exist(
15858    catalog: &Catalog,
15859    table_name: &str,
15860    column_positions: &[usize],
15861    key: &[&Value],
15862) -> bool {
15863    if column_positions.len() == 1 {
15864        return on_conflict_key_exists(catalog, table_name, column_positions[0], key[0]);
15865    }
15866    let Some(table) = catalog.get(table_name) else {
15867        return false;
15868    };
15869    table.rows().iter().any(|r| {
15870        column_positions
15871            .iter()
15872            .enumerate()
15873            .all(|(i, &pos)| r.values.get(pos) == Some(key[i]))
15874    })
15875}
15876
15877/// v7.9.9 — apply ON CONFLICT DO UPDATE SET assignments to an
15878/// existing row.
15879///
15880/// `incoming` is the rejected INSERT row (used to resolve
15881/// `EXCLUDED.col` references in the assignment exprs);
15882/// `target_pos` is the position of the existing row in the table.
15883/// Each assignment substitutes `EXCLUDED.col` with the matching
15884/// incoming value, evaluates the resulting expression against
15885/// the existing row, and writes the new value into the
15886/// corresponding column of the returned `Vec<Value>`. If
15887/// `where_` evaluates falsy, returns Ok(None) — PG behaviour:
15888/// the conflicting row is silently kept unchanged.
15889fn apply_on_conflict_assignments(
15890    catalog: &Catalog,
15891    table_name: &str,
15892    target_pos: usize,
15893    incoming: &[Value],
15894    assignments: &[(String, Expr)],
15895    where_: Option<&Expr>,
15896) -> Result<Option<Vec<Value>>, EngineError> {
15897    let table = catalog.get(table_name).ok_or_else(|| {
15898        EngineError::Storage(StorageError::TableNotFound {
15899            name: table_name.into(),
15900        })
15901    })?;
15902    let schema_cols = table.schema().columns.clone();
15903    let existing = table
15904        .rows()
15905        .get(target_pos)
15906        .ok_or_else(|| {
15907            EngineError::Unsupported(alloc::format!(
15908                "ON CONFLICT DO UPDATE: row position {target_pos} out of bounds on {table_name:?}"
15909            ))
15910        })?
15911        .clone();
15912    let ctx = eval::EvalContext::new(&schema_cols, Some(table_name));
15913    // Optional WHERE filter on the conflict row.
15914    if let Some(w) = where_ {
15915        let pred = w.clone();
15916        let pred = substitute_excluded_refs(pred, &schema_cols, incoming);
15917        let v = eval::eval_expr(&pred, &existing, &ctx)?;
15918        if !matches!(v, Value::Bool(true)) {
15919            return Ok(None);
15920        }
15921    }
15922    let mut new_values = existing.values.clone();
15923    for (col_name, expr) in assignments {
15924        let target_idx = schema_cols
15925            .iter()
15926            .position(|c| c.name == *col_name)
15927            .ok_or_else(|| {
15928                EngineError::Eval(EvalError::ColumnNotFound {
15929                    name: col_name.clone(),
15930                })
15931            })?;
15932        let sub = substitute_excluded_refs(expr.clone(), &schema_cols, incoming);
15933        let v = eval::eval_expr(&sub, &existing, &ctx)?;
15934        let coerced = coerce_value(v, schema_cols[target_idx].ty, col_name, target_idx)?;
15935        check_unsigned_range(&coerced, &schema_cols[target_idx], target_idx)?;
15936        new_values[target_idx] = coerced;
15937    }
15938    Ok(Some(new_values))
15939}
15940
15941/// v7.9.9 — walk an `Expr` tree replacing any `Column { qualifier:
15942/// "EXCLUDED", name }` reference with a `Literal` of the matching
15943/// value from the incoming-row vec. Resolution against the
15944/// child-table column list (by name).
15945fn substitute_excluded_refs(expr: Expr, schema_cols: &[ColumnSchema], incoming: &[Value]) -> Expr {
15946    use spg_sql::ast::ColumnName;
15947    match expr {
15948        Expr::Column(ColumnName { qualifier, name })
15949            if qualifier
15950                .as_deref()
15951                .is_some_and(|q| q.eq_ignore_ascii_case("excluded")) =>
15952        {
15953            let pos = schema_cols.iter().position(|c| c.name == name);
15954            match pos {
15955                Some(p) => {
15956                    let v = incoming.get(p).cloned().unwrap_or(Value::Null);
15957                    value_to_literal_expr(v)
15958                        .unwrap_or_else(|_| Expr::Literal(spg_sql::ast::Literal::Null))
15959                }
15960                None => Expr::Column(ColumnName { qualifier, name }),
15961            }
15962        }
15963        Expr::Binary { op, lhs, rhs } => Expr::Binary {
15964            op,
15965            lhs: Box::new(substitute_excluded_refs(*lhs, schema_cols, incoming)),
15966            rhs: Box::new(substitute_excluded_refs(*rhs, schema_cols, incoming)),
15967        },
15968        Expr::Unary { op, expr } => Expr::Unary {
15969            op,
15970            expr: Box::new(substitute_excluded_refs(*expr, schema_cols, incoming)),
15971        },
15972        Expr::FunctionCall { name, args } => Expr::FunctionCall {
15973            name,
15974            args: args
15975                .into_iter()
15976                .map(|a| substitute_excluded_refs(a, schema_cols, incoming))
15977                .collect(),
15978        },
15979        other => other,
15980    }
15981}
15982
15983/// v7.6.2 / v7.6.7 — INSERT-side FK enforcement. For every row
15984/// about to be inserted into `child_table`, every FK declared on
15985/// that table is checked: the row's FK columns must either be
15986/// NULL (SQL spec skip) or match an existing parent row via the
15987/// parent's BTree PK / UNIQUE index.
15988///
15989/// Returns `EngineError::Unsupported` with a `FOREIGN KEY violation`
15990/// payload on first failure.
15991///
15992/// **Self-referencing FKs (v7.6.7 widening):** when `fk.parent_table
15993/// == child_table`, the parent rows visible to this check are
15994///  (a) rows already committed to the table, plus
15995///  (b) earlier rows from the *same* `rows` batch.
15996/// This makes `INSERT INTO tree VALUES (1, NULL), (2, 1), (3, 2)`
15997/// work in a single statement — common pattern for bulk-loading
15998/// hierarchies.
15999/// v7.9.19 — enforce table-level UNIQUE / PRIMARY KEY tuple
16000/// constraints at INSERT time. For each constraint declared on
16001/// the target table, check that no existing row + no earlier row
16002/// in the same batch has the same full-column tuple. NULL in
16003/// any column lifts the row out of the check (SQL spec: NULL
16004/// ≠ NULL for uniqueness). mailrs G1 + G6.
16005fn enforce_uniqueness_inserts(
16006    catalog: &Catalog,
16007    child_table: &str,
16008    constraints: &[spg_storage::UniquenessConstraint],
16009    rows: &[Vec<Value>],
16010) -> Result<(), EngineError> {
16011    if constraints.is_empty() {
16012        return Ok(());
16013    }
16014    let table = catalog.get(child_table).ok_or_else(|| {
16015        EngineError::Storage(StorageError::TableNotFound {
16016            name: child_table.into(),
16017        })
16018    })?;
16019    let schema = table.schema();
16020    for uc in constraints {
16021        for (batch_idx, row_values) in rows.iter().enumerate() {
16022            // v7.17.0 Phase 3.P0-45 — fold each key cell by its
16023            // column's declared Collation before comparing. Phase
16024            // 2.5b wired Collation into GROUP BY / ORDER BY / `=`
16025            // but the UNIQUE-constraint enforcement still compared
16026            // Text byte-wise; a `*_ci` column would let
16027            // `('Foo')` and `('FOO')` coexist when MySQL would
16028            // reject the second. Owned Values so the fold and
16029            // the borrow live in the same scope.
16030            let key: Vec<Value> = uc
16031                .columns
16032                .iter()
16033                .map(|&i| collated_key_cell(&row_values[i], i, schema))
16034                .collect();
16035            let has_null = key.iter().any(|v| matches!(v, Value::Null));
16036            // v7.13.0 — `NULLS NOT DISTINCT` (mailrs round-5 G10,
16037            // PG 15+): two rows whose constrained columns are all
16038            // NULL collide. SQL-standard `NULLS DISTINCT` lets any
16039            // NULL skip the check.
16040            if has_null && !uc.nulls_not_distinct {
16041                continue;
16042            }
16043            // Table-side collision: scan existing rows.
16044            let collides_in_table = table.rows().iter().any(|prow| {
16045                uc.columns.iter().enumerate().all(|(i, &p)| {
16046                    prow.values
16047                        .get(p)
16048                        .is_some_and(|v| collated_key_cell(v, p, schema) == key[i])
16049                })
16050            });
16051            // Batch-side collision: earlier rows in the same INSERT.
16052            let collides_in_batch = rows[..batch_idx].iter().any(|earlier| {
16053                uc.columns.iter().enumerate().all(|(i, &p)| {
16054                    earlier
16055                        .get(p)
16056                        .is_some_and(|v| collated_key_cell(v, p, schema) == key[i])
16057                })
16058            });
16059            if collides_in_table || collides_in_batch {
16060                let kind = if uc.is_primary_key {
16061                    "PRIMARY KEY"
16062                } else {
16063                    "UNIQUE"
16064                };
16065                let col_names: Vec<String> = uc
16066                    .columns
16067                    .iter()
16068                    .map(|&i| table.schema().columns[i].name.clone())
16069                    .collect();
16070                return Err(EngineError::Unsupported(alloc::format!(
16071                    "{kind} violation on {child_table:?} columns {col_names:?}: \
16072                     row #{batch_idx} duplicates an existing key"
16073                )));
16074            }
16075        }
16076    }
16077    Ok(())
16078}
16079
16080/// v7.17.0 Phase 3.P0-45 — return a key cell folded by its column's
16081/// declared `Collation`. For `CaseInsensitive`, fold Text payloads to
16082/// ASCII lowercase (matches Phase 2.5's `*_ci` semantics: ASCII case-
16083/// fold only, non-ASCII bytes stay byte-wise). For `Binary` or non-Text
16084/// values, the cell passes through unchanged. The caller compares the
16085/// folded values with `==`.
16086fn collated_key_cell(
16087    v: &spg_storage::Value,
16088    column_position: usize,
16089    schema: &spg_storage::TableSchema,
16090) -> spg_storage::Value {
16091    match (v, schema.columns.get(column_position).map(|c| c.collation)) {
16092        (spg_storage::Value::Text(s), Some(spg_storage::Collation::CaseInsensitive)) => {
16093            spg_storage::Value::Text(s.to_ascii_lowercase())
16094        }
16095        _ => v.clone(),
16096    }
16097}
16098
16099/// v7.9.29 — `true` iff `v` counts as a truthy SQL value for a
16100/// WHERE-style predicate. NULL → false (three-valued logic
16101/// collapses to "skip this row" for index inclusion). Numeric
16102/// non-zero, BIGINT non-zero, TINYINT non-zero, BOOLEAN true → true.
16103/// Everything else (strings, vectors, JSON, …) is not a valid
16104/// predicate result and surfaces as `false` so a malformed
16105/// predicate degrades to "row not in index" rather than panicking.
16106fn predicate_truthy(v: &spg_storage::Value) -> bool {
16107    use spg_storage::Value as V;
16108    match v {
16109        V::Bool(b) => *b,
16110        V::Int(n) => *n != 0,
16111        V::BigInt(n) => *n != 0,
16112        V::SmallInt(n) => *n != 0,
16113        _ => false,
16114    }
16115}
16116
16117/// v7.9.29 — at CREATE UNIQUE INDEX time, scan the table's
16118/// committed rows for pre-existing duplicates. If any pair of rows
16119/// matches the predicate AND has the same index key, refuse to
16120/// create the index so the user fixes the data before retrying.
16121fn check_existing_unique_violation(
16122    idx: &spg_storage::Index,
16123    schema: &spg_storage::TableSchema,
16124    rows: &[spg_storage::Row],
16125) -> Result<(), EngineError> {
16126    let predicate_expr = match idx.partial_predicate.as_deref() {
16127        Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
16128            EngineError::Unsupported(alloc::format!(
16129                "stored partial predicate {s:?} failed to re-parse: {e:?}"
16130            ))
16131        })?),
16132        None => None,
16133    };
16134    let ctx = eval::EvalContext::new(&schema.columns, None);
16135    let key_positions = unique_key_positions(idx);
16136    let mut seen: alloc::vec::Vec<alloc::vec::Vec<spg_storage::Value>> = alloc::vec::Vec::new();
16137    for row in rows {
16138        if let Some(expr) = &predicate_expr {
16139            let v = eval::eval_expr(expr, row, &ctx).map_err(|e| {
16140                EngineError::Unsupported(alloc::format!(
16141                    "evaluating UNIQUE INDEX predicate against existing row: {e:?}"
16142                ))
16143            })?;
16144            if !predicate_truthy(&v) {
16145                continue;
16146            }
16147        }
16148        let key: alloc::vec::Vec<spg_storage::Value> = key_positions
16149            .iter()
16150            .map(|&p| {
16151                let v = row
16152                    .values
16153                    .get(p)
16154                    .cloned()
16155                    .unwrap_or(spg_storage::Value::Null);
16156                collated_key_cell(&v, p, schema)
16157            })
16158            .collect();
16159        if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
16160            continue;
16161        }
16162        if seen.iter().any(|other| *other == key) {
16163            return Err(EngineError::Unsupported(alloc::format!(
16164                "CREATE UNIQUE INDEX {:?}: existing rows already violate the constraint",
16165                idx.name
16166            )));
16167        }
16168        seen.push(key);
16169    }
16170    Ok(())
16171}
16172
16173/// v7.9.29 — full key tuple for a UNIQUE INDEX (leading +
16174/// extra positions). For single-column indexes this is just
16175/// `[column_position]`.
16176fn unique_key_positions(idx: &spg_storage::Index) -> alloc::vec::Vec<usize> {
16177    let mut out = alloc::vec::Vec::with_capacity(1 + idx.extra_column_positions.len());
16178    out.push(idx.column_position);
16179    out.extend_from_slice(&idx.extra_column_positions);
16180    out
16181}
16182
16183/// v7.9.29 — at INSERT time, walk every `is_unique` index on the
16184/// target table. For each, eval the index's optional predicate
16185/// against (a) the candidate row and (b) every committed row plus
16186/// earlier batch rows; only rows where the predicate is truthy
16187/// participate. A duplicate key among predicate-matching rows is a
16188/// uniqueness violation. NULL keys lift the row out of the check
16189/// (matching PG's "UNIQUE allows multiple NULLs" semantics).
16190fn enforce_unique_index_inserts(
16191    catalog: &Catalog,
16192    table_name: &str,
16193    rows: &[alloc::vec::Vec<spg_storage::Value>],
16194) -> Result<(), EngineError> {
16195    let table = catalog.get(table_name).ok_or_else(|| {
16196        EngineError::Storage(StorageError::TableNotFound {
16197            name: table_name.into(),
16198        })
16199    })?;
16200    let schema = table.schema();
16201    let ctx = eval::EvalContext::new(&schema.columns, None);
16202    for idx in table.indices() {
16203        if !idx.is_unique {
16204            continue;
16205        }
16206        // Re-parse the predicate once per index per batch.
16207        let predicate_expr = match idx.partial_predicate.as_deref() {
16208            Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
16209                EngineError::Unsupported(alloc::format!(
16210                    "UNIQUE INDEX {:?} predicate {s:?} failed to re-parse: {e:?}",
16211                    idx.name
16212                ))
16213            })?),
16214            None => None,
16215        };
16216        let key_positions = unique_key_positions(idx);
16217        let key_of = |values: &[spg_storage::Value]| -> alloc::vec::Vec<spg_storage::Value> {
16218            // v7.17.0 Phase 3.P0-45 — fold per-column collation
16219            // before building the comparison key so a `*_ci`
16220            // column treats `'Foo'` and `'FOO'` as equal.
16221            key_positions
16222                .iter()
16223                .map(|&p| {
16224                    let v = values.get(p).cloned().unwrap_or(spg_storage::Value::Null);
16225                    collated_key_cell(&v, p, schema)
16226                })
16227                .collect()
16228        };
16229        // Helper: does `values` participate in this index? (predicate
16230        // truthy when present.) Wraps `values` into a transient Row
16231        // because eval_expr requires &Row.
16232        let participates = |values: &[spg_storage::Value]| -> Result<bool, EngineError> {
16233            let Some(expr) = &predicate_expr else {
16234                return Ok(true);
16235            };
16236            let tmp_row = spg_storage::Row {
16237                values: values.to_vec(),
16238            };
16239            let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
16240                EngineError::Unsupported(alloc::format!(
16241                    "UNIQUE INDEX {:?} predicate eval: {e:?}",
16242                    idx.name
16243                ))
16244            })?;
16245            Ok(predicate_truthy(&v))
16246        };
16247        for (batch_idx, row_values) in rows.iter().enumerate() {
16248            if !participates(row_values)? {
16249                continue;
16250            }
16251            let key = key_of(row_values);
16252            if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
16253                continue;
16254            }
16255            // Committed-table collision.
16256            for prow in table.rows() {
16257                if !participates(&prow.values)? {
16258                    continue;
16259                }
16260                if key_of(&prow.values) == key {
16261                    return Err(EngineError::Unsupported(alloc::format!(
16262                        "UNIQUE INDEX {:?} violation on {table_name:?}: \
16263                         row #{batch_idx} duplicates an existing key",
16264                        idx.name
16265                    )));
16266                }
16267            }
16268            // Within-batch collision: earlier rows in the same INSERT.
16269            for earlier in &rows[..batch_idx] {
16270                if !participates(earlier)? {
16271                    continue;
16272                }
16273                if key_of(earlier) == key {
16274                    return Err(EngineError::Unsupported(alloc::format!(
16275                        "UNIQUE INDEX {:?} violation on {table_name:?}: \
16276                         row #{batch_idx} duplicates an earlier row in the same batch",
16277                        idx.name
16278                    )));
16279                }
16280            }
16281        }
16282    }
16283    Ok(())
16284}
16285
16286/// v7.13.0 — `UPDATE OF cols` filter helper (mailrs round-5 G7).
16287/// Returns `true` when at least one of `filter_cols` has a
16288/// different value in `new_row` vs `old_row`. Column lookup is
16289/// case-insensitive against `schema_cols`; unknown filter columns
16290/// are treated as "not changed" (the trigger therefore won't
16291/// fire on them — surfacing a parse-time error would be too
16292/// strict for catalog reloads where the schema may have drifted).
16293fn any_column_changed(
16294    filter_cols: &[String],
16295    schema_cols: &[ColumnSchema],
16296    old_row: &Row,
16297    new_row: &Row,
16298) -> bool {
16299    for col_name in filter_cols {
16300        let Some(pos) = schema_cols
16301            .iter()
16302            .position(|c| c.name.eq_ignore_ascii_case(col_name))
16303        else {
16304            continue;
16305        };
16306        let old_v = old_row.values.get(pos);
16307        let new_v = new_row.values.get(pos);
16308        if old_v != new_v {
16309            return true;
16310        }
16311    }
16312    false
16313}
16314
16315/// v7.13.0 — evaluate every CHECK predicate on the schema against
16316/// each candidate row. Mirrors PG semantics: a `false` result
16317/// rejects the mutation; a NULL result *passes* (CHECK rejects
16318/// only on definite-false, not on unknown). mailrs round-5 G3.
16319fn enforce_check_constraints(
16320    catalog: &Catalog,
16321    table_name: &str,
16322    rows: &[alloc::vec::Vec<spg_storage::Value>],
16323) -> Result<(), EngineError> {
16324    let table = catalog.get(table_name).ok_or_else(|| {
16325        EngineError::Storage(StorageError::TableNotFound {
16326            name: table_name.into(),
16327        })
16328    })?;
16329    let schema = table.schema();
16330    // v7.17.0 Phase 1.5 — domain-level CHECKs are enforced in
16331    // parallel with table-level CHECKs. Collect both lists up
16332    // front; if neither exists we early-out.
16333    let mut domain_checks_per_col: alloc::vec::Vec<(usize, alloc::vec::Vec<Expr>)> =
16334        alloc::vec::Vec::new();
16335    for (idx, col) in schema.columns.iter().enumerate() {
16336        let Some(dname) = &col.user_domain_type else {
16337            continue;
16338        };
16339        let Some(dom) = catalog.domain_types().get(dname) else {
16340            continue;
16341        };
16342        let mut parsed_for_col: alloc::vec::Vec<Expr> =
16343            alloc::vec::Vec::with_capacity(dom.checks.len());
16344        for src in &dom.checks {
16345            let expr = spg_sql::parser::parse_expression(src).map_err(|e| {
16346                EngineError::Unsupported(alloc::format!(
16347                    "DOMAIN {dname:?} CHECK ({src:?}) on column {:?}: re-parse failed: {e:?}",
16348                    col.name
16349                ))
16350            })?;
16351            parsed_for_col.push(expr);
16352        }
16353        if !parsed_for_col.is_empty() {
16354            domain_checks_per_col.push((idx, parsed_for_col));
16355        }
16356    }
16357    if schema.checks.is_empty() && domain_checks_per_col.is_empty() {
16358        return Ok(());
16359    }
16360    let ctx = eval::EvalContext::new(&schema.columns, None);
16361    let mut parsed: alloc::vec::Vec<(usize, Expr)> = alloc::vec::Vec::new();
16362    for (i, src) in schema.checks.iter().enumerate() {
16363        let expr = spg_sql::parser::parse_expression(src).map_err(|e| {
16364            EngineError::Unsupported(alloc::format!(
16365                "CHECK constraint #{i} on {table_name:?} ({src:?}) failed to re-parse: {e:?}"
16366            ))
16367        })?;
16368        parsed.push((i, expr));
16369    }
16370    for (batch_idx, row_values) in rows.iter().enumerate() {
16371        let tmp_row = spg_storage::Row {
16372            values: row_values.clone(),
16373        };
16374        for (i, expr) in &parsed {
16375            let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
16376                EngineError::Unsupported(alloc::format!(
16377                    "CHECK constraint #{i} on {table_name:?} eval at row #{batch_idx}: {e:?}"
16378                ))
16379            })?;
16380            // PG: NULL passes (CHECK rejects on definite-false only).
16381            if matches!(v, spg_storage::Value::Bool(false)) {
16382                return Err(EngineError::Unsupported(alloc::format!(
16383                    "CHECK constraint violation on {table_name:?} (row #{batch_idx}): {:?}",
16384                    schema.checks[*i]
16385                )));
16386            }
16387        }
16388        // v7.17.0 Phase 1.5 — domain-level CHECKs. Each CHECK
16389        // expression references VALUE as a column-name; we
16390        // substitute the per-row cell into the eval context by
16391        // synthesising a single-column row of just that value
16392        // under a temporary `value` column schema.
16393        for (col_idx, checks) in &domain_checks_per_col {
16394            let cell = row_values
16395                .get(*col_idx)
16396                .cloned()
16397                .unwrap_or(spg_storage::Value::Null);
16398            let synth_cols = alloc::vec![spg_storage::ColumnSchema::new(
16399                "value",
16400                schema.columns[*col_idx].ty,
16401                schema.columns[*col_idx].nullable,
16402            )];
16403            let synth_ctx = eval::EvalContext::new(&synth_cols, None);
16404            let synth_row = spg_storage::Row {
16405                values: alloc::vec![cell],
16406            };
16407            for (ci, expr) in checks.iter().enumerate() {
16408                let v = eval::eval_expr(expr, &synth_row, &synth_ctx).map_err(|e| {
16409                    EngineError::Unsupported(alloc::format!(
16410                        "DOMAIN CHECK #{ci} on column {:?} eval at row #{batch_idx}: {e:?}",
16411                        schema.columns[*col_idx].name
16412                    ))
16413                })?;
16414                if matches!(v, spg_storage::Value::Bool(false)) {
16415                    return Err(EngineError::Unsupported(alloc::format!(
16416                        "DOMAIN CHECK violation on column {:?} (row #{batch_idx})",
16417                        schema.columns[*col_idx].name
16418                    )));
16419                }
16420            }
16421        }
16422    }
16423    Ok(())
16424}
16425
16426fn enforce_fk_inserts(
16427    catalog: &Catalog,
16428    child_table: &str,
16429    fks: &[spg_storage::ForeignKeyConstraint],
16430    rows: &[Vec<Value>],
16431) -> Result<(), EngineError> {
16432    for fk in fks {
16433        let parent_is_self = fk.parent_table == child_table;
16434        let parent = if parent_is_self {
16435            // Self-ref: read the current state of the same table.
16436            // The mut borrow on child has been dropped by the caller.
16437            catalog.get(child_table).ok_or_else(|| {
16438                EngineError::Storage(StorageError::TableNotFound {
16439                    name: child_table.into(),
16440                })
16441            })?
16442        } else {
16443            catalog.get(&fk.parent_table).ok_or_else(|| {
16444                EngineError::Storage(StorageError::TableNotFound {
16445                    name: fk.parent_table.clone(),
16446                })
16447            })?
16448        };
16449        for (batch_idx, row_values) in rows.iter().enumerate() {
16450            // Single-column FK fast path: try the parent's BTree
16451            // index for an O(log n) lookup. Composite FKs fall back
16452            // to a parent-row scan.
16453            if fk.local_columns.len() == 1 {
16454                let v = &row_values[fk.local_columns[0]];
16455                if matches!(v, Value::Null) {
16456                    continue;
16457                }
16458                let parent_col = fk.parent_columns[0];
16459                let key = spg_storage::IndexKey::from_value(v).ok_or_else(|| {
16460                    EngineError::Unsupported(alloc::format!(
16461                        "FOREIGN KEY column value of type {:?} is not index-eligible",
16462                        v.data_type()
16463                    ))
16464                })?;
16465                let present_committed = parent.indices().iter().any(|idx| {
16466                    matches!(idx.kind, spg_storage::IndexKind::BTree(_))
16467                        && idx.column_position == parent_col
16468                        && idx.partial_predicate.is_none()
16469                        && !idx.lookup_eq(&key).is_empty()
16470                });
16471                // v7.6.7 self-ref widening: also accept a match
16472                // against earlier rows in this same batch when the
16473                // FK points at the table being inserted into.
16474                let present_in_batch = parent_is_self
16475                    && rows[..batch_idx]
16476                        .iter()
16477                        .any(|earlier| earlier.get(parent_col) == Some(v));
16478                if !(present_committed || present_in_batch) {
16479                    return Err(EngineError::Unsupported(alloc::format!(
16480                        "FOREIGN KEY violation: no parent row in {:?} where {} = {:?}",
16481                        fk.parent_table,
16482                        parent
16483                            .schema()
16484                            .columns
16485                            .get(parent_col)
16486                            .map_or("?", |c| c.name.as_str()),
16487                        v,
16488                    )));
16489                }
16490            } else {
16491                // Composite FK: scan parent rows. v7.6.7 also
16492                // accepts a match against earlier rows in the same
16493                // batch (self-ref bulk-loading of hierarchies).
16494                if fk
16495                    .local_columns
16496                    .iter()
16497                    .all(|&i| matches!(row_values.get(i), Some(Value::Null)))
16498                {
16499                    continue;
16500                }
16501                let local: Vec<&Value> = fk.local_columns.iter().map(|&i| &row_values[i]).collect();
16502                let parent_match_committed = parent.rows().iter().any(|prow| {
16503                    fk.parent_columns
16504                        .iter()
16505                        .enumerate()
16506                        .all(|(i, &pi)| prow.values.get(pi) == Some(local[i]))
16507                });
16508                let parent_match_in_batch = parent_is_self
16509                    && rows[..batch_idx].iter().any(|earlier| {
16510                        fk.parent_columns
16511                            .iter()
16512                            .enumerate()
16513                            .all(|(i, &pi)| earlier.get(pi) == Some(local[i]))
16514                    });
16515                if !(parent_match_committed || parent_match_in_batch) {
16516                    return Err(EngineError::Unsupported(alloc::format!(
16517                        "FOREIGN KEY violation: no parent row in {:?} matching composite key",
16518                        fk.parent_table,
16519                    )));
16520                }
16521            }
16522        }
16523    }
16524    Ok(())
16525}
16526
16527/// v7.6.4 / v7.6.5 — one step of the FK action plan computed for a
16528/// DELETE on a parent. The plan is a list of these steps, stacked
16529/// across the FK graph by `plan_fk_parent_deletions`.
16530#[derive(Debug, Clone)]
16531struct FkChildStep {
16532    child_table: String,
16533    action: FkChildAction,
16534}
16535
16536#[derive(Debug, Clone)]
16537enum FkChildAction {
16538    /// CASCADE — remove these rows. Sorted, deduplicated positions.
16539    Delete { positions: Vec<usize> },
16540    /// SET NULL — for each (row, column) in the flat list, write
16541    /// NULL into that child cell. Multiple FKs on the same row may
16542    /// produce overlapping entries (deduped at plan time).
16543    SetNull {
16544        positions: Vec<usize>,
16545        columns: Vec<usize>,
16546    },
16547    /// SET DEFAULT — same shape as SetNull but writes the column's
16548    /// declared DEFAULT value (resolved at plan time). Columns
16549    /// without a DEFAULT raise an error during planning.
16550    SetDefault {
16551        positions: Vec<usize>,
16552        columns: Vec<usize>,
16553        defaults: Vec<Value>,
16554    },
16555}
16556
16557/// v7.6.3 → v7.6.5 — plan FK fallout for a DELETE on a parent table.
16558///
16559/// Walks every table in the catalog looking for FKs whose
16560/// `parent_table` is `parent_table_name`. For each such FK + each
16561/// to-be-deleted parent row:
16562///
16563///   - RESTRICT / NoAction → error, no plan returned
16564///   - CASCADE → child rows get scheduled for deletion; recursive
16565///   - SetNull → child FK column(s) scheduled to be NULL-ed.
16566///     Verified NULL-able at plan time.
16567///   - SetDefault → child FK column(s) scheduled to be reset to
16568///     their declared DEFAULT. Columns without a DEFAULT raise.
16569///
16570/// SET NULL / SET DEFAULT do NOT cascade further — the child row
16571/// stays; only one of its columns mutates.
16572fn plan_fk_parent_deletions(
16573    catalog: &Catalog,
16574    parent_table_name: &str,
16575    to_delete_positions: &[usize],
16576    to_delete_rows: &[Vec<Value>],
16577) -> Result<Vec<FkChildStep>, EngineError> {
16578    use alloc::collections::{BTreeMap, BTreeSet};
16579    if to_delete_rows.is_empty() {
16580        return Ok(Vec::new());
16581    }
16582    let mut delete_plan: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
16583    // setnull / setdefault keyed by child_table → (row_idx, col_idx) → optional default
16584    let mut setnull_plan: BTreeMap<String, BTreeSet<(usize, usize)>> = BTreeMap::new();
16585    let mut setdefault_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
16586    let mut visited: BTreeSet<(String, usize)> = BTreeSet::new();
16587    for &p in to_delete_positions {
16588        visited.insert((parent_table_name.to_string(), p));
16589    }
16590    let mut work: Vec<(String, Vec<Value>)> = to_delete_rows
16591        .iter()
16592        .map(|r| (parent_table_name.to_string(), r.clone()))
16593        .collect();
16594    while let Some((cur_parent, parent_row)) = work.pop() {
16595        for child_name in catalog.table_names() {
16596            let child = catalog
16597                .get(&child_name)
16598                .expect("table_names → catalog.get round-trip is total");
16599            for fk in &child.schema().foreign_keys {
16600                if fk.parent_table != cur_parent {
16601                    continue;
16602                }
16603                let parent_key: Vec<&Value> = fk
16604                    .parent_columns
16605                    .iter()
16606                    .map(|&pi| &parent_row[pi])
16607                    .collect();
16608                if parent_key.iter().any(|v| matches!(v, Value::Null)) {
16609                    continue;
16610                }
16611                for (child_row_idx, child_row) in child.rows().iter().enumerate() {
16612                    if child_name == cur_parent
16613                        && visited.contains(&(child_name.clone(), child_row_idx))
16614                    {
16615                        continue;
16616                    }
16617                    let matches_key = fk
16618                        .local_columns
16619                        .iter()
16620                        .enumerate()
16621                        .all(|(i, &li)| child_row.values.get(li) == Some(parent_key[i]));
16622                    if !matches_key {
16623                        continue;
16624                    }
16625                    match fk.on_delete {
16626                        spg_storage::FkAction::Restrict | spg_storage::FkAction::NoAction => {
16627                            return Err(EngineError::Unsupported(alloc::format!(
16628                                "FOREIGN KEY violation: DELETE on {cur_parent:?} is \
16629                                 restricted by FK from {child_name:?}.{:?}",
16630                                fk.local_columns,
16631                            )));
16632                        }
16633                        spg_storage::FkAction::Cascade => {
16634                            if visited.insert((child_name.clone(), child_row_idx)) {
16635                                delete_plan
16636                                    .entry(child_name.clone())
16637                                    .or_default()
16638                                    .insert(child_row_idx);
16639                                work.push((child_name.clone(), child_row.values.clone()));
16640                            }
16641                        }
16642                        spg_storage::FkAction::SetNull => {
16643                            // Verify every local FK column is NULL-able.
16644                            for &li in &fk.local_columns {
16645                                let col = child.schema().columns.get(li).ok_or_else(|| {
16646                                    EngineError::Unsupported(alloc::format!(
16647                                        "FK local column {li} missing in {child_name:?}"
16648                                    ))
16649                                })?;
16650                                if !col.nullable {
16651                                    return Err(EngineError::Unsupported(alloc::format!(
16652                                        "FOREIGN KEY ON DELETE SET NULL: column \
16653                                         {child_name:?}.{:?} is NOT NULL — cannot SET NULL",
16654                                        col.name,
16655                                    )));
16656                                }
16657                            }
16658                            let entry = setnull_plan.entry(child_name.clone()).or_default();
16659                            for &li in &fk.local_columns {
16660                                entry.insert((child_row_idx, li));
16661                            }
16662                        }
16663                        spg_storage::FkAction::SetDefault => {
16664                            // Resolve the DEFAULT for every local FK col.
16665                            let entry = setdefault_plan.entry(child_name.clone()).or_default();
16666                            for &li in &fk.local_columns {
16667                                let col = child.schema().columns.get(li).ok_or_else(|| {
16668                                    EngineError::Unsupported(alloc::format!(
16669                                        "FK local column {li} missing in {child_name:?}"
16670                                    ))
16671                                })?;
16672                                let default = col.default.clone().ok_or_else(|| {
16673                                    EngineError::Unsupported(alloc::format!(
16674                                        "FOREIGN KEY ON DELETE SET DEFAULT: column \
16675                                         {child_name:?}.{:?} has no DEFAULT declared",
16676                                        col.name,
16677                                    ))
16678                                })?;
16679                                entry.insert((child_row_idx, li), default);
16680                            }
16681                        }
16682                    }
16683                }
16684            }
16685        }
16686    }
16687    // Flatten the three plans into the ordered `FkChildStep` list.
16688    // Deletes are applied last per child (after any null/default
16689    // re-writes on the same child) so a child row that's both
16690    // re-written and then cascade-deleted only ends up deleted —
16691    // but in v7.6.5 SetNull/Cascade never overlap on the same row
16692    // (a single FK chooses exactly one action), so the order is
16693    // mostly a precaution.
16694    let mut steps: Vec<FkChildStep> = Vec::new();
16695    for (child_table, entries) in setnull_plan {
16696        let (positions, columns): (Vec<usize>, Vec<usize>) = entries.into_iter().unzip();
16697        steps.push(FkChildStep {
16698            child_table,
16699            action: FkChildAction::SetNull { positions, columns },
16700        });
16701    }
16702    for (child_table, entries) in setdefault_plan {
16703        let mut positions = Vec::with_capacity(entries.len());
16704        let mut columns = Vec::with_capacity(entries.len());
16705        let mut defaults = Vec::with_capacity(entries.len());
16706        for ((p, c), v) in entries {
16707            positions.push(p);
16708            columns.push(c);
16709            defaults.push(v);
16710        }
16711        steps.push(FkChildStep {
16712            child_table,
16713            action: FkChildAction::SetDefault {
16714                positions,
16715                columns,
16716                defaults,
16717            },
16718        });
16719    }
16720    for (child_table, positions) in delete_plan {
16721        steps.push(FkChildStep {
16722            child_table,
16723            action: FkChildAction::Delete {
16724                positions: positions.into_iter().collect(),
16725            },
16726        });
16727    }
16728    Ok(steps)
16729}
16730
16731/// v7.6.6 — plan FK fallout for an UPDATE that mutates parent-side
16732/// PK/UNIQUE columns. Walks every other table whose FK references
16733/// `parent_table_name`; for each FK whose parent_columns overlap a
16734/// mutated column, decides the action by `fk.on_update`.
16735///
16736///   - RESTRICT / NoAction → error if any child references the OLD
16737///     value
16738///   - CASCADE → child FK columns get rewritten to the NEW parent
16739///     value (a SetNull-style update step with the new value)
16740///   - SetNull → child FK columns set to NULL
16741///   - SetDefault → child FK columns set to declared default
16742///
16743/// `plan_with_old` is `(row_position, old_values, new_values)` so
16744/// the planner can detect "did this row's parent key actually
16745/// change?" — only rows where at least one referenced parent
16746/// column moved trigger inbound work.
16747fn plan_fk_parent_updates(
16748    catalog: &Catalog,
16749    parent_table_name: &str,
16750    plan_with_old: &[(usize, Vec<Value>, Vec<Value>)],
16751) -> Result<Vec<FkChildStep>, EngineError> {
16752    use alloc::collections::BTreeMap;
16753    if plan_with_old.is_empty() {
16754        return Ok(Vec::new());
16755    }
16756    // For each child table we may touch, build per-child step
16757    // lists. UPDATE never deletes children — `delete_plan` stays
16758    // empty here but is kept structurally aligned with
16759    // `plan_fk_parent_deletions` for future use.
16760    let delete_plan: BTreeMap<String, alloc::collections::BTreeSet<usize>> = BTreeMap::new();
16761    let mut setnull_plan: BTreeMap<String, alloc::collections::BTreeSet<(usize, usize)>> =
16762        BTreeMap::new();
16763    let mut setdefault_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
16764    // Cascade-update plan: child_table → row_idx → col_idx → new_value
16765    let mut cascade_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
16766
16767    for child_name in catalog.table_names() {
16768        let child = catalog
16769            .get(&child_name)
16770            .expect("table_names → catalog.get total");
16771        for fk in &child.schema().foreign_keys {
16772            if fk.parent_table != parent_table_name {
16773                continue;
16774            }
16775            for (_pos, old_row, new_row) in plan_with_old {
16776                // Did any parent FK column change?
16777                let key_changed = fk
16778                    .parent_columns
16779                    .iter()
16780                    .any(|&pi| old_row.get(pi) != new_row.get(pi));
16781                if !key_changed {
16782                    continue;
16783                }
16784                // The OLD parent key — used to find referring children.
16785                let old_key: Vec<&Value> =
16786                    fk.parent_columns.iter().map(|&pi| &old_row[pi]).collect();
16787                if old_key.iter().any(|v| matches!(v, Value::Null)) {
16788                    // NULL parent has no children — skip.
16789                    continue;
16790                }
16791                let new_key: Vec<&Value> =
16792                    fk.parent_columns.iter().map(|&pi| &new_row[pi]).collect();
16793                for (child_row_idx, child_row) in child.rows().iter().enumerate() {
16794                    // Self-ref same-row updates: a row updating its
16795                    // own PK doesn't restrict itself.
16796                    if child_name == parent_table_name
16797                        && plan_with_old.iter().any(|(p, _, _)| *p == child_row_idx)
16798                    {
16799                        continue;
16800                    }
16801                    let matches_key = fk
16802                        .local_columns
16803                        .iter()
16804                        .enumerate()
16805                        .all(|(i, &li)| child_row.values.get(li) == Some(old_key[i]));
16806                    if !matches_key {
16807                        continue;
16808                    }
16809                    match fk.on_update {
16810                        spg_storage::FkAction::Restrict | spg_storage::FkAction::NoAction => {
16811                            return Err(EngineError::Unsupported(alloc::format!(
16812                                "FOREIGN KEY violation: UPDATE on {parent_table_name:?} PK is \
16813                                 restricted by FK from {child_name:?}.{:?}",
16814                                fk.local_columns,
16815                            )));
16816                        }
16817                        spg_storage::FkAction::Cascade => {
16818                            // Rewrite child FK columns to new key.
16819                            let entry = cascade_plan.entry(child_name.clone()).or_default();
16820                            for (i, &li) in fk.local_columns.iter().enumerate() {
16821                                entry.insert((child_row_idx, li), new_key[i].clone());
16822                            }
16823                        }
16824                        spg_storage::FkAction::SetNull => {
16825                            for &li in &fk.local_columns {
16826                                let col = child.schema().columns.get(li).ok_or_else(|| {
16827                                    EngineError::Unsupported(alloc::format!(
16828                                        "FK local column {li} missing in {child_name:?}"
16829                                    ))
16830                                })?;
16831                                if !col.nullable {
16832                                    return Err(EngineError::Unsupported(alloc::format!(
16833                                        "FOREIGN KEY ON UPDATE SET NULL: column \
16834                                         {child_name:?}.{:?} is NOT NULL",
16835                                        col.name,
16836                                    )));
16837                                }
16838                            }
16839                            let entry = setnull_plan.entry(child_name.clone()).or_default();
16840                            for &li in &fk.local_columns {
16841                                entry.insert((child_row_idx, li));
16842                            }
16843                        }
16844                        spg_storage::FkAction::SetDefault => {
16845                            let entry = setdefault_plan.entry(child_name.clone()).or_default();
16846                            for &li in &fk.local_columns {
16847                                let col = child.schema().columns.get(li).ok_or_else(|| {
16848                                    EngineError::Unsupported(alloc::format!(
16849                                        "FK local column {li} missing in {child_name:?}"
16850                                    ))
16851                                })?;
16852                                let default = col.default.clone().ok_or_else(|| {
16853                                    EngineError::Unsupported(alloc::format!(
16854                                        "FOREIGN KEY ON UPDATE SET DEFAULT: column \
16855                                         {child_name:?}.{:?} has no DEFAULT",
16856                                        col.name,
16857                                    ))
16858                                })?;
16859                                entry.insert((child_row_idx, li), default);
16860                            }
16861                        }
16862                    }
16863                }
16864            }
16865        }
16866    }
16867    // Flatten into FkChildStep list. UPDATE doesn't produce
16868    // DeleteSteps (CASCADE on UPDATE just rewrites FK values).
16869    let mut steps: Vec<FkChildStep> = Vec::new();
16870    for (child_table, entries) in cascade_plan {
16871        let mut positions = Vec::with_capacity(entries.len());
16872        let mut columns = Vec::with_capacity(entries.len());
16873        let mut defaults = Vec::with_capacity(entries.len());
16874        for ((p, c), v) in entries {
16875            positions.push(p);
16876            columns.push(c);
16877            defaults.push(v);
16878        }
16879        // We reuse `FkChildAction::SetDefault` for cascade-update:
16880        // both shapes are "write a known value into specific cells"
16881        // — `apply_per_cell_writes` doesn't care whether the value
16882        // came from a DEFAULT declaration or a new parent key.
16883        steps.push(FkChildStep {
16884            child_table,
16885            action: FkChildAction::SetDefault {
16886                positions,
16887                columns,
16888                defaults,
16889            },
16890        });
16891    }
16892    for (child_table, entries) in setnull_plan {
16893        let (positions, columns): (Vec<usize>, Vec<usize>) = entries.into_iter().unzip();
16894        steps.push(FkChildStep {
16895            child_table,
16896            action: FkChildAction::SetNull { positions, columns },
16897        });
16898    }
16899    for (child_table, entries) in setdefault_plan {
16900        let mut positions = Vec::with_capacity(entries.len());
16901        let mut columns = Vec::with_capacity(entries.len());
16902        let mut defaults = Vec::with_capacity(entries.len());
16903        for ((p, c), v) in entries {
16904            positions.push(p);
16905            columns.push(c);
16906            defaults.push(v);
16907        }
16908        steps.push(FkChildStep {
16909            child_table,
16910            action: FkChildAction::SetDefault {
16911                positions,
16912                columns,
16913                defaults,
16914            },
16915        });
16916    }
16917    let _ = delete_plan; // UPDATE never deletes children.
16918    Ok(steps)
16919}
16920
16921/// v7.6.5 — apply one FK child step to the catalog. Encapsulates
16922/// the three action variants so the DELETE executor stays a
16923/// simple loop over the planned steps.
16924fn apply_fk_child_step(catalog: &mut Catalog, step: &FkChildStep) -> Result<(), EngineError> {
16925    let child = catalog.get_mut(&step.child_table).ok_or_else(|| {
16926        EngineError::Storage(StorageError::TableNotFound {
16927            name: step.child_table.clone(),
16928        })
16929    })?;
16930    match &step.action {
16931        FkChildAction::Delete { positions } => {
16932            let _ = child.delete_rows(positions);
16933        }
16934        FkChildAction::SetNull { positions, columns } => {
16935            apply_per_cell_writes(child, positions, columns, |_| Value::Null)?;
16936        }
16937        FkChildAction::SetDefault {
16938            positions,
16939            columns,
16940            defaults,
16941        } => {
16942            apply_per_cell_writes(child, positions, columns, |i| defaults[i].clone())?;
16943        }
16944    }
16945    Ok(())
16946}
16947
16948/// v7.6.5 — write new values into selected child cells via
16949/// `Table::update_row` (the catalog's existing UPDATE entry).
16950/// Groups writes by row position so multi-column updates on the
16951/// same row only call `update_row` once. `value_for(i)` produces
16952/// the new value for the i-th (position, column) entry.
16953fn apply_per_cell_writes(
16954    child: &mut spg_storage::Table,
16955    positions: &[usize],
16956    columns: &[usize],
16957    mut value_for: impl FnMut(usize) -> Value,
16958) -> Result<(), EngineError> {
16959    use alloc::collections::BTreeMap;
16960    let mut by_row: BTreeMap<usize, Vec<(usize, Value)>> = BTreeMap::new();
16961    for i in 0..positions.len() {
16962        by_row
16963            .entry(positions[i])
16964            .or_default()
16965            .push((columns[i], value_for(i)));
16966    }
16967    for (pos, mutations) in by_row {
16968        let mut new_values = child.rows()[pos].values.clone();
16969        for (col, v) in mutations {
16970            if let Some(slot) = new_values.get_mut(col) {
16971                *slot = v;
16972            }
16973        }
16974        child
16975            .update_row(pos, new_values)
16976            .map_err(EngineError::Storage)?;
16977    }
16978    Ok(())
16979}
16980
16981fn fk_action_sql_to_storage(a: spg_sql::ast::FkAction) -> spg_storage::FkAction {
16982    match a {
16983        spg_sql::ast::FkAction::Restrict => spg_storage::FkAction::Restrict,
16984        spg_sql::ast::FkAction::Cascade => spg_storage::FkAction::Cascade,
16985        spg_sql::ast::FkAction::SetNull => spg_storage::FkAction::SetNull,
16986        spg_sql::ast::FkAction::SetDefault => spg_storage::FkAction::SetDefault,
16987        spg_sql::ast::FkAction::NoAction => spg_storage::FkAction::NoAction,
16988    }
16989}
16990
16991/// v7.9.21 — resolve a column's DEFAULT for INSERT-time
16992/// default-fill. Free fn (rather than `&self`) so callers
16993/// with an active `&mut Table` borrow can still use it.
16994/// Literal defaults take the cached path (`col.default`);
16995/// runtime defaults hit `clock_fn` at each call. mailrs G4.
16996fn resolve_column_default_free(
16997    col: &ColumnSchema,
16998    clock_fn: Option<ClockFn>,
16999) -> Result<Value, EngineError> {
17000    if let Some(rt) = &col.runtime_default {
17001        return eval_runtime_default_free(rt, col.ty, clock_fn);
17002    }
17003    Ok(col.default.clone().unwrap_or(Value::Null))
17004}
17005
17006fn eval_runtime_default_free(
17007    rt: &str,
17008    ty: DataType,
17009    clock_fn: Option<ClockFn>,
17010) -> Result<Value, EngineError> {
17011    let s = rt.trim().to_ascii_lowercase();
17012    // v7.17.0 Phase 2.1 — also strip `(N)` precision suffix
17013    // so MySQL `CURRENT_TIMESTAMP(6)` resolves the same as
17014    // bare `CURRENT_TIMESTAMP`. SPG stores TIMESTAMP at fixed
17015    // microsecond resolution; the precision modifier is
17016    // parser-only.
17017    let with_no_parens = s.trim_end_matches("()");
17018    let canonical: &str = if let Some(open_idx) = with_no_parens.find('(') {
17019        if with_no_parens.ends_with(')') {
17020            &with_no_parens[..open_idx]
17021        } else {
17022            with_no_parens
17023        }
17024    } else {
17025        with_no_parens
17026    };
17027    let now_us = match clock_fn {
17028        Some(f) => f(),
17029        None => 0,
17030    };
17031    let v = match canonical {
17032        "now" | "current_timestamp" | "localtimestamp" => Value::Timestamp(now_us),
17033        "current_date" => Value::Date((now_us / 86_400_000_000) as i32),
17034        "current_time" | "localtime" => Value::Timestamp(now_us),
17035        // v7.17.0 — UUID generators in DEFAULT clauses. Required
17036        // for the canonical Django / Rails / Hibernate `id UUID
17037        // PRIMARY KEY DEFAULT gen_random_uuid()` pattern. Each
17038        // INSERT evaluates the function fresh; the per-row UUID
17039        // is the storage value, not a cached literal.
17040        "gen_random_uuid" | "uuid_generate_v4" => Value::Uuid(eval::gen_random_uuid_bytes()),
17041        other => {
17042            return Err(EngineError::Unsupported(alloc::format!(
17043                "runtime DEFAULT expression {other:?} not supported \
17044                 (v7.17.0 whitelist: now() / current_timestamp / \
17045                 current_date / current_time / localtimestamp / \
17046                 localtime / gen_random_uuid() / \
17047                 uuid_generate_v4())"
17048            )));
17049        }
17050    };
17051    coerce_value(v, ty, "DEFAULT", 0)
17052}
17053
17054/// v7.9.21 — true when a DEFAULT expression needs INSERT-time
17055/// evaluation rather than being cacheable as a literal Value.
17056/// FunctionCall is the immediate case (`now()`,
17057/// `current_timestamp`). Literal expressions and simple sign-
17058/// flipped numerics still take the static-cache path.
17059fn is_runtime_default_expr(expr: &Expr) -> bool {
17060    match expr {
17061        Expr::FunctionCall { .. } => true,
17062        Expr::Unary { expr, .. } => is_runtime_default_expr(expr),
17063        _ => false,
17064    }
17065}
17066
17067/// v7.17.0 Phase 1.4 — INSERT/UPDATE-time enum label check. When
17068/// `col_idx` has a registered label list, the cell value must be
17069/// NULL or one of the labels (case-sensitive per PG).
17070/// v7.17.0 Phase 3.P0-37 — validate + canonicalise a MySQL inline
17071/// SET cell. For non-SET columns this is a no-op pass-through.
17072///
17073/// Semantics:
17074///   * NULL preserved.
17075///   * Empty string → `''` (zero flags).
17076///   * Otherwise split on ',', trim each token, validate every
17077///     token against the column's variant list (error on miss),
17078///     de-dup, then re-emit in DEFINITION order joined by ','.
17079fn canonicalize_set_value(
17080    lookup: &alloc::collections::BTreeMap<usize, Vec<String>>,
17081    col_idx: usize,
17082    col_name: &str,
17083    value: Value,
17084) -> Result<Value, EngineError> {
17085    let Some(variants) = lookup.get(&col_idx) else {
17086        return Ok(value);
17087    };
17088    match value {
17089        Value::Null => Ok(Value::Null),
17090        Value::Text(s) => {
17091            if s.is_empty() {
17092                return Ok(Value::Text(alloc::string::String::new()));
17093            }
17094            // Collect a presence-set of variant indices to keep
17095            // definition order + handle de-dup in one pass.
17096            let mut present = alloc::vec![false; variants.len()];
17097            for raw in s.split(',') {
17098                let tok = raw.trim();
17099                if tok.is_empty() {
17100                    continue;
17101                }
17102                let idx = variants.iter().position(|v| v == tok).ok_or_else(|| {
17103                    EngineError::Unsupported(alloc::format!(
17104                        "column {col_name:?}: invalid SET token {tok:?}; \
17105                         allowed: {variants:?}"
17106                    ))
17107                })?;
17108                present[idx] = true;
17109            }
17110            // Re-emit in definition order.
17111            let mut out = alloc::string::String::new();
17112            let mut first = true;
17113            for (i, keep) in present.iter().enumerate() {
17114                if !keep {
17115                    continue;
17116                }
17117                if !first {
17118                    out.push(',');
17119                }
17120                first = false;
17121                out.push_str(&variants[i]);
17122            }
17123            Ok(Value::Text(out))
17124        }
17125        other => Err(EngineError::Unsupported(alloc::format!(
17126            "column {col_name:?}: SET-typed column expects TEXT, got {:?}",
17127            other.data_type()
17128        ))),
17129    }
17130}
17131
17132fn enforce_enum_label(
17133    lookup: &alloc::collections::BTreeMap<usize, Vec<String>>,
17134    col_idx: usize,
17135    col_name: &str,
17136    value: &Value,
17137) -> Result<(), EngineError> {
17138    if let Some(labels) = lookup.get(&col_idx) {
17139        match value {
17140            Value::Null => Ok(()),
17141            Value::Text(s) => {
17142                if labels.iter().any(|l| l == s) {
17143                    Ok(())
17144                } else {
17145                    Err(EngineError::Unsupported(alloc::format!(
17146                        "column {col_name:?}: invalid enum label {s:?}; allowed: {labels:?}"
17147                    )))
17148                }
17149            }
17150            other => Err(EngineError::Unsupported(alloc::format!(
17151                "column {col_name:?}: enum-typed column expects TEXT, got {:?}",
17152                other.data_type()
17153            ))),
17154        }
17155    } else {
17156        Ok(())
17157    }
17158}
17159
17160fn column_def_to_schema(c: ColumnDef) -> Result<ColumnSchema, EngineError> {
17161    let ty = column_type_to_data_type(c.ty);
17162    let mut schema = ColumnSchema::new(c.name.clone(), ty, c.nullable);
17163    // user_type_ref is the raw ident the parser couldn't resolve
17164    // to a built-in; classification into enum vs domain happens
17165    // at exec_create_table where we have catalog access. We
17166    // park it temporarily as user_enum_type and the engine
17167    // promotes domain bindings to user_domain_type before the
17168    // table is stored.
17169    if let Some(name) = c.user_type_ref {
17170        schema.user_enum_type = Some(name);
17171    }
17172    // v7.17.0 Phase 2.1 — render the ON UPDATE expression to
17173    // canonical text (the engine re-parses at UPDATE time).
17174    if let Some(expr) = c.on_update_runtime {
17175        schema.on_update_runtime = Some(alloc::format!("{expr}"));
17176    }
17177    // v7.17.0 Phase 2.5 — bridge the AST `Collation` enum to the
17178    // storage one. Same variants, different crates (spg-storage
17179    // owns no dep on spg-sql).
17180    schema.collation = match c.collation {
17181        spg_sql::ast::Collation::Binary => spg_storage::Collation::Binary,
17182        spg_sql::ast::Collation::CaseInsensitive => spg_storage::Collation::CaseInsensitive,
17183    };
17184    // v7.17.0 Phase 4.4 — MySQL `UNSIGNED` flag propagates to
17185    // storage so engine INSERT / UPDATE can range-check.
17186    schema.is_unsigned = c.is_unsigned;
17187    // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM variant list.
17188    // INSERT validation lives in coerce_value (Text → Text path
17189    // with the column's variant list as the accept-set).
17190    schema.inline_enum_variants = c.inline_enum_variants;
17191    // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant list.
17192    // INSERT canonicalisation (de-dup + sort by definition order)
17193    // lives in the exec_insert path next to the ENUM check.
17194    schema.inline_set_variants = c.inline_set_variants;
17195    if let Some(default_expr) = c.default {
17196        // v7.9.21 — distinguish literal defaults (evaluated once
17197        // at CREATE TABLE) from expression defaults (deferred to
17198        // INSERT). Function calls (`now()`, `current_timestamp`
17199        // — see v7.9.20 keyword promotion) take the runtime path.
17200        // Literals continue to cache. mailrs G4.
17201        if is_runtime_default_expr(&default_expr) {
17202            let display = alloc::format!("{default_expr}");
17203            schema = schema.with_runtime_default(display);
17204        } else {
17205            let raw = literal_expr_to_value(default_expr)?;
17206            let coerced = coerce_value(raw, ty, &c.name, 0)?;
17207            schema = schema.with_default(coerced);
17208        }
17209    }
17210    if c.auto_increment {
17211        // AUTO_INCREMENT only makes sense on integer-shaped columns.
17212        if !matches!(ty, DataType::SmallInt | DataType::Int | DataType::BigInt) {
17213            return Err(EngineError::Unsupported(alloc::format!(
17214                "AUTO_INCREMENT requires an integer column type, got {ty:?}"
17215            )));
17216        }
17217        schema = schema.with_auto_increment();
17218    }
17219    Ok(schema)
17220}
17221
17222/// v7.10.4 — decode a BYTEA literal. Accepts:
17223///   * `\xDEADBEEF` (case-insensitive hex; whitespace stripped)
17224///   * `Hello\000world` (backslash escape form; `\\` for literal backslash)
17225///   * Anything else → raw UTF-8 bytes of the input (PG accepts this too).
17226fn decode_bytea_literal(s: &str) -> Result<alloc::vec::Vec<u8>, &'static str> {
17227    let s = s.trim();
17228    if let Some(hex) = s.strip_prefix("\\x").or_else(|| s.strip_prefix("\\X")) {
17229        // Hex form. Each pair of hex digits → one byte.
17230        let cleaned: alloc::string::String = hex.chars().filter(|c| !c.is_whitespace()).collect();
17231        if cleaned.len() % 2 != 0 {
17232            return Err("odd-length hex literal");
17233        }
17234        let mut out = alloc::vec::Vec::with_capacity(cleaned.len() / 2);
17235        let cleaned_bytes = cleaned.as_bytes();
17236        for i in (0..cleaned_bytes.len()).step_by(2) {
17237            let hi = hex_nibble(cleaned_bytes[i])?;
17238            let lo = hex_nibble(cleaned_bytes[i + 1])?;
17239            out.push((hi << 4) | lo);
17240        }
17241        return Ok(out);
17242    }
17243    // Escape form or raw. Walk char-by-char; `\\` and `\NNN` octal
17244    // sequences decode; anything else is a literal byte.
17245    let bytes = s.as_bytes();
17246    let mut out = alloc::vec::Vec::with_capacity(bytes.len());
17247    let mut i = 0;
17248    while i < bytes.len() {
17249        let b = bytes[i];
17250        if b == b'\\' && i + 1 < bytes.len() {
17251            let n = bytes[i + 1];
17252            if n == b'\\' {
17253                out.push(b'\\');
17254                i += 2;
17255                continue;
17256            }
17257            if n.is_ascii_digit()
17258                && i + 3 < bytes.len()
17259                && bytes[i + 2].is_ascii_digit()
17260                && bytes[i + 3].is_ascii_digit()
17261            {
17262                let oct = |x: u8| (x - b'0') as u32;
17263                let v = oct(n) * 64 + oct(bytes[i + 2]) * 8 + oct(bytes[i + 3]);
17264                if v <= 0xFF {
17265                    out.push(v as u8);
17266                    i += 4;
17267                    continue;
17268                }
17269            }
17270        }
17271        out.push(b);
17272        i += 1;
17273    }
17274    Ok(out)
17275}
17276
17277fn hex_nibble(b: u8) -> Result<u8, &'static str> {
17278    match b {
17279        b'0'..=b'9' => Ok(b - b'0'),
17280        b'a'..=b'f' => Ok(b - b'a' + 10),
17281        b'A'..=b'F' => Ok(b - b'A' + 10),
17282        _ => Err("invalid hex digit"),
17283    }
17284}
17285
17286/// v7.10.11 — decode a PG TEXT[] external array form
17287/// (`{a,b,NULL}` with optional double-quoted elements). The
17288/// engine takes a leading/trailing `{`/`}` and splits at commas.
17289/// Quoted elements (`"hello, world"`) preserve embedded commas;
17290/// `\\` and `\"` decode to literal backslash / quote. Plain
17291/// unquoted `NULL` (case-insensitive) maps to `None`.
17292/// v7.11.13 — pick the array type for `ARRAY[lit, …]` from the
17293/// element values. Single-element-type rules:
17294///   - all NULL / all Text → TextArray
17295///   - all Int (or Int+NULL) → IntArray
17296///   - any BigInt without Text → BigIntArray (widening)
17297///   - any Text → TextArray (fallback; non-string elements
17298///     render as text)
17299fn array_literal_widen(items: alloc::vec::Vec<Value>) -> Value {
17300    let mut has_text = false;
17301    let mut has_bigint = false;
17302    let mut has_int = false;
17303    for v in &items {
17304        match v {
17305            Value::Null => {}
17306            Value::Text(_) | Value::Json(_) => has_text = true,
17307            Value::BigInt(_) => has_bigint = true,
17308            Value::Int(_) | Value::SmallInt(_) => has_int = true,
17309            _ => has_text = true,
17310        }
17311    }
17312    if has_text || (!has_bigint && !has_int) {
17313        let out: alloc::vec::Vec<Option<alloc::string::String>> = items
17314            .into_iter()
17315            .map(|v| match v {
17316                Value::Null => None,
17317                Value::Text(s) | Value::Json(s) => Some(s),
17318                other => Some(alloc::format!("{other:?}")),
17319            })
17320            .collect();
17321        return Value::TextArray(out);
17322    }
17323    if has_bigint {
17324        let out: alloc::vec::Vec<Option<i64>> = items
17325            .into_iter()
17326            .map(|v| match v {
17327                Value::Null => None,
17328                Value::Int(n) => Some(i64::from(n)),
17329                Value::SmallInt(n) => Some(i64::from(n)),
17330                Value::BigInt(n) => Some(n),
17331                _ => unreachable!("widen: unexpected non-integer in BigInt path"),
17332            })
17333            .collect();
17334        return Value::BigIntArray(out);
17335    }
17336    let out: alloc::vec::Vec<Option<i32>> = items
17337        .into_iter()
17338        .map(|v| match v {
17339            Value::Null => None,
17340            Value::Int(n) => Some(n),
17341            Value::SmallInt(n) => Some(i32::from(n)),
17342            _ => unreachable!("widen: unexpected non-i32-compatible in Int path"),
17343        })
17344        .collect();
17345    Value::IntArray(out)
17346}
17347
17348fn decode_text_array_literal(
17349    s: &str,
17350) -> Result<alloc::vec::Vec<Option<alloc::string::String>>, &'static str> {
17351    let trimmed = s.trim();
17352    let inner = trimmed
17353        .strip_prefix('{')
17354        .and_then(|x| x.strip_suffix('}'))
17355        .ok_or("TEXT[] literal must be enclosed in '{...}'")?;
17356    let mut out: alloc::vec::Vec<Option<alloc::string::String>> = alloc::vec::Vec::new();
17357    if inner.trim().is_empty() {
17358        return Ok(out);
17359    }
17360    let bytes = inner.as_bytes();
17361    let mut i = 0;
17362    while i <= bytes.len() {
17363        // Skip leading whitespace.
17364        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
17365            i += 1;
17366        }
17367        // Quoted element.
17368        if i < bytes.len() && bytes[i] == b'"' {
17369            i += 1; // open quote
17370            let mut buf = alloc::string::String::new();
17371            while i < bytes.len() && bytes[i] != b'"' {
17372                if bytes[i] == b'\\' && i + 1 < bytes.len() {
17373                    buf.push(bytes[i + 1] as char);
17374                    i += 2;
17375                } else {
17376                    buf.push(bytes[i] as char);
17377                    i += 1;
17378                }
17379            }
17380            if i >= bytes.len() {
17381                return Err("unterminated quoted element");
17382            }
17383            i += 1; // close quote
17384            out.push(Some(buf));
17385        } else {
17386            // Unquoted element — read until next comma or end.
17387            let start = i;
17388            while i < bytes.len() && bytes[i] != b',' {
17389                i += 1;
17390            }
17391            let raw = inner[start..i].trim();
17392            if raw.eq_ignore_ascii_case("NULL") {
17393                out.push(None);
17394            } else {
17395                out.push(Some(alloc::string::ToString::to_string(raw)));
17396            }
17397        }
17398        // Skip whitespace, expect comma or end.
17399        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
17400            i += 1;
17401        }
17402        if i >= bytes.len() {
17403            break;
17404        }
17405        if bytes[i] != b',' {
17406            return Err("expected ',' between TEXT[] elements");
17407        }
17408        i += 1;
17409    }
17410    Ok(out)
17411}
17412
17413/// v7.10.11 — encode a TEXT[] back into the PG external array
17414/// form. NULL elements become the literal `NULL`; elements
17415/// containing commas, quotes, backslashes, or braces are
17416/// double-quoted with `\\` / `\"` escapes.
17417fn encode_text_array(items: &[Option<alloc::string::String>]) -> alloc::string::String {
17418    let mut out = alloc::string::String::with_capacity(2 + items.len() * 8);
17419    out.push('{');
17420    for (i, item) in items.iter().enumerate() {
17421        if i > 0 {
17422            out.push(',');
17423        }
17424        match item {
17425            None => out.push_str("NULL"),
17426            Some(s) => {
17427                let needs_quote = s.is_empty()
17428                    || s.eq_ignore_ascii_case("NULL")
17429                    || s.chars()
17430                        .any(|c| matches!(c, ',' | '{' | '}' | '"' | '\\' | ' ' | '\t'));
17431                if needs_quote {
17432                    out.push('"');
17433                    for c in s.chars() {
17434                        if c == '"' || c == '\\' {
17435                            out.push('\\');
17436                        }
17437                        out.push(c);
17438                    }
17439                    out.push('"');
17440                } else {
17441                    out.push_str(s);
17442                }
17443            }
17444        }
17445    }
17446    out.push('}');
17447    out
17448}
17449
17450/// v7.10.4 — encode BYTEA bytes in PG hex output format
17451/// (`\x` prefix, lowercase hex pairs). Used by Text-side
17452/// round-trip + the wire layer's text-mode encoder.
17453fn encode_bytea_hex(b: &[u8]) -> alloc::string::String {
17454    let mut out = alloc::string::String::with_capacity(2 + 2 * b.len());
17455    out.push_str("\\x");
17456    for byte in b {
17457        let hi = byte >> 4;
17458        let lo = byte & 0x0F;
17459        out.push(hex_digit(hi));
17460        out.push(hex_digit(lo));
17461    }
17462    out
17463}
17464
17465const fn hex_digit(n: u8) -> char {
17466    match n {
17467        0..=9 => (b'0' + n) as char,
17468        10..=15 => (b'a' + n - 10) as char,
17469        _ => '?',
17470    }
17471}
17472
17473/// v7.17.0 Phase 3.P0-39 — parse a PG `hstore` text literal into
17474/// a flat key→value map. Empty string → empty map. Duplicate
17475/// keys take last-write-wins (matches PG `hstore_in`).
17476///
17477/// Accepted shapes (minimal subset):
17478///   * `'a=>1, b=>2'`            — bareword keys/values
17479///   * `'"a"=>"1", "b"=>"2"'`    — quoted keys/values
17480///   * `'a=>NULL'`               — case-insensitive NULL token
17481///     surfaces as `None` (no quotes around NULL)
17482///
17483/// Returns None on parse failure → caller surfaces as hard error.
17484fn parse_hstore_str(
17485    s: &str,
17486) -> Option<Vec<(alloc::string::String, Option<alloc::string::String>)>> {
17487    let bytes = s.as_bytes();
17488    let mut i = 0;
17489    let mut out: Vec<(alloc::string::String, Option<alloc::string::String>)> = Vec::new();
17490    let skip_ws = |bytes: &[u8], i: &mut usize| {
17491        while *i < bytes.len() && matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r') {
17492            *i += 1;
17493        }
17494    };
17495    let parse_token = |bytes: &[u8], i: &mut usize| -> Option<alloc::string::String> {
17496        if *i >= bytes.len() {
17497            return None;
17498        }
17499        if bytes[*i] == b'"' {
17500            *i += 1;
17501            let mut out = alloc::string::String::new();
17502            while *i < bytes.len() {
17503                match bytes[*i] {
17504                    b'"' => {
17505                        *i += 1;
17506                        return Some(out);
17507                    }
17508                    b'\\' if *i + 1 < bytes.len() => {
17509                        out.push(bytes[*i + 1] as char);
17510                        *i += 2;
17511                    }
17512                    c => {
17513                        out.push(c as char);
17514                        *i += 1;
17515                    }
17516                }
17517            }
17518            None
17519        } else {
17520            let start = *i;
17521            while *i < bytes.len()
17522                && !matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r' | b',' | b'=')
17523            {
17524                *i += 1;
17525            }
17526            if *i == start {
17527                return None;
17528            }
17529            Some(alloc::str::from_utf8(&bytes[start..*i]).ok()?.to_string())
17530        }
17531    };
17532    skip_ws(bytes, &mut i);
17533    while i < bytes.len() {
17534        let key = parse_token(bytes, &mut i)?;
17535        skip_ws(bytes, &mut i);
17536        if i + 1 >= bytes.len() || bytes[i] != b'=' || bytes[i + 1] != b'>' {
17537            return None;
17538        }
17539        i += 2;
17540        skip_ws(bytes, &mut i);
17541        // Check for unquoted NULL token (case-insensitive).
17542        let val_token = if i + 4 <= bytes.len()
17543            && bytes[i..i + 4].eq_ignore_ascii_case(b"NULL")
17544            && (i + 4 == bytes.len() || matches!(bytes[i + 4], b' ' | b'\t' | b',' | b'\n' | b'\r'))
17545        {
17546            i += 4;
17547            None
17548        } else {
17549            Some(parse_token(bytes, &mut i)?)
17550        };
17551        // Replace any existing entry with the same key (last-wins).
17552        if let Some(pos) = out.iter().position(|(k, _)| k == &key) {
17553            out[pos] = (key, val_token);
17554        } else {
17555            out.push((key, val_token));
17556        }
17557        skip_ws(bytes, &mut i);
17558        if i >= bytes.len() {
17559            break;
17560        }
17561        if bytes[i] == b',' {
17562            i += 1;
17563            skip_ws(bytes, &mut i);
17564            continue;
17565        }
17566        return None;
17567    }
17568    Some(out)
17569}
17570
17571/// v7.17.0 Phase 3.P0-39 — render a hstore as canonical PG text
17572/// form `"k"=>"v"` (keys and non-NULL values always quoted;
17573/// NULL token is bare).
17574fn format_hstore_str(
17575    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
17576) -> alloc::string::String {
17577    let mut out = alloc::string::String::new();
17578    for (i, (k, v)) in pairs.iter().enumerate() {
17579        if i > 0 {
17580            out.push_str(", ");
17581        }
17582        out.push('"');
17583        out.push_str(k);
17584        out.push_str("\"=>");
17585        match v {
17586            None => out.push_str("NULL"),
17587            Some(val) => {
17588                out.push('"');
17589                out.push_str(val);
17590                out.push('"');
17591            }
17592        }
17593    }
17594    out
17595}
17596
17597/// v7.17.0 Phase 3.P0-39 — pub re-export so pgwire + sqllogictest
17598/// share the single hstore renderer.
17599pub fn format_hstore_text(
17600    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
17601) -> alloc::string::String {
17602    format_hstore_str(pairs)
17603}
17604
17605// ─── v7.17.0 Phase 3.P0-40 — 2D array parse + display ─────────
17606
17607/// Split a PG external 2D-array literal `'{{a,b},{c,d}}'` into
17608/// per-row token lists. Returns Err on shape mismatch.
17609fn split_2d_literal(s: &str) -> Result<Vec<Vec<alloc::string::String>>, &'static str> {
17610    let s = s.trim();
17611    let outer = s
17612        .strip_prefix('{')
17613        .and_then(|x| x.strip_suffix('}'))
17614        .ok_or("missing outer '{...}' braces")?;
17615    let trimmed = outer.trim();
17616    if trimmed.is_empty() {
17617        return Ok(Vec::new());
17618    }
17619    let mut rows: Vec<Vec<alloc::string::String>> = Vec::new();
17620    let mut i = 0;
17621    let bytes = trimmed.as_bytes();
17622    while i < bytes.len() {
17623        while i < bytes.len() && matches!(bytes[i], b' ' | b'\t' | b'\n' | b'\r' | b',') {
17624            i += 1;
17625        }
17626        if i >= bytes.len() {
17627            break;
17628        }
17629        if bytes[i] != b'{' {
17630            return Err("expected '{' opening a row");
17631        }
17632        i += 1;
17633        let row_start = i;
17634        let mut depth = 1;
17635        while i < bytes.len() && depth > 0 {
17636            match bytes[i] {
17637                b'{' => depth += 1,
17638                b'}' => depth -= 1,
17639                _ => {}
17640            }
17641            if depth > 0 {
17642                i += 1;
17643            }
17644        }
17645        if depth != 0 {
17646            return Err("unbalanced '{...}' in row");
17647        }
17648        let row_text = &trimmed[row_start..i];
17649        i += 1;
17650        let cells: Vec<alloc::string::String> = if row_text.trim().is_empty() {
17651            Vec::new()
17652        } else {
17653            row_text.split(',').map(|t| t.trim().to_string()).collect()
17654        };
17655        rows.push(cells);
17656    }
17657    if let Some(first) = rows.first() {
17658        let cols = first.len();
17659        for r in &rows {
17660            if r.len() != cols {
17661                return Err("ragged 2D array (rows have different column counts)");
17662            }
17663        }
17664    }
17665    Ok(rows)
17666}
17667
17668fn parse_int_2d_literal(s: &str) -> Result<Vec<Vec<Option<i32>>>, &'static str> {
17669    let raw = split_2d_literal(s)?;
17670    raw.into_iter()
17671        .map(|row| {
17672            row.into_iter()
17673                .map(|cell| {
17674                    if cell.eq_ignore_ascii_case("NULL") {
17675                        Ok(None)
17676                    } else {
17677                        cell.parse::<i32>()
17678                            .map(Some)
17679                            .map_err(|_| "invalid int element")
17680                    }
17681                })
17682                .collect()
17683        })
17684        .collect()
17685}
17686
17687fn parse_bigint_2d_literal(s: &str) -> Result<Vec<Vec<Option<i64>>>, &'static str> {
17688    let raw = split_2d_literal(s)?;
17689    raw.into_iter()
17690        .map(|row| {
17691            row.into_iter()
17692                .map(|cell| {
17693                    if cell.eq_ignore_ascii_case("NULL") {
17694                        Ok(None)
17695                    } else {
17696                        cell.parse::<i64>()
17697                            .map(Some)
17698                            .map_err(|_| "invalid bigint element")
17699                    }
17700                })
17701                .collect()
17702        })
17703        .collect()
17704}
17705
17706fn parse_text_2d_literal(s: &str) -> Result<Vec<Vec<Option<alloc::string::String>>>, &'static str> {
17707    let raw = split_2d_literal(s)?;
17708    Ok(raw
17709        .into_iter()
17710        .map(|row| {
17711            row.into_iter()
17712                .map(|cell| {
17713                    if cell.eq_ignore_ascii_case("NULL") {
17714                        None
17715                    } else {
17716                        Some(cell.trim_matches('"').to_string())
17717                    }
17718                })
17719                .collect()
17720        })
17721        .collect())
17722}
17723
17724fn format_int_2d_text(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
17725    let mut out = alloc::string::String::from("{");
17726    for (i, row) in rows.iter().enumerate() {
17727        if i > 0 {
17728            out.push(',');
17729        }
17730        out.push('{');
17731        for (j, cell) in row.iter().enumerate() {
17732            if j > 0 {
17733                out.push(',');
17734            }
17735            match cell {
17736                None => out.push_str("NULL"),
17737                Some(n) => out.push_str(&alloc::format!("{n}")),
17738            }
17739        }
17740        out.push('}');
17741    }
17742    out.push('}');
17743    out
17744}
17745
17746fn format_bigint_2d_text(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
17747    let mut out = alloc::string::String::from("{");
17748    for (i, row) in rows.iter().enumerate() {
17749        if i > 0 {
17750            out.push(',');
17751        }
17752        out.push('{');
17753        for (j, cell) in row.iter().enumerate() {
17754            if j > 0 {
17755                out.push(',');
17756            }
17757            match cell {
17758                None => out.push_str("NULL"),
17759                Some(n) => out.push_str(&alloc::format!("{n}")),
17760            }
17761        }
17762        out.push('}');
17763    }
17764    out.push('}');
17765    out
17766}
17767
17768fn format_text_2d_text(rows: &[Vec<Option<alloc::string::String>>]) -> alloc::string::String {
17769    let mut out = alloc::string::String::from("{");
17770    for (i, row) in rows.iter().enumerate() {
17771        if i > 0 {
17772            out.push(',');
17773        }
17774        out.push('{');
17775        for (j, cell) in row.iter().enumerate() {
17776            if j > 0 {
17777                out.push(',');
17778            }
17779            match cell {
17780                None => out.push_str("NULL"),
17781                Some(s) => out.push_str(s),
17782            }
17783        }
17784        out.push('}');
17785    }
17786    out.push('}');
17787    out
17788}
17789
17790/// v7.17.0 Phase 3.P0-40 — pub re-exports so pgwire + sqllogictest
17791/// share the single 2D-array renderer.
17792pub fn format_int_2d_text_pub(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
17793    format_int_2d_text(rows)
17794}
17795pub fn format_bigint_2d_text_pub(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
17796    format_bigint_2d_text(rows)
17797}
17798pub fn format_text_2d_text_pub(
17799    rows: &[Vec<Option<alloc::string::String>>],
17800) -> alloc::string::String {
17801    format_text_2d_text(rows)
17802}
17803
17804/// v7.17.0 Phase 3.P0-38 — parse a PG range literal of the form
17805/// `'[lo,up)'` / `'(lo,up]'` / `'[lo,up]'` / `'(lo,up)'` /
17806/// `'empty'`. Lower / upper may be empty (unbounded). Returns
17807/// `None` on any parse failure; caller surfaces as hard error.
17808fn parse_range_str(s: &str, kind: spg_storage::RangeKind) -> Option<Value> {
17809    let s = s.trim();
17810    if s.eq_ignore_ascii_case("empty") {
17811        return Some(Value::Range {
17812            kind,
17813            lower: None,
17814            upper: None,
17815            lower_inc: false,
17816            upper_inc: false,
17817            empty: true,
17818        });
17819    }
17820    let bytes = s.as_bytes();
17821    if bytes.len() < 3 {
17822        return None;
17823    }
17824    let lower_inc = match bytes[0] {
17825        b'[' => true,
17826        b'(' => false,
17827        _ => return None,
17828    };
17829    let upper_inc = match bytes[bytes.len() - 1] {
17830        b']' => true,
17831        b')' => false,
17832        _ => return None,
17833    };
17834    let inner = &s[1..s.len() - 1];
17835    let (lo_text, up_text) = inner.split_once(',')?;
17836    let lower = if lo_text.is_empty() {
17837        None
17838    } else {
17839        Some(alloc::boxed::Box::new(parse_range_element(lo_text, kind)?))
17840    };
17841    let upper = if up_text.is_empty() {
17842        None
17843    } else {
17844        Some(alloc::boxed::Box::new(parse_range_element(up_text, kind)?))
17845    };
17846    Some(Value::Range {
17847        kind,
17848        lower,
17849        upper,
17850        lower_inc,
17851        upper_inc,
17852        empty: false,
17853    })
17854}
17855
17856/// v7.17.0 Phase 3.P0-38 — parse a single range bound text into
17857/// the matching element Value for the RangeKind.
17858fn parse_range_element(text: &str, kind: spg_storage::RangeKind) -> Option<Value> {
17859    let text = text.trim().trim_matches('"');
17860    use spg_storage::RangeKind as K;
17861    match kind {
17862        K::Int4 => text.parse::<i32>().ok().map(Value::Int),
17863        K::Int8 => text.parse::<i64>().ok().map(Value::BigInt),
17864        K::Num => {
17865            // Reuse the Numeric parse via the engine's text-coercion
17866            // path; bail to None on failure.
17867            let dot = text.find('.');
17868            let scale: u8 = dot.map_or(0, |p| (text.len() - p - 1) as u8);
17869            let digits: alloc::string::String = text
17870                .chars()
17871                .filter(|c| *c == '-' || c.is_ascii_digit())
17872                .collect();
17873            let scaled: i128 = digits.parse().ok()?;
17874            Some(Value::Numeric { scaled, scale })
17875        }
17876        K::Ts | K::TsTz => {
17877            // Reuse the existing timestamp parse path. v7.17.0
17878            // expects `'YYYY-MM-DD HH:MM:SS[.ffffff]'` in range
17879            // bounds (TZ offset on TsTz is OOS for the initial
17880            // P0-38; ship plain Timestamp shape).
17881            crate::eval::parse_timestamp_literal(text).map(Value::Timestamp)
17882        }
17883        K::Date => crate::eval::parse_date_literal(text).map(Value::Date),
17884    }
17885}
17886
17887/// v7.17.0 Phase 3.P0-38 — render a Range value as its canonical
17888/// PG text form. Re-exported via [`format_range_text`] for use
17889/// from spg-server's pgwire layer.
17890pub fn format_range_text(v: &Value) -> alloc::string::String {
17891    format_range_str(v)
17892}
17893
17894fn format_range_str(v: &Value) -> alloc::string::String {
17895    let Value::Range {
17896        lower,
17897        upper,
17898        lower_inc,
17899        upper_inc,
17900        empty,
17901        ..
17902    } = v
17903    else {
17904        return alloc::string::String::new();
17905    };
17906    if *empty {
17907        return "empty".into();
17908    }
17909    let mut out = alloc::string::String::new();
17910    out.push(if *lower_inc { '[' } else { '(' });
17911    if let Some(l) = lower {
17912        out.push_str(&format_range_element(l));
17913    }
17914    out.push(',');
17915    if let Some(u) = upper {
17916        out.push_str(&format_range_element(u));
17917    }
17918    out.push(if *upper_inc { ']' } else { ')' });
17919    out
17920}
17921
17922fn format_range_element(v: &Value) -> alloc::string::String {
17923    match v {
17924        Value::Int(n) => alloc::format!("{n}"),
17925        Value::BigInt(n) => alloc::format!("{n}"),
17926        Value::Date(d) => crate::eval::format_date(*d),
17927        Value::Timestamp(t) => crate::eval::format_timestamp(*t),
17928        Value::Numeric { scaled, scale } => crate::eval::format_numeric(*scaled, *scale),
17929        other => alloc::format!("{other:?}"),
17930    }
17931}
17932
17933/// v7.17.0 Phase 3.P0-35 — parse a PG `money` literal into i64
17934/// cents. Accepts:
17935///   * Optional leading `-` (negative)
17936///   * Optional `$` prefix
17937///   * Integer portion with optional `,` thousands separators
17938///   * Optional `.` followed by 1-2 digits (cents); 1 digit
17939///     auto-pads to 2 (`.5` → 50 cents).
17940///
17941/// Returns None on any parse failure — caller surfaces as hard
17942/// SQL error.
17943fn parse_money_str(s: &str) -> Option<i64> {
17944    let s = s.trim();
17945    let (neg, rest) = match s.strip_prefix('-') {
17946        Some(r) => (true, r.trim_start()),
17947        None => (false, s),
17948    };
17949    let rest = rest.strip_prefix('$').unwrap_or(rest).trim_start();
17950    let (int_part, frac_part) = match rest.split_once('.') {
17951        Some((i, f)) => (i, Some(f)),
17952        None => (rest, None),
17953    };
17954    if int_part.is_empty() {
17955        return None;
17956    }
17957    // Validate + strip commas from the integer portion.
17958    let mut int_digits = alloc::string::String::with_capacity(int_part.len());
17959    for b in int_part.bytes() {
17960        match b {
17961            b',' => {}
17962            b'0'..=b'9' => int_digits.push(b as char),
17963            _ => return None,
17964        }
17965    }
17966    if int_digits.is_empty() {
17967        return None;
17968    }
17969    let dollars: i64 = int_digits.parse().ok()?;
17970    let cents: i64 = match frac_part {
17971        None => 0,
17972        Some(f) => {
17973            if f.is_empty() || f.len() > 2 || !f.bytes().all(|b| b.is_ascii_digit()) {
17974                return None;
17975            }
17976            let padded = if f.len() == 1 {
17977                alloc::format!("{f}0")
17978            } else {
17979                f.to_string()
17980            };
17981            padded.parse().ok()?
17982        }
17983    };
17984    let total = dollars.checked_mul(100)?.checked_add(cents)?;
17985    Some(if neg { -total } else { total })
17986}
17987
17988/// v7.17.0 Phase 3.P0-34 — parse a PG `timetz` literal
17989/// `HH:MM:SS[.fraction]±HH[:MM]` into (us, offset_secs).
17990///
17991/// The offset suffix is MANDATORY: SPG doesn't have a session TZ
17992/// wired into eval, so a bare `HH:MM:SS` literal would be
17993/// ambiguous. Returns None for any parse failure or out-of-range
17994/// component — caller surfaces as a hard SQL error.
17995///
17996/// Offset range: ±14 hours (±50400 seconds), matching PG's
17997/// internal limit.
17998fn parse_timetz_str(s: &str) -> Option<(i64, i32)> {
17999    let s = s.trim();
18000    // Find the offset sign — scan from right since the time part
18001    // never contains '+' / '-' (after the optional fractional dot
18002    // it's all digits and ':').
18003    let bytes = s.as_bytes();
18004    let sign_pos = bytes
18005        .iter()
18006        .enumerate()
18007        .rev()
18008        .find(|&(_, &b)| b == b'+' || b == b'-')
18009        .map(|(i, _)| i)?;
18010    if sign_pos == 0 {
18011        return None; // bare sign — no time component
18012    }
18013    let time_part = &s[..sign_pos];
18014    let offset_part = &s[sign_pos..];
18015    let us = parse_time_str(time_part)?;
18016    let sign: i32 = if offset_part.starts_with('+') { 1 } else { -1 };
18017    let offset_body = &offset_part[1..];
18018    let (hh_str, mm_str) = match offset_body.split_once(':') {
18019        Some((h, m)) => (h, m),
18020        None => (offset_body, "0"),
18021    };
18022    let hh: i32 = hh_str.parse().ok()?;
18023    let mm: i32 = mm_str.parse().ok()?;
18024    if !(0..=14).contains(&hh) || !(0..=59).contains(&mm) {
18025        return None;
18026    }
18027    let total = sign * (hh * 3600 + mm * 60);
18028    if total.abs() > 50_400 {
18029        return None;
18030    }
18031    Some((us, total))
18032}
18033
18034/// v7.17.0 Phase 3.P0-33 — funnel an integer literal through MySQL
18035/// YEAR range validation: 0 sentinel or 1901..=2155. Out-of-range
18036/// surfaces as a hard SQL error (no silent truncation, mirrors PG
18037/// `time_in` / `uuid_in` discipline).
18038fn coerce_int_to_year(n: i64, col_name: &str) -> Result<Value, EngineError> {
18039    if n == 0 || (1901..=2155).contains(&n) {
18040        // u16::try_from cannot fail in this range; the cast also
18041        // covers the 0 sentinel.
18042        return Ok(Value::Year(n as u16));
18043    }
18044    Err(EngineError::Eval(EvalError::TypeMismatch {
18045        detail: alloc::format!(
18046            "year value out of range: {n} (column `{col_name}`; \
18047             MySQL accepts 0 or 1901..=2155)"
18048        ),
18049    }))
18050}
18051
18052/// v7.17.0 Phase 3.P0-32 — parse a PG `time` literal
18053/// `HH:MM:SS[.fraction]` into microseconds since 00:00:00.
18054///
18055/// Accepts:
18056///   * `HH:MM:SS`            — exact-second precision
18057///   * `HH:MM:SS.f` .. `.ffffff` — 1-6 fractional digits, right-padded
18058///     with zeros to microseconds
18059///
18060/// Range: hour 0..=23, minute 0..=59, second 0..=59. Anything else
18061/// returns None — caller surfaces as a hard SQL error (no silent
18062/// truncation, matches PG's `time_in` behaviour).
18063fn parse_time_str(s: &str) -> Option<i64> {
18064    let s = s.trim();
18065    let (hms, frac) = match s.split_once('.') {
18066        Some((h, f)) => (h, Some(f)),
18067        None => (s, None),
18068    };
18069    let mut parts = hms.split(':');
18070    let hh: u32 = parts.next()?.parse().ok()?;
18071    let mm: u32 = parts.next()?.parse().ok()?;
18072    let ss: u32 = parts.next()?.parse().ok()?;
18073    if parts.next().is_some() {
18074        return None;
18075    }
18076    if hh > 23 || mm > 59 || ss > 59 {
18077        return None;
18078    }
18079    let frac_us: i64 = match frac {
18080        None => 0,
18081        Some(f) => {
18082            if f.is_empty() || f.len() > 6 || !f.bytes().all(|b| b.is_ascii_digit()) {
18083                return None;
18084            }
18085            // Right-pad with zeros so '.5' = 500000 µsec.
18086            let mut padded = alloc::string::String::with_capacity(6);
18087            padded.push_str(f);
18088            while padded.len() < 6 {
18089                padded.push('0');
18090            }
18091            padded.parse().ok()?
18092        }
18093    };
18094    Some(
18095        i64::from(hh) * 3_600_000_000
18096            + i64::from(mm) * 60_000_000
18097            + i64::from(ss) * 1_000_000
18098            + frac_us,
18099    )
18100}
18101
18102const fn column_type_to_data_type(t: ColumnTypeName) -> DataType {
18103    match t {
18104        ColumnTypeName::SmallInt => DataType::SmallInt,
18105        ColumnTypeName::Int => DataType::Int,
18106        ColumnTypeName::BigInt => DataType::BigInt,
18107        ColumnTypeName::Float => DataType::Float,
18108        ColumnTypeName::Text => DataType::Text,
18109        ColumnTypeName::Varchar(n) => DataType::Varchar(n),
18110        ColumnTypeName::Char(n) => DataType::Char(n),
18111        ColumnTypeName::Bool => DataType::Bool,
18112        ColumnTypeName::Vector { dim, encoding } => DataType::Vector {
18113            dim,
18114            encoding: match encoding {
18115                SqlVecEncoding::F32 => VecEncoding::F32,
18116                SqlVecEncoding::Sq8 => VecEncoding::Sq8,
18117                SqlVecEncoding::F16 => VecEncoding::F16,
18118            },
18119        },
18120        ColumnTypeName::Numeric(precision, scale) => DataType::Numeric { precision, scale },
18121        ColumnTypeName::Date => DataType::Date,
18122        ColumnTypeName::Timestamp => DataType::Timestamp,
18123        ColumnTypeName::Timestamptz => DataType::Timestamptz,
18124        ColumnTypeName::Json => DataType::Json,
18125        ColumnTypeName::Jsonb => DataType::Jsonb,
18126        ColumnTypeName::Bytes => DataType::Bytes,
18127        ColumnTypeName::TextArray => DataType::TextArray,
18128        ColumnTypeName::IntArray => DataType::IntArray,
18129        ColumnTypeName::BigIntArray => DataType::BigIntArray,
18130        ColumnTypeName::TsVector => DataType::TsVector,
18131        ColumnTypeName::TsQuery => DataType::TsQuery,
18132        ColumnTypeName::Uuid => DataType::Uuid,
18133        ColumnTypeName::Time => DataType::Time,
18134        ColumnTypeName::Year => DataType::Year,
18135        ColumnTypeName::TimeTz => DataType::TimeTz,
18136        ColumnTypeName::Money => DataType::Money,
18137        ColumnTypeName::Range(k) => DataType::Range(match k {
18138            spg_sql::ast::RangeKindAst::Int4 => spg_storage::RangeKind::Int4,
18139            spg_sql::ast::RangeKindAst::Int8 => spg_storage::RangeKind::Int8,
18140            spg_sql::ast::RangeKindAst::Num => spg_storage::RangeKind::Num,
18141            spg_sql::ast::RangeKindAst::Ts => spg_storage::RangeKind::Ts,
18142            spg_sql::ast::RangeKindAst::TsTz => spg_storage::RangeKind::TsTz,
18143            spg_sql::ast::RangeKindAst::Date => spg_storage::RangeKind::Date,
18144        }),
18145        ColumnTypeName::Hstore => DataType::Hstore,
18146        ColumnTypeName::IntArray2D => DataType::IntArray2D,
18147        ColumnTypeName::BigIntArray2D => DataType::BigIntArray2D,
18148        ColumnTypeName::TextArray2D => DataType::TextArray2D,
18149    }
18150}
18151
18152/// Convert an INSERT VALUES expression to a storage Value. Supports literal
18153/// expressions, unary-minus over numeric literals, and pgvector-style
18154/// `'[..]'::vector` cast (v1.2). Anything more complex returns `Unsupported`.
18155fn literal_expr_to_value(expr: Expr) -> Result<Value, EngineError> {
18156    match expr {
18157        Expr::Literal(l) => Ok(literal_to_value(l)),
18158        Expr::Cast { expr, target } => {
18159            let inner_value = literal_expr_to_value(*expr)?;
18160            crate::eval::cast_value(inner_value, target).map_err(EngineError::Eval)
18161        }
18162        Expr::Unary {
18163            op: UnOp::Neg,
18164            expr,
18165        } => match *expr {
18166            Expr::Literal(Literal::Integer(n)) => {
18167                // Fold to i32 if it fits, else BigInt. Parser emits Integer(i64)
18168                // — overflow on negate of i64::MIN is the one edge case.
18169                let neg = n.checked_neg().ok_or_else(|| {
18170                    EngineError::Unsupported("integer literal overflow on negation".into())
18171                })?;
18172                Ok(int_value_for(neg))
18173            }
18174            Expr::Literal(Literal::Float(x)) => Ok(Value::Float(-x)),
18175            other => Err(EngineError::Unsupported(alloc::format!(
18176                "unary minus over non-literal expression: {other:?}"
18177            ))),
18178        },
18179        // v7.10.10 — `ARRAY[lit, lit, …]` constructor accepted at
18180        // INSERT-time. Each element must reduce to a Value through
18181        // `literal_expr_to_value`; NULL elements become `None`.
18182        // v7.11.13 — deduce shape from element values: all Int →
18183        // IntArray; any BigInt → BigIntArray (widening); any Text
18184        // → TextArray. Cast targets (`ARRAY[]::INT[]`) flow through
18185        // the outer Cast arm before reaching here and re-coerce.
18186        Expr::Array(items) => {
18187            let mut materialised: alloc::vec::Vec<Value> =
18188                alloc::vec::Vec::with_capacity(items.len());
18189            for elem in items {
18190                materialised.push(literal_expr_to_value(elem)?);
18191            }
18192            Ok(array_literal_widen(materialised))
18193        }
18194        // Any other Expr shape — fall back to a general evaluation
18195        // against an empty row + empty schema. This unblocks the
18196        // app-common patterns where INSERT VALUES carries a
18197        // non-correlated function call:
18198        //   INSERT INTO t VALUES (concat('U-', 42))
18199        //   INSERT INTO t VALUES (now())
18200        //   INSERT INTO t VALUES (format('%s-%s', 'a', 'b'))
18201        // Any expression that references a column or `$N`
18202        // placeholder fails cleanly inside `eval_expr` with a
18203        // descriptive error; literals + casts + ARRAY[…] continue
18204        // to take the fast paths above so the hot INSERT path is
18205        // unchanged on the common case.
18206        other => {
18207            let empty_schema: alloc::vec::Vec<spg_storage::ColumnSchema> = alloc::vec::Vec::new();
18208            let ctx = EvalContext::new(&empty_schema, None);
18209            let empty_row = spg_storage::Row::new(alloc::vec::Vec::new());
18210            crate::eval::eval_expr(&other, &empty_row, &ctx).map_err(EngineError::Eval)
18211        }
18212    }
18213}
18214
18215fn literal_to_value(l: Literal) -> Value {
18216    match l {
18217        Literal::Integer(n) => int_value_for(n),
18218        Literal::Float(x) => Value::Float(x),
18219        Literal::String(s) => Value::Text(s),
18220        Literal::Bool(b) => Value::Bool(b),
18221        Literal::Null => Value::Null,
18222        Literal::Vector(v) => Value::Vector(v),
18223        Literal::TextArray(items) => Value::TextArray(items),
18224        Literal::IntArray(items) => Value::IntArray(items),
18225        Literal::BigIntArray(items) => Value::BigIntArray(items),
18226        Literal::Interval { months, micros, .. } => Value::Interval { months, micros },
18227    }
18228}
18229
18230/// Pick `Int` (`i32`) when the literal fits, else `BigInt`. `INT` vs `BIGINT`
18231/// columns will still enforce the right tag downstream — this is just the
18232/// default we synthesise from an unannotated integer literal.
18233fn int_value_for(n: i64) -> Value {
18234    if let Ok(small) = i32::try_from(n) {
18235        Value::Int(small)
18236    } else {
18237        Value::BigInt(n)
18238    }
18239}
18240
18241/// Widen / narrow `v` to fit `expected`. Numerics permit safe widening
18242/// (`Int → BigInt`, `Int/BigInt → Float`) and best-effort narrowing
18243/// (`BigInt → Int` succeeds only when the value fits in `i32`). Everything
18244/// else returns `TypeMismatch` carrying the column name for caller diagnostics.
18245/// `NULL` is always permitted; the nullability check happens later in storage.
18246#[allow(clippy::too_many_lines)]
18247/// v7.17.0 Phase 4.4 — reject negative integer values on UNSIGNED
18248/// columns. Called after `coerce_value` at each INSERT / UPDATE
18249/// site that has ColumnSchema context. NULL passes through (a
18250/// nullable UNSIGNED column can legitimately hold NULL).
18251fn check_unsigned_range(
18252    v: &Value,
18253    schema: &ColumnSchema,
18254    position: usize,
18255) -> Result<(), EngineError> {
18256    if !schema.is_unsigned {
18257        return Ok(());
18258    }
18259    let n = match v {
18260        Value::SmallInt(x) => i64::from(*x),
18261        Value::Int(x) => i64::from(*x),
18262        Value::BigInt(x) => *x,
18263        _ => return Ok(()), // non-integer cells (NULL, default) skip
18264    };
18265    if n < 0 {
18266        return Err(EngineError::Unsupported(alloc::format!(
18267            "column {:?} is UNSIGNED but got negative value {n} at position {position}",
18268            schema.name
18269        )));
18270    }
18271    Ok(())
18272}
18273
18274fn coerce_value(
18275    v: Value,
18276    expected: DataType,
18277    col_name: &str,
18278    position: usize,
18279) -> Result<Value, EngineError> {
18280    if v.is_null() {
18281        return Ok(Value::Null);
18282    }
18283    let actual = v.data_type().expect("non-null");
18284    if actual == expected {
18285        return Ok(v);
18286    }
18287    let coerced = match (v, expected) {
18288        (Value::Int(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
18289        (Value::Int(n), DataType::Float) => Some(Value::Float(f64::from(n))),
18290        (Value::Int(n), DataType::SmallInt) => i16::try_from(n).ok().map(Value::SmallInt),
18291        (Value::Int(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
18292            i128::from(n),
18293            precision,
18294            scale,
18295            col_name,
18296        )?),
18297        (Value::SmallInt(n), DataType::Int) => Some(Value::Int(i32::from(n))),
18298        (Value::SmallInt(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
18299        (Value::SmallInt(n), DataType::Float) => Some(Value::Float(f64::from(n))),
18300        (Value::SmallInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
18301            i128::from(n),
18302            precision,
18303            scale,
18304            col_name,
18305        )?),
18306        (Value::BigInt(n), DataType::Int) => i32::try_from(n).ok().map(Value::Int),
18307        (Value::BigInt(n), DataType::SmallInt) => i16::try_from(n).ok().map(Value::SmallInt),
18308        #[allow(clippy::cast_precision_loss)]
18309        (Value::BigInt(n), DataType::Float) => Some(Value::Float(n as f64)),
18310        (Value::BigInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
18311            i128::from(n),
18312            precision,
18313            scale,
18314            col_name,
18315        )?),
18316        (Value::Float(x), DataType::Numeric { precision, scale }) => {
18317            Some(numeric_from_float(x, precision, scale, col_name)?)
18318        }
18319        // v7.17.0 Phase 3.P0-67 — Text → NUMERIC. Parse a
18320        // canonical decimal text (`"-1234.56"` / `"42"` /
18321        // `"0.0001"`) into `(mantissa, source_scale)` and rescale
18322        // to the column's declared scale. Required for prepared
18323        // binds: `value_to_literal` flattens a Value::Numeric
18324        // into a TEXT literal because Literal carries no native
18325        // Numeric variant, so the placeholder substitution path
18326        // reaches coerce_value as Text → Numeric. Without this
18327        // arm the round-trip surfaces a TypeMismatch even though
18328        // the cell already left the engine as a valid Numeric.
18329        (Value::Text(s), DataType::Numeric { precision, scale }) => {
18330            let Some((mantissa, src_scale)) = parse_numeric_text(&s) else {
18331                return Err(EngineError::Eval(EvalError::TypeMismatch {
18332                    detail: alloc::format!("cannot parse {s:?} as NUMERIC for column `{col_name}`"),
18333                }));
18334            };
18335            Some(numeric_rescale(
18336                mantissa, src_scale, precision, scale, col_name,
18337            )?)
18338        }
18339        // Text → DATE / TIMESTAMP: parse canonical text forms.
18340        (Value::Text(s), DataType::Date) => {
18341            let d = eval::parse_date_literal(&s).ok_or_else(|| {
18342                EngineError::Eval(EvalError::TypeMismatch {
18343                    detail: alloc::format!("cannot parse {s:?} as DATE for column `{col_name}`"),
18344                })
18345            })?;
18346            Some(Value::Date(d))
18347        }
18348        // v7.14.0 — MySQL DEFAULT clauses quote integer / float
18349        // / boolean literals (`DEFAULT '0'`, `DEFAULT '1'`,
18350        // `DEFAULT '3.14'`, `DEFAULT 'true'`). Coerce the text
18351        // form to the column's numeric / bool type at DEFAULT-
18352        // installation time so the storage check sees a typed
18353        // value. Parse failures fall through to TypeMismatch.
18354        (Value::Text(s), DataType::SmallInt) => s.parse::<i16>().ok().map(Value::SmallInt),
18355        (Value::Text(s), DataType::Int) => s.parse::<i32>().ok().map(Value::Int),
18356        (Value::Text(s), DataType::BigInt) => s.parse::<i64>().ok().map(Value::BigInt),
18357        (Value::Text(s), DataType::Float) => s.parse::<f64>().ok().map(Value::Float),
18358        (Value::Text(s), DataType::Bool) => match s.to_ascii_lowercase().as_str() {
18359            "0" | "false" | "f" | "no" | "off" => Some(Value::Bool(false)),
18360            "1" | "true" | "t" | "yes" | "on" => Some(Value::Bool(true)),
18361            _ => None,
18362        },
18363        // v7.17.0 Phase 3.P0-46 — MySQL TINYINT(1) (which Phase 4.3
18364        // classifies as DataType::Bool) is the storage shape every
18365        // mysqldump-restored boolean column lands in. mysqldump emits
18366        // the values as integer `0` / `1` literals, so int → bool
18367        // coerce on INSERT is required for a 0-change cutover. MySQL's
18368        // rule is "any non-zero is truthy"; we follow that for all
18369        // signed int widths so the same coerce path serves an
18370        // explicit `BOOLEAN` column too.
18371        (Value::Int(n), DataType::Bool) => Some(Value::Bool(n != 0)),
18372        (Value::SmallInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
18373        (Value::BigInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
18374        // v4.9: Text ↔ JSON coercion. No structural validation —
18375        // any text literal is accepted; the responsibility for
18376        // valid JSON lies with the producer.
18377        (Value::Text(s), DataType::Json | DataType::Jsonb) => Some(Value::Json(s)),
18378        (Value::Json(s), DataType::Text) => Some(Value::Text(s)),
18379        // v7.13.3 — mailrs round-7 S10. SPG's storage represents
18380        // both JSON and JSONB on-disk as `Value::Json(String)` —
18381        // they share the underlying text payload. The cast
18382        // `'<text>'::jsonb` produces a Value::Json that needs to
18383        // satisfy a DataType::Jsonb column. Identity coerce in
18384        // both directions so JSON ↔ JSONB assignments work at all
18385        // INSERT / ALTER COLUMN TYPE / DEFAULT contexts.
18386        (Value::Json(s), DataType::Jsonb | DataType::Json) => Some(Value::Json(s)),
18387        // v7.10.4 — Text → BYTEA. Decode PG-style literal forms:
18388        //   - Hex:    `\x48656c6c6f`  (case-insensitive hex pairs)
18389        //   - Escape: `Hello\\000world`  (backslash + octal triples)
18390        //   - Plain:  any string → raw UTF-8 bytes (PG also accepts)
18391        // Errors surface as TypeMismatch so the operator gets a
18392        // clear "this literal isn't a bytea literal" hint.
18393        (Value::Text(s), DataType::Bytes) => {
18394            let bytes = decode_bytea_literal(&s).map_err(|e| {
18395                EngineError::Eval(EvalError::TypeMismatch {
18396                    detail: alloc::format!(
18397                        "cannot parse {s:?} as BYTEA for column `{col_name}`: {e}"
18398                    ),
18399                })
18400            })?;
18401            Some(Value::Bytes(bytes))
18402        }
18403        // v7.10.4 — BYTEA → Text round-trip uses the PG hex
18404        // output (lowercase, `\x` prefix). Important when a
18405        // SELECT pulls a bytea cell through a Text column path.
18406        (Value::Bytes(b), DataType::Text) => Some(Value::Text(encode_bytea_hex(&b))),
18407        // v7.17.0 — Text → UUID. PG accepts canonical hyphenated,
18408        // unhyphenated, uppercase, and `{...}`-braced forms; we
18409        // funnel all four through `spg_storage::parse_uuid_str`.
18410        // A malformed literal surfaces as a SQL TypeMismatch
18411        // rather than silently inserting garbage — `0-change
18412        // cutover` requires that an app inserting bad UUID text
18413        // sees the same hard error PG would raise.
18414        (Value::Text(s), DataType::Uuid) => match spg_storage::parse_uuid_str(&s) {
18415            Some(b) => Some(Value::Uuid(b)),
18416            None => {
18417                return Err(EngineError::Eval(EvalError::TypeMismatch {
18418                    detail: alloc::format!(
18419                        "invalid input syntax for type uuid: {s:?} (column `{col_name}`)"
18420                    ),
18421                }));
18422            }
18423        },
18424        // v7.17.0 — UUID → Text canonical 8-4-4-4-12 lowercase.
18425        // Surfaces when a SELECT plucks a uuid cell through a
18426        // Text column path (e.g. INSERT INTO log SELECT id::text
18427        // FROM other_table).
18428        (Value::Uuid(b), DataType::Text) => Some(Value::Text(spg_storage::format_uuid(&b))),
18429        // v7.17.0 Phase 3.P0-32 — Text → TIME. Accepts
18430        // `HH:MM:SS` and `HH:MM:SS.ffffff` (1-6 fractional digits).
18431        // Out-of-range hour/min/sec is a hard SQL error (no
18432        // silent truncation — same 0-change-cutover discipline
18433        // we apply to UUID).
18434        (Value::Text(s), DataType::Time) => match parse_time_str(&s) {
18435            Some(us) => Some(Value::Time(us)),
18436            None => {
18437                return Err(EngineError::Eval(EvalError::TypeMismatch {
18438                    detail: alloc::format!(
18439                        "invalid input syntax for type time: {s:?} (column `{col_name}`)"
18440                    ),
18441                }));
18442            }
18443        },
18444        // v7.17.0 Phase 3.P0-32 — TIME → Text canonical `HH:MM:SS[.ffffff]`.
18445        (Value::Time(us), DataType::Text) => Some(Value::Text(eval::format_time(us))),
18446        // v7.17.0 Phase 3.P0-33 — int / bigint → YEAR. Range
18447        // check enforces the MySQL canonical 1901..=2155 + 0
18448        // sentinel; out-of-range is a hard SQL error (no silent
18449        // truncation, mirrors P0-32 / P0-25 discipline).
18450        (Value::SmallInt(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
18451        (Value::Int(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
18452        (Value::BigInt(n), DataType::Year) => Some(coerce_int_to_year(n, col_name)?),
18453        // Text → YEAR. Accepts the 4-digit decimal form only;
18454        // two-digit YEAR (`'99'` → 1999) was deprecated in MySQL
18455        // 5.7 and is out of scope for v7.17.0.
18456        (Value::Text(s), DataType::Year) => match s.trim().parse::<i64>() {
18457            Ok(n) => Some(coerce_int_to_year(n, col_name)?),
18458            Err(_) => {
18459                return Err(EngineError::Eval(EvalError::TypeMismatch {
18460                    detail: alloc::format!(
18461                        "invalid input syntax for type year: {s:?} (column `{col_name}`)"
18462                    ),
18463                }));
18464            }
18465        },
18466        // YEAR → Text 4-digit zero-padded.
18467        (Value::Year(y), DataType::Text) => Some(Value::Text(alloc::format!("{y:04}"))),
18468        // v7.17.0 Phase 3.P0-34 — Text → TIMETZ. Mandatory
18469        // signed offset suffix; missing offset is a hard error
18470        // (SPG has no session TZ wired into eval, unlike PG).
18471        (Value::Text(s), DataType::TimeTz) => match parse_timetz_str(&s) {
18472            Some((us, offset_secs)) => Some(Value::TimeTz { us, offset_secs }),
18473            None => {
18474                return Err(EngineError::Eval(EvalError::TypeMismatch {
18475                    detail: alloc::format!(
18476                        "invalid input syntax for type time with time zone: \
18477                         {s:?} (column `{col_name}`)"
18478                    ),
18479                }));
18480            }
18481        },
18482        // TIMETZ → Text canonical `HH:MM:SS[.ffffff]±HH[:MM]`.
18483        (Value::TimeTz { us, offset_secs }, DataType::Text) => {
18484            Some(Value::Text(eval::format_timetz(us, offset_secs)))
18485        }
18486        // v7.17.0 Phase 3.P0-35 — Text → MONEY. Accepts `$N.NN`,
18487        // `$N,NNN.NN`, optional leading `-`. Bare numeric literals
18488        // arrive via the Int/BigInt/Float/Numeric arms below.
18489        (Value::Text(s), DataType::Money) => match parse_money_str(&s) {
18490            Some(c) => Some(Value::Money(c)),
18491            None => {
18492                return Err(EngineError::Eval(EvalError::TypeMismatch {
18493                    detail: alloc::format!(
18494                        "invalid input syntax for type money: {s:?} (column `{col_name}`)"
18495                    ),
18496                }));
18497            }
18498        },
18499        // Int / BigInt / SmallInt / Float / Numeric → MONEY.
18500        // Bare numeric literal is interpreted as a major-unit
18501        // amount (matches PG: `100`::money → $100.00 = 10000 cents).
18502        (Value::SmallInt(n), DataType::Money) => {
18503            Some(Value::Money(i64::from(n).saturating_mul(100)))
18504        }
18505        (Value::Int(n), DataType::Money) => Some(Value::Money(i64::from(n).saturating_mul(100))),
18506        (Value::BigInt(n), DataType::Money) => Some(Value::Money(n.saturating_mul(100))),
18507        (Value::Float(x), DataType::Money) => {
18508            // Round half-away-from-zero to cents (no_std — no
18509            // `f64::round`, so hand-roll via biased truncation).
18510            let scaled = x * 100.0;
18511            let cents = if scaled >= 0.0 {
18512                (scaled + 0.5) as i64
18513            } else {
18514                (scaled - 0.5) as i64
18515            };
18516            Some(Value::Money(cents))
18517        }
18518        (Value::Numeric { scaled, scale }, DataType::Money) => {
18519            // Convert exact decimal to cents (scale 2). If scale > 2,
18520            // round half-away-from-zero. If scale < 2, multiply up.
18521            let cents = if scale == 2 {
18522                scaled
18523            } else if scale < 2 {
18524                let mult = 10_i128.pow(u32::from(2 - scale));
18525                scaled.saturating_mul(mult)
18526            } else {
18527                let div = 10_i128.pow(u32::from(scale - 2));
18528                let half = div / 2;
18529                let bias = if scaled >= 0 { half } else { -half };
18530                (scaled + bias) / div
18531            };
18532            Some(Value::Money(i64::try_from(cents).unwrap_or(i64::MAX)))
18533        }
18534        // MONEY → Text canonical `$N,NNN.CC`.
18535        (Value::Money(c), DataType::Text) => Some(Value::Text(eval::format_money(c))),
18536        // v7.17.0 Phase 3.P0-38 — Text → Range. Accepts canonical
18537        // PG forms: `'empty'`, `'[a,b)'`, `'(a,b]'`, `'[a,b]'`,
18538        // `'(a,b)'`, with empty lower or upper for unbounded.
18539        (Value::Text(s), DataType::Range(kind)) => match parse_range_str(&s, kind) {
18540            Some(v) => Some(v),
18541            None => {
18542                return Err(EngineError::Eval(EvalError::TypeMismatch {
18543                    detail: alloc::format!(
18544                        "invalid input syntax for range type: {s:?} (column `{col_name}`)"
18545                    ),
18546                }));
18547            }
18548        },
18549        // Range → Text canonical form (`[a,b)`, `'empty'`, etc).
18550        (v @ Value::Range { .. }, DataType::Text) => Some(Value::Text(format_range_str(&v))),
18551        // v7.17.0 Phase 3.P0-39 — Text → Hstore.
18552        (Value::Text(s), DataType::Hstore) => match parse_hstore_str(&s) {
18553            Some(pairs) => Some(Value::Hstore(pairs)),
18554            None => {
18555                return Err(EngineError::Eval(EvalError::TypeMismatch {
18556                    detail: alloc::format!(
18557                        "invalid input syntax for type hstore: {s:?} (column `{col_name}`)"
18558                    ),
18559                }));
18560            }
18561        },
18562        // Hstore → Text canonical `"k"=>"v"` form.
18563        (Value::Hstore(pairs), DataType::Text) => Some(Value::Text(format_hstore_str(&pairs))),
18564        // v7.17.0 Phase 3.P0-40 — Text → 2D arrays via PG
18565        // external `'{{a,b},{c,d}}'` literal.
18566        (Value::Text(s), DataType::IntArray2D) => match parse_int_2d_literal(&s) {
18567            Ok(m) => Some(Value::IntArray2D(m)),
18568            Err(e) => {
18569                return Err(EngineError::Eval(EvalError::TypeMismatch {
18570                    detail: alloc::format!(
18571                        "invalid input syntax for INT[][]: {s:?} (column `{col_name}`): {e}"
18572                    ),
18573                }));
18574            }
18575        },
18576        (Value::Text(s), DataType::BigIntArray2D) => match parse_bigint_2d_literal(&s) {
18577            Ok(m) => Some(Value::BigIntArray2D(m)),
18578            Err(e) => {
18579                return Err(EngineError::Eval(EvalError::TypeMismatch {
18580                    detail: alloc::format!(
18581                        "invalid input syntax for BIGINT[][]: {s:?} (column `{col_name}`): {e}"
18582                    ),
18583                }));
18584            }
18585        },
18586        (Value::Text(s), DataType::TextArray2D) => match parse_text_2d_literal(&s) {
18587            Ok(m) => Some(Value::TextArray2D(m)),
18588            Err(e) => {
18589                return Err(EngineError::Eval(EvalError::TypeMismatch {
18590                    detail: alloc::format!(
18591                        "invalid input syntax for TEXT[][]: {s:?} (column `{col_name}`): {e}"
18592                    ),
18593                }));
18594            }
18595        },
18596        // 2D arrays → Text canonical nested form.
18597        (Value::IntArray2D(rows), DataType::Text) => Some(Value::Text(format_int_2d_text(&rows))),
18598        (Value::BigIntArray2D(rows), DataType::Text) => {
18599            Some(Value::Text(format_bigint_2d_text(&rows)))
18600        }
18601        (Value::TextArray2D(rows), DataType::Text) => Some(Value::Text(format_text_2d_text(&rows))),
18602        // v7.10.11 — Text → TEXT[]. Decode PG's external array
18603        // form `'{a,b,NULL}'`. NULL element token (case-insensitive)
18604        // is the literal `NULL`; everything else is a quoted or
18605        // unquoted text element. mailrs `'{label1,label2}'::TEXT[]`.
18606        (Value::Text(s), DataType::TextArray) => {
18607            let arr = decode_text_array_literal(&s).map_err(|e| {
18608                EngineError::Eval(EvalError::TypeMismatch {
18609                    detail: alloc::format!(
18610                        "cannot parse {s:?} as TEXT[] for column `{col_name}`: {e}"
18611                    ),
18612                })
18613            })?;
18614            Some(Value::TextArray(arr))
18615        }
18616        // v7.16.0 — Text → IntArray / BigIntArray for the
18617        // spg-sqlx Bind path. Decode the PG external form
18618        // `{1,2,3}` as a TEXT array first, then parse each
18619        // element as int. Same shape as the TextArray decode
18620        // above with an element-wise narrow.
18621        (Value::Text(s), DataType::IntArray) => {
18622            let arr = decode_text_array_literal(&s).map_err(|e| {
18623                EngineError::Eval(EvalError::TypeMismatch {
18624                    detail: alloc::format!(
18625                        "cannot parse {s:?} as INT[] for column `{col_name}`: {e}"
18626                    ),
18627                })
18628            })?;
18629            let mut out: Vec<Option<i32>> = Vec::with_capacity(arr.len());
18630            for elem in arr {
18631                match elem {
18632                    None => out.push(None),
18633                    Some(t) => {
18634                        let n: i32 = t.parse().map_err(|_| {
18635                            EngineError::Eval(EvalError::TypeMismatch {
18636                                detail: alloc::format!(
18637                                    "cannot parse {t:?} as INT element for `{col_name}`"
18638                                ),
18639                            })
18640                        })?;
18641                        out.push(Some(n));
18642                    }
18643                }
18644            }
18645            Some(Value::IntArray(out))
18646        }
18647        (Value::Text(s), DataType::BigIntArray) => {
18648            let arr = decode_text_array_literal(&s).map_err(|e| {
18649                EngineError::Eval(EvalError::TypeMismatch {
18650                    detail: alloc::format!(
18651                        "cannot parse {s:?} as BIGINT[] for column `{col_name}`: {e}"
18652                    ),
18653                })
18654            })?;
18655            let mut out: Vec<Option<i64>> = Vec::with_capacity(arr.len());
18656            for elem in arr {
18657                match elem {
18658                    None => out.push(None),
18659                    Some(t) => {
18660                        let n: i64 = t.parse().map_err(|_| {
18661                            EngineError::Eval(EvalError::TypeMismatch {
18662                                detail: alloc::format!(
18663                                    "cannot parse {t:?} as BIGINT element for `{col_name}`"
18664                                ),
18665                            })
18666                        })?;
18667                        out.push(Some(n));
18668                    }
18669                }
18670            }
18671            Some(Value::BigIntArray(out))
18672        }
18673        // v7.10.11 — TEXT[] → Text round-trip uses PG's
18674        // external array form (`{a,b,NULL}`). Lets a SELECT
18675        // pull an array column through any Text-side codepath.
18676        (Value::TextArray(items), DataType::Text) => Some(Value::Text(encode_text_array(&items))),
18677        // v7.17.0 Phase 3.P0-68 — Text → VECTOR auto-coerce.
18678        // Matches the existing Text → TsVector arm and the
18679        // `::vector` cast: PG-canonical pgvector external form
18680        // (`'[1, 2, -3]'`) becomes a typed Vector value at the
18681        // column boundary. Dim mismatch surfaces as TypeMismatch.
18682        // For SQ8 / HALF encodings we chain through the standard
18683        // quantise helpers so the storage shape matches the
18684        // declared encoding without a second coerce pass.
18685        (Value::Text(s), DataType::Vector { dim, encoding }) => {
18686            let parsed = eval::parse_vector_text(&s).ok_or_else(|| {
18687                EngineError::Eval(EvalError::TypeMismatch {
18688                    detail: alloc::format!("cannot parse {s:?} as VECTOR for column `{col_name}`"),
18689                })
18690            })?;
18691            if parsed.len() != dim as usize {
18692                return Err(EngineError::Eval(EvalError::TypeMismatch {
18693                    detail: alloc::format!(
18694                        "VECTOR({dim}) column `{col_name}` rejects literal of length {}",
18695                        parsed.len()
18696                    ),
18697                }));
18698            }
18699            Some(match encoding {
18700                VecEncoding::F32 => Value::Vector(parsed),
18701                VecEncoding::Sq8 => Value::Sq8Vector(spg_storage::quantize::quantize(&parsed)),
18702                VecEncoding::F16 => {
18703                    Value::HalfVector(spg_storage::halfvec::HalfVector::from_f32_slice(&parsed))
18704                }
18705            })
18706        }
18707        // v7.16.1 — Text → TSVECTOR auto-coerce for the
18708        // INSERT-side wire path (mailrs round-9 A.2.a). PG
18709        // implicitly promotes the TEXT literal at INSERT into a
18710        // TSVECTOR column; SPG previously rejected with a hard
18711        // type mismatch, blocking 23,276 pg_dump rows into
18712        // `messages.search_vector`. We route through the same
18713        // `decode_tsvector_external` the `::tsvector` cast
18714        // already uses, so PG-canonical forms (`'word'`,
18715        // `'word:1A,2B'`, multi-lexeme, empty `''`) all parse.
18716        (Value::Text(s), DataType::TsVector) => {
18717            let lexs = eval::decode_tsvector_external(&s).map_err(|e| {
18718                EngineError::Eval(EvalError::TypeMismatch {
18719                    detail: alloc::format!(
18720                        "cannot parse {s:?} as TSVECTOR for column `{col_name}`: {e}"
18721                    ),
18722                })
18723            })?;
18724            Some(Value::TsVector(lexs))
18725        }
18726        (Value::Text(s), DataType::Timestamp | DataType::Timestamptz) => {
18727            let t = eval::parse_timestamp_literal(&s).ok_or_else(|| {
18728                EngineError::Eval(EvalError::TypeMismatch {
18729                    detail: alloc::format!(
18730                        "cannot parse {s:?} as TIMESTAMP for column `{col_name}`"
18731                    ),
18732                })
18733            })?;
18734            Some(Value::Timestamp(t))
18735        }
18736        // DATE ↔ TIMESTAMP convertibility (DATE → midnight,
18737        // TIMESTAMP → day truncation).
18738        (Value::Date(d), DataType::Timestamp | DataType::Timestamptz) => {
18739            Some(Value::Timestamp(i64::from(d) * 86_400_000_000))
18740        }
18741        // v7.9.21 — Value::Timestamp lands in either Timestamp
18742        // or Timestamptz columns; the on-disk layout is the
18743        // same i64 microseconds UTC.
18744        (Value::Timestamp(t), DataType::Timestamptz) => Some(Value::Timestamp(t)),
18745        (Value::Timestamp(t), DataType::Date) => {
18746            let days = t.div_euclid(86_400_000_000);
18747            i32::try_from(days).ok().map(Value::Date)
18748        }
18749        (
18750            Value::Numeric {
18751                scaled,
18752                scale: src_scale,
18753            },
18754            DataType::Numeric { precision, scale },
18755        ) => Some(numeric_rescale(
18756            scaled, src_scale, precision, scale, col_name,
18757        )?),
18758        #[allow(clippy::cast_precision_loss)]
18759        (Value::Numeric { scaled, scale }, DataType::Float) => {
18760            let mut div = 1.0_f64;
18761            for _ in 0..scale {
18762                div *= 10.0;
18763            }
18764            Some(Value::Float((scaled as f64) / div))
18765        }
18766        (Value::Numeric { scaled, scale }, DataType::Int) => {
18767            let truncated = numeric_truncate_to_integer(scaled, scale);
18768            i32::try_from(truncated).ok().map(Value::Int)
18769        }
18770        (Value::Numeric { scaled, scale }, DataType::BigInt) => {
18771            let truncated = numeric_truncate_to_integer(scaled, scale);
18772            i64::try_from(truncated).ok().map(Value::BigInt)
18773        }
18774        (Value::Numeric { scaled, scale }, DataType::SmallInt) => {
18775            let truncated = numeric_truncate_to_integer(scaled, scale);
18776            i16::try_from(truncated).ok().map(Value::SmallInt)
18777        }
18778        // VARCHAR(n) enforces an upper bound on character count.
18779        (Value::Text(s), DataType::Varchar(max)) => {
18780            if u32::try_from(s.chars().count()).unwrap_or(u32::MAX) <= max {
18781                Some(Value::Text(s))
18782            } else {
18783                return Err(EngineError::Unsupported(alloc::format!(
18784                    "value for VARCHAR({max}) column `{col_name}` exceeds length: \
18785                     {} chars",
18786                    s.chars().count()
18787                )));
18788            }
18789        }
18790        // v6.0.1: f32 → SQ8 INSERT-time quantisation. Triggered
18791        // when the column declares `VECTOR(N) USING SQ8` and
18792        // the INSERT VALUES expression yields a raw f32 vector
18793        // (the normal pgvector-shape literal). Dim mismatch
18794        // falls through the `_ => None` arm and surfaces as
18795        // `TypeMismatch` with the expected SQ8 column type —
18796        // matching the F32 path's existing error.
18797        (
18798            Value::Vector(v),
18799            DataType::Vector {
18800                dim,
18801                encoding: VecEncoding::Sq8,
18802            },
18803        ) if v.len() == dim as usize => Some(Value::Sq8Vector(spg_storage::quantize::quantize(&v))),
18804        // v6.0.3: f32 → f16 INSERT-time conversion for HALF
18805        // columns. Bit-exact at the storage layer (modulo
18806        // half-precision rounding); no rerank pass needed at
18807        // search time.
18808        (
18809            Value::Vector(v),
18810            DataType::Vector {
18811                dim,
18812                encoding: VecEncoding::F16,
18813            },
18814        ) if v.len() == dim as usize => Some(Value::HalfVector(
18815            spg_storage::halfvec::HalfVector::from_f32_slice(&v),
18816        )),
18817        // CHAR(n) right-pads with U+0020 to exactly n chars; if the input
18818        // is already longer we reject (PG truncates trailing-space-only;
18819        // staying strict for v1).
18820        (Value::Text(s), DataType::Char(size)) => {
18821            let len = u32::try_from(s.chars().count()).unwrap_or(u32::MAX);
18822            if len > size {
18823                return Err(EngineError::Unsupported(alloc::format!(
18824                    "value for CHAR({size}) column `{col_name}` exceeds length: \
18825                     {len} chars"
18826                )));
18827            }
18828            let need = (size - len) as usize;
18829            let mut padded = s;
18830            padded.reserve(need);
18831            for _ in 0..need {
18832                padded.push(' ');
18833            }
18834            Some(Value::Text(padded))
18835        }
18836        _ => None,
18837    };
18838    coerced.ok_or(EngineError::Storage(StorageError::TypeMismatch {
18839        column: col_name.into(),
18840        expected,
18841        actual,
18842        position,
18843    }))
18844}
18845
18846/// v7.12.4 — render a function arg list into the
18847/// canonical form the storage layer caches as
18848/// [`spg_storage::FunctionDef::args_repr`]. The catalogue uses
18849/// this string for both display + as a coarse signature key
18850/// for the (deferred) overload resolution v7.12.5+ adds.
18851fn render_function_args(args: &[spg_sql::ast::FunctionArg]) -> alloc::string::String {
18852    use core::fmt::Write;
18853    let mut out = alloc::string::String::from("(");
18854    for (i, a) in args.iter().enumerate() {
18855        if i > 0 {
18856            out.push_str(", ");
18857        }
18858        match a.mode {
18859            spg_sql::ast::FunctionArgMode::In => {}
18860            spg_sql::ast::FunctionArgMode::Out => out.push_str("OUT "),
18861            spg_sql::ast::FunctionArgMode::InOut => out.push_str("INOUT "),
18862        }
18863        if let Some(n) = &a.name {
18864            out.push_str(n);
18865            out.push(' ');
18866        }
18867        match &a.ty {
18868            spg_sql::ast::FunctionArgType::Typed(t) => {
18869                let _ = write!(out, "{t}");
18870            }
18871            spg_sql::ast::FunctionArgType::Raw(s) => out.push_str(s),
18872        }
18873    }
18874    out.push(')');
18875    out
18876}
18877
18878/// v7.19 P5 — true iff `expr` is `unnest(arg)` at the top level
18879/// (case-insensitive). Used by `exec_select_cancel`'s
18880/// projection loop to detect Set-Returning-Function rows that
18881/// need per-row expansion. Only the top-level call counts —
18882/// `coalesce(unnest(arr), 'x')` is NOT a SRF row from the
18883/// projection's perspective; it would surface as an "unknown
18884/// function" mismatch downstream, which is what we want
18885/// (multi-SRF / nested SRF is documented carve-out for v7.19).
18886fn is_top_level_unnest(expr: &spg_sql::ast::Expr) -> bool {
18887    match expr {
18888        spg_sql::ast::Expr::FunctionCall { name, args } => {
18889            name.eq_ignore_ascii_case("unnest") && args.len() == 1
18890        }
18891        _ => false,
18892    }
18893}
18894
18895/// v7.19 P5 — extract the array argument out of a top-level
18896/// `unnest(arg)` call. `None` if `expr` isn't a `unnest` call
18897/// of arity 1 (mirrors `is_top_level_unnest`).
18898fn top_level_unnest_arg(expr: &spg_sql::ast::Expr) -> Option<&spg_sql::ast::Expr> {
18899    match expr {
18900        spg_sql::ast::Expr::FunctionCall { name, args }
18901            if name.eq_ignore_ascii_case("unnest") && args.len() == 1 =>
18902        {
18903            Some(&args[0])
18904        }
18905        _ => None,
18906    }
18907}
18908
18909/// v7.19 P5 — turn an array-typed `Value` into the element list
18910/// `unnest()` projection emits. NULL → empty list (PG: `unnest(NULL)
18911/// = (no rows)`). Non-array values fall through to a type-mismatch
18912/// error.
18913fn array_value_to_elements(v: &Value) -> Result<Vec<Value>, EngineError> {
18914    match v {
18915        Value::Null => Ok(Vec::new()),
18916        Value::TextArray(items) => Ok(items
18917            .iter()
18918            .map(|opt| {
18919                opt.as_ref()
18920                    .map(|s| Value::Text(s.clone()))
18921                    .unwrap_or(Value::Null)
18922            })
18923            .collect()),
18924        Value::IntArray(items) => Ok(items
18925            .iter()
18926            .map(|opt| opt.map(Value::Int).unwrap_or(Value::Null))
18927            .collect()),
18928        Value::BigIntArray(items) => Ok(items
18929            .iter()
18930            .map(|opt| opt.map(Value::BigInt).unwrap_or(Value::Null))
18931            .collect()),
18932        other => Err(EngineError::Eval(EvalError::TypeMismatch {
18933            detail: alloc::format!(
18934                "unnest() expects an array argument, got {:?}",
18935                other.data_type()
18936            ),
18937        })),
18938    }
18939}
18940
18941#[cfg(test)]
18942mod tests {
18943    use super::*;
18944    use alloc::vec;
18945
18946    fn unwrap_command_ok(r: &QueryResult) -> usize {
18947        match r {
18948            QueryResult::CommandOk { affected, .. } => *affected,
18949            QueryResult::Rows { .. } => panic!("expected CommandOk, got Rows"),
18950        }
18951    }
18952
18953    #[test]
18954    fn update_seek_positions_engages_on_indexed_eq() {
18955        let mut e = Engine::new();
18956        e.execute("CREATE TABLE b (id INT NOT NULL, v INT NOT NULL)")
18957            .unwrap();
18958        e.execute("CREATE INDEX b_id ON b (id)").unwrap();
18959        for i in 0..100 {
18960            e.execute(&alloc::format!("INSERT INTO b VALUES ({i}, {i})"))
18961                .unwrap();
18962        }
18963        let stmt = spg_sql::parser::parse_statement("UPDATE b SET v = v + 1 WHERE id = 42")
18964            .expect("parse");
18965        let Statement::Update(u) = stmt else {
18966            panic!("expected Update, got {stmt:?}");
18967        };
18968        let w = u.where_.as_ref().expect("where");
18969        let table = e.catalog().get("b").unwrap();
18970        let schema_cols = table.schema().columns.clone();
18971        // step-by-step: each sub-resolution must succeed.
18972        let Expr::Binary { lhs, op, rhs } = w else {
18973            panic!("WHERE not Binary: {w:?}");
18974        };
18975        assert_eq!(*op, BinOp::Eq, "op not Eq");
18976        let pair = resolve_col_literal_pair(lhs, rhs, &schema_cols, "b");
18977        assert!(
18978            pair.is_some(),
18979            "resolve_col_literal_pair None: lhs={lhs:?} rhs={rhs:?}"
18980        );
18981        let (col_pos, value) = pair.unwrap();
18982        assert!(
18983            table.index_on(col_pos).is_some(),
18984            "no index on col {col_pos}"
18985        );
18986        assert!(
18987            IndexKey::from_value(&value).is_some(),
18988            "IndexKey::from_value None for {value:?}"
18989        );
18990        let positions = try_index_seek_positions(w, &schema_cols, table, "b");
18991        assert_eq!(positions, Some(vec![42]), "seek did not engage");
18992    }
18993
18994    #[test]
18995    fn create_table_registers_schema() {
18996        let mut e = Engine::new();
18997        e.execute("CREATE TABLE foo (a INT NOT NULL, b TEXT)")
18998            .unwrap();
18999        assert_eq!(e.catalog().table_count(), 1);
19000        let t = e.catalog().get("foo").unwrap();
19001        assert_eq!(t.schema().columns.len(), 2);
19002        assert_eq!(t.schema().columns[0].ty, DataType::Int);
19003        assert!(!t.schema().columns[0].nullable);
19004        assert_eq!(t.schema().columns[1].ty, DataType::Text);
19005    }
19006
19007    #[test]
19008    fn create_table_vector_default_is_f32_encoded() {
19009        let mut e = Engine::new();
19010        e.execute("CREATE TABLE t (v VECTOR(8))").unwrap();
19011        let t = e.catalog().get("t").unwrap();
19012        assert_eq!(
19013            t.schema().columns[0].ty,
19014            DataType::Vector {
19015                dim: 8,
19016                encoding: VecEncoding::F32,
19017            },
19018        );
19019    }
19020
19021    #[test]
19022    fn create_table_vector_using_sq8_succeeds() {
19023        // v6.0.1 step 3: the step-1 fence in `column_def_to_schema`
19024        // is lifted. CREATE TABLE persists an SQ8 column type in
19025        // the catalog; INSERT (next test) quantises raw f32 input.
19026        let mut e = Engine::new();
19027        e.execute("CREATE TABLE t (v VECTOR(8) USING SQ8)").unwrap();
19028        let t = e.catalog().get("t").unwrap();
19029        assert_eq!(
19030            t.schema().columns[0].ty,
19031            DataType::Vector {
19032                dim: 8,
19033                encoding: VecEncoding::Sq8,
19034            },
19035        );
19036    }
19037
19038    #[test]
19039    fn insert_into_sq8_column_quantises_f32_payload() {
19040        // v6.0.1 step 3: INSERT-time `coerce_value` rewrites a raw
19041        // `Value::Vector(Vec<f32>)` literal into the column's
19042        // quantised representation. The row that lands in the
19043        // catalog must therefore hold a `Value::Sq8Vector`, not the
19044        // original f32 buffer — that's the bit that delivers the
19045        // 4× compression target.
19046        let mut e = Engine::new();
19047        e.execute("CREATE TABLE t (v VECTOR(4) USING SQ8)").unwrap();
19048        e.execute("INSERT INTO t VALUES ([0.0, 0.25, 0.5, 1.0])")
19049            .unwrap();
19050        let t = e.catalog().get("t").unwrap();
19051        assert_eq!(t.rows().len(), 1);
19052        match &t.rows()[0].values[0] {
19053            Value::Sq8Vector(q) => {
19054                assert_eq!(q.bytes.len(), 4);
19055                // min/max are derived from the payload: min=0.0, max=1.0.
19056                assert!((q.min - 0.0).abs() < 1e-6);
19057                assert!((q.max - 1.0).abs() < 1e-6);
19058            }
19059            other => panic!("expected Sq8Vector cell, got {other:?}"),
19060        }
19061    }
19062
19063    #[test]
19064    fn create_table_vector_using_half_succeeds_and_insert_converts_to_f16() {
19065        // v6.0.3: CREATE TABLE accepts USING HALF; INSERT path
19066        // converts the incoming `Value::Vector(Vec<f32>)` cell
19067        // into `Value::HalfVector(HalfVector)` via the new
19068        // `coerce_value` arm. The dequantised round-trip is
19069        // bit-exact for f16-representable values, so 0.0 / 0.25
19070        // / 0.5 / 1.0 hit their grid points exactly.
19071        let mut e = Engine::new();
19072        e.execute("CREATE TABLE t (v VECTOR(4) USING HALF)")
19073            .unwrap();
19074        e.execute("INSERT INTO t VALUES ([0.0, 0.25, 0.5, 1.0])")
19075            .unwrap();
19076        let t = e.catalog().get("t").unwrap();
19077        assert_eq!(t.rows().len(), 1);
19078        match &t.rows()[0].values[0] {
19079            Value::HalfVector(h) => {
19080                assert_eq!(h.dim(), 4);
19081                let back = h.to_f32_vec();
19082                let expected = alloc::vec![0.0_f32, 0.25, 0.5, 1.0];
19083                for (g, e) in back.iter().zip(expected.iter()) {
19084                    assert!(
19085                        (g - e).abs() < 1e-6,
19086                        "{g} vs {e} should be exact on f16 grid"
19087                    );
19088                }
19089            }
19090            other => panic!("expected HalfVector cell, got {other:?}"),
19091        }
19092    }
19093
19094    #[test]
19095    fn alter_index_rebuild_in_place_succeeds() {
19096        // v6.0.4: bare REBUILD (no encoding switch) walks every
19097        // row again to rebuild the NSW graph. Verifies the engine
19098        // dispatch + storage helper plumbing without changing any
19099        // cell encoding.
19100        let mut e = Engine::new();
19101        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(3) NOT NULL)")
19102            .unwrap();
19103        for i in 0..8_i32 {
19104            #[allow(clippy::cast_precision_loss)]
19105            let base = (i as f32) * 0.1;
19106            e.execute(&alloc::format!(
19107                "INSERT INTO t VALUES ({i}, [{base}, {b1}, {b2}])",
19108                b1 = base + 0.01,
19109                b2 = base + 0.02,
19110            ))
19111            .unwrap();
19112        }
19113        e.execute("CREATE INDEX t_idx ON t USING hnsw (v)").unwrap();
19114        e.execute("ALTER INDEX t_idx REBUILD").unwrap();
19115        // Schema encoding stays F32 (no encoding clause).
19116        assert_eq!(
19117            e.catalog().get("t").unwrap().schema().columns[1].ty,
19118            DataType::Vector {
19119                dim: 3,
19120                encoding: VecEncoding::F32,
19121            },
19122        );
19123    }
19124
19125    #[test]
19126    fn alter_index_rebuild_with_encoding_switches_cell_type() {
19127        // v6.0.4: REBUILD WITH (encoding = SQ8) recodes every
19128        // stored cell from F32 → SQ8 + rebuilds the graph atop the
19129        // new encoding. Post-rebuild, cells must be Sq8Vector and
19130        // the schema must report encoding = Sq8.
19131        let mut e = Engine::new();
19132        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(4) NOT NULL)")
19133            .unwrap();
19134        e.execute("INSERT INTO t VALUES (1, [0.0, 0.25, 0.5, 1.0])")
19135            .unwrap();
19136        e.execute("CREATE INDEX t_idx ON t USING hnsw (v)").unwrap();
19137        e.execute("ALTER INDEX t_idx REBUILD WITH (encoding = SQ8)")
19138            .unwrap();
19139        let t = e.catalog().get("t").unwrap();
19140        assert_eq!(
19141            t.schema().columns[1].ty,
19142            DataType::Vector {
19143                dim: 4,
19144                encoding: VecEncoding::Sq8,
19145            },
19146        );
19147        assert!(matches!(t.rows()[0].values[1], Value::Sq8Vector(_)));
19148    }
19149
19150    #[test]
19151    fn alter_index_rebuild_unknown_index_errors() {
19152        let mut e = Engine::new();
19153        let err = e.execute("ALTER INDEX nope REBUILD").unwrap_err();
19154        assert!(
19155            matches!(
19156                &err,
19157                EngineError::Storage(StorageError::IndexNotFound { name }) if name == "nope"
19158            ),
19159            "got: {err}"
19160        );
19161    }
19162
19163    #[test]
19164    fn alter_index_rebuild_on_btree_index_errors() {
19165        // REBUILD on a B-tree index has no semantic meaning in
19166        // v6.0.4 — rejected at the storage layer with `Unsupported`.
19167        let mut e = Engine::new();
19168        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19169        e.execute("INSERT INTO t VALUES (1)").unwrap();
19170        e.execute("CREATE INDEX t_idx ON t (id)").unwrap();
19171        let err = e.execute("ALTER INDEX t_idx REBUILD").unwrap_err();
19172        assert!(
19173            matches!(&err, EngineError::Storage(StorageError::Unsupported(_))),
19174            "got: {err}"
19175        );
19176    }
19177
19178    #[test]
19179    fn prepared_insert_substitutes_placeholders() {
19180        // v6.1.1: prepare() parses once; execute_prepared() walks the
19181        // AST and replaces $1/$2 with the param Values BEFORE the
19182        // dispatch sees them. Same logical result as a simple-query
19183        // INSERT, but parse happens once per *statement*, not per
19184        // execution.
19185        let mut e = Engine::new();
19186        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)")
19187            .unwrap();
19188        let stmt = e.prepare("INSERT INTO t VALUES ($1, $2)").unwrap();
19189        for (id, name) in [(1, "alice"), (2, "bob"), (3, "carol")] {
19190            e.execute_prepared(stmt.clone(), &[Value::Int(id), Value::Text(name.into())])
19191                .unwrap();
19192        }
19193        // Read back via simple-query SELECT.
19194        let rows_result = e.execute("SELECT id, name FROM t").unwrap();
19195        let QueryResult::Rows { rows, .. } = rows_result else {
19196            panic!("expected Rows")
19197        };
19198        assert_eq!(rows.len(), 3);
19199    }
19200
19201    #[test]
19202    fn prepared_select_with_placeholder_filters_rows() {
19203        let mut e = Engine::new();
19204        e.execute("CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)")
19205            .unwrap();
19206        for i in 0..10_i32 {
19207            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, {})", i * 7))
19208                .unwrap();
19209        }
19210        let stmt = e.prepare("SELECT id FROM t WHERE v = $1").unwrap();
19211        let QueryResult::Rows { rows, .. } = e.execute_prepared(stmt, &[Value::Int(35)]).unwrap()
19212        else {
19213            panic!("expected Rows")
19214        };
19215        // v = 35 means i*7 = 35 → i = 5.
19216        assert_eq!(rows.len(), 1);
19217        assert_eq!(rows[0].values[0], Value::Int(5));
19218    }
19219
19220    #[test]
19221    fn prepared_too_few_params_errors() {
19222        let mut e = Engine::new();
19223        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19224        let stmt = e.prepare("INSERT INTO t VALUES ($1)").unwrap();
19225        let err = e.execute_prepared(stmt, &[]).unwrap_err();
19226        assert!(
19227            matches!(
19228                &err,
19229                EngineError::Eval(EvalError::PlaceholderOutOfRange { n: 1, bound: 0 })
19230            ),
19231            "got: {err}"
19232        );
19233    }
19234
19235    #[test]
19236    fn bytea_cast_round_trips_text_input() {
19237        // v7.18 — `'hello'::bytea` produces the raw bytes. Closes
19238        // the mailrs D-pre #3 reverse-acceptance gap.
19239        let e = Engine::new();
19240        let r = e.execute_readonly("SELECT 'hello'::bytea").unwrap();
19241        let QueryResult::Rows { rows, .. } = r else {
19242            panic!("expected Rows")
19243        };
19244        assert_eq!(rows.len(), 1);
19245        assert_eq!(rows[0].values[0], Value::Bytes(b"hello".to_vec()));
19246    }
19247
19248    #[test]
19249    fn bytea_cast_pg_escape_hex_form() {
19250        // E'\\xdeadbeef'::bytea — E-string decodes to `\xdeadbeef`
19251        // (literal 10 chars), then ::bytea reads it as PG hex
19252        // form bytea literal → 4 bytes.
19253        let e = Engine::new();
19254        let r = e.execute_readonly(r"SELECT E'\\xdeadbeef'::bytea").unwrap();
19255        let QueryResult::Rows { rows, .. } = r else {
19256            panic!("expected Rows")
19257        };
19258        assert_eq!(
19259            rows[0].values[0],
19260            Value::Bytes(vec![0xde, 0xad, 0xbe, 0xef])
19261        );
19262    }
19263
19264    #[test]
19265    fn bytea_cast_chains_through_octet_length() {
19266        // octet_length('hello'::bytea) → 5. Confirms the cast
19267        // composes inside larger expressions, not just at top
19268        // level.
19269        let e = Engine::new();
19270        let r = e
19271            .execute_readonly("SELECT octet_length('hello'::bytea)")
19272            .unwrap();
19273        let QueryResult::Rows { rows, .. } = r else {
19274            panic!("expected Rows")
19275        };
19276        match &rows[0].values[0] {
19277            Value::Int(n) => assert_eq!(*n, 5),
19278            Value::BigInt(n) => assert_eq!(*n, 5),
19279            other => panic!("expected integer length, got {other:?}"),
19280        }
19281    }
19282
19283    #[test]
19284    fn readonly_prepared_on_snapshot_select_with_placeholder() {
19285        // v7.18 — sqlx Pool fan-out relies on running prepared
19286        // SELECTs against a frozen snapshot without re-entering
19287        // the writer engine. Mirrors the simple-query SELECT path
19288        // in `execute_readonly_on_snapshot` but takes a Statement
19289        // + bound params (the shape sqlx's Execute path produces).
19290        let mut e = Engine::new();
19291        e.execute("CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)")
19292            .unwrap();
19293        for i in 0..10_i32 {
19294            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, {})", i * 7))
19295                .unwrap();
19296        }
19297        let snapshot = e.clone_snapshot();
19298        let stmt = e.prepare("SELECT id FROM t WHERE v = $1").unwrap();
19299        let QueryResult::Rows { rows, .. } =
19300            Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(35)])
19301                .unwrap()
19302        else {
19303            panic!("expected Rows")
19304        };
19305        assert_eq!(rows.len(), 1);
19306        assert_eq!(rows[0].values[0], Value::Int(5));
19307    }
19308
19309    #[test]
19310    fn readonly_prepared_on_snapshot_rejects_writes() {
19311        // DDL / DML prepared statements on the readonly path must
19312        // surface `WriteRequired` so the spg-sqlx connection layer
19313        // routes them to the writer mutex instead of the snapshot.
19314        let mut e = Engine::new();
19315        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19316        let snapshot = e.clone_snapshot();
19317        let stmt = e.prepare("INSERT INTO t VALUES ($1)").unwrap();
19318        let err = Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(1)])
19319            .unwrap_err();
19320        assert!(matches!(&err, EngineError::WriteRequired), "got: {err}");
19321    }
19322
19323    #[test]
19324    fn readonly_prepared_on_snapshot_frozen_view() {
19325        // The snapshot reflects engine state at clone_snapshot()
19326        // time. Writes after the snapshot are NOT visible — caller
19327        // takes a fresh snapshot (or `AsyncReadHandle::refresh()`)
19328        // to see them. This is the contract the per-statement
19329        // refresh in spg-sqlx relies on.
19330        let mut e = Engine::new();
19331        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19332        e.execute("INSERT INTO t VALUES (1)").unwrap();
19333        let snapshot = e.clone_snapshot();
19334        e.execute("INSERT INTO t VALUES (2)").unwrap();
19335        let stmt = e.prepare("SELECT id FROM t WHERE id = $1").unwrap();
19336        let QueryResult::Rows { rows, .. } =
19337            Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(2)])
19338                .unwrap()
19339        else {
19340            panic!("expected Rows")
19341        };
19342        assert!(rows.is_empty(), "id=2 was inserted after snapshot");
19343    }
19344
19345    #[test]
19346    fn describe_prepared_on_snapshot_resolves_columns() {
19347        // v7.18 — sqlx's Executor::describe path on the readonly
19348        // fan-out needs to resolve column names + types against
19349        // the snapshot's catalog (not the live engine's catalog,
19350        // which may have moved on).
19351        let mut e = Engine::new();
19352        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)")
19353            .unwrap();
19354        let snapshot = e.clone_snapshot();
19355        let stmt = e.prepare("SELECT id, name FROM t WHERE id = $1").unwrap();
19356        let (_params, cols) = Engine::describe_prepared_on_snapshot(&snapshot, &stmt);
19357        assert_eq!(cols.len(), 2);
19358        assert_eq!(cols[0].name, "id");
19359        assert_eq!(cols[0].ty, DataType::Int);
19360        assert_eq!(cols[1].name, "name");
19361        assert_eq!(cols[1].ty, DataType::Text);
19362    }
19363
19364    #[test]
19365    fn insert_into_half_column_dim_mismatch_errors() {
19366        let mut e = Engine::new();
19367        e.execute("CREATE TABLE t (v VECTOR(4) USING HALF)")
19368            .unwrap();
19369        let err = e.execute("INSERT INTO t VALUES ([1.0, 2.0])").unwrap_err();
19370        assert!(matches!(
19371            &err,
19372            EngineError::Storage(StorageError::TypeMismatch { .. })
19373        ));
19374    }
19375
19376    #[test]
19377    fn insert_into_sq8_column_dim_mismatch_errors() {
19378        // Dim mismatch falls through the `coerce_value` Vector→Sq8
19379        // arm's guard and surfaces as `TypeMismatch` — the same
19380        // error the F32 path produces today, so client error
19381        // handling stays uniform across encodings.
19382        let mut e = Engine::new();
19383        e.execute("CREATE TABLE t (v VECTOR(4) USING SQ8)").unwrap();
19384        let err = e.execute("INSERT INTO t VALUES ([1.0, 2.0])").unwrap_err();
19385        assert!(
19386            matches!(
19387                &err,
19388                EngineError::Storage(StorageError::TypeMismatch { .. })
19389            ),
19390            "got: {err}",
19391        );
19392    }
19393
19394    #[test]
19395    fn create_table_duplicate_errors() {
19396        let mut e = Engine::new();
19397        e.execute("CREATE TABLE foo (a INT)").unwrap();
19398        let err = e.execute("CREATE TABLE foo (a INT)").unwrap_err();
19399        assert!(matches!(
19400            err,
19401            EngineError::Storage(StorageError::DuplicateTable { ref name }) if name == "foo"
19402        ));
19403    }
19404
19405    #[test]
19406    fn insert_into_unknown_table_errors() {
19407        let mut e = Engine::new();
19408        let err = e.execute("INSERT INTO ghost VALUES (1)").unwrap_err();
19409        assert!(matches!(
19410            err,
19411            EngineError::Storage(StorageError::TableNotFound { ref name }) if name == "ghost"
19412        ));
19413    }
19414
19415    #[test]
19416    fn insert_happy_path_reports_one_affected() {
19417        let mut e = Engine::new();
19418        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
19419        let r = e.execute("INSERT INTO foo VALUES (42)").unwrap();
19420        assert_eq!(unwrap_command_ok(&r), 1);
19421        assert_eq!(e.catalog().get("foo").unwrap().row_count(), 1);
19422    }
19423
19424    #[test]
19425    fn insert_arity_mismatch_propagates() {
19426        let mut e = Engine::new();
19427        e.execute("CREATE TABLE foo (a INT, b TEXT)").unwrap();
19428        let err = e.execute("INSERT INTO foo VALUES (1)").unwrap_err();
19429        assert!(matches!(
19430            err,
19431            EngineError::Storage(StorageError::ArityMismatch { .. })
19432        ));
19433    }
19434
19435    #[test]
19436    fn insert_negative_integer_via_unary_minus() {
19437        let mut e = Engine::new();
19438        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
19439        e.execute("INSERT INTO foo VALUES (-7)").unwrap();
19440        let rows = e.catalog().get("foo").unwrap().rows();
19441        assert_eq!(rows[0].values[0], Value::Int(-7));
19442    }
19443
19444    #[test]
19445    fn insert_expression_evaluated_against_empty_context() {
19446        // PG-canonical: INSERT VALUES accepts an arbitrary scalar
19447        // expression. The engine evaluates against an empty row
19448        // context — column references would error, but pure
19449        // arithmetic / function calls are fine.
19450        let mut e = Engine::new();
19451        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
19452        e.execute("INSERT INTO foo VALUES (1 + 2)").unwrap();
19453        let rows = e.catalog().get("foo").unwrap().rows();
19454        assert_eq!(rows[0].values[0], Value::Int(3));
19455    }
19456
19457    #[test]
19458    fn select_star_returns_all_rows_in_insertion_order() {
19459        let mut e = Engine::new();
19460        e.execute("CREATE TABLE foo (a INT NOT NULL, b TEXT NOT NULL)")
19461            .unwrap();
19462        e.execute("INSERT INTO foo VALUES (1, 'one')").unwrap();
19463        e.execute("INSERT INTO foo VALUES (2, 'two')").unwrap();
19464        e.execute("INSERT INTO foo VALUES (3, 'three')").unwrap();
19465
19466        let r = e.execute("SELECT * FROM foo").unwrap();
19467        let QueryResult::Rows { columns, rows } = r else {
19468            panic!("expected Rows")
19469        };
19470        assert_eq!(columns.len(), 2);
19471        assert_eq!(columns[0].name, "a");
19472        assert_eq!(rows.len(), 3);
19473        assert_eq!(
19474            rows[1].values,
19475            vec![Value::Int(2), Value::Text("two".into())]
19476        );
19477    }
19478
19479    #[test]
19480    fn select_star_on_empty_table_returns_zero_rows() {
19481        let mut e = Engine::new();
19482        e.execute("CREATE TABLE foo (a INT)").unwrap();
19483        let r = e.execute("SELECT * FROM foo").unwrap();
19484        match r {
19485            QueryResult::Rows { rows, .. } => assert!(rows.is_empty()),
19486            QueryResult::CommandOk { .. } => panic!("expected Rows"),
19487        }
19488    }
19489
19490    // --- v0.4: WHERE + projection ------------------------------------------
19491
19492    fn make_three_row_users(e: &mut Engine) {
19493        e.execute("CREATE TABLE users (id INT NOT NULL, name TEXT NOT NULL, score INT)")
19494            .unwrap();
19495        e.execute("INSERT INTO users VALUES (1, 'alice', 90)")
19496            .unwrap();
19497        e.execute("INSERT INTO users VALUES (2, 'bob', NULL)")
19498            .unwrap();
19499        e.execute("INSERT INTO users VALUES (3, 'cara', 70)")
19500            .unwrap();
19501    }
19502
19503    fn unwrap_rows(r: QueryResult) -> (Vec<ColumnSchema>, Vec<Row>) {
19504        match r {
19505            QueryResult::Rows { columns, rows } => (columns, rows),
19506            QueryResult::CommandOk { .. } => panic!("expected Rows"),
19507        }
19508    }
19509
19510    #[test]
19511    fn where_filter_passes_only_true_rows() {
19512        let mut e = Engine::new();
19513        make_three_row_users(&mut e);
19514        let r = e.execute("SELECT * FROM users WHERE id > 1").unwrap();
19515        let (_, rows) = unwrap_rows(r);
19516        assert_eq!(rows.len(), 2);
19517        assert_eq!(rows[0].values[0], Value::Int(2));
19518        assert_eq!(rows[1].values[0], Value::Int(3));
19519    }
19520
19521    #[test]
19522    fn where_with_null_result_filters_out_row() {
19523        let mut e = Engine::new();
19524        make_three_row_users(&mut e);
19525        // score is NULL for bob → score > 80 is NULL → row excluded
19526        let r = e.execute("SELECT * FROM users WHERE score > 80").unwrap();
19527        let (_, rows) = unwrap_rows(r);
19528        assert_eq!(rows.len(), 1);
19529        assert_eq!(rows[0].values[1], Value::Text("alice".into()));
19530    }
19531
19532    #[test]
19533    fn projection_named_columns() {
19534        let mut e = Engine::new();
19535        make_three_row_users(&mut e);
19536        let r = e.execute("SELECT name, score FROM users").unwrap();
19537        let (cols, rows) = unwrap_rows(r);
19538        assert_eq!(cols.len(), 2);
19539        assert_eq!(cols[0].name, "name");
19540        assert_eq!(cols[1].name, "score");
19541        assert_eq!(rows.len(), 3);
19542        assert_eq!(
19543            rows[0].values,
19544            vec![Value::Text("alice".into()), Value::Int(90)]
19545        );
19546    }
19547
19548    #[test]
19549    fn projection_with_column_alias() {
19550        let mut e = Engine::new();
19551        make_three_row_users(&mut e);
19552        let r = e
19553            .execute("SELECT name AS who FROM users WHERE id = 1")
19554            .unwrap();
19555        let (cols, rows) = unwrap_rows(r);
19556        assert_eq!(cols[0].name, "who");
19557        assert_eq!(rows.len(), 1);
19558        assert_eq!(rows[0].values[0], Value::Text("alice".into()));
19559    }
19560
19561    #[test]
19562    fn qualified_column_with_table_alias_resolves() {
19563        let mut e = Engine::new();
19564        make_three_row_users(&mut e);
19565        let r = e
19566            .execute("SELECT u.id, u.name FROM users AS u WHERE u.id < 3")
19567            .unwrap();
19568        let (cols, rows) = unwrap_rows(r);
19569        assert_eq!(cols.len(), 2);
19570        assert_eq!(rows.len(), 2);
19571    }
19572
19573    #[test]
19574    fn qualified_column_with_wrong_alias_errors() {
19575        let mut e = Engine::new();
19576        make_three_row_users(&mut e);
19577        let err = e.execute("SELECT x.id FROM users AS u").unwrap_err();
19578        assert!(matches!(
19579            err,
19580            EngineError::Eval(EvalError::UnknownQualifier { ref qualifier }) if qualifier == "x"
19581        ));
19582    }
19583
19584    #[test]
19585    fn select_unknown_column_errors_in_projection() {
19586        let mut e = Engine::new();
19587        make_three_row_users(&mut e);
19588        let err = e.execute("SELECT ghost FROM users").unwrap_err();
19589        assert!(matches!(
19590            err,
19591            EngineError::Eval(EvalError::ColumnNotFound { ref name }) if name == "ghost"
19592        ));
19593    }
19594
19595    #[test]
19596    fn where_unknown_column_errors() {
19597        let mut e = Engine::new();
19598        make_three_row_users(&mut e);
19599        let err = e
19600            .execute("SELECT * FROM users WHERE ghost = 1")
19601            .unwrap_err();
19602        assert!(matches!(
19603            err,
19604            EngineError::Eval(EvalError::ColumnNotFound { .. })
19605        ));
19606    }
19607
19608    #[test]
19609    fn expression_projection_evaluates_and_renders() {
19610        // Compound expressions in the SELECT list are evaluated per row;
19611        // the output column is typed TEXT, name defaults to the expression.
19612        let mut e = Engine::new();
19613        e.execute("CREATE TABLE t (a INT NOT NULL)").unwrap();
19614        e.execute("INSERT INTO t VALUES (3)").unwrap();
19615        let (_, rows) = unwrap_rows(e.execute("SELECT 1 + 2 FROM t").unwrap());
19616        assert_eq!(rows.len(), 1);
19617        // The expression evaluates to integer 3; rendered as the cell value
19618        // (storage::Value::Int(3) since arithmetic kept ints).
19619        assert_eq!(rows[0].values[0], Value::Int(3));
19620    }
19621
19622    #[test]
19623    fn select_unknown_table_errors() {
19624        let mut e = Engine::new();
19625        let err = e.execute("SELECT * FROM ghost").unwrap_err();
19626        assert!(matches!(
19627            err,
19628            EngineError::Storage(StorageError::TableNotFound { .. })
19629        ));
19630    }
19631
19632    #[test]
19633    fn invalid_sql_returns_parse_error() {
19634        // v4.4: UPDATE is now real SQL, so use a true syntactic
19635        // garbage payload for the parse-error path.
19636        let mut e = Engine::new();
19637        let err = e.execute("THIS_IS_NOT_A_KEYWORD foo bar baz").unwrap_err();
19638        assert!(matches!(err, EngineError::Parse(_)));
19639    }
19640
19641    // --- v0.8 CREATE INDEX + index seek ------------------------------------
19642
19643    #[test]
19644    fn create_index_registers_on_table() {
19645        let mut e = Engine::new();
19646        make_three_row_users(&mut e);
19647        e.execute("CREATE INDEX by_name ON users (name)").unwrap();
19648        let t = e.catalog().get("users").unwrap();
19649        assert_eq!(t.indices().len(), 1);
19650        assert_eq!(t.indices()[0].name, "by_name");
19651    }
19652
19653    #[test]
19654    fn create_index_on_unknown_table_errors() {
19655        let mut e = Engine::new();
19656        let err = e.execute("CREATE INDEX i ON ghost (a)").unwrap_err();
19657        assert!(matches!(
19658            err,
19659            EngineError::Storage(StorageError::TableNotFound { .. })
19660        ));
19661    }
19662
19663    #[test]
19664    fn create_index_on_unknown_column_errors() {
19665        let mut e = Engine::new();
19666        make_three_row_users(&mut e);
19667        let err = e.execute("CREATE INDEX i ON users (ghost)").unwrap_err();
19668        assert!(matches!(
19669            err,
19670            EngineError::Storage(StorageError::ColumnNotFound { .. })
19671        ));
19672    }
19673
19674    #[test]
19675    fn select_eq_uses_index_returns_same_rows_as_scan() {
19676        // Build two engines: one with an index, one without. Same query →
19677        // same row set (index is a planner optimisation, not a semantic
19678        // change).
19679        let mut without = Engine::new();
19680        make_three_row_users(&mut without);
19681        let mut with = Engine::new();
19682        make_three_row_users(&mut with);
19683        with.execute("CREATE INDEX by_id ON users (id)").unwrap();
19684
19685        let q = "SELECT * FROM users WHERE id = 2";
19686        let (_, no_idx_rows) = unwrap_rows(without.execute(q).unwrap());
19687        let (_, idx_rows) = unwrap_rows(with.execute(q).unwrap());
19688        assert_eq!(no_idx_rows, idx_rows);
19689        assert_eq!(idx_rows.len(), 1);
19690    }
19691
19692    #[test]
19693    fn select_eq_with_no_matching_index_value_returns_empty() {
19694        let mut e = Engine::new();
19695        make_three_row_users(&mut e);
19696        e.execute("CREATE INDEX by_id ON users (id)").unwrap();
19697        let (_, rows) = unwrap_rows(e.execute("SELECT * FROM users WHERE id = 999").unwrap());
19698        assert_eq!(rows.len(), 0);
19699    }
19700
19701    // --- v0.9 transactions -------------------------------------------------
19702
19703    #[test]
19704    fn begin_sets_in_transaction_flag() {
19705        let mut e = Engine::new();
19706        assert!(!e.in_transaction());
19707        e.execute("BEGIN").unwrap();
19708        assert!(e.in_transaction());
19709    }
19710
19711    #[test]
19712    fn double_begin_errors() {
19713        let mut e = Engine::new();
19714        e.execute("BEGIN").unwrap();
19715        let err = e.execute("BEGIN").unwrap_err();
19716        assert_eq!(err, EngineError::TransactionAlreadyOpen);
19717    }
19718
19719    #[test]
19720    fn commit_without_begin_errors() {
19721        let mut e = Engine::new();
19722        let err = e.execute("COMMIT").unwrap_err();
19723        assert_eq!(err, EngineError::NoActiveTransaction);
19724    }
19725
19726    #[test]
19727    fn rollback_without_begin_errors() {
19728        let mut e = Engine::new();
19729        let err = e.execute("ROLLBACK").unwrap_err();
19730        assert_eq!(err, EngineError::NoActiveTransaction);
19731    }
19732
19733    #[test]
19734    fn commit_applies_shadow_to_committed_catalog() {
19735        let mut e = Engine::new();
19736        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
19737        e.execute("BEGIN").unwrap();
19738        e.execute("INSERT INTO t VALUES (1)").unwrap();
19739        e.execute("INSERT INTO t VALUES (2)").unwrap();
19740        e.execute("COMMIT").unwrap();
19741        assert!(!e.in_transaction());
19742        assert_eq!(e.catalog().get("t").unwrap().row_count(), 2);
19743    }
19744
19745    #[test]
19746    fn rollback_discards_shadow() {
19747        let mut e = Engine::new();
19748        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
19749        e.execute("BEGIN").unwrap();
19750        e.execute("INSERT INTO t VALUES (1)").unwrap();
19751        e.execute("INSERT INTO t VALUES (2)").unwrap();
19752        e.execute("ROLLBACK").unwrap();
19753        assert!(!e.in_transaction());
19754        assert_eq!(e.catalog().get("t").unwrap().row_count(), 0);
19755    }
19756
19757    #[test]
19758    fn select_during_tx_sees_uncommitted_writes_own_session() {
19759        // The shadow catalog is read by SELECTs while a TX is open — the
19760        // session can see its own pending writes.
19761        let mut e = Engine::new();
19762        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
19763        e.execute("BEGIN").unwrap();
19764        e.execute("INSERT INTO t VALUES (42)").unwrap();
19765        let (_, rows) = unwrap_rows(e.execute("SELECT * FROM t").unwrap());
19766        assert_eq!(rows.len(), 1);
19767        assert_eq!(rows[0].values[0], Value::Int(42));
19768    }
19769
19770    #[test]
19771    fn snapshot_with_no_users_is_bare_catalog_format() {
19772        let mut e = Engine::new();
19773        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19774        let bytes = e.snapshot();
19775        assert_eq!(
19776            &bytes[..8],
19777            b"SPGDB001",
19778            "must be the bare v3.x catalog magic"
19779        );
19780        let e2 = Engine::restore_envelope(&bytes).unwrap();
19781        assert!(e2.users().is_empty());
19782        assert_eq!(e2.catalog().table_count(), 1);
19783    }
19784
19785    #[test]
19786    fn snapshot_with_users_round_trips_both_via_envelope() {
19787        let mut e = Engine::new();
19788        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19789        e.create_user("alice", "pw1", Role::Admin, [9; 16]).unwrap();
19790        e.create_user("bob", "pw2", Role::ReadOnly, [5; 16])
19791            .unwrap();
19792        let bytes = e.snapshot();
19793        assert_eq!(&bytes[..8], b"SPGENV01", "must be the v4.1 envelope magic");
19794        let e2 = Engine::restore_envelope(&bytes).unwrap();
19795        assert_eq!(e2.users().len(), 2);
19796        assert_eq!(e2.verify_user("alice", "pw1"), Some(Role::Admin));
19797        assert_eq!(e2.verify_user("bob", "pw2"), Some(Role::ReadOnly));
19798        assert_eq!(e2.verify_user("alice", "wrong"), None);
19799        assert_eq!(e2.catalog().table_count(), 1);
19800    }
19801
19802    #[test]
19803    fn ddl_inside_tx_also_rolled_back() {
19804        let mut e = Engine::new();
19805        e.execute("BEGIN").unwrap();
19806        e.execute("CREATE TABLE t (v INT)").unwrap();
19807        // Visible inside the TX.
19808        e.execute("SELECT * FROM t").unwrap();
19809        e.execute("ROLLBACK").unwrap();
19810        // Gone after rollback.
19811        let err = e.execute("SELECT * FROM t").unwrap_err();
19812        assert!(matches!(
19813            err,
19814            EngineError::Storage(StorageError::TableNotFound { .. })
19815        ));
19816    }
19817
19818    // ── v6.1.2: CREATE / DROP PUBLICATION (engine-side) ──────
19819
19820    #[test]
19821    fn create_publication_lands_in_catalog() {
19822        let mut e = Engine::new();
19823        assert!(e.publications().is_empty());
19824        e.execute("CREATE PUBLICATION pub_a").unwrap();
19825        assert_eq!(e.publications().len(), 1);
19826        assert!(e.publications().contains("pub_a"));
19827    }
19828
19829    #[test]
19830    fn create_publication_duplicate_errors() {
19831        let mut e = Engine::new();
19832        e.execute("CREATE PUBLICATION pub_a").unwrap();
19833        let err = e.execute("CREATE PUBLICATION pub_a").unwrap_err();
19834        assert!(
19835            alloc::format!("{err:?}").contains("DuplicateName"),
19836            "got {err:?}"
19837        );
19838    }
19839
19840    #[test]
19841    fn drop_publication_silent_when_absent() {
19842        let mut e = Engine::new();
19843        // PG-compatible: DROP a publication that doesn't exist
19844        // succeeds (no-op) but reports zero affected.
19845        let r = e.execute("DROP PUBLICATION nope").unwrap();
19846        match r {
19847            QueryResult::CommandOk { affected, .. } => assert_eq!(affected, 0),
19848            other => panic!("expected CommandOk, got {other:?}"),
19849        }
19850    }
19851
19852    #[test]
19853    fn drop_publication_present_reports_one_affected() {
19854        let mut e = Engine::new();
19855        e.execute("CREATE PUBLICATION pub_a").unwrap();
19856        let r = e.execute("DROP PUBLICATION pub_a").unwrap();
19857        match r {
19858            QueryResult::CommandOk {
19859                affected,
19860                modified_catalog,
19861            } => {
19862                assert_eq!(affected, 1);
19863                assert!(modified_catalog);
19864            }
19865            other => panic!("expected CommandOk, got {other:?}"),
19866        }
19867        assert!(e.publications().is_empty());
19868    }
19869
19870    #[test]
19871    fn publications_persist_across_snapshot_restore() {
19872        // The persist-across-restart ship-gate at the engine layer —
19873        // snapshot → restore_envelope round trip must preserve the
19874        // publication catalog. The spg-server e2e covers the
19875        // process-restart variant.
19876        let mut e = Engine::new();
19877        e.execute("CREATE PUBLICATION pub_a").unwrap();
19878        e.execute("CREATE PUBLICATION pub_b FOR ALL TABLES")
19879            .unwrap();
19880        let snap = e.snapshot();
19881        let e2 = Engine::restore_envelope(&snap).unwrap();
19882        assert_eq!(e2.publications().len(), 2);
19883        assert!(e2.publications().contains("pub_a"));
19884        assert!(e2.publications().contains("pub_b"));
19885    }
19886
19887    #[test]
19888    fn create_publication_allowed_inside_transaction() {
19889        // v6.1.4 dropped the v6.1.2 in-TX guard — PG allows
19890        // CREATE PUBLICATION inside a TX and the auto-commit
19891        // wrap path needs the same allowance.
19892        let mut e = Engine::new();
19893        e.execute("BEGIN").unwrap();
19894        e.execute("CREATE PUBLICATION pub_a").unwrap();
19895        e.execute("COMMIT").unwrap();
19896        assert!(e.publications().contains("pub_a"));
19897    }
19898
19899    // ── v6.1.3: SHOW PUBLICATIONS + FOR-list variants ───────
19900
19901    #[test]
19902    fn create_publication_for_table_list_lands_with_scope() {
19903        let mut e = Engine::new();
19904        e.execute("CREATE TABLE t1 (id INT NOT NULL)").unwrap();
19905        e.execute("CREATE TABLE t2 (id INT NOT NULL)").unwrap();
19906        e.execute("CREATE PUBLICATION pub_a FOR TABLE t1, t2")
19907            .unwrap();
19908        let scope = e.publications().get("pub_a").cloned();
19909        let Some(spg_sql::ast::PublicationScope::ForTables(ts)) = scope else {
19910            panic!("expected ForTables scope, got {scope:?}")
19911        };
19912        assert_eq!(ts, alloc::vec!["t1".to_string(), "t2".to_string()]);
19913    }
19914
19915    #[test]
19916    fn create_publication_all_tables_except_lands_with_scope() {
19917        let mut e = Engine::new();
19918        e.execute("CREATE PUBLICATION pub_a FOR ALL TABLES EXCEPT t3")
19919            .unwrap();
19920        let scope = e.publications().get("pub_a").cloned();
19921        let Some(spg_sql::ast::PublicationScope::AllTablesExcept(ts)) = scope else {
19922            panic!("expected AllTablesExcept scope, got {scope:?}")
19923        };
19924        assert_eq!(ts, alloc::vec!["t3".to_string()]);
19925    }
19926
19927    #[test]
19928    fn show_publications_empty_returns_zero_rows() {
19929        let e = Engine::new();
19930        let r = e.execute_readonly("SHOW PUBLICATIONS").unwrap();
19931        let QueryResult::Rows { rows, columns } = r else {
19932            panic!()
19933        };
19934        assert!(rows.is_empty());
19935        assert_eq!(columns.len(), 3);
19936        assert_eq!(columns[0].name, "name");
19937        assert_eq!(columns[1].name, "scope");
19938        assert_eq!(columns[2].name, "table_count");
19939    }
19940
19941    #[test]
19942    fn show_publications_returns_one_row_per_publication_ordered_by_name() {
19943        let mut e = Engine::new();
19944        e.execute("CREATE PUBLICATION z_pub").unwrap();
19945        e.execute("CREATE PUBLICATION a_pub FOR TABLE t1, t2")
19946            .unwrap();
19947        e.execute("CREATE PUBLICATION m_pub FOR ALL TABLES EXCEPT bad")
19948            .unwrap();
19949        let r = e.execute_readonly("SHOW PUBLICATIONS").unwrap();
19950        let QueryResult::Rows { rows, .. } = r else {
19951            panic!()
19952        };
19953        assert_eq!(rows.len(), 3);
19954        // Alphabetical order: a_pub, m_pub, z_pub.
19955        let names: Vec<&str> = rows
19956            .iter()
19957            .map(|r| {
19958                if let Value::Text(s) = &r.values[0] {
19959                    s.as_str()
19960                } else {
19961                    panic!()
19962                }
19963            })
19964            .collect();
19965        assert_eq!(names, alloc::vec!["a_pub", "m_pub", "z_pub"]);
19966        // Row 0 — a_pub scope summary + table_count = 2.
19967        match &rows[0].values[1] {
19968            Value::Text(s) => assert_eq!(s, "FOR TABLE t1, t2"),
19969            other => panic!("expected Text, got {other:?}"),
19970        }
19971        assert_eq!(rows[0].values[2], Value::Int(2));
19972        // Row 1 — m_pub.
19973        match &rows[1].values[1] {
19974            Value::Text(s) => assert_eq!(s, "FOR ALL TABLES EXCEPT bad"),
19975            other => panic!("expected Text, got {other:?}"),
19976        }
19977        assert_eq!(rows[1].values[2], Value::Int(1));
19978        // Row 2 — z_pub (AllTables → NULL count).
19979        match &rows[2].values[1] {
19980            Value::Text(s) => assert_eq!(s, "FOR ALL TABLES"),
19981            other => panic!("expected Text, got {other:?}"),
19982        }
19983        assert_eq!(rows[2].values[2], Value::Null);
19984    }
19985
19986    #[test]
19987    fn for_list_scopes_persist_across_snapshot() {
19988        // The v6.1.2 envelope-v3 round-trip exercised AllTables;
19989        // v6.1.3 needs the scope-1 / scope-2 tags to survive too.
19990        let mut e = Engine::new();
19991        e.execute("CREATE PUBLICATION p1 FOR TABLE t1, t2").unwrap();
19992        e.execute("CREATE PUBLICATION p2 FOR ALL TABLES EXCEPT bad, worse")
19993            .unwrap();
19994        let snap = e.snapshot();
19995        let e2 = Engine::restore_envelope(&snap).unwrap();
19996        assert_eq!(e2.publications().len(), 2);
19997        let p1 = e2.publications().get("p1").cloned();
19998        let Some(spg_sql::ast::PublicationScope::ForTables(ts)) = p1 else {
19999            panic!("p1 scope lost: {p1:?}")
20000        };
20001        assert_eq!(ts, alloc::vec!["t1".to_string(), "t2".to_string()]);
20002        let p2 = e2.publications().get("p2").cloned();
20003        let Some(spg_sql::ast::PublicationScope::AllTablesExcept(ts)) = p2 else {
20004            panic!("p2 scope lost: {p2:?}")
20005        };
20006        assert_eq!(ts, alloc::vec!["bad".to_string(), "worse".to_string()]);
20007    }
20008
20009    // ── v6.1.4: CREATE / DROP SUBSCRIPTION + SHOW + envelope v4 ─
20010
20011    #[test]
20012    fn create_subscription_lands_in_catalog_with_defaults() {
20013        let mut e = Engine::new();
20014        e.execute(
20015            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=127.0.0.1 port=20002' PUBLICATION pub_a",
20016        )
20017        .unwrap();
20018        let s = e.subscriptions().get("sub_a").cloned().expect("present");
20019        assert_eq!(s.conn_str, "host=127.0.0.1 port=20002");
20020        assert_eq!(s.publications, alloc::vec!["pub_a".to_string()]);
20021        assert!(s.enabled);
20022        assert_eq!(s.last_received_pos, 0);
20023    }
20024
20025    #[test]
20026    fn create_subscription_duplicate_name_errors() {
20027        let mut e = Engine::new();
20028        e.execute("CREATE SUBSCRIPTION s CONNECTION 'host=x' PUBLICATION p")
20029            .unwrap();
20030        let err = e
20031            .execute("CREATE SUBSCRIPTION s CONNECTION 'host=y' PUBLICATION p")
20032            .unwrap_err();
20033        assert!(
20034            alloc::format!("{err:?}").contains("DuplicateName"),
20035            "got {err:?}"
20036        );
20037    }
20038
20039    #[test]
20040    fn drop_subscription_silent_when_absent() {
20041        let mut e = Engine::new();
20042        let r = e.execute("DROP SUBSCRIPTION never").unwrap();
20043        match r {
20044            QueryResult::CommandOk { affected, .. } => assert_eq!(affected, 0),
20045            other => panic!("expected CommandOk, got {other:?}"),
20046        }
20047    }
20048
20049    #[test]
20050    fn subscription_advance_updates_last_pos_monotone() {
20051        let mut e = Engine::new();
20052        e.execute("CREATE SUBSCRIPTION s CONNECTION 'h=x' PUBLICATION p")
20053            .unwrap();
20054        assert!(e.subscription_advance("s", 100));
20055        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 100);
20056        assert!(e.subscription_advance("s", 50)); // stale → ignored
20057        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 100);
20058        assert!(e.subscription_advance("s", 200));
20059        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 200);
20060        assert!(!e.subscription_advance("missing", 1));
20061    }
20062
20063    #[test]
20064    fn show_subscriptions_returns_rows_ordered_by_name() {
20065        let mut e = Engine::new();
20066        e.execute("CREATE SUBSCRIPTION z_sub CONNECTION 'h=x' PUBLICATION p1, p2")
20067            .unwrap();
20068        e.execute("CREATE SUBSCRIPTION a_sub CONNECTION 'h=y' PUBLICATION p3")
20069            .unwrap();
20070        let r = e.execute_readonly("SHOW SUBSCRIPTIONS").unwrap();
20071        let QueryResult::Rows { rows, columns } = r else {
20072            panic!()
20073        };
20074        assert_eq!(rows.len(), 2);
20075        assert_eq!(columns.len(), 5);
20076        assert_eq!(columns[0].name, "name");
20077        assert_eq!(columns[4].name, "last_received_pos");
20078        // Alphabetical: a_sub, z_sub.
20079        let names: Vec<&str> = rows
20080            .iter()
20081            .map(|r| {
20082                if let Value::Text(s) = &r.values[0] {
20083                    s.as_str()
20084                } else {
20085                    panic!()
20086                }
20087            })
20088            .collect();
20089        assert_eq!(names, alloc::vec!["a_sub", "z_sub"]);
20090        // Row 0: a_sub
20091        assert_eq!(rows[0].values[1], Value::Text("h=y".to_string()));
20092        assert_eq!(rows[0].values[2], Value::Text("p3".to_string()));
20093        assert_eq!(rows[0].values[3], Value::Bool(true));
20094        assert_eq!(rows[0].values[4], Value::BigInt(0));
20095        // Row 1: z_sub — publications join with ", "
20096        assert_eq!(rows[1].values[2], Value::Text("p1, p2".to_string()));
20097    }
20098
20099    #[test]
20100    fn subscriptions_persist_across_snapshot_envelope_v4() {
20101        let mut e = Engine::new();
20102        e.execute("CREATE SUBSCRIPTION s1 CONNECTION 'h=A' PUBLICATION p1, p2")
20103            .unwrap();
20104        e.execute("CREATE SUBSCRIPTION s2 CONNECTION 'h=B' PUBLICATION p3")
20105            .unwrap();
20106        e.subscription_advance("s2", 42);
20107        let snap = e.snapshot();
20108        let e2 = Engine::restore_envelope(&snap).unwrap();
20109        assert_eq!(e2.subscriptions().len(), 2);
20110        let s1 = e2.subscriptions().get("s1").unwrap();
20111        assert_eq!(s1.conn_str, "h=A");
20112        assert_eq!(
20113            s1.publications,
20114            alloc::vec!["p1".to_string(), "p2".to_string()]
20115        );
20116        assert_eq!(s1.last_received_pos, 0);
20117        let s2 = e2.subscriptions().get("s2").unwrap();
20118        assert_eq!(s2.last_received_pos, 42);
20119    }
20120
20121    #[test]
20122    fn v3_envelope_loads_with_empty_subscriptions() {
20123        // v3 snapshot (publications-only). Forge it by hand so we
20124        // verify v6.1.4 readers don't panic — they must surface
20125        // empty subscriptions and a populated publication table.
20126        let mut e = Engine::new();
20127        e.execute("CREATE PUBLICATION pub_legacy").unwrap();
20128        let catalog = e.catalog.serialize();
20129        let users = crate::users::serialize_users(&e.users);
20130        let pubs = e.publications.serialize();
20131        let mut buf = Vec::new();
20132        buf.extend_from_slice(b"SPGENV01");
20133        buf.push(3u8); // v3
20134        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
20135        buf.extend_from_slice(&catalog);
20136        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
20137        buf.extend_from_slice(&users);
20138        buf.extend_from_slice(&u32::try_from(pubs.len()).unwrap().to_le_bytes());
20139        buf.extend_from_slice(&pubs);
20140        let crc = spg_crypto::crc32::crc32(&buf);
20141        buf.extend_from_slice(&crc.to_le_bytes());
20142
20143        let e2 = Engine::restore_envelope(&buf).expect("v3 envelope restores under v4 reader");
20144        assert!(e2.subscriptions().is_empty());
20145        assert!(e2.publications().contains("pub_legacy"));
20146    }
20147
20148    #[test]
20149    fn create_subscription_allowed_inside_transaction() {
20150        let mut e = Engine::new();
20151        e.execute("BEGIN").unwrap();
20152        e.execute("CREATE SUBSCRIPTION s CONNECTION 'h=x' PUBLICATION p")
20153            .unwrap();
20154        e.execute("COMMIT").unwrap();
20155        assert!(e.subscriptions().contains("s"));
20156    }
20157
20158    // ── v6.2.0: ANALYZE + spg_statistic + envelope v5 ──────────
20159    #[test]
20160    fn analyze_populates_histogram_bounds() {
20161        let mut e = Engine::new();
20162        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT)")
20163            .unwrap();
20164        for i in 0..50 {
20165            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, 'name{i}')"))
20166                .unwrap();
20167        }
20168        e.execute("ANALYZE t").unwrap();
20169        let stats = e.statistics();
20170        let id_stats = stats.get("t", "id").unwrap();
20171        assert!(id_stats.histogram_bounds.len() >= 2);
20172        assert_eq!(id_stats.histogram_bounds.first().unwrap(), "0");
20173        assert_eq!(id_stats.histogram_bounds.last().unwrap(), "49");
20174        assert!((id_stats.null_frac - 0.0).abs() < 1e-6);
20175        assert_eq!(id_stats.n_distinct, 50);
20176    }
20177
20178    #[test]
20179    fn reanalyze_overwrites_prior_stats() {
20180        let mut e = Engine::new();
20181        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
20182        for i in 0..10 {
20183            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
20184                .unwrap();
20185        }
20186        e.execute("ANALYZE t").unwrap();
20187        let n1 = e.statistics().get("t", "id").unwrap().n_distinct;
20188        assert_eq!(n1, 10);
20189        for i in 10..30 {
20190            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
20191                .unwrap();
20192        }
20193        e.execute("ANALYZE t").unwrap();
20194        let n2 = e.statistics().get("t", "id").unwrap().n_distinct;
20195        assert_eq!(n2, 30);
20196    }
20197
20198    #[test]
20199    fn analyze_unknown_table_errors() {
20200        let mut e = Engine::new();
20201        let err = e.execute("ANALYZE nonexistent").unwrap_err();
20202        assert!(matches!(
20203            err,
20204            EngineError::Storage(StorageError::TableNotFound { .. })
20205        ));
20206    }
20207
20208    #[test]
20209    fn bare_analyze_covers_all_user_tables() {
20210        let mut e = Engine::new();
20211        e.execute("CREATE TABLE t1 (id INT NOT NULL)").unwrap();
20212        e.execute("CREATE TABLE t2 (name TEXT NOT NULL)").unwrap();
20213        e.execute("INSERT INTO t1 VALUES (1)").unwrap();
20214        e.execute("INSERT INTO t2 VALUES ('alice')").unwrap();
20215        let r = e.execute("ANALYZE").unwrap();
20216        match r {
20217            QueryResult::CommandOk {
20218                affected,
20219                modified_catalog,
20220            } => {
20221                assert_eq!(affected, 2);
20222                assert!(modified_catalog);
20223            }
20224            other => panic!("expected CommandOk, got {other:?}"),
20225        }
20226        assert!(e.statistics().get("t1", "id").is_some());
20227        assert!(e.statistics().get("t2", "name").is_some());
20228    }
20229
20230    #[test]
20231    fn select_from_spg_statistic_returns_rows_per_column() {
20232        let mut e = Engine::new();
20233        e.execute("CREATE TABLE t (id INT NOT NULL, label TEXT)")
20234            .unwrap();
20235        e.execute("INSERT INTO t VALUES (1, 'a')").unwrap();
20236        e.execute("INSERT INTO t VALUES (2, 'b')").unwrap();
20237        e.execute("ANALYZE t").unwrap();
20238        let r = e.execute_readonly("SELECT * FROM spg_statistic").unwrap();
20239        let QueryResult::Rows { rows, columns } = r else {
20240            panic!()
20241        };
20242        // v6.7.0 — spg_statistic gained a `cold_row_count` column.
20243        assert_eq!(columns.len(), 6);
20244        assert_eq!(columns[0].name, "table_name");
20245        assert_eq!(columns[4].name, "histogram_bounds");
20246        assert_eq!(columns[5].name, "cold_row_count");
20247        assert_eq!(rows.len(), 2, "one row per column of t");
20248        // Sorted by (table_name, column_name).
20249        match (&rows[0].values[0], &rows[0].values[1]) {
20250            (Value::Text(t), Value::Text(c)) => {
20251                assert_eq!(t, "t");
20252                // BTreeMap orders (table, column); columns "id" < "label".
20253                assert_eq!(c, "id");
20254            }
20255            _ => panic!(),
20256        }
20257    }
20258
20259    #[test]
20260    fn analyze_skips_vector_columns() {
20261        // Vector columns have their own stats shape (HNSW graph);
20262        // ANALYZE leaves them out of spg_statistic.
20263        let mut e = Engine::new();
20264        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(3) NOT NULL)")
20265            .unwrap();
20266        e.execute("INSERT INTO t VALUES (1, [1, 2, 3])").unwrap();
20267        e.execute("ANALYZE t").unwrap();
20268        assert!(e.statistics().get("t", "id").is_some());
20269        assert!(e.statistics().get("t", "v").is_none());
20270    }
20271
20272    #[test]
20273    fn statistics_persist_across_envelope_v5_round_trip() {
20274        let mut e = Engine::new();
20275        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
20276        for i in 0..20 {
20277            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
20278                .unwrap();
20279        }
20280        e.execute("ANALYZE").unwrap();
20281        let snap = e.snapshot();
20282        let e2 = Engine::restore_envelope(&snap).unwrap();
20283        let s = e2.statistics().get("t", "id").unwrap();
20284        assert_eq!(s.n_distinct, 20);
20285    }
20286
20287    // ── v6.2.1 auto-analyze threshold ───────────────────────────
20288
20289    #[test]
20290    fn auto_analyze_threshold_fires_after_10pct_of_min_rows_on_small_table() {
20291        // For a table with 0 rows then 10 inserts → modified=10,
20292        // row_count=10. Threshold = 0.1 × max(10, 100) = 10. So
20293        // after the 10th INSERT the threshold is met.
20294        let mut e = Engine::new();
20295        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
20296        for i in 0..9 {
20297            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
20298                .unwrap();
20299        }
20300        assert!(e.tables_needing_analyze().is_empty(), "9 < threshold");
20301        e.execute("INSERT INTO t VALUES (9)").unwrap();
20302        let needs = e.tables_needing_analyze();
20303        assert_eq!(needs, alloc::vec!["t".to_string()]);
20304    }
20305
20306    #[test]
20307    fn auto_analyze_threshold_uses_10pct_of_row_count_for_large_tables() {
20308        // After ANALYZE on 1000 rows, threshold = 0.1 × row_count.
20309        // Each new INSERT bumps both modified and row_count, so to
20310        // trigger from N=1000 we need modifications ≥ 0.1 × (1000+M),
20311        // i.e. M ≥ 112. The test inserts 50 (no fire), then 150
20312        // more (200 total mods, row_count=1200, threshold=120 → fire).
20313        let mut e = Engine::new();
20314        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
20315        for i in 0..1000 {
20316            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
20317                .unwrap();
20318        }
20319        e.execute("ANALYZE t").unwrap();
20320        assert!(e.tables_needing_analyze().is_empty(), "fresh ANALYZE");
20321        for i in 1000..1050 {
20322            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
20323                .unwrap();
20324        }
20325        assert!(
20326            e.tables_needing_analyze().is_empty(),
20327            "50 inserts < threshold of ~105"
20328        );
20329        for i in 1050..1200 {
20330            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
20331                .unwrap();
20332        }
20333        assert_eq!(
20334            e.tables_needing_analyze(),
20335            alloc::vec!["t".to_string()],
20336            "200 inserts > 0.1 × 1200 threshold"
20337        );
20338    }
20339
20340    #[test]
20341    fn auto_analyze_threshold_resets_after_analyze() {
20342        let mut e = Engine::new();
20343        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
20344        for i in 0..200 {
20345            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
20346                .unwrap();
20347        }
20348        assert!(!e.tables_needing_analyze().is_empty());
20349        e.execute("ANALYZE").unwrap();
20350        assert!(
20351            e.tables_needing_analyze().is_empty(),
20352            "ANALYZE must reset the counter"
20353        );
20354    }
20355
20356    #[test]
20357    fn auto_analyze_threshold_tracks_updates_and_deletes() {
20358        let mut e = Engine::new();
20359        e.execute("CREATE TABLE t (id INT NOT NULL, label TEXT)")
20360            .unwrap();
20361        for i in 0..50 {
20362            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, 'x')"))
20363                .unwrap();
20364        }
20365        e.execute("ANALYZE t").unwrap();
20366        // UPDATE 20 rows + DELETE 5 → modified=25. Threshold = 0.1
20367        // × max(50, 100) = 10. So 25 >= 10 → trigger.
20368        e.execute("UPDATE t SET label = 'y' WHERE id < 20").unwrap();
20369        e.execute("DELETE FROM t WHERE id >= 45").unwrap();
20370        assert_eq!(e.tables_needing_analyze(), alloc::vec!["t".to_string()]);
20371    }
20372
20373    #[test]
20374    fn v4_envelope_loads_with_empty_statistics() {
20375        // Forge a v4 envelope by hand: catalog + users + pubs +
20376        // subs trailer, no statistics. A v6.2.0 reader must accept
20377        // it and surface an empty Statistics.
20378        let mut e = Engine::new();
20379        e.create_user("alice", "secret", crate::users::Role::ReadOnly, [0u8; 16])
20380            .unwrap();
20381        let catalog = e.catalog.serialize();
20382        let users = crate::users::serialize_users(&e.users);
20383        let pubs = e.publications.serialize();
20384        let subs = e.subscriptions.serialize();
20385        let mut buf = Vec::new();
20386        buf.extend_from_slice(b"SPGENV01");
20387        buf.push(4u8);
20388        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
20389        buf.extend_from_slice(&catalog);
20390        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
20391        buf.extend_from_slice(&users);
20392        buf.extend_from_slice(&u32::try_from(pubs.len()).unwrap().to_le_bytes());
20393        buf.extend_from_slice(&pubs);
20394        buf.extend_from_slice(&u32::try_from(subs.len()).unwrap().to_le_bytes());
20395        buf.extend_from_slice(&subs);
20396        let crc = spg_crypto::crc32::crc32(&buf);
20397        buf.extend_from_slice(&crc.to_le_bytes());
20398        let e2 = Engine::restore_envelope(&buf).expect("v4 envelope restores");
20399        assert!(e2.statistics().is_empty());
20400    }
20401
20402    #[test]
20403    fn v1_v2_envelope_loads_with_empty_publications() {
20404        // A snapshot taken before v6.1.2 (no publication trailer,
20405        // envelope v2) must still deserialise — and the resulting
20406        // engine must report zero publications. Use the engine's own
20407        // round-trip with no publications: that emits v3 but with an
20408        // empty pubs block. Then forge a v2 envelope by hand to lock
20409        // the back-compat path.
20410        let mut e = Engine::new();
20411        // Force users to be non-empty so the snapshot takes the
20412        // envelope path rather than the bare-catalog fallback.
20413        e.create_user("alice", "secret", crate::users::Role::ReadOnly, [0u8; 16])
20414            .unwrap();
20415
20416        // Forge an envelope v2: same shape as v3 but no pubs trailer.
20417        let catalog = e.catalog.serialize();
20418        let users = crate::users::serialize_users(&e.users);
20419        let mut buf = Vec::new();
20420        buf.extend_from_slice(b"SPGENV01");
20421        buf.push(2u8); // v2
20422        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
20423        buf.extend_from_slice(&catalog);
20424        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
20425        buf.extend_from_slice(&users);
20426        let crc = spg_crypto::crc32::crc32(&buf);
20427        buf.extend_from_slice(&crc.to_le_bytes());
20428
20429        let e2 = Engine::restore_envelope(&buf).expect("v2 envelope restores");
20430        assert!(e2.publications().is_empty());
20431    }
20432}